Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1362d71182 |
@@ -1,39 +0,0 @@
|
||||
# Files that have no business inside a user's install.
|
||||
#
|
||||
# `comfy node publish` zips the git-tracked tree minus everything matched here.
|
||||
# Syntax is gitignore's, via pathspec ("gitwildmatch"), and this file must stay
|
||||
# at the repo root: comfy-cli/comfy_cli/file_utils.py.
|
||||
#
|
||||
# Two reasons, in order:
|
||||
# 1. Somebody installing this pack should get the pack, not our dev tooling.
|
||||
# This drops 20 files and about 965 KB that no user ever runs.
|
||||
# 2. The Registry security scanner reads EVERY file in the archive, so dev-only
|
||||
# code costs real findings. `scripts/` alone contributed three: two process
|
||||
# launches and the package's only outbound urllib call, none of which ship
|
||||
# in anything a user executes. See .claude/patterns/registry-compliance.md
|
||||
# sections 4d and 4e.
|
||||
#
|
||||
# Every entry below was checked for runtime references before being added: all
|
||||
# mentions of scripts/ and docs/ in shipped code are comments and docstrings,
|
||||
# never an import or a file read. Re-check with:
|
||||
# grep -rn "scripts/\|docs/" --include=*.py --include=*.js --include=*.mjs .
|
||||
#
|
||||
# NEVER add here: js/, nodes/, locales/, __init__.py, server_routes.py,
|
||||
# pyproject.toml, LICENSE, README.md, assets/{fonts,icons,sounds,models,vendor}/,
|
||||
# or workflows/ (ComfyUI serves those as this pack's native node templates at
|
||||
# /api/workflow_templates/ComfyUI-Pixaroma/, so they are a user-facing feature).
|
||||
|
||||
# Dev tooling: parity checks, generators, the release preflight gate.
|
||||
scripts/
|
||||
|
||||
# Math specifications. Cited from code comments as the source of truth, but only
|
||||
# ever read by a developer working from the git repo, never at runtime.
|
||||
docs/
|
||||
|
||||
# Standalone browser harness for the AudioReact JS/Python parity check.
|
||||
assets/audio_studio_parity/
|
||||
|
||||
# Repo housekeeping, meaningless once installed.
|
||||
.clauderules
|
||||
.gitignore
|
||||
.gitlab-ci.yml
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Bug report
|
||||
description: Something in Pixaroma is genuinely broken (after ruling out the browser-cache fix)
|
||||
title: "[Bug] "
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Before filing:** if a node looks blank, broken, or is missing its controls right after an update, it is almost always a browser cache. Please try the 30-second fix first: https://github.com/pixaroma/ComfyUI-Pixaroma/issues/59
|
||||
- type: checkboxes
|
||||
id: cache-check
|
||||
attributes:
|
||||
label: Quick check
|
||||
options:
|
||||
- label: I hard-refreshed (Ctrl+Shift+R / Cmd+Shift+R) and the problem is still there
|
||||
required: false
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What is happening?
|
||||
description: Describe the problem. Screenshots help a lot.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: What did you do right before it happened?
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: versions
|
||||
attributes:
|
||||
label: Versions
|
||||
description: "Drop a Version Check Pixaroma node on the canvas, hit its Copy button, and paste here (ComfyUI version, frontend version, node interface, Pixaroma version)."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: console
|
||||
attributes:
|
||||
label: Console errors (if a node is broken or blank)
|
||||
description: Press F12, click Console, reload the page, then paste or screenshot any red error lines.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: "📌 Node looks blank or broken after updating? Read this first"
|
||||
url: https://github.com/pixaroma/ComfyUI-Pixaroma/issues/59
|
||||
about: "Almost always a browser cache. The fix takes 30 seconds, and most 'broken node' reports turn out to be this. Please check here before filing."
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Publish to Comfy registry
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v5
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ## Add your own personal access token to your Github Repository secrets and reference it here.
|
||||
@@ -1,7 +1,5 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
# model-fingerprint cache, only lands here if ComfyUI's user dir is unavailable
|
||||
pixaroma_model_hashes.json
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
.agent
|
||||
@@ -209,19 +207,7 @@ cython_debug/
|
||||
.cursorignore
|
||||
.cursorindexingignore
|
||||
|
||||
# Internal Claude Code development notes - local only, NOT published.
|
||||
# Kept on the dev machine + backed up to Dropbox; end users don't need it.
|
||||
CLAUDE.md
|
||||
|
||||
# Design source files - keep these local; the repo ships the exported assets
|
||||
# (e.g. .svg icons), not the editable Illustrator sources.
|
||||
*.ai
|
||||
|
||||
# Marimo
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# Local-only settings written at runtime (allowed folders, Civitai key fallback).
|
||||
# Never publish these - they contain the user's own paths and secrets.
|
||||
/user/
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,472 @@
|
||||
.clauderules
|
||||
.gitignore
|
||||
.gitlab-ci.yml
|
||||
.gitlab/issue_templates/Default.md
|
||||
CLAUDE.md
|
||||
LICENSE
|
||||
README.md
|
||||
__init__.py
|
||||
assets/audio_studio_parity/index.html
|
||||
assets/audio_studio_parity/test_image.png
|
||||
assets/fonts/Anton-Regular.ttf
|
||||
assets/fonts/BebasNeue-Regular.ttf
|
||||
assets/fonts/Caveat-Variable.ttf
|
||||
assets/fonts/Inter-Variable.ttf
|
||||
assets/fonts/JetBrainsMono-Variable.ttf
|
||||
assets/fonts/Lora-Variable.ttf
|
||||
assets/fonts/Montserrat-Variable.ttf
|
||||
assets/fonts/NOTICE.md
|
||||
assets/fonts/Oswald-Variable.ttf
|
||||
assets/fonts/PlayfairDisplay-Italic-Variable.ttf
|
||||
assets/fonts/PlayfairDisplay-Variable.ttf
|
||||
assets/fonts/Roboto-Variable.ttf
|
||||
assets/icons/3D/add-3d-object.svg
|
||||
assets/icons/3D/arch.svg
|
||||
assets/icons/3D/back.svg
|
||||
assets/icons/3D/bed.svg
|
||||
assets/icons/3D/blob.svg
|
||||
assets/icons/3D/bookshelf.svg
|
||||
assets/icons/3D/bottle.svg
|
||||
assets/icons/3D/bowl.svg
|
||||
assets/icons/3D/bunny.svg
|
||||
assets/icons/3D/cactus.svg
|
||||
assets/icons/3D/capsule.svg
|
||||
assets/icons/3D/chair.svg
|
||||
assets/icons/3D/cloud.svg
|
||||
assets/icons/3D/cone.svg
|
||||
assets/icons/3D/couch.svg
|
||||
assets/icons/3D/crystal.svg
|
||||
assets/icons/3D/cube.svg
|
||||
assets/icons/3D/cylinder.svg
|
||||
assets/icons/3D/dome.svg
|
||||
assets/icons/3D/drop-on-floor.svg
|
||||
assets/icons/3D/fence-post.svg
|
||||
assets/icons/3D/flower.svg
|
||||
assets/icons/3D/focus.svg
|
||||
assets/icons/3D/front.svg
|
||||
assets/icons/3D/gear.svg
|
||||
assets/icons/3D/goblet.svg
|
||||
assets/icons/3D/house.svg
|
||||
assets/icons/3D/lamp-post.svg
|
||||
assets/icons/3D/load-3d-model.svg
|
||||
assets/icons/3D/mushroom.svg
|
||||
assets/icons/3D/pine-tree.svg
|
||||
assets/icons/3D/plane.svg
|
||||
assets/icons/3D/plant-pot.svg
|
||||
assets/icons/3D/prism.svg
|
||||
assets/icons/3D/pyramid.svg
|
||||
assets/icons/3D/ring.svg
|
||||
assets/icons/3D/rock.svg
|
||||
assets/icons/3D/side.svg
|
||||
assets/icons/3D/signpost.svg
|
||||
assets/icons/3D/sphere.svg
|
||||
assets/icons/3D/table.svg
|
||||
assets/icons/3D/teapot.svg
|
||||
assets/icons/3D/terrain.svg
|
||||
assets/icons/3D/top.svg
|
||||
assets/icons/3D/torus.svg
|
||||
assets/icons/3D/tree.svg
|
||||
assets/icons/3D/tube.svg
|
||||
assets/icons/3D/vase.svg
|
||||
assets/icons/layers/add.svg
|
||||
assets/icons/layers/delete.svg
|
||||
assets/icons/layers/duplicate.svg
|
||||
assets/icons/layers/edit.svg
|
||||
assets/icons/layers/eye-hidden.svg
|
||||
assets/icons/layers/eye-visible.svg
|
||||
assets/icons/layers/flatten.svg
|
||||
assets/icons/layers/fx.svg
|
||||
assets/icons/layers/lock-locked.svg
|
||||
assets/icons/layers/lock-unlocked.svg
|
||||
assets/icons/layers/merge-down.svg
|
||||
assets/icons/layers/move-down.svg
|
||||
assets/icons/layers/move-up.svg
|
||||
assets/icons/layers/upload.svg
|
||||
assets/icons/note/CLIP.svg
|
||||
assets/icons/note/GGUF.svg
|
||||
assets/icons/note/LORA.svg
|
||||
assets/icons/note/VAE.svg
|
||||
assets/icons/note/ai-brain.svg
|
||||
assets/icons/note/arrow-down.svg
|
||||
assets/icons/note/arrow-left.svg
|
||||
assets/icons/note/arrow-right.svg
|
||||
assets/icons/note/arrow-up.svg
|
||||
assets/icons/note/box.svg
|
||||
assets/icons/note/checkmark.svg
|
||||
assets/icons/note/checkpoint-v1.svg
|
||||
assets/icons/note/checkpoint-v2.svg
|
||||
assets/icons/note/chip.svg
|
||||
assets/icons/note/data.svg
|
||||
assets/icons/note/file.svg
|
||||
assets/icons/note/folder-v1.svg
|
||||
assets/icons/note/folder-v2.svg
|
||||
assets/icons/note/gear.svg
|
||||
assets/icons/note/github.svg
|
||||
assets/icons/note/huggingface.svg
|
||||
assets/icons/note/model-v1.svg
|
||||
assets/icons/note/model-v2.svg
|
||||
assets/icons/note/model-v3.svg
|
||||
assets/icons/note/model-v4.svg
|
||||
assets/icons/note/model-v5.svg
|
||||
assets/icons/note/model-v6.svg
|
||||
assets/icons/note/model-v7.svg
|
||||
assets/icons/note/node-v1.svg
|
||||
assets/icons/note/node-v2.svg
|
||||
assets/icons/note/node-v3.svg
|
||||
assets/icons/note/node-v4.svg
|
||||
assets/icons/note/nodes-installed.svg
|
||||
assets/icons/note/pixaroma.svg
|
||||
assets/icons/note/plus.svg
|
||||
assets/icons/note/puzzle.svg
|
||||
assets/icons/note/question-mark.svg
|
||||
assets/icons/note/question.svg
|
||||
assets/icons/note/warning-v1.svg
|
||||
assets/icons/note/warning-v2.svg
|
||||
assets/icons/note/workflow-v1.svg
|
||||
assets/icons/note/workflow-v2.svg
|
||||
assets/icons/note/workflow-v3.svg
|
||||
assets/icons/ui/align-bottom.svg
|
||||
assets/icons/ui/align-center-h.svg
|
||||
assets/icons/ui/align-center-v.svg
|
||||
assets/icons/ui/align-left.svg
|
||||
assets/icons/ui/align-right.svg
|
||||
assets/icons/ui/align-top.svg
|
||||
assets/icons/ui/apply.svg
|
||||
assets/icons/ui/audio.svg
|
||||
assets/icons/ui/bg-color-drop.svg
|
||||
assets/icons/ui/bg-color-outline.svg
|
||||
assets/icons/ui/bg-color.svg
|
||||
assets/icons/ui/brush.svg
|
||||
assets/icons/ui/button-color-drop.svg
|
||||
assets/icons/ui/button-color-outline.svg
|
||||
assets/icons/ui/button-color.svg
|
||||
assets/icons/ui/button-design.svg
|
||||
assets/icons/ui/clear-format.svg
|
||||
assets/icons/ui/close.svg
|
||||
assets/icons/ui/code.svg
|
||||
assets/icons/ui/copy.svg
|
||||
assets/icons/ui/cursor.svg
|
||||
assets/icons/ui/delete.svg
|
||||
assets/icons/ui/diamond.svg
|
||||
assets/icons/ui/discord.svg
|
||||
assets/icons/ui/distribute-horizontal.svg
|
||||
assets/icons/ui/distribute-vertical.svg
|
||||
assets/icons/ui/download-model.svg
|
||||
assets/icons/ui/download.svg
|
||||
assets/icons/ui/eraser.svg
|
||||
assets/icons/ui/eyedropper.svg
|
||||
assets/icons/ui/fill.svg
|
||||
assets/icons/ui/fit-height.svg
|
||||
assets/icons/ui/fit-width.svg
|
||||
assets/icons/ui/fit.svg
|
||||
assets/icons/ui/flat.svg
|
||||
assets/icons/ui/flip-horizontal.svg
|
||||
assets/icons/ui/flip-vertical.svg
|
||||
assets/icons/ui/folder.svg
|
||||
assets/icons/ui/grid.svg
|
||||
assets/icons/ui/help.svg
|
||||
assets/icons/ui/highlight-color-drop.svg
|
||||
assets/icons/ui/highlight-color-outline.svg
|
||||
assets/icons/ui/icon-insert.svg
|
||||
assets/icons/ui/image.svg
|
||||
assets/icons/ui/line-color-drop.svg
|
||||
assets/icons/ui/line-color-outline.svg
|
||||
assets/icons/ui/line-color.svg
|
||||
assets/icons/ui/link.svg
|
||||
assets/icons/ui/list-dot.svg
|
||||
assets/icons/ui/list-number.svg
|
||||
assets/icons/ui/loop.svg
|
||||
assets/icons/ui/magnet.svg
|
||||
assets/icons/ui/minus.svg
|
||||
assets/icons/ui/move.svg
|
||||
assets/icons/ui/off.svg
|
||||
assets/icons/ui/on.svg
|
||||
assets/icons/ui/pause.svg
|
||||
assets/icons/ui/pen-opacity.svg
|
||||
assets/icons/ui/pen-size.svg
|
||||
assets/icons/ui/pencil.svg
|
||||
assets/icons/ui/play.svg
|
||||
assets/icons/ui/plus.svg
|
||||
assets/icons/ui/read-more.svg
|
||||
assets/icons/ui/redo.svg
|
||||
assets/icons/ui/reset.svg
|
||||
assets/icons/ui/rotate-ccw.svg
|
||||
assets/icons/ui/rotate-cw.svg
|
||||
assets/icons/ui/round.svg
|
||||
assets/icons/ui/save.svg
|
||||
assets/icons/ui/separator.svg
|
||||
assets/icons/ui/shape.svg
|
||||
assets/icons/ui/smudge.svg
|
||||
assets/icons/ui/square.svg
|
||||
assets/icons/ui/star.svg
|
||||
assets/icons/ui/stop.svg
|
||||
assets/icons/ui/swap.svg
|
||||
assets/icons/ui/text-color-drop.svg
|
||||
assets/icons/ui/text-color-outline.svg
|
||||
assets/icons/ui/triangle.svg
|
||||
assets/icons/ui/undo.svg
|
||||
assets/icons/ui/upload.svg
|
||||
assets/icons/ui/view-model-page.svg
|
||||
assets/icons/ui/youtube.svg
|
||||
assets/models/bunny.glb
|
||||
assets/note-pixaroma-llm-prompt.txt
|
||||
assets/pixaroma_logo.png
|
||||
assets/pixaroma_logo.svg
|
||||
assets/sounds/Checkpoint-Reached.mp3
|
||||
assets/sounds/Complete-Pixa.mp3
|
||||
assets/sounds/Dang.mp3
|
||||
assets/sounds/Echo.mp3
|
||||
assets/sounds/Ready-PixaGirl.mp3
|
||||
assets/sounds/Sector-Clear.mp3
|
||||
assets/sounds/Stage-Clear.mp3
|
||||
assets/sounds/Tada.mp3
|
||||
assets/sounds/Tidi.mp3
|
||||
assets/sounds/Vista.mp3
|
||||
assets/vendor/three/examples/jsm/controls/OrbitControls.mjs
|
||||
assets/vendor/three/examples/jsm/controls/TransformControls.mjs
|
||||
assets/vendor/three/examples/jsm/geometries/TeapotGeometry.mjs
|
||||
assets/vendor/three/examples/jsm/loaders/GLTFLoader.mjs
|
||||
assets/vendor/three/examples/jsm/loaders/MTLLoader.mjs
|
||||
assets/vendor/three/examples/jsm/loaders/OBJLoader.mjs
|
||||
assets/vendor/three/examples/jsm/postprocessing/EffectComposer.mjs
|
||||
assets/vendor/three/examples/jsm/postprocessing/OutlinePass.mjs
|
||||
assets/vendor/three/examples/jsm/postprocessing/OutputPass.mjs
|
||||
assets/vendor/three/examples/jsm/postprocessing/RenderPass.mjs
|
||||
assets/vendor/three/examples/jsm/utils/BufferGeometryUtils.mjs
|
||||
assets/vendor/three/three.mjs
|
||||
docs/audio-react-math.md
|
||||
docs/composer-fx-math.md
|
||||
docs/text-overlay-render.md
|
||||
js/3d/api.mjs
|
||||
js/3d/composites.mjs
|
||||
js/3d/core.mjs
|
||||
js/3d/engine.mjs
|
||||
js/3d/importer.mjs
|
||||
js/3d/index.js
|
||||
js/3d/interaction.mjs
|
||||
js/3d/objects.mjs
|
||||
js/3d/persistence.mjs
|
||||
js/3d/picker.mjs
|
||||
js/3d/shape_params.mjs
|
||||
js/3d/shapes.mjs
|
||||
js/align/index.js
|
||||
js/audio_studio/api.mjs
|
||||
js/audio_studio/audio_analysis.mjs
|
||||
js/audio_studio/core.mjs
|
||||
js/audio_studio/index.js
|
||||
js/audio_studio/render.mjs
|
||||
js/audio_studio/shaders.mjs
|
||||
js/audio_studio/transport.mjs
|
||||
js/audio_studio/ui.mjs
|
||||
js/brand/index.js
|
||||
js/compare/index.js
|
||||
js/composer/api.mjs
|
||||
js/composer/core.mjs
|
||||
js/composer/crop.mjs
|
||||
js/composer/eraser.mjs
|
||||
js/composer/fx_engine.mjs
|
||||
js/composer/index.js
|
||||
js/composer/interaction.mjs
|
||||
js/composer/layers.mjs
|
||||
js/composer/placeholder.mjs
|
||||
js/composer/render.mjs
|
||||
js/composer/ui.mjs
|
||||
js/connection_fx/index.js
|
||||
js/crop/alignments.mjs
|
||||
js/crop/core.mjs
|
||||
js/crop/index.js
|
||||
js/crop/interaction.mjs
|
||||
js/crop/panel.mjs
|
||||
js/crop/render.mjs
|
||||
js/find_replace/core.mjs
|
||||
js/find_replace/index.js
|
||||
js/find_replace/interaction.mjs
|
||||
js/find_replace/render.mjs
|
||||
js/framework/bg_removal_dropdown.mjs
|
||||
js/framework/canvas.mjs
|
||||
js/framework/components.mjs
|
||||
js/framework/fonts.mjs
|
||||
js/framework/index.mjs
|
||||
js/framework/layers.mjs
|
||||
js/framework/layout.mjs
|
||||
js/framework/text_editor.mjs
|
||||
js/framework/text_render.mjs
|
||||
js/framework/theme.mjs
|
||||
js/help_toolbar/help_defs.mjs
|
||||
js/help_toolbar/index.js
|
||||
js/image_resize/index.js
|
||||
js/image_resize/ui.mjs
|
||||
js/label/core.mjs
|
||||
js/label/index.js
|
||||
js/label/render.mjs
|
||||
js/load_image/api.mjs
|
||||
js/load_image/index.js
|
||||
js/load_image/panel_polish.mjs
|
||||
js/load_image/resize_modes.mjs
|
||||
js/load_image/ui.mjs
|
||||
js/mute_switch/core.mjs
|
||||
js/mute_switch/editor.mjs
|
||||
js/mute_switch/index.js
|
||||
js/mute_switch/render.mjs
|
||||
js/mute_switch/upstream.mjs
|
||||
js/mute_switch/vue_list.mjs
|
||||
js/node_colors/index.js
|
||||
js/note/blocks.mjs
|
||||
js/note/codeview.mjs
|
||||
js/note/core.mjs
|
||||
js/note/css.mjs
|
||||
js/note/icons.mjs
|
||||
js/note/index.js
|
||||
js/note/render.mjs
|
||||
js/note/sanitize.mjs
|
||||
js/note/toolbar.mjs
|
||||
js/notify/index.js
|
||||
js/number/index.js
|
||||
js/paint/api.mjs
|
||||
js/paint/canvas.mjs
|
||||
js/paint/core.mjs
|
||||
js/paint/engine.mjs
|
||||
js/paint/events.mjs
|
||||
js/paint/history.mjs
|
||||
js/paint/index.js
|
||||
js/paint/render.mjs
|
||||
js/paint/tools.mjs
|
||||
js/paint/transform.mjs
|
||||
js/paint/ui.mjs
|
||||
js/pause_image/index.js
|
||||
js/pause_image/state.mjs
|
||||
js/pause_image/ui.mjs
|
||||
js/preview/index.js
|
||||
js/prompt_multi/core.mjs
|
||||
js/prompt_multi/index.js
|
||||
js/prompt_multi/interaction.mjs
|
||||
js/prompt_multi/render.mjs
|
||||
js/prompt_pack/core.mjs
|
||||
js/prompt_pack/index.js
|
||||
js/prompt_pack/interaction.mjs
|
||||
js/prompt_pack/render.mjs
|
||||
js/prompt_reader/index.js
|
||||
js/prompt_stack/core.mjs
|
||||
js/prompt_stack/index.js
|
||||
js/prompt_stack/interaction.mjs
|
||||
js/prompt_stack/render.mjs
|
||||
js/reference/index.js
|
||||
js/resolution/index.js
|
||||
js/run_button_fx/index.js
|
||||
js/save_mp4/index.js
|
||||
js/shared/color_picker.mjs
|
||||
js/shared/graph_loading.mjs
|
||||
js/shared/graph_undo_guard.mjs
|
||||
js/shared/help.mjs
|
||||
js/shared/index.mjs
|
||||
js/shared/label_css.mjs
|
||||
js/shared/nodes2.mjs
|
||||
js/shared/preview.mjs
|
||||
js/shared/queue_drivers.mjs
|
||||
js/shared/resize_floor.mjs
|
||||
js/shared/resize_panel.mjs
|
||||
js/shared/utils.mjs
|
||||
js/showtext/index.js
|
||||
js/switch/core.mjs
|
||||
js/switch/editor.mjs
|
||||
js/switch/index.js
|
||||
js/switch/render.mjs
|
||||
js/switch/vue_list.mjs
|
||||
js/switch_source/core.mjs
|
||||
js/switch_source/editor.mjs
|
||||
js/switch_source/index.js
|
||||
js/switch_wh/index.js
|
||||
js/text/dynamic_prompts.mjs
|
||||
js/text/index.js
|
||||
js/text_overlay/core.mjs
|
||||
js/text_overlay/defaults.mjs
|
||||
js/text_overlay/index.js
|
||||
js/text_overlay/interaction.mjs
|
||||
js/text_watermark/defaults.mjs
|
||||
js/text_watermark/index.js
|
||||
js/version_check/index.js
|
||||
js/wh/index.js
|
||||
js/xy_plot/core.mjs
|
||||
js/xy_plot/grid.mjs
|
||||
js/xy_plot/index.js
|
||||
js/xy_plot/ui.mjs
|
||||
locales/en/commands.json
|
||||
nodes/_audio_react_engine.py
|
||||
nodes/_bg_removal_helpers.py
|
||||
nodes/_font_catalog.py
|
||||
nodes/_fx_adjust_engine.py
|
||||
nodes/_prompt_reader_helpers.py
|
||||
nodes/_resize_helpers.py
|
||||
nodes/_save_helpers.py
|
||||
nodes/_text_render_helpers.py
|
||||
nodes/_type_helpers.py
|
||||
nodes/node_3d.py
|
||||
nodes/node_audio_studio.py
|
||||
nodes/node_compare.py
|
||||
nodes/node_composition.py
|
||||
nodes/node_crop.py
|
||||
nodes/node_find_replace.py
|
||||
nodes/node_image_resize.py
|
||||
nodes/node_label.py
|
||||
nodes/node_load_image.py
|
||||
nodes/node_mute_switch.py
|
||||
nodes/node_note.py
|
||||
nodes/node_notify.py
|
||||
nodes/node_number.py
|
||||
nodes/node_paint.py
|
||||
nodes/node_pause_image.py
|
||||
nodes/node_preview.py
|
||||
nodes/node_prompt_from_list.py
|
||||
nodes/node_prompt_multi.py
|
||||
nodes/node_prompt_pack.py
|
||||
nodes/node_prompt_reader.py
|
||||
nodes/node_prompt_stack.py
|
||||
nodes/node_ref.py
|
||||
nodes/node_remove_background.py
|
||||
nodes/node_resolution.py
|
||||
nodes/node_save_mp4.py
|
||||
nodes/node_show_text.py
|
||||
nodes/node_switch.py
|
||||
nodes/node_switch_source.py
|
||||
nodes/node_switch_wh.py
|
||||
nodes/node_text.py
|
||||
nodes/node_text_overlay.py
|
||||
nodes/node_text_watermark.py
|
||||
nodes/node_version_check.py
|
||||
nodes/node_wh.py
|
||||
nodes/node_xy_plot.py
|
||||
pyproject.toml
|
||||
scripts/audio_parity_check.py
|
||||
scripts/build_mute_switch_workflows.py
|
||||
scripts/download_fonts.py
|
||||
scripts/font_catalog_check.py
|
||||
scripts/fx_js_runner.mjs
|
||||
scripts/fx_parity_check.py
|
||||
scripts/mute_switch_test.mjs
|
||||
scripts/save_prefix_check.py
|
||||
scripts/text_overlay_parity_check.py
|
||||
server_routes.py
|
||||
workflows/3D Builder Pixaroma Workflow v2.jpg
|
||||
workflows/3D Builder Pixaroma Workflow.jpg
|
||||
workflows/3D Builder Pixaroma Workflow.json
|
||||
workflows/AudioReact Workflow v2.jpg
|
||||
workflows/AudioReact Workflow.jpg
|
||||
workflows/AudioReact Workflow.json
|
||||
workflows/Crop Pixaroma Workflow v2.jpg
|
||||
workflows/Crop Pixaroma Workflow.jpg
|
||||
workflows/Crop Pixaroma Workflow.json
|
||||
workflows/Image Compare Pixaroma Workflow v2.jpg
|
||||
workflows/Image Compare Pixaroma Workflow.jpg
|
||||
workflows/Image Compare Pixaroma Workflow.json
|
||||
workflows/Image Composer Pixaroma Workflow v2.jpg
|
||||
workflows/Image Composer Pixaroma Workflow.jpg
|
||||
workflows/Image Composer Pixaroma Workflow.json
|
||||
workflows/Labels Pixaroma Workflow v2.jpg
|
||||
workflows/Labels Pixaroma Workflow.jpg
|
||||
workflows/Labels Pixaroma Workflow.json
|
||||
workflows/Note Pixaroma Workflow v2.jpg
|
||||
workflows/Note Pixaroma Workflow.jpg
|
||||
workflows/Note Pixaroma Workflow.json
|
||||
workflows/Paint Pixaroma Workflow v2.jpg
|
||||
workflows/Paint Pixaroma Workflow.jpg
|
||||
workflows/Paint Pixaroma Workflow.json
|
||||
@@ -1,475 +1,234 @@
|
||||
import os
|
||||
from . import server_routes # side-effect import for route registration
|
||||
from .nodes.node_composition import NODE_CLASS_MAPPINGS as _MAPS_COMPOSITION
|
||||
from .nodes.node_composition import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_COMPOSITION
|
||||
from .nodes.node_3d import NODE_CLASS_MAPPINGS as _MAPS_3D
|
||||
from .nodes.node_3d import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_3D
|
||||
from .nodes.node_audio_studio import NODE_CLASS_MAPPINGS as _MAPS_AUDIO_STUDIO
|
||||
from .nodes.node_audio_studio import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_AUDIO_STUDIO
|
||||
from .nodes.node_save_mp4 import NODE_CLASS_MAPPINGS as _MAPS_SAVE_MP4
|
||||
from .nodes.node_save_mp4 import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SAVE_MP4
|
||||
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
|
||||
from .nodes.node_load_image import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_LOAD_IMAGE
|
||||
from .nodes.node_load_image_mini import NODE_CLASS_MAPPINGS as _MAPS_LOAD_IMAGE_MINI
|
||||
from .nodes.node_load_image_mini import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_LOAD_IMAGE_MINI
|
||||
from .nodes.node_image_info import NODE_CLASS_MAPPINGS as _MAPS_IMAGE_INFO
|
||||
from .nodes.node_image_info import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_IMAGE_INFO
|
||||
from .nodes.node_paint import NODE_CLASS_MAPPINGS as _MAPS_PAINT
|
||||
from .nodes.node_paint import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PAINT
|
||||
from .nodes.node_preview import NODE_CLASS_MAPPINGS as _MAPS_PREVIEW
|
||||
from .nodes.node_preview import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PREVIEW
|
||||
from .nodes.node_prompt_reader import NODE_CLASS_MAPPINGS as _MAPS_PROMPT_READER
|
||||
from .nodes.node_prompt_reader import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PROMPT_READER
|
||||
from .nodes.node_resolution import NODE_CLASS_MAPPINGS as _MAPS_RESOLUTION
|
||||
from .nodes.node_resolution import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_RESOLUTION
|
||||
from .nodes.node_show_text import NODE_CLASS_MAPPINGS as _MAPS_SHOW_TEXT
|
||||
from .nodes.node_show_text import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SHOW_TEXT
|
||||
from .nodes.node_note import NODE_CLASS_MAPPINGS as _MAPS_NOTE
|
||||
from .nodes.node_note import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_NOTE
|
||||
from .nodes.node_info import NODE_CLASS_MAPPINGS as _MAPS_INFO
|
||||
from .nodes.node_info import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_INFO
|
||||
from .nodes.node_notify import NODE_CLASS_MAPPINGS as _MAPS_NOTIFY
|
||||
from .nodes.node_notify import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_NOTIFY
|
||||
from .nodes.node_switch import NODE_CLASS_MAPPINGS as _MAPS_SWITCH
|
||||
from .nodes.node_switch import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SWITCH
|
||||
from .nodes.node_switch_wh import NODE_CLASS_MAPPINGS as _MAPS_SWITCH_WH
|
||||
from .nodes.node_switch_wh import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SWITCH_WH
|
||||
from .nodes.node_switch_source import NODE_CLASS_MAPPINGS as _MAPS_SWITCH_SOURCE
|
||||
from .nodes.node_switch_source import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SWITCH_SOURCE
|
||||
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_number_pick import NODE_CLASS_MAPPINGS as _MAPS_NUMBER_PICK
|
||||
from .nodes.node_number_pick import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_NUMBER_PICK
|
||||
from .nodes.node_sliders import NODE_CLASS_MAPPINGS as _MAPS_SLIDERS
|
||||
from .nodes.node_sliders import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SLIDERS
|
||||
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 import NODE_CLASS_MAPPINGS as _MAPS_PROMPT
|
||||
from .nodes.node_prompt import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PROMPT
|
||||
from .nodes.node_prompt_stack import NODE_CLASS_MAPPINGS as _MAPS_PROMPT_STACK
|
||||
from .nodes.node_prompt_stack import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PROMPT_STACK
|
||||
from .nodes.node_prompt_multi import NODE_CLASS_MAPPINGS as _MAPS_PROMPT_MULTI
|
||||
from .nodes.node_prompt_multi import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PROMPT_MULTI
|
||||
from .nodes.node_prompt_from_list import NODE_CLASS_MAPPINGS as _MAPS_PROMPT_FROM_LIST
|
||||
from .nodes.node_prompt_from_list import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PROMPT_FROM_LIST
|
||||
from .nodes.node_prompt_pack import NODE_CLASS_MAPPINGS as _MAPS_PROMPT_PACK
|
||||
from .nodes.node_prompt_pack import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PROMPT_PACK
|
||||
from .nodes.node_prompt_each import NODE_CLASS_MAPPINGS as _MAPS_PROMPT_EACH
|
||||
from .nodes.node_prompt_each import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PROMPT_EACH
|
||||
from .nodes.node_remove_background import NODE_CLASS_MAPPINGS as _MAPS_REMOVE_BG
|
||||
from .nodes.node_remove_background import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_REMOVE_BG
|
||||
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_longest_side import NODE_CLASS_MAPPINGS as _MAPS_LONGEST_SIDE
|
||||
from .nodes.node_longest_side import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_LONGEST_SIDE
|
||||
from .nodes.node_outpaint import NODE_CLASS_MAPPINGS as _MAPS_OUTPAINT
|
||||
from .nodes.node_outpaint import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_OUTPAINT
|
||||
from .nodes.node_outpaint_stitch import NODE_CLASS_MAPPINGS as _MAPS_OUTPAINT_STITCH
|
||||
from .nodes.node_outpaint_stitch import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_OUTPAINT_STITCH
|
||||
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
|
||||
from .nodes.node_version_check import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_VERSION_CHECK
|
||||
from .nodes.node_pause_image import NODE_CLASS_MAPPINGS as _MAPS_PAUSE_IMAGE
|
||||
from .nodes.node_pause_image import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PAUSE_IMAGE
|
||||
from .nodes.node_pause_text import NODE_CLASS_MAPPINGS as _MAPS_PAUSE_TEXT
|
||||
from .nodes.node_pause_text import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PAUSE_TEXT
|
||||
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
|
||||
from .nodes.node_load_video import NODE_CLASS_MAPPINGS as _MAPS_LOAD_VIDEO
|
||||
from .nodes.node_load_video import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_LOAD_VIDEO
|
||||
from .nodes.node_load_video_frame import NODE_CLASS_MAPPINGS as _MAPS_LOAD_VIDEO_FRAME
|
||||
from .nodes.node_load_video_frame import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_LOAD_VIDEO_FRAME
|
||||
from .nodes.node_first_last_frame import NODE_CLASS_MAPPINGS as _MAPS_FIRST_LAST_FRAME
|
||||
from .nodes.node_first_last_frame import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_FIRST_LAST_FRAME
|
||||
from .nodes.node_loop import NODE_CLASS_MAPPINGS as _MAPS_LOOP
|
||||
from .nodes.node_loop import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_LOOP
|
||||
from .nodes.node_combine import NODE_CLASS_MAPPINGS as _MAPS_COMBINE
|
||||
from .nodes.node_combine import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_COMBINE
|
||||
from .nodes.node_group_switch import NODE_CLASS_MAPPINGS as _MAPS_GROUP_SWITCH
|
||||
from .nodes.node_group_switch import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_GROUP_SWITCH
|
||||
from .nodes.node_run_timer import NODE_CLASS_MAPPINGS as _MAPS_RUN_TIMER
|
||||
from .nodes.node_run_timer import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_RUN_TIMER
|
||||
from .nodes.node_run_log import NODE_CLASS_MAPPINGS as _MAPS_RUN_LOG
|
||||
from .nodes.node_run_log import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_RUN_LOG
|
||||
from .nodes.node_monitor import NODE_CLASS_MAPPINGS as _MAPS_MONITOR
|
||||
from .nodes.node_monitor import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_MONITOR
|
||||
from .nodes.node_free_vram import NODE_CLASS_MAPPINGS as _MAPS_FREE_VRAM
|
||||
from .nodes.node_free_vram import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_FREE_VRAM
|
||||
from .nodes.node_sketch import NODE_CLASS_MAPPINGS as _MAPS_SKETCH
|
||||
from .nodes.node_sketch import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SKETCH
|
||||
from .nodes.node_layout_boxes import NODE_CLASS_MAPPINGS as _MAPS_LAYOUT_BOXES
|
||||
from .nodes.node_layout_boxes import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_LAYOUT_BOXES
|
||||
from .nodes.node_ref_images import NODE_CLASS_MAPPINGS as _MAPS_REF_IMAGES
|
||||
from .nodes.node_ref_images import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_REF_IMAGES
|
||||
from .nodes.node_video_tools import NODE_CLASS_MAPPINGS as _MAPS_VIDEO_TOOLS
|
||||
from .nodes.node_video_tools import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_VIDEO_TOOLS
|
||||
from .nodes.node_save_image import NODE_CLASS_MAPPINGS as _MAPS_SAVE_IMAGE
|
||||
from .nodes.node_save_image import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SAVE_IMAGE
|
||||
from .nodes.node_save_video import NODE_CLASS_MAPPINGS as _MAPS_SAVE_VIDEO
|
||||
from .nodes.node_save_video import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SAVE_VIDEO
|
||||
from .nodes.node_save_text import NODE_CLASS_MAPPINGS as _MAPS_SAVE_TEXT
|
||||
from .nodes.node_save_text import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SAVE_TEXT
|
||||
from .nodes.node_krea_lora_convert import NODE_CLASS_MAPPINGS as _MAPS_KREA_LORA_CONVERT
|
||||
from .nodes.node_krea_lora_convert import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_KREA_LORA_CONVERT
|
||||
from .nodes.node_krea2_enhancer import NODE_CLASS_MAPPINGS as _MAPS_KREA2_ENHANCER
|
||||
from .nodes.node_krea2_enhancer import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_KREA2_ENHANCER
|
||||
from .nodes.node_lora_loader import NODE_CLASS_MAPPINGS as _MAPS_LORA_LOADER
|
||||
from .nodes.node_lora_loader import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_LORA_LOADER
|
||||
from .nodes.node_sizes import NODE_CLASS_MAPPINGS as _MAPS_SIZES
|
||||
from .nodes.node_sizes import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SIZES
|
||||
from .nodes.node_text_join import NODE_CLASS_MAPPINGS as _MAPS_TEXT_JOIN
|
||||
from .nodes.node_text_join import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_TEXT_JOIN
|
||||
from .nodes.node_dropdown import NODE_CLASS_MAPPINGS as _MAPS_DROPDOWN
|
||||
from .nodes.node_dropdown import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_DROPDOWN
|
||||
from .nodes.node_duration import NODE_CLASS_MAPPINGS as _MAPS_DURATION
|
||||
from .nodes.node_duration import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_DURATION
|
||||
from .nodes.node_load_audio import NODE_CLASS_MAPPINGS as _MAPS_LOAD_AUDIO
|
||||
from .nodes.node_load_audio import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_LOAD_AUDIO
|
||||
from .nodes.node_h3_audio_sync import NODE_CLASS_MAPPINGS as _MAPS_H3_SYNC
|
||||
from .nodes.node_h3_audio_sync import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_H3_SYNC
|
||||
from .nodes.node_h3_long_video import NODE_CLASS_MAPPINGS as _MAPS_H3_LONG_VIDEO
|
||||
from .nodes.node_h3_long_video import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_H3_LONG_VIDEO
|
||||
from .nodes.node_h3_latent_upscale import NODE_CLASS_MAPPINGS as _MAPS_H3_LATENT_UPSCALE
|
||||
from .nodes.node_h3_latent_upscale import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_H3_LATENT_UPSCALE
|
||||
from .nodes.node_h3_timeline import NODE_CLASS_MAPPINGS as _MAPS_H3_TIMELINE
|
||||
from .nodes.node_h3_timeline import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_H3_TIMELINE
|
||||
from .nodes.node_video_prompt import NODE_CLASS_MAPPINGS as _MAPS_VIDEO_PROMPT
|
||||
from .nodes.node_video_prompt import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_VIDEO_PROMPT
|
||||
from .nodes.node_ai_prompt import NODE_CLASS_MAPPINGS as _MAPS_AI_PROMPT
|
||||
from .nodes.node_ai_prompt import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_AI_PROMPT
|
||||
from .nodes.node_music_prompt import NODE_CLASS_MAPPINGS as _MAPS_MUSIC_PROMPT
|
||||
from .nodes.node_music_prompt import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_MUSIC_PROMPT
|
||||
from .nodes.node_load_3d import NODE_CLASS_MAPPINGS as _MAPS_LOAD_3D
|
||||
from .nodes.node_load_3d import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_LOAD_3D
|
||||
from .nodes.node_save_3d import NODE_CLASS_MAPPINGS as _MAPS_SAVE_3D
|
||||
from .nodes.node_save_3d import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SAVE_3D
|
||||
from .nodes.node_text_match import NODE_CLASS_MAPPINGS as _MAPS_TEXT_MATCH
|
||||
from .nodes.node_text_match import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_TEXT_MATCH
|
||||
from .nodes.node_image_grid import NODE_CLASS_MAPPINGS as _MAPS_IMAGE_GRID
|
||||
from .nodes.node_image_grid import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_IMAGE_GRID
|
||||
from .nodes.node_load_texts_folder import NODE_CLASS_MAPPINGS as _MAPS_LOAD_TEXTS_FOLDER
|
||||
from .nodes.node_load_texts_folder import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_LOAD_TEXTS_FOLDER
|
||||
from .nodes.node_caption_review import NODE_CLASS_MAPPINGS as _MAPS_CAPTION_REVIEW
|
||||
from .nodes.node_caption_review import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_CAPTION_REVIEW
|
||||
|
||||
# development mode for loading additional reference nodes
|
||||
dev_mode = False
|
||||
if dev_mode:
|
||||
from .nodes.node_ref import NODE_CLASS_MAPPINGS as _MAPS_UTILS
|
||||
from .nodes.node_ref import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_UTILS
|
||||
else:
|
||||
_MAPS_UTILS = {}
|
||||
_NAMES_UTILS = {}
|
||||
|
||||
# combine all node mappings
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
**_MAPS_3D,
|
||||
**_MAPS_AUDIO_STUDIO,
|
||||
**_MAPS_COMPOSITION,
|
||||
**_MAPS_PAINT,
|
||||
**_MAPS_PREVIEW,
|
||||
**_MAPS_PROMPT_READER,
|
||||
**_MAPS_RESOLUTION,
|
||||
**_MAPS_COMPARE,
|
||||
**_MAPS_CROP,
|
||||
**_MAPS_UNCROP,
|
||||
**_MAPS_INPAINT_CROP,
|
||||
**_MAPS_INPAINT_STITCH,
|
||||
**_MAPS_LABEL,
|
||||
**_MAPS_LOAD_IMAGE,
|
||||
**_MAPS_LOAD_IMAGE_MINI,
|
||||
**_MAPS_IMAGE_INFO,
|
||||
**_MAPS_NOTE,
|
||||
**_MAPS_INFO,
|
||||
**_MAPS_NOTIFY,
|
||||
**_MAPS_SAVE_MP4,
|
||||
**_MAPS_SWITCH,
|
||||
**_MAPS_SWITCH_WH,
|
||||
**_MAPS_SWITCH_SOURCE,
|
||||
**_MAPS_MUTE_SWITCH,
|
||||
**_MAPS_WH,
|
||||
**_MAPS_PORTRAIT_LANDSCAPE,
|
||||
**_MAPS_NUMBER,
|
||||
**_MAPS_NUMBER_PICK,
|
||||
**_MAPS_SLIDERS,
|
||||
**_MAPS_SEED,
|
||||
**_MAPS_TEXT,
|
||||
**_MAPS_PROMPT,
|
||||
**_MAPS_PROMPT_STACK,
|
||||
**_MAPS_PROMPT_MULTI,
|
||||
**_MAPS_PROMPT_FROM_LIST,
|
||||
**_MAPS_PROMPT_PACK,
|
||||
**_MAPS_PROMPT_EACH,
|
||||
**_MAPS_UTILS,
|
||||
**_MAPS_SHOW_TEXT,
|
||||
**_MAPS_REMOVE_BG,
|
||||
**_MAPS_TEXT_OVERLAY,
|
||||
**_MAPS_IMAGE_RESIZE,
|
||||
**_MAPS_RESIZE_CROP,
|
||||
**_MAPS_LONGEST_SIDE,
|
||||
**_MAPS_OUTPAINT,
|
||||
**_MAPS_OUTPAINT_STITCH,
|
||||
**_MAPS_TEXT_WATERMARK,
|
||||
**_MAPS_VERSION_CHECK,
|
||||
**_MAPS_PAUSE_IMAGE,
|
||||
**_MAPS_PAUSE_TEXT,
|
||||
**_MAPS_XY_PLOT,
|
||||
**_MAPS_FIND_REPLACE,
|
||||
**_MAPS_LIF,
|
||||
**_MAPS_SET_GET,
|
||||
**_MAPS_LOAD_VIDEO,
|
||||
**_MAPS_LOAD_VIDEO_FRAME,
|
||||
**_MAPS_FIRST_LAST_FRAME,
|
||||
**_MAPS_LOOP,
|
||||
**_MAPS_COMBINE,
|
||||
**_MAPS_GROUP_SWITCH,
|
||||
**_MAPS_RUN_TIMER,
|
||||
**_MAPS_RUN_LOG,
|
||||
**_MAPS_MONITOR,
|
||||
**_MAPS_FREE_VRAM,
|
||||
**_MAPS_SKETCH,
|
||||
**_MAPS_LAYOUT_BOXES,
|
||||
**_MAPS_REF_IMAGES,
|
||||
**_MAPS_VIDEO_TOOLS,
|
||||
**_MAPS_SAVE_IMAGE,
|
||||
**_MAPS_SAVE_VIDEO,
|
||||
**_MAPS_SAVE_TEXT,
|
||||
**_MAPS_KREA_LORA_CONVERT,
|
||||
**_MAPS_KREA2_ENHANCER,
|
||||
**_MAPS_LORA_LOADER,
|
||||
**_MAPS_SIZES,
|
||||
**_MAPS_TEXT_JOIN,
|
||||
**_MAPS_DROPDOWN,
|
||||
**_MAPS_DURATION,
|
||||
**_MAPS_LOAD_AUDIO,
|
||||
**_MAPS_LOAD_3D,
|
||||
**_MAPS_SAVE_3D,
|
||||
**_MAPS_H3_SYNC,
|
||||
**_MAPS_H3_LONG_VIDEO,
|
||||
**_MAPS_H3_LATENT_UPSCALE,
|
||||
**_MAPS_H3_TIMELINE,
|
||||
**_MAPS_VIDEO_PROMPT,
|
||||
**_MAPS_AI_PROMPT,
|
||||
**_MAPS_MUSIC_PROMPT,
|
||||
**_MAPS_TEXT_MATCH,
|
||||
**_MAPS_IMAGE_GRID,
|
||||
**_MAPS_LOAD_TEXTS_FOLDER,
|
||||
**_MAPS_CAPTION_REVIEW,
|
||||
}
|
||||
|
||||
# combine all node display name mappings
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
**_NAMES_COMPOSITION,
|
||||
**_NAMES_3D,
|
||||
**_NAMES_AUDIO_STUDIO,
|
||||
**_NAMES_COMPARE,
|
||||
**_NAMES_CROP,
|
||||
**_NAMES_UNCROP,
|
||||
**_NAMES_INPAINT_CROP,
|
||||
**_NAMES_INPAINT_STITCH,
|
||||
**_NAMES_LABEL,
|
||||
**_NAMES_LOAD_IMAGE,
|
||||
**_NAMES_LOAD_IMAGE_MINI,
|
||||
**_NAMES_IMAGE_INFO,
|
||||
**_NAMES_NOTE,
|
||||
**_NAMES_INFO,
|
||||
**_NAMES_NOTIFY,
|
||||
**_NAMES_SAVE_MP4,
|
||||
**_NAMES_SWITCH,
|
||||
**_NAMES_SWITCH_WH,
|
||||
**_NAMES_SWITCH_SOURCE,
|
||||
**_NAMES_MUTE_SWITCH,
|
||||
**_NAMES_WH,
|
||||
**_NAMES_PORTRAIT_LANDSCAPE,
|
||||
**_NAMES_NUMBER,
|
||||
**_NAMES_NUMBER_PICK,
|
||||
**_NAMES_SLIDERS,
|
||||
**_NAMES_SEED,
|
||||
**_NAMES_TEXT,
|
||||
**_NAMES_PROMPT,
|
||||
**_NAMES_PROMPT_STACK,
|
||||
**_NAMES_PROMPT_MULTI,
|
||||
**_NAMES_PROMPT_FROM_LIST,
|
||||
**_NAMES_PROMPT_PACK,
|
||||
**_NAMES_PROMPT_EACH,
|
||||
**_NAMES_UTILS,
|
||||
**_NAMES_PAINT,
|
||||
**_NAMES_PREVIEW,
|
||||
**_NAMES_PROMPT_READER,
|
||||
**_NAMES_RESOLUTION,
|
||||
**_NAMES_SHOW_TEXT,
|
||||
**_NAMES_REMOVE_BG,
|
||||
**_NAMES_TEXT_OVERLAY,
|
||||
**_NAMES_IMAGE_RESIZE,
|
||||
**_NAMES_RESIZE_CROP,
|
||||
**_NAMES_LONGEST_SIDE,
|
||||
**_NAMES_OUTPAINT,
|
||||
**_NAMES_OUTPAINT_STITCH,
|
||||
**_NAMES_TEXT_WATERMARK,
|
||||
**_NAMES_VERSION_CHECK,
|
||||
**_NAMES_PAUSE_IMAGE,
|
||||
**_NAMES_PAUSE_TEXT,
|
||||
**_NAMES_XY_PLOT,
|
||||
**_NAMES_FIND_REPLACE,
|
||||
**_NAMES_LIF,
|
||||
**_NAMES_SET_GET,
|
||||
**_NAMES_LOAD_VIDEO,
|
||||
**_NAMES_LOAD_VIDEO_FRAME,
|
||||
**_NAMES_FIRST_LAST_FRAME,
|
||||
**_NAMES_LOOP,
|
||||
**_NAMES_COMBINE,
|
||||
**_NAMES_GROUP_SWITCH,
|
||||
**_NAMES_RUN_TIMER,
|
||||
**_NAMES_RUN_LOG,
|
||||
**_NAMES_MONITOR,
|
||||
**_NAMES_FREE_VRAM,
|
||||
**_NAMES_SKETCH,
|
||||
**_NAMES_LAYOUT_BOXES,
|
||||
**_NAMES_REF_IMAGES,
|
||||
**_NAMES_VIDEO_TOOLS,
|
||||
**_NAMES_SAVE_IMAGE,
|
||||
**_NAMES_SAVE_VIDEO,
|
||||
**_NAMES_SAVE_TEXT,
|
||||
**_NAMES_KREA_LORA_CONVERT,
|
||||
**_NAMES_KREA2_ENHANCER,
|
||||
**_NAMES_LORA_LOADER,
|
||||
**_NAMES_SIZES,
|
||||
**_NAMES_TEXT_JOIN,
|
||||
**_NAMES_DROPDOWN,
|
||||
**_NAMES_DURATION,
|
||||
**_NAMES_LOAD_AUDIO,
|
||||
**_NAMES_LOAD_3D,
|
||||
**_NAMES_SAVE_3D,
|
||||
**_NAMES_H3_SYNC,
|
||||
**_NAMES_H3_LONG_VIDEO,
|
||||
**_NAMES_H3_LATENT_UPSCALE,
|
||||
**_NAMES_H3_TIMELINE,
|
||||
**_NAMES_VIDEO_PROMPT,
|
||||
**_NAMES_AI_PROMPT,
|
||||
**_NAMES_MUSIC_PROMPT,
|
||||
**_NAMES_TEXT_MATCH,
|
||||
**_NAMES_IMAGE_GRID,
|
||||
**_NAMES_LOAD_TEXTS_FOLDER,
|
||||
**_NAMES_CAPTION_REVIEW,
|
||||
}
|
||||
|
||||
# web directory for loading js files
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
|
||||
# console print banner
|
||||
def display_pixaroma_banner(node_mappings, list_all_nodes=False):
|
||||
"""
|
||||
Displays a styled startup banner in the terminal with version and node info.
|
||||
"""
|
||||
|
||||
# --- 1. Get Version from pyproject.toml ---
|
||||
version = "Unknown"
|
||||
try:
|
||||
import toml
|
||||
|
||||
toml_path = os.path.join(os.path.dirname(__file__), "pyproject.toml")
|
||||
with open(toml_path, "r", encoding="utf-8") as f:
|
||||
version = toml.load(f).get("project", {}).get("version", "Unknown")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- 2. Define ANSI Color Constants ---
|
||||
CLR_ORANGE = "\033[38;2;246;103;68m"
|
||||
CLR_WHITE_BOLD = "\033[1;37m"
|
||||
CLR_GREY = "\033[0;37m"
|
||||
CLR_RESET = "\033[0m"
|
||||
|
||||
# --- 3. Format Node List for Wrapping ---
|
||||
sorted_node_names = sorted(node_mappings.values())
|
||||
max_chars_per_line = 110
|
||||
lines_to_print = []
|
||||
current_line = ""
|
||||
|
||||
for i, name in enumerate(sorted_node_names):
|
||||
# Add comma unless it's the last item
|
||||
entry = f"{name}, " if i != len(sorted_node_names) - 1 else name
|
||||
|
||||
if current_line and len(current_line) + len(entry) > max_chars_per_line:
|
||||
lines_to_print.append(current_line.rstrip(", "))
|
||||
current_line = ""
|
||||
current_line += entry
|
||||
|
||||
if current_line:
|
||||
lines_to_print.append(current_line.rstrip(", "))
|
||||
|
||||
# --- 4. Print the Banner ---
|
||||
# The bar is a box-drawing character. A stdout that cannot encode it (ComfyUI's output redirected
|
||||
# to a file or pipe on Windows = cp1252) gets a plain "-" bar instead: a print that raises here
|
||||
# used to fail the WHOLE pack's import (found 2026-10-04).
|
||||
import sys
|
||||
|
||||
bar_char = "━"
|
||||
try:
|
||||
bar_char.encode(getattr(sys.stdout, "encoding", None) or "ascii")
|
||||
except (LookupError, UnicodeEncodeError):
|
||||
bar_char = "-"
|
||||
horizontal_bar = f"{CLR_ORANGE}{bar_char * 120}{CLR_RESET}"
|
||||
|
||||
print(horizontal_bar)
|
||||
print(
|
||||
f" {CLR_WHITE_BOLD}Pixaroma{CLR_RESET} v{version} | "
|
||||
f"{CLR_ORANGE}{len(node_mappings)} nodes{CLR_RESET} Loaded"
|
||||
)
|
||||
|
||||
if list_all_nodes:
|
||||
for line in lines_to_print:
|
||||
print(f" {CLR_GREY}{line}{CLR_RESET}")
|
||||
|
||||
print(
|
||||
f" {CLR_GREY}ComfyUI Tutorials: https://www.youtube.com/@pixaroma{CLR_RESET}"
|
||||
)
|
||||
print(
|
||||
f" {CLR_GREY}This is a notice, not an error. All {CLR_ORANGE}Pixaroma"
|
||||
f"{CLR_GREY} nodes work in both Classic and Nodes 2.0 mode.{CLR_RESET}"
|
||||
)
|
||||
print(
|
||||
f" {CLR_GREY}If something looks off right after switching the Node UI "
|
||||
f"mode, hard-refresh the page (Ctrl+Shift+R).{CLR_RESET}"
|
||||
)
|
||||
print(horizontal_bar)
|
||||
|
||||
|
||||
# display the banner when the module is loaded; a cosmetic print must never fail the pack's import
|
||||
try:
|
||||
display_pixaroma_banner(NODE_DISPLAY_NAME_MAPPINGS)
|
||||
except Exception:
|
||||
pass
|
||||
import os
|
||||
from . import server_routes # side-effect import for route registration
|
||||
from .nodes.node_composition import NODE_CLASS_MAPPINGS as _MAPS_COMPOSITION
|
||||
from .nodes.node_composition import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_COMPOSITION
|
||||
from .nodes.node_3d import NODE_CLASS_MAPPINGS as _MAPS_3D
|
||||
from .nodes.node_3d import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_3D
|
||||
from .nodes.node_audio_studio import NODE_CLASS_MAPPINGS as _MAPS_AUDIO_STUDIO
|
||||
from .nodes.node_audio_studio import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_AUDIO_STUDIO
|
||||
from .nodes.node_save_mp4 import NODE_CLASS_MAPPINGS as _MAPS_SAVE_MP4
|
||||
from .nodes.node_save_mp4 import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SAVE_MP4
|
||||
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_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
|
||||
from .nodes.node_load_image import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_LOAD_IMAGE
|
||||
from .nodes.node_paint import NODE_CLASS_MAPPINGS as _MAPS_PAINT
|
||||
from .nodes.node_paint import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PAINT
|
||||
from .nodes.node_preview import NODE_CLASS_MAPPINGS as _MAPS_PREVIEW
|
||||
from .nodes.node_preview import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PREVIEW
|
||||
from .nodes.node_prompt_reader import NODE_CLASS_MAPPINGS as _MAPS_PROMPT_READER
|
||||
from .nodes.node_prompt_reader import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PROMPT_READER
|
||||
from .nodes.node_resolution import NODE_CLASS_MAPPINGS as _MAPS_RESOLUTION
|
||||
from .nodes.node_resolution import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_RESOLUTION
|
||||
from .nodes.node_show_text import NODE_CLASS_MAPPINGS as _MAPS_SHOW_TEXT
|
||||
from .nodes.node_show_text import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SHOW_TEXT
|
||||
from .nodes.node_note import NODE_CLASS_MAPPINGS as _MAPS_NOTE
|
||||
from .nodes.node_note import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_NOTE
|
||||
from .nodes.node_notify import NODE_CLASS_MAPPINGS as _MAPS_NOTIFY
|
||||
from .nodes.node_notify import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_NOTIFY
|
||||
from .nodes.node_switch import NODE_CLASS_MAPPINGS as _MAPS_SWITCH
|
||||
from .nodes.node_switch import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SWITCH
|
||||
from .nodes.node_switch_wh import NODE_CLASS_MAPPINGS as _MAPS_SWITCH_WH
|
||||
from .nodes.node_switch_wh import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SWITCH_WH
|
||||
from .nodes.node_switch_source import NODE_CLASS_MAPPINGS as _MAPS_SWITCH_SOURCE
|
||||
from .nodes.node_switch_source import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_SWITCH_SOURCE
|
||||
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_number import NODE_CLASS_MAPPINGS as _MAPS_NUMBER
|
||||
from .nodes.node_number import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_NUMBER
|
||||
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
|
||||
from .nodes.node_prompt_stack import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PROMPT_STACK
|
||||
from .nodes.node_prompt_multi import NODE_CLASS_MAPPINGS as _MAPS_PROMPT_MULTI
|
||||
from .nodes.node_prompt_multi import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PROMPT_MULTI
|
||||
from .nodes.node_prompt_from_list import NODE_CLASS_MAPPINGS as _MAPS_PROMPT_FROM_LIST
|
||||
from .nodes.node_prompt_from_list import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PROMPT_FROM_LIST
|
||||
from .nodes.node_prompt_pack import NODE_CLASS_MAPPINGS as _MAPS_PROMPT_PACK
|
||||
from .nodes.node_prompt_pack import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PROMPT_PACK
|
||||
from .nodes.node_remove_background import NODE_CLASS_MAPPINGS as _MAPS_REMOVE_BG
|
||||
from .nodes.node_remove_background import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_REMOVE_BG
|
||||
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_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
|
||||
from .nodes.node_version_check import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_VERSION_CHECK
|
||||
from .nodes.node_pause_image import NODE_CLASS_MAPPINGS as _MAPS_PAUSE_IMAGE
|
||||
from .nodes.node_pause_image import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_PAUSE_IMAGE
|
||||
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
|
||||
|
||||
# development mode for loading additional refrence nodes
|
||||
dev_mode = False
|
||||
if dev_mode:
|
||||
from .nodes.node_ref import NODE_CLASS_MAPPINGS as _MAPS_UTILS
|
||||
from .nodes.node_ref import NODE_DISPLAY_NAME_MAPPINGS as _NAMES_UTILS
|
||||
else:
|
||||
_MAPS_UTILS = {}
|
||||
_NAMES_UTILS = {}
|
||||
|
||||
# combine all node mappings
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
**_MAPS_3D,
|
||||
**_MAPS_AUDIO_STUDIO,
|
||||
**_MAPS_COMPOSITION,
|
||||
**_MAPS_PAINT,
|
||||
**_MAPS_PREVIEW,
|
||||
**_MAPS_PROMPT_READER,
|
||||
**_MAPS_RESOLUTION,
|
||||
**_MAPS_COMPARE,
|
||||
**_MAPS_CROP,
|
||||
**_MAPS_LABEL,
|
||||
**_MAPS_LOAD_IMAGE,
|
||||
**_MAPS_NOTE,
|
||||
**_MAPS_NOTIFY,
|
||||
**_MAPS_SAVE_MP4,
|
||||
**_MAPS_SWITCH,
|
||||
**_MAPS_SWITCH_WH,
|
||||
**_MAPS_SWITCH_SOURCE,
|
||||
**_MAPS_MUTE_SWITCH,
|
||||
**_MAPS_WH,
|
||||
**_MAPS_NUMBER,
|
||||
**_MAPS_TEXT,
|
||||
**_MAPS_PROMPT_STACK,
|
||||
**_MAPS_PROMPT_MULTI,
|
||||
**_MAPS_PROMPT_FROM_LIST,
|
||||
**_MAPS_PROMPT_PACK,
|
||||
**_MAPS_UTILS,
|
||||
**_MAPS_SHOW_TEXT,
|
||||
**_MAPS_REMOVE_BG,
|
||||
**_MAPS_TEXT_OVERLAY,
|
||||
**_MAPS_IMAGE_RESIZE,
|
||||
**_MAPS_TEXT_WATERMARK,
|
||||
**_MAPS_VERSION_CHECK,
|
||||
**_MAPS_PAUSE_IMAGE,
|
||||
**_MAPS_XY_PLOT,
|
||||
**_MAPS_FIND_REPLACE,
|
||||
}
|
||||
|
||||
# combine all node display name mappings
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
**_NAMES_COMPOSITION,
|
||||
**_NAMES_3D,
|
||||
**_NAMES_AUDIO_STUDIO,
|
||||
**_NAMES_COMPARE,
|
||||
**_NAMES_CROP,
|
||||
**_NAMES_LABEL,
|
||||
**_NAMES_LOAD_IMAGE,
|
||||
**_NAMES_NOTE,
|
||||
**_NAMES_NOTIFY,
|
||||
**_NAMES_SAVE_MP4,
|
||||
**_NAMES_SWITCH,
|
||||
**_NAMES_SWITCH_WH,
|
||||
**_NAMES_SWITCH_SOURCE,
|
||||
**_NAMES_MUTE_SWITCH,
|
||||
**_NAMES_WH,
|
||||
**_NAMES_NUMBER,
|
||||
**_NAMES_TEXT,
|
||||
**_NAMES_PROMPT_STACK,
|
||||
**_NAMES_PROMPT_MULTI,
|
||||
**_NAMES_PROMPT_FROM_LIST,
|
||||
**_NAMES_PROMPT_PACK,
|
||||
**_NAMES_UTILS,
|
||||
**_NAMES_PAINT,
|
||||
**_NAMES_PREVIEW,
|
||||
**_NAMES_PROMPT_READER,
|
||||
**_NAMES_RESOLUTION,
|
||||
**_NAMES_SHOW_TEXT,
|
||||
**_NAMES_REMOVE_BG,
|
||||
**_NAMES_TEXT_OVERLAY,
|
||||
**_NAMES_IMAGE_RESIZE,
|
||||
**_NAMES_TEXT_WATERMARK,
|
||||
**_NAMES_VERSION_CHECK,
|
||||
**_NAMES_PAUSE_IMAGE,
|
||||
**_NAMES_XY_PLOT,
|
||||
**_NAMES_FIND_REPLACE,
|
||||
}
|
||||
|
||||
# web directory for loading js files
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
|
||||
# console print banner
|
||||
def display_pixaroma_banner(node_mappings, list_all_nodes=False):
|
||||
"""
|
||||
Displays a styled startup banner in the terminal with version and node info.
|
||||
"""
|
||||
|
||||
# --- 1. Get Version from pyproject.toml ---
|
||||
version = "Unknown"
|
||||
try:
|
||||
import toml
|
||||
|
||||
toml_path = os.path.join(os.path.dirname(__file__), "pyproject.toml")
|
||||
with open(toml_path, "r", encoding="utf-8") as f:
|
||||
version = toml.load(f).get("project", {}).get("version", "Unknown")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- 2. Define ANSI Color Constants ---
|
||||
CLR_ORANGE = "\033[38;2;246;103;68m"
|
||||
CLR_WHITE_BOLD = "\033[1;37m"
|
||||
CLR_GREY = "\033[0;37m"
|
||||
CLR_RESET = "\033[0m"
|
||||
|
||||
# --- 3. Format Node List for Wrapping ---
|
||||
sorted_node_names = sorted(node_mappings.values())
|
||||
max_chars_per_line = 110
|
||||
lines_to_print = []
|
||||
current_line = ""
|
||||
|
||||
for i, name in enumerate(sorted_node_names):
|
||||
# Add comma unless it's the last item
|
||||
entry = f"{name}, " if i != len(sorted_node_names) - 1 else name
|
||||
|
||||
if current_line and len(current_line) + len(entry) > max_chars_per_line:
|
||||
lines_to_print.append(current_line.rstrip(", "))
|
||||
current_line = ""
|
||||
current_line += entry
|
||||
|
||||
if current_line:
|
||||
lines_to_print.append(current_line.rstrip(", "))
|
||||
|
||||
# --- 4. Print the Banner ---
|
||||
horizontal_bar = f"{CLR_ORANGE}{'━' * 120}{CLR_RESET}"
|
||||
|
||||
print(horizontal_bar)
|
||||
print(
|
||||
f" {CLR_WHITE_BOLD}Pixaroma{CLR_RESET} v{version} | "
|
||||
f"{CLR_ORANGE}{len(node_mappings)} nodes{CLR_RESET} Loaded"
|
||||
)
|
||||
|
||||
if list_all_nodes:
|
||||
for line in lines_to_print:
|
||||
print(f" {CLR_GREY}{line}{CLR_RESET}")
|
||||
|
||||
print(
|
||||
f" {CLR_GREY}ComfyUI Tutorials: https://www.youtube.com/@pixaroma{CLR_RESET}"
|
||||
)
|
||||
print(
|
||||
f" {CLR_GREY}This is a notice, not an error. All {CLR_ORANGE}Pixaroma"
|
||||
f"{CLR_GREY} nodes work in both Classic and Nodes 2.0 mode.{CLR_RESET}"
|
||||
)
|
||||
print(
|
||||
f" {CLR_GREY}If something looks off right after switching the Node UI "
|
||||
f"mode, hard-refresh the page (Ctrl+Shift+R).{CLR_RESET}"
|
||||
)
|
||||
print(horizontal_bar)
|
||||
|
||||
|
||||
# display the banner when the module is loaded
|
||||
display_pixaroma_banner(NODE_DISPLAY_NAME_MAPPINGS)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M61.972,51.884L36.207,7.258c-1.87-3.239-6.545-3.239-8.415,0L2.028,51.884c-1.87,3.239.467,7.287,4.207,7.287h51.53c3.74,0,6.077-4.049,4.207-7.287ZM32,53.409c-3.143,0-5.691-2.548-5.691-5.691s2.548-5.691,5.691-5.691,5.691,2.548,5.691,5.691-2.548,5.691-5.691,5.691ZM35.899,35.463c0,1.293-.718,2.341-1.604,2.341h-4.59c-.886,0-1.604-1.048-1.604-2.341l-1.792-12.819c0-1.293,1.048-2.341,2.341-2.341h6.699c1.293,0,2.341,1.048,2.341,2.341l-1.792,12.819Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 521 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M45.743,30.397c4.655,3.574,6.878,8.37,7.743,14.042,1.328,3.703,1.97,7.032-.716,10.181-1.525,1.787-3.311,3.189-5.409,4.312-9.497,5.083-21.014,5.11-30.557.099-2.145-1.126-4.031-2.575-5.562-4.392-1.78-2.112-2.258-4.707-1.581-7.37l.934-3.003c.689-5.502,3.188-10.437,7.719-13.861-4.055-6.707-9.619-18.69-6.327-25.867,1.115-2.431,3.507-3.686,6.154-3.2,4.244.78,6.927,5.379,8.331,9.319,1.228,3.445,1.852,6.946,2.323,10.571l.508,4.837c1.847-.183,3.558-.207,5.437-.004.473-5.688,1.182-11.774,3.453-17.045,1.528-3.547,4.352-7.484,8.356-7.763,2.238-.156,4.205.888,5.263,2.843,1.003,1.853,1.23,3.878,1.183,6.002-.148,6.695-3.828,14.469-7.253,20.3ZM24.613,44.582c0-1.506-1.221-2.727-2.727-2.727s-2.727,1.221-2.727,2.727,1.221,2.727,2.727,2.727,2.727-1.221,2.727-2.727ZM44.872,44.587c0-1.501-1.217-2.718-2.718-2.718s-2.718,1.217-2.718,2.718,1.217,2.718,2.718,2.718,2.718-1.217,2.718-2.718ZM32.64,52.051l2.006-2.422c.126-.152.169-.621.083-.796s-.457-.443-.67-.444h-3.997c-.222-.001-.609.198-.723.359s-.148.652-.01.821l2.004,2.449c.297.363.971.439,1.307.033Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M47.878,33.643c1.281.003,2.282.832,3.041,1.668l3.928,4.326c.736.811,1.172,1.877,1.169,3.008v15.572c-.006,2.052-1.945,4.055-4.037,4.055H12.131c-2.178,0-4.15-2.093-4.154-4.228v-15.475c-.003-1.366.661-2.374,1.498-3.297l3.657-4.033c.663-.731,1.675-1.581,2.75-1.588h7.911l2.649,3.328h-9.233c-.597,0-1.205.154-1.6.604-.913,1.042-1.782,1.944-2.703,3.215h38.229l-2.785-3.216c-.337-.389-.926-.6-1.454-.6h-9.331l2.629-3.339h7.683ZM45.438,21.133c.297-.372.32-1.018.169-1.38-.133-.318-.668-.652-1.118-.652h-7.112V3.681c0-1.01-.716-1.888-1.796-1.912-2.413-.055-4.784-.064-7.203.014-.808.026-1.736.788-1.737,1.604v15.714h-7.168c-.49,0-.921.334-1.079.698-.148.341-.184.961.122,1.342l12.111,15.107c.402.502.954.767,1.488.762.639-.006.949-.375,1.414-.958l11.909-14.918Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 831 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M59.046,21.359l-3.274,3.274-16.405-16.405,3.274-3.274c2.348-2.348,6.155-2.348,8.503,0l7.902,7.902c2.348,2.348,2.348,6.155,0,8.503ZM7.836,39.76c-1.094,1.094-1.839,2.486-2.143,4.003l-2.415,12.052c-.292,1.46.205,2.833,1.14,3.768.935.935,2.309,1.433,3.768,1.14l12.052-2.415c1.516-.304,2.909-1.05,4.003-2.143l27.075-27.075-16.405-16.405L7.836,39.76Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 423 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M53.973,15.358c-1.032-3.119-2.676-6.016-4.978-8.395C44.787,2.613,38.718.899,32.749.892c-4.878-.006-9.177.802-13.416,3.293-7.689,4.518-12.263,13.994-9.44,22.705.867,2.676,2.251,5.029,4.125,7.111l3.796,4.215c1.202,1.335,1.83,2.7,1.99,4.495.106,1.194,1.274,2.327,2.55,2.328h18.922c1.757.001,2.848-1.347,2.949-3.023.079-1.3.633-2.391,1.456-3.287l3.034-3.301c1.19-1.294,2.285-2.59,3.241-4.057,3.12-4.787,3.814-10.584,2.017-16.013ZM30.284,10.763c-.322,1.093-1.274,1.756-2.443,2.022-3.535.804-6.204,3.562-7.246,7.047-.378,1.265-1.286,2.129-2.549,2.219-.866.062-1.638-.197-2.221-.795-.555-.569-.901-1.443-.691-2.32,1.394-5.795,5.956-10.538,11.827-11.746.853-.175,1.693-.064,2.362.455.967.75,1.302,1.958.96,3.118ZM43.876,51.049c-.009,1.381-1.055,2.811-2.645,2.811h-18.704c-1.589,0-2.532-1.432-2.623-2.829-.108-1.654.912-3.311,2.693-3.313h18.878c1.617.308,2.594,1.674,2.402,3.33ZM40.972,57.617c-.073,1.318-.665,2.435-1.582,3.354-1.333,1.337-3.083,2.048-5.041,2.136h-4.785c-1.263.001-2.489-.36-3.57-.947-1.668-.905-2.852-2.394-3.16-4.261-.053-.32.069-.785.222-.964.164-.193.54-.406.879-.406h16.004c.63,0,1.063.535,1.032,1.087Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M63.18,52.412c-.004,3.614-2.766,7.013-6.442,7.014H7.039c-3.249,0-6.211-3.26-6.219-6.459V10.835c.586-3.382,2.813-5.753,6.206-6.261h49.968c3.801.005,6.321,3.865,6.061,7.231-.082,1.062.127,1.823.126,2.78v37.828ZM35.44,41.668l7.523-7.113c1.843-1.979,4.642-2.324,6.674-.466l7.08,5.615V11.017H7.239v33.764l7.227-6.84c2.076-2.077,4.091-4.217,6.475-5.902,1.87-1.322,3.965-.17,5.418,1.181l9.082,8.448ZM46.33,15.667c-3.14,0-5.685,2.545-5.685,5.685s2.545,5.685,5.685,5.685,5.685-2.545,5.685-5.685-2.545-5.685-5.685-5.685Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 589 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M32,1.914C15.384,1.914,1.914,15.384,1.914,32c0,5.771,1.628,11.161,4.445,15.741l-3.678,11.247c-.411,1.258.715,2.48,2.002,2.173l12.489-2.985c4.377,2.485,9.434,3.911,14.827,3.911,16.616,0,30.086-13.47,30.086-30.086S48.616,1.914,32,1.914ZM37.691,51.212c0,1.293-1.048,2.341-2.341,2.341h-6.699c-1.293,0-2.341-1.048-2.341-2.341v-22.819c0-1.293,1.048-2.341,2.341-2.341h6.699c1.293,0,2.341,1.048,2.341,2.341v22.819ZM32,21.828c-3.143,0-5.691-2.548-5.691-5.691s2.548-5.691,5.691-5.691,5.691,2.548,5.691,5.691-2.548,5.691-5.691,5.691Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 601 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M57.402,32.138c-11.505,11.005-14.356,15.333-22.078,15.159-7.264-.164-16.766-7.015-10.745-12.84,2.945-2.849,3.72,10.213,14.563.526,9.875-10.043,12.053-11.251,11.819-15.27-.333-5.73-7.709-8.059-12.092-3.795-2.674,2.601-2.843,3.199-3.944,2.542-9.932-5.916-14.531,1.327-1.413-11.342,16.411-15.849,39.946,9.662,23.891,25.02ZM36.413,50.619c-8.229.238-9.543-3.672-11.238-1.97-7.511,7.546-17.654-3.79-10.119-10.692,5.811-5.323,9.704-10.87,13.666-6.517,1.795,1.972,3.421,4.552,5.387,2.761,2.834-2.582,4.462-3.576,4.225-5.846-.436-4.161-7.052-10.424-15.377-9.36-5.251.671-6.787,3.143-16.853,13.007-15.754,15.438,8.704,40.757,25.087,24.518,3.456-3.426,6.769-5.946,5.223-5.901Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 744 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M57.331,37.331h-5.942v-8.043h-17.389v-3.18h6.13c2.098,0,3.799-1.701,3.799-3.799V6.424c0-2.098-1.701-3.799-3.799-3.799h-16.259c-2.098,0-3.799,1.701-3.799,3.799v15.885c0,2.098,1.701,3.799,3.799,3.799h6.13v3.18H12.611v8.043h-5.942c-2.098,0-3.799,1.701-3.799,3.799v15.885c0,2.098,1.701,3.799,3.799,3.799h15.885c2.098,0,3.799-1.701,3.799-3.799v-15.885c0-2.098-1.701-3.799-3.799-3.799h-5.943v-4.043h30.777v4.043h-5.943c-2.098,0-3.799,1.701-3.799,3.799v15.885c0,2.098,1.701,3.799,3.799,3.799h15.885c2.098,0,3.799-1.701,3.799-3.799v-15.885c0-2.098-1.701-3.799-3.799-3.799Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 643 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M37.612,6.388v5.942h-8.043v17.389h-3.18v-6.13c0-2.098-1.701-3.799-3.799-3.799H6.705c-2.098,0-3.799,1.701-3.799,3.799v16.259c0,2.098,1.701,3.799,3.799,3.799h15.885c2.098,0,3.799-1.701,3.799-3.799v-6.13h3.18v17.389h8.043v5.942c0,2.098,1.701,3.799,3.799,3.799h15.885c2.098,0,3.799-1.701,3.799-3.799v-15.885c0-2.098-1.701-3.799-3.799-3.799h-15.885c-2.098,0-3.799,1.701-3.799,3.799v5.943h-4.043v-30.777h4.043v5.943c0,2.098,1.701,3.799,3.799,3.799h15.885c2.098,0,3.799-1.701,3.799-3.799V6.388c0-2.098-1.701-3.799-3.799-3.799h-15.885c-2.098,0-3.799,1.701-3.799,3.799Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 639 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M55.404,1.776c-1.718-.074-3.213-.025-4.94-.005v14.606c0,.583-.287.97-.721,1.181-.388.188-.929.188-1.332-.103l-4.308-3.105c-.711-.512-1.401-.599-2.138-.057l-4.329,3.185c-.386.284-.946.305-1.363.091-.334-.171-.679-.6-.679-1.101V1.747h-17.027c-1.015,0-1.831.87-1.831,1.884v56.766c0,.978.866,1.866,1.866,1.866h36.719c3.698-.254,6.625-3.03,6.966-6.72V8.681c-.003-3.703-3.336-6.752-6.882-6.905ZM53.958,47.559c-.712.791-1.51,1.266-2.569,1.266h-24.478c-1.741,0-3.157-1.13-3.342-2.782-.191-1.699.828-3.133,2.569-3.589h25.752c1.125,0,1.945,1.102,2.377,1.782.498,1.182.396,2.36-.308,3.323ZM51.848,37.964h-25.643c-1.739-.308-2.801-1.831-2.649-3.506.153-1.696,1.573-2.898,3.34-2.891h24.295c1.765-.002,3.148,1.121,3.367,2.804.221,1.704-.871,3.293-2.709,3.593ZM13.174,1.77v60.443c-1.424.082-2.828.088-4.259.023-3.792-.174-7.012-3.159-7.202-6.975V10.068c-.001-2.021.318-3.931,1.657-5.493C6.124,1.36,9.168,1.686,13.174,1.77Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 986 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M21.16,49.654c-.481,0-.799.319-1.112.556l-11.425,8.651c-.498.377-1.229.325-1.766.119-.412-.157-1.006-.673-1.008-1.306l-.027-8.21-.899-.451c-2.38-1.15-4.006-3.363-4.303-6.036l.003-27.578c.382-3.643,3.2-6.39,6.848-6.529l6.06.016,3.038-.027,39.986.024c3.536.002,6.815,3.001,6.816,6.574l.009,27.331c.001,3.657-3.248,6.816-6.901,6.82l-35.319.045ZM52.298,26.432c1.546-.235,2.549-1.485,2.493-2.913.018-1.442-1.054-2.581-2.558-2.84l-39.87-.005c-1.621,0-2.874,1.09-3,2.65-.14,1.5.85,2.844,2.425,3.108h40.51ZM39.673,37.807c1.575,0,2.635-1.447,2.672-2.758.028-1.505-.967-2.717-2.499-2.955H11.874c-1.544.254-2.579,1.425-2.515,2.959-.028,1.304,1.122,2.757,2.623,2.757l27.69-.003Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 745 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M32,1.914C15.384,1.914,1.914,15.384,1.914,32c0,5.771,1.628,11.161,4.445,15.741l-3.678,11.247c-.411,1.258.715,2.48,2.002,2.173l12.489-2.985c4.377,2.485,9.434,3.911,14.827,3.911,16.616,0,30.086-13.47,30.086-30.086S48.616,1.914,32,1.914ZM32,55.169c-3.143,0-5.691-2.548-5.691-5.691s2.548-5.691,5.691-5.691,5.691,2.548,5.691,5.691-2.548,5.691-5.691,5.691ZM38.875,33.813c-2.457,1.355-3.088,2.112-3.088,3.632v1.445c0,.854-.692,1.546-1.546,1.546h-5.449c-.854,0-1.546-.692-1.546-1.546v-2.696c0-5.154,2.893-6.724,6.904-8.883,3.411-1.837,4.404-2.694,4.404-4.881,0-3.21-2.345-5.051-6.433-5.051-4.51,0-5.954,2.184-6.521,5.869-.139.901-.895,1.578-1.807,1.578h-5.119c-1.087,0-1.938-.94-1.816-2.021.488-4.308,2.94-13.973,15.491-13.973,8.922,0,14.917,5.392,14.917,13.418,0,6.452-3.809,9.036-8.391,11.563Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 866 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M52.138,19.358c1.076.713,2.252,1.232,3.292.38.751-.615,1.452-1.242,2.062-1.996.533-.659.463-1.996-.126-2.606l-4.192-4.301c-.793-.68-2.15-.706-2.948.014-.703.634-1.409,1.286-1.988,2.009-.771.964-.18,2.003.441,3.049-3.942-2.98-7.752-4.844-12.645-5.575v-3.78h2.307c1.321-.177,2.19-1.368,1.953-2.689.243-1.345-.588-2.558-1.952-2.765h-11.732c-1.441.003-2.321,1.458-2.048,2.745-.128.788.024,1.455.519,2.006.464.454,1.063.71,1.78.71h1.589v3.848c-7.013,1.038-13.395,4.814-17.691,10.477-2.973,3.918-4.837,8.589-5.155,13.506s.559,9.005,2.595,13.414c3.844,8.323,11.865,14.233,21.055,15.013,4.035.342,7.955-.239,11.714-1.654,5.381-2.025,9.865-5.703,13.085-10.435,6.733-9.894,5.478-22.145-1.915-31.372ZM42.828,39.01l-13.843,8.133c-.931.547-2.082.519-3.088.033-.718-.347-1.686-1.249-1.686-2.293v-16.907c0-1.097,1.004-1.922,1.634-2.354,1.142-.484,2.199-.497,3.206.038l13.176,7.743c1.019.599,1.739,1.414,1.859,2.597.099.975-.177,2.374-1.259,3.01Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1009 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M52.186,11.053l-2.243-.024-.043,4.583c-.032,3.421-2.503,6.177-5.935,6.499h-23.982c-2.037-.238-3.687-1.232-4.872-2.875-.527-.884-1.017-1.902-1.025-2.986v-5.148c-1.775-.213-3.532-.094-5.082.829-1.871,1.114-3.37,3.139-3.37,5.427v39.899c.497,3.236,3.188,5.831,6.535,5.83h39.797c3.47-.002,6.036-2.994,6.395-6.318V17.432c-.027-3.372-2.86-6.103-6.177-6.379ZM47.028,35.648l-16.423,16.475c-1.296,1.297-3.419,1.546-4.733.236l-8.959-9.055c-1.421-1.451-.943-3.7.385-5.013.805-.796,1.614-1.32,2.819-1.429.848-.077,1.975.158,2.639.817l5.264,5.221c.173.172.599-.109.651-.312l12.067-12.07c.978-.979,2.2-1.632,3.611-1.289,1.195.29,2.195,1.013,2.901,2.054.864,1.274.959,3.181-.222,4.366ZM20.362,18.902h23.277c1.75-.235,3.131-1.463,3.217-3.209.069-1.417.1-2.8-.031-4.223-.14-1.515-1.453-2.695-2.923-2.924-1.546-.241-3.013.394-3.263-.777-.783-3.662-3.566-6.437-7.371-6.802-2.656-.255-5.332.396-7.246,2.236-1.423,1.368-2.389,3.019-2.795,4.927-.515.663-1.844.217-3.16.42-1.636.253-2.953,1.554-2.967,3.2v3.698c-.015,1.752,1.37,3.452,3.263,3.454ZM32.003,5.885c1.785,0,3.232,1.447,3.232,3.233s-1.447,3.233-3.232,3.233-3.232-1.447-3.232-3.233,1.447-3.233,3.232-3.233Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2.7875 -3.523 64 64"><path d="M29.212,34.213l-9.664,18.498c-.411.788-1.228,1.282-2.116,1.282h-7.451c-1.228,0-2.255-.932-2.376-2.153L3.048,5.584c-.166-1.685,1.427-3.004,3.052-2.524l7.51,2.216c.919.272,1.588,1.066,1.696,2.019l2.643,23.093,6.638-11.511c.392-.68,1.117-1.099,1.902-1.099h5.443c.785,0,1.51.419,1.902,1.099l6.638,11.511,2.643-23.093c.109-.952.777-1.747,1.696-2.019l7.512-2.216c1.625-.479,3.218.839,3.052,2.524l-4.556,46.256c-.12,1.222-1.148,2.153-2.376,2.153h-7.452c-.889,0-1.704-.494-2.116-1.282l-9.664-18.498h0Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 583 B |
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<path transform="translate(1.3 8.505)" d="M25.87,35.5H4.48C2.16,35.49,0,33.74,0,31.31v-14.17c0-1.98,1.9-4.24,4.01-4.25h27.57l.13-7.21c.07-3.8,3.66-6.19,7.26-5.58,2.06.35,3.27,1.61,4.66,3.02l15.97,16.25c3.13,3.18,1.8,7.7-1.13,10.45l-16.88,15.88c-1.68,1.58-4.36,1.58-6.33.71-1.73-.76-3.44-2.71-3.57-4.93-.12-1.96-.07-3.87-.09-5.86l-5.73-.1Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 412 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23.16 21.297"><path d="M17.3,15.407v-1.891c0-.3-.243-.543-.543-.543H1.192c-.658,0-1.192-.533-1.192-1.192v-2.298c0-.658.533-1.192,1.192-1.192h15.566c.3,0,.543-.243.543-.543v-1.891c0-.484.585-.726.926-.384l4.774,4.774c.212.212.212.556,0,.768l-4.774,4.774c-.342.342-.926.1-.926-.384ZM14.5,0H3.009c-.3,0-.543.243-.543.543v4.926c0,.3.243.543.543.543h11.492c.3,0,.543-.243.543-.543V.543c0-.3-.243-.543-.543-.543ZM3.009,21.297h11.492c.3,0,.543-.243.543-.543v-4.959c0-.3-.243-.543-.543-.543H3.009c-.3,0-.543.243-.543.543v4.959c0,.3.243.543.543.543Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 602 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 59.763 63.302"><path d="M53.415,56.13c1.647,2.95-.136,6.803-3.497,7.146-.174.018-.349.026-.526.026H10.371c-.178,0-.354-.009-.526-.026-3.362-.335-5.144-4.197-3.497-7.146L26.691,19.693c1.394-2.497,4.987-2.497,6.381,0l20.343,36.437ZM55.2,0H4.563C2.043,0,0,2.043,0,4.563v5.378c0,2.52,2.043,4.563,4.563,4.563h50.637c2.52,0,4.563-2.043,4.563-4.563v-5.378c0-2.52-2.043-4.563-4.563-4.563Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 442 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23.295 23.295"><path d="M11.647,0C5.225,0,0,5.225,0,11.647s5.225,11.648,11.647,11.648,11.647-5.226,11.647-11.648S18.07,0,11.647,0ZM11.647,4c1.359,0,2.634.36,3.741.985L4.985,15.389c-.624-1.107-.985-2.382-.985-3.742,0-4.217,3.431-7.647,7.647-7.647ZM11.647,19.295c-1.408,0-2.724-.389-3.858-1.055l10.45-10.45c.666,1.134,1.055,2.45,1.055,3.857,0,4.217-3.431,7.648-7.647,7.648Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 433 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 62.258 58.612"><path d="M.801,9.242C-1.321,5.441.976.475,5.307.034c.224-.023.449-.034.678-.034h50.286c.229,0,.456.012.678.034,4.332.432,6.629,5.408,4.506,9.208l-26.216,46.956c-1.796,3.218-6.427,3.218-8.223,0L.801,9.242Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 281 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M22,24L38,10c1.2-1,3,-0.1,3,1.5v41c0,1.6-1.8,2.5-3,1.5L22,40H11c-1.7,0-3-1.3-3-3V27c0-1.7,1.3-3,3-3H22Z"/><path d="M62.1,25.2c1.2-1.2,1.2-3.1,0-4.2s-3.1-1.2-4.2,0L52,26.8l-5.9-5.9c-1.2-1.2-3.1-1.2-4.2,0s-1.2,3.1,0,4.2l5.9,5.9-5.9,5.9c-1.2,1.2-1.2,3.1,0,4.2s3.1,1.2,4.2,0l5.9-5.9,5.9,5.9c1.2,1.2,3.1,1.2,4.2,0s1.2-3.1,0-4.2L56.2,31Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 410 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M22,24L38,10c1.2-1,3,-0.1,3,1.5v41c0,1.6-1.8,2.5-3,1.5L22,40H11c-1.7,0-3-1.3-3-3V27c0-1.7,1.3-3,3-3H22Z"/><path d="M47.3,21.5c-1.2-1.1-3.1-1-4.2,0.2s-1,3.1,0.2,4.2c2,1.8,3.1,4.3,3.1,7s-1.1,5.2-3.1,7c-1.2,1.1-1.3,3-0.2,4.2s3,1.3,4.2,0.2c3.3-2.9,5.1-7.1,5.1-11.4S50.6,24.4,47.3,21.5Z"/><path d="M54.8,12.1c-1.2-1.1-3.1-1-4.2,0.3s-1,3.1,0.3,4.2c4.7,4.1,7.4,10,7.4,16.3s-2.7,12.2-7.4,16.3c-1.2,1.1-1.4,3-0.3,4.2s3,1.4,4.2,0.3c6-5.3,9.4-12.8,9.4-20.8S60.8,17.4,54.8,12.1Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 545 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 58.425 56.954"><path d="M29.212,34.879l-10.785,20.644c-.459.879-1.37,1.431-2.362,1.431H7.75c-1.37,0-2.517-1.04-2.652-2.403L.013,2.928C-.172,1.047,1.606-.424,3.419.111l8.381,2.473c1.026.303,1.772,1.19,1.893,2.253l2.95,25.772,7.408-12.846c.438-.759,1.247-1.226,2.123-1.226h6.075c.876,0,1.685.468,2.123,1.226l7.408,12.846,2.95-25.772c.122-1.063.867-1.95,1.893-2.253L55.006.111c1.813-.535,3.591.936,3.406,2.817l-5.085,51.622c-.134,1.364-1.281,2.403-2.652,2.403h-8.316c-.992,0-1.902-.551-2.362-1.431l-10.785-20.644Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 573 B |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 10 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22" role="img" aria-label="Align button"><title>Align button</title><rect x=".5" y=".5" width="21" height="21" rx="4" fill="#2a2c2e" stroke="#444444"/><svg x="4.50" y="4.50" width="13.00" height="13.00" viewBox="0.0 0.0 64.0 64.0" fill="#dddddd"><path d="M48.355,16.626h6.806c1.434,0,2.597,1.163,2.597,2.597v25.554c0,1.434-1.163,2.597-2.597,2.597h-6.806c-1.434,0-2.597-1.163-2.597-2.597v-25.554c0-1.434,1.163-2.597,2.597-2.597ZM17.657,44.777v-25.554c0-1.434-1.163-2.597-2.597-2.597h-6.806c-1.434,0-2.597,1.163-2.597,2.597v25.554c0,1.434,1.163,2.597,2.597,2.597h6.806c1.434,0,2.597-1.163,2.597-2.597ZM25.707,6.223v51.554c0,1.434,1.163,2.597,2.597,2.597h6.806c1.434,0,2.597-1.163,2.597-2.597V6.223c0-1.434-1.163-2.597-2.597-2.597h-6.806c-1.434,0-2.597,1.163-2.597,2.597Z"/></svg></svg>
|
||||
|
Before Width: | Height: | Size: 863 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22" role="img" aria-label="Help button"><title>Help button</title><rect x=".5" y=".5" width="21" height="21" rx="4" fill="#2a2c2e" stroke="#444444"/><svg x="6.97" y="4.50" width="8.06" height="13.00" viewBox="0.0 0.0 38.919 62.785" fill="#dddddd"><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></svg>
|
||||
|
Before Width: | Height: | Size: 655 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22" role="img" aria-label="Workflows button"><title>Workflows button</title><rect x=".5" y=".5" width="21" height="21" rx="4" fill="#2a2c2e" stroke="#444444"/><svg x="4.85" y="5.00" width="12.31" height="12.00" viewBox="0.0 0.0 58.425 56.954" fill="#dddddd"><path d="M29.212,34.879l-10.785,20.644c-.459.879-1.37,1.431-2.362,1.431H7.75c-1.37,0-2.517-1.04-2.652-2.403L.013,2.928C-.172,1.047,1.606-.424,3.419.111l8.381,2.473c1.026.303,1.772,1.19,1.893,2.253l2.95,25.772,7.408-12.846c.438-.759,1.247-1.226,2.123-1.226h6.075c.876,0,1.685.468,2.123,1.226l7.408,12.846,2.95-25.772c.122-1.063.867-1.95,1.893-2.253L55.006.111c1.813-.535,3.591.936,3.406,2.817l-5.085,51.622c-.134,1.364-1.281,2.403-2.652,2.403h-8.316c-.992,0-1.902-.551-2.362-1.431l-10.785-20.644Z"/></svg></svg>
|
||||
|
Before Width: | Height: | Size: 848 B |
@@ -1,158 +0,0 @@
|
||||
/* three.js r184 examples/jsm/curves/NURBSCurve.js - MIT licensed, (c) three.js authors.
|
||||
* Taken from the readable source ComfyUI ships in comfyui_frontend_package 1.51.10.
|
||||
* Only the import paths were changed, to point at the pack's vendored three.mjs. */
|
||||
import {
|
||||
Curve,
|
||||
Vector3,
|
||||
Vector4
|
||||
} from '../../../three.mjs';
|
||||
import * as NURBSUtils from '../curves/NURBSUtils.mjs';
|
||||
|
||||
/**
|
||||
* This class represents a NURBS curve.
|
||||
*
|
||||
* Implementation is based on `(x, y [, z=0 [, w=1]])` control points with `w=weight`.
|
||||
*
|
||||
* @augments Curve
|
||||
* @three_import import { NURBSCurve } from 'three/addons/curves/NURBSCurve.js';
|
||||
*/
|
||||
class NURBSCurve extends Curve {
|
||||
|
||||
/**
|
||||
* Constructs a new NURBS curve.
|
||||
*
|
||||
* @param {number} degree - The NURBS degree.
|
||||
* @param {Array<number>} knots - The knots as a flat array of numbers.
|
||||
* @param {Array<Vector2|Vector3|Vector4>} controlPoints - An array holding control points.
|
||||
* @param {number} [startKnot] - Index of the start knot into the `knots` array.
|
||||
* @param {number} [endKnot] - Index of the end knot into the `knots` array.
|
||||
*/
|
||||
constructor( degree, knots, controlPoints, startKnot, endKnot ) {
|
||||
|
||||
super();
|
||||
|
||||
const knotsLength = knots ? knots.length - 1 : 0;
|
||||
const pointsLength = controlPoints ? controlPoints.length : 0;
|
||||
|
||||
/**
|
||||
* The NURBS degree.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
this.degree = degree;
|
||||
|
||||
/**
|
||||
* The knots as a flat array of numbers.
|
||||
*
|
||||
* @type {Array<number>}
|
||||
*/
|
||||
this.knots = knots;
|
||||
|
||||
/**
|
||||
* An array of control points.
|
||||
*
|
||||
* @type {Array<Vector4>}
|
||||
*/
|
||||
this.controlPoints = [];
|
||||
|
||||
/**
|
||||
* Index of the start knot into the `knots` array.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
this.startKnot = startKnot || 0;
|
||||
|
||||
/**
|
||||
* Index of the end knot into the `knots` array.
|
||||
*
|
||||
* @type {number}
|
||||
*/
|
||||
this.endKnot = endKnot || knotsLength;
|
||||
|
||||
for ( let i = 0; i < pointsLength; ++ i ) {
|
||||
|
||||
// ensure Vector4 for control points
|
||||
const point = controlPoints[ i ];
|
||||
this.controlPoints[ i ] = new Vector4( point.x, point.y, point.z, point.w );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* This method returns a vector in 3D space for the given interpolation factor.
|
||||
*
|
||||
* @param {number} t - A interpolation factor representing a position on the curve. Must be in the range `[0,1]`.
|
||||
* @param {Vector3} [optionalTarget] - The optional target vector the result is written to.
|
||||
* @return {Vector3} The position on the curve.
|
||||
*/
|
||||
getPoint( t, optionalTarget = new Vector3() ) {
|
||||
|
||||
const point = optionalTarget;
|
||||
|
||||
const u = this.knots[ this.startKnot ] + t * ( this.knots[ this.endKnot ] - this.knots[ this.startKnot ] ); // linear mapping t->u
|
||||
|
||||
// following results in (wx, wy, wz, w) homogeneous point
|
||||
const hpoint = NURBSUtils.calcBSplinePoint( this.degree, this.knots, this.controlPoints, u );
|
||||
|
||||
if ( hpoint.w !== 1.0 ) {
|
||||
|
||||
// project to 3D space: (wx, wy, wz, w) -> (x, y, z, 1)
|
||||
hpoint.divideScalar( hpoint.w );
|
||||
|
||||
}
|
||||
|
||||
return point.set( hpoint.x, hpoint.y, hpoint.z );
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a unit vector tangent for the given interpolation factor.
|
||||
*
|
||||
* @param {number} t - The interpolation factor.
|
||||
* @param {Vector3} [optionalTarget] - The optional target vector the result is written to.
|
||||
* @return {Vector3} The tangent vector.
|
||||
*/
|
||||
getTangent( t, optionalTarget = new Vector3() ) {
|
||||
|
||||
const tangent = optionalTarget;
|
||||
|
||||
const u = this.knots[ 0 ] + t * ( this.knots[ this.knots.length - 1 ] - this.knots[ 0 ] );
|
||||
const ders = NURBSUtils.calcNURBSDerivatives( this.degree, this.knots, this.controlPoints, u, 1 );
|
||||
tangent.copy( ders[ 1 ] ).normalize();
|
||||
|
||||
return tangent;
|
||||
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
|
||||
const data = super.toJSON();
|
||||
|
||||
data.degree = this.degree;
|
||||
data.knots = [ ...this.knots ];
|
||||
data.controlPoints = this.controlPoints.map( p => p.toArray() );
|
||||
data.startKnot = this.startKnot;
|
||||
data.endKnot = this.endKnot;
|
||||
|
||||
return data;
|
||||
|
||||
}
|
||||
|
||||
fromJSON( json ) {
|
||||
|
||||
super.fromJSON( json );
|
||||
|
||||
this.degree = json.degree;
|
||||
this.knots = [ ...json.knots ];
|
||||
this.controlPoints = json.controlPoints.map( p => new Vector4( p[ 0 ], p[ 1 ], p[ 2 ], p[ 3 ] ) );
|
||||
this.startKnot = json.startKnot;
|
||||
this.endKnot = json.endKnot;
|
||||
|
||||
return this;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { NURBSCurve };
|
||||
@@ -1,535 +0,0 @@
|
||||
/* three.js r184 examples/jsm/curves/NURBSUtils.js - MIT licensed, (c) three.js authors.
|
||||
* Taken from the readable source ComfyUI ships in comfyui_frontend_package 1.51.10.
|
||||
* Only the import paths were changed, to point at the pack's vendored three.mjs. */
|
||||
import {
|
||||
Vector3,
|
||||
Vector4
|
||||
} from '../../../three.mjs';
|
||||
|
||||
/**
|
||||
* @module NURBSUtils
|
||||
* @three_import import * as NURBSUtils from 'three/addons/curves/NURBSUtils.js';
|
||||
*/
|
||||
|
||||
/**
|
||||
* Finds knot vector span.
|
||||
*
|
||||
* @param {number} p - The degree.
|
||||
* @param {number} u - The parametric value.
|
||||
* @param {Array<number>} U - The knot vector.
|
||||
* @return {number} The span.
|
||||
*/
|
||||
function findSpan( p, u, U ) {
|
||||
|
||||
const n = U.length - p - 1;
|
||||
|
||||
if ( u >= U[ n ] ) {
|
||||
|
||||
return n - 1;
|
||||
|
||||
}
|
||||
|
||||
if ( u <= U[ p ] ) {
|
||||
|
||||
return p;
|
||||
|
||||
}
|
||||
|
||||
let low = p;
|
||||
let high = n;
|
||||
let mid = Math.floor( ( low + high ) / 2 );
|
||||
|
||||
while ( u < U[ mid ] || u >= U[ mid + 1 ] ) {
|
||||
|
||||
if ( u < U[ mid ] ) {
|
||||
|
||||
high = mid;
|
||||
|
||||
} else {
|
||||
|
||||
low = mid;
|
||||
|
||||
}
|
||||
|
||||
mid = Math.floor( ( low + high ) / 2 );
|
||||
|
||||
}
|
||||
|
||||
return mid;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates basis functions. See The NURBS Book, page 70, algorithm A2.2.
|
||||
*
|
||||
* @param {number} span - The span in which `u` lies.
|
||||
* @param {number} u - The parametric value.
|
||||
* @param {number} p - The degree.
|
||||
* @param {Array<number>} U - The knot vector.
|
||||
* @return {Array<number>} Array[p+1] with basis functions values.
|
||||
*/
|
||||
function calcBasisFunctions( span, u, p, U ) {
|
||||
|
||||
const N = [];
|
||||
const left = [];
|
||||
const right = [];
|
||||
N[ 0 ] = 1.0;
|
||||
|
||||
for ( let j = 1; j <= p; ++ j ) {
|
||||
|
||||
left[ j ] = u - U[ span + 1 - j ];
|
||||
right[ j ] = U[ span + j ] - u;
|
||||
|
||||
let saved = 0.0;
|
||||
|
||||
for ( let r = 0; r < j; ++ r ) {
|
||||
|
||||
const rv = right[ r + 1 ];
|
||||
const lv = left[ j - r ];
|
||||
const temp = N[ r ] / ( rv + lv );
|
||||
N[ r ] = saved + rv * temp;
|
||||
saved = lv * temp;
|
||||
|
||||
}
|
||||
|
||||
N[ j ] = saved;
|
||||
|
||||
}
|
||||
|
||||
return N;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates B-Spline curve points. See The NURBS Book, page 82, algorithm A3.1.
|
||||
*
|
||||
* @param {number} p - The degree of the B-Spline.
|
||||
* @param {Array<number>} U - The knot vector.
|
||||
* @param {Array<Vector4>} P - The control points
|
||||
* @param {number} u - The parametric point.
|
||||
* @return {Vector4} The point for given `u`.
|
||||
*/
|
||||
function calcBSplinePoint( p, U, P, u ) {
|
||||
|
||||
const span = findSpan( p, u, U );
|
||||
const N = calcBasisFunctions( span, u, p, U );
|
||||
const C = new Vector4( 0, 0, 0, 0 );
|
||||
|
||||
for ( let j = 0; j <= p; ++ j ) {
|
||||
|
||||
const point = P[ span - p + j ];
|
||||
const Nj = N[ j ];
|
||||
const wNj = point.w * Nj;
|
||||
C.x += point.x * wNj;
|
||||
C.y += point.y * wNj;
|
||||
C.z += point.z * wNj;
|
||||
C.w += point.w * Nj;
|
||||
|
||||
}
|
||||
|
||||
return C;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates basis functions derivatives. See The NURBS Book, page 72, algorithm A2.3.
|
||||
*
|
||||
* @param {number} span - The span in which `u` lies.
|
||||
* @param {number} u - The parametric point.
|
||||
* @param {number} p - The degree.
|
||||
* @param {number} n - number of derivatives to calculate
|
||||
* @param {Array<number>} U - The knot vector.
|
||||
* @return {Array<Array<number>>} An array[n+1][p+1] with basis functions derivatives.
|
||||
*/
|
||||
function calcBasisFunctionDerivatives( span, u, p, n, U ) {
|
||||
|
||||
const zeroArr = [];
|
||||
for ( let i = 0; i <= p; ++ i )
|
||||
zeroArr[ i ] = 0.0;
|
||||
|
||||
const ders = [];
|
||||
|
||||
for ( let i = 0; i <= n; ++ i )
|
||||
ders[ i ] = zeroArr.slice( 0 );
|
||||
|
||||
const ndu = [];
|
||||
|
||||
for ( let i = 0; i <= p; ++ i )
|
||||
ndu[ i ] = zeroArr.slice( 0 );
|
||||
|
||||
ndu[ 0 ][ 0 ] = 1.0;
|
||||
|
||||
const left = zeroArr.slice( 0 );
|
||||
const right = zeroArr.slice( 0 );
|
||||
|
||||
for ( let j = 1; j <= p; ++ j ) {
|
||||
|
||||
left[ j ] = u - U[ span + 1 - j ];
|
||||
right[ j ] = U[ span + j ] - u;
|
||||
|
||||
let saved = 0.0;
|
||||
|
||||
for ( let r = 0; r < j; ++ r ) {
|
||||
|
||||
const rv = right[ r + 1 ];
|
||||
const lv = left[ j - r ];
|
||||
ndu[ j ][ r ] = rv + lv;
|
||||
|
||||
const temp = ndu[ r ][ j - 1 ] / ndu[ j ][ r ];
|
||||
ndu[ r ][ j ] = saved + rv * temp;
|
||||
saved = lv * temp;
|
||||
|
||||
}
|
||||
|
||||
ndu[ j ][ j ] = saved;
|
||||
|
||||
}
|
||||
|
||||
for ( let j = 0; j <= p; ++ j ) {
|
||||
|
||||
ders[ 0 ][ j ] = ndu[ j ][ p ];
|
||||
|
||||
}
|
||||
|
||||
for ( let r = 0; r <= p; ++ r ) {
|
||||
|
||||
let s1 = 0;
|
||||
let s2 = 1;
|
||||
|
||||
const a = [];
|
||||
for ( let i = 0; i <= p; ++ i ) {
|
||||
|
||||
a[ i ] = zeroArr.slice( 0 );
|
||||
|
||||
}
|
||||
|
||||
a[ 0 ][ 0 ] = 1.0;
|
||||
|
||||
for ( let k = 1; k <= n; ++ k ) {
|
||||
|
||||
let d = 0.0;
|
||||
const rk = r - k;
|
||||
const pk = p - k;
|
||||
|
||||
if ( r >= k ) {
|
||||
|
||||
a[ s2 ][ 0 ] = a[ s1 ][ 0 ] / ndu[ pk + 1 ][ rk ];
|
||||
d = a[ s2 ][ 0 ] * ndu[ rk ][ pk ];
|
||||
|
||||
}
|
||||
|
||||
const j1 = ( rk >= - 1 ) ? 1 : - rk;
|
||||
const j2 = ( r - 1 <= pk ) ? k - 1 : p - r;
|
||||
|
||||
for ( let j = j1; j <= j2; ++ j ) {
|
||||
|
||||
a[ s2 ][ j ] = ( a[ s1 ][ j ] - a[ s1 ][ j - 1 ] ) / ndu[ pk + 1 ][ rk + j ];
|
||||
d += a[ s2 ][ j ] * ndu[ rk + j ][ pk ];
|
||||
|
||||
}
|
||||
|
||||
if ( r <= pk ) {
|
||||
|
||||
a[ s2 ][ k ] = - a[ s1 ][ k - 1 ] / ndu[ pk + 1 ][ r ];
|
||||
d += a[ s2 ][ k ] * ndu[ r ][ pk ];
|
||||
|
||||
}
|
||||
|
||||
ders[ k ][ r ] = d;
|
||||
|
||||
const j = s1;
|
||||
s1 = s2;
|
||||
s2 = j;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
let r = p;
|
||||
|
||||
for ( let k = 1; k <= n; ++ k ) {
|
||||
|
||||
for ( let j = 0; j <= p; ++ j ) {
|
||||
|
||||
ders[ k ][ j ] *= r;
|
||||
|
||||
}
|
||||
|
||||
r *= p - k;
|
||||
|
||||
}
|
||||
|
||||
return ders;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates derivatives of a B-Spline. See The NURBS Book, page 93, algorithm A3.2.
|
||||
*
|
||||
* @param {number} p - The degree.
|
||||
* @param {Array<number>} U - The knot vector.
|
||||
* @param {Array<Vector4>} P - The control points
|
||||
* @param {number} u - The parametric point.
|
||||
* @param {number} nd - The number of derivatives.
|
||||
* @return {Array<Vector4>} An array[d+1] with derivatives.
|
||||
*/
|
||||
function calcBSplineDerivatives( p, U, P, u, nd ) {
|
||||
|
||||
const du = nd < p ? nd : p;
|
||||
const CK = [];
|
||||
const span = findSpan( p, u, U );
|
||||
const nders = calcBasisFunctionDerivatives( span, u, p, du, U );
|
||||
const Pw = [];
|
||||
|
||||
for ( let i = 0; i < P.length; ++ i ) {
|
||||
|
||||
const point = P[ i ].clone();
|
||||
const w = point.w;
|
||||
|
||||
point.x *= w;
|
||||
point.y *= w;
|
||||
point.z *= w;
|
||||
|
||||
Pw[ i ] = point;
|
||||
|
||||
}
|
||||
|
||||
for ( let k = 0; k <= du; ++ k ) {
|
||||
|
||||
const point = Pw[ span - p ].clone().multiplyScalar( nders[ k ][ 0 ] );
|
||||
|
||||
for ( let j = 1; j <= p; ++ j ) {
|
||||
|
||||
point.add( Pw[ span - p + j ].clone().multiplyScalar( nders[ k ][ j ] ) );
|
||||
|
||||
}
|
||||
|
||||
CK[ k ] = point;
|
||||
|
||||
}
|
||||
|
||||
for ( let k = du + 1; k <= nd + 1; ++ k ) {
|
||||
|
||||
CK[ k ] = new Vector4( 0, 0, 0 );
|
||||
|
||||
}
|
||||
|
||||
return CK;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates "K over I".
|
||||
*
|
||||
* @param {number} k - The K value.
|
||||
* @param {number} i - The I value.
|
||||
* @return {number} k!/(i!(k-i)!)
|
||||
*/
|
||||
function calcKoverI( k, i ) {
|
||||
|
||||
let nom = 1;
|
||||
|
||||
for ( let j = 2; j <= k; ++ j ) {
|
||||
|
||||
nom *= j;
|
||||
|
||||
}
|
||||
|
||||
let denom = 1;
|
||||
|
||||
for ( let j = 2; j <= i; ++ j ) {
|
||||
|
||||
denom *= j;
|
||||
|
||||
}
|
||||
|
||||
for ( let j = 2; j <= k - i; ++ j ) {
|
||||
|
||||
denom *= j;
|
||||
|
||||
}
|
||||
|
||||
return nom / denom;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates derivatives (0-nd) of rational curve. See The NURBS Book, page 127, algorithm A4.2.
|
||||
*
|
||||
* @param {Array<Vector4>} Pders - Array with derivatives.
|
||||
* @return {Array<Vector3>} An array with derivatives for rational curve.
|
||||
*/
|
||||
function calcRationalCurveDerivatives( Pders ) {
|
||||
|
||||
const nd = Pders.length;
|
||||
const Aders = [];
|
||||
const wders = [];
|
||||
|
||||
for ( let i = 0; i < nd; ++ i ) {
|
||||
|
||||
const point = Pders[ i ];
|
||||
Aders[ i ] = new Vector3( point.x, point.y, point.z );
|
||||
wders[ i ] = point.w;
|
||||
|
||||
}
|
||||
|
||||
const CK = [];
|
||||
|
||||
for ( let k = 0; k < nd; ++ k ) {
|
||||
|
||||
const v = Aders[ k ].clone();
|
||||
|
||||
for ( let i = 1; i <= k; ++ i ) {
|
||||
|
||||
v.sub( CK[ k - i ].clone().multiplyScalar( calcKoverI( k, i ) * wders[ i ] ) );
|
||||
|
||||
}
|
||||
|
||||
CK[ k ] = v.divideScalar( wders[ 0 ] );
|
||||
|
||||
}
|
||||
|
||||
return CK;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates NURBS curve derivatives. See The NURBS Book, page 127, algorithm A4.2.
|
||||
*
|
||||
* @param {number} p - The degree.
|
||||
* @param {Array<number>} U - The knot vector.
|
||||
* @param {Array<Vector4>} P - The control points in homogeneous space.
|
||||
* @param {number} u - The parametric point.
|
||||
* @param {number} nd - The number of derivatives.
|
||||
* @return {Array<Vector3>} array with derivatives for rational curve.
|
||||
*/
|
||||
function calcNURBSDerivatives( p, U, P, u, nd ) {
|
||||
|
||||
const Pders = calcBSplineDerivatives( p, U, P, u, nd );
|
||||
return calcRationalCurveDerivatives( Pders );
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a rational B-Spline surface point. See The NURBS Book, page 134, algorithm A4.3.
|
||||
*
|
||||
* @param {number} p - The first degree of B-Spline surface.
|
||||
* @param {number} q - The second degree of B-Spline surface.
|
||||
* @param {Array<number>} U - The first knot vector.
|
||||
* @param {Array<number>} V - The second knot vector.
|
||||
* @param {Array<Array<Vector4>>} P - The control points in homogeneous space.
|
||||
* @param {number} u - The first parametric point.
|
||||
* @param {number} v - The second parametric point.
|
||||
* @param {Vector3} target - The target vector.
|
||||
*/
|
||||
function calcSurfacePoint( p, q, U, V, P, u, v, target ) {
|
||||
|
||||
const uspan = findSpan( p, u, U );
|
||||
const vspan = findSpan( q, v, V );
|
||||
const Nu = calcBasisFunctions( uspan, u, p, U );
|
||||
const Nv = calcBasisFunctions( vspan, v, q, V );
|
||||
const temp = [];
|
||||
|
||||
for ( let l = 0; l <= q; ++ l ) {
|
||||
|
||||
temp[ l ] = new Vector4( 0, 0, 0, 0 );
|
||||
for ( let k = 0; k <= p; ++ k ) {
|
||||
|
||||
const point = P[ uspan - p + k ][ vspan - q + l ].clone();
|
||||
const w = point.w;
|
||||
point.x *= w;
|
||||
point.y *= w;
|
||||
point.z *= w;
|
||||
temp[ l ].add( point.multiplyScalar( Nu[ k ] ) );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const Sw = new Vector4( 0, 0, 0, 0 );
|
||||
for ( let l = 0; l <= q; ++ l ) {
|
||||
|
||||
Sw.add( temp[ l ].multiplyScalar( Nv[ l ] ) );
|
||||
|
||||
}
|
||||
|
||||
Sw.divideScalar( Sw.w );
|
||||
target.set( Sw.x, Sw.y, Sw.z );
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a rational B-Spline volume point. See The NURBS Book, page 134, algorithm A4.3.
|
||||
*
|
||||
* @param {number} p - The first degree of B-Spline surface.
|
||||
* @param {number} q - The second degree of B-Spline surface.
|
||||
* @param {number} r - The third degree of B-Spline surface.
|
||||
* @param {Array<number>} U - The first knot vector.
|
||||
* @param {Array<number>} V - The second knot vector.
|
||||
* @param {Array<number>} W - The third knot vector.
|
||||
* @param {Array<Array<Array<Vector4>>>} P - The control points in homogeneous space.
|
||||
* @param {number} u - The first parametric point.
|
||||
* @param {number} v - The second parametric point.
|
||||
* @param {number} w - The third parametric point.
|
||||
* @param {Vector3} target - The target vector.
|
||||
*/
|
||||
function calcVolumePoint( p, q, r, U, V, W, P, u, v, w, target ) {
|
||||
|
||||
const uspan = findSpan( p, u, U );
|
||||
const vspan = findSpan( q, v, V );
|
||||
const wspan = findSpan( r, w, W );
|
||||
const Nu = calcBasisFunctions( uspan, u, p, U );
|
||||
const Nv = calcBasisFunctions( vspan, v, q, V );
|
||||
const Nw = calcBasisFunctions( wspan, w, r, W );
|
||||
const temp = [];
|
||||
|
||||
for ( let m = 0; m <= r; ++ m ) {
|
||||
|
||||
temp[ m ] = [];
|
||||
|
||||
for ( let l = 0; l <= q; ++ l ) {
|
||||
|
||||
temp[ m ][ l ] = new Vector4( 0, 0, 0, 0 );
|
||||
for ( let k = 0; k <= p; ++ k ) {
|
||||
|
||||
const point = P[ uspan - p + k ][ vspan - q + l ][ wspan - r + m ].clone();
|
||||
const w = point.w;
|
||||
point.x *= w;
|
||||
point.y *= w;
|
||||
point.z *= w;
|
||||
temp[ m ][ l ].add( point.multiplyScalar( Nu[ k ] ) );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const Sw = new Vector4( 0, 0, 0, 0 );
|
||||
for ( let m = 0; m <= r; ++ m ) {
|
||||
|
||||
for ( let l = 0; l <= q; ++ l ) {
|
||||
|
||||
Sw.add( temp[ m ][ l ].multiplyScalar( Nw[ m ] ).multiplyScalar( Nv[ l ] ) );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Sw.divideScalar( Sw.w );
|
||||
target.set( Sw.x, Sw.y, Sw.z );
|
||||
|
||||
}
|
||||
|
||||
export {
|
||||
findSpan,
|
||||
calcBasisFunctions,
|
||||
calcBSplinePoint,
|
||||
calcBasisFunctionDerivatives,
|
||||
calcBSplineDerivatives,
|
||||
calcKoverI,
|
||||
calcRationalCurveDerivatives,
|
||||
calcNURBSDerivatives,
|
||||
calcSurfacePoint,
|
||||
calcVolumePoint,
|
||||
};
|
||||
@@ -1,808 +0,0 @@
|
||||
/* three.js r184 examples/jsm/loaders/PLYLoader.js - MIT licensed, (c) three.js authors.
|
||||
* Taken from the readable source ComfyUI ships in comfyui_frontend_package 1.51.10.
|
||||
* Only the import paths were changed, to point at the pack's vendored three.mjs. */
|
||||
import {
|
||||
BufferGeometry,
|
||||
FileLoader,
|
||||
Float32BufferAttribute,
|
||||
Loader,
|
||||
Color,
|
||||
SRGBColorSpace
|
||||
} from '../../../three.mjs';
|
||||
|
||||
const _color = new Color();
|
||||
|
||||
/**
|
||||
* A loader for PLY the PLY format (known as the Polygon
|
||||
* File Format or the Stanford Triangle Format).
|
||||
*
|
||||
* Limitations:
|
||||
* - ASCII decoding assumes file is UTF-8.
|
||||
*
|
||||
* ```js
|
||||
* const loader = new PLYLoader();
|
||||
* const geometry = await loader.loadAsync( './models/ply/ascii/dolphins.ply' );
|
||||
* scene.add( new THREE.Mesh( geometry ) );
|
||||
* ```
|
||||
*
|
||||
* @augments Loader
|
||||
* @three_import import { PLYLoader } from 'three/addons/loaders/PLYLoader.js';
|
||||
*/
|
||||
class PLYLoader extends Loader {
|
||||
|
||||
/**
|
||||
* Constructs a new PLY loader.
|
||||
*
|
||||
* @param {LoadingManager} [manager] - The loading manager.
|
||||
*/
|
||||
constructor( manager ) {
|
||||
|
||||
super( manager );
|
||||
|
||||
// internals
|
||||
|
||||
this.propertyNameMapping = {};
|
||||
this.customPropertyMapping = {};
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts loading from the given URL and passes the loaded PLY asset
|
||||
* to the `onLoad()` callback.
|
||||
*
|
||||
* @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
|
||||
* @param {function(BufferGeometry)} onLoad - Executed when the loading process has been finished.
|
||||
* @param {onProgressCallback} onProgress - Executed while the loading is in progress.
|
||||
* @param {onErrorCallback} onError - Executed when errors occur.
|
||||
*/
|
||||
load( url, onLoad, onProgress, onError ) {
|
||||
|
||||
const scope = this;
|
||||
|
||||
const loader = new FileLoader( this.manager );
|
||||
loader.setPath( this.path );
|
||||
loader.setResponseType( 'arraybuffer' );
|
||||
loader.setRequestHeader( this.requestHeader );
|
||||
loader.setWithCredentials( this.withCredentials );
|
||||
loader.load( url, function ( text ) {
|
||||
|
||||
try {
|
||||
|
||||
onLoad( scope.parse( text ) );
|
||||
|
||||
} catch ( e ) {
|
||||
|
||||
if ( onError ) {
|
||||
|
||||
onError( e );
|
||||
|
||||
} else {
|
||||
|
||||
console.error( e );
|
||||
|
||||
}
|
||||
|
||||
scope.manager.itemError( url );
|
||||
|
||||
}
|
||||
|
||||
}, onProgress, onError );
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a property name mapping that maps default property names
|
||||
* to custom ones. For example, the following maps the properties
|
||||
* “diffuse_(red|green|blue)” in the file to standard color names.
|
||||
*
|
||||
* ```js
|
||||
* loader.setPropertyNameMapping( {
|
||||
* diffuse_red: 'red',
|
||||
* diffuse_green: 'green',
|
||||
* diffuse_blue: 'blue'
|
||||
* } );
|
||||
* ```
|
||||
*
|
||||
* @param {Object} mapping - The mapping dictionary.
|
||||
*/
|
||||
setPropertyNameMapping( mapping ) {
|
||||
|
||||
this.propertyNameMapping = mapping;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom properties outside of the defaults for position, uv, normal
|
||||
* and color attributes can be added using the setCustomPropertyNameMapping method.
|
||||
* For example, the following maps the element properties “custom_property_a”
|
||||
* and “custom_property_b” to an attribute “customAttribute” with an item size of 2.
|
||||
* Attribute item sizes are set from the number of element properties in the property array.
|
||||
*
|
||||
* ```js
|
||||
* loader.setCustomPropertyNameMapping( {
|
||||
* customAttribute: ['custom_property_a', 'custom_property_b'],
|
||||
* } );
|
||||
* ```
|
||||
* @param {Object} mapping - The mapping dictionary.
|
||||
*/
|
||||
setCustomPropertyNameMapping( mapping ) {
|
||||
|
||||
this.customPropertyMapping = mapping;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the given PLY data and returns the resulting geometry.
|
||||
*
|
||||
* @param {ArrayBuffer} data - The raw PLY data as an array buffer.
|
||||
* @return {BufferGeometry} The parsed geometry.
|
||||
*/
|
||||
parse( data ) {
|
||||
|
||||
function parseHeader( data, headerLength = 0 ) {
|
||||
|
||||
const patternHeader = /^ply([\s\S]*)end_header(\r\n|\r|\n)/;
|
||||
let headerText = '';
|
||||
const result = patternHeader.exec( data );
|
||||
|
||||
if ( result !== null ) {
|
||||
|
||||
headerText = result[ 1 ];
|
||||
|
||||
}
|
||||
|
||||
const header = {
|
||||
comments: [],
|
||||
elements: [],
|
||||
headerLength: headerLength,
|
||||
objInfo: ''
|
||||
};
|
||||
|
||||
const lines = headerText.split( /\r\n|\r|\n/ );
|
||||
let currentElement;
|
||||
|
||||
function make_ply_element_property( propertyValues, propertyNameMapping ) {
|
||||
|
||||
const property = { type: propertyValues[ 0 ] };
|
||||
|
||||
if ( property.type === 'list' ) {
|
||||
|
||||
property.name = propertyValues[ 3 ];
|
||||
property.countType = propertyValues[ 1 ];
|
||||
property.itemType = propertyValues[ 2 ];
|
||||
|
||||
} else {
|
||||
|
||||
property.name = propertyValues[ 1 ];
|
||||
|
||||
}
|
||||
|
||||
if ( property.name in propertyNameMapping ) {
|
||||
|
||||
property.name = propertyNameMapping[ property.name ];
|
||||
|
||||
}
|
||||
|
||||
return property;
|
||||
|
||||
}
|
||||
|
||||
for ( let i = 0; i < lines.length; i ++ ) {
|
||||
|
||||
let line = lines[ i ];
|
||||
line = line.trim();
|
||||
|
||||
if ( line === '' ) continue;
|
||||
|
||||
const lineValues = line.split( /\s+/ );
|
||||
const lineType = lineValues.shift();
|
||||
line = lineValues.join( ' ' );
|
||||
|
||||
switch ( lineType ) {
|
||||
|
||||
case 'format':
|
||||
|
||||
header.format = lineValues[ 0 ];
|
||||
header.version = lineValues[ 1 ];
|
||||
|
||||
break;
|
||||
|
||||
case 'comment':
|
||||
|
||||
header.comments.push( line );
|
||||
|
||||
break;
|
||||
|
||||
case 'element':
|
||||
|
||||
if ( currentElement !== undefined ) {
|
||||
|
||||
header.elements.push( currentElement );
|
||||
|
||||
}
|
||||
|
||||
currentElement = {};
|
||||
currentElement.name = lineValues[ 0 ];
|
||||
currentElement.count = parseInt( lineValues[ 1 ] );
|
||||
currentElement.properties = [];
|
||||
|
||||
break;
|
||||
|
||||
case 'property':
|
||||
|
||||
currentElement.properties.push( make_ply_element_property( lineValues, scope.propertyNameMapping ) );
|
||||
|
||||
break;
|
||||
|
||||
case 'obj_info':
|
||||
|
||||
header.objInfo = line;
|
||||
|
||||
break;
|
||||
|
||||
|
||||
default:
|
||||
|
||||
console.log( 'unhandled', lineType, lineValues );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if ( currentElement !== undefined ) {
|
||||
|
||||
header.elements.push( currentElement );
|
||||
|
||||
}
|
||||
|
||||
return header;
|
||||
|
||||
}
|
||||
|
||||
function parseASCIINumber( n, type ) {
|
||||
|
||||
switch ( type ) {
|
||||
|
||||
case 'char': case 'uchar': case 'short': case 'ushort': case 'int': case 'uint':
|
||||
case 'int8': case 'uint8': case 'int16': case 'uint16': case 'int32': case 'uint32':
|
||||
|
||||
return parseInt( n );
|
||||
|
||||
case 'float': case 'double': case 'float32': case 'float64':
|
||||
|
||||
return parseFloat( n );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function parseASCIIElement( properties, tokens ) {
|
||||
|
||||
const element = {};
|
||||
|
||||
for ( let i = 0; i < properties.length; i ++ ) {
|
||||
|
||||
if ( tokens.empty() ) return null;
|
||||
|
||||
if ( properties[ i ].type === 'list' ) {
|
||||
|
||||
const list = [];
|
||||
const n = parseASCIINumber( tokens.next(), properties[ i ].countType );
|
||||
|
||||
for ( let j = 0; j < n; j ++ ) {
|
||||
|
||||
if ( tokens.empty() ) return null;
|
||||
|
||||
list.push( parseASCIINumber( tokens.next(), properties[ i ].itemType ) );
|
||||
|
||||
}
|
||||
|
||||
element[ properties[ i ].name ] = list;
|
||||
|
||||
} else {
|
||||
|
||||
element[ properties[ i ].name ] = parseASCIINumber( tokens.next(), properties[ i ].type );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return element;
|
||||
|
||||
}
|
||||
|
||||
function createBuffer() {
|
||||
|
||||
const buffer = {
|
||||
indices: [],
|
||||
vertices: [],
|
||||
normals: [],
|
||||
uvs: [],
|
||||
faceVertexUvs: [],
|
||||
colors: [],
|
||||
faceVertexColors: []
|
||||
};
|
||||
|
||||
for ( const customProperty of Object.keys( scope.customPropertyMapping ) ) {
|
||||
|
||||
buffer[ customProperty ] = [];
|
||||
|
||||
}
|
||||
|
||||
return buffer;
|
||||
|
||||
}
|
||||
|
||||
function mapElementAttributes( properties ) {
|
||||
|
||||
const elementNames = properties.map( property => {
|
||||
|
||||
return property.name;
|
||||
|
||||
} );
|
||||
|
||||
function findAttrName( names ) {
|
||||
|
||||
for ( let i = 0, l = names.length; i < l; i ++ ) {
|
||||
|
||||
const name = names[ i ];
|
||||
|
||||
if ( elementNames.includes( name ) ) return name;
|
||||
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
return {
|
||||
attrX: findAttrName( [ 'x', 'px', 'posx' ] ) || 'x',
|
||||
attrY: findAttrName( [ 'y', 'py', 'posy' ] ) || 'y',
|
||||
attrZ: findAttrName( [ 'z', 'pz', 'posz' ] ) || 'z',
|
||||
attrNX: findAttrName( [ 'nx', 'normalx' ] ),
|
||||
attrNY: findAttrName( [ 'ny', 'normaly' ] ),
|
||||
attrNZ: findAttrName( [ 'nz', 'normalz' ] ),
|
||||
attrS: findAttrName( [ 's', 'u', 'texture_u', 'tx' ] ),
|
||||
attrT: findAttrName( [ 't', 'v', 'texture_v', 'ty' ] ),
|
||||
attrR: findAttrName( [ 'red', 'diffuse_red', 'r', 'diffuse_r' ] ),
|
||||
attrG: findAttrName( [ 'green', 'diffuse_green', 'g', 'diffuse_g' ] ),
|
||||
attrB: findAttrName( [ 'blue', 'diffuse_blue', 'b', 'diffuse_b' ] ),
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
function parseASCII( data, header ) {
|
||||
|
||||
// PLY ascii format specification, as per http://en.wikipedia.org/wiki/PLY_(file_format)
|
||||
|
||||
const buffer = createBuffer();
|
||||
|
||||
const patternBody = /end_header\s+(\S[\s\S]*\S|\S)\s*$/;
|
||||
let body, matches;
|
||||
|
||||
if ( ( matches = patternBody.exec( data ) ) !== null ) {
|
||||
|
||||
body = matches[ 1 ].split( /\s+/ );
|
||||
|
||||
} else {
|
||||
|
||||
body = [ ];
|
||||
|
||||
}
|
||||
|
||||
const tokens = new ArrayStream( body );
|
||||
|
||||
loop: for ( let i = 0; i < header.elements.length; i ++ ) {
|
||||
|
||||
const elementDesc = header.elements[ i ];
|
||||
const attributeMap = mapElementAttributes( elementDesc.properties );
|
||||
|
||||
for ( let j = 0; j < elementDesc.count; j ++ ) {
|
||||
|
||||
const element = parseASCIIElement( elementDesc.properties, tokens );
|
||||
|
||||
if ( ! element ) break loop;
|
||||
|
||||
handleElement( buffer, elementDesc.name, element, attributeMap );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return postProcess( buffer );
|
||||
|
||||
}
|
||||
|
||||
function postProcess( buffer ) {
|
||||
|
||||
let geometry = new BufferGeometry();
|
||||
|
||||
// mandatory buffer data
|
||||
|
||||
if ( buffer.indices.length > 0 ) {
|
||||
|
||||
geometry.setIndex( buffer.indices );
|
||||
|
||||
}
|
||||
|
||||
geometry.setAttribute( 'position', new Float32BufferAttribute( buffer.vertices, 3 ) );
|
||||
|
||||
// optional buffer data
|
||||
|
||||
if ( buffer.normals.length > 0 ) {
|
||||
|
||||
geometry.setAttribute( 'normal', new Float32BufferAttribute( buffer.normals, 3 ) );
|
||||
|
||||
}
|
||||
|
||||
if ( buffer.uvs.length > 0 ) {
|
||||
|
||||
geometry.setAttribute( 'uv', new Float32BufferAttribute( buffer.uvs, 2 ) );
|
||||
|
||||
}
|
||||
|
||||
if ( buffer.colors.length > 0 ) {
|
||||
|
||||
geometry.setAttribute( 'color', new Float32BufferAttribute( buffer.colors, 3 ) );
|
||||
|
||||
}
|
||||
|
||||
if ( buffer.faceVertexUvs.length > 0 || buffer.faceVertexColors.length > 0 ) {
|
||||
|
||||
geometry = geometry.toNonIndexed();
|
||||
|
||||
if ( buffer.faceVertexUvs.length > 0 ) geometry.setAttribute( 'uv', new Float32BufferAttribute( buffer.faceVertexUvs, 2 ) );
|
||||
if ( buffer.faceVertexColors.length > 0 ) geometry.setAttribute( 'color', new Float32BufferAttribute( buffer.faceVertexColors, 3 ) );
|
||||
|
||||
}
|
||||
|
||||
// custom buffer data
|
||||
|
||||
for ( const customProperty of Object.keys( scope.customPropertyMapping ) ) {
|
||||
|
||||
if ( buffer[ customProperty ].length > 0 ) {
|
||||
|
||||
geometry.setAttribute(
|
||||
customProperty,
|
||||
new Float32BufferAttribute(
|
||||
buffer[ customProperty ],
|
||||
scope.customPropertyMapping[ customProperty ].length
|
||||
)
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
geometry.computeBoundingSphere();
|
||||
|
||||
return geometry;
|
||||
|
||||
}
|
||||
|
||||
function handleElement( buffer, elementName, element, cacheEntry ) {
|
||||
|
||||
if ( elementName === 'vertex' ) {
|
||||
|
||||
buffer.vertices.push( element[ cacheEntry.attrX ], element[ cacheEntry.attrY ], element[ cacheEntry.attrZ ] );
|
||||
|
||||
if ( cacheEntry.attrNX !== null && cacheEntry.attrNY !== null && cacheEntry.attrNZ !== null ) {
|
||||
|
||||
buffer.normals.push( element[ cacheEntry.attrNX ], element[ cacheEntry.attrNY ], element[ cacheEntry.attrNZ ] );
|
||||
|
||||
}
|
||||
|
||||
if ( cacheEntry.attrS !== null && cacheEntry.attrT !== null ) {
|
||||
|
||||
buffer.uvs.push( element[ cacheEntry.attrS ], element[ cacheEntry.attrT ] );
|
||||
|
||||
}
|
||||
|
||||
if ( cacheEntry.attrR !== null && cacheEntry.attrG !== null && cacheEntry.attrB !== null ) {
|
||||
|
||||
_color.setRGB(
|
||||
element[ cacheEntry.attrR ] / 255.0,
|
||||
element[ cacheEntry.attrG ] / 255.0,
|
||||
element[ cacheEntry.attrB ] / 255.0,
|
||||
SRGBColorSpace
|
||||
);
|
||||
|
||||
buffer.colors.push( _color.r, _color.g, _color.b );
|
||||
|
||||
}
|
||||
|
||||
for ( const customProperty of Object.keys( scope.customPropertyMapping ) ) {
|
||||
|
||||
for ( const elementProperty of scope.customPropertyMapping[ customProperty ] ) {
|
||||
|
||||
buffer[ customProperty ].push( element[ elementProperty ] );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} else if ( elementName === 'face' ) {
|
||||
|
||||
const vertex_indices = element.vertex_indices || element.vertex_index; // issue #9338
|
||||
const texcoord = element.texcoord;
|
||||
|
||||
if ( vertex_indices.length === 3 ) {
|
||||
|
||||
buffer.indices.push( vertex_indices[ 0 ], vertex_indices[ 1 ], vertex_indices[ 2 ] );
|
||||
|
||||
if ( texcoord && texcoord.length === 6 ) {
|
||||
|
||||
buffer.faceVertexUvs.push( texcoord[ 0 ], texcoord[ 1 ] );
|
||||
buffer.faceVertexUvs.push( texcoord[ 2 ], texcoord[ 3 ] );
|
||||
buffer.faceVertexUvs.push( texcoord[ 4 ], texcoord[ 5 ] );
|
||||
|
||||
}
|
||||
|
||||
} else if ( vertex_indices.length === 4 ) {
|
||||
|
||||
buffer.indices.push( vertex_indices[ 0 ], vertex_indices[ 1 ], vertex_indices[ 3 ] );
|
||||
buffer.indices.push( vertex_indices[ 1 ], vertex_indices[ 2 ], vertex_indices[ 3 ] );
|
||||
|
||||
}
|
||||
|
||||
// face colors
|
||||
|
||||
if ( cacheEntry.attrR !== null && cacheEntry.attrG !== null && cacheEntry.attrB !== null ) {
|
||||
|
||||
_color.setRGB(
|
||||
element[ cacheEntry.attrR ] / 255.0,
|
||||
element[ cacheEntry.attrG ] / 255.0,
|
||||
element[ cacheEntry.attrB ] / 255.0,
|
||||
SRGBColorSpace
|
||||
);
|
||||
buffer.faceVertexColors.push( _color.r, _color.g, _color.b );
|
||||
buffer.faceVertexColors.push( _color.r, _color.g, _color.b );
|
||||
buffer.faceVertexColors.push( _color.r, _color.g, _color.b );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function binaryReadElement( at, properties ) {
|
||||
|
||||
const element = {};
|
||||
let read = 0;
|
||||
|
||||
for ( let i = 0; i < properties.length; i ++ ) {
|
||||
|
||||
const property = properties[ i ];
|
||||
const valueReader = property.valueReader;
|
||||
|
||||
if ( property.type === 'list' ) {
|
||||
|
||||
const list = [];
|
||||
|
||||
const n = property.countReader.read( at + read );
|
||||
read += property.countReader.size;
|
||||
|
||||
for ( let j = 0; j < n; j ++ ) {
|
||||
|
||||
list.push( valueReader.read( at + read ) );
|
||||
read += valueReader.size;
|
||||
|
||||
}
|
||||
|
||||
element[ property.name ] = list;
|
||||
|
||||
} else {
|
||||
|
||||
element[ property.name ] = valueReader.read( at + read );
|
||||
read += valueReader.size;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return [ element, read ];
|
||||
|
||||
}
|
||||
|
||||
function setPropertyBinaryReaders( properties, body, little_endian ) {
|
||||
|
||||
function getBinaryReader( dataview, type, little_endian ) {
|
||||
|
||||
switch ( type ) {
|
||||
|
||||
// correspondences for non-specific length types here match rply:
|
||||
case 'int8': case 'char': return { read: ( at ) => {
|
||||
|
||||
return dataview.getInt8( at );
|
||||
|
||||
}, size: 1 };
|
||||
case 'uint8': case 'uchar': return { read: ( at ) => {
|
||||
|
||||
return dataview.getUint8( at );
|
||||
|
||||
}, size: 1 };
|
||||
case 'int16': case 'short': return { read: ( at ) => {
|
||||
|
||||
return dataview.getInt16( at, little_endian );
|
||||
|
||||
}, size: 2 };
|
||||
case 'uint16': case 'ushort': return { read: ( at ) => {
|
||||
|
||||
return dataview.getUint16( at, little_endian );
|
||||
|
||||
}, size: 2 };
|
||||
case 'int32': case 'int': return { read: ( at ) => {
|
||||
|
||||
return dataview.getInt32( at, little_endian );
|
||||
|
||||
}, size: 4 };
|
||||
case 'uint32': case 'uint': return { read: ( at ) => {
|
||||
|
||||
return dataview.getUint32( at, little_endian );
|
||||
|
||||
}, size: 4 };
|
||||
case 'float32': case 'float': return { read: ( at ) => {
|
||||
|
||||
return dataview.getFloat32( at, little_endian );
|
||||
|
||||
}, size: 4 };
|
||||
case 'float64': case 'double': return { read: ( at ) => {
|
||||
|
||||
return dataview.getFloat64( at, little_endian );
|
||||
|
||||
}, size: 8 };
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
for ( let i = 0, l = properties.length; i < l; i ++ ) {
|
||||
|
||||
const property = properties[ i ];
|
||||
|
||||
if ( property.type === 'list' ) {
|
||||
|
||||
property.countReader = getBinaryReader( body, property.countType, little_endian );
|
||||
property.valueReader = getBinaryReader( body, property.itemType, little_endian );
|
||||
|
||||
} else {
|
||||
|
||||
property.valueReader = getBinaryReader( body, property.type, little_endian );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function parseBinary( data, header ) {
|
||||
|
||||
const buffer = createBuffer();
|
||||
|
||||
const little_endian = ( header.format === 'binary_little_endian' );
|
||||
const body = new DataView( data, header.headerLength );
|
||||
let result, loc = 0;
|
||||
|
||||
for ( let currentElement = 0; currentElement < header.elements.length; currentElement ++ ) {
|
||||
|
||||
const elementDesc = header.elements[ currentElement ];
|
||||
const properties = elementDesc.properties;
|
||||
const attributeMap = mapElementAttributes( properties );
|
||||
|
||||
setPropertyBinaryReaders( properties, body, little_endian );
|
||||
|
||||
for ( let currentElementCount = 0; currentElementCount < elementDesc.count; currentElementCount ++ ) {
|
||||
|
||||
result = binaryReadElement( loc, properties );
|
||||
loc += result[ 1 ];
|
||||
const element = result[ 0 ];
|
||||
|
||||
handleElement( buffer, elementDesc.name, element, attributeMap );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return postProcess( buffer );
|
||||
|
||||
}
|
||||
|
||||
function extractHeaderText( bytes ) {
|
||||
|
||||
let i = 0;
|
||||
let cont = true;
|
||||
|
||||
let line = '';
|
||||
const lines = [];
|
||||
|
||||
const startLine = new TextDecoder().decode( bytes.subarray( 0, 5 ) );
|
||||
const hasCRNL = /^ply\r\n/.test( startLine );
|
||||
|
||||
do {
|
||||
|
||||
const c = String.fromCharCode( bytes[ i ++ ] );
|
||||
|
||||
if ( c !== '\n' && c !== '\r' ) {
|
||||
|
||||
line += c;
|
||||
|
||||
} else {
|
||||
|
||||
if ( line === 'end_header' ) cont = false;
|
||||
if ( line !== '' ) {
|
||||
|
||||
lines.push( line );
|
||||
line = '';
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
} while ( cont && i < bytes.length );
|
||||
|
||||
// ascii section using \r\n as line endings
|
||||
if ( hasCRNL === true ) i ++;
|
||||
|
||||
return { headerText: lines.join( '\r' ) + '\r', headerLength: i };
|
||||
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
let geometry;
|
||||
const scope = this;
|
||||
|
||||
if ( data instanceof ArrayBuffer ) {
|
||||
|
||||
const bytes = new Uint8Array( data );
|
||||
const { headerText, headerLength } = extractHeaderText( bytes );
|
||||
const header = parseHeader( headerText, headerLength );
|
||||
|
||||
if ( header.format === 'ascii' ) {
|
||||
|
||||
const text = new TextDecoder().decode( bytes );
|
||||
|
||||
geometry = parseASCII( text, header );
|
||||
|
||||
} else {
|
||||
|
||||
geometry = parseBinary( data, header );
|
||||
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
geometry = parseASCII( data, parseHeader( data ) );
|
||||
|
||||
}
|
||||
|
||||
return geometry;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ArrayStream {
|
||||
|
||||
constructor( arr ) {
|
||||
|
||||
this.arr = arr;
|
||||
this.i = 0;
|
||||
|
||||
}
|
||||
|
||||
empty() {
|
||||
|
||||
return this.i >= this.arr.length;
|
||||
|
||||
}
|
||||
|
||||
next() {
|
||||
|
||||
return this.arr[ this.i ++ ];
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { PLYLoader };
|
||||
@@ -1,424 +0,0 @@
|
||||
/* three.js r184 examples/jsm/loaders/STLLoader.js - MIT licensed, (c) three.js authors.
|
||||
* Taken from the readable source ComfyUI ships in comfyui_frontend_package 1.51.10.
|
||||
* Only the import paths were changed, to point at the pack's vendored three.mjs. */
|
||||
import {
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
Color,
|
||||
FileLoader,
|
||||
Float32BufferAttribute,
|
||||
Loader,
|
||||
Vector3,
|
||||
SRGBColorSpace
|
||||
} from '../../../three.mjs';
|
||||
|
||||
/**
|
||||
* A loader for the STL format, as created by Solidworks and other CAD programs.
|
||||
*
|
||||
* Supports both binary and ASCII encoded files. The loader returns a non-indexed buffer geometry.
|
||||
*
|
||||
* Limitations:
|
||||
* - Binary decoding supports "Magics" color format (http://en.wikipedia.org/wiki/STL_(file_format)#Color_in_binary_STL).
|
||||
* - There is perhaps some question as to how valid it is to always assume little-endian-ness.
|
||||
* - ASCII decoding assumes file is UTF-8.
|
||||
*
|
||||
* ```js
|
||||
* const loader = new STLLoader();
|
||||
* const geometry = await loader.loadAsync( './models/stl/slotted_disk.stl' )
|
||||
* scene.add( new THREE.Mesh( geometry ) );
|
||||
* ```
|
||||
* For binary STLs geometry might contain colors for vertices. To use it:
|
||||
* ```js
|
||||
* // use the same code to load STL as above
|
||||
* if ( geometry.hasColors ) {
|
||||
* material = new THREE.MeshPhongMaterial( { opacity: geometry.alpha, vertexColors: true } );
|
||||
* }
|
||||
* const mesh = new THREE.Mesh( geometry, material );
|
||||
* ```
|
||||
* For ASCII STLs containing multiple solids, each solid is assigned to a different group.
|
||||
* Groups can be used to assign a different color by defining an array of materials with the same length of
|
||||
* geometry.groups and passing it to the Mesh constructor:
|
||||
*
|
||||
* ```js
|
||||
* const materials = [];
|
||||
* const nGeometryGroups = geometry.groups.length;
|
||||
*
|
||||
* for ( let i = 0; i < nGeometryGroups; i ++ ) {
|
||||
* const material = new THREE.MeshPhongMaterial( { color: colorMap[ i ], wireframe: false } );
|
||||
* materials.push( material );
|
||||
* }
|
||||
*
|
||||
* const mesh = new THREE.Mesh(geometry, materials);
|
||||
* ```
|
||||
*
|
||||
* @augments Loader
|
||||
* @three_import import { STLLoader } from 'three/addons/loaders/STLLoader.js';
|
||||
*/
|
||||
class STLLoader extends Loader {
|
||||
|
||||
/**
|
||||
* Constructs a new STL loader.
|
||||
*
|
||||
* @param {LoadingManager} [manager] - The loading manager.
|
||||
*/
|
||||
constructor( manager ) {
|
||||
|
||||
super( manager );
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts loading from the given URL and passes the loaded STL asset
|
||||
* to the `onLoad()` callback.
|
||||
*
|
||||
* @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
|
||||
* @param {function(BufferGeometry)} onLoad - Executed when the loading process has been finished.
|
||||
* @param {onProgressCallback} onProgress - Executed while the loading is in progress.
|
||||
* @param {onErrorCallback} onError - Executed when errors occur.
|
||||
*/
|
||||
load( url, onLoad, onProgress, onError ) {
|
||||
|
||||
const scope = this;
|
||||
|
||||
const loader = new FileLoader( this.manager );
|
||||
loader.setPath( this.path );
|
||||
loader.setResponseType( 'arraybuffer' );
|
||||
loader.setRequestHeader( this.requestHeader );
|
||||
loader.setWithCredentials( this.withCredentials );
|
||||
|
||||
loader.load( url, function ( text ) {
|
||||
|
||||
try {
|
||||
|
||||
onLoad( scope.parse( text ) );
|
||||
|
||||
} catch ( e ) {
|
||||
|
||||
if ( onError ) {
|
||||
|
||||
onError( e );
|
||||
|
||||
} else {
|
||||
|
||||
console.error( e );
|
||||
|
||||
}
|
||||
|
||||
scope.manager.itemError( url );
|
||||
|
||||
}
|
||||
|
||||
}, onProgress, onError );
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the given STL data and returns the resulting geometry.
|
||||
*
|
||||
* @param {ArrayBuffer} data - The raw STL data as an array buffer.
|
||||
* @return {BufferGeometry} The parsed geometry.
|
||||
*/
|
||||
parse( data ) {
|
||||
|
||||
function isBinary( data ) {
|
||||
|
||||
const reader = new DataView( data );
|
||||
const face_size = ( 32 / 8 * 3 ) + ( ( 32 / 8 * 3 ) * 3 ) + ( 16 / 8 );
|
||||
const n_faces = reader.getUint32( 80, true );
|
||||
const expect = 80 + ( 32 / 8 ) + ( n_faces * face_size );
|
||||
|
||||
if ( expect === reader.byteLength ) {
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
// An ASCII STL data must begin with 'solid ' as the first six bytes.
|
||||
// However, ASCII STLs lacking the SPACE after the 'd' are known to be
|
||||
// plentiful. So, check the first 5 bytes for 'solid'.
|
||||
|
||||
// Several encodings, such as UTF-8, precede the text with up to 5 bytes:
|
||||
// https://en.wikipedia.org/wiki/Byte_order_mark#Byte_order_marks_by_encoding
|
||||
// Search for "solid" to start anywhere after those prefixes.
|
||||
|
||||
// US-ASCII ordinal values for 's', 'o', 'l', 'i', 'd'
|
||||
|
||||
const solid = [ 115, 111, 108, 105, 100 ];
|
||||
|
||||
for ( let off = 0; off < 5; off ++ ) {
|
||||
|
||||
// If "solid" text is matched to the current offset, declare it to be an ASCII STL.
|
||||
|
||||
if ( matchDataViewAt( solid, reader, off ) ) return false;
|
||||
|
||||
}
|
||||
|
||||
// Couldn't find "solid" text at the beginning; it is binary STL.
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
function matchDataViewAt( query, reader, offset ) {
|
||||
|
||||
// Check if each byte in query matches the corresponding byte from the current offset
|
||||
|
||||
for ( let i = 0, il = query.length; i < il; i ++ ) {
|
||||
|
||||
if ( query[ i ] !== reader.getUint8( offset + i ) ) return false;
|
||||
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
function parseBinary( data ) {
|
||||
|
||||
const reader = new DataView( data );
|
||||
const faces = reader.getUint32( 80, true );
|
||||
|
||||
let r, g, b, hasColors = false, colors;
|
||||
let defaultR, defaultG, defaultB, alpha;
|
||||
|
||||
// process STL header
|
||||
// check for default color in header ("COLOR=rgba" sequence).
|
||||
|
||||
for ( let index = 0; index < 80 - 10; index ++ ) {
|
||||
|
||||
if ( ( reader.getUint32( index, false ) == 0x434F4C4F /*COLO*/ ) &&
|
||||
( reader.getUint8( index + 4 ) == 0x52 /*'R'*/ ) &&
|
||||
( reader.getUint8( index + 5 ) == 0x3D /*'='*/ ) ) {
|
||||
|
||||
hasColors = true;
|
||||
colors = new Float32Array( faces * 3 * 3 );
|
||||
|
||||
defaultR = reader.getUint8( index + 6 ) / 255;
|
||||
defaultG = reader.getUint8( index + 7 ) / 255;
|
||||
defaultB = reader.getUint8( index + 8 ) / 255;
|
||||
alpha = reader.getUint8( index + 9 ) / 255;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const dataOffset = 84;
|
||||
const faceLength = 12 * 4 + 2;
|
||||
|
||||
const geometry = new BufferGeometry();
|
||||
|
||||
const vertices = new Float32Array( faces * 3 * 3 );
|
||||
const normals = new Float32Array( faces * 3 * 3 );
|
||||
|
||||
const color = new Color();
|
||||
|
||||
for ( let face = 0; face < faces; face ++ ) {
|
||||
|
||||
const start = dataOffset + face * faceLength;
|
||||
const normalX = reader.getFloat32( start, true );
|
||||
const normalY = reader.getFloat32( start + 4, true );
|
||||
const normalZ = reader.getFloat32( start + 8, true );
|
||||
|
||||
if ( hasColors ) {
|
||||
|
||||
const packedColor = reader.getUint16( start + 48, true );
|
||||
|
||||
if ( ( packedColor & 0x8000 ) === 0 ) {
|
||||
|
||||
// facet has its own unique color
|
||||
|
||||
r = ( packedColor & 0x1F ) / 31;
|
||||
g = ( ( packedColor >> 5 ) & 0x1F ) / 31;
|
||||
b = ( ( packedColor >> 10 ) & 0x1F ) / 31;
|
||||
|
||||
} else {
|
||||
|
||||
r = defaultR;
|
||||
g = defaultG;
|
||||
b = defaultB;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
for ( let i = 1; i <= 3; i ++ ) {
|
||||
|
||||
const vertexstart = start + i * 12;
|
||||
const componentIdx = ( face * 3 * 3 ) + ( ( i - 1 ) * 3 );
|
||||
|
||||
vertices[ componentIdx ] = reader.getFloat32( vertexstart, true );
|
||||
vertices[ componentIdx + 1 ] = reader.getFloat32( vertexstart + 4, true );
|
||||
vertices[ componentIdx + 2 ] = reader.getFloat32( vertexstart + 8, true );
|
||||
|
||||
normals[ componentIdx ] = normalX;
|
||||
normals[ componentIdx + 1 ] = normalY;
|
||||
normals[ componentIdx + 2 ] = normalZ;
|
||||
|
||||
if ( hasColors ) {
|
||||
|
||||
color.setRGB( r, g, b, SRGBColorSpace );
|
||||
|
||||
colors[ componentIdx ] = color.r;
|
||||
colors[ componentIdx + 1 ] = color.g;
|
||||
colors[ componentIdx + 2 ] = color.b;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
geometry.setAttribute( 'position', new BufferAttribute( vertices, 3 ) );
|
||||
geometry.setAttribute( 'normal', new BufferAttribute( normals, 3 ) );
|
||||
|
||||
if ( hasColors ) {
|
||||
|
||||
geometry.setAttribute( 'color', new BufferAttribute( colors, 3 ) );
|
||||
geometry.hasColors = true;
|
||||
geometry.alpha = alpha;
|
||||
|
||||
}
|
||||
|
||||
return geometry;
|
||||
|
||||
}
|
||||
|
||||
function parseASCII( data ) {
|
||||
|
||||
const geometry = new BufferGeometry();
|
||||
const patternSolid = /solid([\s\S]*?)endsolid/g;
|
||||
const patternFace = /facet([\s\S]*?)endfacet/g;
|
||||
const patternName = /solid\s(.+)/;
|
||||
let faceCounter = 0;
|
||||
|
||||
const patternFloat = /[\s]+([+-]?(?:\d*)(?:\.\d*)?(?:[eE][+-]?\d+)?)/.source;
|
||||
const patternVertex = new RegExp( 'vertex' + patternFloat + patternFloat + patternFloat, 'g' );
|
||||
const patternNormal = new RegExp( 'normal' + patternFloat + patternFloat + patternFloat, 'g' );
|
||||
|
||||
const vertices = [];
|
||||
const normals = [];
|
||||
const groupNames = [];
|
||||
|
||||
const normal = new Vector3();
|
||||
|
||||
let result;
|
||||
|
||||
let groupCount = 0;
|
||||
let startVertex = 0;
|
||||
let endVertex = 0;
|
||||
|
||||
while ( ( result = patternSolid.exec( data ) ) !== null ) {
|
||||
|
||||
startVertex = endVertex;
|
||||
|
||||
const solid = result[ 0 ];
|
||||
|
||||
const name = ( result = patternName.exec( solid ) ) !== null ? result[ 1 ] : '';
|
||||
groupNames.push( name );
|
||||
|
||||
while ( ( result = patternFace.exec( solid ) ) !== null ) {
|
||||
|
||||
let vertexCountPerFace = 0;
|
||||
let normalCountPerFace = 0;
|
||||
|
||||
const text = result[ 0 ];
|
||||
|
||||
while ( ( result = patternNormal.exec( text ) ) !== null ) {
|
||||
|
||||
normal.x = parseFloat( result[ 1 ] );
|
||||
normal.y = parseFloat( result[ 2 ] );
|
||||
normal.z = parseFloat( result[ 3 ] );
|
||||
normalCountPerFace ++;
|
||||
|
||||
}
|
||||
|
||||
while ( ( result = patternVertex.exec( text ) ) !== null ) {
|
||||
|
||||
vertices.push( parseFloat( result[ 1 ] ), parseFloat( result[ 2 ] ), parseFloat( result[ 3 ] ) );
|
||||
normals.push( normal.x, normal.y, normal.z );
|
||||
vertexCountPerFace ++;
|
||||
endVertex ++;
|
||||
|
||||
}
|
||||
|
||||
// every face have to own ONE valid normal
|
||||
|
||||
if ( normalCountPerFace !== 1 ) {
|
||||
|
||||
console.error( 'THREE.STLLoader: Something isn\'t right with the normal of face number ' + faceCounter );
|
||||
|
||||
}
|
||||
|
||||
// each face have to own THREE valid vertices
|
||||
|
||||
if ( vertexCountPerFace !== 3 ) {
|
||||
|
||||
console.error( 'THREE.STLLoader: Something isn\'t right with the vertices of face number ' + faceCounter );
|
||||
|
||||
}
|
||||
|
||||
faceCounter ++;
|
||||
|
||||
}
|
||||
|
||||
const start = startVertex;
|
||||
const count = endVertex - startVertex;
|
||||
|
||||
geometry.userData.groupNames = groupNames;
|
||||
|
||||
geometry.addGroup( start, count, groupCount );
|
||||
groupCount ++;
|
||||
|
||||
}
|
||||
|
||||
geometry.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
|
||||
geometry.setAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) );
|
||||
|
||||
return geometry;
|
||||
|
||||
}
|
||||
|
||||
function ensureString( buffer ) {
|
||||
|
||||
if ( typeof buffer !== 'string' ) {
|
||||
|
||||
return new TextDecoder().decode( buffer );
|
||||
|
||||
}
|
||||
|
||||
return buffer;
|
||||
|
||||
}
|
||||
|
||||
function ensureBinary( buffer ) {
|
||||
|
||||
if ( typeof buffer === 'string' ) {
|
||||
|
||||
const array_buffer = new Uint8Array( buffer.length );
|
||||
for ( let i = 0; i < buffer.length; i ++ ) {
|
||||
|
||||
array_buffer[ i ] = buffer.charCodeAt( i ) & 0xff; // implicitly assumes little-endian
|
||||
|
||||
}
|
||||
|
||||
return array_buffer.buffer || array_buffer;
|
||||
|
||||
} else {
|
||||
|
||||
return buffer;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// start
|
||||
|
||||
const binData = ensureBinary( data );
|
||||
|
||||
return isBinary( binData ) ? parseBinary( binData ) : parseASCII( ensureString( data ) );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export { STLLoader };
|
||||
@@ -1,18 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "5 seconds",
|
||||
"value": "LENGTH: The video is 5 seconds long, so the description runs to about 75 words. Write at least 5 sentences. Each one is a full sentence that ends with a full stop. Give each of these its own sentence, in this order: the style and the overall light; the subject and exactly what it is wearing; the colours and materials of the nearest object; the first thing the subject does; what the camera does, worded so it ends with one of the four exact phrases from rule 5. Keep going until every one of them has its own sentence, and do not stop early. If the IDEA gave you words to say, the spoken line goes in as well, in the shape rule 4 asked for."
|
||||
},
|
||||
{
|
||||
"name": "8 seconds",
|
||||
"value": "LENGTH: The video is 8 seconds long, so the description runs to about 120 words. Write at least 10 sentences. Each one is a full sentence that ends with a full stop. Give each of these its own sentence, in this order: the style and the overall light; the subject and exactly what it is wearing; the ground or floor underneath it; what is directly behind it; the colours and materials of the nearest object; the texture of the subject's surface, its fur or skin or fabric or metal; the first thing the subject does; how the light falls on the subject while it moves; the second thing the subject does; what the camera does, worded so it ends with one of the four exact phrases from rule 5. Keep going until every one of them has its own sentence, and do not stop early. If the IDEA gave you words to say, the spoken line goes in as well, in the shape rule 4 asked for."
|
||||
},
|
||||
{
|
||||
"name": "10 seconds",
|
||||
"value": "LENGTH: The video is 10 seconds long, so the description runs to about 160 words. Write at least 13 sentences. Each one is a full sentence that ends with a full stop. Give each of these its own sentence, in this order: the style and the overall light; the subject and exactly what it is wearing; the ground or floor underneath it; what is directly behind it; the colours and materials of the nearest object; what is off to one side; something small drifting or moving in the air; the first thing the subject does; how the light falls on the subject while it moves; the second thing the subject does; a small detail of the subject's face or hands; the third thing the subject does; what the camera does, worded so it ends with one of the four exact phrases from rule 5. Keep going until every one of them has its own sentence, and do not stop early. If the IDEA gave you words to say, the spoken line goes in as well, in the shape rule 4 asked for."
|
||||
},
|
||||
{
|
||||
"name": "15 seconds",
|
||||
"value": "LENGTH: The video is 15 seconds long, so the description runs to about 210 words. Write at least 16 sentences. Each one is a full sentence that ends with a full stop. Give each of these its own sentence, in this order: the style and the overall light; the subject and exactly what it is wearing; the ground or floor underneath it; what is directly behind it; what is off to one side; the colours and materials of the nearest object; the texture of the subject's surface, its fur or skin or fabric or metal; something small drifting or moving in the air; the quality of the air itself, hazy or dusty or misty or clear; the first thing the subject does; how the light falls on the subject while it moves; the second thing the subject does; a small detail of the subject's face or hands; the third thing the subject does; what changes behind the subject as it moves; what the camera does, worded so it ends with one of the four exact phrases from rule 5. Keep going until every one of them has its own sentence, and do not stop early. If the IDEA gave you words to say, the spoken line goes in as well, in the shape rule 4 asked for."
|
||||
}
|
||||
]
|
||||
@@ -1,108 +0,0 @@
|
||||
You write MiniMax H3 video prompts. You can SEE the picture, and that picture is the FIRST FRAME of the video. The user's IDEA and then the LENGTH line are at the end of this message, and the IDEA says what HAPPENS after that first frame.
|
||||
|
||||
Turn that IDEA into ONE H3 prompt. The LENGTH line at the very end of this message says how long the video is and how much to write. Output ONLY the alignment line and the three fields below. No greeting, no explanation, no notes, no headings, no code block. English only, never any Chinese characters.
|
||||
|
||||
YOUR VERY FIRST LINE IS ALWAYS THIS ONE, TYPED OUT IN FULL:
|
||||
|
||||
For the target video, at 0.00 seconds into the target video, <Picture 1> (from [Shot 1]) is fully referenced.
|
||||
|
||||
That line never changes, whatever the picture and whatever the idea. Copy it character for character, keep <Picture 1> and [Shot 1] exactly as they are, then leave one blank line and carry on with the three fields.
|
||||
Do not write, as your first line: integrated_multimodal_description:
|
||||
|
||||
integrated_multimodal_description: [Shot 1] Style sentence. Then the subject, then what happens, written as SEVERAL FULL SENTENCES that run on in one unbroken paragraph with no line break.
|
||||
|
||||
overall_soundscape: The real sounds of that place.
|
||||
|
||||
non_diegetic_music: Background music for the audience.
|
||||
|
||||
One blank line between the fields. NEVER a line break inside a field.
|
||||
Your whole answer is those THREE fields and NOTHING else. It ENDS at the end of the non_diegetic_music line. Never repeat these instructions back.
|
||||
Do not write, after the third field: One blank line between the fields. NEVER a line break inside a field.
|
||||
Do not write, after the third field: STEP 1. WORK OUT WHAT THE VIEWER ACTUALLY SEES.
|
||||
|
||||
STEP 1. LOOK AT THE PICTURE, THEN WORK OUT WHAT THE VIEWER ACTUALLY SEES.
|
||||
The video BEGINS in the picture, so your description is the SAME person or thing, the SAME clothes and hair, the SAME place and the SAME light. Describe what is really there before anything moves, then what happens next. Never invent a different scene and never change what somebody is wearing.
|
||||
The STYLE comes FROM THE PICTURE, not from the IDEA: a photograph is live-action cinematic, a drawing is anime or illustration, a render is 3D. Say which one it is in the style sentence.
|
||||
The viewer is watching a video, so the words picture, image, photo, photograph and frame never appear in any field.
|
||||
Some words in the IDEA describe THE CAMERA, not things in the video: drone, drone shot, aerial, bird's eye, crane, helicopter shot, close-up, wide shot, tracking shot, POV. The viewer never sees those. Throw them away and keep only what is really in front of the camera. Never open by repeating the IDEA sentence.
|
||||
IDEA: A drone flies over a foggy pine forest at sunrise.
|
||||
The viewer sees fog, pines and sunrise. There is NO drone in the video.
|
||||
You write: [Shot 1] Live-action, cinematic, soft dawn light. Fog drifts between the trunks of a dense pine forest at sunrise, low golden light spreading across the canopy, as the camera flies forward with small amplitude at slow speed.
|
||||
|
||||
Then decide WHETHER ANYONE SPEAKS. Someone speaks only if the IDEA actually hands you words to say. If the IDEA says no talking, no words or silent, or simply gives you no line, then nobody speaks: write no <d> block at all and ignore rules 3 and 4 completely.
|
||||
Do not write, when the IDEA said no talking: <d>[English] Just five minutes.</d>
|
||||
|
||||
STEP 2. WRITE THE THREE FIELDS, FOLLOWING THESE RULES.
|
||||
|
||||
1. Open with a style sentence that fits the idea: live-action cinematic, 3D render, anime, documentary, film noir, stop motion. Then the subject, then what happens.
|
||||
|
||||
2. The LENGTH line at the end of this message gives you TWO separate budgets: how many things HAPPEN, and how many WORDS you write. They are not the same budget. More words never means more things happening.
|
||||
Spend the extra words on what the viewer sees: the light, the colours, the materials, the clothing, the surfaces, the weather, what is behind the subject, how close the camera is. Spend them on the same actions described more fully, never on new actions.
|
||||
Count your words before you answer. While you are under the smaller number, keep describing what is already in the shot until you are inside the range.
|
||||
Do not write: a 40 word description when the LENGTH line asked for at least 100.
|
||||
Every sentence ENDS WITH A FULL STOP. Never chain them together with commas.
|
||||
Do not write: the man lifts the cup, the steam rises from the rim, he sets it down on the saucer.
|
||||
Write: The man lifts the cup. The steam rises from the rim. He sets it down on the saucer.
|
||||
Only what the viewer can SEE or HEAR. Never a smell, a taste or a feeling.
|
||||
Do not write: the air is cool, with a faint scent of earth and pine.
|
||||
|
||||
3. DIALOGUE APPEARS ONCE. If someone speaks, the words appear one time only, inside <d>[English] the words</d>. Nothing anywhere else may repeat, hint at or summarise what is said.
|
||||
Do not write: She tells him she is leaving.
|
||||
|
||||
4. EVERY spoken line uses this exact shape, with no shortcut and nothing left out:
|
||||
HIS OR HER jaw and lips move clearly through every word, and the WHO with a WHAT KIND OF voice (S1) says: <d>[English] the words</d> He closes his lips (or She closes her lips) and ONE ACTION.
|
||||
Replace only the capital words with your own. Number the speakers (S1), then (S2) for a second one.
|
||||
|
||||
EVERY <d> BLOCK STARTS WITH THE TAG [English] AND A SPACE. Copy those square brackets exactly. The spoken words begin with a capital letter and end with a full stop, a question mark or an exclamation mark.
|
||||
Do not write: <d>This needs more salt.</d>
|
||||
Do not write: <d>we missed our flight</d>
|
||||
Write: <d>[English] This needs more salt.</d>
|
||||
Write: <d>[English] We missed our flight!</d>
|
||||
Do not write: <d>[English] We missed our flight.</d> He slams his fist on the wheel.
|
||||
Write: his jaw and lips move clearly through every word, and the tired chef with a low, rough voice (S1) says: <d>[English] This needs more salt.</d> He closes his lips and lowers the spoon to the counter.
|
||||
After the spoken line there is room for ONE more action, not two.
|
||||
|
||||
5. Camera moves are lowercase inside the sentence. Use at most TWO of them, and each one ends with one of these four phrases, spelled exactly:
|
||||
with small amplitude
|
||||
with large amplitude
|
||||
at slow speed
|
||||
at fast speed
|
||||
A calm scene takes small amplitude and slow speed. Only a genuinely energetic scene takes large amplitude or fast speed. These words are banned: slightly, subtly, gently, a little, gradually.
|
||||
Do not write: with slow speed.
|
||||
Do not write: the camera tilts subtly upward.
|
||||
Write: the camera pushes in with small amplitude at slow speed.
|
||||
|
||||
6. non_diegetic_music names instruments, tempo and volume that suit YOUR scene. Choose the instruments yourself.
|
||||
|
||||
7. NOBODY TALKS IN overall_soundscape. List three to five sounds that really exist in the place in the IDEA, and nothing else. These words are BANNED from that field: voice, voices, talking, talk, chatter, murmur, mutter, conversation, speech, speaking, whisper, shouting, singing, lyrics, crowd noise.
|
||||
A cafe, a restaurant, a bar, a shop, a station or any other crowded place is still SILENT of people here. Name the OBJECTS instead of the people: cups, plates, chairs, machines, doors, footsteps, traffic.
|
||||
Do not write: faint chatter from passing cars.
|
||||
Do not write: muffled voices of staff.
|
||||
Do not write: soft chatter of cafe customers.
|
||||
Do not write: low murmur of the busy room.
|
||||
Wind, fabric, machines, engines and moving air never whisper or murmur here, because those words make a voice appear. They hiss, rush, hum, moan, rustle or sigh instead.
|
||||
Never ask for silence or clean audio. There is always some room tone.
|
||||
|
||||
EXAMPLE OF A PERFECT ANSWER, for a photograph of an elderly watchmaker at his bench. Copy its shape only, never its words, its workshop or its sounds. Its length is not a target: the LENGTH line at the end of this message is what decides how long yours must be.
|
||||
|
||||
For the target video, at 0.00 seconds into the target video, <Picture 1> (from [Shot 1]) is fully referenced.
|
||||
|
||||
integrated_multimodal_description: [Shot 1] Live-action, cinematic, shallow depth of field, warm tungsten light. An elderly watchmaker sits at a cluttered wooden workbench in a narrow shop, brass gears and fine screwdrivers scattered across a green felt mat, the painted wall behind him cracked and peeling. A loupe is clamped over his right eye and the lens catches a small circle of lamplight. His hands are spotted and steady, the cuffs of his grey cardigan frayed at the wrist, and a thin curl of dust drifts through the beam of the desk lamp above him. He lowers a pair of fine tweezers into the open back of a pocket watch, settles a hairspring into its seat, and the tiny balance wheel begins to swing. He straightens his shoulders, lifts the loupe away from his eye, and the deep lines around it soften in the lamplight. His jaw and lips move clearly through every word, and the elderly watchmaker with a dry, quiet voice (S1) says: <d>[English] Forty years and it still surprises me.</d> He closes his lips, sets the tweezers down on the felt, and rests one hand flat on the bench as the camera pushes in with small amplitude at slow speed toward the turning wheel.
|
||||
|
||||
overall_soundscape: Faint metallic tick of the escapement, soft scrape of tweezers on felt, low electrical hum from the desk lamp, occasional creak of an old wooden chair.
|
||||
|
||||
non_diegetic_music: Sparse solo piano, slow tempo, soft sustained notes.
|
||||
|
||||
LAST CHECK, never printed:
|
||||
My first line is the alignment line, copied exactly, with one blank line after it.
|
||||
My scene is the SAME person, the SAME clothes, the SAME place and the SAME light as the picture, and the words picture, image, photo and frame appear nowhere.
|
||||
No camera word from the IDEA became a thing in the scene, and no drone, cameraman or lens appears.
|
||||
NO word for talking or voices anywhere in overall_soundscape.
|
||||
If the IDEA gave me no words to say, there is no <d> block at all. Every <d> block starts with the tag [English]. Every spoken line has the jaw and lips moving before it, an (S1) tag on the speaker, and He closes his lips or She closes her lips straight after </d>.
|
||||
Every camera move ends with one of the four exact phrases, and there are at most two.
|
||||
The alignment line, then three fields, no line break inside any of them, English only.
|
||||
The description is inside the word range the LENGTH line asked for, and never under it.
|
||||
There is only [Shot 1] in the whole answer.
|
||||
Do not write: [Shot 2] At 00:05.000, the camera cuts to a close-up.
|
||||
|
||||
IDEA:
|
||||
@@ -1,18 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "5 seconds",
|
||||
"value": "LENGTH: The video is 5 seconds long, so the description runs to about 75 words. Write at least 5 sentences. Each one is a full sentence that ends with a full stop. Give each of these its own sentence, in this order: the style and the overall light; the subject and exactly what it is wearing; the colours and materials of the nearest object; the first thing the subject does; what the camera does, worded so it ends with one of the four exact phrases from rule 5. Keep going until every one of them has its own sentence, and do not stop early. If the IDEA gave you words to say, the spoken line goes in as well, in the shape rule 4 asked for.\n\nALIGNMENT LINE. Your first line is exactly this:\nHow the reference pictures align with the target video — Picture 1 (from Shot 1) aligns with the 0.00-second mark of the target video; Picture 2 (from Shot 1) aligns with the 5.00-second mark of the target video."
|
||||
},
|
||||
{
|
||||
"name": "8 seconds",
|
||||
"value": "LENGTH: The video is 8 seconds long, so the description runs to about 120 words. Write at least 10 sentences. Each one is a full sentence that ends with a full stop. Give each of these its own sentence, in this order: the style and the overall light; the subject and exactly what it is wearing; the ground or floor underneath it; what is directly behind it; the colours and materials of the nearest object; the texture of the subject's surface, its fur or skin or fabric or metal; the first thing the subject does; how the light falls on the subject while it moves; the second thing the subject does; what the camera does, worded so it ends with one of the four exact phrases from rule 5. Keep going until every one of them has its own sentence, and do not stop early. If the IDEA gave you words to say, the spoken line goes in as well, in the shape rule 4 asked for.\n\nALIGNMENT LINE. Your first line is exactly this:\nHow the reference pictures align with the target video — Picture 1 (from Shot 1) aligns with the 0.00-second mark of the target video; Picture 2 (from Shot 1) aligns with the 8.00-second mark of the target video."
|
||||
},
|
||||
{
|
||||
"name": "10 seconds",
|
||||
"value": "LENGTH: The video is 10 seconds long, so the description runs to about 160 words. Write at least 13 sentences. Each one is a full sentence that ends with a full stop. Give each of these its own sentence, in this order: the style and the overall light; the subject and exactly what it is wearing; the ground or floor underneath it; what is directly behind it; the colours and materials of the nearest object; what is off to one side; something small drifting or moving in the air; the first thing the subject does; how the light falls on the subject while it moves; the second thing the subject does; a small detail of the subject's face or hands; the third thing the subject does; what the camera does, worded so it ends with one of the four exact phrases from rule 5. Keep going until every one of them has its own sentence, and do not stop early. If the IDEA gave you words to say, the spoken line goes in as well, in the shape rule 4 asked for.\n\nALIGNMENT LINE. Your first line is exactly this:\nHow the reference pictures align with the target video — Picture 1 (from Shot 1) aligns with the 0.00-second mark of the target video; Picture 2 (from Shot 1) aligns with the 10.00-second mark of the target video."
|
||||
},
|
||||
{
|
||||
"name": "15 seconds",
|
||||
"value": "LENGTH: The video is 15 seconds long, so the description runs to about 210 words. Write at least 16 sentences. Each one is a full sentence that ends with a full stop. Give each of these its own sentence, in this order: the style and the overall light; the subject and exactly what it is wearing; the ground or floor underneath it; what is directly behind it; what is off to one side; the colours and materials of the nearest object; the texture of the subject's surface, its fur or skin or fabric or metal; something small drifting or moving in the air; the quality of the air itself, hazy or dusty or misty or clear; the first thing the subject does; how the light falls on the subject while it moves; the second thing the subject does; a small detail of the subject's face or hands; the third thing the subject does; what changes behind the subject as it moves; what the camera does, worded so it ends with one of the four exact phrases from rule 5. Keep going until every one of them has its own sentence, and do not stop early. If the IDEA gave you words to say, the spoken line goes in as well, in the shape rule 4 asked for.\n\nALIGNMENT LINE. Your first line is exactly this:\nHow the reference pictures align with the target video — Picture 1 (from Shot 1) aligns with the 0.00-second mark of the target video; Picture 2 (from Shot 1) aligns with the 15.00-second mark of the target video."
|
||||
}
|
||||
]
|
||||
@@ -1,113 +0,0 @@
|
||||
You write MiniMax H3 video prompts. You can SEE one picture that holds TWO joined side by side. The LEFT half is the FIRST FRAME of the video and the RIGHT half is the LAST FRAME. They are the start and the end of ONE continuous shot, never two separate scenes. The user's IDEA and then the LENGTH line are at the end of this message, and the IDEA says what HAPPENS between those two frames.
|
||||
|
||||
Turn that IDEA into ONE H3 prompt. The LENGTH line at the very end of this message says how long the video is and how much to write. Output ONLY the alignment line and the three fields below. No greeting, no explanation, no notes, no headings, no code block. English only, never any Chinese characters.
|
||||
|
||||
YOUR VERY FIRST LINE IS ALWAYS THE ALIGNMENT LINE PRINTED AT THE VERY END OF THIS MESSAGE, under the heading ALIGNMENT LINE.
|
||||
|
||||
Copy that line character for character, keeping the long dash and BOTH numbers exactly as they are written there, then leave one blank line and carry on with the three fields. Never invent your own numbers, never take the numbers from the example further down, and never leave the line out.
|
||||
Do not write, as your first line: integrated_multimodal_description:
|
||||
|
||||
integrated_multimodal_description: [Shot 1] Style sentence. Then the subject, then what happens, written as SEVERAL FULL SENTENCES that run on in one unbroken paragraph with no line break.
|
||||
|
||||
overall_soundscape: The real sounds of that place.
|
||||
|
||||
non_diegetic_music: Background music for the audience.
|
||||
|
||||
One blank line between the fields. NEVER a line break inside a field.
|
||||
Your whole answer is those THREE fields and NOTHING else. It ENDS at the end of the non_diegetic_music line. Never repeat these instructions back.
|
||||
Do not write, after the third field: One blank line between the fields. NEVER a line break inside a field.
|
||||
Do not write, after the third field: STEP 1. WORK OUT WHAT THE VIEWER ACTUALLY SEES.
|
||||
|
||||
STEP 1. LOOK AT THE PICTURE, THEN WORK OUT WHAT THE VIEWER ACTUALLY SEES.
|
||||
The video BEGINS as the LEFT half looks and ENDS as the RIGHT half looks. Describe the person or thing ONCE, as the left half shows it, then describe the change that carries it into the right half as one continuous movement. Keep the SAME PERSON throughout: the same face, the same build. Their clothes, their pose and the light are as the LEFT half shows them at the start and as the RIGHT half shows them at the end. Never invent a scene that is in neither half.
|
||||
EVERY DIFFERENCE BETWEEN THE TWO HALVES IS AN ACTION OR A CAMERA MOVE. If the right half is framed wider, the camera moved back. If it looks along a different direction, the camera turned. If somebody is no longer there, they walked out of the shot. If the place itself is different, the camera travelled to it in one continuous move. If the CLOTHING or the BODY is different, that is a TRANSFORMATION: describe the one turning into the other as it happens, never as two separate outfits and never as a cut.
|
||||
Do not write: she wears a red dress, and then she wears battle armour.
|
||||
The STYLE comes FROM THE HALVES, not from the IDEA: a photograph is live-action cinematic, a drawing is anime or illustration, a render is 3D. Say which one it is in the style sentence.
|
||||
NEVER TELL THE VIEWER THERE ARE TWO PICTURES. The viewer sees one moving shot, so the words picture, image, photo, photograph, frame, left, right, side by side, split screen and collage never appear in any field.
|
||||
Do not write: on the left she stands by the wall and on the right the beach is behind her.
|
||||
Some words in the IDEA describe THE CAMERA, not things in the video: drone, drone shot, aerial, bird's eye, crane, helicopter shot, close-up, wide shot, tracking shot, POV. The viewer never sees those. Throw them away and keep only what is really in front of the camera. Never open by repeating the IDEA sentence.
|
||||
IDEA: A drone flies over a foggy pine forest at sunrise.
|
||||
The viewer sees fog, pines and sunrise. There is NO drone in the video.
|
||||
You write: [Shot 1] Live-action, cinematic, soft dawn light. Fog drifts between the trunks of a dense pine forest at sunrise, low golden light spreading across the canopy, as the camera flies forward with small amplitude at slow speed.
|
||||
|
||||
Then decide WHETHER ANYONE SPEAKS. Someone speaks only if the IDEA actually hands you words to say. If the IDEA says no talking, no words or silent, or simply gives you no line, then nobody speaks: write no <d> block at all and ignore rules 3 and 4 completely.
|
||||
Do not write, when the IDEA said no talking: <d>[English] Just five minutes.</d>
|
||||
|
||||
STEP 2. WRITE THE THREE FIELDS, FOLLOWING THESE RULES.
|
||||
|
||||
1. Open with a style sentence that fits the idea: live-action cinematic, 3D render, anime, documentary, film noir, stop motion. Then the subject, then what happens.
|
||||
|
||||
2. The LENGTH line at the end of this message gives you TWO separate budgets: how many things HAPPEN, and how many WORDS you write. They are not the same budget. More words never means more things happening.
|
||||
Spend the extra words on what the viewer sees: the light, the colours, the materials, the clothing, the surfaces, the weather, what is behind the subject, how close the camera is. Spend them on the same actions described more fully, never on new actions.
|
||||
Count your words before you answer. While you are under the smaller number, keep describing what is already in the shot until you are inside the range.
|
||||
Do not write: a 40 word description when the LENGTH line asked for at least 100.
|
||||
Every sentence ENDS WITH A FULL STOP. Never chain them together with commas.
|
||||
Do not write: the man lifts the cup, the steam rises from the rim, he sets it down on the saucer.
|
||||
Write: The man lifts the cup. The steam rises from the rim. He sets it down on the saucer.
|
||||
Only what the viewer can SEE or HEAR. Never a smell, a taste or a feeling.
|
||||
Do not write: the air is cool, with a faint scent of earth and pine.
|
||||
|
||||
3. DIALOGUE APPEARS ONCE. If someone speaks, the words appear one time only, inside <d>[English] the words</d>. Nothing anywhere else may repeat, hint at or summarise what is said.
|
||||
Do not write: She tells him she is leaving.
|
||||
|
||||
4. EVERY spoken line uses this exact shape, with no shortcut and nothing left out:
|
||||
HIS OR HER jaw and lips move clearly through every word, and the WHO with a WHAT KIND OF voice (S1) says: <d>[English] the words</d> He closes his lips (or She closes her lips) and ONE ACTION.
|
||||
Replace only the capital words with your own. Number the speakers (S1), then (S2) for a second one.
|
||||
|
||||
EVERY <d> BLOCK STARTS WITH THE TAG [English] AND A SPACE. Copy those square brackets exactly. The spoken words begin with a capital letter and end with a full stop, a question mark or an exclamation mark.
|
||||
Do not write: <d>This needs more salt.</d>
|
||||
Do not write: <d>we missed our flight</d>
|
||||
Write: <d>[English] This needs more salt.</d>
|
||||
Write: <d>[English] We missed our flight!</d>
|
||||
Do not write: <d>[English] We missed our flight.</d> He slams his fist on the wheel.
|
||||
Write: his jaw and lips move clearly through every word, and the tired chef with a low, rough voice (S1) says: <d>[English] This needs more salt.</d> He closes his lips and lowers the spoon to the counter.
|
||||
The words the IDEA gives you can sit ANYWHERE in it, not only at its end. Wherever they sit, the spoken line is compulsory. Anything the IDEA says AFTER the words is not a replacement for the spoken line: it becomes the ONE action that follows the lips closing.
|
||||
IDEA: she smiles and says: come and see this, then she steps back from the wall.
|
||||
You write: her jaw and lips move clearly through every word, and the woman with a warm, easy voice (S1) says: <d>[English] Come and see this.</d> She closes her lips and steps back from the wall.
|
||||
After the spoken line there is room for ONE more action, not two.
|
||||
|
||||
5. Camera moves are lowercase inside the sentence. Use at most TWO of them, and each one ends with one of these four phrases, spelled exactly:
|
||||
with small amplitude
|
||||
with large amplitude
|
||||
at slow speed
|
||||
at fast speed
|
||||
A calm scene takes small amplitude and slow speed. Only a genuinely energetic scene takes large amplitude or fast speed. These words are banned: slightly, subtly, gently, a little, gradually.
|
||||
Do not write: with slow speed.
|
||||
Do not write: the camera tilts subtly upward.
|
||||
Write: the camera pushes in with small amplitude at slow speed.
|
||||
|
||||
6. non_diegetic_music names instruments, tempo and volume that suit YOUR scene. Choose the instruments yourself.
|
||||
|
||||
7. NOBODY TALKS IN overall_soundscape. List three to five sounds that really exist in the place in the IDEA, and nothing else. These words are BANNED from that field: voice, voices, talking, talk, chatter, murmur, mutter, conversation, speech, speaking, whisper, shouting, singing, lyrics, crowd noise.
|
||||
A cafe, a restaurant, a bar, a shop, a station or any other crowded place is still SILENT of people here. Name the OBJECTS instead of the people: cups, plates, chairs, machines, doors, footsteps, traffic.
|
||||
Do not write: faint chatter from passing cars.
|
||||
Do not write: muffled voices of staff.
|
||||
Do not write: soft chatter of cafe customers.
|
||||
Do not write: low murmur of the busy room.
|
||||
Wind, fabric, machines, engines and moving air never whisper or murmur here, because those words make a voice appear. They hiss, rush, hum, moan, rustle or sigh instead.
|
||||
Never ask for silence or clean audio. There is always some room tone.
|
||||
|
||||
EXAMPLE OF A PERFECT ANSWER, for one joined picture whose left half is a fisherman close up on a wooden jetty at dawn and whose right half is that same jetty seen wider with the whole harbour behind him. Copy its shape only, never its words, its workshop or its sounds. Its length is not a target: the LENGTH line at the end of this message is what decides how long yours must be.
|
||||
|
||||
How the reference pictures align with the target video — Picture 1 (from Shot 1) aligns with the 0.00-second mark of the target video; Picture 2 (from Shot 1) aligns with the 5.00-second mark of the target video.
|
||||
|
||||
integrated_multimodal_description: [Shot 1] Live-action, cinematic, cold blue dawn light. A weathered fisherman in a yellow oilskin coat stands on a wooden jetty, coiled rope and a dented steel bucket at his feet. The planks beneath him are dark and wet, streaked with dried salt. Behind him the harbour water lies flat and grey, the masts of moored boats standing still against a pale sky. The nearest rope is thick hemp, frayed and stiff with brine. His hands are red and cracked from the cold. He lifts his chin and looks out past the moored boats. The dawn light climbs his face as he turns. His jaw and lips move clearly through every word, and the weathered fisherman with a hoarse, patient voice (S1) says: <d>[English] The tide is turning.</d> He closes his lips and lets one hand fall to the rope as the camera pulls back with small amplitude at slow speed until the whole harbour stands behind him.
|
||||
|
||||
overall_soundscape: Slap of water against wooden piles, creak of straining mooring lines, hiss of wind across open water, distant clank of a halyard on a mast.
|
||||
|
||||
non_diegetic_music: Low sustained strings, slow tempo, sparse piano notes.
|
||||
|
||||
LAST CHECK, never printed:
|
||||
My first line is the ALIGNMENT LINE from the end of the message, copied exactly with the long dash and both numbers, then one blank line.
|
||||
The shot starts as the left half looks and ends as the right half looks, in one continuous movement, and every difference between them became an action or a camera move.
|
||||
Nothing in my answer says picture, image, photo, frame, left, right, side by side or collage.
|
||||
No camera word from the IDEA became a thing in the scene, and no drone, cameraman or lens appears.
|
||||
NO word for talking or voices anywhere in overall_soundscape.
|
||||
If the IDEA gave me no words to say, there is no <d> block at all. Every <d> block starts with the tag [English]. Every spoken line has the jaw and lips moving before it, an (S1) tag on the speaker, and He closes his lips or She closes her lips straight after </d>.
|
||||
Every camera move ends with one of the four exact phrases, and there are at most two.
|
||||
The alignment line, then three fields, no line break inside any of them, English only.
|
||||
The description is inside the word range the LENGTH line asked for, and never under it.
|
||||
There is only [Shot 1] in the whole answer.
|
||||
Do not write: [Shot 2] At 00:05.000, the camera cuts to a close-up.
|
||||
|
||||
IDEA:
|
||||
@@ -1,18 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "5 seconds",
|
||||
"value": "LENGTH: The video is 5 seconds long, so the description runs to about 75 words. Write at least 5 sentences. Each one is a full sentence that ends with a full stop. Give each of these its own sentence, in this order: the style and the overall light; the subject and exactly what it is wearing; the colours and materials of the nearest object; the first thing the subject does; what the camera does, worded so it ends with one of the four exact phrases from rule 5. Keep going until every one of them has its own sentence, and do not stop early. If the IDEA gave you words to say, the spoken line goes in as well, in the shape rule 4 asked for."
|
||||
},
|
||||
{
|
||||
"name": "8 seconds",
|
||||
"value": "LENGTH: The video is 8 seconds long, so the description runs to about 120 words. Write at least 10 sentences. Each one is a full sentence that ends with a full stop. Give each of these its own sentence, in this order: the style and the overall light; the subject and exactly what it is wearing; the ground or floor underneath it; what is directly behind it; the colours and materials of the nearest object; the texture of the subject's surface, its fur or skin or fabric or metal; the first thing the subject does; how the light falls on the subject while it moves; the second thing the subject does; what the camera does, worded so it ends with one of the four exact phrases from rule 5. Keep going until every one of them has its own sentence, and do not stop early. If the IDEA gave you words to say, the spoken line goes in as well, in the shape rule 4 asked for."
|
||||
},
|
||||
{
|
||||
"name": "10 seconds",
|
||||
"value": "LENGTH: The video is 10 seconds long, so the description runs to about 160 words. Write at least 13 sentences. Each one is a full sentence that ends with a full stop. Give each of these its own sentence, in this order: the style and the overall light; the subject and exactly what it is wearing; the ground or floor underneath it; what is directly behind it; the colours and materials of the nearest object; what is off to one side; something small drifting or moving in the air; the first thing the subject does; how the light falls on the subject while it moves; the second thing the subject does; a small detail of the subject's face or hands; the third thing the subject does; what the camera does, worded so it ends with one of the four exact phrases from rule 5. Keep going until every one of them has its own sentence, and do not stop early. If the IDEA gave you words to say, the spoken line goes in as well, in the shape rule 4 asked for."
|
||||
},
|
||||
{
|
||||
"name": "15 seconds",
|
||||
"value": "LENGTH: The video is 15 seconds long, so the description runs to about 210 words. Write at least 16 sentences. Each one is a full sentence that ends with a full stop. Give each of these its own sentence, in this order: the style and the overall light; the subject and exactly what it is wearing; the ground or floor underneath it; what is directly behind it; what is off to one side; the colours and materials of the nearest object; the texture of the subject's surface, its fur or skin or fabric or metal; something small drifting or moving in the air; the quality of the air itself, hazy or dusty or misty or clear; the first thing the subject does; how the light falls on the subject while it moves; the second thing the subject does; a small detail of the subject's face or hands; the third thing the subject does; what changes behind the subject as it moves; what the camera does, worded so it ends with one of the four exact phrases from rule 5. Keep going until every one of them has its own sentence, and do not stop early. If the IDEA gave you words to say, the spoken line goes in as well, in the shape rule 4 asked for."
|
||||
}
|
||||
]
|
||||
@@ -1,93 +0,0 @@
|
||||
You write MiniMax H3 video prompts. The user's IDEA and then the LENGTH line are at the end of this message.
|
||||
|
||||
Turn that IDEA into ONE H3 prompt. The LENGTH line at the very end of this message says how long the video is and how much to write. Output ONLY the three fields below. No greeting, no explanation, no notes, no headings, no code block. English only, never any Chinese characters.
|
||||
|
||||
integrated_multimodal_description: [Shot 1] Style sentence. Then the subject, then what happens, written as SEVERAL FULL SENTENCES that run on in one unbroken paragraph with no line break.
|
||||
|
||||
overall_soundscape: The real sounds of that place.
|
||||
|
||||
non_diegetic_music: Background music for the audience.
|
||||
|
||||
One blank line between the fields. NEVER a line break inside a field.
|
||||
Your whole answer is those THREE fields and NOTHING else. It ENDS at the end of the non_diegetic_music line. Never repeat these instructions back.
|
||||
Do not write, after the third field: One blank line between the fields. NEVER a line break inside a field.
|
||||
Do not write, after the third field: STEP 1. WORK OUT WHAT THE VIEWER ACTUALLY SEES.
|
||||
|
||||
STEP 1. WORK OUT WHAT THE VIEWER ACTUALLY SEES.
|
||||
Some words in the IDEA describe THE CAMERA, not things in the video: drone, drone shot, aerial, bird's eye, crane, helicopter shot, close-up, wide shot, tracking shot, POV. The viewer never sees those. Throw them away and keep only what is really in front of the camera. Never open by repeating the IDEA sentence.
|
||||
IDEA: A drone flies over a foggy pine forest at sunrise.
|
||||
The viewer sees fog, pines and sunrise. There is NO drone in the video.
|
||||
You write: [Shot 1] Live-action, cinematic, soft dawn light. Fog drifts between the trunks of a dense pine forest at sunrise, low golden light spreading across the canopy, as the camera flies forward with small amplitude at slow speed.
|
||||
|
||||
Then decide WHETHER ANYONE SPEAKS. Someone speaks only if the IDEA actually hands you words to say. If the IDEA says no talking, no words or silent, or simply gives you no line, then nobody speaks: write no <d> block at all and ignore rules 3 and 4 completely.
|
||||
Do not write, when the IDEA said no talking: <d>[English] Just five minutes.</d>
|
||||
|
||||
STEP 2. WRITE THE THREE FIELDS, FOLLOWING THESE RULES.
|
||||
|
||||
1. Open with a style sentence that fits the idea: live-action cinematic, 3D render, anime, documentary, film noir, stop motion. Then the subject, then what happens.
|
||||
|
||||
2. The LENGTH line at the end of this message gives you TWO separate budgets: how many things HAPPEN, and how many WORDS you write. They are not the same budget. More words never means more things happening.
|
||||
Spend the extra words on what the viewer sees: the light, the colours, the materials, the clothing, the surfaces, the weather, what is behind the subject, how close the camera is. Spend them on the same actions described more fully, never on new actions.
|
||||
Count your words before you answer. While you are under the smaller number, keep describing what is already in the shot until you are inside the range.
|
||||
Do not write: a 40 word description when the LENGTH line asked for at least 100.
|
||||
Every sentence ENDS WITH A FULL STOP. Never chain them together with commas.
|
||||
Do not write: the man lifts the cup, the steam rises from the rim, he sets it down on the saucer.
|
||||
Write: The man lifts the cup. The steam rises from the rim. He sets it down on the saucer.
|
||||
Only what the viewer can SEE or HEAR. Never a smell, a taste or a feeling.
|
||||
Do not write: the air is cool, with a faint scent of earth and pine.
|
||||
|
||||
3. DIALOGUE APPEARS ONCE. If someone speaks, the words appear one time only, inside <d>[English] the words</d>. Nothing anywhere else may repeat, hint at or summarise what is said.
|
||||
Do not write: She tells him she is leaving.
|
||||
|
||||
4. EVERY spoken line uses this exact shape, with no shortcut and nothing left out:
|
||||
HIS OR HER jaw and lips move clearly through every word, and the WHO with a WHAT KIND OF voice (S1) says: <d>[English] the words</d> He closes his lips (or She closes her lips) and ONE ACTION.
|
||||
Replace only the capital words with your own. Number the speakers (S1), then (S2) for a second one.
|
||||
|
||||
EVERY <d> BLOCK STARTS WITH THE TAG [English] AND A SPACE. Copy those square brackets exactly. The spoken words begin with a capital letter and end with a full stop, a question mark or an exclamation mark.
|
||||
Do not write: <d>This needs more salt.</d>
|
||||
Do not write: <d>we missed our flight</d>
|
||||
Write: <d>[English] This needs more salt.</d>
|
||||
Write: <d>[English] We missed our flight!</d>
|
||||
Do not write: <d>[English] We missed our flight.</d> He slams his fist on the wheel.
|
||||
Write: his jaw and lips move clearly through every word, and the tired chef with a low, rough voice (S1) says: <d>[English] This needs more salt.</d> He closes his lips and lowers the spoon to the counter.
|
||||
After the spoken line there is room for ONE more action, not two.
|
||||
|
||||
5. Camera moves are lowercase inside the sentence. Use at most TWO of them, and each one ends with one of these four phrases, spelled exactly:
|
||||
with small amplitude
|
||||
with large amplitude
|
||||
at slow speed
|
||||
at fast speed
|
||||
A calm scene takes small amplitude and slow speed. Only a genuinely energetic scene takes large amplitude or fast speed. These words are banned: slightly, subtly, gently, a little, gradually.
|
||||
Do not write: with slow speed.
|
||||
Do not write: the camera tilts subtly upward.
|
||||
Write: the camera pushes in with small amplitude at slow speed.
|
||||
|
||||
6. non_diegetic_music names instruments, tempo and volume that suit YOUR scene. Choose the instruments yourself.
|
||||
|
||||
7. NOBODY TALKS IN overall_soundscape. List three to five sounds that really exist in the place in the IDEA, and nothing else. These words are BANNED from that field: voice, voices, talking, talk, chatter, murmur, mutter, conversation, speech, speaking, whisper, shouting, singing, lyrics, crowd noise.
|
||||
A cafe, a restaurant, a bar, a shop, a station or any other crowded place is still SILENT of people here. Name the OBJECTS instead of the people: cups, plates, chairs, machines, doors, footsteps, traffic.
|
||||
Do not write: faint chatter from passing cars.
|
||||
Do not write: muffled voices of staff.
|
||||
Do not write: soft chatter of cafe customers.
|
||||
Do not write: low murmur of the busy room.
|
||||
Never ask for silence or clean audio. There is always some room tone.
|
||||
|
||||
EXAMPLE OF A PERFECT ANSWER. Copy its shape only, never its words, its workshop or its sounds. Its length is not a target: the LENGTH line at the end of this message is what decides how long yours must be.
|
||||
|
||||
integrated_multimodal_description: [Shot 1] Live-action, cinematic, shallow depth of field, warm tungsten light. An elderly watchmaker sits at a cluttered wooden workbench in a narrow shop, brass gears and fine screwdrivers scattered across a green felt mat, the painted wall behind him cracked and peeling. A loupe is clamped over his right eye and the lens catches a small circle of lamplight. His hands are spotted and steady, the cuffs of his grey cardigan frayed at the wrist, and a thin curl of dust drifts through the beam of the desk lamp above him. He lowers a pair of fine tweezers into the open back of a pocket watch, settles a hairspring into its seat, and the tiny balance wheel begins to swing. He straightens his shoulders, lifts the loupe away from his eye, and the deep lines around it soften in the lamplight. His jaw and lips move clearly through every word, and the elderly watchmaker with a dry, quiet voice (S1) says: <d>[English] Forty years and it still surprises me.</d> He closes his lips, sets the tweezers down on the felt, and rests one hand flat on the bench as the camera pushes in with small amplitude at slow speed toward the turning wheel.
|
||||
|
||||
overall_soundscape: Faint metallic tick of the escapement, soft scrape of tweezers on felt, low electrical hum from the desk lamp, occasional creak of an old wooden chair.
|
||||
|
||||
non_diegetic_music: Sparse solo piano, slow tempo, soft sustained notes.
|
||||
|
||||
LAST CHECK, never printed:
|
||||
No camera word from the IDEA became a thing in the scene, and no drone, cameraman or lens appears.
|
||||
NO word for talking or voices anywhere in overall_soundscape.
|
||||
If the IDEA gave me no words to say, there is no <d> block at all. Every <d> block starts with the tag [English]. Every spoken line has the jaw and lips moving before it, an (S1) tag on the speaker, and He closes his lips or She closes her lips straight after </d>.
|
||||
Every camera move ends with one of the four exact phrases, and there are at most two.
|
||||
Three fields only, no line break inside any of them, English only.
|
||||
The description is inside the word range the LENGTH line asked for, and never under it.
|
||||
There is only [Shot 1] in the whole answer.
|
||||
Do not write: [Shot 2] At 00:05.000, the camera cuts to a close-up.
|
||||
|
||||
IDEA:
|
||||
@@ -1,61 +0,0 @@
|
||||
# Inpaint Crop / Stitch math
|
||||
|
||||
Single source of truth for the Inpaint Crop Pixaroma + Inpaint Stitch Pixaroma geometry and seam math. The Python in `nodes/_inpaint_helpers.py` is authoritative (runs at execute time); the JS mirrors it for the editor's live preview. Update this doc first, then both sides.
|
||||
|
||||
## 1. Crop region (`compute_region`)
|
||||
|
||||
Mirrored 1:1 by `js/inpaint_crop/geometry.mjs::computeRegion`. From the painted mask's bounding box and the image size:
|
||||
|
||||
1. Context expand: `rw = bw + 2*max(context_px, blend) + bw*context_pct/100` (same for height). Using `max(context_px, blend)` rather than just `context_px` gives the seam feather room to reach 0 inside the crop, so a big softness grows the crop instead of leaving a hard seam (Option B; see section 3).
|
||||
2. Place + clamp the SOURCE crop rect inside the image (centred on the mask). In `force` mode the wanted region is first grown to the `target_w/target_h` aspect, then AFTER the clamp the source aspect is re-imposed to that aspect (the W/H clamp can clip one axis and not the other - shrink the over-long axis to the largest aspect-correct rect that fits).
|
||||
3. Compute the OUTPUT size FROM the clamped source rect (NEVER the un-clamped wanted region), so the crop is resized at its real aspect and is never stretched when the image edge clipped the region (e.g. a big softness on a mask near the border - the source clamps to the image, so the output must too, or the crop/mask comes out squished):
|
||||
- `keep`: scale the cropped rect's long side to `target` (respecting `allow_upscale`); then `min_size` FIRST (bump both sides up so the short side reaches `min_size`), then `max_size` LAST as a hard ceiling (so an extreme-aspect crop can't blow the long side past `max_size` into an OOM tensor; the short side may then end below `min_size` - acceptable). Round each side to `multiple`.
|
||||
- `force`: the fixed `target_w x target_h` (the source was already re-imposed to that aspect in step 2).
|
||||
- `free`: the cropped size rounded to `multiple`, capped at `max_size`.
|
||||
|
||||
No accepted drift: the editor's box, rect and output size equal the node's exactly. Two rules make that true. Every rounding in the JS is half-to-even, like Python's `round()` (half-up once made the editor read 904 where the node made 896). And the editor's mask box counts only pixels at alpha >= 128, the node's `mask > 0.5`; counting the brush's faint anti-aliased rim made it 1 px wider each side (728 vs 720).
|
||||
|
||||
## 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.
|
||||
@@ -28,14 +28,7 @@ draws the pill un-skewed then the text under a skew transform.
|
||||
|
||||
## 1b. Variable Font Handling
|
||||
|
||||
Most bundled fonts are variable. Catalog entry has a `wght` field activated at draw time. Browser: FontFace loaded with `weight: "100 900"`, canvas font string picks weight. Static fonts (Bebas Neue, Anton) skip the axis step.
|
||||
|
||||
Python PIL sets the axes BY NAME, never by position (`set_variation_by_axes` takes one value per axis in the FONT's own order):
|
||||
- the Weight axis -> `wght`;
|
||||
- the Optical size axis -> the LOGICAL font size in px (`fontSize`, never the 3x supersampled size of §13), clamped to the axis range. This is what the browser's canvas does: measured in Chrome with the font genuinely loaded, 10 and 12 px use 14, 16 -> 16, 20 -> 20, 28 -> 28, 40 -> 32 (Inter's range is 14..32);
|
||||
- every other axis -> its default.
|
||||
|
||||
Why: Inter lists Optical size BEFORE Weight, so the old `set_variation_by_axes([wght])` set Inter's optical size to the weight (clamped to 32) and left it Regular - "Inter Bold" rendered Regular in the output while the preview showed Bold (measured: 638 px wide against the browser's 685 at 40 px). A font whose axes carry no recognisable "weight" name falls back to the old positional behaviour (first axis = weight), which is what every other bundled font already gets right.
|
||||
Most bundled fonts are variable. Catalog entry has a `wght` field activated at draw time. Browser: FontFace loaded with `weight: "100 900"`, canvas font string picks weight. Python PIL: `font.set_variation_by_axes([wght])` after truetype load. Static fonts (Bebas Neue, Anton) skip the axis step.
|
||||
|
||||
## 2. Text Measurement
|
||||
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
// Pixaroma 3D — API helpers
|
||||
//
|
||||
// NOTE: do NOT wrap these routes in pixApiUrl(). `api.fetchApi` already calls
|
||||
// `api.apiURL()` on the route it is given, and apiURL is NOT idempotent when
|
||||
// ComfyUI is served under a sub-path:
|
||||
//
|
||||
// apiURL(e) = e.startsWith("/api") ? api_base + e : api_base + "/api" + e
|
||||
//
|
||||
// With api_base "" (localhost) a double wrap is a silent no-op, which is why it
|
||||
// tests clean. With api_base "/comfy" (a reverse proxy serving ComfyUI at
|
||||
// https://host/comfy/) the second pass no longer sees a leading "/api" and
|
||||
// prefixes again: /comfy/api/comfy/api/pixaroma/api/3d/save -> 404.
|
||||
// pixApiUrl is for URLs we hand to fetch/import/img.src OURSELVES, not for
|
||||
// routes passed to fetchApi.
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
|
||||
export class ThreeDAPI {
|
||||
static async saveRender(projectId, dataURL) {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// Pixaroma 3D Editor — Core class, constructor, UI building
|
||||
// ============================================================
|
||||
import { ThreeDAPI } from "./api.mjs";
|
||||
import { pixApiUrl, pixAsset } from "../shared/api_url.mjs";
|
||||
import { installGraphUndoGuard } from "../shared/graph_undo_guard.mjs";
|
||||
import { openShapePicker } from "./picker.mjs";
|
||||
import {
|
||||
@@ -36,18 +35,6 @@ export let THREE = null,
|
||||
// Local vendored three.js (served by server_routes.py @ /pixaroma/vendor/three/)
|
||||
// keeps the 3D Builder working offline and pins the version so upstream
|
||||
// Three.js breaking changes can't silently break saved scenes.
|
||||
//
|
||||
// Deliberately a BARE base: it is wrapped at each use (`pixApiUrl(THREE_VENDOR +
|
||||
// "/three.mjs")`), never pre-wrapped, because the host appends its auth token as
|
||||
// a query string and a pre-wrapped base would bury the token mid-url.
|
||||
//
|
||||
// KNOWN LIMIT on a token-gated host: only the ENTRY url can carry a token. The
|
||||
// vendored addons import three by RELATIVE specifier ("../../../three.mjs"), and
|
||||
// a relative specifier resolves against the importer's PATH and inherits no query
|
||||
// string - so those sub-imports arrive token-less and are refused. The 3D Builder
|
||||
// therefore cannot load its three.js on such a host no matter how this line is
|
||||
// written. Fixing it means changing how the vendored tree is served (a bundled
|
||||
// single file, or a token-free route for /pixaroma/vendor), not changing this.
|
||||
export const THREE_VENDOR = "/pixaroma/vendor/three";
|
||||
export async function loadThree() {
|
||||
if (THREE) return;
|
||||
@@ -55,13 +42,13 @@ export async function loadThree() {
|
||||
// that showed as a gray flash when the editor first opened.
|
||||
const [threeMod, orbitMod, transformMod, composerMod, renderMod, outlineMod, outputMod] =
|
||||
await Promise.all([
|
||||
import(pixApiUrl(THREE_VENDOR + "/three.mjs")),
|
||||
import(pixApiUrl(THREE_VENDOR + "/examples/jsm/controls/OrbitControls.mjs")),
|
||||
import(pixApiUrl(THREE_VENDOR + "/examples/jsm/controls/TransformControls.mjs")),
|
||||
import(pixApiUrl(THREE_VENDOR + "/examples/jsm/postprocessing/EffectComposer.mjs")),
|
||||
import(pixApiUrl(THREE_VENDOR + "/examples/jsm/postprocessing/RenderPass.mjs")),
|
||||
import(pixApiUrl(THREE_VENDOR + "/examples/jsm/postprocessing/OutlinePass.mjs")),
|
||||
import(pixApiUrl(THREE_VENDOR + "/examples/jsm/postprocessing/OutputPass.mjs")),
|
||||
import(THREE_VENDOR + "/three.mjs"),
|
||||
import(THREE_VENDOR + "/examples/jsm/controls/OrbitControls.mjs"),
|
||||
import(THREE_VENDOR + "/examples/jsm/controls/TransformControls.mjs"),
|
||||
import(THREE_VENDOR + "/examples/jsm/postprocessing/EffectComposer.mjs"),
|
||||
import(THREE_VENDOR + "/examples/jsm/postprocessing/RenderPass.mjs"),
|
||||
import(THREE_VENDOR + "/examples/jsm/postprocessing/OutlinePass.mjs"),
|
||||
import(THREE_VENDOR + "/examples/jsm/postprocessing/OutputPass.mjs"),
|
||||
]);
|
||||
THREE = threeMod;
|
||||
OrbitControls = orbitMod.OrbitControls;
|
||||
@@ -702,7 +689,7 @@ export class Pixaroma3DEditor {
|
||||
pc.insertBefore(bgFileInput, pc.firstChild);
|
||||
const uploadBtn = createButton("Upload Background Image", {
|
||||
variant: "full",
|
||||
iconSrc: pixAsset("icons/ui/upload.svg"),
|
||||
iconSrc: "/pixaroma/assets/icons/ui/upload.svg",
|
||||
onClick: () => bgFileInput.click(),
|
||||
title: "Browse for a background image",
|
||||
});
|
||||
@@ -767,7 +754,7 @@ export class Pixaroma3DEditor {
|
||||
ico.style.cssText = "width:30px;height:30px;";
|
||||
ico.setAttribute("role", "img");
|
||||
ico.setAttribute("aria-label", label);
|
||||
const iconUrl = `url("${pixAsset(`icons/3D/${iconFile}`)}")`;
|
||||
const iconUrl = `url("/pixaroma/assets/icons/3D/${iconFile}")`;
|
||||
ico.style.webkitMaskImage = iconUrl;
|
||||
ico.style.maskImage = iconUrl;
|
||||
const lbl = document.createElement("span");
|
||||
@@ -1004,7 +991,7 @@ export class Pixaroma3DEditor {
|
||||
// lets hover tinting to orange drop in via CSS later.
|
||||
const iconEl = document.createElement("span");
|
||||
iconEl.className = "pxf-tool-btn-icon";
|
||||
const iconUrl = `url("${pixAsset(`icons/3D/${t.ico}`)}")`;
|
||||
const iconUrl = `url("/pixaroma/assets/icons/3D/${t.ico}")`;
|
||||
iconEl.style.cssText =
|
||||
`display:block;width:22px;height:22px;margin:0 auto 2px;` +
|
||||
`background-color:#ccc;` +
|
||||
@@ -1054,7 +1041,7 @@ export class Pixaroma3DEditor {
|
||||
cam.content.appendChild(perspRow);
|
||||
const focusBtn = createButton("Focus Selected (0)", {
|
||||
variant: "standard",
|
||||
iconSrc: pixAsset("icons/3D/focus.svg"),
|
||||
iconSrc: "/pixaroma/assets/icons/3D/focus.svg",
|
||||
onClick: () => this._camView("focus"),
|
||||
title: "Center camera on selected object",
|
||||
});
|
||||
@@ -1400,7 +1387,7 @@ export class Pixaroma3DEditor {
|
||||
// or shrinks.
|
||||
Pixaroma3DEditor.prototype._buildAlignDistributeBar = function (titlebarCenter) {
|
||||
if (!titlebarCenter) return;
|
||||
const AD_ICON_PATH = "icons/ui/";
|
||||
const AD_ICON_PATH = "/pixaroma/assets/icons/ui/";
|
||||
|
||||
// Build one icon button. Mask-image approach so the icon color is
|
||||
// driven by CSS — matches the other tool/camera buttons and gives
|
||||
@@ -1411,7 +1398,7 @@ Pixaroma3DEditor.prototype._buildAlignDistributeBar = function (titlebarCenter)
|
||||
b.title = title;
|
||||
const ico = document.createElement("span");
|
||||
ico.className = "p3d-ad-ico";
|
||||
const url = `url("${pixAsset(AD_ICON_PATH + iconFile)}")`;
|
||||
const url = `url("${AD_ICON_PATH}${iconFile}")`;
|
||||
ico.style.cssText =
|
||||
`display:block;width:18px;height:18px;` +
|
||||
`background-color:#ccc;` +
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
// bunny) so repeat Bunny clicks don't refetch / re-parse.
|
||||
// ============================================================
|
||||
import { Pixaroma3DEditor, getTHREE, THREE_VENDOR } from "./core.mjs";
|
||||
import { pixApiUrl } from "../shared/api_url.mjs";
|
||||
import { ThreeDAPI } from "./api.mjs";
|
||||
|
||||
let _GLTFLoader = null;
|
||||
@@ -16,25 +15,21 @@ const _assetCache = new Map(); // url → cached Group
|
||||
|
||||
async function getGLTFLoader() {
|
||||
if (_GLTFLoader) return _GLTFLoader;
|
||||
// pixApiUrl on the COMPLETE tail, exactly as core.mjs::loadThree does. Both
|
||||
// must agree: every addon here does `import "../../../three.mjs"`, so a bare
|
||||
// URL here would resolve to /pixaroma/vendor/three/three.mjs while core loaded
|
||||
// /api/pixaroma/vendor/three/three.mjs — a SECOND three.js instance.
|
||||
const mod = await import(pixApiUrl(THREE_VENDOR + "/examples/jsm/loaders/GLTFLoader.mjs"));
|
||||
const mod = await import(THREE_VENDOR + "/examples/jsm/loaders/GLTFLoader.mjs");
|
||||
_GLTFLoader = mod.GLTFLoader;
|
||||
return _GLTFLoader;
|
||||
}
|
||||
|
||||
async function getOBJLoader() {
|
||||
if (_OBJLoader) return _OBJLoader;
|
||||
const mod = await import(pixApiUrl(THREE_VENDOR + "/examples/jsm/loaders/OBJLoader.mjs"));
|
||||
const mod = await import(THREE_VENDOR + "/examples/jsm/loaders/OBJLoader.mjs");
|
||||
_OBJLoader = mod.OBJLoader;
|
||||
return _OBJLoader;
|
||||
}
|
||||
|
||||
async function getMTLLoader() {
|
||||
if (_MTLLoader) return _MTLLoader;
|
||||
const mod = await import(pixApiUrl(THREE_VENDOR + "/examples/jsm/loaders/MTLLoader.mjs"));
|
||||
const mod = await import(THREE_VENDOR + "/examples/jsm/loaders/MTLLoader.mjs");
|
||||
_MTLLoader = mod.MTLLoader;
|
||||
return _MTLLoader;
|
||||
}
|
||||
@@ -42,7 +37,7 @@ async function getMTLLoader() {
|
||||
async function getMergeVertices() {
|
||||
if (_mergeVertices) return _mergeVertices;
|
||||
const mod = await import(
|
||||
pixApiUrl(THREE_VENDOR + "/examples/jsm/utils/BufferGeometryUtils.mjs")
|
||||
THREE_VENDOR + "/examples/jsm/utils/BufferGeometryUtils.mjs"
|
||||
);
|
||||
_mergeVertices = mod.mergeVertices;
|
||||
return _mergeVertices;
|
||||
@@ -196,9 +191,9 @@ export function viewURLForStoredPath(path) {
|
||||
const parts = path.split("/");
|
||||
const fname = parts.pop();
|
||||
const subfolder = parts.join("/");
|
||||
return pixApiUrl("/view?filename=" + encodeURIComponent(fname)
|
||||
return "/view?filename=" + encodeURIComponent(fname)
|
||||
+ "&type=input&subfolder=" + encodeURIComponent(subfolder)
|
||||
+ "&t=" + Date.now());
|
||||
+ "&t=" + Date.now();
|
||||
}
|
||||
|
||||
// Read a File as a base64 data URL (used by the upload pipeline).
|
||||
@@ -224,9 +219,9 @@ async function uploadOne(projectId, file) {
|
||||
const parts = res.path.split("/");
|
||||
const fname = parts.pop();
|
||||
const subfolder = parts.join("/");
|
||||
const url = pixApiUrl("/view?filename=" + encodeURIComponent(fname)
|
||||
const url = "/view?filename=" + encodeURIComponent(fname)
|
||||
+ "&type=input&subfolder=" + encodeURIComponent(subfolder)
|
||||
+ "&t=" + Date.now());
|
||||
+ "&t=" + Date.now();
|
||||
return { path: res.path, storedName: res.filename, url, origName: file.name };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ============================================================
|
||||
// Pixaroma 3D Editor — Entry point (ComfyUI widget registration)
|
||||
// ============================================================
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
|
||||
// Import core class first, then mixin files (side-effect imports add methods to prototype)
|
||||
import { Pixaroma3DEditor } from "./core.mjs";
|
||||
@@ -12,7 +12,6 @@ import "./shape_params.mjs";
|
||||
import "./interaction.mjs";
|
||||
import "./persistence.mjs";
|
||||
import "./importer.mjs";
|
||||
import { registerNodeAccent } from "../shared/node_settings.mjs";
|
||||
|
||||
import {
|
||||
allow_debug,
|
||||
@@ -22,22 +21,25 @@ import {
|
||||
activateNodePreview,
|
||||
downloadDataURL,
|
||||
applyAdaptiveCanvasOnly,
|
||||
installCanvasZoomPassthrough,
|
||||
} from "../shared/index.mjs";
|
||||
import { growToCoreMinHeight, capComputeSizeInNodes2 } from "../shared/core_min_size.mjs";
|
||||
|
||||
app.registerExtension({
|
||||
name: "Pixaroma.3DEditor",
|
||||
|
||||
// No Settings-panel row: this option lives on the node itself (the gear in
|
||||
// the selection toolbar / the right-click entry). The setting id is unchanged
|
||||
// and merely unregistered, so an existing choice carries over; the read site
|
||||
// supplies the default for the unset case.
|
||||
settings: [
|
||||
{
|
||||
id: "Pixaroma.3D.DefaultBgColor",
|
||||
name: "Default Background Color — 3D Builder (default #6e6e6e)",
|
||||
type: "color",
|
||||
defaultValue: "#6e6e6e",
|
||||
tooltip: "Color used as the background for new 3D scenes. Default is #6e6e6e (neutral gray). NOTE: ComfyUI's color field shows saved values without '#' but requires '#' when typing — enter '#6e6e6e' to reset, or use the color picker.",
|
||||
category: ["👑 Pixaroma", "3D Builder"],
|
||||
},
|
||||
],
|
||||
|
||||
// Handle execution result (OUTPUT_NODE = True on python side)
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== "Pixaroma3D") return;
|
||||
capComputeSizeInNodes2(nodeType); // Nodes 2.0 Ctrl+Z grow on frontend 1.53 (3d-builder.md #13)
|
||||
|
||||
const originalOnExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
@@ -124,7 +126,6 @@ app.registerExtension({
|
||||
});
|
||||
|
||||
// ── DOM widget (sent to Python as kwargs["SceneWidget"]) ──
|
||||
installCanvasZoomPassthrough(parts.container);
|
||||
let widget = node.addDOMWidget("SceneWidget", "custom", parts.container, {
|
||||
// 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).
|
||||
@@ -141,9 +142,6 @@ app.registerExtension({
|
||||
margin: 5,
|
||||
});
|
||||
applyAdaptiveCanvasOnly(widget);
|
||||
// Classic fresh drop: start at core's own height (312), or the first Ctrl+Z
|
||||
// grows the node and jams undo (3d-builder.md #12).
|
||||
growToCoreMinHeight(node);
|
||||
|
||||
// cleanup when node is removed
|
||||
node.onRemoved = () => {
|
||||
@@ -161,17 +159,3 @@ app.registerExtension({
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export { Pixaroma3DEditor } from "./core.mjs";
|
||||
|
||||
// The colour option is not offered: this node's face is ComfyUI's own grey
|
||||
// button plus a preview, so there is no Pixaroma orange on it to change. The
|
||||
// panel exists purely to host the scene background, which used to sit in the
|
||||
// global Settings panel.
|
||||
registerNodeAccent("Pixaroma3D", {
|
||||
title: "3D Builder",
|
||||
accent: false,
|
||||
rows: [
|
||||
{ kind: "color", setting: "Pixaroma.3D.DefaultBgColor", defaultValue: "#6e6e6e",
|
||||
label: "Background colour for new scenes",
|
||||
hint: "The colour a fresh 3D scene starts on. Existing scenes keep theirs." },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// Pixaroma 3D Editor — Tools, camera, keyboard, click, undo
|
||||
// ============================================================
|
||||
import { Pixaroma3DEditor, getTHREE } from "./core.mjs";
|
||||
import { pixAsset } from "../shared/api_url.mjs";
|
||||
import { isCompositeType, buildComposite } from "./composites.mjs";
|
||||
import { openShapePicker } from "./picker.mjs";
|
||||
// Static import of the SYNCHRONOUS importer helper so the composite
|
||||
@@ -664,7 +663,7 @@ Pixaroma3DEditor.prototype._applySnap = function (state) {
|
||||
const { loadGLBFromURL, prepareImportedGroup, wrapImportPivot } = mod;
|
||||
try {
|
||||
const innerGroup = await loadGLBFromURL(
|
||||
pixAsset("models/bunny.glb"),
|
||||
"/pixaroma/assets/models/bunny.glb",
|
||||
);
|
||||
const idx = this.objects.indexOf(ph);
|
||||
if (idx === -1) return;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// Pixaroma 3D Editor — Save/restore, background image mgmt
|
||||
// ============================================================
|
||||
import { Pixaroma3DEditor, getTHREE, ThreeDAPI } from "./core.mjs";
|
||||
import { pixApiUrl, pixAsset } from "../shared/api_url.mjs";
|
||||
import { loadTeapotGeometry, getShapeDefaults } from "./shapes.mjs";
|
||||
import {
|
||||
COMPOSITES, isCompositeType, buildComposite, getCompositeDefaults,
|
||||
@@ -252,13 +251,13 @@ Pixaroma3DEditor.prototype._restoreScene = function (jsonStr) {
|
||||
const parts = d.bgImage.path.replace(/\\/g, "/").split("/");
|
||||
const fname = parts.pop();
|
||||
const subfolder = parts.join("/") || "pixaroma";
|
||||
const imgSrc = pixApiUrl(
|
||||
const imgSrc =
|
||||
"/view?filename=" +
|
||||
encodeURIComponent(fname) +
|
||||
"&type=input&subfolder=" +
|
||||
encodeURIComponent(subfolder) +
|
||||
"&t=" +
|
||||
Date.now());
|
||||
Date.now();
|
||||
this._showBgImage(imgSrc, false);
|
||||
}
|
||||
// Start the session with nothing selected — the user picks which
|
||||
@@ -448,7 +447,7 @@ Pixaroma3DEditor.prototype._addObjFromData = function (od) {
|
||||
if (this._closed) return;
|
||||
const { loadGLBFromURL, prepareImportedGroup, wrapImportPivot } = mod;
|
||||
try {
|
||||
const innerGroup = await loadGLBFromURL(pixAsset("models/bunny.glb"));
|
||||
const innerGroup = await loadGLBFromURL("/pixaroma/assets/models/bunny.glb");
|
||||
// Replace placeholder in-place so the layer panel entry and
|
||||
// object ordering don't shuffle.
|
||||
const idx = this.objects.indexOf(placeholder);
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
|
||||
import { SHAPES, loadTeapotGeometry } from "./shapes.mjs";
|
||||
import { COMPOSITES } from "./composites.mjs";
|
||||
import { pixAsset } from "../shared/api_url.mjs";
|
||||
|
||||
// ─── Category layout (5 columns per row) ────────────────────
|
||||
// One flat list per section. Order inside each section is the
|
||||
@@ -191,7 +190,7 @@ async function spawnItem(editor, item) {
|
||||
const { loadGLBFromURL } = await import("./importer.mjs");
|
||||
try {
|
||||
const group = await loadGLBFromURL(
|
||||
pixAsset("models/bunny.glb"),
|
||||
"/pixaroma/assets/models/bunny.glb",
|
||||
);
|
||||
editor._addImportedGroup(group, "bunny", { name: "Bunny" });
|
||||
} catch (e) {
|
||||
@@ -309,7 +308,7 @@ export function openShapePicker(editor) {
|
||||
ico.className = "p3d-picker-ico";
|
||||
ico.setAttribute("role", "img");
|
||||
ico.setAttribute("aria-label", info.label);
|
||||
const iconUrl = `url("${pixAsset(`icons/3D/${info.icon}`)}")`;
|
||||
const iconUrl = `url("/pixaroma/assets/icons/3D/${info.icon}")`;
|
||||
ico.style.webkitMaskImage = iconUrl;
|
||||
ico.style.maskImage = iconUrl;
|
||||
const lbl = document.createElement("span");
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
// object's geoParams, rebuilds geometry on change.
|
||||
// ============================================================
|
||||
import { Pixaroma3DEditor, getTHREE } from "./core.mjs";
|
||||
import { pixAsset } from "../shared/api_url.mjs";
|
||||
import { SHAPES, buildGeometry } from "./shapes.mjs";
|
||||
import { isCompositeType, COMPOSITES, buildComposite, getCompositeDefaults } from "./composites.mjs";
|
||||
|
||||
@@ -76,7 +75,7 @@ Pixaroma3DEditor.prototype._rebuildShapePanel = function () {
|
||||
head.style.cssText =
|
||||
"display:flex;align-items:center;gap:6px;margin-bottom:6px;";
|
||||
const icon = document.createElement("img");
|
||||
icon.src = pixAsset(`icons/3D/${composite.icon}`);
|
||||
icon.src = `/pixaroma/assets/icons/3D/${composite.icon}`;
|
||||
icon.style.cssText = "width:16px;height:16px;filter:invert(90%);";
|
||||
const name = document.createElement("span");
|
||||
name.textContent = composite.label;
|
||||
@@ -242,7 +241,7 @@ Pixaroma3DEditor.prototype._rebuildShapePanel = function () {
|
||||
head.style.cssText =
|
||||
"display:flex;align-items:center;gap:6px;margin-bottom:6px;";
|
||||
const icon = document.createElement("img");
|
||||
icon.src = pixAsset(`icons/3D/${shape.icon}`);
|
||||
icon.src = `/pixaroma/assets/icons/3D/${shape.icon}`;
|
||||
icon.style.cssText = "width:16px;height:16px;filter:invert(90%);";
|
||||
const name = document.createElement("span");
|
||||
name.textContent = shape.label;
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
// `live: false` = rebuild debounced 60 ms (heavy shapes).
|
||||
|
||||
import { THREE_VENDOR } from "./core.mjs";
|
||||
import { pixApiUrl } from "../shared/api_url.mjs";
|
||||
|
||||
// ─── Lazy-loaded Teapot geometry ──────────────────────────────
|
||||
// TeapotGeometry lives in three/examples and weighs ~20KB. We fetch
|
||||
@@ -21,10 +20,8 @@ import { pixApiUrl } from "../shared/api_url.mjs";
|
||||
let _TeapotGeometry = null;
|
||||
export async function loadTeapotGeometry() {
|
||||
if (_TeapotGeometry) return _TeapotGeometry;
|
||||
// pixApiUrl on the COMPLETE tail — must match core.mjs::loadThree, or this
|
||||
// addon's own `import "../../../three.mjs"` pulls a SECOND three.js instance.
|
||||
const mod = await import(
|
||||
pixApiUrl(THREE_VENDOR + "/examples/jsm/geometries/TeapotGeometry.mjs")
|
||||
THREE_VENDOR + "/examples/jsm/geometries/TeapotGeometry.mjs"
|
||||
);
|
||||
_TeapotGeometry = mod.TeapotGeometry;
|
||||
return _TeapotGeometry;
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
// AI Prompt Pixaroma - the one thing this node asks the server for.
|
||||
//
|
||||
// The formula lives ON the node, so there is nothing here to save, reset,
|
||||
// import or export server-side. All that is left is the list of models on
|
||||
// disk for the picker.
|
||||
//
|
||||
// Re-fetched on every panel open (convention #18): a custom picker backed by
|
||||
// our own route gets NOTHING from ComfyUI's R refresh, so a session cache
|
||||
// would look permanently stale after somebody renames a file.
|
||||
|
||||
import { pixApiUrl } from "../shared/api_url.mjs";
|
||||
|
||||
/**
|
||||
* { ok, models: [...], error? }
|
||||
*
|
||||
* Never rejects. The panel must still open and say what is wrong when the
|
||||
* server is unreachable, rather than showing an empty picker with no
|
||||
* explanation - an empty folder and a failed scan must not look identical.
|
||||
*/
|
||||
async function post(route, body) {
|
||||
try {
|
||||
const res = await fetch(pixApiUrl("/pixaroma/api/ai_prompt" + route), {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
const data = await res.json();
|
||||
return { ok: !!data?.ok, message: String(data?.message || "") };
|
||||
} catch (e) {
|
||||
return { ok: false, message: String(e?.message || e) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shipped presets and the user's own.
|
||||
*
|
||||
* A preset is a formula AND the settings that make it work: the Krea 2 wording
|
||||
* rambles at temperature 0.7 and behaves at 0.3, so shipping the text without
|
||||
* the number would ship something that looks broken.
|
||||
*/
|
||||
export async function fetchPresets() {
|
||||
try {
|
||||
const res = await fetch(pixApiUrl("/pixaroma/api/ai_prompt/presets"), {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error("presets -> " + res.status);
|
||||
const data = await res.json();
|
||||
// The route answers 200 with an `error` field when its own read threw, so
|
||||
// ignoring that field turned a server-side failure into a confident empty
|
||||
// library - the exact lie the rest of this is built to avoid. Three
|
||||
// channels exist (HTTP status, `error`, `userError`); honour all three.
|
||||
if (data?.error) {
|
||||
return { ok: false, shipped: [], user: [], userError: false,
|
||||
error: String(data.error) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
shipped: Array.isArray(data?.shipped) ? data.shipped : [],
|
||||
user: Array.isArray(data?.user) ? data.user : [],
|
||||
// The server read the file and could not understand it. An empty library
|
||||
// and an unreadable one must never look the same: in the second case the
|
||||
// user still HAS presets, and saving would have overwritten them.
|
||||
userError: !!data?.userError,
|
||||
};
|
||||
} catch (e) {
|
||||
return { ok: false, shipped: [], user: [], userError: false,
|
||||
error: String(e?.message || e) };
|
||||
}
|
||||
}
|
||||
|
||||
export function savePreset(preset) {
|
||||
return post("/presets/save", preset);
|
||||
}
|
||||
|
||||
export function deletePreset(name) {
|
||||
return post("/presets/delete", { name });
|
||||
}
|
||||
|
||||
export async function fetchModels() {
|
||||
try {
|
||||
const res = await fetch(pixApiUrl("/pixaroma/api/ai_prompt/models"), {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error("models -> " + res.status);
|
||||
const data = await res.json();
|
||||
return {
|
||||
ok: !data?.error,
|
||||
models: Array.isArray(data?.models) ? data.models : [],
|
||||
// Byte size per name, for the picker's size label. A SEPARATE map on
|
||||
// purpose: `models` stays a plain string array because several
|
||||
// `.includes(...)` checks in the panels depend on it.
|
||||
sizes: (data && typeof data.sizes === "object" && data.sizes) || {},
|
||||
error: data?.error ? String(data.error) : null,
|
||||
};
|
||||
} catch (e) {
|
||||
return { ok: false, models: [], sizes: {}, error: String(e?.message || e) };
|
||||
}
|
||||
}
|
||||
@@ -1,418 +0,0 @@
|
||||
// AI Prompt Pixaroma - state.
|
||||
//
|
||||
// Vue Compat #9: everything lives on node.properties.aiPromptState and is
|
||||
// injected into the hidden AIPromptState input at graphToPrompt time, so the
|
||||
// node has no visible widgets and no stray input dots beyond the five real
|
||||
// ones (clip, image, video, audio, text).
|
||||
//
|
||||
// THE KEYS ARE snake_case ON PURPOSE, matching Video Prompt: Python's
|
||||
// parse_state reads them directly, so there is no camelCase-to-snake mapping
|
||||
// layer to get out of step.
|
||||
//
|
||||
// THE FORMULA IS IN HERE, not in a file. That is the deliberate difference
|
||||
// from Video Prompt. Three consequences, all wanted:
|
||||
// - a duplicate of the node gets its own copy, so a chain can hold three
|
||||
// different instructions;
|
||||
// - a shared workflow carries its instructions with it;
|
||||
// - the formula is part of the node's cache signature, so editing it
|
||||
// re-runs by itself. Video Prompt's formulas live on disk and needed an
|
||||
// IS_CHANGED fingerprint bolted on afterwards, because editing one
|
||||
// changed nothing until something else did (video-prompt.md #8).
|
||||
|
||||
export const CLASS = "PixaromaAIPrompt";
|
||||
export const HIDDEN_INPUT = "AIPromptState";
|
||||
export const STATE_PROP = "aiPromptState";
|
||||
|
||||
export const MIN_W = 320;
|
||||
export const DEFAULT_W = 400;
|
||||
export const MIN_H = 300;
|
||||
export const DEFAULT_H = 430;
|
||||
|
||||
export const SEED_FIXED = "fixed";
|
||||
export const SEED_RANDOM = "random";
|
||||
|
||||
export const ORDER_IDEA = "idea";
|
||||
export const ORDER_WIRED = "wired";
|
||||
|
||||
// Mirrors Prompt Pixaroma's SEP_OPTIONS and the Python SEP_MAP, so wiring text
|
||||
// into either node behaves the same way.
|
||||
export const SEP_OPTIONS = [
|
||||
["newline", "New line"],
|
||||
["blank", "Blank line"],
|
||||
["space", "Space"],
|
||||
["comma", "Comma"],
|
||||
["period", "Full stop"],
|
||||
["pipe", "Pipe"],
|
||||
["break", "BREAK"],
|
||||
["none", "Nothing"],
|
||||
];
|
||||
|
||||
// Same ratio model as Video Prompt: the idea box and the prompt box share the
|
||||
// node's height, and the grip between them moves the line. A RATIO rather than
|
||||
// a pixel height because every rect measured inside a node body is in SCREEN
|
||||
// pixels (element px times the canvas zoom), so a stored pixel height is wrong
|
||||
// at every zoom but the one it was set at.
|
||||
export const IDEA_SHARE_DEFAULT = 0.36;
|
||||
export const IDEA_SHARE_MIN = 0.12;
|
||||
export const IDEA_SHARE_MAX = 0.8;
|
||||
|
||||
// There is NO expanded-preview state here, and that is deliberate. One was built
|
||||
// and taken out the same day: the face already carries a banner, two caption rows,
|
||||
// two text boxes, a grip and a button row, so on a 320x368 node the idea box is
|
||||
// already under its own 44px minimum. A third box collapsed to an 11px sliver, and
|
||||
// the only ways out were forcing the node taller than the user had made it or
|
||||
// letting the buttons spill past the frame. The tag COLOURS in the idea box already
|
||||
// answer "is this tag real", which is the part that earns its space; what a slot
|
||||
// rolled shows up in the generated prompt underneath. See ai-prompt.md #21.
|
||||
|
||||
export const DEFAULT_STATE = {
|
||||
// --- what Python reads --------------------------------------------------
|
||||
idea: "",
|
||||
formula: "",
|
||||
// Empty on purpose. Video Prompt ships a filename and auto-picks a
|
||||
// substitute because it cannot work without a vision model; this node CAN
|
||||
// work with none - it passes its text through - so there is nothing to
|
||||
// guess and nothing to go wrong on somebody else's machine.
|
||||
model: "",
|
||||
clip_type: "minimax",
|
||||
order: ORDER_IDEA,
|
||||
sep: "newline",
|
||||
seed: 0,
|
||||
// Core's Generate Text defaults, exactly, so a workflow moved between the
|
||||
// two nodes behaves the same (comfy_extras/nodes_textgen.py).
|
||||
temperature: 0.7,
|
||||
max_length: 512,
|
||||
top_k: 64,
|
||||
top_p: 0.95,
|
||||
min_p: 0.05,
|
||||
repetition_penalty: 1.05,
|
||||
presence_penalty: 0.0,
|
||||
do_sample: true,
|
||||
thinking: false,
|
||||
use_default_template: true,
|
||||
release_model: false,
|
||||
// The formula as the model's SYSTEM message, like core's TextGenerate
|
||||
// system_prompt (2026-10-16). Python decides whether the model can take one
|
||||
// (nodes/_ai_prompt_helpers.py plan_prompt) and says so when it cannot.
|
||||
system_formula: false,
|
||||
// Multi-token prediction, core Generate Text's `mtp` (auto / off). On = core's
|
||||
// default; off is for a card where MTP drafting crashes (ai-prompt.md #34).
|
||||
// Machine-specific, so deliberately NOT in SETTING_KEYS: a preset, a recipe or
|
||||
// Reset must never switch it back on.
|
||||
mtp: true,
|
||||
// NOTE: no "passthrough" flag, deliberately. Passing the text through is what
|
||||
// the node does when there is no model or nothing to send, and a switch to
|
||||
// turn that off could only mean "error instead", which is strictly worse.
|
||||
// --- face only, never sent (see PROMPT_KEYS) ----------------------------
|
||||
seed_mode: SEED_FIXED,
|
||||
idea_share: IDEA_SHARE_DEFAULT,
|
||||
};
|
||||
|
||||
// Exactly the keys Python reads. Anything outside this list is presentation,
|
||||
// and sending it would change the node's cache signature - so dragging the
|
||||
// grip to make the text box taller would re-run a 10 GB model
|
||||
// (reference_cosmetic_key_in_injected_state_recaches).
|
||||
const PROMPT_KEYS = [
|
||||
"idea", "formula", "model", "clip_type", "order", "sep", "seed",
|
||||
"temperature", "max_length", "top_k", "top_p", "min_p",
|
||||
"repetition_penalty", "presence_penalty", "do_sample", "thinking",
|
||||
"use_default_template", "release_model", "system_formula", "mtp",
|
||||
];
|
||||
|
||||
// Exactly what a PRESET may carry, and it must match SETTING_KEYS in
|
||||
// nodes/_ai_prompt_presets.py. The idea, the seed, the join order and the
|
||||
// separator are deliberately absent: those belong to the workflow and the
|
||||
// wiring, not to the recipe.
|
||||
export const SETTING_KEYS = [
|
||||
"temperature", "max_length", "top_k", "top_p", "min_p",
|
||||
"repetition_penalty", "presence_penalty", "do_sample", "thinking",
|
||||
"use_default_template", "system_formula",
|
||||
];
|
||||
|
||||
function num(value, fallback, lo, hi) {
|
||||
const out = typeof value === "number" ? value : parseFloat(value);
|
||||
if (!Number.isFinite(out)) return fallback;
|
||||
return Math.max(lo, Math.min(hi, out));
|
||||
}
|
||||
|
||||
function str(value, fallback) {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
export function readState(node) {
|
||||
const raw = node?.properties?.[STATE_PROP];
|
||||
const st = { ...DEFAULT_STATE, ...(raw && typeof raw === "object" ? raw : {}) };
|
||||
st.idea = str(st.idea, "");
|
||||
st.formula = str(st.formula, "");
|
||||
st.model = str(st.model, "").trim();
|
||||
st.clip_type = str(st.clip_type, "").trim() || "minimax";
|
||||
st.order = st.order === ORDER_WIRED ? ORDER_WIRED : ORDER_IDEA;
|
||||
st.sep = SEP_OPTIONS.some(([k]) => k === st.sep) ? st.sep : "newline";
|
||||
st.seed = Math.trunc(num(st.seed, 0, 0, Number.MAX_SAFE_INTEGER));
|
||||
st.seed_mode = st.seed_mode === SEED_RANDOM ? SEED_RANDOM : SEED_FIXED;
|
||||
st.temperature = num(st.temperature, 0.7, 0.01, 2);
|
||||
st.max_length = Math.trunc(num(st.max_length, 512, 1, 32768));
|
||||
st.top_k = Math.trunc(num(st.top_k, 64, 0, 1000));
|
||||
st.top_p = num(st.top_p, 0.95, 0, 1);
|
||||
st.min_p = num(st.min_p, 0.05, 0, 1);
|
||||
st.repetition_penalty = num(st.repetition_penalty, 1.05, 0, 5);
|
||||
st.presence_penalty = num(st.presence_penalty, 0, 0, 5);
|
||||
st.do_sample = st.do_sample !== false;
|
||||
st.thinking = st.thinking === true;
|
||||
st.use_default_template = st.use_default_template !== false;
|
||||
st.release_model = st.release_model === true;
|
||||
st.system_formula = st.system_formula === true;
|
||||
st.mtp = st.mtp !== false;
|
||||
st.idea_share = num(st.idea_share, IDEA_SHARE_DEFAULT, IDEA_SHARE_MIN, IDEA_SHARE_MAX);
|
||||
return st;
|
||||
}
|
||||
|
||||
export function writeState(node, patch) {
|
||||
if (!node) return { ...DEFAULT_STATE };
|
||||
const next = { ...readState(node), ...(patch || {}) };
|
||||
node.properties = node.properties || {};
|
||||
node.properties[STATE_PROP] = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wires
|
||||
// ---------------------------------------------------------------------------
|
||||
export function slotConnected(node, name) {
|
||||
const inputs = node?.inputs || [];
|
||||
for (const inp of inputs) {
|
||||
if (inp && inp.name === name) return inp.link != null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// image_2 ... image_8 (2026-09-29)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extra picture sockets, added one at a time as pictures are wired, because a
|
||||
// BATCH crops every picture to the first one's size. Python accepts them WITHOUT
|
||||
// listing them in the schema (nodes/_ai_prompt_helpers.py AIPromptInputs), so a
|
||||
// node saved before they existed never gains one on load - only a user's own
|
||||
// wiring adds or removes them (syncImageSlots, called from index.js).
|
||||
export const MAX_IMAGES = 8; // `image` + image_2 ... image_8, same cap as Python
|
||||
const EXTRA_IMAGE = /^image_(\d+)$/;
|
||||
|
||||
function extraNumber(inp) {
|
||||
const m = EXTRA_IMAGE.exec(inp?.name || "");
|
||||
const n = m ? Number(m[1]) : 0;
|
||||
return n >= 2 ? n : 0;
|
||||
}
|
||||
|
||||
/** The image_N inputs, in the order they sit on the node. */
|
||||
export function extraImageInputs(node) {
|
||||
return (node?.inputs || []).filter((inp) => extraNumber(inp) > 0);
|
||||
}
|
||||
|
||||
/** How many pictures are wired, `image` included. */
|
||||
export function connectedImageCount(node) {
|
||||
let n = slotConnected(node, "image") ? 1 : 0;
|
||||
for (const inp of extraImageInputs(node)) if (inp.link != null) n += 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep exactly one EMPTY picture socket after the last wired one: `image` wired
|
||||
* shows image_2, image_2 wired shows image_3, up to MAX_IMAGES. Only unwired
|
||||
* sockets at the END are ever removed, so a wire is never renumbered.
|
||||
* Returns how many sockets were added (positive) or removed (negative).
|
||||
* Callers must NOT run this during a load or a configure replay.
|
||||
*/
|
||||
export function syncImageSlots(node) {
|
||||
const inputs = node?.inputs;
|
||||
if (!inputs || !inputs.some((inp) => inp?.name === "image")) return 0;
|
||||
let last = slotConnected(node, "image") ? 1 : 0;
|
||||
let highest = 1;
|
||||
for (const inp of extraImageInputs(node)) {
|
||||
const n = extraNumber(inp);
|
||||
highest = Math.max(highest, n);
|
||||
if (inp.link != null) last = Math.max(last, n);
|
||||
}
|
||||
const want = last ? Math.min(MAX_IMAGES, last + 1) - 1 : 0; // extra sockets wanted
|
||||
let delta = 0;
|
||||
while (extraImageInputs(node).length < want && highest < MAX_IMAGES) {
|
||||
highest += 1;
|
||||
node.addInput(`image_${highest}`, "IMAGE");
|
||||
delta += 1;
|
||||
}
|
||||
while (extraImageInputs(node).length > want) {
|
||||
const extras = extraImageInputs(node);
|
||||
const tail = extras[extras.length - 1];
|
||||
if (!tail || tail.link != null) break;
|
||||
node.removeInput(node.inputs.indexOf(tail));
|
||||
delta -= 1;
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the model is being GIVEN, as a short phrase for the banner.
|
||||
*
|
||||
* It counts the typed idea as well as the wires. It used to list the wires
|
||||
* only, so a node with the model on a wire and a full idea typed in read
|
||||
* "Model on wire … nothing wired" - a contradiction on one line, and it read
|
||||
* as a warning on a node that was about to work perfectly well. The user asked
|
||||
* why it said nothing was wired. **A hint beside a working node must say what
|
||||
* WILL happen, not name what is absent.**
|
||||
*
|
||||
* `clip` is deliberately not in the list: that is the MODEL, and the label
|
||||
* beside this hint already reports it.
|
||||
*
|
||||
* It reads the WIRES, which is not the same as what arrived - a muted loader
|
||||
* is dropped by graphToPrompt - so the readout below the prompt is still the
|
||||
* thing that reports what really ran.
|
||||
*/
|
||||
export function wiredSummary(node) {
|
||||
const bits = [];
|
||||
if (readState(node).idea.trim()) bits.push("your idea");
|
||||
const pics = connectedImageCount(node);
|
||||
if (pics) bits.push(pics > 1 ? `${pics} images` : "image");
|
||||
if (slotConnected(node, "video")) bits.push("video");
|
||||
if (slotConnected(node, "audio")) bits.push("audio");
|
||||
if (slotConnected(node, "text")) bits.push("text");
|
||||
// Nothing but the formula is a real, working state: the model is sent the
|
||||
// instruction by itself. Saying so beats reporting an absence.
|
||||
if (!bits.length) return "formula only";
|
||||
return bits.join(" + ");
|
||||
}
|
||||
|
||||
/** The one rule: a model (picked or on a wire) AND something to send. */
|
||||
export function willGenerate(node) {
|
||||
const st = readState(node);
|
||||
if (!st.model && !slotConnected(node, "clip")) return false;
|
||||
if (st.formula.trim() || st.idea.trim()) return true;
|
||||
// Nothing typed here, but wired text counts as something to send. We cannot
|
||||
// see its VALUE from the browser, only that a wire exists.
|
||||
return slotConnected(node, "text");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed
|
||||
// ---------------------------------------------------------------------------
|
||||
export function rollSeed() {
|
||||
// Number.MAX_SAFE_INTEGER, not the full 64-bit range: above it a value stops
|
||||
// round-tripping through JSON as the same integer, and a seed that changed
|
||||
// on the way to Python would break Fixed mode's whole promise.
|
||||
return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
|
||||
/**
|
||||
* The seed this run will use.
|
||||
*
|
||||
* Random rolls a fresh one per run, so the state - and therefore the node's
|
||||
* cache signature - differs and the model runs again. Fixed reuses the stored
|
||||
* number, so an unchanged node is cached and Run is instant. That is Seed
|
||||
* Pixaroma's model, and it is why there is no nonce here.
|
||||
*
|
||||
* The rolled number goes on a RUNTIME field, never node.properties, so a run
|
||||
* can never mark a clean workflow as modified (Vue Compat #18).
|
||||
*/
|
||||
export function seedForRun(node) {
|
||||
const st = readState(node);
|
||||
if (st.seed_mode !== SEED_RANDOM) return st.seed;
|
||||
const rolled = rollSeed();
|
||||
node._pixApLastSeed = rolled;
|
||||
return rolled;
|
||||
}
|
||||
|
||||
export function injectedState(node) {
|
||||
const st = readState(node);
|
||||
const out = {};
|
||||
for (const key of PROMPT_KEYS) out[key] = st[key];
|
||||
out.seed = seedForRun(node);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The last run's answer
|
||||
// ---------------------------------------------------------------------------
|
||||
// A SEPARATE property from aiPromptState, and SERIALIZED on purpose.
|
||||
//
|
||||
// It lived on a runtime `node._pixApOut` field at first, on the reading that
|
||||
// Vue Compat #18 forbids a run from writing serialized state. That reading was
|
||||
// too broad, and the user reported the cost: switch workflow tabs, come back,
|
||||
// and the generated prompt was gone. MEASURED - a tab switch DESTROYS and
|
||||
// rebuilds every node object (a marker stamped on the node was gone on the way
|
||||
// back, while node.properties came back intact), so no `node._xxx` field can
|
||||
// survive one (Vue Compat #11). #18 is about the LOAD window: an untouched
|
||||
// workflow must not flag ITSELF modified merely by being opened. A run is the
|
||||
// user asking for something, and the workflow genuinely holds something new
|
||||
// afterwards - which is exactly why Preview Image Pixaroma already persists
|
||||
// its own run output this way (`pixaromaFrames`, js/preview/index.js).
|
||||
//
|
||||
// Kept OUT of aiPromptState deliberately. PROMPT_KEYS is an allow-list so the
|
||||
// answer could not reach Python either way, but a separate key cannot touch
|
||||
// injectedState, the preset SETTING_KEYS, the recipe export or the settings
|
||||
// panel's "N changed" counter AT ALL - zero blast radius on the run path,
|
||||
// where a mistake costs a 10 GB model reload.
|
||||
export const LAST_PROP = "aiPromptLast";
|
||||
|
||||
/** The last run's answer, or empty fields for a node that has never run. */
|
||||
export function readLast(node) {
|
||||
const raw = node?.properties?.[LAST_PROP];
|
||||
const src = raw && typeof raw === "object" ? raw : {};
|
||||
return {
|
||||
text: str(src.text, ""),
|
||||
meta: str(src.meta, ""),
|
||||
error: src.error === true,
|
||||
muted: src.muted === true,
|
||||
warn: src.warn === true,
|
||||
seed: Number.isFinite(Number(src.seed)) ? Number(src.seed) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whole-object write, so a failure can never inherit a field from a success. */
|
||||
export function writeLast(node, next) {
|
||||
if (!node) return;
|
||||
node.properties = node.properties || {};
|
||||
node.properties[LAST_PROP] = {
|
||||
text: str(next?.text, ""),
|
||||
meta: str(next?.meta, ""),
|
||||
error: next?.error === true,
|
||||
muted: next?.muted === true,
|
||||
// "Send the formula as a system message" was on and could not happen, so
|
||||
// the meta line (which says why) is amber. Python's payload `warn`.
|
||||
warn: next?.warn === true,
|
||||
// The seed the run REPORTED, so the chip can still name it after a tab
|
||||
// switch has taken _pixApLastSeed with the node object - see displaySeed.
|
||||
seed: Number.isFinite(Number(next?.seed)) ? Number(next.seed) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The runtime field dies with the node object on a workflow tab switch, and
|
||||
* once the ANSWER started surviving one (LAST_PROP) that left the chip naming a
|
||||
* seed which had nothing to do with the text beside it - and a user who copied
|
||||
* that number into Fixed to lock the result in would silently get a different
|
||||
* generation. So fall back to the seed the run reported, which is stored with
|
||||
* the answer and outlives the node object.
|
||||
*/
|
||||
export function displaySeed(node) {
|
||||
const st = readState(node);
|
||||
if (st.seed_mode === SEED_RANDOM) {
|
||||
if (Number.isFinite(node?._pixApLastSeed)) return node._pixApLastSeed;
|
||||
const ran = readLast(node).seed;
|
||||
if (Number.isFinite(ran)) return ran;
|
||||
}
|
||||
return st.seed;
|
||||
}
|
||||
|
||||
/**
|
||||
* A model filename tidied for the banner: the folder and the extension go,
|
||||
* because every file has one and neither tells the reader anything.
|
||||
*
|
||||
* It does NOT truncate. A fixed character cut is wrong at every node width but
|
||||
* one - it hid the end of the name even on a node made wide enough to show it
|
||||
* all. The banner label is `overflow:hidden; text-overflow:ellipsis`, so the
|
||||
* name uses whatever room there is and only ellipsises when it genuinely runs
|
||||
* out. Widen the node and more of it appears.
|
||||
*/
|
||||
export function shortModel(name) {
|
||||
const raw = String(name || "").trim();
|
||||
if (!raw) return "";
|
||||
return raw.split(/[\\/]/).pop().replace(/\.(safetensors|sft|gguf|pt|bin)$/i, "");
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
// AI Prompt Pixaroma - help. Convention #16: registering this is what makes
|
||||
// the orange ? appear in the node selection toolbar and gives the node a page
|
||||
// in the Help browser. Written for an artist, not a programmer.
|
||||
//
|
||||
// It deliberately covers the two questions that are impossible to answer by
|
||||
// looking at the node - what a duplicate carries, and whether two nodes load
|
||||
// the model twice - because getting either wrong wastes real time and memory.
|
||||
|
||||
export const AI_PROMPT_HELP = {
|
||||
title: "AI Prompt Pixaroma",
|
||||
tagline: "Run a language model on your own machine with an instruction you save on the node.",
|
||||
sections: [
|
||||
{
|
||||
heading: "What it does",
|
||||
body:
|
||||
"You give this node a model and an instruction, wire in whatever you have, "
|
||||
+ "and it hands back text.\n\n"
|
||||
+ "The instruction is called the formula. You write it once in the settings "
|
||||
+ "and it stays on the node, so the node becomes a step that always does the "
|
||||
+ "same job: describe a photo, rewrite a prompt in another style, turn a rough "
|
||||
+ "note into a finished one.\n\n"
|
||||
+ "Everything runs on your own machine using a model in your ComfyUI "
|
||||
+ "models/text_encoders folder. No account, no key, nothing sent anywhere.\n\n"
|
||||
+ "It is the general-purpose sister of Video Prompt Pixaroma. That one knows "
|
||||
+ "about MiniMax H3 and gives you durations and a frame count. This one knows "
|
||||
+ "about nothing in particular, which is what lets you point it at any job.",
|
||||
},
|
||||
{
|
||||
heading: "The one rule worth learning",
|
||||
body:
|
||||
"Everything the model is asked is one piece of text: the formula, then your "
|
||||
+ "idea, then anything wired into the text input.\n\n"
|
||||
+ "A node with no model chosen does not fail. It passes its text straight "
|
||||
+ "through, unchanged, and the banner says so. That is deliberate: you can "
|
||||
+ "drop one into a graph that is already working and set it up afterwards "
|
||||
+ "without breaking anything downstream.\n\n"
|
||||
+ "A node with no formula still runs. The model just gets your idea by "
|
||||
+ "itself, which is exactly what you want for a quick \"make this more "
|
||||
+ "cinematic\".",
|
||||
table: {
|
||||
headers: ["Model", "Formula", "Idea or wired text", "What happens"],
|
||||
rows: [
|
||||
["none", "anything", "anything", "Passes the text through unchanged"],
|
||||
["chosen", "empty", "empty", "Passes it through: there is nothing to ask"],
|
||||
["chosen", "empty", "present", "Runs, with your idea alone"],
|
||||
["chosen", "written", "empty", "Runs on the formula alone, useful with a picture"],
|
||||
["chosen", "written", "present", "Runs. The normal case"],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "The buttons on the node",
|
||||
defs: [
|
||||
["The gear", "Opens the settings: the model, the formula, and how wired text is joined. It sits in the empty space beside the input dots so it costs the node no height."],
|
||||
["The seed, and F or R", "F is Fixed: the same seed every Run, so an unchanged node is cached and Run is instant. R is Random: a new seed each Run, so it writes something different every time. Click the number to type one."],
|
||||
["Tags", "Opens your tag library, the same one Prompt Pixaroma uses. Anything you save there can be used in either node."],
|
||||
["Idea first, Wired first", "Only appears when something is wired into the text input. It decides which of the two comes first. The starting choice is in the settings."],
|
||||
["Expand", "Opens your idea in a full-screen box, for when it is long."],
|
||||
["The bar between the two boxes", "Drag it to give more room to whichever box you are using. Double-click resets it."],
|
||||
["Re-roll", "Rolls a new seed and runs again, so you get a different answer without changing anything you wrote."],
|
||||
["Copy", "Copies the text it wrote."],
|
||||
["Free VRAM", "Unloads the model as soon as the text is written, so a video model later in the workflow gets the memory back. Read the memory section below before turning it on in a chain."],
|
||||
["Generate", "Runs the workflow."],
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Tags in your idea",
|
||||
body:
|
||||
"Your idea box understands the same @tags as Prompt Pixaroma, out of the "
|
||||
+ "same library, so a phrase you saved once can be used in both nodes. Press "
|
||||
+ "Tags to open it.\n\n"
|
||||
+ "They are swapped for their real words before the model is asked anything, "
|
||||
+ "so the model reads finished English and never sees an @ sign.",
|
||||
defs: [
|
||||
["@name", "Your saved snippet, exactly as you wrote it."],
|
||||
["*Category", "One random tag out of that category, rolled fresh on every Run."],
|
||||
["#name", "One random line out of that tag, rolled fresh on every Run."],
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Reading the colours as you type",
|
||||
body:
|
||||
"The colour tells you what a word will become, so you can see at a glance "
|
||||
+ "whether a tag is real before you Run anything.",
|
||||
defs: [
|
||||
["Orange", "An @tag that exists."],
|
||||
["Green", "A *category that exists and has something in it."],
|
||||
["Violet", "A #list that exists and has lines in it."],
|
||||
["A red wavy underline", "That name is not in your library, so it will be sent to the model as the literal text you typed. Every name looks like this while you are still typing it, and it takes its real colour the moment it matches."],
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Seeing what a slot actually picked",
|
||||
body:
|
||||
"There is no preview box on this node, on purpose: the face is already two "
|
||||
+ "text boxes deep and a third one left nothing readable.\n\n"
|
||||
+ "You rarely need one. The colours above tell you a tag is real before you "
|
||||
+ "Run, and the prompt the model writes is right there underneath, built "
|
||||
+ "from whatever the slot picked. If you want to see the swapped words "
|
||||
+ "themselves, put a Prompt Pixaroma node in front of this one: it has the "
|
||||
+ "expanded view, and its output wires straight into the text input here.",
|
||||
},
|
||||
{
|
||||
heading: "Chaining several of them",
|
||||
body:
|
||||
"The output is plain text and the text input takes plain text, so these "
|
||||
+ "stack with no glue in between. Each one does a different job because each "
|
||||
+ "one carries its own formula.\n\n"
|
||||
+ "A worked example: a Load Image goes into one node whose formula is "
|
||||
+ "\"describe this photo as a short video prompt\". Its text goes into a "
|
||||
+ "second node whose formula is \"rewrite this in the style of a 90s anime "
|
||||
+ "cel\". Meanwhile a Load Audio goes into a third whose formula is \"name "
|
||||
+ "the mood of this music in five words\", and that text also joins the "
|
||||
+ "second node. Four wires, no other nodes.\n\n"
|
||||
+ "That third one needs a model that can HEAR, and in ComfyUI that means "
|
||||
+ "Gemma 4 (`gemma4_e4b_it_fp8_scaled.safetensors`). No Qwen3-VL can hear, "
|
||||
+ "however good it is with pictures: it takes the audio, ignores it and "
|
||||
+ "writes something confident anyway. The Sound preset is set up for this.\n\n"
|
||||
+ "Rename each node to what its formula does and the graph reads as a "
|
||||
+ "sentence. Double-click the title to rename it.",
|
||||
},
|
||||
{
|
||||
heading: "More than one picture",
|
||||
body:
|
||||
"Wire a picture into image and an image_2 slot appears below the others; "
|
||||
+ "wire that and image_3 appears, up to 8 pictures. The model sees each one "
|
||||
+ "separately and at its own size, so a wide picture and a tall one both "
|
||||
+ "arrive whole. Unplug the last one and its empty slot goes away again.\n\n"
|
||||
+ "A batch in the image input also works, but a batch makes every picture "
|
||||
+ "the size of the first one, cutting the edges off the others. The extra "
|
||||
+ "slots avoid that.\n\n"
|
||||
+ "Qwen3-VL and Qwen3.5 get the pictures one by one. Gemma 4 takes them as "
|
||||
+ "one batch, the first picture's size. On a node from before this update "
|
||||
+ "that already has a picture wired, unplug and re-plug that wire once and "
|
||||
+ "the image_2 slot appears.",
|
||||
},
|
||||
{
|
||||
heading: "What happens when you duplicate one",
|
||||
body:
|
||||
"A duplicate carries everything: the model, all the sampling settings, the "
|
||||
+ "seed and its mode, and the formula. They all live on the node, so a copy "
|
||||
+ "is a complete independent one.\n\n"
|
||||
+ "That matters most for the formula. Editing it on the copy afterwards does "
|
||||
+ "NOT change the original, which is what makes a chain of three possible. "
|
||||
+ "It also means a workflow you share carries its instructions with it, so "
|
||||
+ "whoever opens it sees the same wording you used.\n\n"
|
||||
+ "This is the one real difference from Video Prompt Pixaroma, whose "
|
||||
+ "formulas are shared files: change one there and every copy changes.",
|
||||
},
|
||||
{
|
||||
heading: "Two nodes, one model: does it load twice?",
|
||||
body:
|
||||
"No. The model is shared by every AI Prompt node in the workflow.\n\n"
|
||||
+ "Two nodes naming the same file means one load and one copy in memory. "
|
||||
+ "The second node finds it already there and starts writing straight away.\n\n"
|
||||
+ "Two nodes naming DIFFERENT models take turns. Only one is kept, so the "
|
||||
+ "second unloads the first and puts itself there instead, and they swap back "
|
||||
+ "and forth on each run. That is deliberate: holding two ten-gigabyte models "
|
||||
+ "at once is worse than loading one twice, especially on a 12 GB card. If "
|
||||
+ "you are mixing models in a chain, expect the first run to be slow.\n\n"
|
||||
+ "The best trick for a chain is Fixed seeds. With the seed on F and nothing "
|
||||
+ "changed, ComfyUI serves the whole node from its cache on the second Run, "
|
||||
+ "so nothing loads at all and the run goes straight to your image or video.",
|
||||
},
|
||||
{
|
||||
heading: "Free VRAM in a chain: the one trap",
|
||||
body:
|
||||
"Free VRAM unloads the model the moment THAT node finishes. So if an early "
|
||||
+ "node in a chain has it on and a later node wants the same model, the "
|
||||
+ "later one has to load it all over again in the same run.\n\n"
|
||||
+ "The rule is simple: turn Free VRAM on only for the LAST node that uses "
|
||||
+ "that model, usually the one feeding your image or video. Leave it off on "
|
||||
+ "the ones before it.\n\n"
|
||||
+ "It is skipped entirely when a model comes in on the clip wire, because "
|
||||
+ "that model belongs to the node feeding it and may be shared with the rest "
|
||||
+ "of your workflow. The button dims to show it is doing nothing.",
|
||||
},
|
||||
{
|
||||
heading: "Presets: a formula and the settings that make it work",
|
||||
body:
|
||||
"A formula on its own is only half a recipe. The Krea 2 idea formula "
|
||||
+ "that ships with this node writes beautifully at temperature 0.3 and "
|
||||
+ "rambles, invents objects and sometimes refuses at 0.7, using the very "
|
||||
+ "same words on the very same model. So a preset carries the wording "
|
||||
+ "AND the settings it was measured at.\n\n"
|
||||
+ "It also remembers which model it was written for. If you have that "
|
||||
+ "model it is chosen for you; if you do not, the line under the picker "
|
||||
+ "says so in amber and your own model is left alone, so a preset from "
|
||||
+ "somebody else's machine can never point this node at something that "
|
||||
+ "is not there.\n\n"
|
||||
+ "Everything below is in the settings, under Presets.",
|
||||
defs: [
|
||||
["Pick one from the list", "Fills in the formula, the temperature and the sampling values in one go. Turn off \"Bring its settings too\" first if you want the wording only."],
|
||||
["The orange and grey dots", "An orange dot means the preset ships with Pixaroma. A grey one means you saved it yourself. The picker row at the top wears the same dot, so it says which kind is loaded without being opened."],
|
||||
["Type to filter", "The box at the top of the list narrows it as you type. Every word you type has to appear in the name but the order does not matter, so \"krea image\" finds the image one. When one name is left, Enter loads it."],
|
||||
["All, Pixaroma, Mine", "The three buttons under the filter show everything, only the ones that ship with Pixaroma, or only your own, with a count on each. Mine stays greyed out until you have saved one."],
|
||||
["Hover a name", "Shows which model it was written for, whether you have that model, and its temperature. All before you load it."],
|
||||
["Save current", "Keeps whatever this node has right now under a name you choose, so your own recipes sit in the same list. After changing one of your own presets, its name is already filled in: press Enter to update it, or type a new name to keep both."],
|
||||
["Deleting one", "Hover a name in the list and a ✕ appears at the end of the row. It works on your own presets and asks first, because they are files on disk. On the ones that ship with Pixaroma it is greyed out: those come back with every update, so they cannot be deleted."],
|
||||
["Reset", "On the Advanced sampling line. Puts the sampling values back to the defaults and leaves your formula and model alone. It covers everything a preset can carry, so the Behaviour switches at the bottom go back too, except Faster writing (MTP), which is about your graphics card and is never reset. It wakes up only when something has really changed, and its tooltip names what."],
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Sending a recipe to somebody else",
|
||||
body:
|
||||
"Export and Import sit beside the formula in the settings, and they "
|
||||
+ "carry the whole recipe: the wording, the sampling settings, and the "
|
||||
+ "model it was written for. Sending only the words would send something "
|
||||
+ "that looks broken, because the temperature is half of why it works.\n\n"
|
||||
+ "Each one offers a file or the clipboard. Pick whichever suits.",
|
||||
defs: [
|
||||
["Export, then Save as a file", "Writes a .txt you can keep, back up, or send to somebody."],
|
||||
["Export, then Copy to clipboard", "Puts the same thing on the clipboard, ready to paste straight into a Discord message."],
|
||||
["Import, then Open a file", "Loads a .txt somebody sent you."],
|
||||
["Import, then Paste from clipboard", "Loads one you have just copied out of a message."],
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Where your presets are kept",
|
||||
body:
|
||||
"The list holds two kinds, and a dot on each name says which: orange for "
|
||||
+ "the ones that ship with Pixaroma, grey for your own. The buttons under "
|
||||
+ "the filter can show just one kind or the other.\n\n"
|
||||
+ "The ones that ship with Pixaroma are on every machine that installs "
|
||||
+ "it, so a formula like Krea 2 is already there for everybody and you "
|
||||
+ "never need to send it to anyone. New ones arrive with a Pixaroma "
|
||||
+ "update.\n\n"
|
||||
+ "Your own are kept together in one file:\n"
|
||||
+ "`ComfyUI/user/pixaroma/ai_prompt_presets.json`\n"
|
||||
+ "That sits outside the plugin folder, so updating or reinstalling "
|
||||
+ "Pixaroma cannot wipe them, and it is the one file to copy when you "
|
||||
+ "move to another machine.\n\n"
|
||||
+ "A recipe you import that arrives with a name is added to that list by "
|
||||
+ "itself, so it is there next time too. The file it came from is "
|
||||
+ "ordinary readable text: you can open it in Notepad and change the "
|
||||
+ "temperature by hand. And a plain .txt with no header still loads as "
|
||||
+ "the formula on its own, exactly as it always did, so older exports "
|
||||
+ "and any prompt you already had lying around still work.",
|
||||
defs: [
|
||||
["The name in brackets", "Every preset ends with the model it was written and measured on, so you can see that before you load it. They are not fussy: a formula tuned on one Qwen3-VL build behaves the same on the others. Only the Gemma 4 one genuinely needs its model, because nothing else here can hear."],
|
||||
["Video - prompt from a video (Gemma 4 E4B)", "Watches a clip and writes the video prompt that would make one like it: the medium, what HAPPENS from beginning to end, and what the camera does. Wire Load Video Pixaroma into the video input. Frames are read as 24fps and sampled one per second, so load a few seconds rather than a whole clip."],
|
||||
["Transcribe - the words only (Gemma 4 E4B)", "Writes out what is said and nothing else: no summary, no description of the sounds, no speaker labels or timestamps, and an unclear word comes back as [inaudible]. Use this one when you want the words themselves; use Sound when you want the mood."],
|
||||
["Sound - describe what you hear (Gemma 4 E4B)", "Wire a Load Audio in and it says what kind of sound it is, quotes any words spoken or sung, names the instruments and gives the mood. Gemma 4 is the ONLY text encoder ComfyUI can feed audio to, so a Qwen3-VL will take the audio, ignore it and answer anyway. Send its text into a second AI Prompt node to turn it into an image prompt."],
|
||||
["FLUX 3 - prompt from an idea", "BFL's own prompt upsampler for FLUX 3 Image Klein, with their system prompt, their settings and Send the formula as a system message on, so it asks exactly what their Text Generate template asks. It turns a short idea into the long caption FLUX 3 was trained on. Best on short real-world ideas: a fantasy label like \"magical forest\" makes it pile on glowing extras. Top_k is 40, because the template's 1 writes the same caption whatever the seed. Use it before a Flux3 Layout Prompt node, never after, because it removes the box tags."],
|
||||
["Krea 2 - prompt from an idea", "Turns a rough idea into a full Krea 2 prompt. Built from Krea's own published prompt-expansion instructions, then tightened by watching where it went wrong. Measured on Qwen3-VL 4B and 8B."],
|
||||
["Krea 2 - prompt from an image", "Wire a Load Image into the image input and it writes the prompt that would make a similar picture, naming the medium, the framing and the light. Leave Your idea empty for this one. Needs a vision model. Photographs come back cleanest."],
|
||||
["Ming Image - prompt from an idea", "Turns a short idea into the structured JSON layout Ming Image 0.1 Design reads best, built on Ming's own published rewriter: every part of the design with its position and colours, and every word you put in quotes kept exactly. Wire a line like Canvas: 832 x 1248 px into the text input (a core Format Text node fed by your width and height) so the layout fits the shape of your picture. Two versions: Qwen3.8 27B, the writer Ming's makers name, and the smaller Qwen3.5 9B. Ming's own text encoder cannot write, so it needs one of these."],
|
||||
["MOSS-Image - prompt from an idea", "Turns a short idea into the detailed caption MOSS-Image likes, with the prompt enhancer from ComfyUI's own MOSS-Image workflow (a short form of OpenMOSS's own), sent as a system message with that workflow's settings. It keeps everything you asked for, fills the gaps with concrete visual choices, and quotes every word that should appear in the picture. MOSS's text encoder can write, so wire the workflow's Load CLIP into the clip input and the model already loaded does it."],
|
||||
["MOSS-Image - prompt from an image", "Wire a Load Image into the image input and it writes the MOSS-Image caption that would make the picture again: the medium first, every person's hair, age and clothes, and any text in quotes. Leave Your idea empty, or type a change there (like: at night) to get the caption with that change. MOSS's text encoder can see, so wire the workflow's Load CLIP into the clip input."],
|
||||
["Qwen Image 2.1 - prompt from an idea", "Turns a short idea into the long, detailed prompt Qwen Image 2.1 likes, built from Qwen's own published prompt enhancer: the style and the medium first, then everything placed in the frame. Put any words you want written in the picture in quotes in your idea, and it keeps them exactly. Qwen Image 2.1's text encoder is Qwen3-VL 8B, so wire the workflow's Load CLIP into the clip input and one model does both jobs."],
|
||||
["Qwen Image 2.1 - transparent picture from an idea", "For a subject on a transparent background. It wraps your idea in the exact words Qwen Image 2.1 needs to make a real transparency channel and describes only the subject. In testing a plain idea gave a solid background every time, and this preset made most of the picture transparent. Save the result as a PNG."],
|
||||
["Qwen Image 2.1 - prompt from an image", "Wire a Load Image into the image input and it writes the Qwen Image 2.1 prompt that would make a picture like it, naming the style as well as the medium, so a cartoon stays a cartoon. Leave Your idea empty for this one."],
|
||||
["Qwen Image 2.1 - edit from an instruction", "For Qwen Image 2.1's image edit. Wire your picture into the image input and a second one into image_2 (it appears once the first is wired), type the change in Your idea, and it writes a clear edit instruction that leads with the change and keeps everything else. Qwen Image 2.1 already follows short edits well, so this helps most with vague requests, style changes and two-picture edits."],
|
||||
["Z-Image - prompt from an idea", "The same job as the Krea one, written for Z-Image Turbo, which wants a much longer and more detailed prompt. Built from the makers' own guidance, so its Max len is set high to leave room for one. Leave Thinking off, which is the default: Z-Image's encoder is a reasoning model, and thinking costs about three times the wait for no better prompt."],
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Sharing a workflow's model instead of loading a second one",
|
||||
body:
|
||||
"Some image models use a language model as their text encoder. Krea 2 "
|
||||
+ "and Qwen Image 2.1 both use Qwen3-VL, which is exactly the kind of "
|
||||
+ "model this node wants. "
|
||||
+ "When that is true you can wire the workflow's own CLIP loader "
|
||||
+ "straight into this node's clip input and write your prompts with the "
|
||||
+ "model that is already in memory. Nothing extra loads, and a whole "
|
||||
+ "text-to-image run takes seconds.\n\n"
|
||||
+ "Not every text encoder can write, though. Ming Image's only reads: "
|
||||
+ "wired in here it stops with \"not a language model\". For a model like "
|
||||
+ "that, pick a writer of its own in the gear instead.\n\n"
|
||||
+ "The banner changes to Model on wire and Free VRAM dims, because a "
|
||||
+ "model that arrived on a wire belongs to the node feeding it.\n\n"
|
||||
+ "One thing to watch: a small model needs a lower temperature than a "
|
||||
+ "big one. A 4B at 0.7 will echo your formula back at you or invent "
|
||||
+ "things you never asked for. At 0.3 it behaves. If a formula seems to "
|
||||
+ "be ignored, lower the temperature before you blame the wording.",
|
||||
},
|
||||
{
|
||||
heading: "Send the formula as a system message",
|
||||
body:
|
||||
"A chat model reads two kinds of text: a system message that sets its job, "
|
||||
+ "and the user's turn that asks. By default this node puts the formula inside "
|
||||
+ "the prompt, above your idea, and the presets were written for that.\n\n"
|
||||
+ "Some models were trained with their instructions in the system message, "
|
||||
+ "like BFL's FLUX 3 prompt upsampler. For those, open the settings and turn on "
|
||||
+ "Send the formula as a system message, under Behaviour. The formula then "
|
||||
+ "goes to the model as its system message, and only your idea, plus any wired "
|
||||
+ "text, is the prompt. That is exactly how ComfyUI's own Text Generate node "
|
||||
+ "sends its system prompt: tested with the FLUX 3 upsampler, the same seed "
|
||||
+ "gave the same caption word for word and the same picture.\n\n"
|
||||
+ "It needs ComfyUI 0.38 or newer, a model that has a system message (Qwen3.5, "
|
||||
+ "Qwen3-VL, Z-Image's Qwen3 and Gemma 4 do), and Use the model's own template "
|
||||
+ "on. When one of those is missing, the formula goes inside the prompt as "
|
||||
+ "before and the line under the answer says so in amber. Nothing is dropped "
|
||||
+ "without telling you.",
|
||||
},
|
||||
{
|
||||
heading: "Writing a good formula",
|
||||
bullets: [
|
||||
"Say what you want back, not what you want it to think about. \"Write one paragraph describing this photo as a video prompt\" beats \"analyse this photo\".",
|
||||
"Say how long. Models write far more than you expect unless you tell them a length.",
|
||||
"Say what NOT to include. \"Do not write a title, a preamble, or any explanation, only the prompt itself\" saves a lot of tidying up.",
|
||||
"Give it one example of a good answer if the shape matters. A small model copies the example far more reliably than it follows the rules.",
|
||||
"Leave the changeable part out of the formula and type it into Your idea instead. That way one node handles every picture you throw at it.",
|
||||
],
|
||||
},
|
||||
// NO "what you can wire in" section here on purpose. The Help browser
|
||||
// GENERATES that reference from this node's own Python tooltips
|
||||
// (help-browser.md #7), so writing one as well prints the same thing
|
||||
// twice and the two copies drift apart the first time an input changes.
|
||||
{
|
||||
heading: "What you need installed",
|
||||
body:
|
||||
"One language model in ComfyUI/models/text_encoders. For anything that has "
|
||||
+ "to look at a picture it must be a vision model.\n\n"
|
||||
+ "A good all-round choice is "
|
||||
+ "`qwen3-vl-8b-heretic-1.3.0_fp8_e4m3fn.safetensors`, about 10 GB, for cards "
|
||||
+ "with 12 GB or more. Take it from the comfyui folder of that repository, "
|
||||
+ "not the root: the root holds the raw model, which ComfyUI cannot load as a "
|
||||
+ "text encoder. For an 8 GB card use the 4B build instead.\n\n"
|
||||
+ "The shipped formulas were written and measured on Qwen3-VL 4B builds, "
|
||||
+ "which is why each preset names one. They are not fussy: the same wording "
|
||||
+ "was checked on the 8B and on both 4B builds and behaves the same. So when "
|
||||
+ "a preset says the model it was written for is one you do not have, that "
|
||||
+ "line is telling you what it was tuned on, not that anything is wrong.\n\n"
|
||||
+ "The picker marks any file that does not look like a vision model. It does "
|
||||
+ "not block them, because a text-only model is the right choice for a step "
|
||||
+ "that only rewrites text, and it is a lot smaller and quicker.",
|
||||
},
|
||||
{
|
||||
heading: "If something looks wrong",
|
||||
defs: [
|
||||
["It hands back my own words unchanged", "No model is chosen, or there is nothing to send. The banner says which."],
|
||||
["It describes a picture it cannot have seen", "The model is text-only. It accepts the picture and ignores it, silently. Pick one the list does NOT mark with \"(no vision)\". That mark is a guess from the filename, since ComfyUI only knows a model's abilities from inside the file, so treat it as a hint rather than a verdict."],
|
||||
["It answers about audio or video but describes the wrong thing", "The model cannot hear or watch. Only Gemma 4 can, and the list marks it \"(sees + hears)\". Every other model takes the audio or the frames, ignores them and answers anyway. A Qwen3-VL can see a still picture, which is a different thing from watching a clip."],
|
||||
["ComfyUI stops responding while reading a video", "Load fewer frames. Set Max frames on Load Video to a few seconds' worth, since the frames are sampled one per second anyway and more of them buy nothing. Loading the same clip repeatedly in quick succession has been seen to lock the server, which needs a restart."],
|
||||
["Run does nothing and the text never changes", "The seed is Fixed and nothing else changed, so ComfyUI is serving the cached answer. That is the point of Fixed. Press Re-roll, or switch the seed to R."],
|
||||
["It writes far too much", "Say a length in the formula, and lower Max len in the settings so it cannot run on."],
|
||||
["It repeats my formula back at me, or ignores it", "The temperature is too high for the model. Try 0.3. Small models especially need it, and this single setting is the difference between a formula working and looking broken."],
|
||||
["It hands back nothing at all", "Usually the formula is too short for that model to get going. A one-line instruction can produce an empty answer where a proper formula produces a good paragraph. Load a preset and try again before you blame the model."],
|
||||
["It refuses, or says it cannot see my idea", "Same cause: lower the temperature to 0.3."],
|
||||
["Every run reloads the model", "Two nodes in the workflow are using different models. Give them the same one if you can."],
|
||||
["It got the words in the picture wrong", "The model reads one large piece of text reliably, and guesses at several smaller ones: a menu reading PLAY, SETTINGS, EXIT came back as PAY, SHEETS, EAT. It never says it is unsure, so a wrong word looks like a right one. A bigger model does not help, and nor does asking it to read carefully; both were tried. If the words matter, read them in the prompt before you Run, and to correct them press Copy, paste into a Text Pixaroma node, fix the words there and wire that into your text encoder."],
|
||||
["I typed in Your idea with the image preset and it changed nothing", "That preset's strongest rule is to describe only what is really in the picture, so it overrules you, which is what stops it inventing things. A change of medium does land, so \"as a watercolour painting\" works. To change what is IN the picture, leave the idea empty and send this node's text into a second AI Prompt node whose formula is the change you want."],
|
||||
["A workflow somebody sent me says the model is not in my text_encoders folder", "A workflow carries the model's NAME, not the model itself. Either download that file, or open the settings and pick one you already have. Everything else, the formula and all the settings, came with the workflow and is already there."],
|
||||
["The line under the answer says the formula went in the prompt", "Send the formula as a system message is on, but this run could not use one: the model has no system message, Use the model's own template is off, or there was nothing to send besides the formula. The run still worked, with the formula inside the prompt as usual. The amber line says which."],
|
||||
["A run stops with a CUDA error such as \"illegal memory access\"", "Open the settings and turn off Faster writing (MTP), under Behaviour. Some models (several Qwen3.5 files) carry an extra MTP part that writes a few words ahead, and on some cards and model files that crashes. Off writes a little slower and gives the same kind of answer. It is the same as setting mtp to off on ComfyUI's Generate Text node, and presets never turn it back on."],
|
||||
["The settings panel will not close", "Click the gear again, press Escape, or click the canvas."],
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,385 +0,0 @@
|
||||
// AI Prompt Pixaroma - wiring.
|
||||
//
|
||||
// core.mjs holds the state, ui.mjs the face, settings.mjs the gear panel and
|
||||
// the full-screen editor, api.mjs the model list, help.mjs the help text.
|
||||
//
|
||||
// Nothing about this node is derived from a stored mode. The connection
|
||||
// handler below repaints the face and, since 2026-09-29, adds or removes the
|
||||
// image_2 ... image_8 sockets - which IS serialized state, so that one part is
|
||||
// gated exactly like the Switch family (Vue Compat #17/#19): never inside
|
||||
// configure (its replay walks the LIVE inputs list, and a handler that appends
|
||||
// an input there runs away), and never while a workflow is loading.
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { isGraphLoading } from "../shared/graph_loading.mjs";
|
||||
import { registerNodeHelp } from "../shared/help.mjs";
|
||||
import { registerNodeSettings, repaintAccent } from "../shared/node_settings.mjs";
|
||||
import { isVueNodes } from "../shared/nodes2.mjs";
|
||||
import {
|
||||
CLASS, DEFAULT_H, DEFAULT_W, HIDDEN_INPUT, MIN_H, MIN_W,
|
||||
injectedState, readState, rollSeed, syncImageSlots, writeState,
|
||||
} from "./core.mjs";
|
||||
import { notifyGraphChanged } from "../shared/graph_changed.mjs";
|
||||
import { registerSeedRoller } from "../shared/seed_roll.mjs";
|
||||
// The @tag layer, shared with Prompt Pixaroma. beginPickBuild + makeRunResolvers are
|
||||
// what let a *category / #list in the idea roll a fresh pick per run and still be
|
||||
// held until a queue is actually accepted (prompt.md #31).
|
||||
import { beginPickBuild } from "../prompt/cursors.mjs";
|
||||
import { makeRunResolvers } from "../prompt/tag_field.mjs";
|
||||
import { expandAll } from "../prompt/expand.mjs";
|
||||
// The renderer hook here TOGGLES ONE CSS CLASS. It does NOT rebuild the face:
|
||||
// this node has one DOM widget in both renderers, and a rebuild hook on Video
|
||||
// Prompt leaked a widget root per flip and was reverted (video-prompt.md #13).
|
||||
//
|
||||
// It is needed because the band's placement is the one thing that genuinely
|
||||
// differs: Classic floats it up into the slot band, and Nodes 2.0 CLIPS
|
||||
// anything above the widget top. Measured on a live flip - the band stayed
|
||||
// floated and the gear, seed and join segment were invisible and unreachable
|
||||
// until a page refresh (reference_renderer_flip_breaks_per_renderer_ui).
|
||||
import { onRendererChange } from "../shared/renderer_switch.mjs";
|
||||
import { runTab, eachRestoredOutput } from "../shared/run_tab.mjs";
|
||||
import {
|
||||
applyError, applyResult, buildFace, destroyFace, injectCSS, placeBand,
|
||||
renderFace,
|
||||
} from "./ui.mjs";
|
||||
import {
|
||||
closeAIPromptPanelFor, openAIPromptPanel, openEditor, refreshAIPromptPanel,
|
||||
} from "./settings.mjs";
|
||||
import { AI_PROMPT_HELP } from "./help.mjs";
|
||||
|
||||
registerNodeHelp(CLASS, AI_PROMPT_HELP);
|
||||
|
||||
// Pause Text's Regenerate re-rolls every upstream widget called /seed/i. This
|
||||
// node's seed is not a widget - it lives in the injected state (Vue Compat #9)
|
||||
// - so that walk used to pass straight over it and, with the mode on Fixed, the
|
||||
// model stayed cached and handed back the SAME text (reported 2026-08-16).
|
||||
//
|
||||
// Writing the stored number is the right move rather than a one-shot override:
|
||||
// it is exactly what the walk already does to a Seed Pixaroma widget, and in
|
||||
// Random mode seedForRun rolls per run anyway, so this only has to rescue Fixed.
|
||||
registerSeedRoller(CLASS, (node) => {
|
||||
writeState(node, { seed: rollSeed() });
|
||||
renderFace(node);
|
||||
});
|
||||
|
||||
function openPanel(node) {
|
||||
openAIPromptPanel(node, (n) => {
|
||||
renderFace(n);
|
||||
repaintAccent(n);
|
||||
n.setDirtyCanvas?.(true, true);
|
||||
});
|
||||
}
|
||||
|
||||
/** The face's Expand button. Reuses the panel's editor rather than a second
|
||||
* one that would drift from it; `owner` means deleting the node closes it. */
|
||||
function openIdeaEditor(node) {
|
||||
openEditor("Your idea — " + (node.title || "AI Prompt"), readState(node).idea,
|
||||
(text) => {
|
||||
writeState(node, { idea: text });
|
||||
renderFace(node);
|
||||
return true;
|
||||
}, { spellcheck: true, owner: node });
|
||||
}
|
||||
|
||||
// Its own panel, so it registers as a custom settings host. ownMenuItem stays
|
||||
// false because the node adds no right-click line of its own - the central one
|
||||
// in help_toolbar is the only entry, and true would remove it.
|
||||
registerNodeSettings(CLASS, {
|
||||
title: "AI Prompt",
|
||||
ownMenuItem: false,
|
||||
open: (node) => openPanel(node),
|
||||
closeFor: (node) => closeAIPromptPanelFor(node),
|
||||
// The face paints its banner from --pix-acc on the widget root, which the
|
||||
// shared repaint reaches, but the readout and the chips are re-derived in
|
||||
// renderFace - so a DEFAULT colour change needs this hook to land
|
||||
// (node-settings-accent invariant 2).
|
||||
onChange: (node) => renderFace(node),
|
||||
});
|
||||
|
||||
// Deferred one tick so a wire DRAGGED ONTO an occupied socket (disconnect, then
|
||||
// connect) is judged once, on the settled state, and so no socket is removed
|
||||
// while LiteGraph is still inside its own disconnect.
|
||||
function scheduleImageSync(node) {
|
||||
if (node._pixApSyncT) return;
|
||||
node._pixApSyncT = setTimeout(() => {
|
||||
node._pixApSyncT = null;
|
||||
if (!node.graph || node._pixApConfiguring || isGraphLoading()) return;
|
||||
const h0 = node.size?.[1] || 0;
|
||||
let delta = 0;
|
||||
try { delta = syncImageSlots(node); } catch (e) {
|
||||
console.error("[Pixaroma.AIPrompt] image slots", e);
|
||||
return;
|
||||
}
|
||||
if (!delta) return;
|
||||
// Each socket row takes 20px from the body unless the node grows by it -
|
||||
// in BOTH renderers. addInput only grows a node up to its bare minimum,
|
||||
// and in Nodes 2.0 the stored height is a floor the body does not push
|
||||
// against (its column is absolute), so without this the button row was
|
||||
// measured hanging below the node frame there. setSize, never a raw
|
||||
// node.size write, so it sticks in Nodes 2.0 too.
|
||||
const target = delta > 0
|
||||
? Math.max(node.size[1], h0 + 20 * delta)
|
||||
: Math.max(MIN_H, h0 + 20 * delta);
|
||||
node.setSize?.([node.size[0], target]);
|
||||
renderFace(node);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
// The socket appears a tick AFTER core recorded the mouseup that made the
|
||||
// wire, so record the change ourselves (convention #31) - or Ctrl+Z and
|
||||
// the unsaved-changes dot would miss it.
|
||||
notifyGraphChanged();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Pixaroma.AIPrompt",
|
||||
|
||||
// A Run that ended while another workflow tab was open (js/shared/run_tab.mjs).
|
||||
// No elapsed time for it: the answer's own `seconds` is shown when present.
|
||||
onNodeOutputsUpdated(nodeOutputs) {
|
||||
eachRestoredOutput(nodeOutputs, CLASS, (node, output) => {
|
||||
const payload = output?.pixaroma_ai_prompt?.[0];
|
||||
if (payload) applyResult(node, payload, null);
|
||||
});
|
||||
},
|
||||
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== CLASS) return;
|
||||
// A re-registration (hot reload) would otherwise double-wrap every hook.
|
||||
if (nodeType.prototype._pixApPatched) return;
|
||||
nodeType.prototype._pixApPatched = true;
|
||||
|
||||
injectCSS();
|
||||
|
||||
const _created = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
_created?.apply(this, arguments);
|
||||
buildFace(this, openPanel, openIdeaEditor);
|
||||
|
||||
// Fresh size, SYNCHRONOUSLY. configure() runs right after this and
|
||||
// restores a saved size, so a deferred write here would clobber the
|
||||
// user's own size on every reload and every duplicate (convention #9).
|
||||
if (!Array.isArray(this.size)) this.size = [DEFAULT_W, DEFAULT_H];
|
||||
this.size[0] = DEFAULT_W;
|
||||
this.size[1] = DEFAULT_H;
|
||||
|
||||
// Vue Compat #8: nodeCreated fires BEFORE configure, so reading the
|
||||
// state now would render the defaults and then flash to the saved ones.
|
||||
queueMicrotask(() => renderFace(this));
|
||||
|
||||
// Class toggle only - see the import note. Released in onRemoved.
|
||||
this._pixApRendererOff = onRendererChange(() => placeBand(this));
|
||||
};
|
||||
|
||||
const _configure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
const r = _configure?.apply(this, arguments);
|
||||
// DOM ONLY. Nothing here may write node.size, properties or slots, or an
|
||||
// untouched workflow opens flagged "modified" (Vue Compat #18).
|
||||
renderFace(this);
|
||||
queueMicrotask(() => renderFace(this));
|
||||
return r;
|
||||
};
|
||||
|
||||
// Raise a flag for the WHOLE of configure, not in the onConfigure hook:
|
||||
// LiteGraph replays onConnectionsChange for every input INSIDE configure,
|
||||
// before onConfigure runs, and a flag raised there gates nothing (Vue Compat
|
||||
// #17's correction). This wraps ComfyNode's own configure.
|
||||
const _configureFn = nodeType.prototype.configure;
|
||||
nodeType.prototype.configure = function () {
|
||||
this._pixApConfiguring = true;
|
||||
try { return _configureFn.apply(this, arguments); }
|
||||
finally { this._pixApConfiguring = false; }
|
||||
};
|
||||
|
||||
// Wiring the clip input changes which model runs, and wiring text makes
|
||||
// the join segment appear - pure repaints. Wiring a PICTURE may also add or
|
||||
// remove an image_N socket, which is serialized state, so that part runs
|
||||
// only for the user's own wiring: never in a configure replay, never during
|
||||
// a load, undo or tab switch (isGraphLoading covers all three).
|
||||
const _conn = nodeType.prototype.onConnectionsChange;
|
||||
nodeType.prototype.onConnectionsChange = function (type) {
|
||||
const r = _conn?.apply(this, arguments);
|
||||
renderFace(this);
|
||||
refreshAIPromptPanel(this);
|
||||
if (type === (globalThis.LiteGraph?.INPUT ?? 1) && !this._pixApConfiguring && !isGraphLoading()) {
|
||||
scheduleImageSync(this);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
// Classic-only clamps. In Nodes 2.0 the RENDERED size lives in the Vue
|
||||
// layout store, not in node.size, so clamping here desyncs the two and the
|
||||
// node jumps back to the clamped size on a workflow switch.
|
||||
const _resize = nodeType.prototype.onResize;
|
||||
nodeType.prototype.onResize = function (size) {
|
||||
if (!isVueNodes() && !isGraphLoading()) {
|
||||
if (size[0] < MIN_W) size[0] = MIN_W;
|
||||
if (size[1] < MIN_H) size[1] = MIN_H;
|
||||
}
|
||||
return _resize?.apply(this, arguments);
|
||||
};
|
||||
|
||||
const _draw = nodeType.prototype.onDrawForeground;
|
||||
nodeType.prototype.onDrawForeground = function () {
|
||||
// The load gate is load-bearing: a draw hook runs on the FIRST frame of
|
||||
// a workflow load, earlier than any other clamp, so an ungated write
|
||||
// here is the one place that can rewrite a saved node.size on a clean
|
||||
// open (convention #7).
|
||||
if (!isVueNodes() && !isGraphLoading()) {
|
||||
if (this.size[0] < MIN_W) this.size[0] = MIN_W;
|
||||
if (this.size[1] < MIN_H) this.size[1] = MIN_H;
|
||||
}
|
||||
return _draw?.apply(this, arguments);
|
||||
};
|
||||
|
||||
const _removed = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function () {
|
||||
clearTimeout(this._pixApSyncT);
|
||||
this._pixApSyncT = null;
|
||||
closeAIPromptPanelFor(this);
|
||||
try { this._pixApRendererOff?.(); } catch (e) { /* already gone */ }
|
||||
this._pixApRendererOff = null;
|
||||
destroyFace(this);
|
||||
return _removed?.apply(this, arguments);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Results
|
||||
// ---------------------------------------------------------------------------
|
||||
// ComfyUI already emits a real per-token progress bar during generation (there
|
||||
// is a ProgressBar inside the generate loop in comfy/text_encoders/llama.py),
|
||||
// so the node inherits that for free. What it does not give is a wall-clock
|
||||
// number, and since generation stops early on the end token the bar fills only
|
||||
// part way - so the elapsed seconds beside the readout is the more useful one.
|
||||
const STARTED = new Map(); // node id -> ms
|
||||
|
||||
function findById(id) {
|
||||
return findNode(buildIndex(), id);
|
||||
}
|
||||
|
||||
// An interrupted run leaves its start time behind, and the next CACHED run
|
||||
// (which fires no "executing") would then print an absurd elapsed time.
|
||||
api.addEventListener("execution_start", () => STARTED.clear());
|
||||
|
||||
api.addEventListener("executing", (e) => {
|
||||
const id = e?.detail?.node ?? e?.detail;
|
||||
if (id == null) return;
|
||||
if (findById(id)) STARTED.set(String(id), Date.now());
|
||||
});
|
||||
|
||||
// ComfyUI's toast for a node failure says only "This node threw an error
|
||||
// during execution", with the real message behind a View details click.
|
||||
// Somebody who picked a model that cannot write text would learn nothing from
|
||||
// that, so put the message where they are already looking.
|
||||
api.addEventListener("execution_error", (e) => {
|
||||
const d = e?.detail;
|
||||
if (!d) return;
|
||||
const node = findById(d.node_id ?? d.node);
|
||||
if (!node) return;
|
||||
STARTED.delete(String(d.node_id ?? d.node));
|
||||
applyError(node, d.exception_message);
|
||||
});
|
||||
|
||||
api.addEventListener("executed", (e) => {
|
||||
const detail = e?.detail;
|
||||
const payload = detail?.output?.pixaroma_ai_prompt?.[0];
|
||||
if (!payload) return;
|
||||
const t0 = STARTED.get(String(detail.node));
|
||||
STARTED.delete(String(detail.node));
|
||||
const elapsed = t0 ? Math.max(0, Math.round((Date.now() - t0) / 100) / 10) : null;
|
||||
// Decided after this message's listeners ran: a Run that ended while ANOTHER
|
||||
// workflow tab is open belongs to that tab (findById looks in the OPEN tab,
|
||||
// and ids repeat across workflows). Core keeps the result and hands it back
|
||||
// when that tab opens again: onNodeOutputsUpdated (js/shared/run_tab.mjs).
|
||||
queueMicrotask(() => {
|
||||
if (runTab(detail.output) === "other") return;
|
||||
const node = findById(detail.node);
|
||||
if (!node) return;
|
||||
applyResult(node, payload, elapsed);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// graphToPrompt: inject the state
|
||||
// ---------------------------------------------------------------------------
|
||||
// INJECT ONLY - never prune here, because Export (API) serialises this same
|
||||
// output and a prune would silently strip the node's settings from it.
|
||||
//
|
||||
// This is also where a Random seed is BAKED IN for the run, so the value the
|
||||
// model actually used is the value that went into the queued workflow.
|
||||
function buildIndex() {
|
||||
const index = new Map();
|
||||
const seen = new Set();
|
||||
const visit = (graph) => {
|
||||
if (!graph || seen.has(graph)) return; // a subgraph cycle would recurse forever
|
||||
seen.add(graph);
|
||||
for (const n of graph._nodes || graph.nodes || []) {
|
||||
if (!n) continue;
|
||||
if (n.comfyClass === CLASS || n.type === CLASS) index.set(String(n.id), n);
|
||||
const inner = n.subgraph || n.graph || n._graph;
|
||||
if (inner) visit(inner);
|
||||
}
|
||||
};
|
||||
visit(app.graph);
|
||||
return index;
|
||||
}
|
||||
|
||||
function findNode(index, id) {
|
||||
const s = String(id);
|
||||
if (index.has(s)) return index.get(s);
|
||||
const tail = s.includes(":") ? s.slice(s.lastIndexOf(":") + 1) : null;
|
||||
return tail && index.has(tail) ? index.get(tail) : null;
|
||||
}
|
||||
|
||||
const _origGraphToPrompt_fn = app.graphToPrompt;
|
||||
const _origGraphToPrompt = (...a) => _origGraphToPrompt_fn.apply(app, a);
|
||||
app.graphToPrompt = async function (...args) {
|
||||
const result = await _origGraphToPrompt(...args);
|
||||
try {
|
||||
const out = result?.output;
|
||||
if (out) {
|
||||
// Open the pick build BEFORE anything rolls. Prompt Pixaroma's hook does the
|
||||
// same and beginPickBuild is idempotent per prompt OBJECT, so whichever of the
|
||||
// two runs first opens the build and the other reuses it - extension load order
|
||||
// is not stable, so neither may assume it goes first. Get this wrong and one
|
||||
// node's picks carry a build id the commit will skip, and its "In order" list
|
||||
// never advances again.
|
||||
beginPickBuild(out);
|
||||
let index = null;
|
||||
for (const id in out) {
|
||||
const entry = out[id];
|
||||
if (!entry || entry.class_type !== CLASS) continue;
|
||||
if (!index) index = buildIndex();
|
||||
const node = findNode(index, id);
|
||||
if (!node) continue;
|
||||
try {
|
||||
const st = injectedState(node);
|
||||
// Expand @tags, and roll every *category and #list NOW, at queue time, so
|
||||
// each run gets a fresh pick. A different pick changes this string, so the
|
||||
// cache key changes and the model runs again - no nonce needed, exactly as
|
||||
// the seed works (ai-prompt.md #1: this node must never grow an IS_CHANGED).
|
||||
//
|
||||
// The FORMULA is deliberately left alone. It is prose written for a model
|
||||
// and people write things like "step #1" in it, which would be scanned as a
|
||||
// token; the idea box is where tags belong and where the highlight shows
|
||||
// you what they did.
|
||||
const res = expandAll(st.idea, makeRunResolvers());
|
||||
st.idea = res.out;
|
||||
entry.inputs = entry.inputs || {};
|
||||
entry.inputs[HIDDEN_INPUT] = JSON.stringify(st);
|
||||
} catch (nodeErr) {
|
||||
// Per-node guard: one bad node must not stop the others being injected.
|
||||
console.error("[Pixaroma.AIPrompt] inject failed for node", id, nodeErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[Pixaroma.AIPrompt] inject failed", e);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export { openPanel };
|
||||
@@ -1,174 +0,0 @@
|
||||
// AI Prompt Pixaroma - the recipe file format.
|
||||
//
|
||||
// A formula on its own is HALF a recipe. The Krea 2 wording writes cleanly at
|
||||
// temperature 0.3 and rambles, invents objects or refuses at 0.7 on the same
|
||||
// model and the same words, so a file that carries only the prose hands
|
||||
// somebody something that looks broken. This carries the settings too, and the
|
||||
// model it was measured with.
|
||||
//
|
||||
// WHY A READABLE .txt AND NOT JSON. Three reasons, in order of how much they
|
||||
// matter:
|
||||
// 1. it can be pasted into a chat message and read by a person who does not
|
||||
// have this plugin - which is how these actually get shared;
|
||||
// 2. the one number that matters most (temperature) is visible and editable
|
||||
// in Notepad, without knowing what JSON is;
|
||||
// 3. it degrades: anything we cannot parse is still just text.
|
||||
//
|
||||
// BACKWARDS COMPATIBLE BY CONSTRUCTION. A file that does not begin with the
|
||||
// header line is treated as a plain formula, exactly as Import did before this
|
||||
// format existed. So every .txt already exported, and any old prompt file
|
||||
// somebody has lying about, still imports as the wording. That is also why a
|
||||
// header with no `---` keeps the WHOLE text as the formula rather than
|
||||
// guessing: never lose the user's words to a parse.
|
||||
|
||||
import { DEFAULT_STATE, SETTING_KEYS } from "./core.mjs";
|
||||
|
||||
export const RECIPE_HEADER = "# Pixaroma AI Prompt recipe";
|
||||
const SEP_LINE = "---";
|
||||
|
||||
// Derived from DEFAULT_STATE rather than listed again, so adding a boolean
|
||||
// setting cannot leave this behind. Same reason SETTING_KEYS is imported and
|
||||
// not copied.
|
||||
const BOOL_KEYS = new Set(
|
||||
SETTING_KEYS.filter((key) => typeof DEFAULT_STATE[key] === "boolean"),
|
||||
);
|
||||
|
||||
const TRUE_WORDS = ["yes", "true", "on", "1"];
|
||||
const FALSE_WORDS = ["no", "false", "off", "0"];
|
||||
|
||||
// The settings that take a FRACTION. A comma decimal is rescued for these and
|
||||
// only these: much of the world writes 0,3, and the whole point of a readable
|
||||
// .txt is that people hand-edit it.
|
||||
//
|
||||
// The two integer settings are excluded because a comma there is far more
|
||||
// likely a thousands separator, and reading "max_length: 1,024" as 1.024 would
|
||||
// be a guess at what the writer meant. Be honest about what that buys, though:
|
||||
// parseFloat("1,024") is 1 and readState truncates, so that file caps the
|
||||
// answer at ONE TOKEN either way. Excluding them avoids GUESSING; it does not
|
||||
// protect anybody. Rejecting a comma outright for integer keys would be the
|
||||
// real improvement, and is deliberately left for its own change.
|
||||
//
|
||||
// Listed by hand rather than derived from DEFAULT_STATE because
|
||||
// presence_penalty defaults to 0.0, which Number.isInteger calls an integer.
|
||||
const FRACTION_KEYS = new Set([
|
||||
"temperature", "top_p", "min_p", "repetition_penalty", "presence_penalty",
|
||||
]);
|
||||
|
||||
/** One line, no line breaks: a header value must not be able to end the header. */
|
||||
function oneLine(value) {
|
||||
return String(value == null ? "" : value).replace(/[\r\n]+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* A recipe as text: a small readable header, `---`, then the formula verbatim.
|
||||
*
|
||||
* Booleans are written yes/no rather than true/false because the file is meant
|
||||
* to be read by people, and parsing accepts either.
|
||||
*/
|
||||
export function formatRecipe(recipe) {
|
||||
const r = recipe || {};
|
||||
const lines = [RECIPE_HEADER];
|
||||
if (oneLine(r.name)) lines.push("name: " + oneLine(r.name));
|
||||
if (oneLine(r.model)) lines.push("model: " + oneLine(r.model));
|
||||
if (oneLine(r.note)) lines.push("note: " + oneLine(r.note));
|
||||
|
||||
const settings = r.settings && typeof r.settings === "object" ? r.settings : {};
|
||||
for (const key of SETTING_KEYS) {
|
||||
const value = settings[key];
|
||||
if (value == null) continue;
|
||||
lines.push(key + ": " + (BOOL_KEYS.has(key) ? (value ? "yes" : "no") : String(value)));
|
||||
}
|
||||
|
||||
lines.push(SEP_LINE);
|
||||
return lines.join("\n") + "\n" + String(r.formula == null ? "" : r.formula);
|
||||
}
|
||||
|
||||
function coerce(key, value) {
|
||||
if (BOOL_KEYS.has(key)) {
|
||||
const word = value.toLowerCase();
|
||||
if (TRUE_WORDS.includes(word)) return true;
|
||||
if (FALSE_WORDS.includes(word)) return false;
|
||||
return null;
|
||||
}
|
||||
// parseFloat("0,3") is 0, which is FINITE, so it used to be accepted and
|
||||
// then clamped to the floor: a file reading 0,3 behaving as 0.01, the exact
|
||||
// "the formula looks broken" failure this format exists to prevent. Nothing
|
||||
// formatRecipe writes contains a comma, so this only ever rescues human
|
||||
// input. See FRACTION_KEYS for why it is scoped by key.
|
||||
const decimal = FRACTION_KEYS.has(key) && /^[+-]?\d+,\d+$/.test(value);
|
||||
const n = parseFloat(decimal ? value.replace(",", ".") : value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text -> { name, note, model, settings, formula, hadHeader }.
|
||||
*
|
||||
* Never throws and never returns an empty formula for non-empty input: the
|
||||
* worst case is that the whole text becomes the formula, which is precisely
|
||||
* what a plain .txt should do.
|
||||
*
|
||||
* Values are NOT clamped here. Everything goes onto the node through
|
||||
* writeState, and readState clamps every field on the way back out, so a file
|
||||
* claiming `temperature: 900` cannot reach Python.
|
||||
*/
|
||||
export function parseRecipe(raw) {
|
||||
// \r\n from a Windows editor, and \r alone from an old one. Normalising here
|
||||
// means the header match, the separator match and the stored formula are all
|
||||
// free of stray carriage returns, which otherwise survive into the textarea.
|
||||
const text = String(raw == null ? "" : raw).replace(/\r\n?/g, "\n");
|
||||
const plain = {
|
||||
name: "", note: "", model: "", settings: {}, formula: text, hadHeader: false,
|
||||
};
|
||||
|
||||
const lines = text.split("\n");
|
||||
let start = 0;
|
||||
while (start < lines.length && !lines[start].trim()) start += 1;
|
||||
if (start >= lines.length || lines[start].trim() !== RECIPE_HEADER) return plain;
|
||||
|
||||
let sepAt = -1;
|
||||
for (let i = start + 1; i < lines.length; i += 1) {
|
||||
if (lines[i].trim() === SEP_LINE) { sepAt = i; break; }
|
||||
}
|
||||
// A header with no separator is a file somebody has broken by hand. There is
|
||||
// no way to tell where the formula starts, so keep everything as the formula.
|
||||
if (sepAt === -1) return plain;
|
||||
|
||||
const out = {
|
||||
name: "", note: "", model: "", settings: {},
|
||||
// Only the FIRST separator splits, so a formula containing its own `---`
|
||||
// line survives a round trip untouched.
|
||||
formula: lines.slice(sepAt + 1).join("\n"),
|
||||
hadHeader: true,
|
||||
};
|
||||
|
||||
for (let i = start + 1; i < sepAt; i += 1) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) continue;
|
||||
const at = line.indexOf(":");
|
||||
if (at < 0) continue; // a stray line is ignored, not fatal
|
||||
const key = line.slice(0, at).trim().toLowerCase();
|
||||
const value = line.slice(at + 1).trim(); // split on the FIRST colon, so a
|
||||
if (key === "name") out.name = value; // name may contain one
|
||||
else if (key === "note") out.note = value;
|
||||
else if (key === "model") out.model = value;
|
||||
else if (SETTING_KEYS.includes(key)) {
|
||||
const coerced = coerce(key, value);
|
||||
if (coerced != null) out.settings[key] = coerced;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A filename stem for a recipe: its name if it has one, else the node's. */
|
||||
export function recipeFileStem(name, nodeTitle) {
|
||||
const raw = String(name || nodeTitle || "AI Prompt").trim() || "AI Prompt";
|
||||
const stem = raw
|
||||
.replace(/[^A-Za-z0-9 _-]+/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
// "Krea 2 - text to image" would otherwise become "Krea-2---text-to-image".
|
||||
// This is a file people send to each other, so it should not look sloppy.
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48);
|
||||
return stem || "ai-prompt-recipe";
|
||||
}
|
||||
@@ -1,872 +0,0 @@
|
||||
// AI Prompt Pixaroma - the node face.
|
||||
//
|
||||
// ONE DOM widget in both renderers, so a live renderer flip has nothing to
|
||||
// swap and ComfyUI re-parents the element itself (video-prompt.md #13 - an
|
||||
// onRendererChange rebuild was tried there, leaked a root per flip, and was
|
||||
// reverted).
|
||||
//
|
||||
// THE CONTROLS LIVE IN THE SLOT BAND. Five inputs against one output leaves
|
||||
// four empty rows on the right, and the seed, the gear and the join order sit
|
||||
// there instead of costing the body a row each. In CLASSIC the band FLOATS out
|
||||
// of the widget flow with a negative top, which is free height
|
||||
// (reference_classic_dom_band_float); in Nodes 2.0 the node body CLIPS
|
||||
// anything above the widget top, so it stays in flow as a top row. Both write
|
||||
// only DOM style, never node.size or node.properties, so neither can dirty a
|
||||
// saved workflow, and both are wrapped so a future frontend degrades to a row
|
||||
// rather than breaking the node.
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { pixAsset } from "../shared/api_url.mjs";
|
||||
import { ACC, accentOf, installNodeAccent } from "../shared/node_settings.mjs";
|
||||
import { applyAdaptiveCanvasOnly, isVueNodes } from "../shared/nodes2.mjs";
|
||||
import { installCanvasZoomPassthrough } from "../shared/canvas_zoom.mjs";
|
||||
import { installNativeTextMenu } from "../shared/native_text_menu.mjs";
|
||||
import { installResizeFloor } from "../shared/resize_floor.mjs";
|
||||
import { notifyGraphChanged } from "../shared/graph_changed.mjs";
|
||||
import { passComfyShortcut } from "../shared/text_shortcuts.mjs";
|
||||
// Safe: settings.mjs does not import this file, so there is no cycle.
|
||||
import { askName } from "./settings.mjs";
|
||||
// The @tag / *category / #list layer, shared with Prompt Pixaroma so the two nodes
|
||||
// read the SAME library and colour it the same way (prompt.md #24/#25 - wildCat and
|
||||
// listOf must stay the single source of "is this live").
|
||||
import {
|
||||
acKeydown, backdropHTML, closeAC, injectTagCSS, insertTagAt,
|
||||
maybeAC, syncColumns,
|
||||
} from "../prompt/tag_field.mjs";
|
||||
import { openLibraryEditor, closeLibraryEditorFor } from "../prompt/library_editor.mjs";
|
||||
import { textSize, onTextSizeChange } from "../shared/text_size.mjs";
|
||||
import { followSpellcheck } from "../shared/spellcheck.mjs";
|
||||
import {
|
||||
IDEA_SHARE_DEFAULT,
|
||||
IDEA_SHARE_MAX,
|
||||
IDEA_SHARE_MIN,
|
||||
ORDER_IDEA,
|
||||
ORDER_WIRED,
|
||||
SEED_FIXED,
|
||||
SEED_RANDOM,
|
||||
displaySeed,
|
||||
extraImageInputs,
|
||||
readLast,
|
||||
readState,
|
||||
rollSeed,
|
||||
shortModel,
|
||||
slotConnected,
|
||||
willGenerate,
|
||||
wiredSummary,
|
||||
writeLast,
|
||||
writeState,
|
||||
} from "./core.mjs";
|
||||
|
||||
const CSS_ID = "pixaroma-ai-prompt-css";
|
||||
export const WIDGET_TYPE = "pixaroma_ai_prompt";
|
||||
const WIDGET_MIN_H = 250;
|
||||
|
||||
// Calibrated against a 5-input / 1-output node in the Classic renderer.
|
||||
// The band's first row lands beside the SECOND input (directly under the
|
||||
// output dot) and the second row two slots below it, leaving one empty row
|
||||
// between them so the seed and the join segment never read as one block.
|
||||
const CLASSIC_BAND_TOP = -92;
|
||||
const SLOT_ROW_H = 20; // LiteGraph.NODE_SLOT_HEIGHT, one input row
|
||||
const CLASSIC_RSV_L = 78; // clear the clip / image / video / audio labels
|
||||
// The band spreads over the FOUR free slot rows rather than bunching at the top:
|
||||
// gear + seed beside slot 2, Tags halfway down, and the join segment beside the
|
||||
// TEXT input it belongs to (slot 5), which is what it actually controls.
|
||||
//
|
||||
// Solved, not eyeballed. Rows 1 and 3 are 19px, the Tags row is TAGS_H, and the
|
||||
// centres must be 60px apart (slot 2 at 34 to slot 5 at 94), so
|
||||
// (row3 centre - row1 centre) = 19 + 2*gap + TAGS_H = 60
|
||||
// which fixes the gap once TAGS_H is chosen. With TAGS_H 23 that is a 9px gap, and
|
||||
// it puts the Tags centre at 63.5 - midway between slots 3 and 4, i.e. exactly
|
||||
// between the seed and the join. Change one and re-solve the other; do not nudge
|
||||
// them by eye (pattern #4).
|
||||
const TAGS_H = 23;
|
||||
const CLASSIC_ROW_GAP = 9;
|
||||
const VUE_ROW_GAP = 5;
|
||||
// The widget root stops at node width - 10, while LiteGraph draws the output
|
||||
// dot centred at width - 9 with radius 4, so its right edge is 5px further
|
||||
// out. Measured, not guessed: getConnectionPos(false, 0) put the dot at x=391
|
||||
// on a 400-wide node while the root's right edge sat at 390. Without this the
|
||||
// rail stops short of the dot and the alignment reads as an accident.
|
||||
const CLASSIC_BAND_RIGHT = -5;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (cls) node.className = cls;
|
||||
if (text != null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
export function injectCSS() {
|
||||
if (document.getElementById(CSS_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = CSS_ID;
|
||||
style.textContent = `
|
||||
.pix-ap-root { position:relative; width:100%; height:100%; box-sizing:border-box; }
|
||||
/* The flex column lives on an INNER layer: ComfyUI writes display:block on
|
||||
the addDOMWidget root itself when a node is rebuilt or collapsed, and an
|
||||
inline style beats a class, so a flex column on the root silently dies
|
||||
after the first tab switch. */
|
||||
.pix-ap-inner { position:absolute; inset:0; display:flex; flex-direction:column;
|
||||
gap:6px; font:12px 'Segoe UI', sans-serif; box-sizing:border-box; }
|
||||
|
||||
.pix-ap-band { display:flex; flex-direction:column; align-items:flex-end;
|
||||
gap:${VUE_ROW_GAP}px; }
|
||||
.pix-ap-band.floated { position:absolute; left:${CLASSIC_RSV_L}px;
|
||||
right:${CLASSIC_BAND_RIGHT}px; top:${CLASSIC_BAND_TOP}px;
|
||||
gap:${CLASSIC_ROW_GAP}px; z-index:2; pointer-events:none; }
|
||||
.pix-ap-band.floated > * { pointer-events:auto; }
|
||||
.pix-ap-bandrow { display:flex; align-items:center; gap:6px; }
|
||||
|
||||
.pix-ap-gear { width:19px; height:19px; flex:0 0 auto; border-radius:4px;
|
||||
background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.14);
|
||||
display:flex; align-items:center; justify-content:center; cursor:pointer;
|
||||
padding:0; }
|
||||
.pix-ap-gear::before { content:""; display:block; width:12px; height:12px;
|
||||
background:#bbb;
|
||||
-webkit-mask:url("${pixAsset("icons/note/gear.svg")}") center/contain no-repeat;
|
||||
mask:url("${pixAsset("icons/note/gear.svg")}") center/contain no-repeat; }
|
||||
.pix-ap-gear:hover { border-color:${ACC}; }
|
||||
.pix-ap-gear:hover::before { background:${ACC}; }
|
||||
|
||||
.pix-ap-seedwrap { display:flex; height:19px; flex:0 0 auto; border-radius:4px;
|
||||
overflow:hidden; border:1px solid rgba(255,255,255,.14); }
|
||||
.pix-ap-seed { background:rgba(255,255,255,.05); color:#c9c6c2;
|
||||
font:10.5px monospace; border:none; padding:0 8px; cursor:pointer;
|
||||
display:flex; align-items:center; }
|
||||
.pix-ap-seed:hover { color:${ACC}; }
|
||||
.pix-ap-seedmode { background:rgba(255,255,255,.05); color:#c9c6c2;
|
||||
font:10.5px monospace; border:none; border-left:1px solid rgba(255,255,255,.14);
|
||||
padding:0 7px; cursor:pointer; display:flex; align-items:center; }
|
||||
.pix-ap-seedmode.is-on { background:${ACC}; color:#fff;
|
||||
border-left-color:rgba(0,0,0,.3); }
|
||||
|
||||
.pix-ap-seg { display:flex; height:19px; flex:0 0 auto; border-radius:4px;
|
||||
overflow:hidden; border:1px solid rgba(255,255,255,.14); }
|
||||
.pix-ap-seg button { background:rgba(255,255,255,.04); color:rgba(255,255,255,.6);
|
||||
border:none; font:10.5px 'Segoe UI', sans-serif; padding:0 8px; cursor:pointer;
|
||||
display:flex; align-items:center; }
|
||||
.pix-ap-seg button + button { border-left:1px solid rgba(255,255,255,.12); }
|
||||
.pix-ap-seg button:hover { color:#fff; }
|
||||
.pix-ap-seg button.is-on { background:${ACC}; color:#fff;
|
||||
border-left-color:rgba(0,0,0,.3); }
|
||||
|
||||
.pix-ap-banner { display:flex; align-items:center; gap:8px; flex:0 0 auto;
|
||||
border-radius:5px; padding:6px 9px; font-size:11.5px; color:#e7e4e0;
|
||||
background:color-mix(in srgb, ${ACC} 10%, transparent);
|
||||
border:1px solid color-mix(in srgb, ${ACC} 34%, transparent); }
|
||||
/* The label takes the room and ellipsises; the hint never shrinks. This is
|
||||
what makes a wider node show more of the model name - a JS character cut
|
||||
would show the same truncated string at every width. */
|
||||
.pix-ap-banner .lbl { flex:1 1 auto; min-width:0; overflow:hidden;
|
||||
text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pix-ap-banner .hint { margin-left:auto; padding-left:8px; color:#6f6c67;
|
||||
font-size:11px; flex:0 0 auto; }
|
||||
.pix-ap-banner.is-warn { background:rgba(224,163,58,.12);
|
||||
border-color:rgba(224,163,58,.4); }
|
||||
.pix-ap-banner.is-warn .lbl { color:#e0a33a; }
|
||||
.pix-ap-banner.is-mute { background:rgba(255,255,255,.03);
|
||||
border-color:rgba(255,255,255,.1); }
|
||||
.pix-ap-banner.is-mute .lbl { color:#a4a09a; }
|
||||
|
||||
.pix-ap-caprow { display:flex; align-items:baseline; gap:10px; flex:0 0 auto; }
|
||||
.pix-ap-cap { font-size:10px; letter-spacing:.09em; text-transform:uppercase;
|
||||
color:${ACC}; flex:0 0 auto; }
|
||||
.pix-ap-expand { margin-left:auto; background:none; border:none; color:#6f6c67;
|
||||
font-size:11px; cursor:pointer; padding:0; font-family:'Segoe UI', sans-serif; }
|
||||
.pix-ap-expand:hover { color:${ACC}; }
|
||||
/* One line, always. Left to wrap it ran under the PROMPT label and the two
|
||||
collided; nowrap plus a shrinkable min-width means a long note
|
||||
ellipsises instead of pushing the row taller. */
|
||||
.pix-ap-meta { margin-left:auto; font:10.5px monospace; color:#6f6c67;
|
||||
flex:0 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis;
|
||||
white-space:nowrap; text-align:right; }
|
||||
.pix-ap-meta.is-error { color:#e0a33a; }
|
||||
/* No .is-stale rule here on purpose. Video Prompt marks a readout that no
|
||||
longer matches the node's current settings; porting that needs the
|
||||
matching stamp-and-compare in applyResult/renderFace, which this node
|
||||
does not have, and a CSS class nothing ever sets is just a promise the
|
||||
code does not keep. */
|
||||
|
||||
.pix-ap-out { background:#191919; border:1px solid #343436;
|
||||
border-radius:4px; padding:6px 8px; color:#ddd9d4; font:11.5px/1.45 monospace; font-size:${textSize(11.5)};
|
||||
resize:none; outline:none; box-sizing:border-box; width:100%; }
|
||||
|
||||
/* The idea box is TWO STACKED LAYERS so @tags can be coloured while you type.
|
||||
The dark surface and the border live on the WRAPPER; the backdrop underneath is
|
||||
the only VISIBLE text; the textarea on top has transparent text and a visible
|
||||
caret. One visible text layer means the two can never double or ghost - but
|
||||
their text COLUMNS must be identical or they wrap at different characters and
|
||||
the caret drifts further off with every wrapped line. scrollbar-gutter:stable
|
||||
on both is the first defence and syncColumns() measures the rest
|
||||
(prompt.md #18, house convention #26). */
|
||||
.pix-ap-ideawrap { position:relative; display:flex; box-sizing:border-box;
|
||||
flex:var(--pix-ap-idea-grow,360) 1 0; min-height:44px;
|
||||
background:#191919; border:1px solid #343436; border-radius:4px; }
|
||||
/* ONLY the idea box takes the accent on focus. The readout is readOnly, and
|
||||
a focus ring is the strongest "you can type here" cue there is. */
|
||||
.pix-ap-ideawrap:focus-within { border-color:${ACC}; }
|
||||
.pix-ap-ideabd { position:absolute; inset:0; padding:6px 8px; border:0;
|
||||
font:11.5px/1.45 monospace; font-size:${textSize(11.5)}; color:#ddd9d4; white-space:pre-wrap;
|
||||
word-wrap:break-word; overflow:hidden; scrollbar-gutter:stable;
|
||||
pointer-events:none; box-sizing:border-box; }
|
||||
/* overflow-wrap MUST match .pix-ap-ideabd's. syncColumns equalises the two
|
||||
columns' WIDTH, and width parity is NOT wrap parity: the backdrop breaks a
|
||||
long unbroken token, a textarea defaults to overflow-wrap:normal and moves
|
||||
it whole to the next line, so the caret drifts a little further on every
|
||||
such token. Same gap found and fixed on Prompt (prompt.md #18); this node
|
||||
shares that backdrop, so it shares the bug. It does not reproduce on this
|
||||
machine only because a ComfyUI stylesheet happens to supply break-word. */
|
||||
.pix-ap-idea { flex:1 1 auto; width:100%; height:100%; box-sizing:border-box;
|
||||
background:transparent; color:transparent; caret-color:${ACC}; border:0;
|
||||
border-radius:4px; padding:6px 8px; font:11.5px/1.45 monospace; font-size:${textSize(11.5)}; resize:none;
|
||||
outline:none; scrollbar-gutter:stable;
|
||||
white-space:pre-wrap; overflow-wrap:break-word; }
|
||||
/* ⚠️ color:transparent does NOT hide this layer while text is SELECTED.
|
||||
The browser paints selected text in the selection's own foreground colour,
|
||||
overriding it - CONFIRMED live: selecting in this box makes the textarea's
|
||||
own glyphs appear. Normally they land exactly on the backdrop's and nobody
|
||||
notices; where the two layers' metrics differ by even a fraction the offset
|
||||
ACCUMULATES along the line and you see the sentence twice, sliding apart to
|
||||
the right. That is the "ghosted font" report (2026-08-19), whose video shows
|
||||
"A woman in a fiery plume" doubled inside the selection.
|
||||
Zeroing the selection colour restores the invariant this design rests on -
|
||||
ONE visible layer, always. The backdrop (position:absolute, so it paints
|
||||
ABOVE this in-flow textarea) supplies the visible text over the highlight.
|
||||
⚠️ THE BACKGROUND HALF IS MANDATORY. DECLARING "::selection" AT ALL MAKES
|
||||
CHROME DROP THE UA DEFAULT HIGHLIGHT BACKGROUND, so "color:transparent"
|
||||
alone renders NOTHING on selection - still selected and copyable, but
|
||||
invisible, which a user reported as "I cannot select the prompt any more"
|
||||
(2026-09-07, shipped in v1.4.135). Diagnosed on the sibling Prompt node and
|
||||
mutation-validated there. Note's code view always declared BOTH halves;
|
||||
v1.4.135 copied only the colour ([[feedback_partial_mirror_worse_than_none]]).
|
||||
Do NOT restore a visible selection FOREGROUND colour, and do NOT remove the
|
||||
background. */
|
||||
.pix-ap-idea::selection { color:transparent; background:color-mix(in srgb, ${ACC} 35%, transparent); }
|
||||
.pix-ap-idea::-moz-selection { color:transparent; background:color-mix(in srgb, ${ACC} 35%, transparent); }
|
||||
|
||||
/* NO expanded-preview box here, deliberately - see ai-prompt.md #21. One was
|
||||
built and removed the same day: this face already carries a banner, two
|
||||
caption rows, two text boxes, a grip and a button row, so on a 320x368 node
|
||||
the idea box is ALREADY under its own 44px minimum. A third box collapsed to
|
||||
an 11px sliver, and the only ways out were forcing the node taller than the
|
||||
user had made it or letting the buttons spill past the frame. The colours in
|
||||
the idea box already say whether a tag is real, which is the part that
|
||||
matters; what a slot ROLLED shows up in the generated prompt below. */
|
||||
|
||||
/* Tags: opens the shared library. Deliberately a touch LARGER than the gear and
|
||||
the seed chip - it is the one control on this band a person reaches for while
|
||||
writing, and its height feeds the row arithmetic above (TAGS_H). */
|
||||
.pix-ap-tags { height:${TAGS_H}px; flex:0 0 auto; border-radius:4px; padding:0 13px;
|
||||
background:rgba(255,255,255,.05); border:1px solid rgba(255,255,255,.14);
|
||||
color:#c9c6c2; font:11.5px 'Segoe UI', sans-serif; cursor:pointer;
|
||||
display:flex; align-items:center; gap:5px; }
|
||||
.pix-ap-tags:hover { background:${ACC}; border-color:${ACC}; color:#fff; }
|
||||
/* The PROMPT readout is a preview, not an input, so it wears Prompt
|
||||
Pixaroma's read-only surface (.pix-prm-expand): a LIGHTER, raised panel
|
||||
rather than the sunken dark field an editable box uses. Reported as
|
||||
"since text is on dark background look like is editable". Still
|
||||
selectable and copyable - cursor:text says so. */
|
||||
.pix-ap-out { flex:var(--pix-ap-out-grow,640) 1 0; min-height:64px;
|
||||
background:#2d2d2d; border-color:#3a3a3a; color:#d8d8d8; cursor:text; }
|
||||
.pix-ap-idea::placeholder { color:#5c5a57; }
|
||||
.pix-ap-out::placeholder { color:#7d7a76; }
|
||||
|
||||
.pix-ap-grip { height:9px; flex:0 0 auto; display:flex; align-items:center;
|
||||
justify-content:center; cursor:ns-resize; }
|
||||
.pix-ap-grip i { display:block; width:34px; height:2px; border-radius:2px;
|
||||
background:#3d3d3f; }
|
||||
.pix-ap-grip:hover i { background:${ACC}; }
|
||||
|
||||
.pix-ap-acts { display:flex; align-items:center; gap:6px; flex-wrap:wrap;
|
||||
flex:0 0 auto; }
|
||||
.pix-ap-btn { background:rgba(255,255,255,.05);
|
||||
border:1px solid rgba(255,255,255,.13); color:rgba(255,255,255,.7);
|
||||
border-radius:4px; padding:5px 11px; font:11.5px 'Segoe UI', sans-serif;
|
||||
cursor:pointer; box-sizing:border-box; user-select:none; }
|
||||
.pix-ap-btn:hover { background:${ACC}; border-color:${ACC}; color:#fff; }
|
||||
.pix-ap-btn:disabled, .pix-ap-btn:disabled:hover { opacity:.38;
|
||||
background:rgba(255,255,255,.05); border-color:rgba(255,255,255,.13);
|
||||
color:rgba(255,255,255,.7); cursor:default; }
|
||||
.pix-ap-btn.is-on, .pix-ap-btn.is-on:hover { background:${ACC};
|
||||
border-color:${ACC}; color:#fff; }
|
||||
.pix-ap-btn.is-on::before { content:"✓ "; }
|
||||
.pix-ap-btn.is-inert { opacity:.4; }
|
||||
.pix-ap-btn.is-flash, .pix-ap-btn.is-flash:hover { background:#3ec371;
|
||||
border-color:#3ec371; color:#fff; }
|
||||
.pix-ap-spacer { flex:1; }
|
||||
.pix-ap-primary { font-weight:600; padding:5px 18px; background:${ACC};
|
||||
border-color:${ACC}; color:#fff; }
|
||||
.pix-ap-primary:hover { filter:brightness(1.1); }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function flash(button, label) {
|
||||
if (!button) return;
|
||||
// Cache the ORIGINAL once and cancel any pending restore. Capturing it fresh
|
||||
// on every call meant a second click inside the 700ms window captured
|
||||
// "Copied" as the original, so the button read "Copied" for the rest of the
|
||||
// session - and not even green, so it just looked broken. Nothing in
|
||||
// renderFace rewrites button labels, so nothing ever put it right.
|
||||
// Reproduced here before fixing; the sibling node had already learned this.
|
||||
clearTimeout(button._pixFlashT);
|
||||
if (button._pixFlashOrig == null) button._pixFlashOrig = button.textContent;
|
||||
button.classList.add("is-flash");
|
||||
if (label) button.textContent = label;
|
||||
button._pixFlashT = setTimeout(() => {
|
||||
button.classList.remove("is-flash");
|
||||
if (button._pixFlashOrig != null) button.textContent = button._pixFlashOrig;
|
||||
button._pixFlashOrig = null;
|
||||
button._pixFlashT = null;
|
||||
}, 700);
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// http on a LAN has no clipboard API. execCommand still works there.
|
||||
try {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand("copy");
|
||||
ta.remove();
|
||||
return ok;
|
||||
} catch (e2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the two grow numbers the idea/prompt split rides on.
|
||||
* Style ONLY, so this is safe to call from the load path (Vue Compat #18). */
|
||||
export function applyShare(node) {
|
||||
const els = node?._pixApEls;
|
||||
if (!els?.inner) return;
|
||||
const st = readState(node);
|
||||
const share = st.idea_share;
|
||||
const ideaTotal = Math.round(share * 1000);
|
||||
els.inner.style.setProperty("--pix-ap-idea-grow", String(ideaTotal));
|
||||
els.inner.style.setProperty("--pix-ap-out-grow", String(Math.round((1 - share) * 1000)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the shared tag library, with Insert writing into THIS node's idea box.
|
||||
* The editor itself knows nothing about either node - it takes an accent, an
|
||||
* optional prefill and an onInsert.
|
||||
*/
|
||||
export function openTagLibrary(node, ideaEl, ctx) {
|
||||
openLibraryEditor(node, {
|
||||
accent: accentOf(node),
|
||||
// `sym` is "#" for a List tag (roll one line) and "@" for a snippet - the editor
|
||||
// decides from the card's own kind so Insert drops in the useful form.
|
||||
onInsert: (name, sym) => {
|
||||
// Re-resolve rather than trusting the element captured when the library opened:
|
||||
// Vue can rebuild the widget mid-session and Insert would write into a detached
|
||||
// textarea, then stamp that dead element's value into node.properties.
|
||||
const live = node._pixApEls?.idea || ideaEl;
|
||||
insertTagAt(live, ctx, name, sym);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint the highlight backdrop under the idea textarea.
|
||||
*
|
||||
* DOM only, so it is safe from the load path. There is no expanded-preview box to
|
||||
* fill - see the CSS note above for why one was built and taken out again.
|
||||
*/
|
||||
function renderIdeaTags(node) {
|
||||
const els = node?._pixApEls;
|
||||
if (!els?.ideabd) return;
|
||||
els.ideabd.innerHTML = backdropHTML(els.idea.value);
|
||||
}
|
||||
|
||||
function installGrip(node, grip, idea, out) {
|
||||
let active = false;
|
||||
let pid = null;
|
||||
|
||||
const end = () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
try { if (pid != null) grip.releasePointerCapture(pid); } catch (e) { /* gone */ }
|
||||
pid = null;
|
||||
// A drag ends on pointerup, and the pack-wide change net listens for click
|
||||
// and change - so without this the new height is silently lost on the next
|
||||
// open (convention #31).
|
||||
notifyGraphChanged();
|
||||
};
|
||||
|
||||
grip.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
active = true;
|
||||
pid = e.pointerId;
|
||||
try { grip.setPointerCapture(pid); } catch (e2) { /* not fatal */ }
|
||||
});
|
||||
|
||||
grip.addEventListener("pointermove", (e) => {
|
||||
if (!active) return;
|
||||
// The buttons-up guard. A real mouse can lose its release - the pointer
|
||||
// leaves the viewport, another element takes capture - and without this
|
||||
// the grip follows the cursor forever. A synthetic drag CANNOT reproduce
|
||||
// that, so this is on principle, not on a repro (convention #20).
|
||||
if (!(e.buttons & 1)) { end(); return; }
|
||||
const top = idea.getBoundingClientRect().top;
|
||||
const bottom = out.getBoundingClientRect().bottom;
|
||||
const span = bottom - top;
|
||||
if (span <= 1) return;
|
||||
// A RATIO, never pixels: every rect here is in SCREEN pixels, which is
|
||||
// element pixels times the canvas zoom, so a stored pixel height would be
|
||||
// wrong at every zoom but the one it was set at. Dividing cancels it out.
|
||||
const share = Math.max(IDEA_SHARE_MIN,
|
||||
Math.min(IDEA_SHARE_MAX, (e.clientY - top) / span));
|
||||
writeState(node, { idea_share: share });
|
||||
applyShare(node);
|
||||
});
|
||||
|
||||
grip.addEventListener("pointerup", end);
|
||||
grip.addEventListener("pointercancel", end);
|
||||
grip.addEventListener("lostpointercapture", end);
|
||||
grip.addEventListener("dblclick", (e) => {
|
||||
e.preventDefault();
|
||||
writeState(node, { idea_share: IDEA_SHARE_DEFAULT });
|
||||
applyShare(node);
|
||||
notifyGraphChanged();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Band placement
|
||||
// ---------------------------------------------------------------------------
|
||||
/** Float the band into the slot band in Classic; leave it in flow otherwise.
|
||||
* Wrapped so a frontend change costs the placement, never the node. */
|
||||
export function placeBand(node) {
|
||||
const els = node?._pixApEls;
|
||||
if (!els?.band) return;
|
||||
try {
|
||||
const classic = !isVueNodes();
|
||||
els.band.classList.toggle("floated", classic);
|
||||
// Each image_N socket sits BELOW `text`, and in Classic every slot row
|
||||
// pushes the widget one row further down - so the band rises by the same
|
||||
// amount to stay beside slots 2-5, which is what the constants above were
|
||||
// measured against. DOM style only, so it is safe on the load path.
|
||||
const extra = classic ? extraImageInputs(node).length : 0;
|
||||
els.band.style.top = extra ? `${CLASSIC_BAND_TOP - SLOT_ROW_H * extra}px` : "";
|
||||
} catch (e) {
|
||||
els.band.classList.remove("floated");
|
||||
els.band.style.top = "";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Face
|
||||
// ---------------------------------------------------------------------------
|
||||
export function buildFace(node, openPanel, openIdeaEditor) {
|
||||
injectCSS();
|
||||
injectTagCSS(); // the token colours + the autocomplete popup, shared with Prompt
|
||||
|
||||
const root = el("div", "pix-ap-root");
|
||||
const inner = el("div", "pix-ap-inner");
|
||||
root.appendChild(inner);
|
||||
|
||||
// ---- the slot band -------------------------------------------------------
|
||||
const band = el("div", "pix-ap-band");
|
||||
|
||||
const row1 = el("div", "pix-ap-bandrow");
|
||||
const gear = el("button", "pix-ap-gear");
|
||||
gear.title = "Settings: the model, the formula and how wired text is joined";
|
||||
gear.addEventListener("click", (e) => { e.stopPropagation(); openPanel(node); });
|
||||
const seedwrap = el("div", "pix-ap-seedwrap");
|
||||
const seed = el("button", "pix-ap-seed", "0");
|
||||
const seedmode = el("button", "pix-ap-seedmode", "F");
|
||||
seedwrap.append(seed, seedmode);
|
||||
row1.append(gear, seedwrap);
|
||||
|
||||
// Row 2 is the Tags button, which the user asked to sit under the gear and the
|
||||
// seed. It also does the job the deliberately-empty row used to: it separates the
|
||||
// gear/seed cluster from the join segment below.
|
||||
const rowTags = el("div", "pix-ap-bandrow");
|
||||
const tagsBtn = el("button", "pix-ap-tags", "Tags");
|
||||
tagsBtn.title = "Your @tag library: reusable snippets, *category and #list slots. Shared with Prompt Pixaroma.";
|
||||
rowTags.appendChild(tagsBtn);
|
||||
|
||||
const row2 = el("div", "pix-ap-bandrow");
|
||||
const seg = el("div", "pix-ap-seg");
|
||||
const segIdea = el("button", null, "Idea first");
|
||||
const segWired = el("button", null, "Wired first");
|
||||
seg.append(segIdea, segWired);
|
||||
row2.appendChild(seg);
|
||||
|
||||
band.append(row1, rowTags, row2);
|
||||
inner.appendChild(band);
|
||||
|
||||
// ---- banner --------------------------------------------------------------
|
||||
const banner = el("div", "pix-ap-banner");
|
||||
const bLabel = el("span", "lbl", "");
|
||||
const bHint = el("span", "hint", "");
|
||||
banner.append(bLabel, bHint);
|
||||
inner.appendChild(banner);
|
||||
|
||||
// ---- idea ----------------------------------------------------------------
|
||||
const cap1 = el("div", "pix-ap-caprow");
|
||||
const expand = el("button", "pix-ap-expand", "Expand");
|
||||
expand.title = "Open the idea in a full-screen box";
|
||||
cap1.append(el("span", "pix-ap-cap", "Your idea"), expand);
|
||||
// Two layers: the backdrop is the visible text, the textarea on top owns the caret.
|
||||
const ideawrap = el("div", "pix-ap-ideawrap");
|
||||
const ideabd = el("div", "pix-ap-ideabd");
|
||||
const idea = el("textarea", "pix-ap-idea");
|
||||
idea.placeholder = "what you want, in plain words";
|
||||
followSpellcheck(idea);
|
||||
ideawrap.append(ideabd, idea);
|
||||
inner.append(cap1, ideawrap);
|
||||
|
||||
const grip = el("div", "pix-ap-grip");
|
||||
grip.title = "Drag to change how the height is shared. Double-click to reset.";
|
||||
grip.appendChild(el("i"));
|
||||
inner.appendChild(grip);
|
||||
|
||||
// ---- prompt --------------------------------------------------------------
|
||||
const cap2 = el("div", "pix-ap-caprow");
|
||||
const meta = el("span", "pix-ap-meta", "");
|
||||
cap2.append(el("span", "pix-ap-cap", "Prompt"), meta);
|
||||
const out = el("textarea", "pix-ap-out");
|
||||
out.readOnly = true;
|
||||
out.placeholder = "press Generate to write the text";
|
||||
inner.append(cap2, out);
|
||||
|
||||
// ---- buttons -------------------------------------------------------------
|
||||
const acts = el("div", "pix-ap-acts");
|
||||
const reroll = el("button", "pix-ap-btn", "Re-roll");
|
||||
reroll.title = "Roll a new seed and run again, for a different answer";
|
||||
const copy = el("button", "pix-ap-btn", "Copy");
|
||||
copy.title = "Copy the text";
|
||||
const vram = el("button", "pix-ap-btn", "Free VRAM");
|
||||
const generate = el("button", "pix-ap-btn pix-ap-primary", "Generate");
|
||||
generate.title = "Run the workflow";
|
||||
acts.append(reroll, copy, vram, el("span", "pix-ap-spacer"), generate);
|
||||
inner.appendChild(acts);
|
||||
|
||||
// ---- wiring --------------------------------------------------------------
|
||||
// The @tag layer. ctx is re-resolved per call, never captured: Vue can tear down
|
||||
// and rebuild a node's DOM widget mid-session (Vue Compat #5), and a stale closure
|
||||
// would write into a detached textarea.
|
||||
const acCtx = () => ({
|
||||
accent: () => accentOf(node),
|
||||
commit: (value) => { writeState(node, { idea: value }); renderFace(node); },
|
||||
});
|
||||
|
||||
idea.addEventListener("input", () => {
|
||||
writeState(node, { idea: idea.value });
|
||||
renderFace(node);
|
||||
maybeAC(acCtx(), idea);
|
||||
});
|
||||
idea.addEventListener("keydown", (e) => {
|
||||
// The autocomplete gets first refusal. It deliberately does not claim
|
||||
// Ctrl/Cmd+Enter, and passComfyShortcut lets it (and Ctrl+S) on to ComfyUI:
|
||||
// the blanket stop below used to eat them (UI#44). NOT the Ctrl+Arrow
|
||||
// word-weight shortcut: this is an instruction to a language model, where
|
||||
// "(word:1.05)" means nothing, and on a Mac Cmd+Up/Down must keep jumping
|
||||
// the caret. The idea commits as you type, so the box keeps the focus.
|
||||
if (acKeydown(e)) return;
|
||||
if (passComfyShortcut(e, idea)) return;
|
||||
e.stopPropagation();
|
||||
});
|
||||
// Keep the visible layer scrolled with the invisible one, or the caret leaves the
|
||||
// text behind as soon as the idea is taller than the box.
|
||||
idea.addEventListener("scroll", () => {
|
||||
ideabd.scrollTop = idea.scrollTop;
|
||||
ideabd.scrollLeft = idea.scrollLeft;
|
||||
});
|
||||
// Belt for the column measurement: a theme stylesheet can land after the node was
|
||||
// built and change the scrollbar width without ever resizing the textarea, which
|
||||
// the ResizeObserver would not hear about.
|
||||
idea.addEventListener("focus", () => syncColumns(idea, ideabd));
|
||||
idea.addEventListener("blur", () => closeAC());
|
||||
out.addEventListener("keydown", (e) => { if (passComfyShortcut(e, out)) return; e.stopPropagation(); });
|
||||
|
||||
tagsBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
openTagLibrary(node, idea, acCtx());
|
||||
});
|
||||
|
||||
expand.addEventListener("click", () => openIdeaEditor(node));
|
||||
|
||||
// Themed, not window.prompt: Electron (ComfyUI Desktop) does not implement
|
||||
// that at all, so the chip was simply dead there with nothing shown.
|
||||
seed.addEventListener("click", async () => {
|
||||
const current = readState(node);
|
||||
const typed = await askName("Seed", "Type the seed number to use:",
|
||||
String(current.seed));
|
||||
if (typed == null) return;
|
||||
const value = parseInt(typed, 10);
|
||||
if (!Number.isFinite(value)) return;
|
||||
writeState(node, { seed: Math.max(0, value) });
|
||||
renderFace(node);
|
||||
notifyGraphChanged();
|
||||
});
|
||||
|
||||
seedmode.addEventListener("click", () => {
|
||||
const current = readState(node);
|
||||
writeState(node, {
|
||||
seed_mode: current.seed_mode === SEED_RANDOM ? SEED_FIXED : SEED_RANDOM,
|
||||
});
|
||||
renderFace(node);
|
||||
notifyGraphChanged();
|
||||
});
|
||||
|
||||
segIdea.addEventListener("click", () => {
|
||||
writeState(node, { order: ORDER_IDEA });
|
||||
renderFace(node);
|
||||
notifyGraphChanged();
|
||||
});
|
||||
segWired.addEventListener("click", () => {
|
||||
writeState(node, { order: ORDER_WIRED });
|
||||
renderFace(node);
|
||||
notifyGraphChanged();
|
||||
});
|
||||
|
||||
reroll.addEventListener("click", () => {
|
||||
// In FIXED mode a re-roll has to CHANGE the seed, or nothing differs and
|
||||
// ComfyUI serves the cached answer - the button would look broken. That is
|
||||
// a deliberate user action, so dirtying the workflow is correct.
|
||||
//
|
||||
// In RANDOM mode it must NOT: seedForRun already rolls a fresh seed for
|
||||
// every run and ignores the stored one, so writing one here would change
|
||||
// nothing about the result while still marking a clean workflow modified.
|
||||
if (readState(node).seed_mode !== SEED_RANDOM) {
|
||||
writeState(node, { seed: rollSeed() });
|
||||
renderFace(node);
|
||||
notifyGraphChanged();
|
||||
}
|
||||
app.queuePrompt?.(0, 1);
|
||||
});
|
||||
|
||||
copy.addEventListener("click", async () => {
|
||||
const text = readLast(node).text;
|
||||
if (!text) return;
|
||||
if (await copyText(text)) flash(copy, "Copied");
|
||||
});
|
||||
|
||||
vram.addEventListener("click", () => {
|
||||
const current = readState(node);
|
||||
writeState(node, { release_model: !current.release_model });
|
||||
renderFace(node);
|
||||
notifyGraphChanged();
|
||||
});
|
||||
|
||||
generate.addEventListener("click", () => app.queuePrompt?.(0, 1));
|
||||
|
||||
// Measure from the WRAPPER, not the textarea: the textarea is now inset inside it,
|
||||
// so its top is a couple of pixels off and the drag would creep.
|
||||
installGrip(node, grip, ideawrap, out);
|
||||
|
||||
node._pixApEls = {
|
||||
root, inner, band, banner, bLabel, bHint, idea, out, meta,
|
||||
seed, seedmode, seedwrap, seg, segIdea, segWired, copy, vram, gear,
|
||||
reroll, row1, row2, rowTags, tagsBtn, ideawrap, ideabd,
|
||||
};
|
||||
|
||||
const widget = node.addDOMWidget(WIDGET_TYPE, WIDGET_TYPE, root, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
getMinHeight: () => WIDGET_MIN_H,
|
||||
});
|
||||
// BOTH flags: widget.options.serialize controls prompt inclusion, the
|
||||
// top-level widget.serialize controls workflow persistence. Setting only one
|
||||
// leaves this widget taking a widgets_values slot.
|
||||
widget.serialize = false;
|
||||
// No custom computeSize, and no getMaxHeight. A fill widget with a
|
||||
// computeSize is treated as FIXED height by legacy LiteGraph: the node
|
||||
// refuses to be dragged smaller and the content spills a few px past the
|
||||
// frame (nodes2-preview-fill.md).
|
||||
widget.computeLayoutSize = () => ({ minHeight: WIDGET_MIN_H, minWidth: 1 });
|
||||
applyAdaptiveCanvasOnly(widget);
|
||||
installCanvasZoomPassthrough(root);
|
||||
installNativeTextMenu(root);
|
||||
installNodeAccent(node, root);
|
||||
node._pixApFloorOff = installResizeFloor(root, () => WIDGET_MIN_H);
|
||||
node._pixApWidget = widget;
|
||||
|
||||
// The two text columns must stay identical. Observe the TEXTAREA - never the
|
||||
// backdrop, whose padding syncColumns mutates, or it re-fires forever. A
|
||||
// ResizeObserver rather than onResize because that hook is not reliable for DOM
|
||||
// widgets (Vue Compat #13), and it also covers the renderer flip and the
|
||||
// textarea's own scrollbar appearing, which shrinks its content box.
|
||||
try {
|
||||
node._pixApColRO = new ResizeObserver(() => syncColumns(idea, ideabd));
|
||||
node._pixApColRO.observe(idea);
|
||||
} catch (e) { /* no observer: the focus listener still corrects it */ }
|
||||
// A text size change (js/shared/text_size.mjs) changes the font, not the box, so the observer never hears it.
|
||||
onTextSizeChange(idea, () => syncColumns(idea, ideabd));
|
||||
|
||||
applyShare(node);
|
||||
placeBand(node);
|
||||
renderFace(node);
|
||||
return widget;
|
||||
}
|
||||
|
||||
export function destroyFace(node) {
|
||||
try { node._pixApFloorOff?.(); } catch (e) { /* gone */ }
|
||||
node._pixApFloorOff = null;
|
||||
try { node._pixApColRO?.disconnect(); } catch (e) { /* gone */ }
|
||||
node._pixApColRO = null;
|
||||
// The autocomplete popup and the library are body-level singletons, so a deleted
|
||||
// node must take its own down or they hang there pointing at nothing.
|
||||
try { closeAC(); } catch (e) { /* gone */ }
|
||||
try { closeLibraryEditorFor(node); } catch (e) { /* gone */ }
|
||||
const widget = node?._pixApWidget;
|
||||
if (widget && Array.isArray(node.widgets)) {
|
||||
const at = node.widgets.indexOf(widget);
|
||||
if (at >= 0) node.widgets.splice(at, 1);
|
||||
}
|
||||
node._pixApWidget = null;
|
||||
node._pixApEls = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repaint
|
||||
// ---------------------------------------------------------------------------
|
||||
export function renderFace(node) {
|
||||
const els = node?._pixApEls;
|
||||
if (!els) return;
|
||||
const st = readState(node);
|
||||
const clipWired = slotConnected(node, "clip");
|
||||
const textWired = slotConnected(node, "text");
|
||||
|
||||
if (els.idea.value !== st.idea) els.idea.value = st.idea;
|
||||
// Style only (three CSS custom properties), so this is safe on the load path and
|
||||
// it means the gear's "show what the tags expand to" toggle takes effect without
|
||||
// the panel needing to know about the layout.
|
||||
applyShare(node);
|
||||
// The highlight has to follow every path that can change the idea - typing, an
|
||||
// Insert from the library, the full-screen editor, and a workflow load.
|
||||
renderIdeaTags(node);
|
||||
|
||||
// ---- banner --------------------------------------------------------------
|
||||
els.banner.classList.remove("is-warn", "is-mute");
|
||||
// The full filename on the label itself, so a name too long for even a wide
|
||||
// node is still one hover away.
|
||||
els.bLabel.title = clipWired ? "" : (st.model || "");
|
||||
if (!clipWired && !st.model) {
|
||||
els.banner.classList.add("is-warn");
|
||||
els.bLabel.textContent = "No model";
|
||||
els.bHint.textContent = "text passes through";
|
||||
els.banner.title = "Nothing to write with, so the node hands its text "
|
||||
+ "straight on. Pick a model from the gear.";
|
||||
} else {
|
||||
const name = clipWired ? "Model on wire" : shortModel(st.model);
|
||||
if (!st.formula.trim()) {
|
||||
els.banner.classList.add("is-mute");
|
||||
els.bLabel.textContent = name;
|
||||
els.bHint.textContent = willGenerate(node)
|
||||
? "no formula · idea only" : "nothing to send";
|
||||
els.banner.title = "No formula, so the model gets your idea by itself.";
|
||||
} else {
|
||||
els.bLabel.textContent = name;
|
||||
els.bHint.textContent = wiredSummary(node);
|
||||
els.banner.title = "Formula, your idea and anything wired in.";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- band ---------------------------------------------------------------
|
||||
els.seed.textContent = String(displaySeed(node));
|
||||
els.seed.title = "The seed this run uses. Click to type one.";
|
||||
const random = st.seed_mode === SEED_RANDOM;
|
||||
els.seedmode.textContent = random ? "R" : "F";
|
||||
els.seedmode.classList.toggle("is-on", random);
|
||||
els.seedmode.title = random
|
||||
? "Random: a new seed every Run, so it writes something different each time."
|
||||
: "Fixed: the same seed every Run, so an unchanged node is cached and Run "
|
||||
+ "is instant.";
|
||||
els.reroll.title = random
|
||||
? "Run again. Random mode already rolls a new seed every time, so this is "
|
||||
+ "the same as Generate."
|
||||
: "Roll a new seed and run again, for a different answer.";
|
||||
|
||||
// The join segment is only meaningful while text is wired, and a control
|
||||
// that does nothing is worse than no control. The band has spare rows, so
|
||||
// hiding it costs the node no height either way.
|
||||
els.row2.style.display = textWired ? "" : "none";
|
||||
els.segIdea.classList.toggle("is-on", st.order === ORDER_IDEA);
|
||||
els.segWired.classList.toggle("is-on", st.order === ORDER_WIRED);
|
||||
els.segIdea.title = "Your idea first, then the wired text.";
|
||||
els.segWired.title = "The wired text first, then your idea.";
|
||||
|
||||
// ---- readout -------------------------------------------------------------
|
||||
// Read from node.properties, not a runtime field, so it is still here after
|
||||
// a workflow tab switch - which rebuilds every node object (core.mjs,
|
||||
// LAST_PROP). That also means renderFace on the load path restores it with
|
||||
// nothing extra to wire up.
|
||||
const last = readLast(node);
|
||||
els.out.value = last.text;
|
||||
els.copy.disabled = !last.text || last.error;
|
||||
els.meta.classList.toggle("is-error", last.error || last.muted || last.warn);
|
||||
els.meta.textContent = last.meta;
|
||||
els.meta.title = last.meta;
|
||||
|
||||
// ---- Free VRAM -----------------------------------------------------------
|
||||
els.vram.classList.toggle("is-on", st.release_model);
|
||||
els.vram.classList.toggle("is-inert", clipWired);
|
||||
els.vram.title = clipWired
|
||||
? "Skipped while a model is wired in: that one belongs to the node feeding "
|
||||
+ "it, so it is not this node's to unload."
|
||||
: st.release_model
|
||||
? "On: the model is unloaded as soon as the text is written, so a video "
|
||||
+ "model downstream gets the memory. In a chain, turn this on only for "
|
||||
+ "the LAST node using this model."
|
||||
: "Off: the model stays loaded, so the next run is quicker.";
|
||||
|
||||
placeBand(node);
|
||||
applyShare(node);
|
||||
}
|
||||
|
||||
/** The result of a run, kept on node.properties so a workflow tab switch does
|
||||
* not throw it away (core.mjs, LAST_PROP). It is outside the injected state,
|
||||
* so it can neither reach Python nor change the node's cache signature. */
|
||||
export function applyResult(node, payload, elapsed) {
|
||||
const bits = [];
|
||||
// The banner says WHICH model, so this line does not repeat it - it reports
|
||||
// only what the banner cannot know. The one thing worth shouting about is a
|
||||
// clip wire that was there in the UI but never reached Python, because
|
||||
// graphToPrompt drops an input whose origin node is muted or bypassed: the
|
||||
// banner would read "Model on wire" while a completely different model ran.
|
||||
if (payload?.used_clip === false && slotConnected(node, "clip")) {
|
||||
bits.push("the wired model was muted");
|
||||
}
|
||||
if (payload?.status) bits.push(payload.status);
|
||||
if (payload?.words != null) bits.push(payload.words + " words");
|
||||
const secs = payload?.seconds != null ? payload.seconds : elapsed;
|
||||
if (secs != null) bits.push(Number(secs).toFixed(1) + "s");
|
||||
writeLast(node, {
|
||||
text: String(payload?.text ?? ""),
|
||||
meta: bits.join(" · "),
|
||||
error: false,
|
||||
muted: payload?.used_clip === false && slotConnected(node, "clip"),
|
||||
warn: payload?.warn === true,
|
||||
seed: payload?.seed,
|
||||
});
|
||||
renderFace(node);
|
||||
}
|
||||
|
||||
export function applyError(node, message) {
|
||||
// A whole-object write, so `muted` from an earlier successful run cannot
|
||||
// survive into a failure and paint the meta line for the wrong reason.
|
||||
writeLast(node, {
|
||||
text: String(message || "It did not run."),
|
||||
meta: "did not run",
|
||||
error: true,
|
||||
muted: false,
|
||||
});
|
||||
renderFace(node);
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { BRAND } from "../shared/index.mjs";
|
||||
import { isVueNodes } from "../shared/nodes2.mjs";
|
||||
import { pixAsset } from "../shared/api_url.mjs";
|
||||
|
||||
// =============================================================================
|
||||
// Align Pixaroma - toggleable snap & alignment guides for the node canvas.
|
||||
// Nodes AND groups both participate: drag a node or a group and it snaps to the
|
||||
// edges/centers of nearby nodes and groups alike (groups are move-only; a group
|
||||
// carries its contained nodes with it). alignTargets() is the single combined
|
||||
// node+group target list every snap path iterates.
|
||||
//
|
||||
// Architecture: window-level pointermove listener does the snap math + node
|
||||
// position mutation. LGraphCanvas.prototype.onDrawForeground is monkey-patched
|
||||
@@ -46,17 +41,9 @@ const state = {
|
||||
// position, independent of LiteGraph's tick-by-tick deltas. Snap engages
|
||||
// when desired puts an edge within snapDist of a target line.
|
||||
dragInfo: null, // { posX, posY, cursorX, cursorY, nodeId } or null
|
||||
// Group-move tracking (separate from dragInfo so the carefully-tuned node
|
||||
// path is untouched). Captured on the first tick of a group drag.
|
||||
groupDrag: null, // { ref, gx0, gy0, w, h, cursorX, cursorY, contained, ... } or null
|
||||
groupResize: null, // { ref, x0, y0, cornerX0, cornerY0, cursorX, cursorY } or null (native group BR-resize)
|
||||
// Nodes 2.0: did the current press begin on a node (not a text field), and
|
||||
// did it reach the window? Both, or it is not a node drag (pattern #24).
|
||||
_pressInNode: false,
|
||||
_pressBubbled: false,
|
||||
};
|
||||
|
||||
const ICON_URL = "icons/ui/align-center-v.svg";
|
||||
const ICON_URL = "/pixaroma/assets/icons/ui/align-center-v.svg";
|
||||
|
||||
function toggleEnabled() {
|
||||
const s = app.ui?.settings;
|
||||
@@ -79,8 +66,8 @@ function injectToolbarCSS() {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-color: currentColor;
|
||||
mask-image: url(${pixAsset(ICON_URL)});
|
||||
-webkit-mask-image: url(${pixAsset(ICON_URL)});
|
||||
mask-image: url(${ICON_URL});
|
||||
-webkit-mask-image: url(${ICON_URL});
|
||||
mask-size: contain;
|
||||
-webkit-mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
@@ -146,8 +133,6 @@ function mountToolbarButton() {
|
||||
// Wrap in a group element so it visually matches rgthree / native ComfyUI
|
||||
// button groups in the toolbar.
|
||||
const group = document.createElement("div");
|
||||
// pixaroma-align-group is load-bearing: js/toolbar_visibility hides this
|
||||
// group when the user turns the button off. Do not drop it.
|
||||
group.className = "comfyui-button-group pixaroma-align-group";
|
||||
group.appendChild(btn);
|
||||
|
||||
@@ -165,10 +150,11 @@ app.registerExtension({
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
category: ["👑 Pixaroma", "Align"],
|
||||
tooltip: "Snap nodes and groups to each other's edges and centers while dragging (and nodes while resizing). Hold Shift to bypass (Alt is taken by ComfyUI for duplicate-during-drag).",
|
||||
tooltip: "Snap nodes to others' edges and centers while dragging or resizing. Hold Shift to bypass (Alt is taken by ComfyUI for duplicate-during-drag).",
|
||||
onChange: (v) => {
|
||||
state.enabled = !!v;
|
||||
updateToolbarTint();
|
||||
console.log("[Pixaroma.Align] enabled =", state.enabled);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -194,11 +180,10 @@ app.registerExtension({
|
||||
const d = Number(s.getSettingValue(SETTING_SNAP_DIST));
|
||||
if (Number.isFinite(d) && d >= 4 && d <= 16) state.snapDistPx = d;
|
||||
}
|
||||
console.log("[Pixaroma.Align] setup: enabled=", state.enabled, "snapDist=", state.snapDistPx);
|
||||
mountToolbarButton();
|
||||
installPointerHook();
|
||||
installDrawHook();
|
||||
// Group Pixaroma's pixgroup drag calls this for its snap (it owns its own move).
|
||||
try { window.PixaromaAlign = { snapMovingRect, snapResizeCorner, endExternalDrag: clearExternalGuides }; } catch (_e) {}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -217,9 +202,6 @@ function installPointerHook() {
|
||||
// Capture-phase pointerdown so it runs BEFORE the Vue node's resize handler -
|
||||
// snapshots all node sizes as the gesture-start baseline (resize guard below).
|
||||
window.addEventListener("pointerdown", onWindowPointerDown, true);
|
||||
// Bubble-phase twin: the press REACHED the window, i.e. nothing on the way
|
||||
// (a widget control, a text box) kept it to itself. Pattern #24.
|
||||
window.addEventListener("pointerdown", onWindowPointerDownBubble, false);
|
||||
// Reset drag state on every release. Without this, a release-then-click
|
||||
// sequence with no intervening pointermove leaves stale dragInfo (with
|
||||
// its old lockType) attached to the next drag, breaking classification.
|
||||
@@ -227,6 +209,7 @@ function installPointerHook() {
|
||||
window.addEventListener("pointerup", resetDrag, false);
|
||||
window.addEventListener("pointercancel", resetDrag, false);
|
||||
_hookInstalled = true;
|
||||
console.log("[Pixaroma.Align] pointer hook installed");
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -267,7 +250,7 @@ function installDrawHook() {
|
||||
ctx.strokeStyle = BRAND;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
for (const g of state.activeGuides.slice(0, 8)) {
|
||||
for (const g of state.activeGuides.slice(0, 6)) {
|
||||
if (g.axis === "X") {
|
||||
const x = toScreenX(g.value);
|
||||
ctx.moveTo(x, toScreenY(g.minPerp - overhang));
|
||||
@@ -283,6 +266,7 @@ function installDrawHook() {
|
||||
return ret;
|
||||
};
|
||||
_drawHookInstalled = true;
|
||||
console.log("[Pixaroma.Align] drawFrontCanvas hooked for guide rendering");
|
||||
}
|
||||
|
||||
// Build the 6 reference lines for a graph-space rect.
|
||||
@@ -309,44 +293,12 @@ function getTitleH(n) {
|
||||
return window.LiteGraph?.NODE_TITLE_HEIGHT || 30;
|
||||
}
|
||||
|
||||
// NODES 2.0 ONLY, and only for the two kinds it draws differently from Classic
|
||||
// (pattern #24, measured 2026-10-03): it places EVERY node's element at
|
||||
// pos[1] - title height, title or not, so a title-less face (Label, Run Timer,
|
||||
// Monitor, Info) starts 30 px ABOVE pos[1]; and it draws a collapsed node as a
|
||||
// pill of its own size (225 x 28 here), not _collapsed_width x 30 (a Classic
|
||||
// draw-time value, absent or stale in Nodes 2.0). So for these two, read the
|
||||
// size the node is DRAWN at from its element (graph units, any zoom). null =
|
||||
// keep the Classic geometry (Classic, every other node, no single element).
|
||||
function vueDrawnSize(n) {
|
||||
if (!(n.flags?.collapsed || n.flags?.no_title)) return null;
|
||||
if (!isVueNodes()) return null;
|
||||
const id = window.CSS?.escape ? window.CSS.escape(String(n.id)) : String(n.id);
|
||||
const els = document.querySelectorAll('.lg-node[data-node-id="' + id + '"]');
|
||||
if (els.length !== 1) return null;
|
||||
const r = els[0].getBoundingClientRect();
|
||||
const s = app.canvas?.ds?.scale || 1;
|
||||
const w = r.width / s, h = r.height / s;
|
||||
if (!(w >= 2 && h >= 2)) return null;
|
||||
return { w, h };
|
||||
}
|
||||
|
||||
function nodeRect(n) {
|
||||
const vd = vueDrawnSize(n);
|
||||
if (vd) return { x: n.pos[0], y: n.pos[1] - (window.LiteGraph?.NODE_TITLE_HEIGHT || 30), w: vd.w, h: vd.h };
|
||||
// A COLLAPSED node renders as just its title bar - a small pill of
|
||||
// _collapsed_width, sitting at pos[1] - titleH like any title. Use THAT small
|
||||
// rect so collapsed nodes snap/align by what's actually drawn (not their
|
||||
// hidden expanded body). _collapsed_width is set during draw; fall back to
|
||||
// LiteGraph.NODE_COLLAPSED_WIDTH (80) before the first paint.
|
||||
if (n.flags?.collapsed) {
|
||||
const th = window.LiteGraph?.NODE_TITLE_HEIGHT || 30;
|
||||
const cw = n._collapsed_width || window.LiteGraph?.NODE_COLLAPSED_WIDTH || 80;
|
||||
return { x: n.pos[0], y: n.pos[1] - th, w: cw, h: th };
|
||||
}
|
||||
// In LiteGraph, node.pos[1] is the top of the BODY (below the title bar),
|
||||
// and node.size[1] is the body height. The title sits at pos[1] - titleH.
|
||||
// For snap to align with the visual top edge of the node, include the
|
||||
// title bar in the rect. no_title nodes (Label / Note) have no title bar.
|
||||
// title bar in the rect. Collapsed nodes have no body, just title.
|
||||
// no_title nodes (Label / Note) have no title bar at all.
|
||||
const titleH = getTitleH(n);
|
||||
return {
|
||||
x: n.pos[0],
|
||||
@@ -356,86 +308,6 @@ function nodeRect(n) {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Group geometry ───────────────────────────────────────────────────────
|
||||
// A group's alignable rect is its full bounding box (the title strip sits
|
||||
// INSIDE the top of the box, so there's no title-bar offset like nodes).
|
||||
// Read/write defensively across litegraph versions and both renderers: the
|
||||
// _pos/_size fields, the pos/size getters, or the _bounding [x,y,w,h] cache.
|
||||
// litegraph stores _pos/_size/_bounding as Float32Array (and _pos/_size are
|
||||
// often subarray VIEWS of _bounding). Array.isArray() is FALSE for typed
|
||||
// arrays, so test array-LIKE (numeric .length) instead — using Array.isArray
|
||||
// here made every group read return null and silently disabled group snapping.
|
||||
function arrLike(v, n) { return v != null && typeof v.length === "number" && v.length >= n; }
|
||||
function groupPos(g) {
|
||||
if (arrLike(g?._pos, 2)) return [g._pos[0], g._pos[1]];
|
||||
if (arrLike(g?.pos, 2)) return [g.pos[0], g.pos[1]];
|
||||
if (arrLike(g?._bounding, 4)) return [g._bounding[0], g._bounding[1]];
|
||||
return null;
|
||||
}
|
||||
function groupSize(g) {
|
||||
if (arrLike(g?._size, 2)) return [g._size[0], g._size[1]];
|
||||
if (arrLike(g?.size, 2)) return [g.size[0], g.size[1]];
|
||||
if (arrLike(g?._bounding, 4)) return [g._bounding[2], g._bounding[3]];
|
||||
return null;
|
||||
}
|
||||
function groupRect(g) {
|
||||
const p = groupPos(g), s = groupSize(g);
|
||||
if (!p || !s) return null;
|
||||
return { x: p[0], y: p[1], w: s[0], h: s[1] };
|
||||
}
|
||||
// Write a group's top-left. Array-replace through the pos getter (so any
|
||||
// reactive Nodes 2.0 setter fires) AND sync the raw _pos/_bounding fields, so
|
||||
// the group renders at the new spot in either renderer regardless of which the
|
||||
// renderer reads. (Children are moved separately by the caller.)
|
||||
function setGroupPos(g, x, y) {
|
||||
// Mutate in place (works on Float32Array views). _pos is usually a subarray of
|
||||
// _bounding, so writing _pos also updates _bounding, but set both to be safe.
|
||||
if (arrLike(g._pos, 2)) { g._pos[0] = x; g._pos[1] = y; }
|
||||
if (arrLike(g._bounding, 4)) { g._bounding[0] = x; g._bounding[1] = y; }
|
||||
if (!g._pos && arrLike(g.pos, 2)) { g.pos[0] = x; g.pos[1] = y; }
|
||||
}
|
||||
// Write a group's size (LiteGraph groups resize from the bottom-right, so the
|
||||
// top-left _pos is unchanged). _size is usually a subarray of _bounding; set both.
|
||||
function setGroupSize(g, w, h) {
|
||||
if (arrLike(g._size, 2)) { g._size[0] = w; g._size[1] = h; }
|
||||
if (arrLike(g._bounding, 4)) { g._bounding[2] = w; g._bounding[3] = h; }
|
||||
if (!g._size && arrLike(g.size, 2)) { g.size[0] = w; g.size[1] = h; }
|
||||
}
|
||||
function graphGroups(c) {
|
||||
return c?.graph?._groups || c?.graph?.groups || [];
|
||||
}
|
||||
// A group folded by Group Pixaroma is a slim "bar" that Group Pixaroma drags
|
||||
// itself (bar-only). Align must NOT also treat it as a draggable group or the two
|
||||
// fight and the bar appears stuck. (Detected via the fold flag the features share.)
|
||||
function isFoldedGroup(g) { return !!(g && g.flags && g.flags.pixFold); }
|
||||
|
||||
// Unified alignment targets: every node (its visual rect, title bar included)
|
||||
// PLUS every group (its bounding box). The snap loops and guide extension
|
||||
// iterate this one list, so nodes snap to groups and groups snap to nodes from
|
||||
// a single code path. kind distinguishes the two for the multi-select skip
|
||||
// (which is keyed on node ids); ref is the source object for identity skips.
|
||||
function alignTargets(c) {
|
||||
const out = [];
|
||||
for (const n of (c.graph?._nodes || [])) {
|
||||
// collapsed:false on purpose - collapsed nodes now participate fully as snap
|
||||
// targets (nodeRect returns their small title-bar rect). They used to be
|
||||
// skipped via this flag, which is why a collapsed node never snapped.
|
||||
out.push({ ref: n, kind: "node", id: n.id, rect: nodeRect(n), collapsed: false });
|
||||
}
|
||||
for (const g of graphGroups(c)) {
|
||||
const r = groupRect(g);
|
||||
if (r) out.push({ ref: g, kind: "group", id: g.id, rect: r, collapsed: false });
|
||||
}
|
||||
// Pixaroma groups (js/pixgroup) — read-only snap targets, so dragging a node or a
|
||||
// native group aligns to their edges too. kind "pixgroup" so the node/group skips
|
||||
// (keyed on kind/ref) never touch them; ref null (no LiteGraph object behind them).
|
||||
try {
|
||||
const rects = window.PixaromaPixGroup?.allRects?.() || [];
|
||||
for (const r of rects) out.push({ ref: null, kind: "pixgroup", id: r.id, rect: { x: r.x, y: r.y, w: r.w, h: r.h }, collapsed: false });
|
||||
} catch (_e) {}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Find the closest snap delta along one axis. Returns { delta, target, movingValue } or null.
|
||||
// movingValues / targetValues are arrays of edge values along ONE axis.
|
||||
// Hysteresis: a target equal to `stickyTarget` (the one we were snapped to last
|
||||
@@ -459,7 +331,7 @@ function findClosestSnap(movingValues, targetValues, threshold, stickyTarget, st
|
||||
// line position in graph space. perpRange is [minPerp, maxPerp] of the rects
|
||||
// being aligned (perp = the OTHER axis).
|
||||
function pushGuide(axis, value, perpRange) {
|
||||
if (state.activeGuides.length >= 8) return;
|
||||
if (state.activeGuides.length >= 6) return;
|
||||
state.activeGuides.push({ axis, value, minPerp: perpRange[0], maxPerp: perpRange[1] });
|
||||
}
|
||||
|
||||
@@ -468,15 +340,13 @@ function pushGuide(axis, value, perpRange) {
|
||||
// equals the guide value within EPS. This makes a column of 3+ nodes show
|
||||
// one continuous guide that spans the whole column instead of a short
|
||||
// segment between only the moving and matched rects.
|
||||
// candidates are unified target objects ({ ref, rect, collapsed, ... } from
|
||||
// alignTargets); skipFn receives the source ref (node or group).
|
||||
function extendGuideRange(axis, value, baseLo, baseHi, candidates, skipFn) {
|
||||
const EPS = 0.5;
|
||||
let lo = baseLo, hi = baseHi;
|
||||
for (const cand of candidates) {
|
||||
if (cand.collapsed) continue;
|
||||
if (skipFn(cand.ref)) continue;
|
||||
const oR = cand.rect;
|
||||
for (const other of candidates) {
|
||||
if (skipFn(other)) continue;
|
||||
if (other.flags?.collapsed) continue;
|
||||
const oR = nodeRect(other);
|
||||
const oE = rectEdges(oR);
|
||||
let match = false;
|
||||
if (axis === "X") {
|
||||
@@ -500,47 +370,12 @@ function extendGuideRange(axis, value, baseLo, baseHi, candidates, skipFn) {
|
||||
return [lo, hi];
|
||||
}
|
||||
|
||||
// Draw a guide for EVERY edge of `rect` that coincides (after snapping) with a
|
||||
// target's matching edge, deduped per axis+value and each spanning the moving
|
||||
// rect plus all targets sharing that line. Used by the group-move path so that
|
||||
// when two same-size groups align top AND bottom at once, both lines show — not
|
||||
// just the single edge that drove the position snap.
|
||||
function pushAlignedGuides(rect, targets, skip) {
|
||||
const EPS = 0.5;
|
||||
const me = rectEdges(rect);
|
||||
const xVals = [me.left, me.right, me.centerX];
|
||||
const yVals = [me.top, me.bottom, me.centerY];
|
||||
const xg = new Map(), yg = new Map(); // rounded value -> { value, lo, hi }
|
||||
const add = (map, val, lo, hi) => {
|
||||
const k = Math.round(val);
|
||||
const g = map.get(k);
|
||||
if (g) { g.lo = Math.min(g.lo, lo); g.hi = Math.max(g.hi, hi); }
|
||||
else map.set(k, { value: val, lo, hi });
|
||||
};
|
||||
for (const t of targets) {
|
||||
if (t.collapsed || skip(t.ref)) continue;
|
||||
const oE = rectEdges(t.rect), r = t.rect;
|
||||
for (const mv of xVals) for (const tv of [oE.left, oE.right, oE.centerX]) {
|
||||
if (Math.abs(mv - tv) < EPS) add(xg, tv, Math.min(rect.y, r.y), Math.max(rect.y + rect.h, r.y + r.h));
|
||||
}
|
||||
for (const mv of yVals) for (const tv of [oE.top, oE.bottom, oE.centerY]) {
|
||||
if (Math.abs(mv - tv) < EPS) add(yg, tv, Math.min(rect.x, r.x), Math.max(rect.x + rect.w, r.x + r.w));
|
||||
}
|
||||
}
|
||||
for (const g of xg.values()) pushGuide("X", g.value, [g.lo, g.hi]);
|
||||
for (const g of yg.values()) pushGuide("Y", g.value, [g.lo, g.hi]);
|
||||
}
|
||||
|
||||
// Drop drag bookkeeping AND clear active guides. Use this for both
|
||||
// pointerup/cancel and any mid-tick bail (disabled, Shift, no buttons).
|
||||
// Setting setDirty triggers a redraw so any visible guides disappear
|
||||
// promptly; redraw is cheap when there's nothing to draw.
|
||||
function resetDrag() {
|
||||
state.dragInfo = null;
|
||||
state.groupDrag = null;
|
||||
state.groupResize = null;
|
||||
state._extStickyX = null; state._extStickyY = null;
|
||||
state._multiGroupDrag = false;
|
||||
state._prevNodeStates = null;
|
||||
state._vueResizing = false;
|
||||
state._gestureSizes = null;
|
||||
@@ -558,28 +393,7 @@ function resetDrag() {
|
||||
// drag (even after the node clamps at its min and stops changing) - so the guard
|
||||
// latches reliably from tick 1 and the selected node is never moved while another
|
||||
// node is being resized. (Pattern #16 hardening, 2026-06.)
|
||||
// Can this press start a NODES 2.0 node drag? Nodes 2.0 starts one only from the
|
||||
// node element's own pointer handlers (useNodePointerInteractions.ts), so the
|
||||
// press must begin inside a .lg-node, and not in a text field (typing or
|
||||
// selecting text there never drags the node). Measured before this existed:
|
||||
// with a node selected, a drag on the sidebar, on the Help window's title, or a
|
||||
// text selection inside ANOTHER node's text box all moved the selected node.
|
||||
function isVueNodePress(t) {
|
||||
if (!t || typeof t.closest !== "function") return false;
|
||||
if (!t.closest(".lg-node")) return false;
|
||||
if (t.isContentEditable || t.closest("input, textarea, select, [contenteditable='true'], [contenteditable='']")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function onWindowPointerDownBubble(e) {
|
||||
if (e.button === 0) state._pressBubbled = true;
|
||||
}
|
||||
|
||||
function onWindowPointerDown(e) {
|
||||
// Where the press began, for the Nodes 2.0 gate in onWindowPointerMove. Set on
|
||||
// EVERY press (before any early return) so a stale value never carries over.
|
||||
state._pressInNode = e.button === 0 && isVueNodePress(e.target);
|
||||
state._pressBubbled = false;
|
||||
if (!state.enabled) return;
|
||||
if (e.button !== 0) return; // primary button only
|
||||
const c = app.canvas;
|
||||
@@ -588,11 +402,6 @@ function onWindowPointerDown(e) {
|
||||
for (const n of c.graph._nodes) sizes.set(n.id, [n.size[0], n.size[1]]);
|
||||
state._gestureSizes = sizes;
|
||||
state._vueResizing = false;
|
||||
// Baseline every group's rect (capture phase, before any drag moves them) so
|
||||
// a group MOVE is detectable by position change from the very first move tick.
|
||||
const grects = new Map();
|
||||
for (const g of graphGroups(c)) { const r = groupRect(g); if (r) grects.set(g, r); }
|
||||
state._prevGroupRects = grects;
|
||||
// If the pointer grabbed a RESIZE HANDLE, the element under it shows a
|
||||
// `*-resize` cursor. Latch the resize flag immediately so the move guard bails
|
||||
// from tick 0 - on the very first pointermove the node's size hasn't changed
|
||||
@@ -623,361 +432,15 @@ function applyNodePos(node, x, y, snapActive) {
|
||||
node.pos[1] = y;
|
||||
}
|
||||
}
|
||||
function applyNodeRect(node, x, y, w, h, snapActive, keep) {
|
||||
function applyNodeRect(node, x, y, w, h, snapActive) {
|
||||
if (isVueNodes()) {
|
||||
if (snapActive) requestAnimationFrame(() => { node.pos = [x, y]; node.size = [w, h]; });
|
||||
} else {
|
||||
node.pos[0] = x; node.pos[1] = y;
|
||||
// Through setSize, exactly as LiteGraph's own resize does, so the node's OWN
|
||||
// onResize minimum applies. A raw write skipped it (Align's floor is only
|
||||
// computeSize), and the node's draw-time clamp then widened it without
|
||||
// moving it: the edge the user held slid away. When the node refused part
|
||||
// of a MOVING side, put the OPPOSITE edge back where the user holds it
|
||||
// (keep.left / keep.top: that side is the one being dragged).
|
||||
if (typeof node.setSize === "function") node.setSize([w, h]);
|
||||
else { node.size[0] = w; node.size[1] = h; }
|
||||
if (keep?.left && Math.abs(node.size[0] - w) > 0.01) node.pos[0] = x + w - node.size[0];
|
||||
if (keep?.top && Math.abs(node.size[1] - h) > 0.01) node.pos[1] = y + h - node.size[1];
|
||||
node.size[0] = w; node.size[1] = h;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Group drag: detection + snap (Approach A — absolute desired-from-origin) ─
|
||||
// A group move also shifts its contained nodes, so the node change-detector
|
||||
// below would mistake those for a node drag. We therefore detect + fully handle
|
||||
// a group drag FIRST (and return), before any node logic runs.
|
||||
|
||||
// Refresh the per-group rect cache (runs every drag tick) so the next tick's
|
||||
// change-detection has a fresh baseline.
|
||||
function refreshGroupCache(c) {
|
||||
if (!state._prevGroupRects) state._prevGroupRects = new Map();
|
||||
state._prevGroupRects.clear();
|
||||
for (const g of graphGroups(c)) { const r = groupRect(g); if (r) state._prevGroupRects.set(g, r); }
|
||||
}
|
||||
|
||||
// Identify a group being MOVED this tick: a group whose top-left changed since
|
||||
// last tick with its SIZE unchanged (a size change means a resize, which we
|
||||
// don't snap). _prevGroupRects is baselined on pointerdown, so a real group drag
|
||||
// is caught on the very first move tick.
|
||||
//
|
||||
// We deliberately do NOT trust c.selected_group. It can be STALE (it stays set
|
||||
// after you interact with a group, e.g. recolor it), and in the Vue renderer it
|
||||
// remains set while you drag a NODE or select TEXT inside the group - which made
|
||||
// Align move the whole group instead of the node / instead of selecting text
|
||||
// (reported June 2026, both renderers). Movement-based detection can't
|
||||
// false-fire: dragging a node or selecting text never moves the group.
|
||||
function findDraggedGroup(c) {
|
||||
const prev = state._prevGroupRects;
|
||||
if (!prev) return null;
|
||||
for (const g of graphGroups(c)) {
|
||||
if (isFoldedGroup(g)) continue; // Group Pixaroma drags folded bars itself
|
||||
const r = groupRect(g), p = prev.get(g);
|
||||
if (!r || !p) continue;
|
||||
const moved = Math.abs(r.x - p.x) > 0.01 || Math.abs(r.y - p.y) > 0.01;
|
||||
const resized = Math.abs(r.w - p.w) > 0.01 || Math.abs(r.h - p.h) > 0.01;
|
||||
if (moved && !resized) return g;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// How many groups MOVED (top-left changed, size unchanged) since last tick. >1
|
||||
// means a multi-group drag - Align can't rigidly snap the whole set, and snapping
|
||||
// one member drifts it into the others, so we stay out and let LiteGraph move
|
||||
// them rigidly.
|
||||
function countMovedGroups(c) {
|
||||
const prev = state._prevGroupRects;
|
||||
if (!prev) return 0;
|
||||
let n = 0;
|
||||
for (const g of graphGroups(c)) {
|
||||
if (isFoldedGroup(g)) continue;
|
||||
const r = groupRect(g), p = prev.get(g);
|
||||
if (!r || !p) continue;
|
||||
const moved = Math.abs(r.x - p.x) > 0.01 || Math.abs(r.y - p.y) > 0.01;
|
||||
const resized = Math.abs(r.w - p.w) > 0.01 || Math.abs(r.h - p.h) > 0.01;
|
||||
if (moved && !resized) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// A native group whose SIZE changed since last tick = being resized (LiteGraph
|
||||
// groups resize from the bottom-right, top-left fixed). Distinct from a move.
|
||||
function findResizedGroup(c) {
|
||||
const prev = state._prevGroupRects;
|
||||
if (!prev) return null;
|
||||
for (const g of graphGroups(c)) {
|
||||
if (isFoldedGroup(g)) continue;
|
||||
const r = groupRect(g), p = prev.get(g);
|
||||
if (!r || !p) continue;
|
||||
if (Math.abs(r.w - p.w) > 0.01 || Math.abs(r.h - p.h) > 0.01) return g;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Nodes that move rigidly with the group. Prefer LiteGraph's own _nodes (it
|
||||
// recomputes them when the group grab starts); fall back to geometry (node
|
||||
// center inside the group rect) for any build that doesn't populate it.
|
||||
function groupContainedNodes(c, g, gRect) {
|
||||
if (Array.isArray(g._nodes) && g._nodes.length) return g._nodes.slice();
|
||||
const out = [];
|
||||
for (const n of (c.graph?._nodes || [])) {
|
||||
const r = nodeRect(n);
|
||||
const cx = r.x + r.w / 2, cy = r.y + r.h / 2;
|
||||
if (cx >= gRect.x && cx <= gRect.x + gRect.w && cy >= gRect.y && cy <= gRect.y + gRect.h) out.push(n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Write the snapped group + contained-node positions. Legacy: overwrite absolute
|
||||
// every tick (same as the node path). Nodes 2.0: only correct when a snap is
|
||||
// actually engaged, deferred in a rAF so it wins over Vue's own cursor-driven
|
||||
// write (mirrors applyNodePos); when not snapped, leave the reactive drag alone.
|
||||
function applyGroupDrag(g, contained, gx, gy, snapActive) {
|
||||
if (isVueNodes()) {
|
||||
if (!snapActive) return;
|
||||
// The group FRAME is canvas-painted (nothing competes to write it), so set it
|
||||
// SYNCHRONOUSLY — deferring it to the rAF made the frame oscillate between
|
||||
// ComfyUI's unsnapped paint and our snapped paint (the "group frame wiggles but
|
||||
// the nodes don't" report). The NODES are Vue-rendered, so their write stays in
|
||||
// the rAF to win over Vue's own cursor-driven layout write.
|
||||
setGroupPos(g, gx, gy);
|
||||
requestAnimationFrame(() => {
|
||||
for (const cn of contained) cn.node.pos = [gx + cn.off[0], gy + cn.off[1]];
|
||||
});
|
||||
} else {
|
||||
setGroupPos(g, gx, gy);
|
||||
for (const cn of contained) { cn.node.pos[0] = gx + cn.off[0]; cn.node.pos[1] = gy + cn.off[1]; }
|
||||
}
|
||||
}
|
||||
|
||||
function handleGroupDrag(c, group, e) {
|
||||
const scale = c.ds?.scale || 1;
|
||||
const snapGraph = state.snapDistPx / scale;
|
||||
const gRect = groupRect(group);
|
||||
if (!gRect) { state.groupDrag = null; return; }
|
||||
|
||||
// A SIZE change on an existing session means the group is being RESIZED, not
|
||||
// moved (some builds leave selected_group_resizing undefined) — bail so we
|
||||
// don't fight the resize. A fresh session trivially matches and falls through.
|
||||
if (state.groupDrag && state.groupDrag.ref === group &&
|
||||
(Math.abs(gRect.w - state.groupDrag.w) > 0.01 || Math.abs(gRect.h - state.groupDrag.h) > 0.01)) {
|
||||
state.groupDrag = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// (Re)initialise the session on a new group grab. Capture the group's origin,
|
||||
// the cursor origin, and each contained node's OFFSET from the group's
|
||||
// top-left (constant for a rigid move). The baseline tick applies no
|
||||
// correction (desired == current), so the group never jumps on grab.
|
||||
if (!state.groupDrag || state.groupDrag.ref !== group) {
|
||||
const contained = groupContainedNodes(c, group, gRect).map((n) => ({
|
||||
node: n, off: [n.pos[0] - gRect.x, n.pos[1] - gRect.y],
|
||||
}));
|
||||
state.groupDrag = {
|
||||
ref: group,
|
||||
gx0: gRect.x, gy0: gRect.y, w: gRect.w, h: gRect.h,
|
||||
cursorX: e.clientX, cursorY: e.clientY,
|
||||
contained,
|
||||
containedSet: new Set(contained.map((cn) => cn.node)),
|
||||
stickyX: null, stickyY: null,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const di = state.groupDrag;
|
||||
const desiredX = di.gx0 + (e.clientX - di.cursorX) / scale;
|
||||
const desiredY = di.gy0 + (e.clientY - di.cursorY) / scale;
|
||||
const movingRect = { x: desiredX, y: desiredY, w: di.w, h: di.h };
|
||||
const movingE = rectEdges(movingRect);
|
||||
const movingX = [movingE.left, movingE.right, movingE.centerX];
|
||||
const movingY = [movingE.top, movingE.bottom, movingE.centerY];
|
||||
|
||||
const stickyG = snapGraph * 1.5;
|
||||
const targets = alignTargets(c);
|
||||
// A group aligns to other groups' FRAMES and to LOOSE nodes — never to the
|
||||
// nodes nested INSIDE another group (their edges sit at different positions
|
||||
// than the frame and would hijack the snap). Collect every grouped node so we
|
||||
// can exclude them as targets.
|
||||
const groupedNodes = new Set();
|
||||
for (const t of targets) {
|
||||
if (t.kind !== "group" || t.ref === group) continue;
|
||||
for (const n of groupContainedNodes(c, t.ref, t.rect)) groupedNodes.add(n);
|
||||
}
|
||||
let bestX = null, bestY = null;
|
||||
for (const t of targets) {
|
||||
if (t.ref === group) continue; // don't snap to self
|
||||
if (t.kind === "node" && di.containedSet.has(t.ref)) continue; // nor to own children
|
||||
if (t.kind === "node" && groupedNodes.has(t.ref)) continue; // nor to other groups' children
|
||||
// A Pixaroma group INSIDE this native group is carried with it (Group Pixaroma
|
||||
// moves it on the group drag), so it's a moving target → skip it, else the group
|
||||
// wiggles trying to snap to something that follows it.
|
||||
if (t.kind === "pixgroup") {
|
||||
const r = t.rect;
|
||||
if (r.x >= gRect.x && r.y >= gRect.y && r.x + r.w <= gRect.x + gRect.w && r.y + r.h <= gRect.y + gRect.h) continue;
|
||||
}
|
||||
if (t.collapsed) continue;
|
||||
const oRect = t.rect;
|
||||
const dxc = Math.max(0, Math.max(oRect.x - (movingRect.x + movingRect.w), movingRect.x - (oRect.x + oRect.w)));
|
||||
const dyc = Math.max(0, Math.max(oRect.y - (movingRect.y + movingRect.h), movingRect.y - (oRect.y + oRect.h)));
|
||||
if (dxc > 2 * stickyG && dyc > 2 * stickyG) continue;
|
||||
const oE = rectEdges(oRect);
|
||||
const mx = findClosestSnap(movingX, [oE.left, oE.right, oE.centerX], snapGraph, di.stickyX, stickyG);
|
||||
if (mx && (!bestX || Math.abs(mx.delta) < Math.abs(bestX.delta))) bestX = mx;
|
||||
const my = findClosestSnap(movingY, [oE.top, oE.bottom, oE.centerY], snapGraph, di.stickyY, stickyG);
|
||||
if (my && (!bestY || Math.abs(my.delta) < Math.abs(bestY.delta))) bestY = my;
|
||||
}
|
||||
di.stickyX = bestX ? bestX.target : null;
|
||||
di.stickyY = bestY ? bestY.target : null;
|
||||
|
||||
const fx = bestX ? desiredX + bestX.delta : desiredX;
|
||||
const fy = bestY ? desiredY + bestY.delta : desiredY;
|
||||
applyGroupDrag(group, di.contained, fx, fy, !!(bestX || bestY));
|
||||
|
||||
// Position correction uses only the single closest delta per axis (bestX/bestY),
|
||||
// but the GUIDE lines fan out to EVERY edge of the snapped rect that coincides
|
||||
// with a target — so two same-size groups show both their top AND bottom lines,
|
||||
// not just the one that drove the snap.
|
||||
const finalRect = { x: fx, y: fy, w: di.w, h: di.h };
|
||||
const skip = (ref) => ref === group || di.containedSet.has(ref) || groupedNodes.has(ref);
|
||||
state.activeGuides = [];
|
||||
pushAlignedGuides(finalRect, targets, skip);
|
||||
c.setDirty?.(true, true);
|
||||
}
|
||||
|
||||
// Snap a native group RESIZE: the bottom-right corner follows the cursor (absolute
|
||||
// model from resize start, so it never feeds back from its own snapped output);
|
||||
// snap that corner to nearby edges + write the new size. Top-left stays put.
|
||||
function handleGroupResize(c, group, e) {
|
||||
const gRect = groupRect(group);
|
||||
if (!gRect) { state.groupResize = null; return; }
|
||||
if (!state.groupResize || state.groupResize.ref !== group) {
|
||||
state.groupResize = {
|
||||
ref: group, x0: gRect.x, y0: gRect.y,
|
||||
cornerX0: gRect.x + gRect.w, cornerY0: gRect.y + gRect.h,
|
||||
cursorX: e.clientX, cursorY: e.clientY,
|
||||
};
|
||||
state._extStickyX = null; state._extStickyY = null;
|
||||
return; // baseline tick: no correction, so the group never jumps on grab
|
||||
}
|
||||
const ri = state.groupResize;
|
||||
const scale = c.ds?.scale || 1;
|
||||
const desiredX = ri.cornerX0 + (e.clientX - ri.cursorX) / scale;
|
||||
const desiredY = ri.cornerY0 + (e.clientY - ri.cursorY) / scale;
|
||||
const snap = snapResizeCorner(desiredX, desiredY, { excludeGroupId: group.id, bypass: e.shiftKey });
|
||||
setGroupSize(group, Math.max(10, snap.x - ri.x0), Math.max(10, snap.y - ri.y0));
|
||||
c.setDirty?.(true, true);
|
||||
}
|
||||
|
||||
// ── External-drag snap API (used by Group Pixaroma's own pixgroup drag) ──────
|
||||
// A Pixaroma group is moved by its OWN module (frame + contained nodes), so Align
|
||||
// can't drive it via node.pos like a node/native group. Instead that module hands
|
||||
// us the dragged frames' bounding rect here; we set the guides (the draw hook
|
||||
// renders them) and return the snap delta to apply to the frames + their contents.
|
||||
function clearExternalGuides() {
|
||||
state._extStickyX = null; state._extStickyY = null;
|
||||
if (state.activeGuides.length) { state.activeGuides = []; app.canvas?.setDirty?.(true, true); }
|
||||
}
|
||||
function snapMovingRect(rect, opts) {
|
||||
opts = opts || {};
|
||||
const c = app.canvas;
|
||||
if (!state.enabled || !c || opts.bypass || !rect) { clearExternalGuides(); return { dx: 0, dy: 0 }; }
|
||||
// Defensive: an external caller (pixgroup) could pass a non-finite rect; bail cleanly
|
||||
// instead of computing NaN snap deltas that would get written back as positions.
|
||||
if (![rect.x, rect.y, rect.w, rect.h].every(Number.isFinite)) { clearExternalGuides(); return { dx: 0, dy: 0 }; }
|
||||
const scale = c.ds?.scale || 1;
|
||||
const snapGraph = state.snapDistPx / scale;
|
||||
const stickyG = snapGraph * 1.5;
|
||||
const exPix = new Set(opts.excludePixIds || []); // dragged pixgroup ids — don't snap to them
|
||||
const exNodes = new Set(opts.excludeNodes || []); // their member nodes — nor to own children
|
||||
const targets = alignTargets(c);
|
||||
// A Pixaroma group aligns to ANY node or group frame on the canvas (except its own
|
||||
// contents) — INCLUDING a node that sits inside a ComfyUI group. (Native-group moves
|
||||
// still skip other groups' nested nodes via handleGroupDrag, for frame-to-frame.)
|
||||
const me = rectEdges(rect);
|
||||
const movingX = [me.left, me.right, me.centerX];
|
||||
const movingY = [me.top, me.bottom, me.centerY];
|
||||
let bestX = null, bestY = null;
|
||||
for (const t of targets) {
|
||||
if (t.kind === "pixgroup" && exPix.has(t.id)) continue;
|
||||
if (t.kind === "node" && exNodes.has(t.ref)) continue;
|
||||
if (t.collapsed) continue;
|
||||
const oRect = t.rect;
|
||||
const dxc = Math.max(0, Math.max(oRect.x - (rect.x + rect.w), rect.x - (oRect.x + oRect.w)));
|
||||
const dyc = Math.max(0, Math.max(oRect.y - (rect.y + rect.h), rect.y - (oRect.y + oRect.h)));
|
||||
if (dxc > 2 * stickyG && dyc > 2 * stickyG) continue;
|
||||
const oE = rectEdges(oRect);
|
||||
const mx = findClosestSnap(movingX, [oE.left, oE.right, oE.centerX], snapGraph, state._extStickyX, stickyG);
|
||||
if (mx && (!bestX || Math.abs(mx.delta) < Math.abs(bestX.delta))) bestX = mx;
|
||||
const my = findClosestSnap(movingY, [oE.top, oE.bottom, oE.centerY], snapGraph, state._extStickyY, stickyG);
|
||||
if (my && (!bestY || Math.abs(my.delta) < Math.abs(bestY.delta))) bestY = my;
|
||||
}
|
||||
state._extStickyX = bestX ? bestX.target : null;
|
||||
state._extStickyY = bestY ? bestY.target : null;
|
||||
const dx = bestX ? bestX.delta : 0, dy = bestY ? bestY.delta : 0;
|
||||
const finalRect = { x: rect.x + dx, y: rect.y + dy, w: rect.w, h: rect.h };
|
||||
const guideTargets = targets.filter((t) => !(t.kind === "pixgroup" && exPix.has(t.id)));
|
||||
const skip = (ref) => !!(ref && exNodes.has(ref));
|
||||
state.activeGuides = [];
|
||||
pushAlignedGuides(finalRect, guideTargets, skip);
|
||||
c.setDirty?.(true, true);
|
||||
return { dx, dy };
|
||||
}
|
||||
|
||||
// Snap a RESIZE corner (the cursor point) to nearby target edges, one axis each.
|
||||
// Returns the snapped { x, y } for the dragged corner + draws a guide per snapped
|
||||
// axis. Used by Group Pixaroma's pixgroup corner-resize.
|
||||
function snapResizeCorner(x, y, opts) {
|
||||
opts = opts || {};
|
||||
const c = app.canvas;
|
||||
if (!state.enabled || !c || opts.bypass) { clearExternalGuides(); return { x, y }; }
|
||||
// Defensive: bail on a non-finite corner from an external caller (pixgroup).
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) { clearExternalGuides(); return { x, y }; }
|
||||
const scale = c.ds?.scale || 1;
|
||||
const snapGraph = state.snapDistPx / scale;
|
||||
const stickyG = snapGraph * 1.5;
|
||||
const exPix = new Set(opts.excludePixIds || []);
|
||||
const targets = alignTargets(c);
|
||||
// includeGroupedNodes (Pixaroma-group resize): align to ANY node, even ones inside
|
||||
// a ComfyUI group. Native-group resize leaves it off → keeps frame-to-frame, but
|
||||
// still snaps to its OWN contained nodes (the excludeGroupId skip).
|
||||
const groupedNodes = new Set();
|
||||
if (!opts.includeGroupedNodes) {
|
||||
for (const t of targets) {
|
||||
if (t.kind !== "group") continue;
|
||||
// Skip the group BEING resized: we WANT its edge to snap to its OWN contained
|
||||
// nodes (so you can fit the frame snugly to its contents). Once the edge passes
|
||||
// a node's center the node counts as "contained", and excluding it here is
|
||||
// exactly what stopped the bottom edge from ever reaching that node's bottom.
|
||||
if (opts.excludeGroupId != null && t.id === opts.excludeGroupId) continue;
|
||||
for (const n of groupContainedNodes(c, t.ref, t.rect)) groupedNodes.add(n);
|
||||
}
|
||||
}
|
||||
let bx = null, by = null; // { delta, target, rect }
|
||||
for (const t of targets) {
|
||||
if (t.kind === "pixgroup" && exPix.has(t.id)) continue;
|
||||
if (t.kind === "group" && opts.excludeGroupId != null && t.id === opts.excludeGroupId) continue;
|
||||
if (t.kind === "node" && groupedNodes.has(t.ref)) continue;
|
||||
if (t.collapsed) continue;
|
||||
const oE = rectEdges(t.rect);
|
||||
for (const tv of [oE.left, oE.right, oE.centerX]) {
|
||||
const d = tv - x;
|
||||
const allowed = (state._extStickyX != null && Math.abs(tv - state._extStickyX) < 0.01) ? stickyG : snapGraph;
|
||||
if (Math.abs(d) <= allowed && (!bx || Math.abs(d) < Math.abs(bx.delta))) bx = { delta: d, target: tv, rect: t.rect };
|
||||
}
|
||||
for (const tv of [oE.top, oE.bottom, oE.centerY]) {
|
||||
const d = tv - y;
|
||||
const allowed = (state._extStickyY != null && Math.abs(tv - state._extStickyY) < 0.01) ? stickyG : snapGraph;
|
||||
if (Math.abs(d) <= allowed && (!by || Math.abs(d) < Math.abs(by.delta))) by = { delta: d, target: tv, rect: t.rect };
|
||||
}
|
||||
}
|
||||
state._extStickyX = bx ? bx.target : null;
|
||||
state._extStickyY = by ? by.target : null;
|
||||
const sx = bx ? x + bx.delta : x, sy = by ? y + by.delta : y;
|
||||
state.activeGuides = [];
|
||||
if (bx) pushGuide("X", bx.target, [Math.min(sy, bx.rect.y), Math.max(sy, bx.rect.y + bx.rect.h)]);
|
||||
if (by) pushGuide("Y", by.target, [Math.min(sx, by.rect.x), Math.max(sx, by.rect.x + by.rect.w)]);
|
||||
c.setDirty?.(true, true);
|
||||
return { x: sx, y: sy };
|
||||
}
|
||||
|
||||
function onWindowPointerMove(e) {
|
||||
if (!state.enabled) { resetDrag(); return; }
|
||||
// Shift bypasses snap (Alt is taken by ComfyUI for "duplicate during drag").
|
||||
@@ -990,42 +453,6 @@ function onWindowPointerMove(e) {
|
||||
// them since neither moves a node.)
|
||||
if (c.dragging_rectangle != null) { resetDrag(); return; }
|
||||
if (c.dragging_canvas) { resetDrag(); return; }
|
||||
|
||||
// A Pixaroma group (js/pixgroup) drag is owned by that module — it moves the
|
||||
// frame + its contained nodes itself and calls snapMovingRect for the snap. Bail
|
||||
// here WITHOUT clearing guides (snapMovingRect just set them); else our node
|
||||
// detector below would misread the moving contained nodes as a node drag.
|
||||
if (window.PixaromaPixGroup?.isDragging?.()) { state.dragInfo = null; state.groupDrag = null; return; }
|
||||
|
||||
// ── Group drag takes precedence over node logic. Detect it first (and keep an
|
||||
// in-progress session alive even on a still tick, so guides don't flicker when
|
||||
// you pause), then handle + return. A group move also shifts its contained
|
||||
// nodes, which the node detector below would otherwise misread as a node drag.
|
||||
// The button-held + shift + marquee/pan gates above already cleared groupDrag
|
||||
// when appropriate; pointerup/cancel clears it via resetDrag.
|
||||
const draggedGroup = findDraggedGroup(c) || state.groupDrag?.ref || null;
|
||||
const resizedGroupRaw = findResizedGroup(c); // detect BEFORE refreshGroupCache overwrites prev
|
||||
// Latch a multi-group drag (2+ groups moving together). Once latched, Align
|
||||
// stays out of group snapping for the rest of the gesture so the selected
|
||||
// groups move rigidly via LiteGraph instead of creeping into each other.
|
||||
if (countMovedGroups(c) > 1) state._multiGroupDrag = true;
|
||||
refreshGroupCache(c);
|
||||
if (draggedGroup) {
|
||||
if (state._multiGroupDrag) {
|
||||
state.groupDrag = null;
|
||||
if (state.activeGuides.length) { state.activeGuides = []; c.setDirty?.(true, true); }
|
||||
return;
|
||||
}
|
||||
handleGroupDrag(c, draggedGroup, e);
|
||||
return;
|
||||
}
|
||||
// Native group RESIZE (bottom-right corner moved, top-left fixed) — snap the corner.
|
||||
const resizedGroup = resizedGroupRaw || state.groupResize?.ref || null;
|
||||
if (resizedGroup && !state._multiGroupDrag) {
|
||||
handleGroupResize(c, resizedGroup, e);
|
||||
return;
|
||||
}
|
||||
|
||||
// LEGACY-ONLY drag gates. Nodes 2.0 moves nodes WITHOUT setting
|
||||
// last_mouse_dragging (undefined) or pointer.dragStarted (stays false) -
|
||||
// verified via console diagnostic 2026-06-01 - so gating on them would bail
|
||||
@@ -1053,15 +480,6 @@ function onWindowPointerMove(e) {
|
||||
// selected_nodes (marquee/pan already bailed above). Agent-verified 2026-06-01.
|
||||
let draggedNode = null;
|
||||
if (vue) {
|
||||
// Only a press that began on a node (and was not kept by a control or a
|
||||
// text field inside it) can be a node drag. Without this, ANY left-drag with
|
||||
// a node selected - the sidebar, a floating window's title, selecting text
|
||||
// in another node - moved the selected node (pattern #24).
|
||||
if (!(state._pressInNode && state._pressBubbled)) {
|
||||
state.dragInfo = null;
|
||||
if (state.activeGuides.length) { state.activeGuides = []; c.setDirty?.(true, true); }
|
||||
return;
|
||||
}
|
||||
// Resize guard (Nodes 2.0): a resize updates node._size, but a MOVE does
|
||||
// NOT mutate node._pos, and selected_nodes can't tell us whether the user is
|
||||
// moving the selected node or RESIZING a different one. So if ANY node's
|
||||
@@ -1153,22 +571,12 @@ function onWindowPointerMove(e) {
|
||||
// marquee/pan). Clear any guides but DON'T resetDrag - that wipes the cache we
|
||||
// just refreshed, and the next tick could then never detect movement (the
|
||||
// bug that broke Align entirely in Nodes 2.0: every tick-0 cleared the cache).
|
||||
if (!draggedNode) {
|
||||
if (!draggedNode || draggedNode.flags?.collapsed) {
|
||||
if (state.activeGuides.length) { state.activeGuides = []; c.setDirty?.(true, true); }
|
||||
return;
|
||||
}
|
||||
|
||||
// A PINNED node is locked (flags.pinned): native LiteGraph won't drag it, so
|
||||
// Align must not either. If the node we identified as dragged is pinned, stand
|
||||
// down for this gesture. (A multi-select where only SOME members are pinned is
|
||||
// handled below by excluding the pinned ones from the moving set.)
|
||||
if (draggedNode.flags?.pinned) {
|
||||
state.dragInfo = null;
|
||||
if (state.activeGuides.length) { state.activeGuides = []; c.setDirty?.(true, true); }
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-select detection. If 2+ nodes (collapsed included) are selected AND the
|
||||
// Multi-select detection. If 2+ uncollapsed nodes are selected AND the
|
||||
// identified draggedNode is in that selection, the drag is treated as a
|
||||
// rigid bbox move where the cursor delta moves every selected node by the
|
||||
// same amount and snap is computed on the bbox edges/centers.
|
||||
@@ -1180,9 +588,7 @@ function onWindowPointerMove(e) {
|
||||
if (sel) {
|
||||
const selVals = Object.values(sel);
|
||||
if (selVals.length > 1 && selVals.includes(draggedNode)) {
|
||||
// Exclude PINNED nodes from the moving set - they stay locked even when an
|
||||
// unpinned sibling in the selection is dragged (native LiteGraph skips them).
|
||||
const live = selVals.filter((n) => n && !(n.flags && n.flags.pinned));
|
||||
const live = selVals.filter((n) => n && !n.flags?.collapsed);
|
||||
if (live.length > 1) multiNodes = live;
|
||||
}
|
||||
}
|
||||
@@ -1215,11 +621,11 @@ function onWindowPointerMove(e) {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const n of multiNodes) {
|
||||
origPositions.set(n.id, { x: n.pos[0], y: n.pos[1] });
|
||||
const r = nodeRect(n); // collapse-aware visual rect
|
||||
minX = Math.min(minX, r.x);
|
||||
minY = Math.min(minY, r.y);
|
||||
maxX = Math.max(maxX, r.x + r.w);
|
||||
maxY = Math.max(maxY, r.y + r.h);
|
||||
const visTop = n.pos[1] - getTitleH(n);
|
||||
minX = Math.min(minX, n.pos[0]);
|
||||
minY = Math.min(minY, visTop);
|
||||
maxX = Math.max(maxX, n.pos[0] + n.size[0]);
|
||||
maxY = Math.max(maxY, n.pos[1] + n.size[1]);
|
||||
}
|
||||
state.dragInfo = {
|
||||
nodeId: draggedNode.id,
|
||||
@@ -1267,13 +673,6 @@ function onWindowPointerMove(e) {
|
||||
// we never move a node mid-resize. (Vue resize uses a separate mechanism
|
||||
// Align doesn't snap; move is the supported case.)
|
||||
if (!sizeChanged) state.dragInfo.lockType = "move";
|
||||
} else if (c.resizing_node === draggedNode) {
|
||||
// LiteGraph SAYS it is resizing this node, so believe it. Inferring from
|
||||
// what changed misread a left-edge drag on a node already at its OWN
|
||||
// minimum: LiteGraph moved the left edge, the node's onResize widened it
|
||||
// straight back, the size came out unchanged, and the gesture was taken
|
||||
// for a MOVE (the node slid with the cursor).
|
||||
state.dragInfo.lockType = "resize";
|
||||
} else {
|
||||
const posChanged = draggedNode.pos[0] !== state.dragInfo.posX || draggedNode.pos[1] !== state.dragInfo.posY;
|
||||
if (sizeChanged) state.dragInfo.lockType = "resize";
|
||||
@@ -1300,24 +699,10 @@ function onWindowPointerMove(e) {
|
||||
const curTop = draggedNode.pos[1] - titleH; // visual top
|
||||
const curBot = draggedNode.pos[1] + draggedNode.size[1];
|
||||
const EPS = 0.01;
|
||||
// Which edges move: LiteGraph KNOWS (pointer.resizeDirection, e.g. "SW"), so
|
||||
// ask it. Inferring it from the edges was fooled by a node's OWN minimum:
|
||||
// shrinking from the left past it, LiteGraph moves the left edge and the
|
||||
// node's onResize widens itself back, so the RIGHT edge moved too, both sides
|
||||
// were flagged, and the node slid sideways (measured 600 -> 1050). The
|
||||
// inference below stays as the fallback for a build without the field.
|
||||
const dir = c.resizing_node === draggedNode ? c.pointer?.resizeDirection : null;
|
||||
if (typeof dir === "string" && dir) {
|
||||
state.dragInfo.leftMoves = dir.includes("W");
|
||||
state.dragInfo.rightMoves = dir.includes("E");
|
||||
state.dragInfo.topMoves = dir.includes("N");
|
||||
state.dragInfo.botMoves = dir.includes("S");
|
||||
} else {
|
||||
if (Math.abs(curLeft - initLeft) > EPS) state.dragInfo.leftMoves = true;
|
||||
if (Math.abs(curRight - initRight) > EPS) state.dragInfo.rightMoves = true;
|
||||
if (Math.abs(curTop - initTop) > EPS) state.dragInfo.topMoves = true;
|
||||
if (Math.abs(curBot - initBot) > EPS) state.dragInfo.botMoves = true;
|
||||
}
|
||||
if (Math.abs(curLeft - initLeft) > EPS) state.dragInfo.leftMoves = true;
|
||||
if (Math.abs(curRight - initRight) > EPS) state.dragInfo.rightMoves = true;
|
||||
if (Math.abs(curTop - initTop) > EPS) state.dragInfo.topMoves = true;
|
||||
if (Math.abs(curBot - initBot) > EPS) state.dragInfo.botMoves = true;
|
||||
const { leftMoves, rightMoves, topMoves, botMoves } = state.dragInfo;
|
||||
|
||||
let minW = 50, minH = 20;
|
||||
@@ -1346,7 +731,7 @@ function onWindowPointerMove(e) {
|
||||
}
|
||||
|
||||
const stickyG = snapGraph * 1.5;
|
||||
const targets = alignTargets(c);
|
||||
const nodes = c.graph?._nodes || [];
|
||||
let snapLeft = null, snapRight = null, snapTop = null, snapBot = null;
|
||||
const tryTarget = (curBest, t, value, sticky, rect) => {
|
||||
const d = t - value;
|
||||
@@ -1356,10 +741,10 @@ function onWindowPointerMove(e) {
|
||||
}
|
||||
return curBest;
|
||||
};
|
||||
for (const tg of targets) {
|
||||
if (tg.ref === draggedNode) continue;
|
||||
if (tg.collapsed) continue;
|
||||
const oRect = tg.rect;
|
||||
for (const other of nodes) {
|
||||
if (other === draggedNode) continue;
|
||||
if (other.flags?.collapsed) continue;
|
||||
const oRect = nodeRect(other);
|
||||
const oE = rectEdges(oRect);
|
||||
if (leftMoves) {
|
||||
for (const t of [oE.left, oE.right, oE.centerX]) {
|
||||
@@ -1406,7 +791,6 @@ function onWindowPointerMove(e) {
|
||||
draggedNode,
|
||||
fLeft, fTop + titleH, fRight - fLeft, fBot - (fTop + titleH),
|
||||
!!(snapLeft || snapRight || snapTop || snapBot),
|
||||
{ left: leftMoves && !rightMoves, top: topMoves && !botMoves },
|
||||
);
|
||||
|
||||
// Push one guide per engaged edge. fLeft/fRight/fTop/fBot are visual
|
||||
@@ -1462,13 +846,12 @@ function onWindowPointerMove(e) {
|
||||
const movingY = [movingE.top, movingE.bottom, movingE.centerY];
|
||||
const stickyG = snapGraph * 1.5;
|
||||
const allNodes = c.graph?._nodes || [];
|
||||
const targets = alignTargets(c);
|
||||
let bestX = null, bestXRect = null;
|
||||
let bestY = null, bestYRect = null;
|
||||
for (const tg of targets) {
|
||||
if (tg.kind === "node" && di.origIds.has(tg.id)) continue;
|
||||
if (tg.collapsed) continue;
|
||||
const oRect = tg.rect;
|
||||
for (const other of allNodes) {
|
||||
if (di.origIds.has(other.id)) continue;
|
||||
if (other.flags?.collapsed) continue;
|
||||
const oRect = nodeRect(other);
|
||||
const dxc = Math.max(0, Math.max(oRect.x - (movingRect.x + movingRect.w), movingRect.x - (oRect.x + oRect.w)));
|
||||
const dyc = Math.max(0, Math.max(oRect.y - (movingRect.y + movingRect.h), movingRect.y - (oRect.y + oRect.h)));
|
||||
if (dxc > 2 * stickyG && dyc > 2 * stickyG) continue;
|
||||
@@ -1501,8 +884,8 @@ function onWindowPointerMove(e) {
|
||||
"X", bestX.target,
|
||||
Math.min(finalBBox.y, bestXRect.y),
|
||||
Math.max(finalBBox.y + finalBBox.h, bestXRect.y + bestXRect.h),
|
||||
targets,
|
||||
(ref) => ref && di.origIds.has(ref.id),
|
||||
allNodes,
|
||||
(n) => di.origIds.has(n.id) || n === bestXRect,
|
||||
);
|
||||
pushGuide("X", bestX.target, range);
|
||||
}
|
||||
@@ -1511,8 +894,8 @@ function onWindowPointerMove(e) {
|
||||
"Y", bestY.target,
|
||||
Math.min(finalBBox.x, bestYRect.x),
|
||||
Math.max(finalBBox.x + finalBBox.w, bestYRect.x + bestYRect.w),
|
||||
targets,
|
||||
(ref) => ref && di.origIds.has(ref.id),
|
||||
allNodes,
|
||||
(n) => di.origIds.has(n.id) || n === bestYRect,
|
||||
);
|
||||
pushGuide("Y", bestY.target, range);
|
||||
}
|
||||
@@ -1528,28 +911,22 @@ function onWindowPointerMove(e) {
|
||||
|
||||
// Build moving rect at desired position. Use the VISUAL rect (including
|
||||
// the title bar above pos[1]) so snap aligns with what the user sees.
|
||||
// Collapse-aware moving rect: a collapsed node is just its title pill.
|
||||
const collapsed = !!draggedNode.flags?.collapsed;
|
||||
const TH = window.LiteGraph?.NODE_TITLE_HEIGHT || 30;
|
||||
let titleH = collapsed ? TH : getTitleH(draggedNode);
|
||||
let w = collapsed ? (draggedNode._collapsed_width || window.LiteGraph?.NODE_COLLAPSED_WIDTH || 80) : draggedNode.size[0];
|
||||
let h = collapsed ? 0 : draggedNode.size[1];
|
||||
// Nodes 2.0: a collapsed pill / title-less face at the size it is drawn, from pos[1] - TH.
|
||||
const vd = vueDrawnSize(draggedNode);
|
||||
if (vd) { titleH = TH; w = vd.w; h = vd.h - TH; }
|
||||
const titleH = getTitleH(draggedNode);
|
||||
const w = draggedNode.size[0];
|
||||
const h = draggedNode.size[1];
|
||||
const movingRect = { x: desiredX, y: desiredY - titleH, w, h: h + titleH };
|
||||
const movingE = rectEdges(movingRect);
|
||||
const movingX = [movingE.left, movingE.right, movingE.centerX];
|
||||
const movingY = [movingE.top, movingE.bottom, movingE.centerY];
|
||||
|
||||
const stickyG = snapGraph * 1.5;
|
||||
const targets = alignTargets(c);
|
||||
const nodes = c.graph?._nodes || [];
|
||||
let bestX = null, bestXRect = null;
|
||||
let bestY = null, bestYRect = null;
|
||||
for (const t of targets) {
|
||||
if (t.ref === draggedNode) continue;
|
||||
if (t.collapsed) continue;
|
||||
const oRect = t.rect;
|
||||
for (const other of nodes) {
|
||||
if (other === draggedNode) continue;
|
||||
if (other.flags?.collapsed) continue;
|
||||
const oRect = nodeRect(other);
|
||||
const dxc = Math.max(0, Math.max(oRect.x - (movingRect.x + movingRect.w), movingRect.x - (oRect.x + oRect.w)));
|
||||
const dyc = Math.max(0, Math.max(oRect.y - (movingRect.y + movingRect.h), movingRect.y - (oRect.y + oRect.h)));
|
||||
if (dxc > 2 * stickyG && dyc > 2 * stickyG) continue;
|
||||
@@ -1580,8 +957,8 @@ function onWindowPointerMove(e) {
|
||||
"X", bestX.target,
|
||||
Math.min(finalRect.y, bestXRect.y),
|
||||
Math.max(finalRect.y + finalRect.h, bestXRect.y + bestXRect.h),
|
||||
targets,
|
||||
(ref) => ref === draggedNode,
|
||||
nodes,
|
||||
(n) => n === draggedNode || n === bestXRect,
|
||||
);
|
||||
pushGuide("X", bestX.target, range);
|
||||
}
|
||||
@@ -1590,8 +967,8 @@ function onWindowPointerMove(e) {
|
||||
"Y", bestY.target,
|
||||
Math.min(finalRect.x, bestYRect.x),
|
||||
Math.max(finalRect.x + finalRect.w, bestYRect.x + bestYRect.w),
|
||||
targets,
|
||||
(ref) => ref === draggedNode,
|
||||
nodes,
|
||||
(n) => n === draggedNode || n === bestYRect,
|
||||
);
|
||||
pushGuide("Y", bestY.target, range);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// js/audio_studio/api.mjs
|
||||
"use strict";
|
||||
|
||||
import { pixApiUrl } from "../shared/api_url.mjs";
|
||||
import { comboViewRoute } from "../shared/view_url.mjs";
|
||||
|
||||
const UPLOAD_ENDPOINT = "/pixaroma/api/audio_studio/upload";
|
||||
const SYSINFO_ENDPOINT = "/pixaroma/api/audio_studio/sysinfo";
|
||||
|
||||
@@ -20,7 +17,7 @@ const SYSINFO_ENDPOINT = "/pixaroma/api/audio_studio/sysinfo";
|
||||
*/
|
||||
export async function getSysInfo() {
|
||||
try {
|
||||
const res = await fetch(pixApiUrl(SYSINFO_ENDPOINT));
|
||||
const res = await fetch(SYSINFO_ENDPOINT);
|
||||
if (!res.ok) return { total_gb: null, available_gb: null, cap_gb: null };
|
||||
return res.json();
|
||||
} catch {
|
||||
@@ -42,7 +39,7 @@ export async function uploadSource(nodeId, kind, blob, filename) {
|
||||
fd.append("node_id", String(nodeId));
|
||||
fd.append("kind", kind);
|
||||
fd.append("file", blob, filename);
|
||||
const res = await fetch(pixApiUrl(UPLOAD_ENDPOINT), { method: "POST", body: fd });
|
||||
const res = await fetch(UPLOAD_ENDPOINT, { method: "POST", body: fd });
|
||||
if (!res.ok) {
|
||||
let msg = `upload failed: HTTP ${res.status}`;
|
||||
try { msg = (await res.json()).error || msg; } catch {}
|
||||
@@ -81,10 +78,9 @@ export function getUpstreamImageUrl(graph, node) {
|
||||
if (src.comfyClass === "LoadImage" || src.type === "LoadImage") {
|
||||
const w = src.widgets?.find(w => w.name === "image");
|
||||
if (w && w.value) {
|
||||
// The value can be "Studio1/cat.png" or "clipspace/x.png [input]": /view keeps only the BASENAME of
|
||||
// filename and takes the folder from subfolder, so the old basename + empty subfolder 404'd, or showed a
|
||||
// SAME-NAMED root file (image-pickers.md #4, LIVE_BUGS #16). The cache-buster goes on the ROUTE.
|
||||
return pixApiUrl(`${comboViewRoute(w.value)}&t=${Date.now()}`);
|
||||
const fn = String(w.value).split(/[\\/]/).pop();
|
||||
return `/view?filename=${encodeURIComponent(fn)}` +
|
||||
`&type=input&subfolder=&t=${Date.now()}`;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(src.imgs) && src.imgs.length) {
|
||||
@@ -110,7 +106,7 @@ export function getInlineSourceUrl(path) {
|
||||
const parts = path.split("/");
|
||||
const filename = parts.pop();
|
||||
const subfolder = ["pixaroma", ...parts].join("/");
|
||||
return pixApiUrl(`/view?filename=${encodeURIComponent(filename)}` +
|
||||
return `/view?filename=${encodeURIComponent(filename)}` +
|
||||
`&type=input&subfolder=${encodeURIComponent(subfolder)}` +
|
||||
`&t=${Date.now()}`);
|
||||
`&t=${Date.now()}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// js/audio_studio/core.mjs
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { pixApiUrl, pixAsset } from "../shared/api_url.mjs";
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
import { decodeAudio, computeAll, encodeWav, getAudioContext } from "./audio_analysis.mjs";
|
||||
import { getUpstreamImageUrl, getInlineSourceUrl, uploadSource, getSysInfo } from "./api.mjs";
|
||||
import { createEditorLayout, createButton } from "../framework/index.mjs";
|
||||
@@ -218,10 +217,19 @@ export class AudioStudioEditor {
|
||||
open() {
|
||||
injectAudioStudioCSS();
|
||||
|
||||
// Vue Compat #6: stop Ctrl+Z escaping to the graph while the editor is open.
|
||||
// Released in forceClose(), and it self-heals if the overlay is torn down
|
||||
// without forceClose running (Vue Compat #2). HOW it works lives in
|
||||
// js/shared/graph_undo_guard.mjs and nowhere else - do not restate it here.
|
||||
// Vue-compat (CLAUDE.md Pattern #6): neuter Ctrl+Z escape paths while
|
||||
// editor is open. Patches restored in forceClose() - AND they self-heal
|
||||
// (Vue Compat #2): if the overlay is torn down without forceClose running
|
||||
// (e.g. closing the workflow tab while the editor is open), the patches
|
||||
// would otherwise stay installed forever and the user could no longer open
|
||||
// or create ANY workflow until a page refresh. Each wrapper checks whether
|
||||
// our overlay is still in the DOM; if it's gone, it restores the originals
|
||||
// and passes the call through. Ctrl+Z while the editor is genuinely open
|
||||
// is still blocked.
|
||||
// Shared, refcount-safe, self-healing guard. Also covers graph.undo/redo +
|
||||
// the Comfy.Undo/Redo command (the old inline version only did
|
||||
// loadGraphData/configure, so the command-store undo path could still
|
||||
// escape), and refcounts so two editors closing FIFO can't brick the UI.
|
||||
this._undoGuardOff = installGraphUndoGuard(() => this._overlayAlive());
|
||||
|
||||
// Build the standard Pixaroma editor shell. This gives us:
|
||||
@@ -374,13 +382,7 @@ export class AudioStudioEditor {
|
||||
});
|
||||
|
||||
// React to upstream disconnect / reconnect while editor is open.
|
||||
// Value-captured, not bound: the scanner matches a literal dot-bind token
|
||||
// even inside a comment. See registry-compliance.md #4a.
|
||||
const _occFn = this.node.onConnectionsChange;
|
||||
const _occNode = this.node;
|
||||
this._origOnConnectionsChange = _occFn
|
||||
? (...a) => _occFn.apply(_occNode, a)
|
||||
: undefined;
|
||||
this._origOnConnectionsChange = this.node.onConnectionsChange?.bind(this.node);
|
||||
this.node.onConnectionsChange = (type, slotIndex, connected) => {
|
||||
this._origOnConnectionsChange?.(type, slotIndex, connected);
|
||||
if (type !== 1) return; // LiteGraph.INPUT === 1
|
||||
@@ -445,7 +447,7 @@ export class AudioStudioEditor {
|
||||
imgCell.className = "pix-as-source-cell";
|
||||
const imgBtn = createButton("Upload Image", {
|
||||
variant: "standard",
|
||||
iconSrc: pixAsset(UI_ICON + "image.svg"),
|
||||
iconSrc: UI_ICON + "image.svg",
|
||||
title: "Upload an image (auto-disconnects upstream IMAGE wire)",
|
||||
onClick: () => this._pickInlineImage(),
|
||||
});
|
||||
@@ -464,7 +466,7 @@ export class AudioStudioEditor {
|
||||
audCell.className = "pix-as-source-cell";
|
||||
const audBtn = createButton("Upload Audio", {
|
||||
variant: "standard",
|
||||
iconSrc: pixAsset(UI_ICON + "audio.svg"),
|
||||
iconSrc: UI_ICON + "audio.svg",
|
||||
title: "Upload audio (auto-disconnects upstream AUDIO wire). MP3 / OGG / etc. converted to WAV.",
|
||||
onClick: () => this._pickInlineAudio(),
|
||||
});
|
||||
@@ -756,15 +758,15 @@ export class AudioStudioEditor {
|
||||
});
|
||||
}
|
||||
|
||||
// Is this editor's overlay still in the document? This is the isAlive the
|
||||
// shared graph-undo guard polls to detect a teardown that bypassed
|
||||
// Is this editor's overlay still in the document? Used by the self-healing
|
||||
// loadGraphData/configure patches to detect a teardown that bypassed
|
||||
// forceClose (Vue Compat #2).
|
||||
_overlayAlive() {
|
||||
return !!(this.overlay && this.overlay.isConnected);
|
||||
}
|
||||
|
||||
// Release the graph-undo guard taken in open(). Idempotent + safe to call
|
||||
// from BOTH forceClose AND the guard's own self-heal path.
|
||||
// Restore the graph patches we installed in open(). Idempotent + safe to
|
||||
// call from BOTH forceClose AND the self-heal path.
|
||||
_restoreGraphPatches() {
|
||||
if (this._undoGuardOff) { this._undoGuardOff(); this._undoGuardOff = null; }
|
||||
}
|
||||
@@ -996,7 +998,7 @@ AudioStudioEditor.prototype._resolveAudioSource = async function () {
|
||||
const w = src.widgets?.find((w) => w.name === "audio" || w.name === "audio_file");
|
||||
if (w && w.value) {
|
||||
const fn = String(w.value).split(/[\\/]/).pop();
|
||||
upstreamUrl = pixApiUrl(`/view?filename=${encodeURIComponent(fn)}&type=input&subfolder=&t=${Date.now()}`);
|
||||
upstreamUrl = `/view?filename=${encodeURIComponent(fn)}&type=input&subfolder=&t=${Date.now()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// js/audio_studio/index.js
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
|
||||
import { AudioStudioEditor } from "./core.mjs";
|
||||
// Mixin imports — must be side-effect imports BEFORE the first
|
||||
@@ -86,8 +86,7 @@ app.registerExtension({
|
||||
// studio_json from node.properties.audioStudioState into the request
|
||||
// body right before submission. Same pattern as Resolution Pixaroma.
|
||||
async setup() {
|
||||
const _original_fn = app.graphToPrompt;
|
||||
const original = (...a) => _original_fn.apply(app, a);
|
||||
const original = app.graphToPrompt.bind(app);
|
||||
app.graphToPrompt = async function (...args) {
|
||||
const result = await original(...args);
|
||||
try {
|
||||
|
||||
@@ -76,13 +76,6 @@ float onset_at(int frame_idx) {
|
||||
vec4 sample_image(vec2 uv) {
|
||||
return texture(u_image, clamp(uv, 0.0, 1.0));
|
||||
}
|
||||
|
||||
// The engine's sampling grid is [-1,1] with y pointing DOWN (-1 = top row);
|
||||
// v_uv is [0,1] with y pointing UP (GL). A formula that uses the sign of y
|
||||
// (rotation, swirl, tilt, waves) turns the OTHER way if copied onto v_uv, so
|
||||
// those shaders convert with these two and keep the engine's math verbatim.
|
||||
vec2 to_grid(vec2 uv) { return vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0); }
|
||||
vec2 from_grid(vec2 g) { return vec2(g.x * 0.5 + 0.5, 0.5 - g.y * 0.5); }
|
||||
`;
|
||||
|
||||
// --- Motion shaders -----------------------------------------------------
|
||||
@@ -143,8 +136,8 @@ void main() {
|
||||
float sway = sin(phase);
|
||||
float bob = cos(phase);
|
||||
float amp = env_t * u_intensity * 0.04;
|
||||
vec2 g = to_grid(v_uv) - vec2(sway * amp, bob * amp);
|
||||
fragColor = sample_image(from_grid(g));
|
||||
vec2 uv = v_uv - vec2(sway * amp, bob * amp);
|
||||
fragColor = sample_image(uv);
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -157,12 +150,13 @@ void main() {
|
||||
float sway = sin(6.28318530718 * u_motion_speed * u_t);
|
||||
float angle = sway * env_t * u_intensity * (3.14159265359 / 12.0) * u_motion_direction;
|
||||
float c = cos(angle), s = sin(angle);
|
||||
vec2 p = to_grid(v_uv);
|
||||
vec2 p = (v_uv - 0.5);
|
||||
float xs = p.x * u_aspect;
|
||||
float ys = p.y;
|
||||
float nx = (xs * c - ys * s) / u_aspect;
|
||||
float ny = xs * s + ys * c;
|
||||
fragColor = sample_image(from_grid(vec2(nx, ny)));
|
||||
vec2 uv = vec2(nx, ny) + 0.5;
|
||||
fragColor = sample_image(uv);
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -191,7 +185,7 @@ void main() {
|
||||
swirl: COMMON_PRELUDE + `
|
||||
void main() {
|
||||
float env_t = env_at(u_frame_index);
|
||||
vec2 p = to_grid(v_uv);
|
||||
vec2 p = (v_uv - 0.5) * 2.0;
|
||||
float xs = p.x * u_aspect;
|
||||
float ys = p.y;
|
||||
float r = sqrt(xs*xs + ys*ys);
|
||||
@@ -200,7 +194,8 @@ void main() {
|
||||
float thp = theta + twist;
|
||||
float nx = r * cos(thp) / u_aspect;
|
||||
float ny = r * sin(thp);
|
||||
fragColor = sample_image(from_grid(vec2(nx, ny)));
|
||||
vec2 uv = vec2(nx, ny) * 0.5 + 0.5;
|
||||
fragColor = sample_image(uv);
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -226,12 +221,13 @@ void main() {
|
||||
wave: COMMON_PRELUDE + `
|
||||
void main() {
|
||||
float env_t = env_at(u_frame_index);
|
||||
vec2 g = to_grid(v_uv);
|
||||
float yn = (v_uv.y - 0.5) * 2.0;
|
||||
float k = 4.0 * 3.14159265359 * u_wave_density;
|
||||
float omega = 6.28318530718 * max(u_motion_speed * 2.0, 0.4) * u_motion_direction;
|
||||
float A = env_t * u_intensity * 0.05;
|
||||
g.x += A * sin(k * g.y - omega * u_t);
|
||||
fragColor = sample_image(from_grid(g));
|
||||
float dx = A * sin(k * yn - omega * u_t);
|
||||
vec2 uv = v_uv + vec2(dx * 0.5, 0.0);
|
||||
fragColor = sample_image(uv);
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -242,9 +238,10 @@ void main() {
|
||||
float env_t = env_at(u_frame_index);
|
||||
float sway = sin(6.28318530718 * u_motion_speed * u_t);
|
||||
float skew = sway * env_t * u_intensity * 0.20 * u_motion_direction;
|
||||
vec2 p = to_grid(v_uv);
|
||||
vec2 p = (v_uv - 0.5) * 2.0;
|
||||
p.x = p.x + skew * p.y;
|
||||
fragColor = sample_image(from_grid(p));
|
||||
vec2 uv = p * 0.5 + 0.5;
|
||||
fragColor = sample_image(uv);
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -335,14 +332,14 @@ void main() {
|
||||
slit_scan: COMMON_PRELUDE + `
|
||||
void main() {
|
||||
float env_t = env_at(u_frame_index);
|
||||
vec2 g = to_grid(v_uv);
|
||||
float yn = g.y;
|
||||
float yn = (v_uv.y - 0.5) * 2.0; // y in [-1,1]
|
||||
float k = 4.0 * 3.14159265359 * u_slit_density;
|
||||
float omega = 6.28318530718 * max(u_motion_speed * 2.0, 0.4) * u_motion_direction;
|
||||
float A = env_t * u_intensity * 0.04;
|
||||
float dy = A * sin(k * yn - omega * u_t);
|
||||
float dx = A * 0.5 * cos(k * yn - omega * u_t);
|
||||
fragColor = sample_image(from_grid(g + vec2(dx, dy)));
|
||||
vec2 uv = v_uv + vec2(dx, dy) * 0.5;
|
||||
fragColor = sample_image(uv);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// Mixin: transport bar UI (G1) + Web Audio playback (G2).
|
||||
import { AudioStudioEditor } from "./core.mjs";
|
||||
import { getAudioContext } from "./audio_analysis.mjs";
|
||||
import { pixAsset } from "../shared/api_url.mjs";
|
||||
|
||||
function injectTransportCSS() {
|
||||
if (document.getElementById("pix-as-transport-css")) return;
|
||||
@@ -46,8 +45,8 @@ function injectTransportCSS() {
|
||||
.pix-as-stop-btn .pix-as-stop-icon {
|
||||
width: 11px; height: 11px;
|
||||
background-color: #f66744;
|
||||
-webkit-mask: url(${pixAsset("icons/ui/stop.svg")}) center/contain no-repeat;
|
||||
mask: url(${pixAsset("icons/ui/stop.svg")}) center/contain no-repeat;
|
||||
-webkit-mask: url(/pixaroma/assets/icons/ui/stop.svg) center/contain no-repeat;
|
||||
mask: url(/pixaroma/assets/icons/ui/stop.svg) center/contain no-repeat;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -69,8 +68,8 @@ function injectTransportCSS() {
|
||||
.pix-as-loop-btn .pix-as-loop-icon {
|
||||
width: 13px; height: 13px;
|
||||
background-color: #666;
|
||||
-webkit-mask: url(${pixAsset("icons/ui/loop.svg")}) center/contain no-repeat;
|
||||
mask: url(${pixAsset("icons/ui/loop.svg")}) center/contain no-repeat;
|
||||
-webkit-mask: url(/pixaroma/assets/icons/ui/loop.svg) center/contain no-repeat;
|
||||
mask: url(/pixaroma/assets/icons/ui/loop.svg) center/contain no-repeat;
|
||||
pointer-events: none;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
@@ -163,7 +162,7 @@ AudioStudioEditor.prototype._buildTransport = function () {
|
||||
playBtn.title = "Play / Pause (Space)";
|
||||
const playIcon = document.createElement("span");
|
||||
playIcon.className = "pix-as-play-icon";
|
||||
playIcon.style.setProperty("--pix-as-icon-url", `url(${pixAsset("icons/ui/play.svg")})`);
|
||||
playIcon.style.setProperty("--pix-as-icon-url", "url(/pixaroma/assets/icons/ui/play.svg)");
|
||||
playBtn.appendChild(playIcon);
|
||||
playBtn.addEventListener("click", () => this._togglePlay());
|
||||
t.appendChild(playBtn);
|
||||
@@ -251,7 +250,7 @@ AudioStudioEditor.prototype._buildTransport = function () {
|
||||
stepBack.className = "pix-as-frame-step";
|
||||
stepBack.title = "Frame back (Left arrow; Shift+Left = 1s)";
|
||||
const stepBackIcon = document.createElement("img");
|
||||
stepBackIcon.src = pixAsset("icons/ui/play.svg");
|
||||
stepBackIcon.src = "/pixaroma/assets/icons/ui/play.svg";
|
||||
stepBackIcon.style.transform = "rotate(180deg)";
|
||||
stepBack.appendChild(stepBackIcon);
|
||||
stepBack.addEventListener("click", () => this._stepFrame(-1));
|
||||
@@ -261,7 +260,7 @@ AudioStudioEditor.prototype._buildTransport = function () {
|
||||
stepFwd.className = "pix-as-frame-step";
|
||||
stepFwd.title = "Frame forward (Right arrow; Shift+Right = 1s)";
|
||||
const stepFwdIcon = document.createElement("img");
|
||||
stepFwdIcon.src = pixAsset("icons/ui/play.svg");
|
||||
stepFwdIcon.src = "/pixaroma/assets/icons/ui/play.svg";
|
||||
stepFwd.appendChild(stepFwdIcon);
|
||||
stepFwd.addEventListener("click", () => this._stepFrame(1));
|
||||
t.appendChild(stepFwd);
|
||||
@@ -286,13 +285,7 @@ AudioStudioEditor.prototype._buildTransport = function () {
|
||||
});
|
||||
// Stash listeners so forceClose can detach them — otherwise leaking
|
||||
// closures keep the editor alive after close.
|
||||
// No button held = the mouseup was LOST: end the drag instead of seeking on
|
||||
// every move (convention #20, save-mp4.md #11).
|
||||
this._scrubMove = (e) => {
|
||||
if (!dragging) return;
|
||||
if (!(e.buttons & 1)) { dragging = false; return; }
|
||||
seekFromEvent(e);
|
||||
};
|
||||
this._scrubMove = (e) => { if (dragging) seekFromEvent(e); };
|
||||
this._scrubUp = () => { dragging = false; };
|
||||
window.addEventListener("mousemove", this._scrubMove);
|
||||
window.addEventListener("mouseup", this._scrubUp);
|
||||
@@ -360,8 +353,8 @@ AudioStudioEditor.prototype._setPlayIcon = function (kind /* "play" | "pause" */
|
||||
this._playIcon.style.setProperty(
|
||||
"--pix-as-icon-url",
|
||||
kind === "pause"
|
||||
? `url(${pixAsset("icons/ui/pause.svg")})`
|
||||
: `url(${pixAsset("icons/ui/play.svg")})`,
|
||||
? "url(/pixaroma/assets/icons/ui/pause.svg)"
|
||||
: "url(/pixaroma/assets/icons/ui/play.svg)",
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// Audio / Output. Adds methods to AudioStudioEditor.prototype.
|
||||
import { AudioStudioEditor, computeEngineWH } from "./core.mjs";
|
||||
import { createPanel, UI_ICON } from "../framework/index.mjs";
|
||||
import { pixAsset } from "../shared/api_url.mjs";
|
||||
|
||||
const ASPECT_OPTIONS = [
|
||||
"Original",
|
||||
@@ -298,7 +297,7 @@ AudioStudioEditor.prototype._attachSectionAction = function (panelEl, action) {
|
||||
btn.title = action.title || "";
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "pix-as-section-action-icon";
|
||||
icon.style.setProperty("--pix-as-icon-url", `url(${pixAsset(UI_ICON + action.icon)})`);
|
||||
icon.style.setProperty("--pix-as-icon-url", `url(${UI_ICON}${action.icon})`);
|
||||
btn.appendChild(icon);
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -684,7 +683,7 @@ AudioStudioEditor.prototype._addDirectionToggle = function (panel) {
|
||||
b.title = title;
|
||||
const ic = document.createElement("span");
|
||||
ic.className = "pix-as-dir-icon";
|
||||
ic.style.setProperty("--pix-as-icon-url", `url(${pixAsset(UI_ICON + iconFile)})`);
|
||||
ic.style.setProperty("--pix-as-icon-url", `url(${UI_ICON}${iconFile})`);
|
||||
b.appendChild(ic);
|
||||
b.dataset.value = String(val);
|
||||
b.addEventListener("click", () => {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { installPixaromaChangeNet } from "../shared/graph_changed.mjs";
|
||||
import { installBypassRepair } from "../shared/bypass_repair.mjs";
|
||||
import { installTextNodesLightTheme, setThemeMode } from "../shared/light_theme.mjs";
|
||||
import { installUndoDomRemount } from "../shared/undo_dom_remount.mjs";
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
// ── Pixaroma brand defaults ──────────────────────────────────────────────
|
||||
// Single source of truth for the dark brand colors that every Pixaroma node
|
||||
@@ -25,49 +21,8 @@ import { installUndoDomRemount } from "../shared/undo_dom_remount.mjs";
|
||||
const TITLE_BAR_COLOR = "#1d1d1d"; // matches Resolution chip surface
|
||||
const BODY_COLOR = "#2a2a2a"; // matches Resolution root surface
|
||||
|
||||
// Installed from here because this file is the pack's one "applies to EVERY
|
||||
// Pixaroma node" extension and is therefore always loaded. It teaches ComfyUI
|
||||
// to notice a change made by clicking any Pixaroma control - without it the
|
||||
// change is not recorded until the user's next mouseup, so the workflow can sit
|
||||
// there looking unmodified. See graph_changed.mjs for the full mechanism.
|
||||
installPixaromaChangeNet();
|
||||
|
||||
// Bypassing a node silently drops, or re-points, a wire into any wildcard input,
|
||||
// because core picks the bypassed node's input by INDEX when the consumer is "*".
|
||||
// Installed here because brand/ is the one extension that always loads.
|
||||
installBypassRepair();
|
||||
|
||||
// ComfyUI's light colour palettes: readable text boxes and visible buttons on the
|
||||
// prompt-typing nodes, which were designed dark. One stylesheet, inert while a
|
||||
// dark palette is active. Installed here for the same "always loaded" reason.
|
||||
installTextNodesLightTheme();
|
||||
|
||||
// Nodes 2.0: after Ctrl+Z / Ctrl+Y the page kept every Pixaroma node's OLD
|
||||
// controls, wired to the dead node objects, so edits made afterwards were lost.
|
||||
// Puts the live controls back (undo_dom_remount.mjs, CLAUDE.md Vue Compat #26).
|
||||
installUndoDomRemount();
|
||||
|
||||
app.registerExtension({
|
||||
name: "Pixaroma.BrandDefaults",
|
||||
settings: [
|
||||
{
|
||||
// The escape hatch for the light/dark decision. Auto measures the active
|
||||
// palette's own colours; the other two are the user saying the measurement
|
||||
// is wrong for them. Asked for by the reporter of the 2026-09-19 bug, who
|
||||
// had no way out except editing light_theme.mjs by hand.
|
||||
id: "Pixaroma.Theme.Mode",
|
||||
name: "Node look",
|
||||
type: "combo",
|
||||
defaultValue: "Auto",
|
||||
options: ["Auto", "Dark", "Light"],
|
||||
tooltip:
|
||||
"Which look the Pixaroma prompt and text nodes use. Auto follows ComfyUI's colour palette. Pick Dark or Light to force one, if Auto reads your palette wrongly.",
|
||||
category: ["👑 Pixaroma", "Theme"],
|
||||
// A setting's onChange fires BEFORE the store write, so use the argument
|
||||
// and never re-read the value here.
|
||||
onChange: (value) => setThemeMode(value),
|
||||
},
|
||||
],
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
const cat = nodeData?.category;
|
||||
if (typeof cat !== "string" || !cat.startsWith("👑 Pixaroma")) return;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// Pixaroma Canvas Zoom - the user setting for the in-node mouse-wheel behaviour.
|
||||
//
|
||||
// Background: ComfyUI binds wheel-to-zoom on the <canvas>, and a node's DOM panel
|
||||
// (addDOMWidget) is layered OVER it, so wheeling on a node body would never reach
|
||||
// the canvas and zoom would silently stop there (issue #17). Every Pixaroma node
|
||||
// with a DOM panel therefore calls installCanvasZoomPassthrough (the helper in
|
||||
// js/shared/canvas_zoom.mjs), which forwards the wheel to the canvas.
|
||||
//
|
||||
// The helper's one judgement call is what to do over a SCROLLABLE field inside
|
||||
// that panel (a long prompt textarea, a list): scroll the field, or zoom anyway.
|
||||
// This extension registers the setting that decides. It adds no node and patches
|
||||
// nothing - the actual behaviour lives in the helper.
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import {
|
||||
WHEEL_SETTING_ID,
|
||||
WHEEL_SCROLL,
|
||||
WHEEL_ZOOM,
|
||||
} from "../shared/canvas_zoom.mjs";
|
||||
|
||||
app.registerExtension({
|
||||
name: "Pixaroma.CanvasZoom",
|
||||
settings: [
|
||||
{
|
||||
id: WHEEL_SETTING_ID,
|
||||
name: "Mouse wheel over a text box or list inside a node",
|
||||
type: "combo",
|
||||
defaultValue: WHEEL_SCROLL,
|
||||
options: [WHEEL_SCROLL, WHEEL_ZOOM],
|
||||
tooltip:
|
||||
"Choose what the mouse wheel does when the pointer is over a scrollable " +
|
||||
"text box or list inside a Pixaroma node. " +
|
||||
"\"Scroll the field\" scrolls the text, then zooms the canvas once the text " +
|
||||
"has nothing left to scroll. Once you click into a text box, the wheel only " +
|
||||
"scrolls that box and never zooms. " +
|
||||
"\"Zoom the canvas\" always zooms, and you move the text with its scrollbar. " +
|
||||
"Anywhere else on a node the wheel always zooms the canvas, and Ctrl + wheel " +
|
||||
"always zooms it.",
|
||||
// Distinct leaf category: settings that share a leaf collapse into one row.
|
||||
category: ["👑 Pixaroma", "Canvas zoom"],
|
||||
// No onChange needed: the helper reads this setting live on each wheel tick,
|
||||
// so a change applies straight away with no reload.
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
// Caption Review Pixaroma - frontend: two buttons under the folder field
|
||||
// ("Browse folder", "Review captions") and the review window (review.mjs).
|
||||
// The node never runs (Python: nodes/node_caption_review.py); everything it does
|
||||
// happens in the window. Native widgets only on the face, so it looks and sizes
|
||||
// the same in Classic and Nodes 2.0.
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { pickFolderInto } from "../shared/folder_pick.mjs";
|
||||
import { openCaptionReview } from "./review.mjs";
|
||||
|
||||
const COMFY_CLASS = "PixaromaCaptionReview";
|
||||
|
||||
function noSerialize(w) {
|
||||
if (!w) return;
|
||||
w.serialize = false;
|
||||
w.options = { ...(w.options || {}), serialize: false };
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Pixaroma.CaptionReview",
|
||||
nodeCreated(node) {
|
||||
if (node.comfyClass !== COMFY_CLASS) return;
|
||||
// Appended AFTER the native folder widget (VC#23); buttons carry no value.
|
||||
noSerialize(node.addWidget("button", "Browse folder", null, () => {
|
||||
pickFolderInto(node, "folder", "Caption Review");
|
||||
}));
|
||||
noSerialize(node.addWidget("button", "Review captions", null, () => {
|
||||
const w = node.widgets?.find((x) => x.name === "folder");
|
||||
openCaptionReview(node, w ? String(w.value || "") : "");
|
||||
}));
|
||||
const origRemoved = node.onRemoved;
|
||||
node.onRemoved = function () {
|
||||
try { node._pixCrClose?.(); } catch { /* window already gone */ }
|
||||
return origRemoved?.apply(this, arguments);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,333 +0,0 @@
|
||||
// Caption Review Pixaroma - the review window.
|
||||
//
|
||||
// Every picture of a folder with its .txt caption under it, editable. READS come
|
||||
// from the read-only caption_review/list route; WRITES go through Save Text's
|
||||
// existing save_text/write route (claim:false = replace the named file), so the
|
||||
// feature adds no write route (registry-compliance.md 4e #2).
|
||||
//
|
||||
// A card is saved ONLY under its caption's own name. The write route cleans a
|
||||
// name (a leading _, a double __, a trailing dot ...), and a cleaned name would
|
||||
// be a SECOND file beside the picture, so the route marks such cards read_only
|
||||
// (_text_folder_helpers.save_name_changes) and this window never writes them.
|
||||
// After every write the answer's `file` is compared with the name asked for.
|
||||
//
|
||||
// While the window is open, ComfyUI's Ctrl+Z is held off with the shared guard
|
||||
// (graph-undo-guard.md): typing Ctrl+Z in a caption must undo the TEXT, never
|
||||
// reload the graph underneath the window.
|
||||
|
||||
import { pixApiUrl } from "../shared/api_url.mjs";
|
||||
import { installGraphUndoGuard } from "../shared/graph_undo_guard.mjs";
|
||||
|
||||
const CSS = `
|
||||
.pix-cr-overlay { position:fixed; inset:0; z-index:10000; background:rgba(0,0,0,.72); display:flex; align-items:center; justify-content:center; font-family:'Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif; }
|
||||
.pix-cr-win { width:min(1240px,96vw); height:92vh; background:#1b1b1b; border:1px solid var(--pix-acc,#f66744); border-radius:10px; box-shadow:0 18px 50px rgba(0,0,0,.6); display:flex; flex-direction:column; overflow:hidden; color:#ddd; }
|
||||
.pix-cr-head { flex-shrink:0; display:flex; align-items:baseline; gap:12px; padding:12px 16px 6px; }
|
||||
.pix-cr-title { font-size:16px; font-weight:600; color:var(--pix-acc,#f66744); white-space:nowrap; }
|
||||
.pix-cr-folder { font-size:11px; color:#999; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; flex:1; }
|
||||
.pix-cr-count { font-size:12px; color:#bbb; white-space:nowrap; }
|
||||
.pix-cr-tools { flex-shrink:0; display:flex; flex-wrap:wrap; align-items:center; gap:8px; padding:6px 16px 10px; border-bottom:1px solid #333; }
|
||||
.pix-cr-find { width:220px; background:#141414; border:1px solid rgba(255,255,255,.16); border-radius:6px; color:#ddd; font-size:12px; padding:6px 8px; box-sizing:border-box; }
|
||||
.pix-cr-find:focus { outline:none; border-color:var(--pix-acc,#f66744); }
|
||||
.pix-cr-tog { display:flex; align-items:center; gap:6px; font-size:12px; color:#bbb; cursor:pointer; user-select:none; padding:4px 2px; }
|
||||
.pix-cr-tog .box { width:13px; height:13px; border:1px solid #555; border-radius:3px; box-sizing:border-box; }
|
||||
.pix-cr-tog.on .box { background:var(--pix-acc,#f66744); border-color:var(--pix-acc,#f66744); }
|
||||
.pix-cr-btn { box-sizing:border-box; min-width:86px; text-align:center; user-select:none; cursor:pointer; font-size:12px; padding:6px 14px; border-radius:6px; border:1px solid rgba(255,255,255,.16); background:rgba(255,255,255,.05); color:#ddd; }
|
||||
.pix-cr-btn:hover { border-color:var(--pix-acc,#f66744); }
|
||||
.pix-cr-btn.main { background:var(--pix-acc,#f66744); border-color:var(--pix-acc,#f66744); color:#fff; }
|
||||
.pix-cr-btn.main:hover { filter:brightness(1.08); }
|
||||
.pix-cr-btn.off { opacity:.45; pointer-events:none; }
|
||||
.pix-cr-spacer { flex:1; }
|
||||
.pix-cr-msg { flex-shrink:0; min-height:18px; padding:6px 16px 0; font-size:12px; color:#bbb; }
|
||||
.pix-cr-msg.bad { color:#ff8a7a; }
|
||||
.pix-cr-msg.good { color:#3ec371; }
|
||||
.pix-cr-ask { flex-shrink:0; display:none; align-items:center; gap:8px; margin:8px 16px 0; padding:8px 12px; border:1px solid var(--pix-acc,#f66744); border-radius:7px; background:rgba(255,255,255,.04); font-size:12px; }
|
||||
.pix-cr-ask.show { display:flex; }
|
||||
.pix-cr-body { flex:1; min-height:0; overflow:auto; padding:12px 16px 16px; }
|
||||
.pix-cr-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(250px,1fr)); gap:12px; }
|
||||
.pix-cr-card { display:flex; flex-direction:column; gap:6px; background:#222; border:1px solid #333; border-radius:8px; padding:8px; min-width:0; }
|
||||
.pix-cr-card.changed { border-color:var(--pix-acc,#f66744); }
|
||||
.pix-cr-pic { height:192px; display:flex; align-items:center; justify-content:center; background:#0f0f0f; border-radius:5px; overflow:hidden; flex-shrink:0; }
|
||||
.pix-cr-pic img { max-width:100%; max-height:100%; display:block; }
|
||||
.pix-cr-name { display:flex; align-items:center; gap:6px; font-size:11px; color:#ccc; min-width:0; flex-shrink:0; }
|
||||
.pix-cr-name .nm { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; flex:1; }
|
||||
.pix-cr-chip { flex-shrink:0; font-size:10px; padding:1px 6px; border-radius:9px; background:rgba(255,255,255,.08); color:#bbb; }
|
||||
.pix-cr-chip.miss { background:rgba(246,103,68,.18); color:#ffb39f; }
|
||||
.pix-cr-chip.chg { background:var(--pix-acc,#f66744); color:#fff; }
|
||||
.pix-cr-text { width:100%; min-height:150px; resize:vertical; box-sizing:border-box; background:#1d1d1d; color:#e0e0e0; border:1px solid #333; border-radius:5px; font:12px/1.45 'Segoe UI',-apple-system,BlinkMacSystemFont,sans-serif; padding:6px 7px; flex-shrink:0; }
|
||||
.pix-cr-text:focus { outline:none; border-color:var(--pix-acc,#f66744); }
|
||||
.pix-cr-text[readonly] { background:#2d2d2d; border-color:#3a3a3a; color:#d8d8d8; }
|
||||
.pix-cr-foot { font-size:10px; color:#888; flex-shrink:0; }
|
||||
.pix-cr-empty { padding:40px; text-align:center; color:#999; font-size:13px; grid-column:1/-1; }
|
||||
`;
|
||||
|
||||
function injectCSS() {
|
||||
if (document.getElementById("pix-cr-css")) return;
|
||||
const s = document.createElement("style");
|
||||
s.id = "pix-cr-css";
|
||||
s.textContent = CSS;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
const words = (s) => (String(s || "").trim() ? String(s).trim().split(/\s+/).length : 0);
|
||||
|
||||
function thumbURL(folder, rel, mtime) {
|
||||
return pixApiUrl(`/pixaroma/api/load_images_folder/thumb?path=${encodeURIComponent(folder)}&file=${encodeURIComponent(rel)}&mt=${Math.floor(mtime || 0)}`);
|
||||
}
|
||||
|
||||
async function listFolder(folder) {
|
||||
try {
|
||||
const r = await fetch(pixApiUrl(`/pixaroma/api/caption_review/list?path=${encodeURIComponent(folder || "")}`), { cache: "no-store" });
|
||||
return await r.json();
|
||||
} catch (e) {
|
||||
return { ok: false, message: String(e), items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCaption(folder, stem, text) {
|
||||
try {
|
||||
const r = await fetch(pixApiUrl("/pixaroma/api/save_text/write"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ folder, name: stem, content: text, claim: false, digits: 3 }),
|
||||
});
|
||||
return await r.json();
|
||||
} catch (e) {
|
||||
return { ok: false, message: "Could not reach the server to save." };
|
||||
}
|
||||
}
|
||||
|
||||
// Opens the window for `node`; `folder` is the node's folder field. Only one at a time.
|
||||
export function openCaptionReview(node, folder) {
|
||||
if (node._pixCrWindow?.isConnected) return;
|
||||
injectCSS();
|
||||
const ov = document.createElement("div");
|
||||
ov.className = "pix-cr-overlay";
|
||||
ov.innerHTML =
|
||||
`<div class="pix-cr-win" role="dialog" aria-label="Caption Review">` +
|
||||
`<div class="pix-cr-head"><div class="pix-cr-title">Caption Review</div><div class="pix-cr-folder"></div><div class="pix-cr-count"></div></div>` +
|
||||
`<div class="pix-cr-tools">` +
|
||||
`<input class="pix-cr-find" type="text" spellcheck="false" placeholder="Find a word in the captions" title="Show only the pictures whose caption or name contains this">` +
|
||||
`<div class="pix-cr-tog" data-tog="missing" title="Show only the pictures that have no caption yet"><span class="box"></span> Only without a caption</div>` +
|
||||
`<div class="pix-cr-tog" data-tog="changed" title="Show only the captions you changed and did not save yet"><span class="box"></span> Only changed</div>` +
|
||||
`<div class="pix-cr-spacer"></div>` +
|
||||
`<div class="pix-cr-btn" data-act="reload" title="Read the folder again">Reload</div>` +
|
||||
`<div class="pix-cr-btn main off" data-act="save" title="Write every changed caption to its .txt file (Ctrl+S)">Save changes</div>` +
|
||||
`<div class="pix-cr-btn" data-act="close" title="Close the window (Esc)">Close</div>` +
|
||||
`</div>` +
|
||||
`<div class="pix-cr-ask"><span class="q"></span><div class="pix-cr-spacer"></div>` +
|
||||
`<div class="pix-cr-btn main" data-ask="save">Save and close</div>` +
|
||||
`<div class="pix-cr-btn" data-ask="discard">Close without saving</div>` +
|
||||
`<div class="pix-cr-btn" data-ask="stay">Keep editing</div></div>` +
|
||||
`<div class="pix-cr-msg"></div>` +
|
||||
`<div class="pix-cr-body"><div class="pix-cr-grid"><div class="pix-cr-empty">Reading the folder...</div></div></div>` +
|
||||
`</div>`;
|
||||
document.body.appendChild(ov);
|
||||
node._pixCrWindow = ov;
|
||||
|
||||
const q = (s) => ov.querySelector(s);
|
||||
const grid = q(".pix-cr-grid");
|
||||
const msg = q(".pix-cr-msg");
|
||||
const saveBtn = q('[data-act="save"]');
|
||||
const find = q(".pix-cr-find");
|
||||
const ask = q(".pix-cr-ask");
|
||||
const view = { missing: false, changed: false, text: "" };
|
||||
let data = { folder: "", items: [] };
|
||||
let saving = false;
|
||||
|
||||
const undoOff = installGraphUndoGuard(() => ov.isConnected);
|
||||
|
||||
function say(text, kind) {
|
||||
msg.textContent = text || "";
|
||||
msg.className = "pix-cr-msg" + (kind ? " " + kind : "");
|
||||
}
|
||||
const changedItems = () => data.items.filter((it) => !it.read_only && it.value !== it.caption);
|
||||
function syncCounts() {
|
||||
const n = data.items.length;
|
||||
const withCap = data.items.filter((it) => it.has_caption).length;
|
||||
const ch = changedItems().length;
|
||||
q(".pix-cr-count").textContent = n ? `${n} pictures · ${withCap} with a caption · ${n - withCap} without` : "";
|
||||
saveBtn.textContent = ch ? `Save changes (${ch})` : "Save changes";
|
||||
saveBtn.classList.toggle("off", !ch || saving);
|
||||
}
|
||||
function visible(it) {
|
||||
if (view.missing && it.has_caption) return false;
|
||||
if (view.changed && it.value === it.caption) return false;
|
||||
const t = view.text.trim().toLowerCase();
|
||||
if (t && !(it.value.toLowerCase().includes(t) || it.name.toLowerCase().includes(t))) return false;
|
||||
return true;
|
||||
}
|
||||
function chipFor(it) {
|
||||
if (it.read_only) {
|
||||
const why = it.too_big
|
||||
? "This caption file is too big to edit here. Open the .txt file itself."
|
||||
: it.same_name
|
||||
? "Another picture in this folder has the same name (like cat.png and cat.jpg), so both would share one caption file. Rename one of them."
|
||||
: "This name has characters a saved file name cannot keep (such as a _ at the start), so it would be saved under another name. Rename the picture, or edit its .txt file directly.";
|
||||
return `<span class="pix-cr-chip" title="${esc(why)}">read-only</span>`;
|
||||
}
|
||||
if (it.value !== it.caption) return `<span class="pix-cr-chip chg">changed</span>`;
|
||||
if (!it.has_caption) return `<span class="pix-cr-chip miss">no caption</span>`;
|
||||
return `<span class="pix-cr-chip">${esc(it.caption_file)}</span>`;
|
||||
}
|
||||
function renderCard(it) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "pix-cr-card" + (it.value !== it.caption ? " changed" : "");
|
||||
card.innerHTML =
|
||||
`<div class="pix-cr-pic"><img loading="lazy" alt="" src="${thumbURL(data.folder, it.file, it.mtime)}" onerror="this.style.display='none'"></div>` +
|
||||
`<div class="pix-cr-name"><span class="nm" title="${esc(it.name)}">${esc(it.name)}</span>${chipFor(it)}</div>` +
|
||||
`<textarea class="pix-cr-text" spellcheck="true" placeholder="${it.read_only ? "" : "No caption yet: type one and Save"}"></textarea>` +
|
||||
`<div class="pix-cr-foot"></div>`;
|
||||
const ta = card.querySelector("textarea");
|
||||
const foot = card.querySelector(".pix-cr-foot");
|
||||
ta.value = it.value;
|
||||
ta.readOnly = !!it.read_only;
|
||||
const syncFoot = () => { foot.textContent = `${words(ta.value)} words`; };
|
||||
syncFoot();
|
||||
ta.addEventListener("input", () => {
|
||||
it.value = ta.value;
|
||||
card.classList.toggle("changed", it.value !== it.caption);
|
||||
card.querySelector(".pix-cr-name").lastElementChild.outerHTML = chipFor(it);
|
||||
syncFoot();
|
||||
syncCounts();
|
||||
});
|
||||
return card;
|
||||
}
|
||||
function render() {
|
||||
grid.innerHTML = "";
|
||||
const shown = data.items.filter(visible);
|
||||
if (!data.items.length) {
|
||||
grid.innerHTML = `<div class="pix-cr-empty">No pictures in this folder.</div>`;
|
||||
} else if (!shown.length) {
|
||||
grid.innerHTML = `<div class="pix-cr-empty">No picture matches.</div>`;
|
||||
} else {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const it of shown) frag.appendChild(renderCard(it));
|
||||
grid.appendChild(frag);
|
||||
}
|
||||
syncCounts();
|
||||
}
|
||||
async function load() {
|
||||
say("");
|
||||
grid.innerHTML = `<div class="pix-cr-empty">Reading the folder...</div>`;
|
||||
const r = await listFolder(folder);
|
||||
if (!ov.isConnected) return;
|
||||
if (!r?.ok) {
|
||||
data = { folder: "", items: [] };
|
||||
grid.innerHTML = `<div class="pix-cr-empty">${esc(r?.message || "Could not read the folder.")}</div>`;
|
||||
q(".pix-cr-folder").textContent = folder || "ComfyUI output";
|
||||
syncCounts();
|
||||
return;
|
||||
}
|
||||
data = { folder: r.folder, items: (r.items || []).map((it) => ({ ...it, value: it.caption })) };
|
||||
q(".pix-cr-folder").textContent = r.folder;
|
||||
q(".pix-cr-folder").title = r.folder;
|
||||
const notes = [];
|
||||
if (r.truncated) notes.push("Only the first 2000 pictures are shown.");
|
||||
if (r.orphans) notes.push(`${r.orphans} caption file(s) have no picture with the same name.`);
|
||||
if (notes.length) say(notes.join(" "));
|
||||
render();
|
||||
}
|
||||
|
||||
// ONE save at a time, and a second caller gets the RUNNING save's promise, not
|
||||
// an early "done": "Save and close" pressed while Ctrl+S was still writing
|
||||
// closed the window after the first file and lost the rest (review round 1,
|
||||
// reproduced: 1 of 3 written). Edits made during a save are picked up by the
|
||||
// caller asking again (see the ask bar's Save and close).
|
||||
let savingP = null;
|
||||
function saveAll() {
|
||||
if (!savingP) savingP = saveOnce().finally(() => { savingP = null; });
|
||||
return savingP;
|
||||
}
|
||||
async function saveOnce() {
|
||||
const todo = changedItems();
|
||||
if (!todo.length) return true;
|
||||
saving = true;
|
||||
syncCounts();
|
||||
let done = 0;
|
||||
const failed = [];
|
||||
for (const it of todo) {
|
||||
say(`Saving ${done + 1} of ${todo.length}...`);
|
||||
const text = it.value;
|
||||
const res = await writeCaption(data.folder, it.save_stem, text);
|
||||
if (!ov.isConnected) return false;
|
||||
const want = it.save_stem + ".txt";
|
||||
if (res?.ok && (res.file || "") === want) {
|
||||
it.caption = text;
|
||||
it.has_caption = true;
|
||||
it.caption_file = want;
|
||||
done++;
|
||||
} else if (res?.ok) {
|
||||
// Saved, but under another name: tell, and keep the card marked as changed.
|
||||
failed.push(`${it.name}: saved as ${res.file} instead of ${want}`);
|
||||
} else {
|
||||
failed.push(`${it.name}: ${res?.message || "could not save"}`);
|
||||
}
|
||||
}
|
||||
saving = false;
|
||||
render();
|
||||
if (failed.length) {
|
||||
say(`${done} saved, ${failed.length} not: ${failed.slice(0, 3).join("; ")}${failed.length > 3 ? " ..." : ""}`, "bad");
|
||||
return false;
|
||||
}
|
||||
say(`✓ ${done} caption${done === 1 ? "" : "s"} saved`, "good");
|
||||
return true;
|
||||
}
|
||||
|
||||
function close(force) {
|
||||
const ch = changedItems().length;
|
||||
if (!force && ch) {
|
||||
ask.querySelector(".q").textContent = `${ch} caption${ch === 1 ? " is" : "s are"} not saved.`;
|
||||
ask.classList.add("show");
|
||||
return;
|
||||
}
|
||||
window.removeEventListener("keydown", onKey, true);
|
||||
try { undoOff(); } catch { /* already released */ }
|
||||
ov.remove();
|
||||
if (node._pixCrWindow === ov) node._pixCrWindow = null;
|
||||
}
|
||||
node._pixCrClose = () => close(true);
|
||||
|
||||
function onKey(e) {
|
||||
if (!ov.isConnected) return;
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
if (ask.classList.contains("show")) ask.classList.remove("show");
|
||||
else close(false);
|
||||
} else if ((e.ctrlKey || e.metaKey) && (e.key === "s" || e.key === "S")) {
|
||||
// Ctrl+S here saves the captions, never the workflow underneath.
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
saveAll();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
|
||||
find.addEventListener("input", () => { view.text = find.value; render(); });
|
||||
ov.querySelectorAll(".pix-cr-tog").forEach((el) => el.addEventListener("click", () => {
|
||||
const k = el.dataset.tog;
|
||||
view[k] = !view[k];
|
||||
el.classList.toggle("on", view[k]);
|
||||
render();
|
||||
}));
|
||||
q('[data-act="reload"]').addEventListener("click", () => {
|
||||
if (changedItems().length) { say("Save or undo your changes before reloading.", "bad"); return; }
|
||||
load();
|
||||
});
|
||||
saveBtn.addEventListener("click", () => saveAll());
|
||||
q('[data-act="close"]').addEventListener("click", () => close(false));
|
||||
ask.querySelector('[data-ask="save"]').addEventListener("click", async () => {
|
||||
ask.classList.remove("show");
|
||||
let ok = await saveAll(); // joins a save already running
|
||||
if (ok && changedItems().length) ok = await saveAll(); // edits made while it ran
|
||||
if (!ov.isConnected) return;
|
||||
if (ok && !changedItems().length) close(true);
|
||||
});
|
||||
ask.querySelector('[data-ask="discard"]').addEventListener("click", () => close(true));
|
||||
ask.querySelector('[data-ask="stay"]').addEventListener("click", () => ask.classList.remove("show"));
|
||||
// a click on the dark backdrop (outside the window) is a Close
|
||||
ov.addEventListener("mousedown", (e) => { if (e.target === ov) close(false); });
|
||||
|
||||
load();
|
||||
}
|
||||
@@ -1,19 +1,6 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { pixApiUrl } from "../shared/api_url.mjs";
|
||||
import { BRAND, registerNodeHelp,
|
||||
installCanvasZoomPassthrough, registerNodeAccent, accentOf, removeNodeWidget,
|
||||
} from "../shared/index.mjs";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { BRAND, registerNodeHelp } from "../shared/index.mjs";
|
||||
import { applyAdaptiveCanvasOnly, isVueNodes } from "../shared/nodes2.mjs";
|
||||
import { onRendererChange } from "../shared/renderer_switch.mjs";
|
||||
import { isLiveNode } from "../shared/live_node.mjs";
|
||||
import { attachCanvasSnapshot } from "../shared/canvas_snapshot.mjs";
|
||||
import { runTab, eachRestoredOutput } from "../shared/run_tab.mjs";
|
||||
|
||||
// The accent of the node currently being painted. A canvas cannot read a CSS
|
||||
// variable, and the small paint helpers below take no node argument, so
|
||||
// paintCompare sets this once per pass and they all read it. Painting is
|
||||
// synchronous per node, so it can never be read for the wrong node.
|
||||
let _acc = BRAND;
|
||||
|
||||
// Help shown by the selection-toolbar Help button (js/help_toolbar) when an
|
||||
// Image Compare node is selected. Registered at module load.
|
||||
@@ -25,17 +12,13 @@ const COMPARE_HELP = {
|
||||
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: "Missing an image is fine",
|
||||
body: "Both inputs are optional. If only one is connected - for example when a branch feeding one input is muted or bypassed in a workflow that toggles between text-to-image and image-to-image - the node simply shows that one image. If neither is connected it shows a short note. It never throws a system error, so you can leave it in the graph and just ignore it when it has nothing to compare.",
|
||||
},
|
||||
{
|
||||
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, or scroll the mouse wheel over the image, to fade between them."],
|
||||
["Overlay", "Stack both images and drag the slider to fade between them."],
|
||||
["Difference", "Highlight only the pixels that differ between the two."],
|
||||
],
|
||||
},
|
||||
@@ -48,7 +31,7 @@ const COMPARE_HELP = {
|
||||
],
|
||||
},
|
||||
],
|
||||
footer: "Set the view mode a new Compare node starts in from this node's own settings, opened with the gear button on the node toolbar, or by right-clicking the node.",
|
||||
footer: "Set the default view mode in Settings -> Pixaroma -> Image Compare.",
|
||||
};
|
||||
registerNodeHelp("PixaromaCompare", COMPARE_HELP);
|
||||
// Buttons: Show 1 | Left Right | Up Down | Overlay | Difference
|
||||
@@ -208,7 +191,7 @@ 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 or scroll over the image to fade between them.",
|
||||
"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) {
|
||||
@@ -272,11 +255,11 @@ function cmpHideTooltip() {
|
||||
}
|
||||
|
||||
function paintBtn(ctx, r, label, on, hovered) {
|
||||
ctx.fillStyle = on ? _acc : "#2a2c2e";
|
||||
// Hover on a non-active bordered control: border -> accent + text brightens,
|
||||
ctx.fillStyle = on ? BRAND : "#2a2c2e";
|
||||
// Hover on a non-active bordered control: border -> BRAND + text brightens,
|
||||
// no fill change (the Pixaroma node UI convention, CLAUDE.md #13). Active
|
||||
// buttons keep the solid accent fill.
|
||||
ctx.strokeStyle = on ? _acc : (hovered ? _acc : "#444");
|
||||
// buttons keep the solid BRAND fill.
|
||||
ctx.strokeStyle = on ? BRAND : (hovered ? BRAND : "#444");
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
if (ctx.roundRect) ctx.roundRect(r.x, r.y, r.w, r.h, 3);
|
||||
@@ -292,13 +275,13 @@ function paintBtn(ctx, r, label, on, hovered) {
|
||||
|
||||
// Like paintBtn but for the row-2 utility buttons (Save / Disk / Copy), with a
|
||||
// 700ms green "Saved" / "Copied" flash after a successful action. Hover lights
|
||||
// the border accent + brightens the text (Pixaroma node UI convention #13).
|
||||
// the border BRAND + brightens the text (Pixaroma node UI convention #13).
|
||||
function paintUtilBtn(ctx, r, label, hover, flash) {
|
||||
ctx.save();
|
||||
// Hover = border accent + brighter text, NO fill (same as the mode buttons /
|
||||
// Hover = border BRAND + brighter text, NO fill (same as the mode buttons /
|
||||
// Pixaroma convention #13). Fill is reserved for state: green flash = success.
|
||||
ctx.fillStyle = flash ? "#3ec371" : "#2a2c2e";
|
||||
ctx.strokeStyle = flash ? "#3ec371" : (hover ? _acc : "#444");
|
||||
ctx.strokeStyle = flash ? "#3ec371" : (hover ? BRAND : "#444");
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
if (ctx.roundRect) ctx.roundRect(r.x, r.y, r.w, r.h, 3);
|
||||
@@ -337,12 +320,12 @@ function fillTextVCenter(ctx, text, x, yMid) {
|
||||
}
|
||||
}
|
||||
|
||||
// A small filled accent-orange circle with a white number — the image1 / image2
|
||||
// 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 = _acc;
|
||||
ctx.fillStyle = BRAND;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, BADGE_R, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
@@ -365,7 +348,7 @@ function drawSizeLabels(ctx, node, W) {
|
||||
const full = row2Full(W);
|
||||
const rightEdge = full.x + full.w;
|
||||
const yMid = ROW2_Y + BTN_H / 2;
|
||||
const sizeCol = sizesDiffer(node) ? _acc : "#cfcfcf";
|
||||
const sizeCol = sizesDiffer(node) ? BRAND : "#cfcfcf";
|
||||
if (has1) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
@@ -419,19 +402,13 @@ function cmpRepaint(node) {
|
||||
}
|
||||
|
||||
function buildCmpUrl(d) {
|
||||
return pixApiUrl(`/view?filename=${encodeURIComponent(d.filename)}&type=${encodeURIComponent(d.type)}&subfolder=${encodeURIComponent(d.subfolder || "")}&t=${Date.now()}`);
|
||||
return `/view?filename=${encodeURIComponent(d.filename)}&type=${encodeURIComponent(d.type)}&subfolder=${encodeURIComponent(d.subfolder || "")}&t=${Date.now()}`;
|
||||
}
|
||||
|
||||
function loadCmpImage(node, meta, idx) {
|
||||
// Only the LAST request per side may land: a restore from properties and a
|
||||
// newer result can both be loading after a tab switch, and an older picture
|
||||
// finishing second would cover the newer one.
|
||||
const seq = (node._cmpLoadSeq ??= [0, 0]);
|
||||
const mine = ++seq[idx];
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
if (seq[idx] !== mine) return;
|
||||
if (idx === 0) node._cmpImg1 = img;
|
||||
else node._cmpImg2 = img;
|
||||
node.imgs = null;
|
||||
@@ -514,7 +491,7 @@ async function saveShownToOutput(node) {
|
||||
if (!blob) { cmpToast("Run the workflow first"); return; }
|
||||
const image_b64 = await blobToDataURL(blob);
|
||||
const { workflow, output } = await app.graphToPrompt();
|
||||
const resp = await fetch(pixApiUrl("/pixaroma/api/preview/save"), {
|
||||
const resp = await fetch("/pixaroma/api/preview/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_b64, filename_prefix: "Compare", workflow, prompt: output }),
|
||||
@@ -539,7 +516,7 @@ async function saveShownToDisk(node) {
|
||||
if (!blob) { cmpToast("Run the workflow first"); return; }
|
||||
const image_b64 = await blobToDataURL(blob);
|
||||
const { workflow, output } = await app.graphToPrompt();
|
||||
const resp = await fetch(pixApiUrl("/pixaroma/api/preview/prepare"), {
|
||||
const resp = await fetch("/pixaroma/api/preview/prepare", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ image_b64, filename_prefix: "Compare", workflow, prompt: output }),
|
||||
@@ -595,92 +572,34 @@ function saveCompareState(node) {
|
||||
};
|
||||
}
|
||||
|
||||
function saveCompareImagesToProps(node, d1, d2) {
|
||||
function saveCompareImagesToProps(node, outputImages) {
|
||||
node.properties = node.properties || {};
|
||||
const clean = (d) => (d
|
||||
? { filename: d.filename, subfolder: d.subfolder || "", type: d.type || "temp" }
|
||||
: null);
|
||||
// Fixed 2-slot array [side1, side2]; either can be null when only one image
|
||||
// is connected, so the missing side restores empty (not a stale image).
|
||||
node.properties[STATE_KEY] = {
|
||||
mode: node._cmpMode ?? 0,
|
||||
showWhich: node._cmpShowWhich ?? 0,
|
||||
opacity: node._cmpOpacity ?? 0.5,
|
||||
images: [clean(d1), clean(d2)],
|
||||
images: outputImages.slice(0, 2).map((d) => ({
|
||||
filename: d.filename,
|
||||
subfolder: d.subfolder || "",
|
||||
type: d.type || "temp",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function restoreCompareViewState(node) {
|
||||
const s = node.properties?.[STATE_KEY];
|
||||
if (!s) return;
|
||||
if (typeof s.mode === "number") node._cmpMode = s.mode;
|
||||
if (typeof s.showWhich === "number") node._cmpShowWhich = s.showWhich;
|
||||
if (typeof s.opacity === "number") node._cmpOpacity = s.opacity;
|
||||
}
|
||||
|
||||
function restoreCompareFromProperties(node) {
|
||||
if (node._cmpImg1 || node._cmpImg2) return; // idempotent
|
||||
const s = node.properties?.[STATE_KEY];
|
||||
if (!s) return;
|
||||
restoreCompareViewState(node);
|
||||
// images is a [side1, side2] array; either entry may be null (only one image
|
||||
// was connected). Load whichever is present. Older saves stored two non-null
|
||||
// entries, which still work here.
|
||||
if (Array.isArray(s.images)) {
|
||||
if (s.images[0]) loadCmpImage(node, s.images[0], 0);
|
||||
if (s.images[1]) loadCmpImage(node, s.images[1], 1);
|
||||
if (typeof s.mode === "number") node._cmpMode = s.mode;
|
||||
if (typeof s.showWhich === "number") node._cmpShowWhich = s.showWhich;
|
||||
if (typeof s.opacity === "number") node._cmpOpacity = s.opacity;
|
||||
if (Array.isArray(s.images) && s.images.length === 2) {
|
||||
loadCmpImage(node, s.images[0], 0);
|
||||
loadCmpImage(node, s.images[1], 1);
|
||||
}
|
||||
cmpRepaint(node);
|
||||
}
|
||||
|
||||
// Both inputs are optional, so Python skips a missing one and tags each saved
|
||||
// image with its slot (1/2). Map by slot so a lone image2 lands on side 2, not
|
||||
// side 1. Fall back to index order for an untagged payload.
|
||||
function compareSidesOf(output) {
|
||||
const imgs = Array.isArray(output?.images) ? output.images : [];
|
||||
let d1 = null, d2 = null;
|
||||
for (const d of imgs) {
|
||||
if (d?.slot === 2) d2 = d;
|
||||
else if (d?.slot === 1) d1 = d;
|
||||
}
|
||||
if (d1 == null && d2 == null && imgs.length) {
|
||||
d1 = imgs[0] || null;
|
||||
d2 = imgs[1] || null;
|
||||
}
|
||||
return [d1, d2];
|
||||
}
|
||||
|
||||
function applyCompareOutput(node, output) {
|
||||
const [d1, d2] = compareSidesOf(output);
|
||||
// Persist BEFORE loading so a Vue tab switch right after execution
|
||||
// restores correctly.
|
||||
saveCompareImagesToProps(node, d1, d2);
|
||||
// Load present slots; CLEAR absent ones so a stale image can't linger
|
||||
// (e.g. toggling to text2img mutes image1 - side 1 must go empty, not
|
||||
// keep showing the previous run's image). Clearing also outdates a load
|
||||
// still in flight for that side.
|
||||
const seq = (node._cmpLoadSeq ??= [0, 0]);
|
||||
if (d1) loadCmpImage(node, d1, 0); else { seq[0]++; node._cmpImg1 = null; }
|
||||
if (d2) loadCmpImage(node, d2, 1); else { seq[1]++; node._cmpImg2 = null; }
|
||||
cmpRepaint(node);
|
||||
}
|
||||
|
||||
// Restored results (core's onNodeOutputsUpdated, js/shared/run_tab.mjs): a pair
|
||||
// this node does not show yet is a Run it missed; the same pair (every plain
|
||||
// tab switch) changes nothing. The node was just rebuilt by the switch, so its
|
||||
// view (mode, side, opacity) is read back from properties before saving.
|
||||
function restoreCompareOutputs(nodeOutputs) {
|
||||
eachRestoredOutput(nodeOutputs, "PixaromaCompare", (node, output) => {
|
||||
if (!Array.isArray(output.images) || !output.images.length) return;
|
||||
const sides = compareSidesOf(output).map((d) => d?.filename ?? null);
|
||||
const saved = node.properties?.[STATE_KEY]?.images;
|
||||
if (Array.isArray(saved) && saved.length === 2
|
||||
&& saved.every((d, i) => (d?.filename ?? null) === sides[i])) return;
|
||||
restoreCompareViewState(node);
|
||||
applyCompareOutput(node, output);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Body painter (renderer-agnostic) ─────────────────────────
|
||||
// Draws the WHOLE Compare body (buttons + slider/hint + copy + image area)
|
||||
// into `ctx` at origin (0,0) using the supplied width/height. Legacy passes
|
||||
@@ -688,7 +607,6 @@ function restoreCompareOutputs(nodeOutputs) {
|
||||
// Nodes 2.0 DOM canvas passes its CSS box + the last DOM pointer position.
|
||||
// `mouse` = {x, y} in the SAME local coords as the draw, or null (no hover).
|
||||
function paintCompare(ctx, node, W, H, mouse) {
|
||||
_acc = accentOf(node); // every paint helper below reads this
|
||||
// ── Row 1: Show toggle + mode buttons ──
|
||||
ctx.save();
|
||||
const showLabel = node._cmpShowWhich === 1 ? "Show 1" : node._cmpShowWhich === 2 ? "Show 2" : "Show 1";
|
||||
@@ -727,7 +645,7 @@ function paintCompare(ctx, node, W, H, mouse) {
|
||||
ctx.fill();
|
||||
|
||||
// Track fill
|
||||
ctx.fillStyle = _acc;
|
||||
ctx.fillStyle = BRAND;
|
||||
ctx.beginPath();
|
||||
if (ctx.roundRect)
|
||||
ctx.roundRect(trackX, trackY, Math.max(0, trackW * pct), trackH, 3);
|
||||
@@ -735,7 +653,7 @@ function paintCompare(ctx, node, W, H, mouse) {
|
||||
ctx.fill();
|
||||
|
||||
// Thumb
|
||||
ctx.fillStyle = _acc;
|
||||
ctx.fillStyle = BRAND;
|
||||
ctx.beginPath();
|
||||
ctx.arc(thumbX, mid.y + mid.h / 2, 6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
@@ -1033,13 +951,7 @@ function createCompareDOMWidget(node) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;display:block;";
|
||||
root.appendChild(canvas);
|
||||
// A canvas on screen costs the GPU work on every frame the page draws, which
|
||||
// made renders slower (three Compares: +3-4%); while nothing is being drawn
|
||||
// it shows a picture of itself instead (js/shared/canvas_snapshot.mjs).
|
||||
const snap = attachCanvasSnapshot(canvas);
|
||||
node._cmpSnap = snap;
|
||||
|
||||
installCanvasZoomPassthrough(root);
|
||||
const widget = node.addDOMWidget("pixaroma_compare", "pixaroma_compare", root, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
@@ -1055,8 +967,6 @@ function createCompareDOMWidget(node) {
|
||||
// floor is safe. This matches the proven Preview Image strip widget.
|
||||
widget.computeLayoutSize = () => ({ minHeight: MIN_H, minWidth: 1 });
|
||||
applyAdaptiveCanvasOnly(widget);
|
||||
node._cmpDomWidget = widget;
|
||||
node._cmpDomRoot = root;
|
||||
|
||||
// Effective backing-store scale: device pixels per LAYOUT pixel. The Nodes 2.0
|
||||
// node is CSS-transform-scaled by the graph zoom (app.canvas.ds.scale), so a
|
||||
@@ -1097,7 +1007,6 @@ function createCompareDOMWidget(node) {
|
||||
node._cmpDomW = cssW;
|
||||
node._cmpDomH = cssH;
|
||||
paintCompare(ctx, node, cssW, cssH + TOP_TRIM, node._cmpDomMouse || null);
|
||||
snap.changed();
|
||||
};
|
||||
node._cmpDomRender = render;
|
||||
|
||||
@@ -1163,11 +1072,16 @@ function createCompareDOMWidget(node) {
|
||||
cmpLeave(node);
|
||||
render();
|
||||
});
|
||||
// The Overlay opacity wheel is handled by installCompareWheel (core takes the
|
||||
// wheel before a listener here would ever see it); it finds this node and its
|
||||
// drawing's y through these two.
|
||||
root._cmpNode = node;
|
||||
root._cmpWheelY = (e) => localPos(e)[1];
|
||||
root.addEventListener("wheel", (e) => {
|
||||
const [, ly] = localPos(e);
|
||||
if (cmpWheel(node, ly, e.deltaY)) {
|
||||
// Only swallow the wheel when we consumed it (Overlay opacity); otherwise
|
||||
// let it bubble so the graph still zooms.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
render();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
const ro = new ResizeObserver(() => render());
|
||||
ro.observe(root);
|
||||
@@ -1176,100 +1090,19 @@ function createCompareDOMWidget(node) {
|
||||
return widget;
|
||||
}
|
||||
|
||||
// The Overlay opacity wheel, in both looks. Neither renderer hands a Compare the
|
||||
// wheel any more: LiteGraph's wheel handler no longer calls a node's
|
||||
// onMouseWheel (none left in frontend 1.52.7), and in Nodes 2.0 core forwards
|
||||
// every wheel over a node to the canvas in the CAPTURE phase, before the body's
|
||||
// own listener runs. Both looks zoomed the graph instead and the opacity never
|
||||
// moved (measured 2026-10-03, cmp_wheel_probe.js). So one page-level capture
|
||||
// listener finds the Compare under the pointer itself and swallows the wheel
|
||||
// ONLY when it changed the opacity; everywhere else the graph zooms as before.
|
||||
let _cmpWheelInstalled = false;
|
||||
function installCompareWheel() {
|
||||
if (_cmpWheelInstalled) return;
|
||||
_cmpWheelInstalled = true;
|
||||
document.addEventListener("wheel", (e) => {
|
||||
// A trackpad pinch arrives as Ctrl+wheel and a sideways swipe has no deltaY:
|
||||
// both belong to the canvas.
|
||||
if (e.ctrlKey || e.metaKey || !e.deltaY) return;
|
||||
let node = null;
|
||||
let ly = 0;
|
||||
if (isVueNodes()) {
|
||||
const root = e.target?.closest?.(".pix-cmp-root");
|
||||
node = root?._cmpNode;
|
||||
if (!node || !root._cmpWheelY) return;
|
||||
ly = root._cmpWheelY(e);
|
||||
} else {
|
||||
const c = app.canvas;
|
||||
if (!c?.canvas || e.target !== c.canvas) return;
|
||||
const [gx, gy] = c.convertEventToCanvasOffset(e);
|
||||
node = c.graph?.getNodeOnPos?.(gx, gy, c.visible_nodes);
|
||||
if (!node || node.comfyClass !== "PixaromaCompare" || node.flags?.collapsed) return;
|
||||
ly = gy - node.pos[1];
|
||||
}
|
||||
if (!cmpWheel(node, ly, e.deltaY)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cmpRepaint(node);
|
||||
}, { capture: true, passive: false });
|
||||
}
|
||||
|
||||
// Remove the Nodes 2.0 body again (a live switch back to the classic look).
|
||||
function teardownCompareDOMWidget(node) {
|
||||
try { node._cmpDomRO?.disconnect(); } catch {}
|
||||
node._cmpDomRO = null;
|
||||
try { cancelAnimationFrame(node._cmpZoomRaf); } catch {}
|
||||
node._cmpZoomRaf = null;
|
||||
try { node._cmpSnap?.dispose(); } catch {}
|
||||
node._cmpSnap = null;
|
||||
const w = node._cmpDomWidget || (node.widgets || []).find((x) => x && x.name === "pixaroma_compare");
|
||||
// The widget's OWN onRemove must run (monitor.md #8): ComfyUI also keeps DOM
|
||||
// widgets in a store of its own and re-mounts everything in it, so splicing
|
||||
// the widget out and removing the element is not enough - the face comes back.
|
||||
// removeNodeWidget = node.removeWidget: onRemove + splice + the store entry.
|
||||
removeNodeWidget(node, w);
|
||||
try {
|
||||
node._cmpDomRoot?.closest?.(".dom-widget")?.remove();
|
||||
node._cmpDomRoot?.remove();
|
||||
} catch {}
|
||||
if (node._cmpDomRoot) {
|
||||
node._cmpDomRoot._cmpNode = null;
|
||||
node._cmpDomRoot._cmpWheelY = null;
|
||||
}
|
||||
node._cmpDomWidget = null;
|
||||
node._cmpDomRoot = null;
|
||||
node._cmpDomRender = null;
|
||||
node._cmpDomW = null;
|
||||
node._cmpDomH = null;
|
||||
node._cmpDomMouse = null;
|
||||
cmpHideTooltip();
|
||||
}
|
||||
|
||||
// The body the CURRENT renderer needs. The choice in onNodeCreated is made once,
|
||||
// but "Nodes 2.0" can be flipped with the page open: without this a Compare made
|
||||
// in the classic look had NO body at all in Nodes 2.0, and one made in Nodes 2.0
|
||||
// kept its Nodes 2.0 face on screen on top of the classic painting (measured
|
||||
// 2026-09-26, flip_audit_lib.js). The classic body needs nothing built - it is
|
||||
// painted by onDrawForeground, which only runs in the classic renderer.
|
||||
function applyCompareRenderer(node, vue) {
|
||||
if (vue) {
|
||||
if (!node._cmpDomRender) createCompareDOMWidget(node);
|
||||
} else {
|
||||
teardownCompareDOMWidget(node);
|
||||
}
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Pixaroma.Compare",
|
||||
// A Run that ended while another workflow tab was open (js/shared/run_tab.mjs).
|
||||
onNodeOutputsUpdated(nodeOutputs) {
|
||||
restoreCompareOutputs(nodeOutputs);
|
||||
},
|
||||
// No Settings-panel rows: this node's options live on the node itself (the
|
||||
// gear in the selection toolbar / the right-click entry). The setting ids are
|
||||
// unchanged and merely unregistered, so existing choices carry over; every
|
||||
// read site already supplies its own default for the unset case.
|
||||
settings: [
|
||||
{
|
||||
id: SETTING_DEFAULT_MODE,
|
||||
name: "Default Compare Mode",
|
||||
type: "combo",
|
||||
defaultValue: "Show 2",
|
||||
options: DEFAULT_MODE_OPTIONS,
|
||||
tooltip: "The initial view mode when a new Compare node is created",
|
||||
category: ["👑 Pixaroma", "Image Compare"],
|
||||
},
|
||||
],
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "PixaromaCompare") return;
|
||||
|
||||
@@ -1309,18 +1142,12 @@ app.registerExtension({
|
||||
// gated on node.imgs (which onExecuted/onDrawBackground null out).
|
||||
this.hideOutputImages = true;
|
||||
|
||||
// Per-renderer split: Nodes 2.0 gets a DOM-widget canvas that reuses
|
||||
// paintCompare/cmp*; legacy keeps the onDrawForeground + mouse-hook canvas
|
||||
// painting below. Only one path is live per instance, and a live switch of
|
||||
// the renderer rebuilds it (applyCompareRenderer). A copy that is never
|
||||
// added to a graph, or a node left inside a subgraph of a closed workflow,
|
||||
// is skipped (js/shared/live_node.mjs).
|
||||
// Per-renderer split (fixed per page load): Nodes 2.0 gets a DOM-widget
|
||||
// canvas that reuses paintCompare/cmp*; legacy keeps the onDrawForeground
|
||||
// + mouse-hook canvas painting below. Only one path is live per instance.
|
||||
if (isVueNodes()) {
|
||||
createCompareDOMWidget(this);
|
||||
}
|
||||
this._cmpRendererOff = onRendererChange((vue) => {
|
||||
if (isLiveNode(this)) applyCompareRenderer(this, vue);
|
||||
});
|
||||
|
||||
// Restore view state + image refs from properties AFTER configure()
|
||||
// runs (Vue Compat #8 — nodeCreated fires before configure, so defer
|
||||
@@ -1341,14 +1168,13 @@ app.registerExtension({
|
||||
nodeType.prototype.onExecuted = function (output) {
|
||||
// Suppress default preview
|
||||
this.imgs = null;
|
||||
// Core calls this on the OPEN tab's node with the run's id. A Run that
|
||||
// ended while another workflow tab is open belongs to that tab (decided
|
||||
// once the message's listeners ran); core hands it back when that tab
|
||||
// opens again: onNodeOutputsUpdated below (js/shared/run_tab.mjs).
|
||||
queueMicrotask(() => {
|
||||
if (runTab(output) === "other") return;
|
||||
applyCompareOutput(this, output);
|
||||
});
|
||||
|
||||
if (!output?.images || output.images.length < 2) return;
|
||||
// Persist image refs to node.properties before loading so a Vue tab
|
||||
// switch immediately after execution still restores correctly.
|
||||
saveCompareImagesToProps(this, output.images);
|
||||
loadCmpImage(this, output.images[0], 0);
|
||||
loadCmpImage(this, output.images[1], 1);
|
||||
};
|
||||
|
||||
// Suppress default background image rendering (legacy only — onDrawBackground
|
||||
@@ -1411,9 +1237,15 @@ app.registerExtension({
|
||||
if (_origUp) return _origUp.call(this, e, pos);
|
||||
};
|
||||
|
||||
// The wheel (Overlay opacity) is not a node hook any more: see
|
||||
// installCompareWheel.
|
||||
installCompareWheel();
|
||||
const _origWheel = nodeType.prototype.onMouseWheel;
|
||||
nodeType.prototype.onMouseWheel = function (e, pos) {
|
||||
if (isVueNodes()) return _origWheel ? _origWheel.call(this, e, pos) : undefined;
|
||||
if (cmpWheel(this, pos[1], e.deltaY)) {
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
}
|
||||
if (_origWheel) return _origWheel.call(this, e, pos);
|
||||
};
|
||||
|
||||
const _origLeave = nodeType.prototype.onMouseLeave;
|
||||
nodeType.prototype.onMouseLeave = function (e) {
|
||||
@@ -1438,12 +1270,8 @@ app.registerExtension({
|
||||
// Release the DOM-widget ResizeObserver on node removal (Nodes 2.0).
|
||||
const _origRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function () {
|
||||
try { this._cmpRendererOff?.(); } catch {}
|
||||
this._cmpRendererOff = null;
|
||||
try { this._cmpDomRO?.disconnect(); } catch {}
|
||||
try { cancelAnimationFrame(this._cmpZoomRaf); } catch {}
|
||||
try { this._cmpSnap?.dispose(); } catch {}
|
||||
this._cmpSnap = null;
|
||||
cmpHideTooltip();
|
||||
this._cmpDomRO = null;
|
||||
this._cmpDomRender = null;
|
||||
@@ -1451,16 +1279,3 @@ app.registerExtension({
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// The colour option: a right-click "Image Compare settings" entry, the gear in
|
||||
// the selection toolbar, and the shared colour panel behind both. onChange
|
||||
// repaints because the node body is a canvas, which cannot read a CSS variable.
|
||||
registerNodeAccent("PixaromaCompare", {
|
||||
title: "Image Compare",
|
||||
onChange: (node) => cmpRepaint(node),
|
||||
rows: [
|
||||
{ kind: "combo", setting: SETTING_DEFAULT_MODE, options: DEFAULT_MODE_OPTIONS,
|
||||
defaultValue: "Show 2", label: "Default view mode",
|
||||
hint: "The view a newly dropped Compare node starts in" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
|
||||
// NOTE: pass fetchApi the BARE route - do NOT wrap it in pixApiUrl().
|
||||
// fetchApi already ends with `this.apiURL(route)`, so wrapping applies the
|
||||
// prefix TWICE. It looks fine on localhost (api_base is "" and apiURL's
|
||||
// `startsWith("/api")` guard makes the second pass a no-op) but on a hosted
|
||||
// ComfyUI served under a sub-path it yields /base/api/base/api/... -> 404.
|
||||
// pixApiUrl is for RAW fetch()/src= urls only.
|
||||
export const PixaromaAPI = {
|
||||
// Accept either a model id ("u2net" / "isnet-general-use" / etc.)
|
||||
// or a legacy quality tier ("normal" / "high"). Backend now maps
|
||||
|
||||
@@ -346,11 +346,20 @@ export class PixaromaEditor {
|
||||
return !!(this.overlay && this.overlay.isConnected);
|
||||
}
|
||||
|
||||
// Vue Compat #6: stop Ctrl+Z escaping to the graph while the editor is open.
|
||||
// HOW it does that lives in js/shared/graph_undo_guard.mjs and nowhere else -
|
||||
// do not restate it here, that is how these comments went stale. Idempotent:
|
||||
// a re-open just re-registers via a fresh token.
|
||||
// Vue Compat #6: neuter the Ctrl+Z escape. ComfyUI's change-tracker undo runs
|
||||
// via requestAnimationFrame and reaches the graph through app.loadGraphData ->
|
||||
// app.graph.configure; preventDefault on the keydown does NOT stop it. While
|
||||
// the editor is open we no-op both so Ctrl+Z can only drive the editor's own
|
||||
// undo, never tear down the workflow underneath. The patches SELF-HEAL: if
|
||||
// they are ever called while our overlay is gone (tab closed mid-edit, etc),
|
||||
// they restore the originals and pass through - otherwise loadGraphData would
|
||||
// stay disabled forever and brick the whole UI until a page refresh.
|
||||
_installGraphPatches() {
|
||||
// Delegate to the shared, refcount-safe, self-healing guard. It also covers
|
||||
// graph.undo/redo + the Comfy.Undo/Redo command (which this editor's old
|
||||
// bespoke version did not), and unifies the patch with the other editors so
|
||||
// they can never fight over app.loadGraphData. Idempotent: a re-open just
|
||||
// re-registers via a fresh token.
|
||||
if (!this._undoGuardOff)
|
||||
this._undoGuardOff = installGraphUndoGuard(() => this._overlayAlive());
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { pixApiUrl } from "../shared/api_url.mjs";
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
// api is used at the top level (pixaroma-composer-preview WebSocket
|
||||
// listener) AND inside the execution-events try block further down.
|
||||
// Importing it once here keeps both call sites using the same module.
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { api } from "../../../../scripts/api.js";
|
||||
import { applyFx, isNeutral, fxSeed } from "./fx_engine.mjs";
|
||||
import {
|
||||
allow_debug,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
activateNodePreview,
|
||||
downloadDataURL,
|
||||
applyAdaptiveCanvasOnly,
|
||||
installCanvasZoomPassthrough,
|
||||
} from "../shared/index.mjs";
|
||||
|
||||
// Import core class first, then mixins as side-effects
|
||||
@@ -25,8 +23,6 @@ import "./interaction.mjs";
|
||||
import "./placeholder.mjs";
|
||||
import { getUpstreamImageUrlForNode } from "./placeholder.mjs";
|
||||
import { isGraphLoading } from "../shared/graph_loading.mjs";
|
||||
import { isLiveNode } from "../shared/live_node.mjs";
|
||||
import { growToCoreMinHeight, capComputeSizeInNodes2 } from "../shared/core_min_size.mjs";
|
||||
|
||||
// Re-export so other modules can import from index
|
||||
export { PixaromaEditor };
|
||||
@@ -63,8 +59,9 @@ function isEditorOpen(node) {
|
||||
if (!node._pixaromaEditor) return false;
|
||||
const overlay = node._pixaromaEditor.overlay;
|
||||
if (!overlay || !overlay.isConnected) {
|
||||
// Editor was removed from DOM without close handler — clean up. Release the
|
||||
// Ctrl+Z graph-undo guard (Vue Compat #6), then drop the stale reference.
|
||||
// Editor was removed from DOM without close handler — clean up. Restore the
|
||||
// Ctrl+Z graph-undo neutering (Vue Compat #6) so loadGraphData isn't left
|
||||
// disabled, then drop the stale reference.
|
||||
dbg("editor overlay gone — clearing stale reference");
|
||||
try { node._pixaromaEditor._restoreGraphPatches?.(); } catch {}
|
||||
try { node._pixaromaEditor._cleanupKeys?.(); } catch {}
|
||||
@@ -79,7 +76,6 @@ app.registerExtension({
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== "PixaromaImageComposition") return;
|
||||
capComputeSizeInNodes2(nodeType); // Nodes 2.0 Ctrl+Z grow on frontend 1.53 (composer.md #8)
|
||||
|
||||
const originalOnExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
@@ -178,7 +174,6 @@ app.registerExtension({
|
||||
});
|
||||
|
||||
// ── DOM widget ──
|
||||
installCanvasZoomPassthrough(parts.container);
|
||||
let widget = node.addDOMWidget(
|
||||
"ComposerWidget",
|
||||
"custom",
|
||||
@@ -214,9 +209,6 @@ app.registerExtension({
|
||||
},
|
||||
);
|
||||
applyAdaptiveCanvasOnly(widget);
|
||||
// Classic fresh drop: start at core's own height (312), or the first Ctrl+Z
|
||||
// grows the node and jams undo (composer.md #7).
|
||||
growToCoreMinHeight(node);
|
||||
|
||||
// cleanup handled in API listener section below
|
||||
|
||||
@@ -285,7 +277,7 @@ app.registerExtension({
|
||||
let maskUrl = null;
|
||||
if (layer.maskSrc) {
|
||||
const maskFn = layer.maskSrc.split(/[\\/]/).pop();
|
||||
maskUrl = pixApiUrl(`/view?filename=${encodeURIComponent(maskFn)}&type=input&subfolder=pixaroma&t=${Date.now()}`);
|
||||
maskUrl = `/view?filename=${encodeURIComponent(maskFn)}&type=input&subfolder=pixaroma&t=${Date.now()}`;
|
||||
}
|
||||
if (layer.isPlaceholder) {
|
||||
const url = getUpstreamImageUrlForNode(node, `image_${layer.inputIndex}`);
|
||||
@@ -295,7 +287,7 @@ app.registerExtension({
|
||||
const src = layer.src;
|
||||
if (!src || src === "__placeholder__") { loadList.push({ layer, url: null, maskUrl: null }); continue; }
|
||||
const fn = src.split(/[\\/]/).pop();
|
||||
const url = pixApiUrl(`/view?filename=${encodeURIComponent(fn)}&type=input&subfolder=pixaroma&t=${Date.now()}`);
|
||||
const url = `/view?filename=${encodeURIComponent(fn)}&type=input&subfolder=pixaroma&t=${Date.now()}`;
|
||||
dbg(" regular layer", layer.name, "→ url:", url.substring(0, 80));
|
||||
loadList.push({ layer, url, maskUrl });
|
||||
}
|
||||
@@ -504,7 +496,7 @@ app.registerExtension({
|
||||
// Cache-bust so the same filename with different content reloads.
|
||||
t: Date.now(),
|
||||
});
|
||||
const url = pixApiUrl(`/view?${params.toString()}`);
|
||||
const url = `/view?${params.toString()}`;
|
||||
// Pull dims from the current project json so the label reads right.
|
||||
let dimText = null;
|
||||
try {
|
||||
@@ -525,10 +517,6 @@ app.registerExtension({
|
||||
// project_id so multiple composer nodes in the same workflow
|
||||
// don't clobber each other.
|
||||
const _onComposerPreview = (event) => {
|
||||
// A Ctrl+C copy carries the same project_id and never gets onRemoved, so it
|
||||
// used to take this event too and reload the preview nobody sees. Real
|
||||
// nodes, inside subgraphs too, always pass (js/shared/live_node.mjs).
|
||||
if (!isLiveNode(node)) return;
|
||||
const data = event?.detail;
|
||||
if (!data || !data.filename) return;
|
||||
// Match the event to this node. If project_id is missing (shouldn't
|
||||
@@ -629,13 +617,7 @@ app.registerExtension({
|
||||
// `api` already imported at top of file — no need for dynamic import.
|
||||
|
||||
let executionRunning = false;
|
||||
// Copies (Ctrl+C / Clone / Convert to Subgraph) and Composers left inside a
|
||||
// subgraph by a workflow switch never get onRemoved, so these listeners
|
||||
// stayed registered for them. With a placeholder layer each one re-composited
|
||||
// an invisible preview after EVERY run (measured: 3 copies = 3 extra image
|
||||
// draws per run). Skip anything not on an open canvas.
|
||||
const _onExecStart = () => {
|
||||
if (!isLiveNode(node)) return;
|
||||
executionRunning = true;
|
||||
// Reset the WS-preview flag so if this run doesn't hit the
|
||||
// dynamic re-compose path (no placeholders / auto-rembg /
|
||||
@@ -646,7 +628,6 @@ app.registerExtension({
|
||||
|
||||
// "executing" with null detail means execution finished
|
||||
const _onExecuting = (event) => {
|
||||
if (!isLiveNode(node)) return;
|
||||
const detail = event?.detail;
|
||||
if (detail === null || detail?.node === null) {
|
||||
if (executionRunning && !isEditorOpen(node)) {
|
||||
@@ -678,9 +659,9 @@ app.registerExtension({
|
||||
origRemoved?.call(node);
|
||||
clearInterval(pollInterval);
|
||||
// If the node is deleted while its editor is open, tear the editor
|
||||
// down properly: release the Ctrl+Z graph-undo guard (Vue Compat #6)
|
||||
// and detach window listeners, or the guard stays held and the
|
||||
// listeners leak.
|
||||
// down properly: restore the Ctrl+Z graph-undo neutering (Vue Compat
|
||||
// #6) and detach window listeners, or loadGraphData stays a no-op and
|
||||
// the listeners leak.
|
||||
try {
|
||||
const ed = node._pixaromaEditor;
|
||||
if (ed) {
|
||||
|
||||
@@ -303,7 +303,7 @@ PixaromaEditor.prototype.attachEvents = function () {
|
||||
window.removeEventListener("mouseup", this._composerMouseUp);
|
||||
if (this._composerBlur)
|
||||
window.removeEventListener("blur", this._composerBlur);
|
||||
// Release the Ctrl+Z graph-undo guard taken at open (Vue Compat #6).
|
||||
// Restore the Ctrl+Z graph-undo neutering installed at open (Vue Compat #6).
|
||||
this._restoreGraphPatches?.();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
// Placeholder layer management — mixed into PixaromaEditor.prototype
|
||||
import { PixaromaEditor } from "./core.mjs";
|
||||
|
||||
import { pixApiUrl } from "../shared/api_url.mjs";
|
||||
// A LoadImage value can carry a subfolder or a type annotation: a raw value 404s
|
||||
// (image-pickers.md #4). Both upstream URL builders below go through this.
|
||||
import { comboViewRoute } from "../shared/view_url.mjs";
|
||||
const PH_COLORS = ["#4A90D9", "#E07B54", "#7DC97A", "#C06BC9", "#E8C547"];
|
||||
|
||||
PixaromaEditor.prototype._nextPlaceholderIndex = function () {
|
||||
@@ -159,7 +155,7 @@ PixaromaEditor.prototype._getUpstreamImageUrl = function (layer) {
|
||||
if (srcNode.comfyClass === "LoadImage" || srcNode.type === "LoadImage") {
|
||||
const imgWidget = (srcNode.widgets || []).find((w) => w.name === "image");
|
||||
if (imgWidget && imgWidget.value) {
|
||||
return pixApiUrl(`${comboViewRoute(imgWidget.value)}&t=${Date.now()}`);
|
||||
return `/view?filename=${encodeURIComponent(imgWidget.value)}&type=input&t=${Date.now()}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +268,7 @@ export function getUpstreamImageUrlForNode(node, inputName) {
|
||||
if (srcNode.comfyClass === "LoadImage" || srcNode.type === "LoadImage") {
|
||||
const imgWidget = (srcNode.widgets || []).find((w) => w.name === "image");
|
||||
if (imgWidget && imgWidget.value) {
|
||||
return pixApiUrl(`${comboViewRoute(imgWidget.value)}&t=${Date.now()}`);
|
||||
return `/view?filename=${encodeURIComponent(imgWidget.value)}&type=input&t=${Date.now()}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Rendering, history/undo, restore — mixed into PixaromaEditor.prototype
|
||||
import { PixaromaEditor } from "./core.mjs";
|
||||
import { pixApiUrl } from "../shared/api_url.mjs";
|
||||
import { applyFx, isNeutral, fxSeed } from "./fx_engine.mjs";
|
||||
|
||||
// Clone a layer for history restore. Shallow-copy the layer, but DEEP-copy the
|
||||
@@ -574,7 +573,7 @@ PixaromaEditor.prototype.attemptRestore = async function () {
|
||||
};
|
||||
placeholder.src = tempCanvas.toDataURL();
|
||||
};
|
||||
img.src = pixApiUrl(`/view?filename=${encodeURIComponent(fileNameOnly)}&type=input&subfolder=pixaroma&t=${Date.now()}`);
|
||||
img.src = `/view?filename=${encodeURIComponent(fileNameOnly)}&type=input&subfolder=pixaroma&t=${Date.now()}`;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Pixaroma Restore Error:", err);
|
||||
@@ -596,7 +595,7 @@ PixaromaEditor.prototype.finishRestore = function (hadError = false) {
|
||||
this.layers.forEach((l) => {
|
||||
if (l.savedMaskPath_internal) {
|
||||
const maskFileName = l.savedMaskPath_internal.split(/[\\/]/).pop();
|
||||
const maskUrl = pixApiUrl(`/view?filename=${encodeURIComponent(maskFileName)}&type=input&subfolder=pixaroma&t=${Date.now()}`);
|
||||
const maskUrl = `/view?filename=${encodeURIComponent(maskFileName)}&type=input&subfolder=pixaroma&t=${Date.now()}`;
|
||||
this.prepareLayerMask(l, maskUrl);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
import {
|
||||
createEditorLayout,
|
||||
createPanel,
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
// right sidebar fails to build and the editor won't open.
|
||||
import { PixaromaAPI } from "./api.mjs";
|
||||
import { fetchBgRemovalInfo, buildBgRemovalDropdown } from "../framework/bg_removal_dropdown.mjs";
|
||||
import { pixAsset } from "../shared/api_url.mjs";
|
||||
import { PRESETS, NEUTRAL } from "./fx_engine.mjs";
|
||||
|
||||
// Legacy values that predate the multi-model dropdown — remapped to
|
||||
@@ -507,7 +506,7 @@ export class PixaromaUI {
|
||||
const img = new Image();
|
||||
img.onload = () => this.refreshLayersPanel();
|
||||
img.onerror = () => {};
|
||||
img.src = pixAsset("icons/layers/fx.svg");
|
||||
img.src = "/pixaroma/assets/icons/layers/fx.svg";
|
||||
this._fxThumbImg = img;
|
||||
}
|
||||
return this._fxThumbImg;
|
||||
@@ -1203,7 +1202,7 @@ export class PixaromaUI {
|
||||
if (this._canvasToolbar) this._canvasToolbar.setupDropZone(core.workspace);
|
||||
|
||||
// Align bar (in titlebar center) -- using SVG icons
|
||||
const _ai = "icons/ui/";
|
||||
const _ai = "/pixaroma/assets/icons/ui/";
|
||||
const alignBar = document.createElement("div");
|
||||
alignBar.style.cssText = "display:flex;align-items:center;gap:4px;";
|
||||
const alignBtns = [
|
||||
@@ -1236,7 +1235,7 @@ export class PixaromaUI {
|
||||
}
|
||||
const btn = createButton("", {
|
||||
variant: "sm",
|
||||
iconSrc: pixAsset(_ai + cfg.icon),
|
||||
iconSrc: _ai + cfg.icon,
|
||||
title: cfg.title,
|
||||
});
|
||||
btn.id = cfg.id;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { slotAccepts } from "../shared/slot_types.mjs";
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
const SETTING_ID = "Pixaroma.Connection.FX";
|
||||
const PROXIMITY_RADIUS = 110;
|
||||
@@ -91,11 +90,9 @@ function getConnectingInfo() {
|
||||
}
|
||||
|
||||
function typesCompatible(a, b) {
|
||||
// slotAccepts, not "a === b": a ComfyUI multi-type input arrives as the
|
||||
// comma-joined "FLOAT,INT,BOOLEAN" (core's Math Expression), so an equality
|
||||
// test left the magnets dark on exactly the slots the drag COULD land on.
|
||||
// It keeps the "" / "*" wildcard behaviour this used to have (Vue Compat #22).
|
||||
return slotAccepts(a, b);
|
||||
if (!a || !b) return true;
|
||||
if (a === "*" || b === "*") return true;
|
||||
return a === b;
|
||||
}
|
||||
|
||||
function isVueNodes() {
|
||||
@@ -584,8 +581,7 @@ function installDrawHook() {
|
||||
function installLoadHook() {
|
||||
if (loadHookInstalled) return;
|
||||
if (typeof app.loadGraphData !== "function") return;
|
||||
const _origLoad_fn = app.loadGraphData;
|
||||
const origLoad = (...a) => _origLoad_fn.apply(app, a);
|
||||
const origLoad = app.loadGraphData.bind(app);
|
||||
app.loadGraphData = function () {
|
||||
// Every workflow-open / tab-restore / Ctrl+Z funnels through here, and
|
||||
// ComfyUI repopulates graph.links with all saved wires. Suppress the
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
createCanvasToolbar,
|
||||
} from "../framework/index.mjs";
|
||||
import { ALIGNMENTS, computeAlignedXY, defaultAlignForMeta } from "./alignments.mjs";
|
||||
import { pixApiUrl } from "../shared/api_url.mjs";
|
||||
import { installGraphUndoGuard } from "../shared/graph_undo_guard.mjs";
|
||||
|
||||
export const RATIOS = [
|
||||
@@ -39,7 +38,7 @@ export const SNAPS = [
|
||||
|
||||
export const CropAPI = {
|
||||
async saveComposite(projectId, dataURL) {
|
||||
const { api } = await import("../../../scripts/api.js");
|
||||
const { api } = await import("/scripts/api.js");
|
||||
const res = await api.fetchApi("/pixaroma/api/crop/save", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -104,7 +103,7 @@ export class CropEditor {
|
||||
sourceURL = upstreamUrl;
|
||||
} else if (this._srcPath) {
|
||||
const fn = this._srcPath.split(/[\\/]/).pop();
|
||||
sourceURL = pixApiUrl(`/view?filename=${encodeURIComponent(fn)}&type=input&subfolder=pixaroma&t=${Date.now()}`);
|
||||
sourceURL = `/view?filename=${encodeURIComponent(fn)}&type=input&subfolder=pixaroma&t=${Date.now()}`;
|
||||
}
|
||||
|
||||
if (sourceURL) {
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
// ============================================================
|
||||
// Pixaroma Image Crop Editor — Entry Point
|
||||
// ============================================================
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { pixApiUrl } from "../shared/api_url.mjs";
|
||||
import { comboViewRoute } from "../shared/view_url.mjs";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { isGraphLoading } from "../shared/graph_loading.mjs";
|
||||
import { isLiveNode } from "../shared/live_node.mjs";
|
||||
import { runTab, eachRestoredOutput } from "../shared/run_tab.mjs";
|
||||
import { growToCoreMinHeight, capComputeSizeInNodes2 } from "../shared/core_min_size.mjs";
|
||||
import { CropEditor } from "./core.mjs";
|
||||
import "./interaction.mjs"; // mixin: mouse/keyboard events
|
||||
import "./render.mjs"; // mixin: canvas rendering, ratio, save
|
||||
@@ -18,11 +13,9 @@ import {
|
||||
createNodePreview,
|
||||
showNodePreview,
|
||||
restoreNodePreview,
|
||||
clearNodePreview,
|
||||
activateNodePreview,
|
||||
downloadDataURL,
|
||||
applyAdaptiveCanvasOnly,
|
||||
installCanvasZoomPassthrough,
|
||||
} from "../shared/index.mjs";
|
||||
|
||||
// ─── Upstream-image resolver ──────────────────────────────────────────────
|
||||
@@ -48,13 +41,11 @@ function getUpstreamImageURL(node) {
|
||||
const srcNode = graph.getNodeById(link.origin_id);
|
||||
if (!srcNode) return null;
|
||||
|
||||
// LoadImage: read filename from its "image" widget. The value can carry a
|
||||
// subfolder ("Studio1/cat.png") or a type annotation ("x.png [input]"):
|
||||
// comboViewRoute splits both, a raw value 404s (image-pickers.md #4).
|
||||
// LoadImage: read filename from its "image" widget.
|
||||
if (srcNode.comfyClass === "LoadImage" || srcNode.type === "LoadImage") {
|
||||
const imgWidget = (srcNode.widgets || []).find((w) => w.name === "image");
|
||||
if (imgWidget && imgWidget.value) {
|
||||
return pixApiUrl(`${comboViewRoute(imgWidget.value)}&t=${Date.now()}`);
|
||||
return `/view?filename=${encodeURIComponent(imgWidget.value)}&type=input&t=${Date.now()}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,59 +100,10 @@ function getUpstreamSnapshot(node) {
|
||||
// store only the structural parts so workflow JSON stays clean.
|
||||
function buildSourceURL(part, withCacheBust) {
|
||||
if (!part || !part.filename) return null;
|
||||
// The cache-buster is part of the ROUTE handed to pixApiUrl, never appended to
|
||||
// its RESULT: a hosted ComfyUI adds its auth token to the finished url, so
|
||||
// concatenating afterwards writes our param on the far side of that token
|
||||
// (see js/shared/api_url.mjs). Locally the two produce the identical string.
|
||||
return pixApiUrl(`/view?filename=${encodeURIComponent(part.filename)}` +
|
||||
const url = `/view?filename=${encodeURIComponent(part.filename)}` +
|
||||
`&subfolder=${encodeURIComponent(part.subfolder || "")}` +
|
||||
`&type=${encodeURIComponent(part.type || "temp")}` +
|
||||
(withCacheBust ? `&t=${Date.now()}` : ""));
|
||||
}
|
||||
|
||||
// Give a duplicated node its own on-disk scratch space. The crop_json carries a
|
||||
// project_id that keys the src/composite files on disk; a duplicate (alt-drag,
|
||||
// right-click Duplicate, or Ctrl+C/V) copies it verbatim, so saving from the
|
||||
// copy's editor would overwrite the parent's files. If another live node of the
|
||||
// same type already holds this id, re-mint it + clear the paths so the copy
|
||||
// starts blank and isolated. A clean workflow load never has two nodes sharing
|
||||
// an id, so this is a no-op (no write) on load -> no dirty-on-load. When the
|
||||
// copy is wired to an upstream image (the common case) src_path is unused, so
|
||||
// clearing it is invisible there.
|
||||
function dedupeCropProjectId(node) {
|
||||
try {
|
||||
const w = node.widgets?.find((x) => x.name === "CropWidget");
|
||||
if (!w) return;
|
||||
let meta;
|
||||
try { meta = JSON.parse(w.value?.crop_json || "{}"); } catch { return; }
|
||||
const myId = meta?.project_id;
|
||||
if (!myId) return;
|
||||
const g = node.graph || app.graph;
|
||||
const nodes = g?._nodes || g?.nodes || [];
|
||||
const collides = nodes.some((n) => {
|
||||
if (n === node || n?.comfyClass !== node.comfyClass) return false;
|
||||
const ow = n.widgets?.find((x) => x.name === "CropWidget");
|
||||
if (!ow) return false;
|
||||
let om; try { om = JSON.parse(ow.value?.crop_json || "{}"); } catch { return false; }
|
||||
return om?.project_id === myId;
|
||||
});
|
||||
if (!collides) return;
|
||||
meta.project_id = "crop_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9);
|
||||
meta.src_path = "";
|
||||
meta.composite_path = "";
|
||||
w.value = { crop_json: JSON.stringify(meta) };
|
||||
// Also clear the copied cached-source references, else the node-body
|
||||
// thumbnail keeps showing the parent's image (restoreNodePreview can't
|
||||
// blank an already-drawn image). Then repaint: the upstream if the copy is
|
||||
// wired, otherwise the empty placeholder.
|
||||
node._pixaromaCropSourceURL = null;
|
||||
if (node.properties) {
|
||||
delete node.properties.pixaromaCropSource;
|
||||
delete node.properties.pixaromaCropSourceURL;
|
||||
}
|
||||
if (getUpstreamImageURL(node)) node._pixaromaCropRefresh?.();
|
||||
else node._pixaromaCropClearPreview?.();
|
||||
} catch (e) { console.warn("[PixaromaCrop] dedupe project id failed:", e); }
|
||||
`&type=${encodeURIComponent(part.type || "temp")}`;
|
||||
return withCacheBust ? `${url}&t=${Date.now()}` : url;
|
||||
}
|
||||
|
||||
// ─── Global paste handler (clipboard → selected Crop node) ────────────────
|
||||
@@ -274,14 +216,8 @@ function findActiveCropNode() {
|
||||
app.registerExtension({
|
||||
name: "Pixaroma.Crop",
|
||||
|
||||
// A Run that ended while another workflow tab was open (js/shared/run_tab.mjs).
|
||||
onNodeOutputsUpdated(nodeOutputs) {
|
||||
eachRestoredOutput(nodeOutputs, "PixaromaCrop", (node, output) => node._pixCropApplyRun?.(output));
|
||||
},
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== "PixaromaCrop") return;
|
||||
capComputeSizeInNodes2(nodeType); // Nodes 2.0 Ctrl+Z grow on frontend 1.53 (crop-uncrop.md #12)
|
||||
|
||||
const originalOnExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
@@ -323,10 +259,6 @@ app.registerExtension({
|
||||
queueMicrotask(() => this._pixaromaCropRefresh());
|
||||
setTimeout(() => this._pixaromaCropRefresh?.(), 250);
|
||||
}
|
||||
// After the node settles, give a DUPLICATE its own project_id (see
|
||||
// dedupeCropProjectId). Deferred a microtask so a clipboard paste has
|
||||
// added the node to its graph (so this.graph + the sibling are both live).
|
||||
queueMicrotask(() => { if (!isGraphLoading()) dedupeCropProjectId(this); });
|
||||
return ret;
|
||||
};
|
||||
},
|
||||
@@ -345,22 +277,12 @@ 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",
|
||||
"Pixaroma",
|
||||
"Wire an IMAGE input and Run the workflow,\nor click 'Open Crop' to load an image",
|
||||
);
|
||||
// let dedupe (module scope) blank this node's thumbnail after a duplicate
|
||||
node._pixaromaCropClearPreview = () => clearNodePreview(parts, node);
|
||||
|
||||
// ── State -- mirrors the hidden crop_json widget ──
|
||||
let cropJson = "{}";
|
||||
@@ -500,7 +422,6 @@ app.registerExtension({
|
||||
});
|
||||
// Resolution Pixaroma pattern — set BOTH min and max to the same constant
|
||||
// so ComfyUI doesn't stretch the widget to fill leftover node height.
|
||||
installCanvasZoomPassthrough(panel.el);
|
||||
const _cropPanelWidget = node.addDOMWidget("CropPanel", "custom", panel.el, {
|
||||
// canvasOnly set adaptively below (CLAUDE.md Nodes 2.0)
|
||||
serialize: false,
|
||||
@@ -511,7 +432,6 @@ app.registerExtension({
|
||||
applyAdaptiveCanvasOnly(_cropPanelWidget);
|
||||
|
||||
// ── DOM widget (mini-preview) ──
|
||||
installCanvasZoomPassthrough(parts.container);
|
||||
widget = node.addDOMWidget("CropWidget", "custom", parts.container, {
|
||||
// canvasOnly set adaptively below (CLAUDE.md Nodes 2.0)
|
||||
getValue: () => ({ crop_json: cropJson }),
|
||||
@@ -546,9 +466,6 @@ app.registerExtension({
|
||||
margin: 5,
|
||||
});
|
||||
applyAdaptiveCanvasOnly(widget);
|
||||
// Classic fresh drop: up to core's own height (456), or the first Ctrl+Z grows
|
||||
// the node 16px and jams undo (#11).
|
||||
growToCoreMinHeight(node);
|
||||
|
||||
activateNodePreview(parts, node);
|
||||
|
||||
@@ -723,8 +640,14 @@ app.registerExtension({
|
||||
// ── Cache source URL emitted by Python on each execute ──
|
||||
// Works for any IMAGE upstream (VAE Decode etc.) — without this, only
|
||||
// LoadImage chains can resolve a usable preview URL.
|
||||
const applyRunSource = (output) => {
|
||||
const frames = output?.pixaroma_crop_source;
|
||||
const onExec = (event) => {
|
||||
const detail = event?.detail;
|
||||
if (!detail?.output) return;
|
||||
// Cross-version node-id resolution (Vue passes string, legacy passes number).
|
||||
const matched = app.graph.getNodeById(detail.node)
|
||||
|| app.graph.getNodeById(parseInt(detail.node, 10));
|
||||
if (matched !== node) return;
|
||||
const frames = detail.output.pixaroma_crop_source;
|
||||
if (!frames?.length) return;
|
||||
const f = frames[0];
|
||||
const part = { filename: f.filename, subfolder: f.subfolder || "", type: f.type || "temp" };
|
||||
@@ -735,36 +658,13 @@ app.registerExtension({
|
||||
delete node.properties.pixaromaCropSourceURL;
|
||||
rebuildPreviewFromUpstream();
|
||||
};
|
||||
// The extension's onNodeOutputsUpdated hands a missed run back through this.
|
||||
node._pixCropApplyRun = applyRunSource;
|
||||
const onExec = (event) => {
|
||||
const detail = event?.detail;
|
||||
if (!detail?.output) return;
|
||||
// Cross-version node-id resolution (Vue passes string, legacy passes number).
|
||||
const matched = app.graph.getNodeById(detail.node)
|
||||
|| app.graph.getNodeById(parseInt(detail.node, 10));
|
||||
if (matched !== node) return;
|
||||
// A Run that ended while another workflow tab is open belongs to that tab:
|
||||
// the id above found a stranger with the same id. Decided once the
|
||||
// message's listeners ran; core hands the result back when that tab opens
|
||||
// again (js/shared/run_tab.mjs).
|
||||
queueMicrotask(() => {
|
||||
if (runTab(detail.output) === "other") return;
|
||||
applyRunSource(detail.output);
|
||||
});
|
||||
};
|
||||
api.addEventListener("executed", onExec);
|
||||
|
||||
// Refresh after every workflow execution so post-exec preview images
|
||||
// (e.g. an Img Generation upstream) flow into our mini preview.
|
||||
let executionRunning = false;
|
||||
// A Crop left inside a subgraph by a workflow switch never gets onRemoved, so
|
||||
// these stayed registered and it re-downloaded its upstream picture after
|
||||
// every later run for a preview nobody sees (measured: 4 loads per run, 0
|
||||
// without it). Skip anything not on an open canvas (js/shared/live_node.mjs).
|
||||
const onStart = () => { if (isLiveNode(node)) executionRunning = true; };
|
||||
const onStart = () => { executionRunning = true; };
|
||||
const onExecuting = (event) => {
|
||||
if (!isLiveNode(node)) return;
|
||||
const detail = event?.detail;
|
||||
if (detail === null || detail?.node === null) {
|
||||
if (executionRunning) {
|
||||
@@ -785,7 +685,6 @@ app.registerExtension({
|
||||
if (node._pixaromaCropEditor?.el?.overlay?.isConnected) node._pixaromaCropEditor._close();
|
||||
} catch (e) {}
|
||||
origRemoved?.call(node);
|
||||
try { panel.dispose?.(); } catch {}
|
||||
clearInterval(pollInterval);
|
||||
try { api.removeEventListener("execution_start", onStart); } catch {}
|
||||
try { api.removeEventListener("executing", onExecuting); } catch {}
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
// like "1024+256" or "1024*2" — evaluated safely on commit.
|
||||
// ============================================================
|
||||
|
||||
import { ACC, notifyGraphChanged, placeZoomedPopup } from "../shared/index.mjs";
|
||||
import { passComfyShortcut } from "../shared/text_shortcuts.mjs";
|
||||
import { BRAND } from "../shared/index.mjs";
|
||||
import { RATIOS } from "./core.mjs";
|
||||
import { ALIGNMENTS, computeAlignedXY, defaultAlignForMeta } from "./alignments.mjs";
|
||||
|
||||
@@ -71,10 +70,7 @@ const PANEL_CSS = `
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.08s;
|
||||
}
|
||||
.pix-cropp-cell:hover { border-color: ${ACC}; }
|
||||
/* The input draws no outline of its own, so the cell's border IS the focus
|
||||
ring (node UI convention #13: inputs use :focus-within). */
|
||||
.pix-cropp-cell:focus-within { border-color: ${ACC}; }
|
||||
.pix-cropp-cell:hover { border-color: #888; }
|
||||
.pix-cropp-cell label {
|
||||
font-size: 10px;
|
||||
color: #777;
|
||||
@@ -94,65 +90,27 @@ const PANEL_CSS = `
|
||||
font-family: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
/* The Pixaroma dropdown (node UI convention #14), never a native <select>.
|
||||
No side arrows HERE: two dropdowns share one row, and 24px arrow buttons
|
||||
(the WCAG 2.5.8 minimum) left too little room, cutting "16:9 Landscape"
|
||||
and "Bottom Right" short at the default width. Full names win; the arrow
|
||||
KEYS still step through the options. Same 24px height as the cells. */
|
||||
.pix-cropp-combo { flex: 1; min-width: 0; display: flex; }
|
||||
.pix-cropp-combo button {
|
||||
background: #1d1d1d;
|
||||
border: 1px solid #666;
|
||||
border-radius: 4px;
|
||||
min-height: 24px;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.08s;
|
||||
}
|
||||
.pix-cropp-combo button:hover,
|
||||
.pix-cropp-combo button:focus-visible { border-color: ${ACC}; outline: 0; }
|
||||
.pix-cropp-dd {
|
||||
.pix-cropp-combo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 0 6px;
|
||||
background: #1d1d1d;
|
||||
color: #ccc;
|
||||
border: 1px solid #666;
|
||||
border-radius: 4px;
|
||||
outline: 0;
|
||||
padding: 4px 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.pix-cropp-dd-val { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pix-cropp-dd-arrow { flex: none; color: ${ACC}; font-size: 9px; }
|
||||
/* The list lives on document.body, so it is NOT scoped to .pix-cropp. Inner
|
||||
sizes in em: placeZoomedPopup scales the root font with the canvas zoom. */
|
||||
.pix-cropp-pop {
|
||||
position: fixed;
|
||||
z-index: 10900;
|
||||
background: #181818;
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.5);
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
}
|
||||
.pix-cropp-pop-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1.2em;
|
||||
padding: 0.55em 1em;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
min-height: 24px;
|
||||
transition: border-color 0.08s;
|
||||
text-align: center;
|
||||
text-align-last: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pix-cropp-pop-item:last-child { border-bottom: none; }
|
||||
.pix-cropp-pop-item:hover { background: #2a2a2a; }
|
||||
.pix-cropp-pop-label { font-size: 1.08em; color: #ddd; }
|
||||
.pix-cropp-pop-item.active .pix-cropp-pop-label { color: ${ACC}; font-weight: 600; }
|
||||
.pix-cropp-pop-hint { font-size: 0.92em; color: #888; }
|
||||
.pix-cropp-combo:hover { border-color: #888; }
|
||||
`;
|
||||
|
||||
let _cssInjected = false;
|
||||
@@ -198,22 +156,25 @@ export function createCropPanel(callbacks) {
|
||||
const row3 = document.createElement("div");
|
||||
row3.className = "pix-cropp-row";
|
||||
|
||||
// Both keep the old <select>'s contract: `.value` is the option's string
|
||||
// value, and picking one runs the same commit the "change" event did.
|
||||
const ratioSelect = makeCombo({
|
||||
name: "Crop ratio",
|
||||
title: "Lock the crop to a shape. Click to pick from the list.",
|
||||
options: RATIOS.map((r, i) => ({ value: String(i), label: ratioLabel(r), item: r.label, hint: ratioHint(r) })),
|
||||
onPick: () => onRatioCommit(),
|
||||
});
|
||||
const alignSelect = makeCombo({
|
||||
name: "Crop alignment",
|
||||
title: "Where the crop sits on the picture. Any choice except Free sets X and Y for you.",
|
||||
options: ALIGNMENTS.map((a) => ({ value: a.id, label: a.label, hint: a.id === "free" ? "type X and Y" : "" })),
|
||||
onPick: () => onAlignmentCommit(),
|
||||
});
|
||||
const ratioSelect = document.createElement("select");
|
||||
ratioSelect.className = "pix-cropp-combo";
|
||||
for (let i = 0; i < RATIOS.length; i++) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(i);
|
||||
opt.textContent = ratioLabel(RATIOS[i]);
|
||||
ratioSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
row3.append(ratioSelect.el, alignSelect.el);
|
||||
const alignSelect = document.createElement("select");
|
||||
alignSelect.className = "pix-cropp-combo";
|
||||
for (const a of ALIGNMENTS) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = a.id;
|
||||
opt.textContent = a.label;
|
||||
alignSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
row3.append(ratioSelect, alignSelect);
|
||||
|
||||
root.append(row1, row2, row3);
|
||||
|
||||
@@ -384,6 +345,8 @@ export function createCropPanel(callbacks) {
|
||||
hInput.input.addEventListener("change", () => onWHCommit("h"));
|
||||
xInput.input.addEventListener("change", onXYCommit);
|
||||
yInput.input.addEventListener("change", onXYCommit);
|
||||
ratioSelect.addEventListener("change", onRatioCommit);
|
||||
alignSelect.addEventListener("change", onAlignmentCommit);
|
||||
|
||||
// Up/Down arrows act as numeric spinners on the text inputs (since
|
||||
// type=text doesn't get them natively). Shift = ×8 step for fast nudges.
|
||||
@@ -403,12 +366,9 @@ export function createCropPanel(callbacks) {
|
||||
attachArrowSpinner(el);
|
||||
}
|
||||
|
||||
// Block keyboard from bubbling to ComfyUI canvas (would otherwise pan/zoom),
|
||||
// except ComfyUI's save / run, which go on after the blur commits the field
|
||||
// (the inputs commit on change; UI#44). The arrow spinner runs first, so the
|
||||
// arrows keep stepping.
|
||||
for (const el of [wInput.input, hInput.input, xInput.input, yInput.input, ...ratioSelect.keyTargets, ...alignSelect.keyTargets]) {
|
||||
el.addEventListener("keydown", (e) => { if (passComfyShortcut(e, el, { blur: true })) return; e.stopImmediatePropagation(); });
|
||||
// Block keyboard from bubbling to ComfyUI canvas (would otherwise pan/zoom).
|
||||
for (const el of [wInput.input, hInput.input, xInput.input, yInput.input, ratioSelect, alignSelect]) {
|
||||
el.addEventListener("keydown", (e) => e.stopImmediatePropagation());
|
||||
}
|
||||
|
||||
// ── Refresh: read cropJson + image dims, populate inputs ──
|
||||
@@ -440,25 +400,9 @@ export function createCropPanel(callbacks) {
|
||||
alignSelect.value = alignId;
|
||||
}
|
||||
|
||||
// dispose: close a list left open when the node goes away (its document
|
||||
// listeners would otherwise stay until the next outside click).
|
||||
// Only THIS panel's list: another Crop's open list is not ours to close.
|
||||
const dispose = () => { if (ratioSelect.isOpen() || alignSelect.isOpen()) closeComboPopup(); };
|
||||
return { el: root, refresh, dispose };
|
||||
return { el: root, refresh };
|
||||
}
|
||||
|
||||
// "Square" / "Landscape" / "Portrait", shown at the right of a ratio's row in
|
||||
// the list (the closed dropdown carries it in the label already).
|
||||
function ratioHint(r) {
|
||||
if (r.w === 0 || r.h === 0) return "any shape";
|
||||
if (r.w === r.h) return "Square";
|
||||
return r.w > r.h ? "Landscape" : "Portrait";
|
||||
}
|
||||
|
||||
// Names a screen reader announces for the four fields (the visible "W" / "H" /
|
||||
// "X" / "Y" label is not tied to its input).
|
||||
const FIELD_NAMES = { W: "Crop width", H: "Crop height", X: "Crop left edge (X)", Y: "Crop top edge (Y)" };
|
||||
|
||||
// Internal helper — builds a labelled cell with a text input.
|
||||
// type=text (not number) so the user can type math expressions like
|
||||
// "1024+512" — evalExpr is called on commit.
|
||||
@@ -471,125 +415,7 @@ function makeTextInput(label, defaultVal) {
|
||||
input.type = "text";
|
||||
input.inputMode = "numeric";
|
||||
input.spellcheck = false;
|
||||
input.setAttribute("aria-label", FIELD_NAMES[label] || label);
|
||||
if (defaultVal != null) input.value = String(defaultVal);
|
||||
cell.append(lbl, input);
|
||||
return { cell, input };
|
||||
}
|
||||
|
||||
// ── The dropdown: [ value v ] + a list on document.body ──
|
||||
// One list open at a time across every Crop node; its close() is tracked so
|
||||
// dispose() and a second open both tear the document listeners down.
|
||||
let _closeActivePopup = null;
|
||||
function closeComboPopup() { _closeActivePopup?.(); }
|
||||
|
||||
// options: [{ value, label, item?, hint? }] - `label` shows on the closed
|
||||
// dropdown, `item` (default label) + `hint` on the list row.
|
||||
// onPick runs after a USER choice only; setting `.value` from code is silent,
|
||||
// exactly like assigning a <select>'s value.
|
||||
function makeCombo({ name, title, options, onPick }) {
|
||||
let value = options[0].value;
|
||||
const el = document.createElement("div");
|
||||
el.className = "pix-cropp-combo";
|
||||
const dd = document.createElement("button");
|
||||
dd.type = "button";
|
||||
dd.className = "pix-cropp-dd";
|
||||
dd.title = title;
|
||||
dd.setAttribute("aria-haspopup", "listbox");
|
||||
const val = document.createElement("span");
|
||||
val.className = "pix-cropp-dd-val";
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "pix-cropp-dd-arrow";
|
||||
arrow.textContent = "▼";
|
||||
arrow.setAttribute("aria-hidden", "true");
|
||||
dd.append(val, arrow);
|
||||
el.append(dd);
|
||||
|
||||
const indexOf = (v) => Math.max(0, options.findIndex((o) => o.value === v));
|
||||
function paint() {
|
||||
const o = options[indexOf(value)];
|
||||
val.textContent = o.label;
|
||||
dd.setAttribute("aria-label", `${name}: ${o.label}`);
|
||||
}
|
||||
function pick(v) {
|
||||
if (v === value) return;
|
||||
value = v;
|
||||
paint();
|
||||
onPick?.(v);
|
||||
}
|
||||
const step = (d) => pick(options[(indexOf(value) + d + options.length) % options.length].value);
|
||||
// Arrow keys step through the options, as they did on the <select>.
|
||||
dd.addEventListener("keydown", (e) => {
|
||||
const d = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key];
|
||||
if (!d) return;
|
||||
e.preventDefault();
|
||||
step(d);
|
||||
// A key step fires no click or change, so the pack-wide change net
|
||||
// (node UI convention #31) cannot see it; the <select> sent `change` here.
|
||||
notifyGraphChanged();
|
||||
});
|
||||
dd.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (_closeActivePopup && dd.getAttribute("aria-expanded") === "true") { closeComboPopup(); return; }
|
||||
openList();
|
||||
});
|
||||
|
||||
function openList() {
|
||||
closeComboPopup();
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "pix-cropp-pop";
|
||||
pop.setAttribute("role", "listbox");
|
||||
pop.setAttribute("aria-label", name);
|
||||
for (const o of options) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "pix-cropp-pop-item" + (o.value === value ? " active" : "");
|
||||
row.setAttribute("role", "option");
|
||||
row.setAttribute("aria-selected", o.value === value ? "true" : "false");
|
||||
const l = document.createElement("span");
|
||||
l.className = "pix-cropp-pop-label";
|
||||
l.textContent = o.item || o.label;
|
||||
row.appendChild(l);
|
||||
if (o.hint) {
|
||||
const h = document.createElement("span");
|
||||
h.className = "pix-cropp-pop-hint";
|
||||
h.textContent = o.hint;
|
||||
row.appendChild(h);
|
||||
}
|
||||
row.addEventListener("click", (e) => { e.stopPropagation(); close(); pick(o.value); });
|
||||
pop.appendChild(row);
|
||||
}
|
||||
document.body.appendChild(pop);
|
||||
placeZoomedPopup(pop, dd, { baseFontPx: 12, minWidthPx: 150, baseMaxHeightPx: 320 });
|
||||
dd.setAttribute("aria-expanded", "true");
|
||||
|
||||
// Outside press / wheel / Esc close it, all in the CAPTURE phase; a wheel
|
||||
// or press INSIDE the list must not (it scrolls when the canvas is zoomed).
|
||||
const outside = (e) => { if (!pop.contains(e.target) && !dd.contains(e.target)) close(); };
|
||||
const onKey = (e) => { if (e.key === "Escape") { e.stopPropagation(); close(); } };
|
||||
function close() {
|
||||
pop.remove();
|
||||
dd.setAttribute("aria-expanded", "false");
|
||||
document.removeEventListener("pointerdown", outside, true);
|
||||
document.removeEventListener("mousedown", outside, true);
|
||||
document.removeEventListener("wheel", outside, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
if (_closeActivePopup === close) _closeActivePopup = null;
|
||||
}
|
||||
_closeActivePopup = close;
|
||||
document.addEventListener("pointerdown", outside, true);
|
||||
document.addEventListener("mousedown", outside, true);
|
||||
document.addEventListener("wheel", outside, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
}
|
||||
|
||||
paint();
|
||||
return {
|
||||
el,
|
||||
keyTargets: [dd],
|
||||
isOpen: () => dd.getAttribute("aria-expanded") === "true",
|
||||
get value() { return value; },
|
||||
// A value that is not an option reads back "" (it shows the first one),
|
||||
// as a <select> did: the commits then fall back to Free exactly as before.
|
||||
set value(v) { v = String(v); value = options.some((o) => o.value === v) ? v : ""; paint(); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ proto._loadImageFromURL = function (url, onDone) {
|
||||
|
||||
proto._uploadSourceImage = async function (dataURL) {
|
||||
try {
|
||||
const { api } = await import("../../../scripts/api.js");
|
||||
const { api } = await import("/scripts/api.js");
|
||||
const res = await api.fetchApi("/pixaroma/api/crop/upload_src", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
// Browser mirror of nodes/_dropdown_helpers.py.
|
||||
//
|
||||
// THE PARITY RULE: this file and _dropdown_helpers.py must agree on `readable`
|
||||
// and on every coercion. The panel uses `readable` to mark a row that will not
|
||||
// read as the chosen type, and the LIVE PREVIEW uses `coerceValue` to show what
|
||||
// the row will send. If the two drift, the panel confidently tells the user
|
||||
// something the run then contradicts, which is worse than showing nothing.
|
||||
//
|
||||
// The rules are deliberately simple so mirroring stays trivial. Resist any urge
|
||||
// to be clever here (locale-aware numbers, thousands separators, math
|
||||
// expressions): every such rule is a second place for the two languages to
|
||||
// disagree. Number Pixaroma owns math expressions; this node does not.
|
||||
|
||||
export const TYPES = ["text", "int", "float", "bool"];
|
||||
|
||||
// Shown on the socket and in the type chips. The socket word is short on
|
||||
// purpose - it sits on the node row next to the dot and must not eat width.
|
||||
export const TYPE_LABELS = {
|
||||
text: "Text",
|
||||
int: "Whole number",
|
||||
float: "Decimal",
|
||||
bool: "On / off",
|
||||
};
|
||||
export const SOCKET_LABELS = { text: "text", int: "int", float: "float", bool: "on/off" };
|
||||
|
||||
// What LiteGraph should call the output slot, so the canvas refuses a wrong
|
||||
// drag. Python declares ANY; this is the frontend half of that story.
|
||||
export const SOCKET_TYPES = { text: "STRING", int: "INT", float: "FLOAT", bool: "BOOLEAN" };
|
||||
|
||||
// ⚠ The SAVED type above is not the type the canvas may use for a drag.
|
||||
// A plain "STRING" output CANNOT be dragged onto a COMBO widget input -
|
||||
// LiteGraph's isValidConnection("STRING", "COMBO") is false (measured) - and a
|
||||
// COMBO widget is exactly what a sampler_name / scheduler / ckpt_name is. So
|
||||
// the node could drive them only if the wire already existed in the file: once
|
||||
// the user disconnected one, it could never be reconnected by hand. That is the
|
||||
// main thing this node is for, so the type has to say COMBO as well.
|
||||
//
|
||||
// LiteGraph reads a comma-joined type as a LIST of accepted names (the same
|
||||
// mechanism behind core's multi-type inputs, Vue Compat #22), so this keeps the
|
||||
// real refusals: "STRING,COMBO" -> COMBO true, -> STRING true, -> IMAGE FALSE.
|
||||
//
|
||||
// Only `text` gets it. A ComfyUI combo carries STRINGS, so an int/float/bool
|
||||
// Dropdown has no business claiming it can feed one.
|
||||
//
|
||||
// The suffix is a DRAG-TIME concern only and is stripped again in the
|
||||
// `serialize` wrap (js/dropdown/index.js), so saved workflows keep the exact
|
||||
// output type they have always had and no existing file is flagged modified
|
||||
// just by being opened (Vue Compat #18).
|
||||
export const WIRE_SUFFIX = ",COMBO";
|
||||
export const WIRE_TYPES = { ...SOCKET_TYPES, text: SOCKET_TYPES.text + WIRE_SUFFIX };
|
||||
|
||||
export const FALLBACKS = { text: "", int: 0, float: 0.0, bool: false };
|
||||
|
||||
const TRUE_WORDS = new Set(["true", "yes", "on", "y", "t"]);
|
||||
const FALSE_WORDS = new Set(["false", "no", "off", "n", "f"]);
|
||||
|
||||
// Same clamp as the Python side and as Control Panel's _value_of.
|
||||
const LIMIT = 1e12;
|
||||
|
||||
// THE shared number grammar, character for character the same as _NUMBER_RE in
|
||||
// _dropdown_helpers.py. Deliberately NOT Number(): a parity run over 228 cases
|
||||
// caught Number("0x10") returning 16 where Python's float() refuses it, so the
|
||||
// panel said "sends 16" and the run sent 0. Python likewise accepts "1_0",
|
||||
// which Number() refuses. Neither native parser is the contract; this is.
|
||||
// accepts: 5 5. .5 5.5 +5 -3 1e3 1E3 -1e3
|
||||
// refuses: 0x10 0b1 1_0 1,024 1024px abc Infinity NaN (and "")
|
||||
const NUMBER_RE = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
|
||||
|
||||
// Halves go AWAY from zero. Math.round breaks ties toward +Infinity
|
||||
// (Math.round(-3.5) === -3) and Python's round() is banker's rounding
|
||||
// (round(2.5) === 2); they disagree on every exact half. Half-away-from-zero is
|
||||
// also what someone typing 2.5 into a whole-number list means.
|
||||
function roundHalfAway(value) {
|
||||
return value >= 0 ? Math.floor(value + 0.5) : -Math.floor(-value + 0.5);
|
||||
}
|
||||
|
||||
// A number -> the string Python will emit for it in text mode. Python's str()
|
||||
// on a whole float keeps a '.0' that JS drops; _number_to_text there matches
|
||||
// THIS, so both show what the browser shows.
|
||||
function numberToText(value) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function normalizeType(kind) {
|
||||
if (typeof kind !== "string") return "text";
|
||||
const k = kind.trim().toLowerCase();
|
||||
if (TYPES.includes(k)) return k;
|
||||
if (k === "string" || k === "str") return "text";
|
||||
if (k === "integer" || k === "whole") return "int";
|
||||
if (k === "decimal" || k === "number" || k === "double") return "float";
|
||||
if (k === "boolean" || k === "toggle" || k === "onoff" || k === "on/off") return "bool";
|
||||
return "text";
|
||||
}
|
||||
|
||||
// raw -> finite number, or null. Mirrors _as_number.
|
||||
function asNumber(raw) {
|
||||
if (typeof raw === "boolean") return raw ? 1 : 0;
|
||||
if (typeof raw === "number") return Number.isFinite(raw) ? raw : null;
|
||||
if (typeof raw === "string") {
|
||||
const text = raw.trim();
|
||||
// The grammar needs at least one digit, so this covers "" and " " too.
|
||||
if (!NUMBER_RE.test(text)) return null;
|
||||
const value = Number(text);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readable(raw, kind) {
|
||||
kind = normalizeType(kind);
|
||||
if (kind === "text") return true;
|
||||
if (kind === "bool") {
|
||||
if (typeof raw === "boolean") return true;
|
||||
if (typeof raw === "string") {
|
||||
const w = raw.trim().toLowerCase();
|
||||
if (TRUE_WORDS.has(w) || FALSE_WORDS.has(w)) return true;
|
||||
}
|
||||
// The clamp cannot change a zero/non-zero answer, so magnitude is
|
||||
// irrelevant for on/off.
|
||||
return asNumber(raw) !== null;
|
||||
}
|
||||
const n = asNumber(raw);
|
||||
if (n === null) return false;
|
||||
// A value the clamp would MOVE is not readable, even though it parsed. See
|
||||
// the matching note in _dropdown_helpers.py: without this a 15-digit seed got
|
||||
// no warning mark and the run then sent 1000000000000 instead.
|
||||
return n >= -LIMIT && n <= LIMIT;
|
||||
}
|
||||
|
||||
export function coerceValue(raw, kind) {
|
||||
kind = normalizeType(kind);
|
||||
|
||||
if (kind === "text") {
|
||||
if (raw == null) return "";
|
||||
if (typeof raw === "string") return raw;
|
||||
// Match Python: emit the spelling a person would type, not the language's.
|
||||
if (typeof raw === "boolean") return raw ? "true" : "false";
|
||||
if (typeof raw === "number") return numberToText(raw);
|
||||
return String(raw);
|
||||
}
|
||||
|
||||
if (kind === "bool") {
|
||||
if (typeof raw === "boolean") return raw;
|
||||
if (typeof raw === "string") {
|
||||
const w = raw.trim().toLowerCase();
|
||||
if (TRUE_WORDS.has(w)) return true;
|
||||
if (FALSE_WORDS.has(w)) return false;
|
||||
}
|
||||
const n = asNumber(raw);
|
||||
if (n === null) return FALLBACKS.bool;
|
||||
return n !== 0;
|
||||
}
|
||||
|
||||
let n = asNumber(raw);
|
||||
if (n === null) return FALLBACKS[kind];
|
||||
n = Math.max(-LIMIT, Math.min(LIMIT, n));
|
||||
if (kind === "int") return roundHalfAway(n);
|
||||
return n;
|
||||
}
|
||||
|
||||
// A short, single-line rendering of a value for the node face and the popup
|
||||
// hint. A value may be multi-line (a trigger sentence), and a raw multi-line
|
||||
// string would blow apart a one-line row - so take the first line only.
|
||||
export function previewText(raw, kind) {
|
||||
const v = coerceValue(raw, kind);
|
||||
const s = typeof v === "string" ? v : String(v);
|
||||
const firstLine = s.split("\n")[0];
|
||||
return firstLine.length === s.length ? firstLine : firstLine + "…";
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
// Dropdown Pixaroma - state, defaults, and the output slot.
|
||||
//
|
||||
// The list and the type belong to the NODE, not to whatever it is wired to.
|
||||
// That is the whole difference from Control Panel, whose rows adopt the type of
|
||||
// their target, and it is why this node never reads its own connections to
|
||||
// decide anything.
|
||||
|
||||
import { isGraphLoading } from "../shared/graph_loading.mjs";
|
||||
import { accentOf } from "../shared/node_settings.mjs";
|
||||
import { slotAccepts } from "../shared/slot_types.mjs";
|
||||
import { SOCKET_TYPES, WIRE_TYPES, normalizeType } from "./coerce.mjs";
|
||||
|
||||
export const CLASS = "PixaromaDropdown";
|
||||
|
||||
// node.properties key (camelCase) and the Python INPUT_TYPES key (PascalCase).
|
||||
// They differ in case on purpose, matching every other Pixaroma node - and a
|
||||
// silent typo in the second one means Python always sees its default and the
|
||||
// node appears to ignore every change you make, so it is stated twice.
|
||||
export const STATE_PROP = "dropdownState";
|
||||
export const HIDDEN_INPUT = "DropdownState"; // matches the Python INPUT_TYPES key
|
||||
|
||||
// Geometry. Legacy and Nodes 2.0 both derive from these, so they are the one
|
||||
// place to tune the row.
|
||||
export const ROW_H = 26;
|
||||
export const MIN_W = 210;
|
||||
export const DEFAULT_W = 250;
|
||||
export const BODY_PAD = 7;
|
||||
|
||||
// A zero-width space. Truthy, so neither renderer falls back to painting the raw
|
||||
// slot name ("value") on top of our row, but nothing is actually drawn.
|
||||
// An empty string would fall through litegraph's `||` chain back to slot.name.
|
||||
// Written as an escape, NOT as a literal U+200B byte: an invisible character in
|
||||
// source is unreviewable and undiffable (it once cost this project a whole
|
||||
// debugging session in a regex).
|
||||
export const ZW = "\u200B";
|
||||
|
||||
export const OUT_NAME = "value";
|
||||
|
||||
// How many values one entry can carry. Python declares exactly this many ANY
|
||||
// outputs and always returns this many; the browser shows only the first
|
||||
// `outs.length` of them. Four covers sampler+scheduler, width+height,
|
||||
// steps+cfg and model+clip; raising it means changing RETURN_NAMES too.
|
||||
export const MAX_OUTS = 4;
|
||||
|
||||
/** Default name for output i (0-based). Output 1 keeps the historic "value". */
|
||||
export function defaultOutName(i) {
|
||||
return i === 0 ? OUT_NAME : `${OUT_NAME}_${i + 1}`;
|
||||
}
|
||||
|
||||
// How the node picks an entry when the workflow RUNS.
|
||||
// fixed - always the one you chose. The default, and the only mode that
|
||||
// leaves the node completely predictable.
|
||||
// increment - the next one down the list each run, wrapping at the end.
|
||||
// random - any one, never the same twice in a row when there are 2+.
|
||||
export const MODES = ["fixed", "increment", "random"];
|
||||
export const MODE_LETTERS = { fixed: "F", increment: "I", random: "R" };
|
||||
export const MODE_LABELS = {
|
||||
fixed: "Fixed - always the entry you picked",
|
||||
increment: "In order - the next entry each run, wrapping at the end",
|
||||
random: "Random - any entry each run",
|
||||
};
|
||||
|
||||
export function defaultState() {
|
||||
// `outs` is part of the IN-MEMORY shape and every reader may rely on it being
|
||||
// present - readState returns this object verbatim when a node has no state
|
||||
// yet, which is every freshly created node. writeState deletes it again when
|
||||
// it is trivial, so nothing extra is ever written to a saved workflow.
|
||||
return {
|
||||
version: 1, type: "text", index: 0, mode: "fixed", options: [],
|
||||
outs: [{ name: OUT_NAME, type: "text" }],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMode(m) {
|
||||
return MODES.includes(m) ? m : "fixed";
|
||||
}
|
||||
|
||||
/** node -> its state, always a valid object. Never trusts what it finds. */
|
||||
export function readState(node) {
|
||||
const raw = node?.properties?.[STATE_PROP];
|
||||
const st = defaultState();
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return st;
|
||||
|
||||
st.type = normalizeType(raw.type);
|
||||
st.mode = normalizeMode(raw.mode);
|
||||
|
||||
if (Array.isArray(raw.options)) {
|
||||
for (const o of raw.options) {
|
||||
// Drop a non-object row rather than letting it crash the list later.
|
||||
// Control Panel learned this one the hard way: a single null row aborted
|
||||
// value injection for every OTHER node of its type on the canvas.
|
||||
if (!o || typeof o !== "object" || Array.isArray(o)) continue;
|
||||
st.options.push({
|
||||
name: typeof o.name === "string" ? o.name : "",
|
||||
value: typeof o.value === "string" ? o.value : (o.value == null ? "" : String(o.value)),
|
||||
// Outputs 2..N. An entry saved before multi-output has no `v` at all,
|
||||
// which is exactly why there is nothing to migrate: it reads as an
|
||||
// empty list here and writeState drops it again on the way out.
|
||||
v: Array.isArray(o.v)
|
||||
? o.v.map((x) => (typeof x === "string" ? x : (x == null ? "" : String(x))))
|
||||
: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
st.outs = normalizeOuts(raw.outs, st.type);
|
||||
|
||||
const n = Number(raw.index);
|
||||
st.index = Number.isFinite(n) ? Math.max(0, Math.min(st.options.length - 1, Math.trunc(n))) : 0;
|
||||
if (!st.options.length) st.index = 0;
|
||||
return st;
|
||||
}
|
||||
|
||||
/**
|
||||
* raw.outs -> a valid array of {name, type}, always at least one entry long.
|
||||
*
|
||||
* Synthesised from `type` when absent, which is what makes an old single-output
|
||||
* state a valid multi-output state with no rewrite: the default IS the old
|
||||
* shape. Output 1's type mirrors `state.type` so the two can never disagree,
|
||||
* and Python keeps reading `type` exactly as it always has.
|
||||
*/
|
||||
function normalizeOuts(raw, type) {
|
||||
const outs = [];
|
||||
if (Array.isArray(raw)) {
|
||||
for (const o of raw.slice(0, MAX_OUTS)) {
|
||||
if (!o || typeof o !== "object" || Array.isArray(o)) continue;
|
||||
outs.push({ name: typeof o.name === "string" ? o.name : "", type: normalizeType(o.type) });
|
||||
}
|
||||
}
|
||||
if (!outs.length) outs.push({ name: OUT_NAME, type });
|
||||
outs[0].type = type;
|
||||
for (let i = 0; i < outs.length; i++) if (!outs[i].name) outs[i].name = defaultOutName(i);
|
||||
return outs;
|
||||
}
|
||||
|
||||
/** How many outputs this node currently shows. Always 1..MAX_OUTS. */
|
||||
export function outCount(node) {
|
||||
return readState(node).outs.length;
|
||||
}
|
||||
|
||||
/** One entry -> exactly `n` value strings, padded with "". */
|
||||
export function valuesOf(opt, n) {
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (i === 0) out.push(opt && typeof opt.value === "string" ? opt.value : "");
|
||||
else out.push(opt && Array.isArray(opt.v) && typeof opt.v[i - 1] === "string" ? opt.v[i - 1] : "");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Write value `i` (0-based) onto an entry, growing `v` only as far as needed. */
|
||||
export function setValueAt(opt, i, text) {
|
||||
const str = typeof text === "string" ? text : String(text == null ? "" : text);
|
||||
if (i === 0) { opt.value = str; return opt; }
|
||||
if (!Array.isArray(opt.v)) opt.v = [];
|
||||
while (opt.v.length < i) opt.v.push("");
|
||||
opt.v[i - 1] = str;
|
||||
return opt;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single write path. Everything that changes the list goes through here so
|
||||
* the stored index can never point at a row that is not there.
|
||||
*
|
||||
* Deliberately NOT diff-gated against the stored object: callers pass a patch
|
||||
* and we always re-normalize. It IS safe on the load path only because nothing
|
||||
* on the load path calls it - see the note in index.js.
|
||||
*/
|
||||
export function writeState(node, patch) {
|
||||
if (!node) return defaultState();
|
||||
if (!node.properties) node.properties = {};
|
||||
const cur = readState(node);
|
||||
const next = { ...cur, ...(patch || {}) };
|
||||
|
||||
next.version = 1;
|
||||
next.type = normalizeType(next.type);
|
||||
next.mode = normalizeMode(next.mode);
|
||||
next.outs = normalizeOuts(next.outs, next.type);
|
||||
next.options = Array.isArray(next.options) ? next.options.map((o) => {
|
||||
const row = {
|
||||
name: typeof o?.name === "string" ? o.name : "",
|
||||
value: typeof o?.value === "string" ? o.value : (o?.value == null ? "" : String(o.value)),
|
||||
};
|
||||
// Keep `v` only when it actually holds something. An entry that has never
|
||||
// had a second value stays byte-identical to how it was saved, so opening
|
||||
// an existing workflow cannot flag it modified (Vue Compat #18). Trailing
|
||||
// blanks are trimmed for the same reason.
|
||||
const v = Array.isArray(o?.v)
|
||||
? o.v.map((x) => (typeof x === "string" ? x : (x == null ? "" : String(x))))
|
||||
: [];
|
||||
while (v.length && v[v.length - 1] === "") v.pop();
|
||||
if (v.length) row.v = v;
|
||||
return row;
|
||||
}) : [];
|
||||
const n = Number(next.index);
|
||||
next.index = Number.isFinite(n) ? Math.max(0, Math.min(next.options.length - 1, Math.trunc(n))) : 0;
|
||||
if (!next.options.length) next.index = 0;
|
||||
|
||||
// A plain one-output Dropdown never grows an `outs` key, so its stored shape
|
||||
// is exactly what it was before this feature existed.
|
||||
if (next.outs.length <= 1 && next.outs[0].name === OUT_NAME) delete next.outs;
|
||||
|
||||
node.properties[STATE_PROP] = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** The currently selected option, or null when the list is empty. */
|
||||
export function selectedOption(node) {
|
||||
const st = readState(node);
|
||||
return st.options[st.index] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the browser sends Python. ONLY what changes the result.
|
||||
*
|
||||
* The injected string becomes part of the node's inputs, so ComfyUI hashes it:
|
||||
* anything in here that is really display-only would re-run the graph when it
|
||||
* changed. Renaming a row, recolouring the node, reordering, or editing a row
|
||||
* you have NOT selected must all be free. So the names, the rest of the list and
|
||||
* the accent stay out, and Python accepts this lean shape directly.
|
||||
*/
|
||||
/**
|
||||
* The index this BUILD should send.
|
||||
*
|
||||
* In Fixed mode that is simply the entry you chose. The other two modes derive
|
||||
* from a RUNTIME cursor (`node._pixDdCursor`) and NEVER touch node.properties:
|
||||
* a run that wrote the new position into the workflow would flag it modified
|
||||
* every single time you pressed Run, which is the trap Seed Pixaroma documents.
|
||||
* The cost is that the sequence restarts from your chosen entry after a page
|
||||
* reload, which is predictable and visible.
|
||||
*
|
||||
* The pick is HELD in `node._pixDdPending` until it is actually spent, so
|
||||
* calling this twice for one queue (graphToPrompt runs for Export, for Save and
|
||||
* for a queue that then fails validation) hands back the SAME entry.
|
||||
*/
|
||||
export function pendingIndex(node) {
|
||||
const st = readState(node);
|
||||
const n = st.options.length;
|
||||
if (!n) return 0;
|
||||
const clamp = (i) => Math.max(0, Math.min(i, n - 1));
|
||||
if (st.mode === "fixed") return clamp(st.index);
|
||||
|
||||
// A held pick stays valid only while it still points at a real row.
|
||||
if (Number.isInteger(node._pixDdPending) && node._pixDdPending < n) return node._pixDdPending;
|
||||
|
||||
let next;
|
||||
if (st.mode === "random") {
|
||||
// ALWAYS random, including the very first run after switching to R. The
|
||||
// first-run branch below is for In-order only: it exists so a sequence
|
||||
// starts where the user is looking, but applying it to Random made the
|
||||
// first Run send the entry already on the face while the panel promised
|
||||
// "a different entry at random each run".
|
||||
const avoid = Number.isInteger(node._pixDdCursor) ? clamp(node._pixDdCursor) : clamp(st.index);
|
||||
next = Math.floor(Math.random() * n);
|
||||
// Never the same entry twice running - with a two-entry list a repeat reads
|
||||
// as the mode not working at all.
|
||||
if (n > 1 && next === avoid) next = (next + 1 + Math.floor(Math.random() * (n - 1))) % n;
|
||||
} else if (!Number.isInteger(node._pixDdCursor)) {
|
||||
// First run after a load: send what the node is showing, THEN start moving.
|
||||
next = clamp(st.index);
|
||||
} else {
|
||||
next = (clamp(node._pixDdCursor) + 1) % n;
|
||||
}
|
||||
node._pixDdPending = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend the held pick. Called ONLY when a queue is actually accepted, so an
|
||||
* Export or a rejected queue does not move an "In order" list on.
|
||||
*/
|
||||
export function commitPick(node) {
|
||||
if (Number.isInteger(node._pixDdPending)) {
|
||||
node._pixDdCursor = node._pixDdPending;
|
||||
node._pixDdPending = null;
|
||||
} else if (readState(node).mode === "fixed") {
|
||||
node._pixDdCursor = null; // Fixed does not accumulate a position
|
||||
}
|
||||
}
|
||||
|
||||
/** What the node face should show: the pick that is queued or last ran. */
|
||||
export function shownIndex(node) {
|
||||
const st = readState(node);
|
||||
const n = st.options.length;
|
||||
if (!n) return 0;
|
||||
const clamp = (i) => Math.max(0, Math.min(i, n - 1));
|
||||
if (st.mode === "fixed") return clamp(st.index);
|
||||
if (Number.isInteger(node._pixDdPending) && node._pixDdPending < n) return node._pixDdPending;
|
||||
if (Number.isInteger(node._pixDdCursor)) return clamp(node._pixDdCursor);
|
||||
return clamp(st.index);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the browser sends Python. ONLY what changes the result.
|
||||
*
|
||||
* The injected string becomes part of the node's inputs, so ComfyUI hashes it:
|
||||
* anything in here that is really display-only would re-run the graph when it
|
||||
* changed. Renaming a row, recolouring the node, reordering, or editing a row
|
||||
* you have NOT selected must all be free. So the names, the rest of the list,
|
||||
* the mode and the accent stay out, and Python accepts this lean shape directly.
|
||||
*/
|
||||
export function injectedState(node, indexOverride) {
|
||||
const st = readState(node);
|
||||
// XY Plot names the entry for the square being rendered; everything else
|
||||
// takes whatever this build is due to send.
|
||||
const idx = Number.isInteger(indexOverride) ? indexOverride : pendingIndex(node);
|
||||
const opt = st.options[idx];
|
||||
|
||||
// ONE output: emit the historic shape, byte for byte. The injected string is
|
||||
// the node's cache key, so any change here would re-run every existing
|
||||
// workflow the first time it was opened after an update. Multi-output is
|
||||
// strictly additive precisely so this line can stay as it was.
|
||||
if (st.outs.length <= 1) {
|
||||
return { version: 1, type: st.type, value: opt ? opt.value : null };
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
types: st.outs.map((o) => o.type),
|
||||
values: valuesOf(opt, st.outs.length),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the chosen type on the output slot so the CANVAS refuses an incompatible
|
||||
* drag. Python declares ANY; this is the frontend half of that, and there is no
|
||||
* second server-side check behind it.
|
||||
*
|
||||
* Every write is diff-gated. Slots are serialized, and re-writing an identical
|
||||
* value still counts as a change on some builds, which would flag a clean
|
||||
* workflow "modified" the moment it was opened (Vue Compat #18).
|
||||
*/
|
||||
export function syncOutputs(node) {
|
||||
if (!node?.outputs) return;
|
||||
const outs = readState(node).outs;
|
||||
const n = outs.length;
|
||||
|
||||
// Grow/shrink to match.
|
||||
//
|
||||
// ⚠ The trim is NOT a no-op on load, and an earlier version of this comment
|
||||
// wrongly said it was. Python always declares MAX_OUTS, so the ComfyNode
|
||||
// CONSTRUCTOR (`addOutputs(this, nodeData.outputs)`) has already built FOUR
|
||||
// slots before `configure` runs, and nothing on the load path shrinks them
|
||||
// back: `configure` zips the def slots onto the saved ones, and that zip pads
|
||||
// to the LONGER array. Either mechanism alone yields four, so a saved
|
||||
// ONE-output Dropdown arrives here carrying four regardless.
|
||||
// Trimming is what makes serialize() match the file again; delete it and every
|
||||
// workflow containing this node opens flagged "modified" (Vue Compat #18).
|
||||
// (Verified by executing the frontend's own configure, 1.49.6.)
|
||||
//
|
||||
// The three phantom slots carry `links: null`, which is why the gate below
|
||||
// never trips on them and only a genuinely damaged file reaches it.
|
||||
while (node.outputs.length > n) {
|
||||
const last = node.outputs[node.outputs.length - 1];
|
||||
// NEVER destroy a wire because a file was merely OPENED. removeOutput calls
|
||||
// disconnectOutput whenever node.graph is set, and by onConfigure time the
|
||||
// graph, its links and every target node all exist - so without this gate a
|
||||
// state that disagreed with the saved slot count silently cut those wires
|
||||
// on load. That happens for real on a version skew: a 4-output Dropdown
|
||||
// saved by a newer build, re-saved by an older one (which drops `outs`
|
||||
// while keeping the 4 slots and links), then reopened here.
|
||||
//
|
||||
// A state that disagrees with the saved slots is a DAMAGED FILE, not an
|
||||
// instruction to cut. Leave the extra slots in place - they are recoverable
|
||||
// by setting the output count back up, and the values are still stored - and
|
||||
// say so rather than doing it invisibly.
|
||||
if (isGraphLoading() && Array.isArray(last?.links) && last.links.length) {
|
||||
console.warn(
|
||||
`[Pixaroma.Dropdown] node ${node.id}: the saved workflow has `
|
||||
+ `${node.outputs.length} outputs with wires attached but its settings say `
|
||||
+ `${n}. Keeping the wires. Set the output count back to `
|
||||
+ `${node.outputs.length} in the node's settings to use them.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
node.removeOutput?.(node.outputs.length - 1);
|
||||
}
|
||||
while (node.outputs.length < n && node.addOutput) node.addOutput(defaultOutName(node.outputs.length), "*");
|
||||
|
||||
for (let i = 0; i < n && i < node.outputs.length; i++) {
|
||||
const out = node.outputs[i];
|
||||
// WIRE_TYPES, not SOCKET_TYPES: a plain "STRING" cannot be DRAGGED onto a
|
||||
// COMBO widget input (sampler_name, ckpt_name, ...). Stripped back to the
|
||||
// plain name on serialize, so the saved file is unchanged. See coerce.mjs.
|
||||
const want = WIRE_TYPES[outs[i].type] || "*";
|
||||
const nm = outs[i].name || defaultOutName(i);
|
||||
// Every write diff-gated: slots are serialized, and rewriting an identical
|
||||
// value still counts as a change on some builds (Vue Compat #18).
|
||||
if (out.name !== nm) out.name = nm;
|
||||
if (out.label !== ZW) out.label = ZW;
|
||||
if (out.type !== want) out.type = want;
|
||||
}
|
||||
}
|
||||
|
||||
/** Kept so older call sites keep working; the node has had N outputs since. */
|
||||
export const syncOutput = syncOutputs;
|
||||
|
||||
/**
|
||||
* Drop a wire the new type can no longer feed. A real user action ONLY.
|
||||
*
|
||||
* Returns the number of links cut so the caller can say so - silently severing
|
||||
* a connection the user cannot see them lose is how a workflow quietly stops
|
||||
* working. Never runs during a load: the saved graph is by definition already
|
||||
* consistent, and cutting there would damage a file just by opening it.
|
||||
*/
|
||||
export function dropIncompatibleLinks(node) {
|
||||
if (!node?.outputs?.length || isGraphLoading()) return 0;
|
||||
const graph = node.graph;
|
||||
if (!graph) return 0;
|
||||
let cut = 0;
|
||||
for (const out of node.outputs) cut += dropOnOutput(graph, out);
|
||||
return cut;
|
||||
}
|
||||
|
||||
function dropOnOutput(graph, out) {
|
||||
const links = Array.isArray(out?.links) ? out.links.slice() : [];
|
||||
if (!links.length) return 0;
|
||||
const want = out.type;
|
||||
let cut = 0;
|
||||
|
||||
for (const id of links) {
|
||||
let link = graph.links?.[id];
|
||||
// graph.links can be a Map on newer frontends (Vue Compat #3).
|
||||
if (!link && typeof graph.links?.get === "function") link = graph.links.get(id);
|
||||
if (!link) continue;
|
||||
const target = graph.getNodeById?.(link.target_id);
|
||||
const slot = target?.inputs?.[link.target_slot];
|
||||
if (!slot) continue;
|
||||
const accepts = slot.type;
|
||||
// slotAccepts, not "===": it covers the "*" wildcard on either side (Reroute,
|
||||
// Set/Get, Preview Any) AND a ComfyUI multi-type input, which arrives as the
|
||||
// comma-joined "FLOAT,INT,BOOLEAN" (core's Math Expression). An equality test
|
||||
// read that as one unknown name and cut a wire the user had just drawn.
|
||||
if (slotAccepts(accepts, want)) continue;
|
||||
target.disconnectInput?.(link.target_slot);
|
||||
cut++;
|
||||
}
|
||||
return cut;
|
||||
}
|
||||
|
||||
export { accentOf };
|
||||
@@ -1,137 +0,0 @@
|
||||
// Dropdown Pixaroma - the help page.
|
||||
//
|
||||
// Kept beside the node rather than in the central help_defs.mjs map, the way
|
||||
// Compare / Text / XY Plot / Find and Replace do, because it is written against
|
||||
// this node's own behaviour and should move with it.
|
||||
//
|
||||
// Written for someone making pictures, not for someone reading the code: what it
|
||||
// does, how to use it, and what actually comes out. No em dashes (house rule).
|
||||
|
||||
export const DROPDOWN_HELP = {
|
||||
title: "Dropdown Pixaroma",
|
||||
tagline: "A dropdown you fill in yourself: pick a short name, send a long value.",
|
||||
sections: [
|
||||
{
|
||||
heading: "What it is for",
|
||||
body:
|
||||
"Some values are long and you retype them constantly. A LoRA might want a whole "
|
||||
+ "sentence as its trigger. A look you like might be a paragraph of prompt.\n\n"
|
||||
+ "Put them in this node once, give each one a short name, and after that you just "
|
||||
+ "pick the name. The node sends the long value on your behalf.",
|
||||
},
|
||||
{
|
||||
heading: "Setting it up",
|
||||
bullets: [
|
||||
"Press the gear on the node, or right click it, to open the settings.",
|
||||
"An empty list offers `Add your first entry`. Fill in a short name and the value it stands for.",
|
||||
"The value box grows as you type, so a long sentence or several lines are fine.",
|
||||
"The `+` on a row adds another one below it, the `✕` removes that row, and the `⋮⋮` grip drags it up or down.",
|
||||
"`Clear list` at the bottom empties the whole list in one go, after asking. Export first if you might want it back.",
|
||||
"Close the settings. Your list is saved inside the workflow.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Choosing on the canvas",
|
||||
body:
|
||||
"The node itself is one row, plus one read-only row per output when you use more than one. Click it to open your list, or use the `◀` and `▶` "
|
||||
+ "arrows to step through without opening anything.\n\n"
|
||||
+ "The list shows just your names, sized so they fit, and its text follows your zoom "
|
||||
+ "so it stays in step with the node. Hold the mouse over an entry to peek at its "
|
||||
+ "value, or open the settings to see everything.",
|
||||
},
|
||||
{
|
||||
heading: "What comes out",
|
||||
body:
|
||||
"Pick one of four in the settings, and the node's output says which one is active:",
|
||||
table: {
|
||||
headers: ["Setting", "Sends", "Good for"],
|
||||
rows: [
|
||||
["Text", "`text`", "Trigger words, prompt fragments, file names"],
|
||||
["Whole number", "`int`", "Sizes, steps, frame counts"],
|
||||
["Decimal", "`float`", "Strengths, cfg, denoise"],
|
||||
["On / off", "`on/off`", "Any true or false setting"],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "One pick, several values",
|
||||
body:
|
||||
"An entry can carry up to four values at once. In the settings, set how many outputs you "
|
||||
+ "want, give each one a name, and every entry then holds one value per output. Picking "
|
||||
+ "an entry sets all of them together, and the node shows what it resolved to before you "
|
||||
+ "run anything.\n\n"
|
||||
+ "This is what makes settings that belong together stay together. A sampler and its "
|
||||
+ "scheduler are the clearest case: some pairings work and some ruin the picture, so "
|
||||
+ "holding them as one named choice means you cannot accidentally mix a good sampler "
|
||||
+ "with the wrong scheduler.",
|
||||
table: {
|
||||
headers: ["Entry name", "sampler", "scheduler"],
|
||||
rows: [
|
||||
["Safe", "`euler`", "`simple`"],
|
||||
["Alternative look", "`euler`", "`beta`"],
|
||||
["Avoid", "`res_multistep`", "`ddim_uniform`"],
|
||||
],
|
||||
},
|
||||
bullets: [
|
||||
"Each output has its own type, so you can mix text with numbers and on/off.",
|
||||
"Leave it on one output and the node behaves exactly as it always has.",
|
||||
"Turning the count down keeps the values you typed, in case you turn it back up.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "Changing entry on its own each run",
|
||||
body:
|
||||
"The small letter on the node says which entry it will send when you press Run, and you "
|
||||
+ "can click it to change: `F`, then `I`, then `R`, then back again. The same three "
|
||||
+ "buttons are in the settings under `EACH TIME YOU RUN`.",
|
||||
defs: [
|
||||
["`F` Fixed", "Always the entry you picked. This is the default, and the node stays completely predictable."],
|
||||
["`I` In order", "The next entry down the list on each run, going back to the top after the last one. Good for working through a list of looks without touching anything."],
|
||||
["`R` Random", "Any entry each run, never the same one twice in a row."],
|
||||
],
|
||||
bullets: [
|
||||
"`F` is drawn quietly since it is the normal way to work. `I` and `R` are filled in, because those change the value on you, and the name on the node then updates to whatever just ran.",
|
||||
"On `I`, picking an entry by hand takes over: the next run starts from what you chose and carries on from there. On `R` it only means that entry will not be the next one, since random is random.",
|
||||
"Exporting or saving your workflow does not move an in-order list along. Only a real run does.",
|
||||
"The position starts again from your chosen entry when you reload the page, which is also why running a workflow never marks it as changed.",
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "It ignores what it is plugged into",
|
||||
body:
|
||||
"The list and the type belong to this node. Wiring it somewhere else never changes "
|
||||
+ "them.\n\n"
|
||||
+ "That is the difference from Control Panel Pixaroma, whose controls copy the type of "
|
||||
+ "whatever they are wired to. Use Control Panel when you want a dial for one specific "
|
||||
+ "input. Use this when you want your own named list that you can plug in anywhere.",
|
||||
},
|
||||
{
|
||||
heading: "Changing the type later",
|
||||
body:
|
||||
"Nothing is ever deleted. If you switch a list of sentences to Whole number, your "
|
||||
+ "sentences stay exactly as you typed them, and the rows that are not numbers get a "
|
||||
+ "small warning mark. Those rows send 0 until you change them, and switching back to "
|
||||
+ "Text brings everything straight back.\n\n"
|
||||
+ "Anything plugged into the output that no longer fits is unplugged, and the node "
|
||||
+ "tells you how many wires that was.",
|
||||
},
|
||||
{
|
||||
heading: "Comparing every entry at once",
|
||||
body:
|
||||
"XY Plot Pixaroma can drive this node. Pick your Dropdown as an axis and tick the "
|
||||
+ "entries you want, and you get one square per entry with the names written along "
|
||||
+ "the edge.\n\n"
|
||||
+ "That is the quick way to see four LoRA triggers side by side without wiring "
|
||||
+ "anything four times.",
|
||||
},
|
||||
{
|
||||
heading: "Sharing a list",
|
||||
bullets: [
|
||||
"The list saves inside the workflow, so sending someone the workflow sends the entries with it.",
|
||||
"`Export` writes the list to a file. `Import` loads one back and replaces what is there.",
|
||||
"Copying the node copies its list too.",
|
||||
],
|
||||
},
|
||||
],
|
||||
footer: "Find it under 👑 Pixaroma / 🔢 Values, or search for dropdown, list, options, preset, combination, pair or trigger.",
|
||||
};
|
||||