Compare commits
118
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2285166ef1 | ||
|
|
cb6b947f75 | ||
|
|
28c4f6aeb5 | ||
|
|
3a4ce52760 | ||
|
|
e761b3dd0b | ||
|
|
3b0baf351a | ||
|
|
50389b0cdb | ||
|
|
28dd526447 | ||
|
|
abf6cecd52 | ||
|
|
12e7d8c33f | ||
|
|
de95db228c | ||
|
|
93f64d07d7 | ||
|
|
b3a3d7a8ca | ||
|
|
c1d6136663 | ||
|
|
515b9ea4bd | ||
|
|
c37386c1d4 | ||
|
|
7cb0abaacf | ||
|
|
939d320b8b | ||
|
|
9bb8a201fe | ||
|
|
52c3353c69 | ||
|
|
ded731859c | ||
|
|
e14682f54f | ||
|
|
1129232f83 | ||
|
|
9ba82c45b4 | ||
|
|
099b57f755 | ||
|
|
f27e1293b0 | ||
|
|
75621c341f | ||
|
|
ba8d2b8048 | ||
|
|
00164f1df5 | ||
|
|
2316afee62 | ||
|
|
6b1b1cfabc | ||
|
|
aaf63b9d9c | ||
|
|
17fb493a66 | ||
|
|
3e2ae322f8 | ||
|
|
a61d3d4ee0 | ||
|
|
dff08a3b6e | ||
|
|
91b53162cc | ||
|
|
1ea57b6538 | ||
|
|
6d6876dd38 | ||
|
|
f9e17c1fdd | ||
|
|
21ca6e9f47 | ||
|
|
5a41046e3f | ||
|
|
264c133618 | ||
|
|
310d2ba5c3 | ||
|
|
b5bce60c22 | ||
|
|
e532e56ed9 | ||
|
|
f7d6596937 | ||
|
|
5f32967ee5 | ||
|
|
b01e9a4c70 | ||
|
|
c669f1100d | ||
|
|
39da7a3931 | ||
|
|
8b75948359 | ||
|
|
33ce662398 | ||
|
|
7f7481c505 | ||
|
|
68f4f4b301 | ||
|
|
ff6706a918 | ||
|
|
4afb60c921 | ||
|
|
c4a64366a6 | ||
|
|
e4b9a08cec | ||
|
|
a5358c4f78 | ||
|
|
d4df8f7508 | ||
|
|
e93f3337a1 | ||
|
|
d0658ea8b2 | ||
|
|
0853872174 | ||
|
|
2545b82219 | ||
|
|
13145ed881 | ||
|
|
fe04d93afa | ||
|
|
4591dd4ef9 | ||
|
|
cd05a91e14 | ||
|
|
1faca35331 | ||
|
|
3e79954bbe | ||
|
|
8837c75553 | ||
|
|
69fbceb0a1 | ||
|
|
714b638ee8 | ||
|
|
5d00076254 | ||
|
|
8b7a717004 | ||
|
|
6af50147fc | ||
|
|
c54ee8984f | ||
|
|
3ba27901cb | ||
|
|
f131718cf8 | ||
|
|
e8419b2667 | ||
|
|
ef851094ba | ||
|
|
9aefc53134 | ||
|
|
1ec9027413 | ||
|
|
641b907796 | ||
|
|
1985103fa2 | ||
|
|
acad58b613 | ||
|
|
15718c07ee | ||
|
|
438ab30b79 | ||
|
|
a37dfb93b6 | ||
|
|
a6ea043462 | ||
|
|
da98850591 | ||
|
|
812a22c070 | ||
|
|
0afae99b30 | ||
|
|
840f0003dd | ||
|
|
7469c5869e | ||
|
|
a1b14cc4a4 | ||
|
|
1051bee665 | ||
|
|
0a098e1027 | ||
|
|
8f6ac16d27 | ||
|
|
e808f4ff51 | ||
|
|
35651e3445 | ||
|
|
571f4f1896 | ||
|
|
6c705b2e42 | ||
|
|
b063e6f2c1 | ||
|
|
9917dc3d15 | ||
|
|
de88c8d5e4 | ||
|
|
8062c35f70 | ||
|
|
1f07ad8ba1 | ||
|
|
b6012c48fb | ||
|
|
cc5617db04 | ||
|
|
3d94052489 | ||
|
|
5b8cad57d7 | ||
|
|
2b34005150 | ||
|
|
48a2ce3b33 | ||
|
|
c017554d05 | ||
|
|
249275b78d | ||
|
|
adf9cda5c0 |
@@ -11,6 +11,7 @@ on:
|
||||
# version in pyproject.toml, or on a manual run from main. Editing another
|
||||
# pyproject field (description, keywords) without a new version publishes
|
||||
# nothing, and the offline checks must pass before anything is uploaded.
|
||||
# A successful upload also creates the version's tag and GitHub Release.
|
||||
# Requires the repository secret REGISTRY_ACCESS_TOKEN, an API key created
|
||||
# for the "ausboss" publisher at registry.comfy.org.
|
||||
permissions:
|
||||
@@ -28,6 +29,7 @@ jobs:
|
||||
if: ${{ github.repository == 'ausboss/ComfyUI-AusBoss' && github.ref == 'refs/heads/main' }}
|
||||
outputs:
|
||||
publish: ${{ steps.version.outputs.publish }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
@@ -44,6 +46,7 @@ jobs:
|
||||
run: |
|
||||
read_version='import sys, tomllib; print(tomllib.loads(sys.stdin.read())["project"]["version"])'
|
||||
current=$(python -c "$read_version" < pyproject.toml)
|
||||
echo "version=$current" >> "$GITHUB_OUTPUT"
|
||||
previous=""
|
||||
if [ -n "$BEFORE" ] && git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then
|
||||
previous=$(git show "$BEFORE:pyproject.toml" | python -c "$read_version")
|
||||
@@ -84,3 +87,33 @@ jobs:
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
|
||||
github-release:
|
||||
name: Create GitHub Release
|
||||
needs: [check, publish-node]
|
||||
if: ${{ needs.check.outputs.publish == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Publish release notes and tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
VERSION: ${{ needs.check.outputs.version }}
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
if gh release view "$tag" >/dev/null 2>&1; then
|
||||
echo "Release $tag already exists; leaving it unchanged."
|
||||
exit 0
|
||||
fi
|
||||
python scripts/changelog_notes.py --version "$VERSION" --full > "$RUNNER_TEMP/release-notes.md"
|
||||
gh release create "$tag" --target "$GITHUB_SHA" --title "$VERSION" \
|
||||
--notes-file "$RUNNER_TEMP/release-notes.md" --latest
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Publish Model Downloader addon
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
addon_commit:
|
||||
description: Full commit SHA from ComfyUI-AusBoss-Model-Downloader main
|
||||
required: true
|
||||
type: string
|
||||
|
||||
# The existing AusBoss publisher credential stays in this repository. This
|
||||
# manual job publishes only the separately maintained Model Downloader addon.
|
||||
# It does not release or change the main AusBoss node pack.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: registry-publish-model-downloader
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.repository == 'ausboss/ComfyUI-AusBoss' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Require an exact addon commit
|
||||
env:
|
||||
ADDON_COMMIT: ${{ inputs.addon_commit }}
|
||||
run: '[[ "$ADDON_COMMIT" =~ ^[0-9a-f]{40}$ ]]'
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
repository: ausboss/ComfyUI-AusBoss-Model-Downloader
|
||||
ref: ${{ inputs.addon_commit }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Require a commit merged into addon main
|
||||
run: git merge-base --is-ancestor HEAD origin/main
|
||||
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: '24'
|
||||
package-manager-cache: false
|
||||
- name: Install validation and publishing tools
|
||||
run: python -m pip install -r requirements-dev.txt comfy-cli==1.25.0
|
||||
- name: Offline checks
|
||||
run: |
|
||||
python -m unittest discover -s tests -v
|
||||
ruff check --select F,E9 __init__.py downloader tests scripts
|
||||
node --test tests/*.test.mjs
|
||||
node --check js/model_downloader/index.js
|
||||
node --check js/shared/download_controls.mjs
|
||||
- name: Build and inspect Registry package
|
||||
run: |
|
||||
comfy --skip-prompt --no-enable-telemetry node pack
|
||||
python scripts/check_registry_package.py
|
||||
- name: Refuse an existing immutable version
|
||||
run: python scripts/registry_status.py --require-unpublished
|
||||
- name: Publish addon
|
||||
id: publish
|
||||
env:
|
||||
REGISTRY_ACCESS_TOKEN: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
COMFY_NODE_CHANGELOG: 'Initial release: model downloads from Civitai and Hugging Face, installed-file checks, saved model lists, and AusBoss Workflow Note integration.'
|
||||
run: |
|
||||
if [ -z "$REGISTRY_ACCESS_TOKEN" ]; then
|
||||
echo '::error::REGISTRY_ACCESS_TOKEN is not configured.'
|
||||
exit 1
|
||||
fi
|
||||
comfy --skip-prompt --no-enable-telemetry node publish --token "$REGISTRY_ACCESS_TOKEN"
|
||||
- name: Report Registry review status
|
||||
if: always() && steps.publish.outcome != 'skipped'
|
||||
run: python scripts/registry_status.py --report
|
||||
@@ -1,334 +1,12 @@
|
||||
# ComfyUI-AusBoss contributor guide
|
||||
# ComfyUI-AusBoss: start here
|
||||
|
||||
Rules for contributors: how the repo is laid out, the conventions every node
|
||||
follows, and the checks to run.
|
||||
A pack of ComfyUI custom nodes by ausboss. Two kinds of agents read this.
|
||||
|
||||
A suite of polished ComfyUI custom nodes by ausboss. Public nodes must solve a
|
||||
repeated workflow need, keep a compact graph footprint, and pass backend plus
|
||||
browser acceptance before release.
|
||||
|
||||
## READ FIRST: write for the people who use the nodes
|
||||
|
||||
Everything a user reads starts with the plainest version. That covers node
|
||||
descriptions, tooltips, the `?` help pages (`js/docs/*.md`), the README,
|
||||
example workflow notes, report and error text, and the CHANGELOG. Get more
|
||||
technical later in the text, or in its own section.
|
||||
|
||||
- Open with what it does and when to use it, in everyday words and short
|
||||
sentences. Someone new to ComfyUI should get it on the first read.
|
||||
- A tooltip is one or two short sentences: what this is, and what to pick.
|
||||
- Measurements, test results, edge cases and how it works go further down,
|
||||
under a heading such as "Technical details".
|
||||
- Keep jargon out of the opening (resample, warp, frame, affine,
|
||||
estimator, canvas space). If a term is needed, say what it means the
|
||||
first time.
|
||||
- Put a number up top only when it helps someone decide what to do.
|
||||
|
||||
If a sentence needs a second read, rewrite it.
|
||||
|
||||
## Which nodes ship
|
||||
|
||||
A node ships only when its mapping key is listed in `PUBLIC_NODE_IDS` in
|
||||
`scripts/validate_nodes.py`; the validator fails on any registered key that
|
||||
is not listed there.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- Never modify `LICENSE`.
|
||||
- Never bump `version` in `pyproject.toml` — a version bump that lands on
|
||||
main **publishes to the Comfy Registry automatically** (see Releasing).
|
||||
- Keep diffs minimal: touch only the lines the task needs.
|
||||
|
||||
## Third-party independence
|
||||
|
||||
- Never copy third-party code, assets, fonts, icons, CSS, or documentation.
|
||||
- Review ecosystem overlap before accepting a public node. Generic overlap is
|
||||
fine, but implementation, naming, interaction design, and documentation must
|
||||
be this repository's own work.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
__init__.py # NODE_MODULES list → importlib merge of all mappings.
|
||||
# Fail-soft: a broken module logs and is skipped, the rest load.
|
||||
nodes/
|
||||
node_<name>.py # exactly one node (or one tight family) per file;
|
||||
# exports NODE_CLASS_MAPPINGS + NODE_DISPLAY_NAME_MAPPINGS
|
||||
_<topic>_helpers.py # shared backend logic, underscore prefix = not a node
|
||||
js/
|
||||
<name>/index.js # frontend entry per node or pack-wide feature, e.g.
|
||||
# appearance/ (.js files auto-load)
|
||||
shared/*.mjs # import-only shared modules (.mjs files do NOT auto-load)
|
||||
docs/ # developer docs
|
||||
scripts/ # offline checks in stdlib Python. validate_nodes.py is
|
||||
# the entry point; registry_contract.py holds the rules
|
||||
# that keep nodes visible to registry scanners;
|
||||
# registry_status.py reads the Registry API; dev/ is the
|
||||
# Node.js canvas harness (docs/live_testing.md).
|
||||
example_workflows/ # example workflows (regular workflow JSON, not API JSON)
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Public mapping keys use `AUSBOSS_NODES_<Purpose>`. The mapping key is the
|
||||
workflow-compatibility contract and must never be renamed after release.
|
||||
- Inputs and outputs only grow after a release: append an input as
|
||||
optional, append an output, and never rename, reorder, remove or newly
|
||||
require one. Saved workflows keep widget values by position and links by
|
||||
slot, and an API prompt must carry every required input.
|
||||
`tests/test_node_api.py` holds the pack to `tests/fixtures/node_api.json`;
|
||||
refresh that snapshot with the test's `--update` after a compatible change
|
||||
or a new node.
|
||||
- Appending an input to a node with a card or panel takes one more step.
|
||||
Workflows saved by earlier releases end that node's values with an empty
|
||||
value for each card and panel, and values come back by position, so the
|
||||
new input opens holding that empty value. Give a widget input a
|
||||
`resetUnknown` fallback in its card (`js/widget_cards/index.js`), or a
|
||||
`RESET_UNKNOWN` beside `NODE_CLASS` in the node's own `js/<name>/index.js`
|
||||
if it has no card (as LoRA Loader does). The fallback equals the node's
|
||||
default, which is what an API prompt without the input runs with. Then
|
||||
list the input in `tests/saved_widget_values.test.mjs`. Cards and
|
||||
panels themselves are never saved: set `widget.serialize = false` right
|
||||
after `addDOMWidget` (`options.serialize` only keeps a widget out of the
|
||||
prompt).
|
||||
- Write those keys as **string literals** inside `NODE_CLASS_MAPPINGS` and
|
||||
`NODE_DISPLAY_NAME_MAPPINGS` — never a `NODE_ID` variable. Registry scanners
|
||||
(ComfyUI-Manager) AST-parse the source without importing it, so a variable
|
||||
key makes every node invisible and "install missing custom nodes" stops
|
||||
offering the pack. `scripts/validate_nodes.py` enforces this.
|
||||
- Assign each mapping **once**, at module level, to a non-empty dict literal,
|
||||
and never mention the name again — no `update()`, no `del`, no
|
||||
`alias = NODE_CLASS_MAPPINGS`. A scanner reads that one literal and stops,
|
||||
so anything done to the mapping afterwards is invisible to it. Both
|
||||
mappings must carry exactly the same keys.
|
||||
- Display name: `<Name> 🆎` — the emoji is the pack signature. Typing
|
||||
"ausboss" still surfaces every node through the `🆎 AusBoss/<Group>`
|
||||
category, the `AUSBOSS_NODES_` id prefix, and the "ausboss" entry every
|
||||
node keeps in `SEARCH_ALIASES`.
|
||||
- Category: `🆎 AusBoss/<Group>`. The emoji is safe here — categories reach
|
||||
the frontend as JSON and are never printed to the console at import time.
|
||||
- Every node gets `DESCRIPTION`, input `tooltip`s, and `OUTPUT_TOOLTIPS`.
|
||||
- IMAGE tensors are BHWC float batches; MASK is BHW. Return tuples always,
|
||||
even for one output: `(value,)`.
|
||||
- Console output at import time must stay ASCII — ComfyUI on Windows often
|
||||
runs a cp1252 console, and a UnicodeEncodeError there kills the whole pack.
|
||||
- Widget values and route parameters are attacker-controlled: ComfyUI's
|
||||
`/prompt` and the pack's routes need no login. Media reads and writes stay
|
||||
inside ComfyUI's input, output and temp folders - no opt-in switches, no
|
||||
"any folder" settings, no folder pickers. A file is picked from a list and
|
||||
named below one of those folders the way ComfyUI names it
|
||||
(`clip.mp4 [output]`); no input opens a whole path as given.
|
||||
A route answers with data it made (JSON, a small picture), never a file by
|
||||
its path (`web.FileResponse`), and its error text goes through
|
||||
`safe_route_error` (`nodes/_route_errors.py`). Besides those the pack only reads
|
||||
its registered model folders (the one write there is the `.civitai.info`
|
||||
file LoRA Loader's Civitai button saves beside a LoRA) and keeps its own
|
||||
settings files in ComfyUI's user folder (`user/ausboss/`). Nothing is handed to a
|
||||
subprocess. The Registry bans versions for exactly this.
|
||||
- The pack's server code makes no network requests: no HTTP or socket client
|
||||
in shipped Python (`release_preflight.py` fails on one). The one outbound
|
||||
call is LoRA Loader's click-only Civitai lookup, made by the browser to a
|
||||
fixed civitai.com address with a plain SHA256 (`js/lora_loader/index.js`,
|
||||
`js/shared/civitai_lookup.mjs`). `release_preflight.py` allows a raw
|
||||
`fetch(` only there and in `js/cache_guard`, which only talks to the pack's
|
||||
own server. Graph links are made with
|
||||
`linkSlots` from `js/shared/graph_links.mjs`, never the index-based
|
||||
`node.connect(...)`, which registry scans read as a network socket.
|
||||
SECURITY.md states the model for users.
|
||||
- No new pip dependencies without an explicit decision; if truly optional,
|
||||
use `[project.optional-dependencies]` and fail soft at runtime.
|
||||
- Frontend files import each other only with plain string literals
|
||||
(`from "../shared/x.mjs"`, `import("../shared/x.mjs")`). The server adds a
|
||||
version tag to those paths (`nodes/_frontend_cache.py`) so an update never
|
||||
meets old copies from the browser's cache. A path built in code
|
||||
(`new URL(...)`, `"../shared/" + name`) escapes the tag and loads a second,
|
||||
stale copy; `tests/test_frontend_cache.py` fails on one.
|
||||
- Frontend JS never assigns prototype callbacks directly — use
|
||||
`chainCallback` from `js/shared/index.mjs`, or `chainHandler` where the
|
||||
return value matters (a truthy `onMouseDown` result is what stops a node
|
||||
drag). Messages to the user go through `showToast`, never `alert()`.
|
||||
- The mouse wheel over a node zooms the graph, on every part of its face.
|
||||
`js/canvas_passthrough/` hands the wheel on from the pack's panels, and
|
||||
keeps it only for a box that can still scroll or a field being typed in,
|
||||
so a panel adds no wheel listener of its own
|
||||
(`tests/canvas_passthrough.test.mjs` lists the few that exist). After a
|
||||
change to a node face, `scripts/dev/wheel_sweep.mjs` turns the wheel at
|
||||
every point of every node and must find no dead spot, in both renderers.
|
||||
- Every INT and FLOAT a public node exposes reaches the user as a scrub
|
||||
control, Adobe-style: drag the value to scrub, click to type, chevron
|
||||
arrows step, Shift is always the fine step. Use `makeScrubInput` from
|
||||
`js/shared/scrub_input.mjs` — never a bare `<input type=number>`, and
|
||||
never a classic canvas number widget on a finished node face. Units
|
||||
(`px`, `MP`, `×`) ride inside the box in a fixed-width slot that every
|
||||
single-field row reserves, so the numbers down a card share one centre
|
||||
line. Known, deliberate exceptions: the Seed card's seed (typed) and Load
|
||||
Video's trim timecodes (typed).
|
||||
- **Node faces are widget cards.** A node whose face would be classic
|
||||
LiteGraph widgets (full-width rows with an arrow at each end) gets an
|
||||
entry in `js/widget_cards/index.js` instead: `mountWidgetCard` from
|
||||
`js/shared/widget_card.mjs` hides the standard widgets and mirrors them
|
||||
in one compact DOM card — numbers → scrub, ≤ 4 short choices → segmented
|
||||
pill (else a select), booleans → an off | on pill as wide as the other
|
||||
controls (never a small switch inside a card), strings → text field,
|
||||
multiline → `kind: "textarea"` (`grow: true` takes the node's spare
|
||||
height), hex colors → swatch. Rows can depend on other values (`when`)
|
||||
and fold behind a disclosure (`group`). The widgets underneath stay the
|
||||
single source of truth (save/load, undo, API, links).
|
||||
- **One linkable widget per row.** The frontend (≥ 1.10.4, "Widget Input
|
||||
Socket" RFC #9) gives every widget an input socket drawn at the widget's
|
||||
own row, only while a link is dragged, hovered or connected; "convert to
|
||||
input" no longer exists. The card lends each hidden widget its row's
|
||||
position, so two widgets on one row would stack two sockets on one pixel
|
||||
and nobody could aim. A `pair` row is therefore only allowed with
|
||||
`top: true`, which lifts its sockets into the node's slot column under
|
||||
the real inputs (Image Resize width/height); everything else is one
|
||||
widget per row. A value that is meant to be wired rather than typed is a
|
||||
backend `forceInput: True` socket (Math Expression `a`/`b`/`c`), and a
|
||||
multi-type string such as `"FLOAT,INT"` accepts either kind of link.
|
||||
- Preview-carrying nodes (Select Frame, Mask Refine, Mask by Name, LaMa
|
||||
Inpaint, Save Image) share
|
||||
`js/input_preview/`: a thin bar (node tools left, a small `preview`
|
||||
switch right) above the picture, the picture gone and the node shorter
|
||||
when the switch is off, backed by an optional `preview` BOOLEAN input
|
||||
that also skips the temp file. A new node with a result picture reuses
|
||||
it rather than growing its own.
|
||||
- A DOM panel that shows a stage/preview claims the node's free height via
|
||||
`fillNodeHeight` from `js/shared/panel_layout.mjs` — never a hand-rolled
|
||||
`computeSize`, which pins the panel and leaves dead space when the node is
|
||||
dragged taller. `tests/panel_guards.test.mjs` enforces this pack-wide: a
|
||||
new panel entry must be added to its `mustGrow` set (or `fixedByDesign`
|
||||
for genuinely constant-height rows), so the choice is always explicit.
|
||||
A `fixedByDesign` card also calls `pinVuePanelHeight`: its own
|
||||
`computeSize` only pins it in the classic renderer, and Nodes 2.0 would
|
||||
stretch it with the node and squeeze a picture under it. Run Timer does
|
||||
not: its readout fills the node on purpose.
|
||||
- **A picture or clip in a stage sits out of flow.** Nodes 2.0 sizes a node
|
||||
by its content, so an in-flow portrait clip drawn at full width held Load
|
||||
Video at 965 px where classic goes to 584 px. Position the media
|
||||
`absolute` in its stage, and the stage takes its size from the node
|
||||
alone. A picture is centred at its own size (`js/input_preview/`); a
|
||||
player fills the stage so its controls stay along the bottom edge
|
||||
(`js/shared/video_ui.mjs`). Text that is fitted to its box follows the
|
||||
same rule: the Callout's text is `absolute` in its card
|
||||
(`js/callout/index.js`), or the text sizes the card it is fitted to.
|
||||
`tests/panel_guards.test.mjs` holds all three.
|
||||
- **A panel's declared minimum width is its node's real floor.** Pass
|
||||
`exactMinWidth: true` to `fillNodeHeight`; a constant-height card that
|
||||
keeps its own `computeSize` calls `holdNodeMinWidth` instead. Left alone,
|
||||
the frontend adds 104 px to a growing panel's number and ignores a pinned
|
||||
card's number altogether (the node falls to its 210 px default), so a
|
||||
declared 320 meant 424 for one and 210 for the other. Prove each face on
|
||||
a real canvas at that width in both renderers; a panel that clips there
|
||||
gets a larger minimum, not a broken face. Nodes 2.0 never goes under
|
||||
225 px whatever a panel declares.
|
||||
- **A growing panel's declared minimum height holds in Nodes 2.0 too.**
|
||||
Nodes 2.0 lays a node out by its content, not by the floor a widget
|
||||
declares, so a panel whose content has no height of its own was dragged
|
||||
flat there. `fillNodeHeight` also writes the floor to the panel's
|
||||
element as a CSS `min-height`, less `WIDGET_FRAME`, while the panel sits
|
||||
inside a Nodes 2.0 node. So declare `minHeight` as what the element needs
|
||||
plus `WIDGET_FRAME`, and never assign a growing panel's
|
||||
`computeLayoutSize` by hand. Prove the floor with a real corner drag up
|
||||
in both renderers.
|
||||
- Frontend settings use `AusBoss.<Area>.<Name>` ids with
|
||||
`category: ["🆎 AusBoss", "<Area>", "<Leaf>"]` and a distinct leaf per
|
||||
setting. Node color schemes live in `js/shared/appearance.mjs`.
|
||||
|
||||
## Adding a node
|
||||
|
||||
Follow `docs/adding_a_node.md`. Short version: create `nodes/node_<name>.py`
|
||||
modelled on a small existing node such as `nodes/node_image_size.py`, add
|
||||
`"node_<name>"` to `NODE_MODULES` in `__init__.py` and the key to
|
||||
`PUBLIC_NODE_IDS` in `scripts/validate_nodes.py`, give it a help page at
|
||||
`js/docs/<KEY>.md`, optionally add `js/<name>/index.js`, then validate.
|
||||
|
||||
## Validation
|
||||
|
||||
Run the offline checks before a pull request:
|
||||
|
||||
```bash
|
||||
python scripts/validate_nodes.py
|
||||
python scripts/release_preflight.py
|
||||
python scripts/run_python_tests.py # with ComfyUI's Python
|
||||
node --test tests/*.test.mjs
|
||||
```
|
||||
|
||||
Then restart ComfyUI fully (a changed `INPUT_TYPES` is only served after a
|
||||
restart), watch the AusBoss banner for failed modules, confirm the node
|
||||
appears in `GET http://127.0.0.1:8188/object_info`, queue a tiny API graph,
|
||||
and load its example workflow. After JS changes, hard-refresh the browser
|
||||
tab (Ctrl+Shift+R) — the frontend caches `.mjs` modules aggressively.
|
||||
|
||||
Frontend work is proven on a real canvas, not by reading code: the
|
||||
headless-Chrome harness in `scripts/dev/` (recipe, gotchas and the
|
||||
screenshot convention in `docs/live_testing.md`) creates nodes, drives real
|
||||
mouse drags, and screenshots the result. A link-drop test that only checks
|
||||
which input got the link is not enough — look at the picture and ask whether
|
||||
a person could have aimed there.
|
||||
|
||||
A ComfyUI update can break a node without a line of the pack changing. So
|
||||
the pack is run against the newest ComfyUI every night
|
||||
(`.github/workflows/comfyui_compat.yml`): its newest release and its
|
||||
development head, on the CPU, with no model files. Three checks, which also
|
||||
run by hand against any ComfyUI:
|
||||
|
||||
```bash
|
||||
AUSBOSS_COMFY_ROOT=<ComfyUI> python tests/test_comfy_core_contract.py
|
||||
python scripts/compat_boot.py --url http://127.0.0.1:8188
|
||||
node scripts/dev/face_smoke.mjs <chrome port> # and again with --vue
|
||||
```
|
||||
|
||||
- `test_comfy_core_contract.py` reads the pack's own source for everything
|
||||
it takes from ComfyUI and looks each thing up in the real one. It finds
|
||||
imports by itself. A call to one of ComfyUI's own nodes by name
|
||||
(`SAM3_Detect`), or an internal read with a fallback, goes in the tables at
|
||||
the top of that file; the test fails on a node call that is not listed.
|
||||
- `compat_boot.py` asks a running ComfyUI which nodes it offers: every public
|
||||
node of the pack, and every node and required input the examples use.
|
||||
- `face_smoke.mjs` puts every node on a graph and fails when a face does not
|
||||
come up or something throws.
|
||||
|
||||
A failure on the release opens an issue labelled `comfyui-update`. What these
|
||||
cannot see is anything that needs a model: run the real thing on a GPU
|
||||
before a release that follows a ComfyUI update.
|
||||
|
||||
## Releasing
|
||||
|
||||
A new `version` in `pyproject.toml` that lands on main publishes to the Comfy
|
||||
Registry through the publish workflow (`.github/workflows/publish_action.yml`).
|
||||
Contributors never change `version`.
|
||||
|
||||
The Registry shows notes with each version. They are that version's section
|
||||
of `CHANGELOG.md`, one line per entry: its bold opening, and for a new node
|
||||
the sentence that says what it does (`scripts/changelog_notes.py`). So open
|
||||
every entry with a bold line that makes sense on its own.
|
||||
|
||||
## Example workflows
|
||||
|
||||
Examples are what most people run first, so each one must work for a
|
||||
stranger with only the files its Workflow Note lists.
|
||||
|
||||
- Every model loader selects the official file name, the basename of its
|
||||
download URL, carries that file in `properties.models`
|
||||
(`name`, `url`, `directory`), and the Note's model row names the same
|
||||
file. People who keep models in subfolders pick their copy.
|
||||
- Media loaders (Load Image, Load Image + Pad, Load Video, Image Crop +
|
||||
Rotate + Pad and the video nodes) are saved blank. A file name is one the
|
||||
stranger doesn't have, and ComfyUI flags that node red when the workflow
|
||||
loads. The Workflow Note tells people to load their own picture; the
|
||||
sample files stay in `example_workflows/inputs/` for anyone who wants to
|
||||
try one.
|
||||
- Save nodes use a filename prefix without a folder. Seeds are fixed.
|
||||
- Numbered groups hold every node, nothing overlaps (title bars included),
|
||||
the saved zoom is at least 0.6 so widget text draws, the workflow `id` is
|
||||
a real uuid4, and the `.jpg` beside the `.json` shows a real output.
|
||||
- The frontend saves widget values twice, by position and by name
|
||||
(`widgets_values_named`), and can restore by name; both copies must agree.
|
||||
- `scripts/workflow_contract.py` checks links, groups, overlaps, the note,
|
||||
the id, the zoom, the named copies, the download info and the save
|
||||
prefixes. Whether the note tells the truth about the graph is review.
|
||||
- Text-to-image examples have no source to describe. Edit and inpaint
|
||||
examples keep an explicit change instruction: a description of the old
|
||||
scene is not an edit. MiniMax H3 and Klein caption their sources with
|
||||
Qwen3-VL 8B because their own text encoders returned only punctuation
|
||||
when asked to describe an image.
|
||||
- **Using the nodes in a workflow?** Read
|
||||
[js/docs/agents/README.md](js/docs/agents/README.md): which node does which
|
||||
job, where each node's help page is, and the example workflows. That folder
|
||||
ships with the pack, so it is also in a ComfyUI install.
|
||||
- **Changing the pack itself?** Read [CONTRIBUTING.md](CONTRIBUTING.md) in full
|
||||
before the first edit. It holds the layout, the rules every node follows and
|
||||
the checks to run. A new node follows
|
||||
[docs/adding_a_node.md](docs/adding_a_node.md).
|
||||
|
||||
+287
@@ -2,6 +2,293 @@
|
||||
|
||||
All notable changes to ComfyUI-AusBoss are documented here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **Colored link buttons and pop-out notes keep their tile layout when shared.**
|
||||
Callout Tile and Workflow Note Tile save as distinct node types, so an older
|
||||
install can report what is missing. Tiles have larger editors and visible
|
||||
resize grips, start compact when added, and pop-out notes wrap long code lines.
|
||||
- **H3 Prompt Writer can change something in a video while keeping its person and sound.**
|
||||
The new `keeps_clip_sound` output helps route the source audio. Video edits
|
||||
show **From the clip** for their length, and swaps accept an optional
|
||||
`@1 looks like ...` line describing the connected replacement.
|
||||
- **Workflow Switches' Off and On buttons now select the state they name.**
|
||||
Pressing the already selected state leaves it unchanged.
|
||||
|
||||
|
||||
## 2.21.1 - 2026-11-06
|
||||
|
||||
- **The README release badge stays available when the badge service is down.**
|
||||
The badge is served from the repository, shows the current version, and opens
|
||||
the latest GitHub Release.
|
||||
|
||||
## 2.21.0 - 2026-11-05
|
||||
|
||||
- **Crop + Rotate + Pad has clearer controls and steadier previews.** Image
|
||||
and video pickers share Inputs and Outputs tabs and a Load button. Video
|
||||
previews have a play/pause button, and dragging a locked-length clip stops
|
||||
where the full selection still fits. Crop, pad and resize controls share
|
||||
the same compact buttons, and warnings no longer move the controls below.
|
||||
- **Latent Size can show just the sizes recommended for your workflow.**
|
||||
Pick the allowed presets in its settings and save them with the workflow.
|
||||
Workflows without a custom list keep all their choices.
|
||||
- **Workflow Notes and Callouts can be compact titles, links and guide tiles.**
|
||||
Guides support formatted text, tables, accent colors and model download
|
||||
links. YouTube and Discord tiles keep their red and purple choices.
|
||||
- **Media lists open faster on network shares, and controls load behind
|
||||
a web address prefix.** File scans reuse directory information. Frontend
|
||||
imports also work when ComfyUI is served under a path such as `/comfy/`.
|
||||
- **GitHub Releases now follow version updates automatically.** After the
|
||||
Registry upload, the publish workflow creates the release with its notes.
|
||||
The README release badge uses that release and opens its download page.
|
||||
|
||||
- **LoRA Loader supports stacks larger than 64 rows.** Folder adds, templates, and absorbed loaders keep the full stack when you save and reopen a workflow.
|
||||
- **Workflow Note info tiles show a book for guides and a download arrow for models.** Click the dark tile to open the note. Its icon and left edge use the accent you picked.
|
||||
- **H3 Prompt Writer swaps two people.** With Swap the person and two person
|
||||
pictures, Picture 1 takes the place of the person on the left and Picture 2
|
||||
the person on the right. Type who goes to change that, one line each, such
|
||||
as `@1 the man in the hat` and `@2 the woman in red`.
|
||||
- **New node: Load Audio.** Load a sound file and keep only the part you
|
||||
want. The node shows the sound as a wave: drag the IN and OUT handles to
|
||||
trim it, and press play to hear just that part. Set Max length to the
|
||||
longest sound the next step takes, and anything longer shows in red as
|
||||
cut off, so you see exactly what comes out before you run.
|
||||
- **Resizing one node of a selection now resizes them all.** With two or more
|
||||
nodes selected, pull the edge or corner of one: nodes stacked in a column
|
||||
get its width, nodes side by side in a row get its height, and a grid gets
|
||||
both. It works in the classic look and in Nodes 2.0, one Ctrl+Z undoes it,
|
||||
and Settings → 🆎 AusBoss → Canvas → Resize together turns it off.
|
||||
- **Integer 🆎 and Float 🆎 have a big look.** The gear on the node picks it,
|
||||
as on Text 🆎: one large number in the middle of the node, for a setting
|
||||
you change on every run. The value and what the node sends are the same in
|
||||
both looks.
|
||||
- **New node: H3 Prompt Writer.** Type one line for each thing that happens,
|
||||
give each picture and video a job, and it writes the prompt MiniMax H3
|
||||
expects, with the width and height to match. For a video with two people,
|
||||
type who goes and only that person is swapped. The words light up as you
|
||||
type, `@` lists your pictures, and a bar shows each moment on the clip's
|
||||
seconds: drag a block to move it, drag its edge to set its time, click an
|
||||
edge for a cut. Sound and Music boxes fill H3's own sound and music parts.
|
||||
- **Crop For Inpaint 🆎 no longer makes an inpainted face come back slightly
|
||||
bigger.** Its default Multiple is now 32, so Qwen Image 2.1, Flux and other
|
||||
newer models get a size their VAE keeps whole; at 8, the VAE trimmed a few
|
||||
pixels and the stitch stretched what was left over the crop. When the crop
|
||||
is rescaled, it now takes in a few more pixels of context so the picture is
|
||||
scaled the same amount both ways, the way the original Inpaint Crop and
|
||||
Stitch nodes do it. Stitch Inpaint 🆎 notes it in the console when a
|
||||
trimmed picture comes back. Saved workflows keep their Multiple; set it to
|
||||
32 by hand. Thanks to @CognitiveDiffusion for the report (#262).
|
||||
- **Stitch Inpaint 🆎 Tone match works on pictures with see-through parts.**
|
||||
It used to read the colour hidden under the clear parts and tint the new
|
||||
area, often yellow. It now reads only what shows (#232).
|
||||
|
||||
- **Nodes and groups snap into line while you drag them.** Drag a node, a
|
||||
group or a selection near another node or group and it lines up with it:
|
||||
left edges, right edges, tops, bottoms or middles. A teal line shows what it
|
||||
lines up with, dashed when the middles line up. Resizing a node or a group
|
||||
by a corner lines that edge up the same way. Hold Shift while you drag to
|
||||
place it without lining up; ComfyUI then snaps it to its grid as usual. Turn
|
||||
it off under Settings → 🆎 AusBoss → Canvas. In the Nodes 2.0 look, resizing
|
||||
a node does not snap yet. If another pack's snapping is on, AusBoss leaves
|
||||
the dragging to it.
|
||||
|
||||
- **Line up nodes and groups from the selection toolbar.** Select two or more
|
||||
nodes or groups and a Line up button pops out a small panel: line up edges or
|
||||
middles, give them one width or height, or space them evenly. Each tool is
|
||||
also a command, so it can get a key. Turn the button off under Settings →
|
||||
🆎 AusBoss → Canvas.
|
||||
|
||||
- **Stitch Inpaint 🆎 can put a hand crop back on your whole picture.** Crop
|
||||
in Image Crop + Rotate + Pad 🆎, edit the crop, and set **Paste onto** to
|
||||
original in Stitch Inpaint's gear menu. You get your full picture back with
|
||||
only the edit changed. It works with Resize and a turned crop too. The
|
||||
default stays crop, so saved workflows run as before. Thanks to
|
||||
@nickllnickll for the idea.
|
||||
- **Workflow Switches 🆎 + Choice saves how a node is set under a name.** Set
|
||||
a node, press + Choice and name it. Set it another way and press it again.
|
||||
The card turns into one pill, and picking a choice puts the node back. Two
|
||||
named choices used to take about 20 clicks through a menu and Settings. Now
|
||||
they take 5. A choice can also switch off the nodes only it needs, so a
|
||||
model missing from the branch you didn't pick won't stop the run. Switches
|
||||
you already made work as before.
|
||||
- **Node names and descriptions in Chinese.** Switch ComfyUI's language to
|
||||
中文 and every AusBoss node shows its name, description and input and
|
||||
output names in Chinese. The cards on the nodes and the help pages stay in
|
||||
English for now. Thanks to @ne7359 for asking.
|
||||
- **Workflow Switches 🆎 has a guide for AI agents.** Point an agent at
|
||||
`js/docs/agents/workflow_switches.md` in the pack's folder and it can add
|
||||
switches to a workflow for you.
|
||||
|
||||
## 2.20.5 - 2026-10-31
|
||||
|
||||
- **LaMa Inpaint 🆎 only opens known copies of its model.** A LaMa model file
|
||||
can run code when it opens, so the node now checks the file first. The
|
||||
`big-lama.pt` linked on its help page and the one from fashn-ai/LaMa on
|
||||
Hugging Face both work, under any name. Any other file stops the run with a
|
||||
message and is never loaded.
|
||||
- **Frame Interpolate 🆎 errors no longer show folders on the server.** The
|
||||
message names the file, and the folder goes to the server console.
|
||||
- **Image Folder 🆎 "start over" now keeps going.** With one per run and next,
|
||||
the pictures repeat in order. Before, after the last picture every run
|
||||
loaded picture 1. Fixed by @MarxMitX.
|
||||
- **Workflow Switches 🆎 One at a time now holds from the start.** A new
|
||||
switch came on beside the others, and turning the setting on left every
|
||||
row on. Now a new switch starts off while another row is on, and turning
|
||||
the setting on keeps the top row on.
|
||||
|
||||
## 2.20.4 - 2026-10-30
|
||||
|
||||
- **Workflow Switches 🆎 explain "empty".** Hover over the word and the card
|
||||
says what happened: none of that switch's nodes are in this workflow. A
|
||||
workflow pasted and saved on an older version stays that way after an
|
||||
update, so the card says to open the original file again, or to add nodes
|
||||
from the switch's menu.
|
||||
|
||||
## 2.20.3 - 2026-10-29
|
||||
|
||||
- **Workflow Switches 🆎 keep working after a copy and paste.** A whole
|
||||
workflow pasted into a new tab showed "empty" next to every switch, and
|
||||
the switches could not be turned on. A card copied together with its
|
||||
nodes and groups now switches the copies. The same goes for duplicating,
|
||||
Alt + drag, and dragging a saved workflow onto the canvas. A Callout 🆎
|
||||
copied with what its links point at links to the copies too.
|
||||
|
||||
## 2.20.2 - 2026-10-27
|
||||
|
||||
- **Save Image 🆎 shows a run more clearly.** While the models load, an
|
||||
empty picture area says the run is going, instead of asking you to run
|
||||
the workflow. With the steps switch on, the last step stays up until the
|
||||
saved picture has loaded, so the picture no longer blinks out at the
|
||||
end. During an upscale in tiles it keeps the whole picture and says
|
||||
"upscaling in tiles", instead of showing each tile as if it were the
|
||||
picture. Step Preview 🆎 and Save Video 🆎 do the same with tiles.
|
||||
|
||||
## 2.20.1 - 2026-10-26
|
||||
|
||||
- **Crop For Inpaint 🆎 and Stitch Inpaint 🆎 keep a picture's see-through
|
||||
parts.** A cut-out goes through an inpaint with its clear background
|
||||
still clear. Before, Stitch Inpaint stopped with a channel error when the
|
||||
model sent back colour only.
|
||||
|
||||
- **Image Compare A/B 🆎 shows and keeps see-through parts.** They show
|
||||
over a checkerboard. In Erase, rubbing the top picture away brings back
|
||||
the clear parts of the one underneath, and the saved PNG and PSD keep
|
||||
them.
|
||||
|
||||
- **Mask by Touch 🆎 says when the picks were made on another picture.**
|
||||
When the picture coming into the node changed after you picked, the run
|
||||
stopped with "nothing is picked yet" while the picks still showed on the
|
||||
node. It now says the picks were made on another picture, so you know to
|
||||
pick again on the new one.
|
||||
|
||||
## 2.20.0 - 2026-10-25
|
||||
|
||||
- **New: Step Preview 🆎 shows the picture while it is being made, with a
|
||||
Stop button.** Watch each sampling step and stop a run that is going
|
||||
wrong after the first few steps, instead of waiting for the end. The node
|
||||
needs no wires: it shows whichever sampler is working, swaps the blurry
|
||||
last step for the finished picture once it is saved, and has **STOP ALL**
|
||||
for a full queue. A workflow with it asks ComfyUI for step pictures for
|
||||
that run, unless you chose a preview method yourself.
|
||||
|
||||
- **Save Image 🆎 and Save Video 🆎 can show the steps too.** A new
|
||||
**steps** switch, off by default, shows the picture being made where the
|
||||
saved one goes, with a **STOP** button. Workflows saved before keep it
|
||||
off and look and run as they did.
|
||||
|
||||
## 2.19.2 - 2026-10-25
|
||||
|
||||
- **LoRA Loader 🆎 says why a Civitai lookup failed.** The button still
|
||||
reads **Civitai lookup failed**, and a message now gives the reason. When
|
||||
the browser cannot reach civitai.com at all, it names the usual causes: a
|
||||
firewall, a VPN, or ComfyUI started with `--disable-api-nodes`.
|
||||
|
||||
## 2.19.1 - 2026-10-24
|
||||
|
||||
- **LoRA Loader 🆎 saves the LoRA's picture from Civitai, no add-on
|
||||
needed.** **Fetch Civitai info** now also saves the LoRA's first picture
|
||||
next to the file, so hovering the LoRA shows it. Pictures Civitai rates R
|
||||
or above are skipped unless you pick **any** under the gear menu's
|
||||
**Civitai picture** option, and a picture you already have is never
|
||||
replaced. The separate LoRA Previews add-on from 2.19.0 is no longer
|
||||
needed.
|
||||
|
||||
## 2.19.0 - 2026-10-24
|
||||
|
||||
- **LoRA Loader 🆎 can add several LoRAs at once.** In the **+ LoRA** list,
|
||||
tick the box beside each file you want (or Ctrl-click its name; Shift-click
|
||||
ticks a whole run), then click **Add N LoRAs** or press Enter. Ticks stay while you search, and
|
||||
files already in the stack are marked "in stack". One click on a file
|
||||
still adds just that file, and a folder name still adds the whole folder.
|
||||
|
||||
- **LoRA Loader 🆎 has groups.** Right-click a row and choose **Group this
|
||||
LoRA**, or Ctrl-click several rows first and group them together. A group
|
||||
gets a header with its own switch, a fold arrow that hides its rows and
|
||||
shortens the node, and a name you can double-click to change. Turning a
|
||||
group off stops all its LoRAs, and each one keeps its own switch for when
|
||||
the group comes back on. Drag rows in and out by their grip, or drag the
|
||||
whole group by its header. The stack still runs top to bottom as shown.
|
||||
|
||||
- **LoRA Loader 🆎 can show a LoRA's Civitai picture with the new LoRA
|
||||
Previews add-on.** Install the add-on
|
||||
([ComfyUI-AusBoss-LoRA-Previews](https://github.com/ausboss/ComfyUI-AusBoss-LoRA-Previews#install)) and **Fetch Civitai info**
|
||||
also saves the LoRA's preview picture next to the file, so hovering the LoRA
|
||||
shows it. The pack itself still downloads nothing; the info card links to
|
||||
the add-on when a LoRA has Civitai info but no picture.
|
||||
|
||||
- **Workflow Switches 🆎 can turn a LoRA Loader group on and off.** Save a
|
||||
switch once with the group on and once with it off. The group's header
|
||||
shows a lock, and the LoRAs inside stay yours to turn on and off one by
|
||||
one.
|
||||
|
||||
- **Workflow Note 🆎 shows the whole hint under each model file.** The short
|
||||
line under a file name ("optional: the full-size file...", "for 4 steps:
|
||||
...") was cut off with "…" when it was longer than the card. It now wraps
|
||||
onto a second line, and the note grows to fit, so nothing scrolls.
|
||||
|
||||
- **Load Image + Pad 🆎 and the other loaders run alongside packs that
|
||||
change how ComfyUI checks nodes.** With some other custom node packs
|
||||
installed, a run stopped before it started with "unexpected keyword
|
||||
argument 'pad_left'". The nodes that check their inputs before a run
|
||||
(Load Image + Pad, Image Crop + Rotate + Pad, Load Video, the video crop
|
||||
nodes, LoRA Loader, Save Image, Image Folder, LaMa Inpaint and Stitch
|
||||
Inpaint) now ignore the extra values those packs pass along. Nothing
|
||||
changes on a ComfyUI without them.
|
||||
|
||||
## 2.18.1 - 2026-10-21
|
||||
|
||||
- **A picture from the Assets panel loads when dropped on a node's picture
|
||||
in the classic view.** Dragging a picture's card from ComfyUI's Assets
|
||||
panel onto a loader such as Image Crop + Rotate + Pad 🆎 worked only on the
|
||||
node's title bar; dropped on the picture or the controls, it did nothing.
|
||||
It now loads wherever it lands on the node, as it already did in Nodes 2.0.
|
||||
|
||||
## 2.18.0 - 2026-10-21
|
||||
|
||||
- **Image Crop + Rotate + Pad 🆎, Load Image + Pad 🆎 and Load Video 🆎 load
|
||||
files ComfyUI made, as core Load Image does.** A generated picture named
|
||||
the way ComfyUI names it, such as `photo.png [output]`, stopped the run with
|
||||
"A node rejected one or more input values" and "outside ComfyUI's input
|
||||
folder". These loaders now read a file from ComfyUI's input, output or temp
|
||||
folder by that name, and the picture on the node shows it too. A whole
|
||||
path, or a name that climbs out of those folders, is still refused, and the
|
||||
message says to pick the file from the node's list or upload it.
|
||||
- **Mask by Touch 🆎: a text wire no longer plugs into it by mistake.**
|
||||
Dragging a text wire over the node, from a Text node for example, lit a
|
||||
hidden plug at the top edge of its settings. Letting go wired the text in
|
||||
place of what you picked on the picture, so the next run ignored your
|
||||
picks. The two values the picture keeps for itself no longer take a wire.
|
||||
Saved workflows open as before, with your picks.
|
||||
- **Image Folder 🆎: a second text wire no longer plugs into the ticked
|
||||
pictures.** With Folder already wired, dragging another text wire over the
|
||||
node lit the same hidden plug and wired the text in place of the pictures
|
||||
you ticked. The ticked list no longer takes a wire; Folder still does.
|
||||
- **Image Compare A/B 🆎 passes on image_b too.** The node has a second
|
||||
output, `image_b`: the B picture, untouched, often the edited one. Wire it
|
||||
on when B is the picture you want to keep, and the Compare can sit on that
|
||||
wire as well. `image_a` stays the first output, so saved workflows keep
|
||||
their wires.
|
||||
|
||||
## 2.17.0 - 2026-10-19
|
||||
|
||||
- **Layout Boxes 🆎 boxes can be turned, to replace words at a slant.**
|
||||
|
||||
+351
-1
@@ -16,7 +16,7 @@ issue.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- The contributor guide is [AGENTS.md](AGENTS.md), and a new public node
|
||||
- Read the contributor guide below first, and a new public node
|
||||
follows [docs/adding_a_node.md](docs/adding_a_node.md). A new node is a
|
||||
conversation before it is a pull request.
|
||||
- Mapping keys, input names and order, and output order are a permanent API:
|
||||
@@ -41,3 +41,353 @@ issue.
|
||||
harness and what to screenshot.
|
||||
- A fix PR says what was wrong, adds a test that fails before the fix and
|
||||
passes after it, and shows before/after results.
|
||||
|
||||
## Contributor guide
|
||||
|
||||
How the repo is laid out, the conventions every node follows, and the checks
|
||||
to run. A suite of polished ComfyUI custom nodes by ausboss. Public nodes must
|
||||
solve a repeated workflow need, keep a compact graph footprint, and pass
|
||||
backend plus browser acceptance before release.
|
||||
|
||||
### READ FIRST: write for the people who use the nodes
|
||||
|
||||
Everything a user reads starts with the plainest version. That covers node
|
||||
descriptions, tooltips, the `?` help pages (`js/docs/*.md`), the README,
|
||||
example workflow notes, report and error text, and the CHANGELOG. Get more
|
||||
technical later in the text, or in its own section.
|
||||
|
||||
- Open with what it does and when to use it, in everyday words and short
|
||||
sentences. Someone new to ComfyUI should get it on the first read.
|
||||
- A tooltip is one or two short sentences: what this is, and what to pick.
|
||||
- Measurements, test results, edge cases and how it works go further down,
|
||||
under a heading such as "Technical details".
|
||||
- Keep jargon out of the opening (resample, warp, frame, affine,
|
||||
estimator, canvas space). If a term is needed, say what it means the
|
||||
first time.
|
||||
- Put a number up top only when it helps someone decide what to do.
|
||||
|
||||
If a sentence needs a second read, rewrite it.
|
||||
|
||||
### Which nodes ship
|
||||
|
||||
A node ships only when its mapping key is listed in `PUBLIC_NODE_IDS` in
|
||||
`scripts/validate_nodes.py`; the validator fails on any registered key that
|
||||
is not listed there.
|
||||
|
||||
### Hard rules
|
||||
|
||||
- Never modify `LICENSE`.
|
||||
- Never bump `version` in `pyproject.toml` — a version bump that lands on
|
||||
main **publishes to the Comfy Registry automatically** (see Releasing).
|
||||
- Keep diffs minimal: touch only the lines the task needs.
|
||||
|
||||
### Third-party independence
|
||||
|
||||
- Never copy third-party code, assets, fonts, icons, CSS, or documentation.
|
||||
- Review ecosystem overlap before accepting a public node. Generic overlap is
|
||||
fine, but implementation, naming, interaction design, and documentation must
|
||||
be this repository's own work.
|
||||
|
||||
### Architecture
|
||||
|
||||
```text
|
||||
__init__.py # NODE_MODULES list → importlib merge of all mappings.
|
||||
# Fail-soft: a broken module logs and is skipped, the rest load.
|
||||
nodes/
|
||||
node_<name>.py # exactly one node (or one tight family) per file;
|
||||
# exports NODE_CLASS_MAPPINGS + NODE_DISPLAY_NAME_MAPPINGS
|
||||
_<topic>_helpers.py # shared backend logic, underscore prefix = not a node
|
||||
js/
|
||||
<name>/index.js # frontend entry per node or pack-wide feature, e.g.
|
||||
# appearance/ (.js files auto-load)
|
||||
shared/*.mjs # import-only shared modules (.mjs files do NOT auto-load)
|
||||
docs/ # developer docs
|
||||
scripts/ # offline checks in stdlib Python. validate_nodes.py is
|
||||
# the entry point; registry_contract.py holds the rules
|
||||
# that keep nodes visible to registry scanners;
|
||||
# registry_status.py reads the Registry API; dev/ is the
|
||||
# Node.js canvas harness (docs/live_testing.md).
|
||||
example_workflows/ # example workflows (regular workflow JSON, not API JSON)
|
||||
```
|
||||
|
||||
### Conventions
|
||||
|
||||
- Public mapping keys use `AUSBOSS_NODES_<Purpose>`. The mapping key is the
|
||||
workflow-compatibility contract and must never be renamed after release.
|
||||
- Inputs and outputs only grow after a release: append an input as
|
||||
optional, append an output, and never rename, reorder, remove or newly
|
||||
require one. Saved workflows keep widget values by position and links by
|
||||
slot, and an API prompt must carry every required input.
|
||||
`tests/test_node_api.py` holds the pack to `tests/fixtures/node_api.json`;
|
||||
refresh that snapshot with the test's `--update` after a compatible change
|
||||
or a new node.
|
||||
- Appending an input to a node with a card or panel takes one more step.
|
||||
Workflows saved by earlier releases end that node's values with an empty
|
||||
value for each card and panel, and values come back by position, so the
|
||||
new input opens holding that empty value. Give a widget input a
|
||||
`resetUnknown` fallback in its card (`js/widget_cards/index.js`), or a
|
||||
`RESET_UNKNOWN` beside `NODE_CLASS` in the node's own `js/<name>/index.js`
|
||||
if it has no card (as LoRA Loader does). The fallback equals the node's
|
||||
default, which is what an API prompt without the input runs with. Then
|
||||
list the input in `tests/saved_widget_values.test.mjs`. Cards and
|
||||
panels themselves are never saved: set `widget.serialize = false` right
|
||||
after `addDOMWidget` (`options.serialize` only keeps a widget out of the
|
||||
prompt).
|
||||
- Write those keys as **string literals** inside `NODE_CLASS_MAPPINGS` and
|
||||
`NODE_DISPLAY_NAME_MAPPINGS` — never a `NODE_ID` variable. Registry scanners
|
||||
(ComfyUI-Manager) AST-parse the source without importing it, so a variable
|
||||
key makes every node invisible and "install missing custom nodes" stops
|
||||
offering the pack. `scripts/validate_nodes.py` enforces this.
|
||||
- Assign each mapping **once**, at module level, to a non-empty dict literal,
|
||||
and never mention the name again — no `update()`, no `del`, no
|
||||
`alias = NODE_CLASS_MAPPINGS`. A scanner reads that one literal and stops,
|
||||
so anything done to the mapping afterwards is invisible to it. Both
|
||||
mappings must carry exactly the same keys.
|
||||
- Display name: `<Name> 🆎` — the emoji is the pack signature. Typing
|
||||
"ausboss" still surfaces every node through the `🆎 AusBoss/<Group>`
|
||||
category, the `AUSBOSS_NODES_` id prefix, and the "ausboss" entry every
|
||||
node keeps in `SEARCH_ALIASES`.
|
||||
- Category: `🆎 AusBoss/<Group>`. The emoji is safe here — categories reach
|
||||
the frontend as JSON and are never printed to the console at import time.
|
||||
- Every node gets `DESCRIPTION`, input `tooltip`s, and `OUTPUT_TOOLTIPS`.
|
||||
- IMAGE tensors are BHWC float batches; MASK is BHW. Return tuples always,
|
||||
even for one output: `(value,)`.
|
||||
- Console output at import time must stay ASCII — ComfyUI on Windows often
|
||||
runs a cp1252 console, and a UnicodeEncodeError there kills the whole pack.
|
||||
- Widget values and route parameters are attacker-controlled: ComfyUI's
|
||||
`/prompt` and the pack's routes need no login. Media reads and writes stay
|
||||
inside ComfyUI's input, output and temp folders - no opt-in switches, no
|
||||
"any folder" settings, no folder pickers. A file is picked from a list and
|
||||
named below one of those folders the way ComfyUI names it
|
||||
(`clip.mp4 [output]`); no input opens a whole path as given.
|
||||
A route answers with data it made (JSON, a small picture), never a file by
|
||||
its path (`web.FileResponse`), and its error text goes through
|
||||
`safe_route_error` (`nodes/_route_errors.py`). Besides those the pack only reads
|
||||
its registered model folders (the one write there is LoRA Loader's Civitai
|
||||
button: a `.civitai.info` file and a `.preview.jpg` picture, saved beside a
|
||||
LoRA, never replacing a picture) and keeps its own
|
||||
settings files in ComfyUI's user folder (`user/ausboss/`). Nothing is handed to a
|
||||
subprocess. The Registry bans versions for exactly this.
|
||||
- The pack's server code makes no network requests: no HTTP or socket client
|
||||
in shipped Python (`release_preflight.py` fails on one). The one outbound
|
||||
call is LoRA Loader's click-only Civitai lookup, made by the browser to a
|
||||
fixed civitai.com address with a plain SHA256, plus the LoRA's picture
|
||||
from `image.civitai.com` on the same click (`js/lora_loader/index.js`,
|
||||
`js/shared/civitai_lookup.mjs`). `release_preflight.py` allows a raw
|
||||
`fetch(` only there and in `js/cache_guard`, which only talks to the pack's
|
||||
own server. Graph links are made with
|
||||
`linkSlots` from `js/shared/graph_links.mjs`, never the index-based
|
||||
`node.connect(...)`, which registry scans read as a network socket.
|
||||
SECURITY.md states the model for users.
|
||||
- No new pip dependencies without an explicit decision; if truly optional,
|
||||
use `[project.optional-dependencies]` and fail soft at runtime.
|
||||
- Frontend files import each other only with plain string literals
|
||||
(`from "../shared/x.mjs"`, `import("../shared/x.mjs")`). The server adds a
|
||||
version tag to those paths (`nodes/_frontend_cache.py`) so an update never
|
||||
meets old copies from the browser's cache. A path built in code
|
||||
(`new URL(...)`, `"../shared/" + name`) escapes the tag and loads a second,
|
||||
stale copy; `tests/test_frontend_cache.py` fails on one.
|
||||
- Frontend JS never assigns prototype callbacks directly — use
|
||||
`chainCallback` from `js/shared/index.mjs`, or `chainHandler` where the
|
||||
return value matters (a truthy `onMouseDown` result is what stops a node
|
||||
drag). Messages to the user go through `showToast`, never `alert()`.
|
||||
- The mouse wheel over a node zooms the graph, on every part of its face.
|
||||
`js/canvas_passthrough/` hands the wheel on from the pack's panels, and
|
||||
keeps it only for a box that can still scroll or a field being typed in,
|
||||
so a panel adds no wheel listener of its own
|
||||
(`tests/canvas_passthrough.test.mjs` lists the few that exist). After a
|
||||
change to a node face, `scripts/dev/wheel_sweep.mjs` turns the wheel at
|
||||
every point of every node and must find no dead spot, in both renderers.
|
||||
- For compact panel actions and preview switches, use `compactAction` and
|
||||
`panelSwitch` from `js/shared/panel_controls.mjs`. That module owns their
|
||||
appearance, section caption styles, and common file and save labels. A
|
||||
node styles its layout locally; leave the shared control's face to the
|
||||
component. Its selectors only match explicitly named AusBoss controls.
|
||||
- Every INT and FLOAT a public node exposes reaches the user as a scrub
|
||||
control, Adobe-style: drag the value to scrub, click to type, chevron
|
||||
arrows step, Shift is always the fine step. Use `makeScrubInput` from
|
||||
`js/shared/scrub_input.mjs` — never a bare `<input type=number>`, and
|
||||
never a classic canvas number widget on a finished node face. Units
|
||||
(`px`, `MP`, `×`) ride inside the box in a fixed-width slot that every
|
||||
single-field row reserves, so the numbers down a card share one centre
|
||||
line. Known, deliberate exceptions: the Seed card's seed (typed) and Load
|
||||
Video's trim timecodes (typed).
|
||||
- **Node faces are widget cards.** A node whose face would be classic
|
||||
LiteGraph widgets (full-width rows with an arrow at each end) gets an
|
||||
entry in `js/widget_cards/index.js` instead: `mountWidgetCard` from
|
||||
`js/shared/widget_card.mjs` hides the standard widgets and mirrors them
|
||||
in one compact DOM card — numbers → scrub, ≤ 4 short choices → segmented
|
||||
pill (else a select), booleans → an off | on pill as wide as the other
|
||||
controls (never a small switch inside a card), strings → text field,
|
||||
multiline → `kind: "textarea"` (`grow: true` takes the node's spare
|
||||
height), hex colors → swatch. Rows can depend on other values (`when`)
|
||||
and fold behind a disclosure (`group`). The widgets underneath stay the
|
||||
single source of truth (save/load, undo, API, links).
|
||||
- **One linkable widget per row.** The frontend (≥ 1.10.4, "Widget Input
|
||||
Socket" RFC #9) gives every widget an input socket drawn at the widget's
|
||||
own row, only while a link is dragged, hovered or connected; "convert to
|
||||
input" no longer exists. The card lends each hidden widget its row's
|
||||
position, so two widgets on one row would stack two sockets on one pixel
|
||||
and nobody could aim. A `pair` row is therefore only allowed with
|
||||
`top: true`, which lifts its sockets into the node's slot column under
|
||||
the real inputs (Image Resize width/height); everything else is one
|
||||
widget per row. A value that is meant to be wired rather than typed is a
|
||||
backend `forceInput: True` socket (Math Expression `a`/`b`/`c`), and a
|
||||
multi-type string such as `"FLOAT,INT"` accepts either kind of link.
|
||||
- Preview-carrying nodes (Select Frame, Mask Refine, Mask by Name, LaMa
|
||||
Inpaint, Save Image) share
|
||||
`js/input_preview/`: a thin bar (node tools left, a small `preview`
|
||||
switch right) above the picture, the picture gone and the node shorter
|
||||
when the switch is off, backed by an optional `preview` BOOLEAN input
|
||||
that also skips the temp file. A new node with a result picture reuses
|
||||
it rather than growing its own.
|
||||
- A DOM panel that shows a stage/preview claims the node's free height via
|
||||
`fillNodeHeight` from `js/shared/panel_layout.mjs` — never a hand-rolled
|
||||
`computeSize`, which pins the panel and leaves dead space when the node is
|
||||
dragged taller. `tests/panel_guards.test.mjs` enforces this pack-wide: a
|
||||
new panel entry must be added to its `mustGrow` set (or `fixedByDesign`
|
||||
for genuinely constant-height rows), so the choice is always explicit.
|
||||
A `fixedByDesign` card also calls `pinVuePanelHeight`: its own
|
||||
`computeSize` only pins it in the classic renderer, and Nodes 2.0 would
|
||||
stretch it with the node and squeeze a picture under it. Run Timer does
|
||||
not: its readout fills the node on purpose.
|
||||
- **A picture or clip in a stage sits out of flow.** Nodes 2.0 sizes a node
|
||||
by its content, so an in-flow portrait clip drawn at full width held Load
|
||||
Video at 965 px where classic goes to 584 px. Position the media
|
||||
`absolute` in its stage, and the stage takes its size from the node
|
||||
alone. A picture is centred at its own size (`js/input_preview/`); a
|
||||
player fills the stage so its controls stay along the bottom edge
|
||||
(`js/shared/video_ui.mjs`). Text that is fitted to its box follows the
|
||||
same rule: the Callout's text is `absolute` in its card
|
||||
(`js/callout/index.js`), or the text sizes the card it is fitted to.
|
||||
`tests/panel_guards.test.mjs` holds all three.
|
||||
- **A panel's declared minimum width is its node's real floor.** Pass
|
||||
`exactMinWidth: true` to `fillNodeHeight`; a constant-height card that
|
||||
keeps its own `computeSize` calls `holdNodeMinWidth` instead. Left alone,
|
||||
the frontend adds 104 px to a growing panel's number and ignores a pinned
|
||||
card's number altogether (the node falls to its 210 px default), so a
|
||||
declared 320 meant 424 for one and 210 for the other. Prove each face on
|
||||
a real canvas at that width in both renderers; a panel that clips there
|
||||
gets a larger minimum, not a broken face. Nodes 2.0 never goes under
|
||||
225 px whatever a panel declares.
|
||||
- **A growing panel's declared minimum height holds in Nodes 2.0 too.**
|
||||
Nodes 2.0 lays a node out by its content, not by the floor a widget
|
||||
declares, so a panel whose content has no height of its own was dragged
|
||||
flat there. `fillNodeHeight` also writes the floor to the panel's
|
||||
element as a CSS `min-height`, less `WIDGET_FRAME`, while the panel sits
|
||||
inside a Nodes 2.0 node. So declare `minHeight` as what the element needs
|
||||
plus `WIDGET_FRAME`, and never assign a growing panel's
|
||||
`computeLayoutSize` by hand. Prove the floor with a real corner drag up
|
||||
in both renderers.
|
||||
- Frontend settings use `AusBoss.<Area>.<Name>` ids with
|
||||
`category: ["🆎 AusBoss", "<Area>", "<Leaf>"]` and a distinct leaf per
|
||||
setting. Node color schemes live in `js/shared/appearance.mjs`.
|
||||
|
||||
### Adding a node
|
||||
|
||||
Follow `docs/adding_a_node.md`. Short version: create `nodes/node_<name>.py`
|
||||
modelled on a small existing node such as `nodes/node_image_size.py`, add
|
||||
`"node_<name>"` to `NODE_MODULES` in `__init__.py` and the key to
|
||||
`PUBLIC_NODE_IDS` in `scripts/validate_nodes.py`, give it a help page at
|
||||
`js/docs/<KEY>.md`, optionally add `js/<name>/index.js`, then validate.
|
||||
|
||||
### Validation
|
||||
|
||||
Run the offline checks before a pull request:
|
||||
|
||||
```bash
|
||||
python scripts/validate_nodes.py
|
||||
python scripts/release_preflight.py
|
||||
python scripts/run_python_tests.py # with ComfyUI's Python
|
||||
node --test tests/*.test.mjs
|
||||
```
|
||||
|
||||
Then restart ComfyUI fully (a changed `INPUT_TYPES` is only served after a
|
||||
restart), watch the AusBoss banner for failed modules, confirm the node
|
||||
appears in `GET http://127.0.0.1:8188/object_info`, queue a tiny API graph,
|
||||
and load its example workflow. After JS changes, hard-refresh the browser
|
||||
tab (Ctrl+Shift+R) — the frontend caches `.mjs` modules aggressively.
|
||||
|
||||
Frontend work is proven on a real canvas, not by reading code: the
|
||||
headless-Chrome harness in `scripts/dev/` (recipe, gotchas and the
|
||||
screenshot convention in `docs/live_testing.md`) creates nodes, drives real
|
||||
mouse drags, and screenshots the result. A link-drop test that only checks
|
||||
which input got the link is not enough — look at the picture and ask whether
|
||||
a person could have aimed there.
|
||||
|
||||
A ComfyUI update can break a node without a line of the pack changing. So
|
||||
the pack is run against the newest ComfyUI every night
|
||||
(`.github/workflows/comfyui_compat.yml`): its newest release and its
|
||||
development head, on the CPU, with no model files. Three checks, which also
|
||||
run by hand against any ComfyUI:
|
||||
|
||||
```bash
|
||||
AUSBOSS_COMFY_ROOT=<ComfyUI> python tests/test_comfy_core_contract.py
|
||||
python scripts/compat_boot.py --url http://127.0.0.1:8188
|
||||
node scripts/dev/face_smoke.mjs <chrome port> # and again with --vue
|
||||
```
|
||||
|
||||
- `test_comfy_core_contract.py` reads the pack's own source for everything
|
||||
it takes from ComfyUI and looks each thing up in the real one. It finds
|
||||
imports by itself. A call to one of ComfyUI's own nodes by name
|
||||
(`SAM3_Detect`), or an internal read with a fallback, goes in the tables at
|
||||
the top of that file; the test fails on a node call that is not listed.
|
||||
- `compat_boot.py` asks a running ComfyUI which nodes it offers: every public
|
||||
node of the pack, and every node and required input the examples use.
|
||||
- `face_smoke.mjs` puts every node on a graph and fails when a face does not
|
||||
come up or something throws.
|
||||
|
||||
A failure on the release opens an issue labelled `comfyui-update`. What these
|
||||
cannot see is anything that needs a model: run the real thing on a GPU
|
||||
before a release that follows a ComfyUI update.
|
||||
|
||||
### Releasing
|
||||
|
||||
A new `version` in `pyproject.toml` that lands on main publishes to the Comfy
|
||||
Registry through the publish workflow (`.github/workflows/publish_action.yml`).
|
||||
After a successful upload, the same workflow tags that commit and creates a
|
||||
GitHub Release with the full changelog section. The README badge links to the
|
||||
latest GitHub Release. If only that step fails, rerun the failed jobs to retry
|
||||
it without uploading the Registry version again. Existing releases are left
|
||||
unchanged on a rerun. Contributors never change `version`.
|
||||
|
||||
For an authorized release, update both `pyproject.toml` and
|
||||
`AUSBOSS_JS_VERSION`, date the changelog section, then run
|
||||
`python scripts/release_badge.py`. Preflight rejects a badge that does not
|
||||
match the pack version. Its SVG lives in the repository so the release
|
||||
number stays visible when an external badge service is unavailable.
|
||||
|
||||
The Registry shows notes with each version. They are that version's section
|
||||
of `CHANGELOG.md`, one line per entry: its bold opening, and for a new node
|
||||
the sentence that says what it does (`scripts/changelog_notes.py`). So open
|
||||
every entry with a bold line that makes sense on its own.
|
||||
|
||||
### Example workflows
|
||||
|
||||
Examples are what most people run first, so each one must work for a
|
||||
stranger with only the files its Workflow Note lists.
|
||||
|
||||
- Every model loader selects the official file name, the basename of its
|
||||
download URL, carries that file in `properties.models`
|
||||
(`name`, `url`, `directory`), and the Note's model row names the same
|
||||
file. People who keep models in subfolders pick their copy.
|
||||
- Media loaders (Load Image, Load Image + Pad, Load Video, Image Crop +
|
||||
Rotate + Pad and the video nodes) are saved blank. A file name is one the
|
||||
stranger doesn't have, and ComfyUI flags that node red when the workflow
|
||||
loads. The Workflow Note tells people to load their own picture; the
|
||||
sample files stay in `example_workflows/inputs/` for anyone who wants to
|
||||
try one.
|
||||
- Save nodes use a filename prefix without a folder. Seeds are fixed.
|
||||
- Numbered groups hold every node, nothing overlaps (title bars included),
|
||||
the saved zoom is at least 0.6 so widget text draws, the workflow `id` is
|
||||
a real uuid4, and the `.jpg` beside the `.json` shows a real output.
|
||||
- The frontend saves widget values twice, by position and by name
|
||||
(`widgets_values_named`), and can restore by name; both copies must agree.
|
||||
- `scripts/workflow_contract.py` checks links, groups, overlaps, the note,
|
||||
the id, the zoom, the named copies, the download info and the save
|
||||
prefixes. Whether the note tells the truth about the graph is review.
|
||||
- Text-to-image examples have no source to describe. Edit and inpaint
|
||||
examples keep an explicit change instruction: a description of the old
|
||||
scene is not an edit. MiniMax H3 and Klein caption their sources with
|
||||
Qwen3-VL 8B because their own text encoders returned only punctuation
|
||||
when asked to describe an image.
|
||||
|
||||
@@ -3,19 +3,31 @@
|
||||
<p>ComfyUI nodes for image and video work.</p>
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT License">
|
||||
<!-- Dynamic: shields.io reads the version out of pyproject.toml on main
|
||||
at view time, so this badge can never go stale. release_preflight.py
|
||||
checks it stays the dynamic kind. -->
|
||||
<img src="https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fausboss%2FComfyUI-AusBoss%2Fmain%2Fpyproject.toml&query=%24.project.version&label=release&color=00b4aa&style=flat-square" alt="Release">
|
||||
<!-- Generated by scripts/release_badge.py. Release preflight checks
|
||||
the badge against pyproject.toml so it cannot silently go stale. -->
|
||||
<a href="https://github.com/ausboss/ComfyUI-AusBoss/releases/latest"><img src="assets/readme/release.svg" alt="Latest release"></a>
|
||||
<a href="https://www.youtube.com/@ausbossdev"><img src="https://img.shields.io/badge/YouTube-%40ausbossdev-00b4aa?style=flat-square&logo=youtube&logoColor=white" alt="YouTube @ausbossdev"></a>
|
||||
<a href="https://discord.gg/RwVxY9Fuaj"><img src="https://img.shields.io/badge/Discord-AusBoss-00b4aa?style=flat-square&logo=discord&logoColor=white" alt="Discord: AusBoss"></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
I made these nodes for my own ComfyUI workflows, mostly because I wanted features I couldn't find in other packs. I use them myself, and I keep adding new ones.
|
||||
|
||||
Most of them are for image and video work. You can rotate, crop and pad a picture by dragging handles on the node, trim a clip on a timeline, and outpaint or inpaint with your original pixels put back. ComfyUI's core nodes already cover the basics, like seeds, sizes and prompts, so use these where they add something.
|
||||
Most of them are for image and video work. ComfyUI's core nodes already cover the basics, like seeds, sizes and prompts, so use these where they add something. A few of the things they do:
|
||||
|
||||
The **?** in a node's title bar opens a short card with its inputs and outputs, and every node has a longer help page in ComfyUI's node help panel. I post the workflows I make with them on [Civitai](https://civitai.com/user/AusBoss) and [X @Zanzibased](https://x.com/Zanzibased), and videos of them on [YouTube @ausbossdev](https://www.youtube.com/@ausbossdev).
|
||||
- **[Mask by Touch](#mask-by-touch):** click a thing on the picture and get its mask. Hover shows what a click would take, and **Fine edges** follows hair and fur.
|
||||
- **[Mask by Name](#mask-by-name):** type "the red jacket" and get its mask and a cut-out.
|
||||
- **[Image Crop + Rotate + Pad](#image-crop--rotate--pad):** turn, crop and pad a picture by dragging handles on the node, for outpainting.
|
||||
- **[Stitch Inpaint](#stitch-inpaint):** after an inpaint or outpaint, everything you didn't ask to change is your original pixels again.
|
||||
- **[Video Crop + Rotate + Pad → Clip](#video-crop--rotate--pad--clip):** trim a clip on a timeline, and pad or crop it to a new shape.
|
||||
- **[Tiled Upscale](#tiled-upscale):** upscale a picture in tiles to the megapixels you set.
|
||||
- **[Image Folder](#image-folder):** tick pictures in a folder and run the workflow on each one.
|
||||
- **[Layout Boxes](#layout-boxes):** draw boxes where things and words go in the picture, for MOSS-Image.
|
||||
- **[Image Compare A/B](#image-compare-ab):** a before-and-after slider, and a brush to mix the two.
|
||||
- **[LoRA Loader](#lora-loader):** a whole LoRA stack in one node, each row with its own switch and strength.
|
||||
- **[Workflow Switches](#workflow-switches):** turn parts of a workflow off and on from one small card.
|
||||
|
||||
The **?** in a node's title bar opens a short card with its inputs and outputs, and every node has a longer help page in ComfyUI's node help panel. I post the workflows I make with them on [Civitai](https://civitai.com/user/AusBoss) and [X @Zanzibased](https://x.com/Zanzibased), and videos of them on [YouTube @ausbossdev](https://www.youtube.com/@ausbossdev). Can't open Civitai? All my workflows are on [my Discord](https://discord.gg/RwVxY9Fuaj) too. If a node saves you time, a star on the repo helps other people find it.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -42,10 +54,10 @@ The pack uses the Pillow, NumPy, Torch and PyAV that come with ComfyUI. Each exa
|
||||
| [Video nodes](#video-nodes) | Load and trim, pick a frame, transform a clip, change the frame rate, save |
|
||||
| [Mask and inpaint nodes](#mask-and-inpaint-nodes) | Mask a thing by clicking it or typing its name, refine masks, remove objects with LaMa, crop around a mask and stitch the result back |
|
||||
| [Models and conditioning](#models-and-conditioning) | LoRA stack, Krea 2 prompts and references |
|
||||
| [Workflow utilities](#workflow-utilities) | Canvas size, seed, batches, math, text, memory, notes, callouts, switches, timer |
|
||||
| [Workflow utilities](#workflow-utilities) | Canvas size, seed, batches, math, text, memory, notes, callouts, switches, timer, step preview |
|
||||
| [Example workflows](#example-workflows) | Ready-made workflows, each with a setup card |
|
||||
| [For workflow creators](#for-workflow-creators) | Setup cards, before and after, repeatable seeds, measured run times |
|
||||
| [Pack-wide tools](#pack-wide-tools) | Help cards, node colors, recreate and replace, run status, completion sound |
|
||||
| [Pack-wide tools](#pack-wide-tools) | Help cards, node colors, snap into line, recreate and replace, run status, completion sound |
|
||||
|
||||
## Image nodes
|
||||
|
||||
@@ -53,7 +65,7 @@ The pack uses the Pillow, NumPy, Torch and PyAV that come with ComfyUI. Each exa
|
||||
|
||||
Rotate, crop and pad a picture by dragging handles on the node. Cyan squares crop, orange diamonds pad, and the green handle rotates. It rotates first, then crops, then pads. The full-screen editor adds exact sizes, zoom and pan.
|
||||
|
||||
Tap a ratio under the preview to pad the picture to it, or to crop it with **Fit** set to crop. A lit ratio is the shape the canvas has right now: drag a handle to another shape and it goes dark, and the row says Custom. The padlock at the end of the row keeps the shape while you drag, and the button at the start turns the shape between portrait and landscape. **Reset crop** brings back the whole picture, and **Divisible by** makes the width and height divide evenly by a number such as 8, 16 or 32, for models that need it. Feather and output resizing are on the node and in the editor.
|
||||
Open **Crop, pad & resize** under the preview and tap a ratio to pad the picture to it, or to crop it with **Fit** set to crop. A lit ratio is the shape the canvas has right now: drag a handle to another shape and it goes dark, and the row says Custom. The padlock at the end of the row keeps the shape while you drag, and the button at the start turns the shape between portrait and landscape. **Reset crop** brings back the whole picture, and **Divisible by** makes the width and height divide evenly by a number such as 8, 16 or 32, for models that need it. Feather and output resizing are on the node and in the editor.
|
||||
|
||||
It returns the `image`, a `mask` of everything the model paints (padding, see-through parts of the source and the corners a rotation opens up), a `stitcher`, the untouched `original`, the output `width` and `height`, and a `prompt_image`. Wire the stitcher into **Stitch Inpaint** after the sampler to put your original back. See-through parts of a PNG, such as a cutout's missing background, are filled like the padding and painted; `prompt_image` and `original` show them as white. Give `prompt_image` to whatever writes your prompt, so it describes a real backdrop instead of the gray fill. **Load Image + Pad** has more fill choices and a separate reference output.
|
||||
|
||||
@@ -73,7 +85,7 @@ The Klein 9B Outpaint example pads its picture with Load Image + Pad. [Open the
|
||||
|
||||
### Image Folder
|
||||
|
||||
Pick a folder, tick the pictures you want in a thumbnail gallery, and run the workflow on each one. **All in one run** does every picked picture with one press of Run. **One per run** loads one picture each run and steps to the next by itself, so a picture that fails only skips itself. It gives each picture's mask, its file name for saving the result under the same name, its number and how many there are.
|
||||
Pick a folder, tick the pictures you want in a thumbnail gallery, and run the workflow on each one. **All in one run** does every picked picture with one press of Run. **One per run** loads one picture each run and steps to the next by itself, so a picture that fails only skips itself. After the last picture it stops with a message, or starts over at the first and keeps going. It gives each picture's mask, its file name for saving the result under the same name, its number and how many there are.
|
||||
|
||||
**Browse** lists the folders inside ComfyUI's input or output folder. A folder from anywhere else on your computer is brought in: drop it on the node, or choose it in your file explorer, and its pictures are copied into the input folder. A really big folder asks first. The node reads nothing outside those two folders.
|
||||
|
||||
@@ -125,7 +137,7 @@ Qwen drew this watercolor 5.6% taller, and Realign to Source lined it back up wi
|
||||
|
||||
### Image Compare A/B
|
||||
|
||||
Compares two pictures with a slider, or flips between them whole. Nothing covers the picture, and its size shows underneath. The panel grows with the node, and image A passes through, so you can put it anywhere on an image wire. **Erase** mixes the two by hand: rub parts of one picture away with a brush so the other shows through, then save the mix as a PNG under a name you type, with a layered PSD for Photoshop or GIMP if you want it.
|
||||
Compares two pictures with a slider, or flips between them whole. Nothing covers the picture, and its size shows underneath. The panel grows with the node, and images A and B pass through, so you can put it anywhere on an image wire. **Erase** mixes the two by hand: rub parts of one picture away with a brush so the other shows through, then save the mix as a PNG under a name you type, with a layered PSD for Photoshop or GIMP if you want it.
|
||||
|
||||
### Save Image
|
||||
|
||||
@@ -133,6 +145,8 @@ Saves PNG, lossless WebP or lossless JPEG XL, with the workflow embedded if you
|
||||
|
||||
Link `filename` to keep a name from upstream, or `caption_text` to write a matching `.txt` next to each picture. The counter picks a name that isn't taken yet. Turn it off to reuse the same path on purpose. JPEG XL needs the optional `pillow-jxl-plugin` extra. The [help page](js/docs/AUSBOSS_NODES_SaveImage.md) has the full naming and compatibility notes.
|
||||
|
||||
Turn on **steps** to watch the picture form in the node while it is being made, with a **STOP** button for a run that is going wrong. It is off by default.
|
||||
|
||||
## Video nodes
|
||||
|
||||

|
||||
@@ -147,6 +161,12 @@ Upload a video, or pick one from the Source list, which plays the clip under the
|
||||
|
||||
It returns the frames, audio, frame count, fps, width, height, duration and a core `VIDEO`. Only the part you kept is decoded, and it checks memory before a large load. When you save the frames, wire its fps into the save node.
|
||||
|
||||
### Load Audio
|
||||
|
||||
Upload a sound file, or pick one from the Source list, and the node shows it as a wave. Drag the IN and OUT handles to keep only the part you want, or type the times. Press play to hear just that part. Set **Max length** to the longest sound the next step can take, such as a video's length: anything past it turns red and is cut off, so you see what comes out before you run. Click the red length next to OUT to move OUT to the cut.
|
||||
|
||||
It returns the `audio` you kept and its length in `seconds`. Only that part is decoded. [Help page](js/docs/AUSBOSS_NODES_LoadAudio.md).
|
||||
|
||||
### Video Crop + Rotate + Pad → Frame
|
||||
|
||||
Picks one frame from a video, then gives you the same rotate, crop and pad controls as the image node, plus an optional megapixel resize. Scrub to the frame with the playhead on the node; the editor adds frame-by-frame stepping and playback. It returns the transformed `image`, a `mask` of the added area, a `stitcher`, the chosen `original` frame, and the output `width` and `height`. For a whole clip, use the Clip version below.
|
||||
@@ -155,7 +175,7 @@ Picks one frame from a video, then gives you the same rotate, crop and pad contr
|
||||
|
||||
Applies one rotate, crop and pad to every frame of a trimmed clip. It has the source picker, a timeline with a playhead and frame-accurate IN and OUT handles, frame skipping, a frame limit, the transform handles and output resizing. While you drag a trim handle, the node shows the exact first or last frame the run keeps. The bright part of the selection is what gets output.
|
||||
|
||||
Fill, feather and the resize budget sit on the node face, and a new clip keeps them. That matters for video outpainting, because the black, hard-edged canvas the model needs survives a source swap. A fresh Clip node starts with that canvas, and the editor's **Reset transform** goes back to it. **Frames for** trims the end to the frame count the next video model takes: LTX (8n+1) or Wan (4n+1). Tap a ratio to pad the clip to it; a new clip is padded to the lit ratio too. The padlock keeps the shape while you drag a handle.
|
||||
Fill, feather and the resize budget are in **Crop, pad & resize** below the timeline, and a new clip keeps them. That matters for video outpainting, because the black, hard-edged canvas the model needs survives a source swap. A fresh Clip node starts with that canvas, and the editor's **Reset transform** goes back to it. **Frames for** trims the end to the frame count the next video model takes: LTX (8n+1) or Wan (4n+1). Tap a ratio to pad the clip to it; a new clip is padded to the lit ratio too. The padlock keeps the shape while you drag a handle.
|
||||
|
||||
It returns the frames, mask, audio, frame count, fps, size, duration, a `stitcher`, and the chosen `original` frames before the transform. **Inpaint & Stitch** in the editor sets the protected source area and previews the blend. After a video outpaint, wire the stitcher straight into **Stitch Inpaint**, with no Crop For Inpaint in between. It works in chunks to keep memory down.
|
||||
|
||||
@@ -179,7 +199,7 @@ It returns the new frames and their fps. It doesn't touch audio, so carry the so
|
||||
|
||||
Saves a frame batch or a core `VIDEO` as MP4 (H.264 or H.265), WebM (VP9 or AV1), ProRes MOV, lossless FFV1 MKV, GIF or WebP. Which encoders you get depends on your FFmpeg and PyAV build, and H.264 and H.265 come in CPU and NVENC versions. The videos carry color tags, and optionally audio and the workflow.
|
||||
|
||||
**Ping-pong** plays forward and then back without repeating the turnaround frames. A wired `VIDEO` brings its own frame rate; otherwise wire fps from Load Video or Frame Interpolate. The player on the node shows the saved file. Drop a saved video back on the canvas to load the workflow inside it, for the formats that can hold one. The [help page](js/docs/AUSBOSS_NODES_SaveVideo.md) has the format and audio details.
|
||||
**Ping-pong** plays forward and then back without repeating the turnaround frames. A wired `VIDEO` brings its own frame rate; otherwise wire fps from Load Video or Frame Interpolate. The player on the node shows the saved file, and **STEPS** shows the clip's first frame while it is being made, with a **STOP** button. Drop a saved video back on the canvas to load the workflow inside it, for the formats that can hold one. The [help page](js/docs/AUSBOSS_NODES_SaveVideo.md) has the format and audio details.
|
||||
|
||||
## Mask and inpaint nodes
|
||||
|
||||
@@ -199,6 +219,8 @@ The Mask by Name example finds a thing by its name and saves its cut-out and its
|
||||
|
||||
Click a thing on the picture and get its mask. No name to type and no MaskEditor: the picture is on the node. Move the pointer over it and the thing a click would take lights up, with its name beside the pointer: the jacket, not the whole person. Click to pick it, and click more things to add them. What is lit in teal is what the mask will be.
|
||||
|
||||

|
||||
|
||||
When a click could mean more than one thing, the node shows the pick with two arrows, like **‹ jacket · 1 of 3 ›**: an arrow swaps it for the next thing at that spot. A stroke across two things, like both shoes, takes both. Ctrl-click a thing to take it away again.
|
||||
|
||||
To fix the mask by hand, switch **Pick | Paint | Erase** (or press W, B or E, as in Photoshop). The brush has a size and a soft edge, set with **[** and **]** or by holding Alt and the right mouse button and dragging, and it shows under the pointer as it will paint. Hold **Ctrl** for the opposite: erase while you paint, paint while you erase.
|
||||
@@ -249,7 +271,9 @@ The toolbar has the stack toggle, saved templates, reconnect and settings. The t
|
||||
|
||||

|
||||
|
||||
Each LoRA gets an info card from the file's metadata, a `.civitai.info` file next to it, and words you save yourself. The card's **Fetch Civitai info** button looks the file up on Civitai (your browser asks civitai.com, only when you click) and saves the answer as that `.civitai.info` file. Files Civitai doesn't know, such as unpublished or hidden models, come back as not found. The trigger words you pick come out of the `triggers` output.
|
||||
Each LoRA gets an info card from the file's metadata, a `.civitai.info` file next to it, and words you save yourself. The card's **Fetch Civitai info** button looks the file up on Civitai (your browser asks civitai.com, only when you click) and saves the answer as that `.civitai.info` file. Files Civitai doesn't know, such as unpublished or hidden models, come back as not found. The trigger words you pick come out of the `triggers` output. The same click saves the LoRA's picture from Civitai next to the file, so hovering the LoRA shows it (PG-13 or milder unless you allow more in the gear menu; a picture you already have is never replaced).
|
||||
|
||||
In the **+ LoRA** list, tick several files to add them at once. Rows can be gathered into groups, like layer groups in an image editor: each group folds away, and has its own switch that turns all its LoRAs off while each one keeps its own setting. A Workflow Switch can turn a whole group on and off.
|
||||
|
||||
When a file has moved, the node finds it by name if only one file matches. A missing LoRA that is switched on stops the run by default. Turn off **Stop on missing LoRA** to warn and skip it instead. A LoRA that changes nothing in the model gets a warning with its file name.
|
||||
|
||||
@@ -288,7 +312,13 @@ It returns `width`, `height` and an empty image latent. The gear picks the laten
|
||||
|
||||
### Layout Boxes
|
||||
|
||||
Draw boxes on a canvas and say what goes in each one: a thing, or words to write in the picture. Wire `elements` to MOSS-Image Layout Prompt and the picture puts each thing where its box is. It does what ComfyUI's own Create Bounding Boxes does, with the same inputs and the same outputs from the same boxes, and its canvas also works in the classic node look. Drag to draw a box, drag it to move it and its handles to size it; under the canvas each box gets **object** or **text**, its words and up to five colors. A picture on `background` shows under the boxes and sets the canvas size, `width` and `height` can come from Latent Size, and boxes on the `bboxes` wire fill the canvas when the workflow runs. **Grid** snaps the boxes as you drag them, starts off over a picture and takes its size from the gear, **Layouts** starts from a ready arrangement, the arrow keys move the chosen box one pixel at a time (ten with Shift), and Delete and Ctrl + D remove and copy it. The `mask` output is white inside every box: load a picture, draw a box over the words on a sign, and an inpaint chain can write new words there while the rest of the picture stays as it was. For words at a slant, drag the round handle over a box to turn it: the mask turns with it, and a turned text box tells the model its slant.
|
||||
Draw boxes on a canvas and say what goes in each one: a thing, or words to write in the picture. Wire `elements` to MOSS-Image Layout Prompt and the picture puts each thing where its box is. It does what ComfyUI's own Create Bounding Boxes does, with the same inputs and the same outputs from the same boxes, and its canvas also works in the classic node look.
|
||||
|
||||
Drag to draw a box, drag it to move it and its handles to size it; under the canvas each box gets **object** or **text**, its words and up to five colors. A picture on `background` shows under the boxes and sets the canvas size, `width` and `height` can come from Latent Size, and boxes on the `bboxes` wire fill the canvas when the workflow runs. **Grid** snaps the boxes as you drag them, starts off over a picture and takes its size from the gear, **Layouts** starts from a ready arrangement, the arrow keys move the chosen box one pixel at a time (ten with Shift), and Delete and Ctrl + D remove and copy it.
|
||||
|
||||
The `mask` output is white inside every box: load a picture, draw a box over the words on a sign, and an inpaint chain can write new words there while the rest of the picture stays as it was. For words at a slant, drag the round handle over a box to turn it: the mask turns with it, and a turned text box tells the model its slant.
|
||||
|
||||

|
||||
|
||||
### Seed
|
||||
|
||||
@@ -310,6 +340,12 @@ Puts batch `b` after `a`. When the sizes match, nothing is resized. When they di
|
||||
|
||||
A text box that grows with the node and sends its text, unchanged, down a `STRING` wire. Use it for a prompt, caption, path or other text that several nodes share. Spaces and line breaks are kept. Ctrl + Up and Ctrl + Down change the weight of the selected words, as in ComfyUI's own prompt boxes.
|
||||
|
||||
### H3 Prompt Writer
|
||||
|
||||
Type what happens, one line for each moment, and leave a blank line to cut to a new place. Connect up to four pictures and give each one a job: a person, a place, an outfit, a thing or a style. Copy the moves from a video, keep a clip going, or add a voice or a song. A timeline on the node shows when each moment plays before you run it. It needs no extra model and you never see H3's prompt format. Connect a small text model and it fills in a one-line idea.
|
||||
|
||||
**Change something** edits the named part of a video while keeping its person and sound. For a person swap, an optional `@1 looks like ...` line describes the connected replacement. Jobs that keep the source clip's length show **From the clip**; the `keeps_clip_sound` output helps a workflow choose the source audio.
|
||||
|
||||
### Integer
|
||||
|
||||
One whole number (`INT`) on a scrub control, for sharing a size, count or other whole number across nodes. Like every number box in the pack, it takes a typed sum: `1024*1.5`.
|
||||
@@ -338,17 +374,25 @@ A setup card for a shared workflow: a title, instructions in Markdown, each mode
|
||||
|
||||
A note for the canvas whose text grows to fill it, so it reads without zooming in. Double-click to type; a blank line starts a new point and `**two stars**` stress a word. Type an arrow emoji such as ⬆️ and it turns into a teal arrow in the middle of the line. Click a node while you edit, then press an arrow, and the arrow is linked to that node: click it later and the canvas brings the node into view. It never runs and has no wires.
|
||||
|
||||
### Compact note and link tiles
|
||||
|
||||
**Callout Tile** shows a compact title or colored YouTube/Discord link button. **Workflow Note Tile** opens a full help note from a compact button. They reuse the note editors and save as distinct node types, so an older install reports a missing node instead of drawing an incompatible plain note. Install or update ComfyUI-AusBoss, restart ComfyUI and reload when either tile is missing. Existing ordinary notes and callouts keep their original types.
|
||||
|
||||
### Run Timer
|
||||
|
||||
A stopwatch for the whole run, with no wires. It starts when the queue starts running, holds the total when the run ends, and saves the last time with the workflow. Recent times are in its right-click menu. You can resize it. It only shows time and doesn't change how the graph runs.
|
||||
|
||||
### Step Preview
|
||||
|
||||
Shows the picture while it is being made, one step at a time, so you can stop a run that is going wrong after the first few steps. It has no wires: put it anywhere and it shows whichever sampler is working. **STOP** stops this run; **STOP ALL** also empties the queue. When the picture is saved, the finished picture replaces the last step. It turns on ComfyUI's step pictures for its own runs, unless you chose a preview method yourself. The [help page](js/docs/AUSBOSS_NODES_StepPreview.md) covers sharper step pictures.
|
||||
|
||||
### Workflow Switches
|
||||
|
||||
Turns parts of a workflow off and on from one small card. Select the nodes of one part, such as a box and its Save Image, press **+ Switch**, and they get one **off | on** switch with a name you choose. Every group can have a switch of its own too. Off bypasses the nodes, so a step in the middle of a chain can be skipped and the picture still reaches the steps after it; a setting mutes them instead. A switch can change settings instead: save what chosen nodes hold for on and for off, and one click sets Steps to 8 and turns a LoRA row on. **Done** shrinks the card to the labels and their switches. A row whose nodes are only partly on reads **mixed**. The node never runs and has no wires.
|
||||
Turns parts of a workflow off and on from one small card. Select the nodes of one part, such as a box and its Save Image, press **+ Switch**, and they get one **off | on** switch with a name you choose. To pick between settings instead, set a node and press **+ Choice** for each way: the card becomes one pill. Every group can have a switch of its own too. Off bypasses the nodes, so a step in the middle of a chain can be skipped and the picture still reaches the steps after it; a setting mutes them instead. A switch can change settings instead: save what chosen nodes hold for on and for off, and one click sets Steps to 8 and turns a LoRA row on. **Done** shrinks the card to the labels and their switches. A row whose nodes are only partly on reads **mixed**. The node never runs and has no wires. An AI agent can set one up for you: point it at the [agent guide](js/docs/agents/workflow_switches.md).
|
||||
|
||||
## Example workflows
|
||||
|
||||
These are the workflows where the nodes do something core ComfyUI doesn't do by itself: outpainting and inpainting that put your original picture back, lining an edit back up with its source, upscaling a picture in tiles, masking a thing by its name, and removing a watermark from a clip. Open a JSON file from [`example_workflows/`](example_workflows), or find them under this pack in ComfyUI's template browser. Each one has a thumbnail, numbered groups and a Workflow Note with the setup steps and model downloads.
|
||||
These are the workflows where the nodes do something core ComfyUI doesn't do by itself: outpainting and inpainting that put your original picture back, lining an edit back up with its source, upscaling a picture in tiles, masking a thing by its name or with a click, and removing a watermark from a clip. Open a JSON file from [`example_workflows/`](example_workflows), or find them under this pack in ComfyUI's template browser. Each one has a thumbnail, numbered groups and a Workflow Note with the setup steps and model downloads.
|
||||
|
||||

|
||||
|
||||
@@ -392,6 +436,8 @@ People who download your workflow get the nodes through ComfyUI-Manager's missin
|
||||
- **Help cards:** the **?** in a node's title bar opens its description, inputs and outputs. Run Timer has no title bar, so it has **About this node** in its right-click menu.
|
||||
- **Recreate node 🆎** (right-click an AusBoss node) rebuilds a node saved by an older version from the current definition. It keeps the values, links, position and colors.
|
||||
- **Replace with AusBoss nodes 🆎** (canvas right-click or the command palette) finds third-party nodes this pack can stand in for, such as VideoHelperSuite's Load Video and Video Combine or KJNodes' Color Match. It previews each swap and replaces the ones you leave ticked.
|
||||
- **Snap into line:** a node, group or selection you drag lines up with the nodes and groups around it, edge to edge or middle to middle, and a teal line shows what it lines up with. Pulling a corner to resize lines up the same way. Hold Shift while you drag to skip it, or turn it off in Settings → 🆎 AusBoss → Canvas.
|
||||
- **Line up:** select two or more nodes or groups and the selection toolbar gets a Line up button: line up edges or middles, match widths or heights, or space them evenly. With two or more nodes selected, pulling the edge or corner of one resizes them all: a column gets one width, a row one height, a grid both (turn it off in Settings → 🆎 AusBoss → Canvas → Resize together).
|
||||
- **Node colors:** Settings → 🆎 AusBoss → Appearance sets the scheme for every AusBoss node (AusBoss, Graphite, Slate, Teal, Moss, Plum, Rust, Navy, Custom or Theme default). A node's right-click **AusBoss color** menu recolors just that node, and nodes you colored by hand keep their colors.
|
||||
- **Run status:** under Settings → 🆎 AusBoss → Chrome, the queue status can show in the browser tab's title and icon, running nodes get live progress badges, and each node's run time can be shown.
|
||||
- **Completion sound:** Settings → 🆎 AusBoss → Notifications plays a short chime when the queue finishes.
|
||||
|
||||
+13
-2
@@ -13,8 +13,10 @@ follows a few fixed rules.
|
||||
never take a file to read from the browser: they work on the two previews
|
||||
that node made in its last run, and Align writes its lined-up copy of B
|
||||
beside them in ComfyUI's temp folder. The name typed for the new files is a plain name
|
||||
with no folder in it, and they go straight into the output folder. Load Video picks from the input
|
||||
folder; the video transform nodes read a video picked from a list of the
|
||||
with no folder in it, and they go straight into the output folder. Load Video, Load Image + Pad
|
||||
and Image Crop + Rotate + Pad pick from the input folder, and take a file ComfyUI made by
|
||||
the name it gives it (`photo.png [output]`), as core Load Image does; the video transform
|
||||
nodes read a video picked from a list of the
|
||||
input or the output folder, by the name ComfyUI gives it there
|
||||
(`clip.mp4 [output]`), and their uploader streams into the input folder.
|
||||
No input opens a whole path as given. Absolute paths, `..`, `~`, Windows drive and
|
||||
@@ -32,12 +34,21 @@ follows a few fixed rules.
|
||||
`<model>.civitai.info` (inside ComfyUI's LoRA folders). The pack checks
|
||||
that the answer describes the same file before saving it. Nothing is sent
|
||||
until you click, and the button can be turned off in the node's settings.
|
||||
The same click also has your browser download the LoRA's first picture
|
||||
from Civitai's picture server (a small copy, without cookies, rated PG-13
|
||||
or milder unless you allow more). The pack checks it is a picture and
|
||||
saves it beside the LoRA as `<model>.preview.jpg`, never replacing a
|
||||
picture that is there. The gear menu's **Civitai picture** option turns
|
||||
this off.
|
||||
- **No code execution.** Math Expression parses its text into an
|
||||
allowlisted syntax tree of arithmetic and a few math functions; it never
|
||||
runs Python source. The pack starts no subprocesses and installs nothing
|
||||
at runtime.
|
||||
- **No automatic downloads.** You place model weights (LaMa, RAFT optical
|
||||
flow) yourself; the pack never fetches them.
|
||||
- **LaMa opens only known files.** A LaMa model file can run code when it
|
||||
opens, so LaMa Inpaint checks a file's SHA-256 first and opens only known
|
||||
copies of `big-lama.pt`.
|
||||
- **No raw HTML from workflows.** Workflow Note renders its Markdown into
|
||||
text nodes, and its links open only `http(s)` addresses.
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ NODE_MODULES = [
|
||||
"node_compare",
|
||||
"node_frame_interpolate",
|
||||
"node_free_memory",
|
||||
"node_h3_prompt_writer",
|
||||
"node_image_crop_rotate_pad",
|
||||
"node_image_folder",
|
||||
"node_image_resize",
|
||||
@@ -35,6 +36,7 @@ NODE_MODULES = [
|
||||
"node_layout_boxes",
|
||||
"node_literals",
|
||||
"node_load_image_pad",
|
||||
"node_load_audio",
|
||||
"node_load_video",
|
||||
"node_lora_loader",
|
||||
"node_mask_by_name",
|
||||
@@ -49,6 +51,7 @@ NODE_MODULES = [
|
||||
"node_seed",
|
||||
"node_select_frame",
|
||||
"node_show_text",
|
||||
"node_step_preview",
|
||||
"node_tiled_upscale",
|
||||
"node_video_crop_rotate_pad",
|
||||
"node_video_crop_rotate_pad_clip",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="105" height="20" role="img" aria-label="release: v2.21.1">
|
||||
<title>release: v2.21.1</title>
|
||||
<path fill="#555" d="M0 0h48v20H0z"/>
|
||||
<path fill="#00b4aa" d="M48 0h57v20H48z"/>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text x="24" y="14">release</text>
|
||||
<text x="76.5" y="14">v2.21.1</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 425 B |
@@ -21,7 +21,7 @@ Every visible field and output must earn its place. Published IDs, input order,
|
||||
1. Put independently testable processing in an underscore-prefixed helper under `nodes/`.
|
||||
2. Add one V1 wrapper in `nodes/node_<purpose>.py` with `INPUT_TYPES`, `RETURN_TYPES`, `FUNCTION`, `CATEGORY`, and both mapping dictionaries — keys as string literals, never a `NODE_ID` variable, or registry scanners cannot see the node.
|
||||
3. Add the module to `NODE_MODULES` in `__init__.py` and the key to `PUBLIC_NODE_IDS` in `scripts/validate_nodes.py`; the validator fails on a registered key it does not know. A key kept only so old workflows load goes in `LEGACY_NODE_IDS` instead.
|
||||
4. Give the node its face. Any INT, FLOAT, BOOLEAN, COMBO or STRING widget on a public node is shown through a widget card entry in `js/widget_cards/index.js` (see AGENTS.md §Conventions: numbers scrub, booleans are off | on pills, one linkable widget per row, `top: true` or `forceInput` for values people wire). Write more frontend JavaScript only when the card grammar cannot provide the interaction (a stage, a player, an editor).
|
||||
4. Give the node its face. Any INT, FLOAT, BOOLEAN, COMBO or STRING widget on a public node is shown through a widget card entry in `js/widget_cards/index.js` (see CONTRIBUTING.md, Conventions: numbers scrub, booleans are off | on pills, one linkable widget per row, `top: true` or `forceInput` for values people wire). Write more frontend JavaScript only when the card grammar cannot provide the interaction (a stage, a player, an editor).
|
||||
5. Namespace every route, extension, event, DOM marker, CSS class, cache, and browser state with `ausboss`.
|
||||
6. Chain lifecycle hooks through `chainCallback`; never replace core or third-party prototypes directly.
|
||||
7. Keep normal workflow and API execution independent from the custom frontend.
|
||||
@@ -31,7 +31,7 @@ Every visible field and output must earn its place. Published IDs, input order,
|
||||
## Document and prove
|
||||
|
||||
- Add rich help at `js/docs/<exact-mapping-key>.md` (the validator checks it exists) and a section in `README.md`. Name card controls the way the node face shows them, with the widget name alongside: **Tone match** (`color_match`).
|
||||
- Show the node in an example under `example_workflows/`, a new one or an existing one it fits: a Workflow Note, numbered groups holding every node, no overlaps, and a matching `.jpg` of a real output. AGENTS.md § Example workflows lists the rest.
|
||||
- Show the node in an example under `example_workflows/`, a new one or an existing one it fits: a Workflow Note, numbered groups holding every node, no overlaps, and a matching `.jpg` of a real output. CONTRIBUTING.md, Example workflows, lists the rest.
|
||||
- A DOM panel goes into `tests/panel_guards.test.mjs` as `mustGrow` or `fixedByDesign`.
|
||||
- At the next release, name the node in the pyproject `description`: Registry and Manager search read it.
|
||||
- Extend `scripts/validate_nodes.py` when the new contract needs a permanent assertion.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -1,4 +1,4 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import {
|
||||
CUSTOM_SCHEME,
|
||||
DEFAULT_SCHEME,
|
||||
@@ -190,7 +190,7 @@ app.registerExtension({
|
||||
});
|
||||
|
||||
function isTitleless(node) {
|
||||
return node.constructor?.title_mode === (globalThis.LiteGraph?.NO_TITLE ?? 1);
|
||||
return (node.title_mode ?? node.constructor?.title_mode) === (globalThis.LiteGraph?.NO_TITLE ?? 1);
|
||||
}
|
||||
|
||||
function openHelpCard(node, event) {
|
||||
@@ -210,9 +210,8 @@ function openHelpCard(node, event) {
|
||||
function installHelpBadge(node) {
|
||||
// A title-less node (Run Timer) has no bar to hang the badge on; its
|
||||
// card opens from the node's context menu instead.
|
||||
if (isTitleless(node)) return;
|
||||
chainCallback(node, "onDrawForeground", function (ctx) {
|
||||
if (!showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return;
|
||||
if (isTitleless(this) || !showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return;
|
||||
const [x, y] = badgeCenter(this.size[0]);
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
@@ -228,7 +227,7 @@ function installHelpBadge(node) {
|
||||
ctx.restore();
|
||||
});
|
||||
chainHandler(node, "onMouseDown", function (event, pos) {
|
||||
if (!showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return false;
|
||||
if (isTitleless(this) || !showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return false;
|
||||
if (!pos || !hitsBadge(pos, this.size[0])) return false;
|
||||
openHelpCard(this, event);
|
||||
return true; // consume the click so it does not start a drag
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
//
|
||||
// Imports nothing from ../shared on purpose. Old cached copies of those files
|
||||
// are the problem this entry has to survive.
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
const PACK_URL = new URL("../", import.meta.url).href;
|
||||
const PARALLEL = 6;
|
||||
|
||||
+171
-9
@@ -6,7 +6,9 @@
|
||||
// and copy ride the default path); the panel only shows it. Decisions live in
|
||||
// js/shared/callout.mjs under node:test.
|
||||
|
||||
import { app } from "/scripts/app.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { setTileFace, TILE_FACE_CSS } from "../shared/tile_face.mjs";
|
||||
import { CALLOUT_TYPES, serializeNoteIdentity } from "../shared/note_identity.mjs";
|
||||
import { BRAND, chainCallback, keepDomWidgetWidthAuto } from "../shared/index.mjs";
|
||||
import {
|
||||
ARROW_ANGLE,
|
||||
@@ -16,18 +18,22 @@ import {
|
||||
fitFontSize,
|
||||
parseBlocks,
|
||||
pastedLink,
|
||||
repointLinks,
|
||||
targetId,
|
||||
toggleSmall,
|
||||
tileLinkFields,
|
||||
tileLinkText,
|
||||
urlLabel,
|
||||
wrapLink,
|
||||
} from "../shared/callout.mjs";
|
||||
import { onPaste } from "../shared/paste_ids.mjs";
|
||||
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
|
||||
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
|
||||
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
|
||||
import { commitWidgetValue } from "../shared/widget_card_math.mjs";
|
||||
import { compactAction } from "../shared/panel_controls.mjs";
|
||||
import { frameBounds } from "../shared/workflow_switches.mjs";
|
||||
|
||||
const NODE_CLASS = "AUSBOSS_NODES_Callout";
|
||||
const CSS_ID = "ausboss-callout-css";
|
||||
const WIDGET_NAME = "ausboss_callout_panel";
|
||||
const DEFAULT_SIZE = [380, 160];
|
||||
@@ -35,6 +41,10 @@ const PANEL_MIN_WIDTH = 200;
|
||||
const PANEL_MIN_HEIGHT = 64;
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const ARROW_COLOR = "#1fd3c6";
|
||||
const LINK_TILES = {
|
||||
youtube: { label: "Watch Me on YouTube", url: "https://www.youtube.com/@ausbossdev" },
|
||||
discord: { label: "Join for Help or More Workflows", url: "https://discord.gg/RwVxY9Fuaj" },
|
||||
};
|
||||
const FRAME_FILL = 0.8; // the share of the view a node brought into view takes, as in Workflow Switches
|
||||
const FONT = '"IBM Plex Sans", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif';
|
||||
|
||||
@@ -53,6 +63,7 @@ function ensureCss() {
|
||||
const style = document.createElement("style");
|
||||
style.id = CSS_ID;
|
||||
style.textContent = `
|
||||
${TILE_FACE_CSS}
|
||||
.ausboss-callout{box-sizing:border-box;width:100%;height:100%;padding:2px 4px 6px;overflow:hidden;pointer-events:none;}
|
||||
.ausboss-callout-card{position:relative;box-sizing:border-box;width:100%;height:100%;overflow:hidden;border-radius:8px;background:linear-gradient(135deg,rgba(0,180,170,.07),rgba(0,0,0,.2));}
|
||||
.ausboss-callout-inner{position:absolute;top:0;bottom:0;left:0;box-sizing:border-box;width:100%;height:fit-content;margin:auto 0;padding:.6em 1em .6em .85em;color:#d6e6e4;font-family:${FONT};font-weight:500;line-height:1.4;letter-spacing:.003em;overflow-wrap:anywhere;}
|
||||
@@ -61,6 +72,15 @@ function ensureCss() {
|
||||
.ausboss-callout-point b{color:#6fe0d6;font-weight:650;}
|
||||
.ausboss-callout-small{margin-top:.3em;color:#9fb5b2;font-size:max(11px,.62em);font-weight:400;line-height:1.4;}
|
||||
.ausboss-callout-small b{color:#8fd6cf;font-weight:600;}
|
||||
.ausboss-callout-card[data-tile=youtube]{background:#ae2639}
|
||||
.ausboss-callout-card[data-tile=discord]{background:#5146aa}
|
||||
.ausboss-callout-card[data-tile=title]{background:#090909;border-left:4px solid #21c7b9}
|
||||
.ausboss-callout-card[data-tile] .ausboss-callout-inner{padding-block:.15em;}
|
||||
.ausboss-callout-card[data-tile] .ausboss-callout-point{border:0;padding:0;margin:0;font-weight:650;color:#fff}
|
||||
.ausboss-callout-card[data-tile] .ausboss-callout-url{color:white;text-decoration:none;display:flex;align-items:center;gap:12px}
|
||||
.ausboss-callout-card[data-tile] .ausboss-callout-url svg{width:36px;height:28px;flex:none}
|
||||
.ausboss-callout-card[data-tile] .ausboss-callout-out{margin-left:auto}
|
||||
.ausboss-callout-card[data-tile] .ausboss-callout-tools{right:27px;}
|
||||
.ausboss-callout-empty{color:#6f8987;font-size:14px;font-weight:400;}
|
||||
.ausboss-callout-arrow{display:inline-block;width:1.2em;height:1.2em;margin:0 .1em;vertical-align:-.26em;filter:drop-shadow(0 0 .3em rgba(0,180,170,.55));}
|
||||
.ausboss-callout-arrow svg{display:block;width:100%;height:100%;overflow:visible;}
|
||||
@@ -73,7 +93,7 @@ function ensureCss() {
|
||||
.ausboss-callout-out{display:inline-block;margin-left:.2em;font-size:.7em;font-weight:600;vertical-align:.14em;text-decoration:none;opacity:.85;}
|
||||
.ausboss-callout-jump.is-gone{opacity:.45;cursor:default;text-decoration-style:dotted;}
|
||||
.ausboss-callout-tools{position:absolute;top:5px;right:5px;display:flex;gap:4px;opacity:0;transition:opacity .15s;pointer-events:auto;}
|
||||
.ausboss-callout-card:hover .ausboss-callout-tools,.ausboss-callout.is-selected .ausboss-callout-tools{opacity:.95;}
|
||||
.ausboss-callout-card:is(:hover,:focus-within) .ausboss-callout-tools,.ausboss-callout.is-selected .ausboss-callout-tools{opacity:.95;}
|
||||
.ausboss-callout-tool{height:22px;padding:0 8px;border:1px solid rgba(0,180,170,.45);border-radius:5px;background:rgba(8,14,16,.86);color:#9fd8d3;font:600 11px ${FONT};letter-spacing:.02em;cursor:pointer;}
|
||||
.ausboss-callout-tool:hover{color:#fff;border-color:${BRAND};}
|
||||
.ausboss-callout-editor{position:absolute;inset:0;box-sizing:border-box;display:flex;flex-direction:column;gap:6px;padding:6px;border:1px solid ${BRAND};border-radius:8px;background:rgba(5,10,11,.95);pointer-events:auto;container-type:size;}
|
||||
@@ -88,6 +108,17 @@ function ensureCss() {
|
||||
.ausboss-callout-pick svg{display:block;width:100%;height:100%;}
|
||||
.ausboss-callout-size{flex:none;height:24px;margin-left:4px;padding:0 7px;border:1px solid rgba(0,180,170,.35);border-radius:5px;background:rgba(8,14,16,.9);color:#9fd8d3;font:600 11px ${FONT};cursor:pointer;}
|
||||
.ausboss-callout-size:hover{border-color:${BRAND};background:rgba(0,180,170,.14);}
|
||||
.ausboss-tile-editor{box-sizing:border-box;width:min(520px,94vw);max-height:86vh;overflow:auto;margin:auto;padding:24px;border:1px solid #365957;border-top:3px solid ${BRAND};border-radius:10px;background:#111b1c;color:#e6f4f3;box-shadow:0 18px 64px #0008;font:14px/1.5 ${FONT};}
|
||||
.ausboss-tile-editor::backdrop{background:#06090ab8;}
|
||||
.ausboss-tile-editor h2{margin:0 0 18px;font-size:21px;}
|
||||
.ausboss-tile-editor label{display:flex;flex-direction:column;gap:7px;margin-bottom:16px;font-weight:600;}
|
||||
.ausboss-tile-editor input,.ausboss-tile-editor textarea{box-sizing:border-box;width:100%;padding:10px 12px;border:1px solid #365957;border-radius:5px;background:#080e10;color:#e6f4f3;font:16px/1.5 ${FONT};user-select:text;}
|
||||
.ausboss-tile-editor textarea{min-height:120px;resize:vertical;}
|
||||
.ausboss-tile-editor :is(input,textarea):focus{outline:2px solid ${BRAND};outline-offset:1px;}
|
||||
.ausboss-tile-editor footer{display:flex;justify-content:flex-end;align-items:center;gap:8px;}
|
||||
.ausboss-tile-editor footer span{margin-right:auto;color:#99b0ad;font-size:12px;}
|
||||
.ausboss-tile-editor .ausboss-compact-action{height:32px;padding:0 15px;font-size:13px;}
|
||||
.ausboss-tile-editor .ausboss-tile-error{color:#ffc36d;margin:0 0 12px;}
|
||||
@container (max-width:395px){.ausboss-callout-arrowbar>span:first-child,.ausboss-callout-link:not(.has-node){display:none;}}
|
||||
@container (max-width:395px) and (min-height:112px){.ausboss-callout-arrowbar{flex-wrap:wrap;}.ausboss-callout-pick{flex:none;}}
|
||||
`;
|
||||
@@ -175,6 +206,18 @@ function arrowSpan(state, kind, piece = {}) {
|
||||
function linkRun(state, first) {
|
||||
if (first.url !== undefined) {
|
||||
const link = el("a", "ausboss-callout-url");
|
||||
const tile = state.card.dataset.tile;
|
||||
if (tile === "youtube" || tile === "discord") {
|
||||
const icon = svg("svg", { viewBox:"0 0 28 20", "aria-hidden":"true" });
|
||||
if (tile === "youtube") {
|
||||
icon.append(svg("rect",{x:0,y:1,width:28,height:18,rx:5,fill:"white"}),
|
||||
svg("path",{d:"M11 5.5 19 10 11 14.5Z",fill:"#ae2639"}));
|
||||
} else {
|
||||
icon.append(svg("path",{d:"M4 3 10 1l1 2a15 15 0 0 1 6 0l1-2 6 2c2 4 3 8 3 12l-6 4-2-3a17 17 0 0 1-10 0l-2 3-6-4c0-4 1-8 3-12Z",fill:"white"}));
|
||||
for (const cx of [9,19]) icon.append(svg("ellipse",{cx,cy:10,rx:2,ry:2.6,fill:"#5146aa"}));
|
||||
}
|
||||
link.append(icon);
|
||||
}
|
||||
link.href = first.url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
@@ -220,6 +263,10 @@ function bringIntoView(target) {
|
||||
}
|
||||
|
||||
function renderText(state) {
|
||||
const style = state.node.properties?.ausbossCalloutTile;
|
||||
if (["youtube","discord","title"].includes(style)) state.card.dataset.tile = style;
|
||||
else delete state.card.dataset.tile;
|
||||
setTileFace(state.node, state.root, Boolean(state.card.dataset.tile), () => startEdit(state));
|
||||
const blocks = parseBlocks(state.valueWidget.value);
|
||||
state.inner.replaceChildren();
|
||||
state.empty = blocks.length === 0;
|
||||
@@ -317,7 +364,9 @@ function fit(state) {
|
||||
inner.style.fontSize = `${px}px`;
|
||||
return inner.offsetHeight <= height && inner.scrollWidth <= width + 1;
|
||||
};
|
||||
inner.style.fontSize = `${fitFontSize(fits)}px`;
|
||||
const size = fitFontSize(fits);
|
||||
const social = card.dataset.tile === "youtube" || card.dataset.tile === "discord";
|
||||
inner.style.fontSize = `${social ? Math.min(size, 16) : size}px`;
|
||||
}
|
||||
|
||||
function markChanged() {
|
||||
@@ -329,8 +378,78 @@ function markChanged() {
|
||||
}
|
||||
}
|
||||
|
||||
function startTileEdit(state) {
|
||||
if (state.editor) return;
|
||||
const original = String(state.valueWidget.value ?? "");
|
||||
const social = ["youtube", "discord"].includes(state.card.dataset.tile);
|
||||
const link = social ? tileLinkFields(original) : null;
|
||||
const dialog = el("dialog", "ausboss-tile-editor");
|
||||
const heading = social ? "Edit link tile" : "Edit title";
|
||||
dialog.setAttribute("aria-label", heading);
|
||||
dialog.append(el("h2", "", heading));
|
||||
const label = el("label", "", link ? "Button text" : "Text");
|
||||
const text = el(link ? "input" : "textarea");
|
||||
text.value = link?.label ?? original;
|
||||
label.append(text);
|
||||
dialog.append(label);
|
||||
let address;
|
||||
if (link) {
|
||||
const field = el("label", "", "Link address");
|
||||
address = el("input");
|
||||
address.type = "url";
|
||||
address.value = link.url;
|
||||
field.append(address);
|
||||
dialog.append(field);
|
||||
}
|
||||
const error = el("p", "ausboss-tile-error");
|
||||
error.setAttribute("role", "alert");
|
||||
const foot = el("footer");
|
||||
const cancel = compactAction("Cancel");
|
||||
const save = compactAction("Save");
|
||||
foot.append(el("span", "", "Ctrl + Enter to save"), cancel, save);
|
||||
dialog.append(error, foot);
|
||||
const commit = () => {
|
||||
let value = text.value;
|
||||
try {
|
||||
if (address) value = tileLinkText(text.value, address.value);
|
||||
} catch (problem) {
|
||||
error.textContent = problem.message;
|
||||
return;
|
||||
}
|
||||
if (value !== original) {
|
||||
state.node.graph?.beforeChange?.();
|
||||
commitWidgetValue(state.node, state.valueWidget, value, app.canvas);
|
||||
renderText(state);
|
||||
state.node.graph?.afterChange?.();
|
||||
markChanged();
|
||||
}
|
||||
dialog.close();
|
||||
};
|
||||
save.addEventListener("click", commit);
|
||||
cancel.addEventListener("click", () => dialog.close());
|
||||
dialog.addEventListener("keydown", event => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
}
|
||||
});
|
||||
dialog.addEventListener("keyup", event => event.stopPropagation());
|
||||
dialog.addEventListener("close", () => {
|
||||
state.editor = null;
|
||||
dialog.remove();
|
||||
state.root.querySelector(".ausboss-callout-tool")?.focus();
|
||||
}, { once: true });
|
||||
state.editor = dialog;
|
||||
document.body.append(dialog);
|
||||
dialog.showModal();
|
||||
text.focus();
|
||||
text.select();
|
||||
}
|
||||
|
||||
function startEdit(state) {
|
||||
if (state.editor) return;
|
||||
if (state.card.dataset.tile) return startTileEdit(state);
|
||||
const wrap = el("div", "ausboss-callout-editor");
|
||||
const area = el("textarea", "ausboss-callout-edit");
|
||||
area.value = state.valueWidget.value ?? "";
|
||||
@@ -497,16 +616,17 @@ function buildPanel(node) {
|
||||
root.append(card);
|
||||
tools.addEventListener("pointerdown", (event) => event.stopPropagation());
|
||||
|
||||
const panelHeight = () => (card.dataset.tile ? 36 : PANEL_MIN_HEIGHT) + WIDGET_FRAME;
|
||||
const domWidget = node.addDOMWidget(WIDGET_NAME, "ausboss_callout", root, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
getMinHeight: () => PANEL_MIN_HEIGHT + WIDGET_FRAME,
|
||||
getMinHeight: panelHeight,
|
||||
});
|
||||
keepDomWidgetWidthAuto(domWidget);
|
||||
domWidget.serialize = false;
|
||||
fillNodeHeight(domWidget, {
|
||||
minWidth: PANEL_MIN_WIDTH,
|
||||
minHeight: () => PANEL_MIN_HEIGHT + WIDGET_FRAME,
|
||||
minHeight: panelHeight,
|
||||
minNodeSize: [PANEL_MIN_WIDTH, PANEL_MIN_HEIGHT + 40],
|
||||
exactMinWidth: true,
|
||||
});
|
||||
@@ -514,6 +634,9 @@ function buildPanel(node) {
|
||||
const state = { node, valueWidget, domWidget, root, card, inner, empty: true, editor: null, frame: 0 };
|
||||
node.__ausbossCallout = state;
|
||||
edit.addEventListener("click", () => startEdit(state));
|
||||
card.addEventListener("dblclick", (event) => {
|
||||
if (card.dataset.tile && !event.target.closest("a,button,textarea,.ausboss-callout-editor")) startEdit(state);
|
||||
});
|
||||
state.observer = new ResizeObserver(() => {
|
||||
cancelAnimationFrame(state.frame);
|
||||
state.frame = requestAnimationFrame(() => fit(state));
|
||||
@@ -529,17 +652,44 @@ function buildPanel(node) {
|
||||
|
||||
app.registerExtension({
|
||||
name: "AusBoss.Callout",
|
||||
setup() {
|
||||
// A note copied with what its links name follows the copies.
|
||||
onPaste(({ nodes, groups, created }) => {
|
||||
for (const node of created) {
|
||||
if (!CALLOUT_TYPES.has(node?.comfyClass) && !CALLOUT_TYPES.has(node?.type)) continue;
|
||||
const state = buildPanel(node);
|
||||
if (!state) continue;
|
||||
const text = repointLinks(state.valueWidget.value, nodes, groups);
|
||||
if (text === null) continue;
|
||||
state.valueWidget.value = text;
|
||||
renderText(state);
|
||||
}
|
||||
});
|
||||
},
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== NODE_CLASS) return;
|
||||
if (!CALLOUT_TYPES.has(nodeData?.name)) return;
|
||||
hideInputsInDef(nodeData, ["text"]);
|
||||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||
if (nodeData.name === "AUSBOSS_NODES_CalloutTile") {
|
||||
this.properties ??= {};
|
||||
this.properties.ausbossCalloutTile ??= "title";
|
||||
}
|
||||
buildPanel(this);
|
||||
// Keep new tiles compact without changing ordinary or saved callouts.
|
||||
if (nodeData.name === "AUSBOSS_NODES_CalloutTile") this.setSize?.([380, 80]);
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onConfigure", function () {
|
||||
chainCallback(nodeType.prototype, "onSerialize", function (saved) {
|
||||
serializeNoteIdentity(saved, ["youtube", "discord", "title"].includes(this.properties?.ausbossCalloutTile));
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onConfigure", function (saved) {
|
||||
// Saved values land after onNodeCreated; show them.
|
||||
queueMicrotask(() => {
|
||||
const state = buildPanel(this);
|
||||
if (state) renderText(state);
|
||||
if (!state) return;
|
||||
renderText(state);
|
||||
if (state.card.dataset.tile && Number.isFinite(saved?.size?.[1])) {
|
||||
this.setSize?.([this.size[0], saved.size[1]]);
|
||||
}
|
||||
});
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onDblClick", function () {
|
||||
@@ -549,12 +699,24 @@ app.registerExtension({
|
||||
chainCallback(nodeType.prototype, "getExtraMenuOptions", function (_canvas, options) {
|
||||
const state = this.__ausbossCallout;
|
||||
if (!state || !Array.isArray(options)) return;
|
||||
for (const [style, label] of [["plain","Plain callout"],["title","Title tile"],["youtube","Set YouTube link tile"],["discord","Set Discord link tile"]]) {
|
||||
options.unshift({ content: label, callback: () => {
|
||||
this.properties ??= {};
|
||||
this.properties.ausbossCalloutTile = style;
|
||||
const preset = LINK_TILES[style];
|
||||
if (preset) commitWidgetValue(this, state.valueWidget, `[${preset.label}](${preset.url})`, app.canvas);
|
||||
renderText(state);
|
||||
if (style !== "plain") this.setSize?.([this.size[0], style === "title" ? 86 : 78]);
|
||||
markChanged();
|
||||
}});
|
||||
}
|
||||
options.unshift({ content: "Edit callout text", callback: () => startEdit(state) }, null);
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onRemoved", function () {
|
||||
const state = this.__ausbossCallout;
|
||||
if (!state) return;
|
||||
state.observer?.disconnect();
|
||||
state.editor?.close?.();
|
||||
cancelAnimationFrame(state.frame);
|
||||
this.__ausbossCallout = null;
|
||||
});
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
// ComfyUI does for its own text boxes. A panel that wants the wheel for itself
|
||||
// (a scrolling list, a focused text field, an editor) still keeps it.
|
||||
//
|
||||
// A file dragged in from the desktop has the same problem in the classic
|
||||
// renderer: dropped on a node's title it loads into the node, dropped on the
|
||||
// node's panel (the picture, the controls) it missed the node. The drop
|
||||
// listeners here hand it to the node under the pointer.
|
||||
import { app } from "/scripts/app.js";
|
||||
// A file dragged in from the desktop, or a picture's card from the Assets
|
||||
// panel, has the same problem in the classic renderer: dropped on a node's
|
||||
// title it loads into the node, dropped on the node's panel (the picture, the
|
||||
// controls) it missed the node. The drop listeners here hand it to the node
|
||||
// under the pointer.
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import {
|
||||
dragCarriesFiles, graphDragStarts, panelHost, panelKeepsWheel, panelNeedsDropHelp, panelRoot,
|
||||
} from "../shared/canvas_passthrough.mjs";
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { AUSBOSS_JS_VERSION, BRAND, BRAND_BODY, chainCallback } from "../shared/index.mjs";
|
||||
import { nodeByExecutionId } from "../shared/graph_ids.mjs";
|
||||
import { isOwnRun, ownRuns } from "../shared/prompt_scope.mjs";
|
||||
|
||||
+22
-4
@@ -1,5 +1,5 @@
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { isForeignRun, outputKey } from "../shared/prompt_scope.mjs";
|
||||
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
|
||||
import { lookupLink } from "../shared/graph_links.mjs";
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
normalizeCompareMode,
|
||||
sizeAfterErase,
|
||||
} from "../shared/compare.mjs";
|
||||
import { savedLinks } from "../shared/compare_erase.mjs";
|
||||
import { CHECKER, backdropPadding, savedLinks } from "../shared/compare_erase.mjs";
|
||||
import { ERASE_CSS, makeEraser } from "../shared/compare_erase_stage.mjs";
|
||||
|
||||
const NODE_NAME = "AUSBOSS_NODES_Compare";
|
||||
@@ -54,6 +54,7 @@ function ensureCompareCss() {
|
||||
.ausboss-compare-stage{position:relative;flex:1 1 auto;min-height:112px;overflow:hidden;border:1px solid rgba(0,180,170,.34);border-radius:6px;background:#000;box-shadow:inset 0 0 0 1px rgba(255,255,255,.025);touch-action:none;}
|
||||
.ausboss-compare-stage img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none;}
|
||||
.ausboss-compare-stage.is-empty img{visibility:hidden;}
|
||||
.ausboss-compare-stage img.has-alpha{box-sizing:border-box;background-image:repeating-conic-gradient(${CHECKER.light} 0 25%,${CHECKER.dark} 0 50%);background-size:${CHECKER.side * 2}px ${CHECKER.side * 2}px;background-origin:content-box;background-clip:content-box;}
|
||||
.ausboss-compare-seam{position:absolute;top:0;bottom:0;z-index:2;width:1px;margin-left:-0.5px;background:${BRAND};box-shadow:0 0 4px rgba(0,180,170,.55);opacity:0;pointer-events:none;}
|
||||
.ausboss-compare-status{position:absolute;left:7px;top:7px;z-index:3;max-width:calc(100% - 112px);padding:3px 6px;border-radius:4px;background:rgba(0,0,0,.7);color:#b8d3d1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none;backdrop-filter:blur(4px);}
|
||||
/* Once the previews are up the status has nothing to say, and an empty chip
|
||||
@@ -104,6 +105,19 @@ function applyClip(state) {
|
||||
state.identity.setAttribute("aria-label", state.identity.title);
|
||||
}
|
||||
|
||||
// A picture with see-through parts lies on a checkerboard. It is the
|
||||
// picture's own background, held to the picture by padding the size of the
|
||||
// bars beside it, so in Slide the cut that shows A hides B behind A's clear
|
||||
// parts too.
|
||||
function placeBackdrops(state) {
|
||||
const { clientWidth, clientHeight } = state.stage;
|
||||
for (const [image, ref] of [[state.imageA, state.refs?.a], [state.imageB, state.refs?.b]]) {
|
||||
const clear = Boolean(ref?.alpha) && image.naturalWidth > 0;
|
||||
image.classList.toggle("has-alpha", clear);
|
||||
image.style.padding = clear ? backdropPadding(clientWidth, clientHeight, image.naturalWidth, image.naturalHeight) : "";
|
||||
}
|
||||
}
|
||||
|
||||
function setEmpty(state, text) {
|
||||
state.stage.classList.add("is-empty");
|
||||
state.status.textContent = text;
|
||||
@@ -355,7 +369,10 @@ function buildPanel(node) {
|
||||
updateModeButtons(state);
|
||||
|
||||
// The labels are placed in pixels, so they follow the stage's width.
|
||||
const resized = new ResizeObserver(() => applyClip(state));
|
||||
const resized = new ResizeObserver(() => {
|
||||
applyClip(state);
|
||||
placeBackdrops(state);
|
||||
});
|
||||
resized.observe(stage);
|
||||
signal.addEventListener("abort", () => resized.disconnect());
|
||||
// Movement belongs to the preview only; graph dragging and panning remain
|
||||
@@ -378,6 +395,7 @@ function buildPanel(node) {
|
||||
}, { signal });
|
||||
|
||||
const onImageLoad = () => {
|
||||
placeBackdrops(state);
|
||||
state.loaded += 1;
|
||||
if (state.loaded >= 2) {
|
||||
setReady(state);
|
||||
|
||||
@@ -7,9 +7,17 @@ to point at the part you are talking about.
|
||||
Use it to explain a step in a workflow you share. It never runs and has no
|
||||
wires.
|
||||
|
||||
## Tile presets
|
||||
|
||||
Right-click the Callout and choose **Title tile** to give your existing text a black heading with a teal accent. **Set YouTube link tile** and **Set Discord link tile** fill in the AusBoss link and label. Click the label to open the link, or drag the tile to move it. Choose **Plain callout** to return to the standard style with the current text.
|
||||
|
||||
- Press **Edit** on a tile to open a larger editor. A link tile has separate **Button text** and **Link address** fields. Existing mixed text stays in a text box.
|
||||
- Double-click a title to edit it. **Save** or `Ctrl+Enter` keeps the change; **Cancel** or `Esc` leaves the original text.
|
||||
- Drag the visible bottom-right grip to resize. You can also focus the grip and use arrow keys; hold **Shift** for smaller changes. Resizing keeps the tile in place and supports Undo.
|
||||
|
||||
## Writing
|
||||
|
||||
- Double-click the note, or press **Edit**, to type. Click outside the box to
|
||||
- On a plain callout, double-click the note, or press **Edit**, to type. Click outside the box to
|
||||
keep the text. **Esc** drops what you typed.
|
||||
- A blank line starts a new point. Each point gets a thin teal line beside it.
|
||||
- Put two stars around a word to stress it: `**Resize is on.**`
|
||||
@@ -64,6 +72,8 @@ In the text a link is written `[the words](https://example.com)`, or
|
||||
|
||||
- The text is stored in the node's one hidden `text` input, so saving,
|
||||
undo and copy work like any other node. Nothing else is saved with it.
|
||||
- A note copied together with the nodes and groups its links name links to
|
||||
the copies. A link to anything left behind keeps its number.
|
||||
- In the text, small print is a line that starts with six `#` and a space:
|
||||
`###### Off, your words go in as typed.` Small print is 62% of the note's
|
||||
text size and never under 11 px.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Callout Tile
|
||||
|
||||
A compact title or colored link button for a workflow. Choose **Title tile**, **Set YouTube link tile**, or **Set Discord link tile** from its menu. Press **Edit** to change its text or link; you can also double-click a title. Drag its visible corner to resize.
|
||||
|
||||
It uses the same text, arrows and links as [Callout](AUSBOSS_NODES_Callout.md). **Plain callout** returns to the ordinary note face. Nothing runs and it has no outputs.
|
||||
|
||||
Tiles save with their own node type. If ComfyUI reports this node missing, install or update **ComfyUI-AusBoss**, restart ComfyUI, and reload the workflow. An older pack cannot draw the tile correctly. Keep the missing node so its text and settings are preserved.
|
||||
@@ -1,15 +1,16 @@
|
||||
# Image Compare A/B
|
||||
|
||||
Shows two images on one panel so differences pop out instead of hiding in a
|
||||
side-by-side. The first frame of each batch is previewed; `image_a` passes
|
||||
through the output unchanged, so the node can sit in the middle of a graph
|
||||
without breaking anything downstream.
|
||||
side-by-side. The first frame of each batch is previewed; `image_a` and
|
||||
`image_b` pass through their outputs unchanged, so the node can sit in the
|
||||
middle of a graph without breaking anything downstream.
|
||||
|
||||
## Inputs
|
||||
|
||||
- **image_a**: The baseline batch. Its first frame is preview A, and the
|
||||
whole batch is what the output carries.
|
||||
- **image_b**: The comparison batch. Its first frame is preview B.
|
||||
whole batch is what the `image_a` output carries.
|
||||
- **image_b**: The comparison batch. Its first frame is preview B, and the
|
||||
whole batch is what the `image_b` output carries.
|
||||
- **filename** (optional): a name for the files that **Erase** saves, such
|
||||
as the source picture's file name. Leave it unwired to type the name on
|
||||
the node.
|
||||
@@ -28,6 +29,9 @@ picks what the panel does, and its marker slides to your choice:
|
||||
it and it rubs away, so the other one shows through. Then save the mix.
|
||||
See **Erase** below.
|
||||
|
||||
A picture with see-through parts shows them over a checkerboard, as an
|
||||
editor does.
|
||||
|
||||
The labels ride with the split line: **A** just left of it and **B** just right of
|
||||
it, on the picture each one names. With one picture showing, its label sits alone
|
||||
at the top centre. The chosen mode and locked side are
|
||||
@@ -49,12 +53,18 @@ Use it when you want part of one picture and part of the other: the edit
|
||||
is good but it changed the hands, so you paint the old hands back. Or you
|
||||
only want the new sky.
|
||||
|
||||
It also brings back a clear background. When the picture underneath has
|
||||
see-through parts, such as a cut-out the model filled in behind, rub the
|
||||
top one away there and they come back clear. The saved PNG and PSD keep
|
||||
them.
|
||||
|
||||
Pick **Erase** on the switch. A row of tools opens above the picture:
|
||||
|
||||
- **Erase | Restore**: what the brush does. Erase rubs the top picture
|
||||
away. Restore paints it back. Holding **Ctrl**, or using the right
|
||||
mouse button, does the other one for as long as you hold it.
|
||||
- **Size** is the brush's width, in pixels of the picture.
|
||||
- The dot beside **Size** previews one brush dab. It grows with Size and its edge fades as you increase Soft.
|
||||
- **Soft** is the brush's edge: 0 is a hard edge, 100 fades all the way
|
||||
from the middle.
|
||||
- Drag left and right on **Size** or **Soft**, on the word, the number or
|
||||
@@ -128,9 +138,12 @@ B, the "after" picture. If your graph makes A out of B's picture (B is the
|
||||
picture you loaded and A is the edited one), the result is A. To keep one
|
||||
picture up whatever happens, use the **A** or **B** button.
|
||||
|
||||
## Output
|
||||
## Outputs
|
||||
|
||||
- **image_a**: The `image_a` batch, untouched.
|
||||
- **image_b**: The `image_b` batch, untouched: the picture you compared
|
||||
against, often the edited one. Wire it on when the picture you want to
|
||||
keep is B.
|
||||
|
||||
Previews are written to ComfyUI's temp folder and vanish with it. Nothing
|
||||
is saved to the output folder until you press **SAVE** in Erase, and no
|
||||
@@ -150,6 +163,12 @@ network requests are made.
|
||||
left, and a new run does not change it.
|
||||
- **Erase works on the first picture of each batch**, at its full size.
|
||||
The mix is always A's size: a B of another size is resized to fit A.
|
||||
- **See-through parts** are kept in a preview only when a picture has some,
|
||||
so solid pictures save exactly as before. The mix lays the top picture
|
||||
over the other as an editor does. In the PSD each layer keeps its own
|
||||
see-through parts, and the flat picture carries the mix's, its colours
|
||||
stored over white the way Photoshop stores them. **Align** measures the
|
||||
two pictures laid over gray and moves B's see-through parts with it.
|
||||
- **What is kept is the strokes**, not a painted picture. The panel draws
|
||||
them for you, and Save draws them again on the server at full size, so
|
||||
the saved edge is as sharp as the picture however small the node is.
|
||||
|
||||
@@ -32,13 +32,20 @@ side of the picture, which is how the pair outpaints.
|
||||
the paste mask is widened by this many pixels and blurred. The sampling
|
||||
mask sent to the inpainter stays hard-edged, so feathering never weakens
|
||||
what the model sees. `0` pastes with the raw mask.
|
||||
- **Multiple** (`output_multiple`): Crop and target dimensions are rounded
|
||||
up to a multiple of this so samplers accept them.
|
||||
- **Multiple** (`output_multiple`): The crop's size is rounded up to a
|
||||
multiple of this. Keep it at `32` (the default) for Qwen Image 2.1, Flux
|
||||
and most newer models. Their VAE trims a picture that is not a multiple
|
||||
of 16 or 32, and the trimmed result, stretched back over the crop, looks
|
||||
slightly bigger than the original. Stitch Inpaint says so in the console
|
||||
when that happens.
|
||||
- **Target width / Target height** (`target_width` / `target_height`, in
|
||||
the **Target size, extend** fold): Rescale the crop to a fixed size for
|
||||
the sampler. `0` keeps the native crop size; setting only one dimension
|
||||
derives the other from the crop's aspect ratio. Explicit values here
|
||||
override `target_megapixels`.
|
||||
override `target_megapixels`. When the crop is rescaled, the crop area
|
||||
takes in a few more pixels of context on one side so it has the exact
|
||||
shape of the target size, and the picture is scaled the same amount
|
||||
both ways, never stretched.
|
||||
|
||||
### Shaping the mask
|
||||
|
||||
@@ -95,6 +102,12 @@ Any inpainting sampler fits between the two nodes the same way — connect
|
||||
the crop's `image` and `mask` to it, then its output to Stitch Inpaint's
|
||||
`inpainted` input.
|
||||
|
||||
A picture with see-through parts keeps them through the pair. ComfyUI's
|
||||
Load Image splits them off into its `mask`; wire its `IMAGE` and `MASK`
|
||||
into **Join Image with Alpha** to put them back on before the crop. The
|
||||
sampler gets the colours, and Stitch Inpaint gives the result its clear
|
||||
parts back.
|
||||
|
||||
## Technical details
|
||||
|
||||
- Starting values: Context `1.2`, Blend `16` px, Multiple `8`; Grow, Blur,
|
||||
|
||||
@@ -8,6 +8,11 @@ every node that needs it, and change it in one place.
|
||||
- **Value** (`value`): The number to send out. It starts at `0`. The box
|
||||
shows the decimals its steps use (`0.00`) and holds up to three, so a
|
||||
typed `0.125` stays `0.125`.
|
||||
- The gear, right: **Look**. **normal** is the usual box with its label.
|
||||
**big** shows the number large in the middle of the node, with an arrow
|
||||
on each side, for a setting people change on every run, such as the
|
||||
length of a clip. A drag and typing work the same. Each node keeps its own look, and it is
|
||||
saved with the workflow.
|
||||
|
||||
## Limits
|
||||
|
||||
@@ -44,3 +49,7 @@ into **Math Expression 🆎** instead of retyping derived numbers.
|
||||
API is passed on as it is.
|
||||
- Wired only to inputs that share no value (one stops at 1, the other starts
|
||||
at 2), the box keeps no limits.
|
||||
- The look is stored in the node's properties as `ausboss_text_look: "big"`, the
|
||||
same as Text 🆎. It is not an input, so the saved value and what the node
|
||||
sends are the same in both looks, and an older version of the pack shows
|
||||
the usual box.
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
# H3 Prompt Writer
|
||||
|
||||
Type what happens, one line for each moment. Leave a blank line to cut to a new
|
||||
place. The node writes H3's full prompt for you, and you never see its format.
|
||||
|
||||
Use it with MiniMax H3 or Pika H3 Flex. Connect up to four pictures and give each
|
||||
one a job. Copy the moves from a video, keep a clip going, or add a voice or a
|
||||
song.
|
||||
|
||||
## How to write it
|
||||
|
||||
Every line is a moment, and the lines play in order in one shot. The bar under
|
||||
the box shows the second each one starts, before you spend a run.
|
||||
|
||||
```
|
||||
@1 walks through a crowded night market toward us, looking over her shoulder.
|
||||
@1 stops and looks up at the lanterns.
|
||||
|
||||
@1 stands at the edge of a rooftop in the rain, her back to us.
|
||||
@1 slowly turns her head toward us.
|
||||
```
|
||||
|
||||
That is two shots in two places. `@1` is Picture 1. With one person you can
|
||||
write `She` or `He` instead.
|
||||
|
||||
The node also fills in two things H3 misses by itself. A moment gets its own
|
||||
sound when it names something that makes one, like rain, footsteps or thunder.
|
||||
And what changes stays changed: when the lights go out, the next moments in that
|
||||
place say they are still out.
|
||||
|
||||
## The words box and the bar
|
||||
|
||||
The words the node acts on light up as you type: `@1` in teal (amber when
|
||||
nothing is plugged in there), a time like `3s:` in amber, words in quotes in
|
||||
yellow, and a blank line shows as a line marked CUT. Hover the `?` beside
|
||||
"What happens" for a short list of everything you can type.
|
||||
|
||||
Typing `@` lists the pictures you plugged in, with their job. The first letters
|
||||
of a line offer `sound:`, `music:`, `camera:` or `look:`, and after `camera:` or
|
||||
`look:` you get the node's own choices. Tab picks one, Esc closes the list.
|
||||
|
||||
The bar under the box is the clip from start to end, one block for each moment
|
||||
with the pictures it names:
|
||||
|
||||
- **Drag a block** along the bar to move its line. The times and cuts stay
|
||||
where they are.
|
||||
- **Drag the left edge** of a block to set when that moment starts. The node
|
||||
types the time (`3s:`) into its line for you.
|
||||
- **Click the left edge** to add or take out a cut there.
|
||||
- **Click a block** to pick out its line in the words, ready to change.
|
||||
|
||||
Everything on the bar changes the words, so Ctrl+Z undoes it.
|
||||
|
||||
## Give each picture a job
|
||||
|
||||
Pick a job for each picture under the words, then name it in your lines with
|
||||
`@1`, `@2`, `@3` or `@4`.
|
||||
|
||||
- **person**: this person goes wherever your lines say. Name the place in the
|
||||
line, or name none and they stay where the picture shows. Use a clear photo.
|
||||
- **character sheet**: one character drawn from several sides. Only the
|
||||
character is used, not the panels.
|
||||
- **place**: where it happens. Use a picture with nobody in it.
|
||||
- **outfit**: clothes for your person. Works best with no head in the picture.
|
||||
- **thing**: an object or a product. Works best with nobody in the picture.
|
||||
- **style**: only the look is used, for the whole clip.
|
||||
- **start on it**: the clip starts from this picture and stays in its place.
|
||||
- **end on it**: the clip lands on this picture. It can't be named in a line.
|
||||
|
||||
Some scripts, with the jobs they need:
|
||||
|
||||
```
|
||||
@1 sits at the counter in @2. Picture 2 is a place
|
||||
@1 and @2 sit side by side on a night bus. Picture 2 is a person
|
||||
@1 walks down a runway wearing @2. Picture 2 is an outfit
|
||||
@1 spins slowly on a black glass table. Picture 1 is a thing
|
||||
```
|
||||
|
||||
A picture that is off simply isn't counted. If a line still names it, the node
|
||||
stops and tells you which one. It needs at least one picture or a video to start
|
||||
from.
|
||||
|
||||
## Use a video
|
||||
|
||||
Connect a video, then pick what it is for:
|
||||
|
||||
- **copy its moves**: your person does the moves in the video. It needs a
|
||||
picture of your person. Say where in your lines:
|
||||
`@1 dances on a rooftop at sunset.` The clothes can come from words or from an
|
||||
outfit picture.
|
||||
- **swap the person**: your person takes over from the one in the video. The
|
||||
place, the moves and the camera stay. With two person pictures, the first
|
||||
replaces the person on the left and the second replaces the person on the
|
||||
right. To choose who goes, type one line per picture, for example
|
||||
`@1 replaces the man in the hat`. Put spoken words in quotes and name
|
||||
the speaker. If a voice comes from off screen, say so in the line.
|
||||
- **change something**: keep the video and describe the change, for example
|
||||
`She wears a red raincoat`. Be specific about what should change.
|
||||
- **keep going**: more of the same clip, from where it ends. Say what happens
|
||||
next, or leave the box empty and it just carries on. Connect the whole clip:
|
||||
the node sends H3 only the last 2 seconds.
|
||||
- **new voice**: the same clip with a new voice. Connect the voice, and type
|
||||
what they say in quotes.
|
||||
|
||||
If your swap keeps the video's audio, connect it to **video_sound** and turn on
|
||||
**More → Clip's own sound**. The writer uses the clip to estimate when each
|
||||
line is spoken and keeps only the incoming people's words in the swap prompt.
|
||||
Type a time such as `3.2s:` to set it yourself. This switch starts off.
|
||||
|
||||
With an active video, **swap the person**, **change something** and **new voice**
|
||||
use the incoming clip to set the frame count. **Length** says **From the clip**;
|
||||
set the clip's length upstream. The bar does not show a guessed duration or offer
|
||||
retiming controls for these jobs. H3 rounds the frame count to a supported length.
|
||||
Other video jobs, or turning the video off, use the editable **Length** again.
|
||||
|
||||
For a swap, you can optionally describe the replacement on its own line:
|
||||
|
||||
```
|
||||
the woman with black hair walking toward the camera
|
||||
@1 looks like a woman with blonde hair and a black hoodie.
|
||||
```
|
||||
|
||||
The first line chooses who goes; the `@1 looks like` line describes the connected
|
||||
replacement person in Picture 1. Use the number of that picture, with its job set
|
||||
to **person** or **character sheet**. This helps describe the intended appearance;
|
||||
it does not guarantee an exact identity match. The line is optional: existing
|
||||
workflows and scripts keep their previous behavior when it is absent.
|
||||
|
||||
## What you can type
|
||||
|
||||
- **A time**: start a line with `3s:` and it happens at 3 seconds.
|
||||
- **A cut in the same place**: start the line with a kind of shot, like
|
||||
`a close-up of her hand`. Any other line after a blank line goes to a new
|
||||
place.
|
||||
- **One long take**: type `no cuts` and blank lines stop being cuts.
|
||||
- **An ending**: start a line with `ends on`, wherever it sits:
|
||||
`ends on her sitting by the window`.
|
||||
- **Speech**: put the words in quotes: `@1 says "we are late."` Each person
|
||||
keeps their own voice.
|
||||
- **Words on a sign**: name the sign: `a neon sign reads "OPEN"`.
|
||||
- **Sound and music**: use the **Sound** and **Music** boxes, or type
|
||||
`sound: rain on a tin roof` and `music: slow sad piano`. With no music you get
|
||||
none, and the clip comes out quieter.
|
||||
- **Camera and look**: pick them on the node, or type your own:
|
||||
`camera: slow push in`, `look: a 1970s film`.
|
||||
|
||||
A `camera:` or `sound:` line inside a group of lines belongs to that shot. On its
|
||||
own, with blank lines around it, it is for the whole clip.
|
||||
|
||||
Already wrote H3's six parts yourself? Paste them in and they go through as
|
||||
they are.
|
||||
|
||||
## What you can plug in
|
||||
|
||||
- **picture_1** to **picture_4**: your pictures.
|
||||
- **video** and **video_sound**: the frames and the sound of a video. **keep
|
||||
going** sends the last part of the sound to H3. A swap uses the sound to find
|
||||
speech timing when **Clip's own sound** is on. To keep source audio in a swap
|
||||
or **change something**, route the original loader's audio to the save node.
|
||||
- **voice**: a clip of someone talking, about 10 seconds, plus words in quotes
|
||||
in your script. It goes to whoever speaks first. It works with your own voice,
|
||||
or one you have permission to use. Put the spoken line last: with time left
|
||||
after it, H3 tends to keep talking.
|
||||
- **song**: your music. It is cut to the length of the clip and plays as it is,
|
||||
and each moment moves onto its beat.
|
||||
- **clip**: a small text model such as Qwen3-VL 8B. Type one line and it adds
|
||||
what happens next, and it names the sound of the place. Your line always stays
|
||||
first.
|
||||
|
||||
A voice and a song work together. H3 can't keep a song while someone speaks, so
|
||||
the node keeps the song out of H3's way and hands it on as **song_under**, turned
|
||||
down, to mix under the finished clip.
|
||||
|
||||
Then wire the node's outputs into H3: **prompt** into its prompt, **frames** into
|
||||
its length, each **picture** into its next `ref_image`, **video** into `ref_video`,
|
||||
**video_sound** into `ref_video_audio`, **voice** into its first `ref_audio`,
|
||||
**song** into the next one. The pictures come out in the order the prompt counts
|
||||
them, so a picture that is off leaves no gap. **width** and **height** go into
|
||||
H3's width and height: the clip takes the shape of your video, or else of your
|
||||
first picture. **song_under** goes into `audio2` of Audio Merge, with the clip's
|
||||
sound in `audio1`.
|
||||
|
||||
**keeps_clip_sound** is on when a video is connected and **Clip's own sound** is
|
||||
on, or the video job is **change something**. Use it to select the source audio
|
||||
for the finished clip. It is the last output; the existing outputs keep their
|
||||
positions. Keeping source audio still needs the workflow to pass that audio to
|
||||
its save node.
|
||||
|
||||
Nothing plugged in means nothing is added. So a page can turn each one on and off
|
||||
with [Workflow Switches](#AUSBOSS_NODES_WorkflowSwitches): a bypassed loader reads
|
||||
as not plugged in.
|
||||
|
||||
## The controls
|
||||
|
||||
- **Sound** (`sound`): what the clip sounds like, for the whole clip. Same as a
|
||||
`sound:` line.
|
||||
- **Music** (`music`): the music for the whole clip. Same as a `music:` line.
|
||||
- **Length** (`seconds`): how long the clip is, 5 to 15.
|
||||
- **Camera** (`camera`): how the camera moves the whole way through.
|
||||
- **Look** (`look`): from the picture keeps your photo's look. The others redraw
|
||||
the clip. Clay and 3D don't work from words, so change your picture first with
|
||||
an edit model.
|
||||
- **Picture 1** to **Picture 4** (`job_1` to `job_4`): what each picture is
|
||||
for, on the picture chips under the bar. An empty slot shows as a faint `+ @3`.
|
||||
- **Video** (`video_job`): what the video is for, on the video chip.
|
||||
- **Song starts at** (`song_start`, under **More**): the second of the song to
|
||||
start from, so the clip gets the good part.
|
||||
- **Moments on the beat** (`cut_on_beat`, under **More**): moves each moment onto
|
||||
the nearest beat of the song. Off keeps your own seconds.
|
||||
- **Language** (`language`, under **More**): the language of the words in quotes.
|
||||
|
||||
## Tips
|
||||
|
||||
- About one moment a second is the most H3 can play. The line under the rows
|
||||
warns you, and it warns you when a spoken line won't fit its moment.
|
||||
- Say which way they face, and think about where they end up. If someone walks
|
||||
away from us, we see their back until a line turns them.
|
||||
- H3 draws each shot on its own. A cut to a new place hides that. A cut inside one
|
||||
place shows every gap, so keep a place in one shot and move the camera instead.
|
||||
- Nothing moves or exists unless a line says so. If the birds should fly, say so
|
||||
in that shot.
|
||||
- Say what you would see, not what someone feels. `She smiles` works.
|
||||
`She feels happy` doesn't.
|
||||
- A clip came out wrong? Run again with a new seed.
|
||||
|
||||
## Slots
|
||||
|
||||
| Slot | Direction | Type | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| `script` | input | STRING | What happens, one line for each moment. |
|
||||
| `seconds` | input | INT | How long the clip is. |
|
||||
| `clip` | input, optional | CLIP | A small text model that fills in a one-line idea. |
|
||||
| `picture_1` to `picture_4` | input, optional | IMAGE | Your pictures. |
|
||||
| `video` | input, optional | IMAGE | The frames of a video. |
|
||||
| `video_sound` | input, optional | AUDIO | The video's own sound. |
|
||||
| `voice` | input, optional | AUDIO | A clip of the voice to use. |
|
||||
| `song` | input, optional | AUDIO | The music to use. |
|
||||
| `prompt` | output | STRING | The finished prompt. |
|
||||
| `picture_1` to `picture_4` | output | IMAGE | Your pictures, in the order the prompt counts them. |
|
||||
| `video` | output | IMAGE | The video, or only its end to keep going from. |
|
||||
| `video_sound` | output | AUDIO | The video's sound, when it should reach H3. |
|
||||
| `voice` | output | AUDIO | The voice, 15 seconds at most. |
|
||||
| `song` | output | AUDIO | The song, cut to the clip. Empty when a voice is connected too. |
|
||||
| `song_under` | output | AUDIO | With a voice and a song, the song to mix under the finished clip. |
|
||||
| `frames` | output | INT | The clip's length in frames. |
|
||||
| `width`, `height` | output | INT | The shape of your video, or else of your first picture. |
|
||||
| `keeps_clip_sound` | output | BOOLEAN | Whether the finished clip should use the video's own sound. |
|
||||
|
||||
## Technical details
|
||||
|
||||
Code does the reading, so nothing typed can be lost or moved. It follows Pika's
|
||||
and MiniMax's published prompt guides: every picture is defined once, each one
|
||||
states what is kept and how, and a person who changes clothes or moves to a new
|
||||
place is marked that way.
|
||||
|
||||
A timed shot mark makes H3 jump, whatever the words around it say: in tests 31 of
|
||||
36 one-take clips jumped within half a second of a mark. So a mark is written only
|
||||
for a real cut, and the moments of one take are sentences inside one shot. Clips
|
||||
written that way had no jumps in 4 of 4 tries.
|
||||
|
||||
The prompt always tells H3 not to keep the background of a person's picture. With
|
||||
a place in your lines the clip stayed there to the end on 2 of 2 tries; without
|
||||
that sentence the picture's street came back halfway through on 2 of 2. With no
|
||||
place in your lines H3 has nothing else to use, so it stayed in the picture's
|
||||
place on 2 of 2.
|
||||
|
||||
H3's own rewriter runs only on paid services, and the open model reads a prompt as
|
||||
it is. This node does that job on your machine, with no model needed.
|
||||
@@ -10,7 +10,7 @@ original back over the result.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Pick a picture under **Image source**, or click **Upload**. Dropping a
|
||||
1. Choose a file above the preview, or click **Load**. Dropping a
|
||||
picture onto the node works too.
|
||||
2. Drag the handles on the picture. Cyan squares crop. Orange diamonds add space
|
||||
on one side. Orange corners make the whole canvas bigger or smaller. The
|
||||
@@ -28,21 +28,25 @@ uses what the node shows.
|
||||
|
||||
From top to bottom:
|
||||
|
||||
- **Image source**: the pictures in ComfyUI's input folder. The list shows the
|
||||
picture under the pointer and filters as you type. **Upload** adds one. The
|
||||
gear holds the node's display options.
|
||||
- **File picker**: choose **Inputs** for uploaded pictures or **Outputs** for renders, newest first. The list shows the
|
||||
picture under the pointer and filters as you type. **Load** adds one. A
|
||||
picture ComfyUI made, named like `photo.png [output]`, loads too, as it does
|
||||
in core Load Image. The gear holds the node's display options.
|
||||
- **The picture**, with its handles.
|
||||
- **The size line**: each step that sets the output size, ending with the size
|
||||
the run gives you.
|
||||
- **The ratio row**: the orientation button, the ratios (1:1, 4:3, 3:2, 16:9,
|
||||
21:9) and the padlock.
|
||||
- **Fit** (crop | pad) and **Divisible by**.
|
||||
- **Fill**, **Feather**, **Resize** and the two **Center** buttons.
|
||||
- **Crop, pad & resize**: a collapsible group (closed to start, with the fill
|
||||
swatch and a short summary on its bar) holding:
|
||||
- **Aspect ratio** and **Fit to ratio** (Crop or Pad), above the orientation button, the ratios (1:1, 4:3, 3:2, 16:9,
|
||||
21:9) and the padlock.
|
||||
- **Align** and the two **Center** buttons.
|
||||
- **Fill** and **Feather**.
|
||||
- **Resize**, with **Megapixels** and **Step** alongside it when Resize is on.
|
||||
- **Open editor**, **Reset crop** and **Reset**.
|
||||
|
||||
Crop and padding have no number boxes. You set them by dragging, and the size
|
||||
line shows the result. Picking another picture starts the framing over
|
||||
(rotation, crop and padding); Fill, Feather, Divisible by, Resize and a lit
|
||||
(rotation, crop and padding); Fill, Feather, Align, Resize and a lit
|
||||
ratio stay.
|
||||
|
||||
### Ratios, Fit and the padlock
|
||||
@@ -50,8 +54,7 @@ ratio stay.
|
||||
Tap a ratio under the picture to pad the picture to it: every pixel stays and
|
||||
fill bands are added around it, centred. Set **Fit** to crop and the ratio trims
|
||||
the picture instead. Tap the lit ratio again to go back to the whole picture.
|
||||
**Fit** only acts on a lit ratio, so it is dimmed and says "pick a ratio first"
|
||||
until you tap one.
|
||||
You can set **Fit to ratio** before picking a ratio. If a ratio is already lit, switching between Crop and Pad applies it immediately.
|
||||
|
||||
- **A lit ratio is the shape the canvas has now.** Drag a handle to another
|
||||
shape and the ratio goes dark; the row says **Custom** and the size line under
|
||||
@@ -84,11 +87,11 @@ sides at once, so the picture keeps its place in the middle. You can press or le
|
||||
go of Alt during the drag. A corner stops when one of its two sides has no padding
|
||||
left to take away.
|
||||
|
||||
The two **Center** buttons next to Fill, Feather and Resize put the picture in
|
||||
the middle of the canvas: one side to side, one top to bottom. They move padding from
|
||||
one side to the other, so the canvas keeps its size and a lit ratio stays lit. A
|
||||
button is dimmed when the picture is already in the middle or that way has no
|
||||
padding to move. On a narrow node they sit on a line of their own.
|
||||
**Center** moves the crop to the middle of the rotated source, side to side or
|
||||
top to bottom. If there is padding on that axis, the same click also balances
|
||||
the bands around the crop. The crop and output keep their sizes, and the
|
||||
generated mask follows the new placement. Each button dims when its axis is
|
||||
as centered as the pixels allow.
|
||||
|
||||
The orange diamonds move one side at a time, and the padlock works on them as
|
||||
described above. The corners keep the shape with or without the padlock. The
|
||||
@@ -111,9 +114,9 @@ green rotate knob moves aside when a corner handle sits where it usually goes.
|
||||
**Method** (`resize_method`) is in the editor, under **Resize output**.
|
||||
The edge between your picture and the fill stays one clean line at any size.
|
||||
|
||||
### Divisible by
|
||||
### Align
|
||||
|
||||
**Divisible by** (`canvas_multiple`) makes the width and height divide evenly by
|
||||
**Align** (`canvas_multiple`) makes the width and height divide evenly by
|
||||
the number you pick. Some models need sizes divisible by 8, 16 or 32. At 1 it is
|
||||
off. Its arrows go 1, 8, 16, 24 and so on; hold Shift to step by 1.
|
||||
|
||||
@@ -129,24 +132,23 @@ or bottom.
|
||||
The line under the picture names every step that sets the output size, in the
|
||||
order the run applies them, with the size the run emits last and brightest:
|
||||
`crop 2080×1170 → pad 2208×1298 → round to 64 2240×1344 → resize 1344×768`. A step
|
||||
that changes nothing is left out, and "round to" is **Divisible by** at work. An
|
||||
that changes nothing is left out, and "round to" is **Align** at work. An
|
||||
amber line warns when rounding each side to the Step stretches the picture by
|
||||
more than 1% (`1.5% taller: each side rounds to 32 px`); its tooltip names a
|
||||
Step that avoids it, and with Fit on pad, **Even out** adds a few pixels of
|
||||
padding so nothing stretches. It also warns when the resize undoes **Divisible
|
||||
by**. Hover the line for the same breakdown line by line; the editor's right
|
||||
more than 1% (`Resize makes the picture 1.5% taller`); its tooltip names a
|
||||
Step that avoids it, and with Fit on pad, **Pad to keep shape** adds a few pixels of
|
||||
padding so nothing stretches. It also warns when the resize undoes **Align**. Hover the line for the same breakdown line by line; the editor's right
|
||||
panel shows it too.
|
||||
|
||||
The amber line keeps still while you drag a handle or a number. If the drag
|
||||
makes it untrue it goes away, and the line for the new size comes back a
|
||||
moment after you let go. With Resize on its row is always on the node, with
|
||||
text or empty, so the controls under it stay where they are.
|
||||
moment after you let go. The space for the warning stays under the dimensions. On a narrow node, hover
|
||||
over shortened text to read it in full.
|
||||
|
||||
### Reset crop and Reset
|
||||
|
||||
**Reset crop** shows the whole picture again without changing rotation or
|
||||
padding. **Reset** clears rotation, crop and padding and turns the padlock off;
|
||||
Fill, Feather and **Divisible by** stay.
|
||||
Fill, Feather and **Align** stay.
|
||||
|
||||
## Drawing a mask
|
||||
|
||||
@@ -166,8 +168,7 @@ your picture stays under them in **image**, **original** and
|
||||
the teal.
|
||||
|
||||
See-through parts of a PNG show in teal the same way, because the node paints
|
||||
them too. To hide the teal, click the gear at the top right of the image
|
||||
source box and turn off **Show the mask on the picture**. The choice applies
|
||||
them too. To hide the teal, click the gear next to **Load** and turn off **Show the mask on the picture**. The choice applies
|
||||
to every Image Crop + Rotate + Pad in this browser.
|
||||
|
||||
## Outputs
|
||||
@@ -175,7 +176,7 @@ to every Image Crop + Rotate + Pad in this browser.
|
||||
- **image**: the turned, cropped and padded picture. Every frame of an animated
|
||||
image gets the same framing.
|
||||
- **mask**: White where the model paints: the padding, the empty corners a turn leaves, and the see-through parts of your picture.
|
||||
- **stitcher**: Wire to Stitch Inpaint to restore the kept canvas around an outpaint result, blended into the source by **Blend** (32 px unless changed). It follows the final resized canvas.
|
||||
- **stitcher**: Wire to Stitch Inpaint to restore the kept canvas around an outpaint result, blended into the source by **Blend** (32 px unless changed). It follows the final resized canvas. After a hand crop, set **Paste onto** to original in Stitch Inpaint's gear menu to get your whole picture back with the edit in place.
|
||||
- **original**: your picture before rotation, crop, padding or resize, with
|
||||
see-through parts shown as white (your picture there when **Painted area**
|
||||
is on keep).
|
||||
@@ -231,18 +232,18 @@ for fine steps). The editor adds a few things the node does not show:
|
||||
- **Rotate → Degrees** (`rotation_degrees`): type an exact turn, clockwise.
|
||||
Each step is 1°, or 0.1° with Shift. **Reset rotation** sets it back to 0.
|
||||
- **Padding & mask → Reset padding** removes all padding. The same section has
|
||||
Fill, Feather, Divisible by and a **Center** row.
|
||||
Fill, Feather, Align and a **Center** row.
|
||||
- **Resize output → Method** (`resize_method`): how the resize samples the
|
||||
picture. `lanczos`, the default, is the sharp one.
|
||||
- **More**: ratios you added in `ausboss_presets.json` that no button shows.
|
||||
The list only appears when you have some.
|
||||
- **Reset view** undoes zoom and pan. **Reset transform**
|
||||
clears rotation, crop, padding, fill, feather and Divisible by; the picture,
|
||||
clears rotation, crop, padding, fill, feather and Align; the picture,
|
||||
resize and stitch settings stay.
|
||||
- **Preview**: a small picture of the finished output while you work.
|
||||
|
||||
A ratio replaces the existing crop and padding and keeps rotation, fill and
|
||||
resize settings. **Divisible by** and the resize **Step** can slightly change
|
||||
resize settings. **Align** and the resize **Step** can slightly change
|
||||
the fitted shape. The size box on the stage sits clear of the handles.
|
||||
|
||||
### Handles and gestures
|
||||
@@ -279,8 +280,8 @@ The editor's right side holds the stitcher's settings, the same as on the clip n
|
||||
- `fill_color`: `#RGB` or `#RRGGBB`, three numbers (`R, G, B`), one gray
|
||||
number, or a colour name. Anything it cannot read becomes mid-gray.
|
||||
- **Corners.** A corner follows the pointer along the canvas diagonal, and the
|
||||
shape it keeps is the canvas before **Divisible by** rounds it. With
|
||||
Divisible by on, **Center** counts the fill it adds, so the bands you see
|
||||
shape it keeps is the canvas before **Align** rounds it. With
|
||||
Align on, **Center** counts the fill it adds, so the bands you see
|
||||
come out even.
|
||||
- **Resize.** It works like core's *Scale Image to Total Pixels*: the budget is
|
||||
`megapixels × 1024 × 1024`, the shape is kept, and each side rounds to a
|
||||
|
||||
@@ -58,7 +58,8 @@ folders. A folder from anywhere else is copied in:
|
||||
a tile makes it the next.
|
||||
|
||||
After the last picture, **At the end** either stops the run with a message
|
||||
or starts over at the first.
|
||||
or starts over at the first. With **start over**, Picture goes back to 1 and
|
||||
the pictures repeat in order for as many runs as you queue.
|
||||
|
||||
## The card
|
||||
|
||||
@@ -73,7 +74,9 @@ or starts over at the first.
|
||||
run loads, counted from 1.
|
||||
- **After a run** (`after_run`): **next** moves Picture on, **stay** keeps
|
||||
loading the same one, **random** jumps to any of the picked ones.
|
||||
- **At the end** (`at_the_end`): **stop the run** or **start over**.
|
||||
- **At the end** (`at_the_end`): after the last picture, **stop the run**
|
||||
stops with a message, and **start over** goes back to the first and keeps
|
||||
going.
|
||||
|
||||
## The tiles
|
||||
|
||||
@@ -104,7 +107,8 @@ or starts over at the first.
|
||||
There is no setting that changes this.
|
||||
- The picked pictures are saved in the workflow as a list of names
|
||||
(`pictures`). Empty means every picture, so a picture added to the folder
|
||||
later is in too. A name that is no longer in the folder is skipped.
|
||||
later is in too. A name that is no longer in the folder is skipped. It
|
||||
takes no wire: the ticks on the node write it.
|
||||
- Pictures are PNG, JPG, WebP, BMP, GIF (first frame) and TIFF. A photo that
|
||||
was taken turned comes out upright. Files whose names start with a dot are
|
||||
skipped.
|
||||
@@ -116,6 +120,9 @@ or starts over at the first.
|
||||
their own sizes. They are not resized or stacked into one batch.
|
||||
- Moving Picture on is done by the editor when a run is queued. A workflow
|
||||
sent through the API loads exactly the picture `position` names.
|
||||
- With **start over**, a Picture number past the last picture counts round
|
||||
from the first. A workflow saved with such a number loads that picture and
|
||||
moves on from there.
|
||||
- The node runs again when a picked file is added, removed or rewritten.
|
||||
- A folder lists up to 5,000 pictures, and a folder that is brought in is
|
||||
copied up to 3,000.
|
||||
|
||||
@@ -8,6 +8,11 @@ wire it to every node that needs it.
|
||||
- **Value** (`value`): The whole number to send out. It starts at `0`. The
|
||||
arrows and a drag move it by 1, or click and type. It holds numbers as
|
||||
big as a seed.
|
||||
- The gear, right: **Look**. **normal** is the usual box with its label.
|
||||
**big** shows the number large in the middle of the node, with an arrow
|
||||
on each side, for a setting people change on every run, such as the
|
||||
length of a clip. A drag and typing work the same. Each node keeps its own look, and it is
|
||||
saved with the workflow.
|
||||
|
||||
## Limits
|
||||
|
||||
@@ -40,3 +45,7 @@ For arithmetic on the way — halving a size, adding a margin — feed this into
|
||||
- On its own the box goes up to 9,007,199,254,740,991, the largest whole
|
||||
number a browser holds exactly. It stopped at 2,147,483,647 before, which
|
||||
most seeds are above.
|
||||
- The look is stored in the node's properties as `ausboss_text_look: "big"`, the
|
||||
same as Text 🆎. It is not an input, so the saved value and what the node
|
||||
sends are the same in both looks, and an older version of the pack shows
|
||||
the usual box.
|
||||
|
||||
@@ -82,7 +82,7 @@ new region can break up; the node says so in the console when it sees it,
|
||||
and the cure is two passes: pad one axis, run, load the result, pad the
|
||||
other. **A thin strip counts.** More than 8 px of padding on the other
|
||||
axis, such as a few pixels left over from a drag, means the source no
|
||||
longer spans it. **Divisible by** on the Crop + Rotate + Pad nodes and
|
||||
longer spans it. **Align** on the Crop + Rotate + Pad nodes and
|
||||
Load Image + Pad's **Multiple** never add a strip on a side you did not
|
||||
pad. The warning reports the spare pixels on each axis.
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ done one at a time, so a long clip does not need more graphics memory.
|
||||
|
||||
## Model setup
|
||||
|
||||
Place `big-lama.pt` in:
|
||||
Download [`big-lama.pt`](https://github.com/Sanster/models/releases/download/add_big_lama/big-lama.pt)
|
||||
and place it in:
|
||||
|
||||
```text
|
||||
ComfyUI/models/lama/big-lama.pt
|
||||
@@ -20,6 +21,12 @@ refresh the browser or restart ComfyUI so the model list is updated. Until
|
||||
the file is there, the list still shows `big-lama.pt`, and a run stops with a
|
||||
message that says where to put it.
|
||||
|
||||
The node only opens known copies of this file, whatever you name them: the
|
||||
download above, or the one from
|
||||
[fashn-ai/LaMa](https://huggingface.co/fashn-ai/LaMa). A LaMa model file can
|
||||
run code when it opens, so any other file stops the run with a message and is
|
||||
never loaded.
|
||||
|
||||
## Controls
|
||||
|
||||
- **image**: BHWC images or video frames.
|
||||
@@ -28,7 +35,7 @@ message that says where to put it.
|
||||
another size is stretched to fit the picture. A picture with no mask
|
||||
painted on it stops the run with "No mask painted" instead of coming back
|
||||
unchanged.
|
||||
- **Model** (`model`): A `.pt` or `.pth` TorchScript checkpoint from `models/lama`.
|
||||
- **Model** (`model`): A known copy of `big-lama.pt` from `models/lama`.
|
||||
|
||||
## Output
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Load Audio
|
||||
|
||||
Loads a sound file and hands on only the part you pick. Use it for a voice
|
||||
clip, a song or any sound a video model or a save node takes, when you want
|
||||
one part of the file and not the whole thing.
|
||||
|
||||
The node shows the whole file as a wave, so you can see where the loud parts
|
||||
are. The part that comes out is bright. Drag the white **IN** and **OUT**
|
||||
handles to set where it starts and stops, drag the bright part to slide it,
|
||||
or click somewhere else on the wave to move it there. You can also type a time
|
||||
under the wave, in seconds (`12.5`) or minutes and seconds (`1:02.5`).
|
||||
|
||||
Press **▶** to hear only the part that comes out. **LOOP** plays it again and
|
||||
again.
|
||||
|
||||
## Max length
|
||||
|
||||
Set **Max length** to the longest sound the next step can take, such as the
|
||||
length of the video you are making. The start stays where it is, and anything
|
||||
past the limit turns red with a dashed line where it is cut. Next to OUT,
|
||||
the node shows how long the sound that comes out is, such as `15.0 s`. It
|
||||
turns red when part of your pick is cut; hold the mouse over it to see how
|
||||
much, and click it to move OUT to the cut. Play stops at the cut, so what you
|
||||
hear is what comes out. `0` means no limit.
|
||||
|
||||
Max length can be wired. When the wire comes from a number node such as
|
||||
Float 🆎, the wave shows the cut for that number; otherwise the length's tooltip says
|
||||
`max wired` and the run still cuts the sound to the wired length.
|
||||
|
||||
## Controls
|
||||
|
||||
- **Source** (`audio`): a sound file in ComfyUI's input folder. **Upload**
|
||||
adds one. Videos are listed as well, for their sound.
|
||||
- **Max length** (`max_seconds`): the longest the sound may be, in seconds.
|
||||
`0` means no limit.
|
||||
- **IN** (`start_seconds`): where the sound starts.
|
||||
- **OUT** (`end_seconds`): where the sound stops. `0` means the end of the
|
||||
file.
|
||||
|
||||
## Outputs
|
||||
|
||||
- `audio`: the part you picked, for any node that takes `AUDIO`.
|
||||
- `seconds`: how long that sound is.
|
||||
|
||||
## Technical details
|
||||
|
||||
- Only the picked part is decoded: decoding stops once the window is full,
|
||||
so a short pick from a long recording stays quick.
|
||||
- The sound keeps its own sample rate and channels.
|
||||
- A start past the end of the file stops the run with a message that says so.
|
||||
- The wave is drawn in the browser from the file itself; nothing extra is
|
||||
stored with the workflow besides the four values above.
|
||||
@@ -41,7 +41,7 @@ padding value unchanged.
|
||||
|
||||
## Controls
|
||||
|
||||
- **Source** (`image`): Choose or upload from ComfyUI's input folder; the list previews the image under the pointer and filters as you type.
|
||||
- **Source** (`image`): Choose or upload from ComfyUI's input folder; the list previews the image under the pointer and filters as you type. A picture ComfyUI made, named like `photo.png [output]`, loads too, as it does in core Load Image.
|
||||
- **source_image** (optional socket): Wire an image from another node here
|
||||
and it is padded instead of the file; the Source row dims while the wire
|
||||
is connected. The canvas can only show a wired picture after a run, so it
|
||||
|
||||
@@ -47,6 +47,7 @@ embedded workflow replaces the whole graph.
|
||||
## Controls
|
||||
|
||||
- **Source** (`video`): A file in the input folder; **Upload** adds new ones.
|
||||
A video ComfyUI made, named like `clip.mp4 [output]`, loads too.
|
||||
- **IN** (`start_seconds`): Skip everything before this time.
|
||||
- **OUT** (`end_seconds`): Stop at this time; `0` means the source end until a
|
||||
handle is moved.
|
||||
|
||||
@@ -29,11 +29,12 @@ come out of one socket, ready to wire into a prompt.
|
||||
modified date, and trigger words for that LoRA.
|
||||
|
||||
Right-click a row to insert an empty LoRA above or below it, move, duplicate,
|
||||
or remove it. **Remove this LoRA** is red. **Delete all LoRAs…** is a separate red, trash-marked action
|
||||
or remove it, or to put it in a group (see Groups below). With several rows
|
||||
picked (Ctrl/Cmd-click or Shift-click), **Remove these LoRAs** removes them
|
||||
all. **Remove this LoRA** is red. **Delete all LoRAs…** is a separate red, trash-marked action
|
||||
with confirmation; it clears this node's rows and keeps all files on disk.
|
||||
Cancel or Escape keeps the stack. Inserting keeps the existing rows and their settings in order;
|
||||
the new slot uses the gear menu's default strength (initially 1).
|
||||
The stack holds up to 64 rows.
|
||||
One strength drives both
|
||||
model and CLIP by default; the gear menu's **Separate model / CLIP
|
||||
strength** switch shows a second box per row. The switch is per-node — new
|
||||
@@ -42,6 +43,60 @@ model/CLIP strengths flips it on automatically so the difference stays
|
||||
visible. Turning it back off sets every row's CLIP strength to its model
|
||||
strength.
|
||||
|
||||
## Groups
|
||||
|
||||
Groups keep a long stack tidy, like layer groups in an image editor. A
|
||||
group has a header with its own on/off switch, so you can turn a whole set
|
||||
of LoRAs (all your style LoRAs, say) off and on with one click. Each LoRA
|
||||
in the group keeps its own switch too: turn the group off and back on, and
|
||||
the same LoRAs are on as before.
|
||||
|
||||
- **Make a group:** right-click a row and choose **Group this LoRA**. To
|
||||
group several, hold **Ctrl** (**Cmd** on a Mac) and click each row first,
|
||||
or click one and **Shift**-click another to pick every row between them.
|
||||
Then right-click one of them and choose **Group these LoRAs**. The new
|
||||
group appears where the topmost row was, and you type its name.
|
||||
- **Add to a group:** drag a row by its grip onto the group's name, or
|
||||
between any two of its rows. Right-click → **Move to group** does the
|
||||
same.
|
||||
- **Take out of a group:** drag the row above or below the group, or
|
||||
right-click → **Take out of** the group.
|
||||
- **Fold:** the arrow next to the group's switch hides its rows and makes
|
||||
the node shorter. Folded rows still run.
|
||||
- **Rename:** double-click the group's name.
|
||||
- Right-click a group's header to move the whole group, fold it, turn all
|
||||
its LoRAs on or off, ungroup it (the LoRAs stay), or remove it together
|
||||
with its LoRAs. Drag the header's grip to move the whole group.
|
||||
- **Turn a group on and off from a Workflow Switch:** save the switch once
|
||||
with the group on and once with it off. The group's header then shows a
|
||||
lock, and the switch turns the whole group on and off. The LoRAs inside
|
||||
stay yours to turn on and off. Click the lock to take the group back.
|
||||
|
||||
Right-click the empty space around the rows (or under **+ LoRA**) to add
|
||||
LoRAs, group the rows you picked, fold or open every group at once, or
|
||||
delete all LoRAs.
|
||||
|
||||
A plain click anywhere clears a Ctrl/Shift selection, and so does Escape.
|
||||
The count on a header (`2/3 on`) shows how many of its LoRAs are on. While
|
||||
the group is off it reads `group off`, and its LoRAs are dimmed.
|
||||
|
||||
### Technical details
|
||||
|
||||
LoRAs still apply in the order you see, top to bottom; a group changes
|
||||
nothing about how the stack runs. Groups are one level deep, and a group
|
||||
lasts while it has a LoRA in it. Each row keeps its group's name in the
|
||||
saved stack, so groups travel with templates, undo, and rows shown on a
|
||||
subgraph box. Which groups are folded is saved with the node. A group that
|
||||
is off marks each of its rows with `group_off` in the saved stack: none of
|
||||
them runs, and each keeps its own on/off. A row dragged into an off group
|
||||
goes off with it, and a row taken out runs by its own switch again. A
|
||||
renamed group keeps its on/off and its Workflow Switch. The switch at the
|
||||
top of the node turns every row and every group on. Move up and Move down step a row out of the edge of
|
||||
its group, or into a group next to it, one step at a time. While you drag,
|
||||
the drop line starts indented when the row would join a group. Just below
|
||||
a group's last row is outside the group; to add a row at a group's end,
|
||||
drop it on the group's name.
|
||||
|
||||
## Rows on a subgraph box
|
||||
|
||||
When the loader sits inside a subgraph, its rows can show on the box
|
||||
@@ -75,6 +130,10 @@ over: the switch forgets that row and leaves it alone from then on, and
|
||||
stops reading "changed" because of it. Everything else the switch does
|
||||
stays. A locked row still drags to a new place.
|
||||
|
||||
A group that a switch turns on and off shows the lock on its header
|
||||
instead. The LoRAs in it keep plain grips, so each one is still yours to
|
||||
turn on or off. Click the header's lock to take the group over.
|
||||
|
||||
## Strength bars
|
||||
|
||||
Every named row paints a center-zero bar behind its name: teal grows right
|
||||
@@ -110,7 +169,14 @@ its height on rows, not chrome. Left to right:
|
||||
|
||||
**+ LoRA** opens one list of files grouped by folder. Click a file to add
|
||||
one LoRA, or click a folder name to add all its LoRAs, including nested
|
||||
folders. Use the arrow beside a folder to collapse or expand it without
|
||||
folders. To add several files from different folders, tick the box beside
|
||||
each one (or **Ctrl**-click its name), then click **Add N LoRAs** at the
|
||||
bottom or press **Enter**. **Shift**-click ticks every file between the
|
||||
last one you ticked and the one you click. Ticks stay while you search, so you can
|
||||
gather files from several searches; **Clear** unticks them all. Files that
|
||||
are already in the stack say **in stack** and have no box. With the
|
||||
keyboard, **Ctrl+Enter** ticks the highlighted file and **Shift+Enter**
|
||||
ticks the run up to it. Use the arrow beside a folder to collapse or expand it without
|
||||
adding anything. Folded folders are remembered in this browser. Search
|
||||
reveals matching files; clearing it brings back your folded view. You can
|
||||
also fold folders while searching.
|
||||
@@ -122,9 +188,7 @@ their order, strengths and switches. The same filename in different folders
|
||||
counts as separate LoRAs.
|
||||
|
||||
The choice is one undo step. Closing the picker without choosing changes
|
||||
nothing. If a folder would take the stack past 64 rows, nothing is added
|
||||
and a message says how much room is needed. Remove rows or pick a smaller
|
||||
folder. Typing a filename and pressing Enter picks one file; use the arrow
|
||||
nothing. Typing a filename and pressing Enter picks one file; use the arrow
|
||||
keys to highlight a folder before Enter if you want the whole folder.
|
||||
Pointing at a folder with the mouse does not choose it for Enter.
|
||||
Left and Right collapse and expand a highlighted folder.
|
||||
@@ -189,7 +253,7 @@ Details that keep the absorb faithful:
|
||||
rows from a loader without a connected CLIP import with CLIP strength 0
|
||||
- connected auxiliary outputs, such as trigger words, block absorption because
|
||||
bypassing their source would break those connections
|
||||
- linked LoRA settings and stacks beyond the row limit leave the sources active
|
||||
- linked LoRA settings leave the sources active
|
||||
- already-bypassed or muted loaders contribute no rows
|
||||
- a toast summarizes what was imported, bypassed, remapped, and skipped
|
||||
|
||||
@@ -236,6 +300,14 @@ or the bare `.png` / `.jpg` / `.jpeg` / `.webp`). No sidecar, no thumbnail —
|
||||
nothing appears and nothing shifts. The gear menu's **Preview thumbnails**
|
||||
switch turns the behavior off.
|
||||
|
||||
**Get the pictures from Civitai:** click **Fetch Civitai info** (or
|
||||
**Refresh Civitai info**) in a LoRA's info card. It also saves the LoRA's
|
||||
first picture from Civitai next to the file, and the hover and the info card
|
||||
show it right away. Gear menu → **Civitai picture** picks which pictures:
|
||||
**PG-13** (the default) skips pictures Civitai rates R or above, **any**
|
||||
takes the first picture whatever its rating, and **off** saves none. It never
|
||||
replaces a picture you already have.
|
||||
|
||||
## Trigger words
|
||||
|
||||
The info card gathers words from three places: the LoRA file's own metadata,
|
||||
@@ -252,10 +324,14 @@ switched on for the old one.
|
||||
- **Fetch Civitai info** looks this one LoRA up on civitai.com. Nothing is
|
||||
sent until you click. Your browser then sends only the file's SHA256
|
||||
fingerprint, and the answer is saved next to the LoRA as
|
||||
`<model>.civitai.info`. After that the button reads **Refresh Civitai
|
||||
`<model>.civitai.info`, with the LoRA's picture as `<model>.preview.jpg`
|
||||
(see "Get the pictures from Civitai" above). After that the button reads **Refresh Civitai
|
||||
info** and the card gets a **View on Civitai** link. A LoRA Civitai does
|
||||
not know (unpublished or hidden) reads **Not found on Civitai**. Gear
|
||||
menu → **Civitai lookup button** removes the button.
|
||||
menu → **Civitai lookup button** removes the button. When a lookup fails,
|
||||
a message says why. If it always says it could not reach civitai.com,
|
||||
check whether ComfyUI was started with `--disable-api-nodes`: that setting
|
||||
stops the browser from reaching any other site, Civitai included.
|
||||
- **Suggested strength**: a lowest and a highest value at the bottom of the
|
||||
card (leave one empty for "any"). It is only a reminder: a row set outside
|
||||
it shows its number in orange, and the tooltip gives the range. Saved per
|
||||
|
||||
@@ -208,6 +208,8 @@ nothing instead.
|
||||
|
||||
Picks belong to the picture they were made on. Load another picture and the
|
||||
node shows it with nothing picked, and says the old picks are not used.
|
||||
If the workflow runs before the node has shown the new picture, the run
|
||||
stops and says the picks were made on another picture.
|
||||
|
||||
- With a Load Image wired straight in, or through Reroute nodes, the
|
||||
picture shows as soon as you choose a file.
|
||||
@@ -277,6 +279,8 @@ no browser:
|
||||
]}
|
||||
```
|
||||
|
||||
- `touches` takes no wire, and neither does `ask` (below): the node's
|
||||
picture writes them. From the API, send them as values.
|
||||
- A `paint` touch may carry **`"s"`**, how soft the brush's edge was (1 to
|
||||
100); without it the brush is hard. A soft brush gives a mask with values
|
||||
between 0 and 1 along its edge.
|
||||
|
||||
@@ -8,6 +8,14 @@ Pick a picture size by shape and megapixels, or by dragging. Use it in place of
|
||||
- **Gear:** set the **Snap step**, edit the **Ratio rail**, choose the **Latent family** (`latent`) and **Batch size** (`batch_size`), and hide the **Dot grid** or **MP cost curves**. **Snap step** (8, 16, 32 or 64; starts at 32) is the grid that drags, arrow keys and chips land on. The **Ratio rail** is the list of ratio chips, typed as `1:1, 21:9`. The display options apply to every Latent Size node in this browser; latent family and batch size (1 to 64) belong to this node.
|
||||
- **Node size:** drag the node's corner to make it wider or taller, so it lines up with the nodes around it. The controls stretch with it, and the canvas preview takes any extra height. It can be as narrow as 320 pixels.
|
||||
|
||||
## Workflow resolutions
|
||||
|
||||
In the gear, choose **This workflow → Workflow resolutions → Edit list**. Enter one exact width × height per line, up to 24. The list belongs to this node and travels with the saved workflow. An empty list leaves the normal editor unchanged.
|
||||
|
||||
- **Recommendations:** leave **Limit choices to this list** off. The Sizes row shows your quick picks, and users can still type, drag or choose another shape.
|
||||
- **Strict model sizes:** turn the limit on. The Shape row shows only the exact ratios in the list. Select a ratio to see its allowed sizes. For example, 576×1024 and 720×1280 share 9:16; 480×864 is a separate 5:9 option. Landscape is available only if you list a landscape size. The canvas stays visible, but it cannot be stretched. The swap button works only if the exact reverse size is listed.
|
||||
- On enabling the limit, an unlinked size stays selected if allowed; otherwise the first listed size is selected. A linked width or height is checked at run time, before the latent is allocated. An unsupported pair produces an error naming the allowed sizes. For video workflows, connect these width and height outputs to the video latent node.
|
||||
|
||||
## Outputs and links
|
||||
|
||||
Outputs are **width**, **height**, and **latent**, in that order. The node has four input sockets above the panel: width, height, latent and batch_size. Linking width or height locks the controls that would change both dimensions; the other dimension remains editable. Linking latent or batch_size sets the latent family or batch size from another node, and the gear then shows that setting as linked. A linked value is read when the workflow runs, so the preview keeps showing the size stored on the node.
|
||||
|
||||
@@ -41,9 +41,9 @@ file's name. A caption on the `caption_text` input writes the paired `.txt`.
|
||||
workflow in EXIF. **JXL** (`jxl lossless`, lossless JPEG XL) is the most
|
||||
compact; it needs the optional `pillow-jxl-plugin` in ComfyUI's python
|
||||
(also the pack's `jxl` extra) and few browsers preview it.
|
||||
- **Embed workflow** (`save_metadata`): **yes** stores the prompt and
|
||||
- **Embed workflow** (`save_metadata`): **On** stores the prompt and
|
||||
workflow in the file (PNG text chunks, EXIF in webp and jxl) so it drags
|
||||
back into ComfyUI; **no** writes a clean file for sharing or datasets.
|
||||
back into ComfyUI; **Off** writes a clean file for sharing or datasets.
|
||||
`--disable-metadata` on the server wins.
|
||||
- **Preview** (`preview`): the small switch under the card shows the saved
|
||||
image on the node. Off, the picture's box goes away and the node is
|
||||
@@ -52,6 +52,16 @@ file's name. A caption on the `caption_text` input writes the paired `.txt`.
|
||||
still lists it. When a run saves more than one image (a batch, or a list
|
||||
such as a folder of images), arrows beside the switch (‹ 2 / 5 ›) step
|
||||
through them.
|
||||
- **Steps** (`step_preview`, off by default): the switch left of
|
||||
**preview** shows the picture while it is being made, one step at a time,
|
||||
where the saved image goes, with a **STOP** button on the bar. Stop a run
|
||||
that is going wrong after the first steps instead of waiting for the end;
|
||||
a stopped run saves nothing and says so on the picture. Once the image is
|
||||
saved it replaces the steps. With several samplers, the node shows the one
|
||||
that feeds it. During an upscale in tiles it keeps the whole picture and
|
||||
says "upscaling in tiles" instead of showing each tile. Display only: the
|
||||
saved file is the same with it on or off.
|
||||
The [Step Preview](AUSBOSS_NODES_StepPreview.md) page has the details.
|
||||
|
||||
## Inputs
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ duration, shows a tall video whole, and has a **LOOP** control. `gif` and
|
||||
`webp` are shown as pictures with just the file name, since a video player
|
||||
cannot play either.
|
||||
|
||||
**Steps** above the player (`step_preview`, off by default) shows the clip's first frame in
|
||||
the player while it is being made, one step at a time, with a **STOP**
|
||||
button on the player. Stop a clip that is going wrong after the first steps
|
||||
instead of waiting for the end; a stopped run saves nothing. The saved
|
||||
video replaces the steps. The [Step Preview](AUSBOSS_NODES_StepPreview.md)
|
||||
page has the details.
|
||||
|
||||
## Controls
|
||||
|
||||
- **frames**: BHWC frame batch to encode. For the subsampled formats, odd
|
||||
@@ -30,7 +37,7 @@ cannot play either.
|
||||
- **Ping-pong** (`pingpong`): Plays the clip forward then backward so it loops seamlessly.
|
||||
Roughly doubles the frame count and the encode time. The first and last
|
||||
frames are not repeated at the turnaround, so the loop does not hitch.
|
||||
- **Metadata** (`save_metadata`): On by default. Turn it off to share a file
|
||||
- **Embed workflow** (`save_metadata`): On by default. Turn it off to share a file
|
||||
without your prompt and node graph embedded in it. If ComfyUI was started
|
||||
with `--disable-metadata`, nothing is embedded whatever this says.
|
||||
- **audio** (optional): A track to mux into the file, e.g. Load Video's
|
||||
@@ -76,7 +83,7 @@ metadata, so an embedded workflow is not available for them.
|
||||
The node's face follows the table: widgets the chosen format ignores are
|
||||
hidden rather than left to look meaningful. The CRF field leaves the
|
||||
**FPS · CRF** row for `mov prores`, `mkv ffv1` and `gif` (their rows read
|
||||
*ignored* above), and the **Metadata** row disappears for `gif` and `webp`,
|
||||
*ignored* above), and the **Embed workflow** row disappears for `gif` and `webp`,
|
||||
which cannot carry it. Hidden is not removed:
|
||||
the widget keeps its position and its value, saved workflows store and restore
|
||||
it exactly as before, and switching the format back brings it back with the
|
||||
|
||||
@@ -27,6 +27,15 @@ needs.
|
||||
- The line underneath shows the last run's seed. The history saves with the
|
||||
workflow.
|
||||
|
||||
## Compact look
|
||||
|
||||
The **gear** beside the number picks the look. **Compact** is one short
|
||||
row: the number, a mode chip and a dice. Tap the chip to go Random → Fixed →
|
||||
+1 → −1. The dice rolls a new seed and pins it. A long seed shrinks to fit.
|
||||
Hover the number to see the last run's seed. The look is saved on the node,
|
||||
and new Seed nodes start in the look you picked last. **Full** brings back
|
||||
the whole card, with the seeds it ran with.
|
||||
|
||||
## Output
|
||||
|
||||
- **seed**: The seed, unchanged.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Step Preview
|
||||
|
||||
Shows the picture while it is being made, one step at a time, with a
|
||||
**Stop** button. If the picture is going wrong (the pose, the framing, an
|
||||
extra arm) you can see it after the first few steps and stop the run,
|
||||
instead of waiting for the last step.
|
||||
|
||||
Put it anywhere on the canvas. It needs no wires: it shows whichever sampler
|
||||
in the workflow is making a picture right now, and the caption under the
|
||||
picture says how far it is ("step 3 of 8").
|
||||
|
||||
## The buttons
|
||||
|
||||
- **STOP**: stops this run now. Nothing after the sampler runs, so nothing
|
||||
is saved. The next run in the queue still starts.
|
||||
- **STOP ALL · N**: shows when more runs are waiting in the queue. It
|
||||
empties the queue and stops this run.
|
||||
|
||||
As soon as the run saves the picture, the finished picture replaces the
|
||||
blurry last step, and it stays on the node until the next run starts. The
|
||||
word at the right of the bar says how the run ended: done, stopped or
|
||||
failed. A stopped run keeps the last step it reached.
|
||||
|
||||
An upscale in tiles works on one small piece of the picture at a time.
|
||||
Those pieces are not shown as the picture: the node keeps the last whole
|
||||
picture and says "upscaling in tiles", with the steps of the piece being
|
||||
worked on.
|
||||
|
||||
## Save Image and Save Video can do it too
|
||||
|
||||
Save Image 🆎 and Save Video 🆎 have a **steps** switch of their own (off by
|
||||
default). On, the picture area of the save node shows the steps of the
|
||||
picture it is about to save, with the same Stop button. Use the save node's
|
||||
switch when you look at the result anyway, and this node when you want a
|
||||
bigger view, or one view for a workflow with several samplers.
|
||||
|
||||
## Sharper step pictures
|
||||
|
||||
The step pictures are small and blurry. That is enough to judge the pose
|
||||
and the layout. Some models have a small preview decoder (a TAESD file)
|
||||
that makes them sharper: put it in ComfyUI's `models/vae_approx` folder and
|
||||
ComfyUI uses it.
|
||||
|
||||
## Technical details
|
||||
|
||||
- ComfyUI only makes step pictures when its preview method is something
|
||||
other than `none`, and a fresh install starts with `none`. When a queued
|
||||
workflow holds this node, or a Save Image or Save Video with its steps
|
||||
switch on, the pack asks for the `auto` method for that one run.
|
||||
- It never overrides a choice you made: if you picked a method in
|
||||
ComfyUI's **Live preview method** setting, or started ComfyUI with
|
||||
`--preview-method`, that one is used. `--preview-method none` on the
|
||||
command line is treated as "not chosen", since it is the default.
|
||||
- `auto` uses the model's TAESD decoder from `models/vae_approx` when it is
|
||||
installed, and a quick color estimate (latent2rgb) otherwise. Some models
|
||||
have neither (SeedVR2, for one), and then no step pictures come.
|
||||
- The pictures are at most 512 px on their long side (`--preview-size`).
|
||||
They do not change the result: the same seed makes the same picture with
|
||||
the steps shown or not.
|
||||
- They cost next to nothing. Measured with Qwen Image 2.1 on one desktop:
|
||||
8.50 s with the steps off and 8.40 s on for 8 steps, 17.62 s and 17.64 s
|
||||
for 25 steps, the same picture every time.
|
||||
- While previews are on, a ComfyUI sampler that sits on the top level of
|
||||
the workflow (not inside a box) shows the steps on its own face too, as
|
||||
it does whenever previews are turned on in ComfyUI. The node grows to
|
||||
hold the picture and keeps the last step after the run.
|
||||
- Runs queued from another workflow tab are not shown here.
|
||||
- With video models the step picture is the clip's first frame.
|
||||
@@ -7,6 +7,22 @@ from **Crop For Inpaint 🆎**, **Load Image + Pad 🆎** or any
|
||||
result, it is resized to fit first, then joined to your picture the way
|
||||
**Seam** says.
|
||||
|
||||
## Paste onto: crop or original
|
||||
|
||||
**Paste onto** picks what you get back. It lives in the gear menu, next to
|
||||
Seam.
|
||||
|
||||
- **crop** (the default) gives back the picture you framed.
|
||||
- **original** puts the edit on your whole picture, at its full size. Use it
|
||||
after you crop by hand in **Image Crop + Rotate + Pad 🆎**: the model works
|
||||
on the crop, and everything you did not paint stays exactly as it was. It
|
||||
also works when you turned or resized the crop.
|
||||
|
||||
Padding has no place on your picture, so anything painted in added space is
|
||||
left out. With **Crop For Inpaint** and **Load Image + Pad** the result is
|
||||
your whole picture either way. The video nodes don't have this yet: their
|
||||
result stays on the crop, and the console says so once.
|
||||
|
||||
## Seam: classic or blend in
|
||||
|
||||
**Seam** picks how the new area joins your picture. It lives in the gear
|
||||
@@ -60,6 +76,17 @@ are in the model's own picture.
|
||||
4n+1 (Wan) frames and drop the tail - so a long run is never thrown away
|
||||
over the count. More inpainted frames than source frames is still an error.
|
||||
|
||||
## Pictures with see-through parts
|
||||
|
||||
A picture with a clear background, such as a cut-out, keeps it. Most
|
||||
models paint colour only, so the new area takes your picture's own
|
||||
see-through parts where it lands: what was clear stays clear, and the edit
|
||||
shows where your picture is solid. A model that paints see-through parts
|
||||
of its own (Qwen Image 2.1 can) has them blended in like the colours.
|
||||
|
||||
So with a colour-only model, new things painted into a clear area stay
|
||||
hidden. Fill that area in first if the edit needs it.
|
||||
|
||||
## Controls
|
||||
|
||||
- **stitcher**: The stitcher from one of the nodes above. It carries the
|
||||
@@ -70,6 +97,8 @@ are in the model's own picture.
|
||||
- **Seam** (`seam`, in the gear menu): `classic` (the default) or
|
||||
`blend in`. Read "Seam: classic or blend in" above. It has no socket,
|
||||
so a link cannot drive it.
|
||||
- **Paste onto** (`paste_onto`, in the gear menu): `crop` (the default) or
|
||||
`original`. Read "Paste onto: crop or original" above. It has no socket.
|
||||
- **Fix edge halo** (`fix_edge_halo`, classic seam only): Off by default. Recovers the true
|
||||
color under the feathered seam before pasting, so half-transparent edge
|
||||
pixels stop blending their background in a second time. It costs real
|
||||
@@ -207,6 +236,8 @@ you brighten it first.
|
||||
## Outputs
|
||||
|
||||
- **image**: The original-size image with the inpainted region blended in.
|
||||
After a crop in a Crop + Rotate + Pad node, that means the crop's size,
|
||||
unless **Paste onto** is on original.
|
||||
- **blend_mask**: The feathered paste mask in the stitched image's own
|
||||
coordinates — white where the inpaint blended in, zero where the original
|
||||
survived untouched. Wire it into a color-match or compositing node to
|
||||
@@ -222,6 +253,16 @@ you brighten it first.
|
||||
The rest of this page is for people who want to know how blend in works
|
||||
and how it was tested.
|
||||
|
||||
### How Paste onto original works
|
||||
|
||||
Image Crop + Rotate + Pad's stitcher carries your picture and the crop's
|
||||
position. A straight crop at its own size is a plain paste, so every pixel
|
||||
the mask does not touch is bit-identical. A turned or resized crop is sampled
|
||||
back (bicubic) once per picture pixel, so the edit in those cases is as soft
|
||||
as the canvas it was painted on. Untouched pixels stay bit-identical there
|
||||
too, and in the feathered band your full-size picture replaces the canvas's
|
||||
softer copy of it.
|
||||
|
||||
### How the empty check works
|
||||
|
||||
The new area is cut into 16 px blocks, and only blocks fully inside it are
|
||||
|
||||
@@ -11,21 +11,22 @@ generated-area `mask`, a `stitcher` for Stitch Inpaint, the selected
|
||||
|
||||
## Source and frame
|
||||
|
||||
- **Uploads**: Pick an input video (the list plays the clip under the pointer, muted, and filters as you type), click **Upload** in the compact source card, or
|
||||
- **Inputs**: Pick an input video (the list plays the clip under the pointer, muted, and filters as you type), click **Load** in the compact source card, or
|
||||
drop a video file onto the node. Picking another video keeps Fill, Feather,
|
||||
**Divisible by**, the Resize settings and the lit ratio button (the new frame is
|
||||
**Align**, the Resize settings and the lit ratio button (the new frame is
|
||||
fitted to it the way **Fit** is set); rotation, crop, padding and the playhead
|
||||
start over.
|
||||
- **Canvas row** under the Fit row: fill swatch, feather amount and the Resize off | on switch on the node face; turning Resize on opens the megapixel budget and its Step. The line under the picture names each step that sets the output size, as on the image node.
|
||||
- **Upload** streams to the input folder, so videos larger than the buffered image-upload limit can be selected here or dropped onto the node.
|
||||
- **Uploads** / **Outputs** (`source_mode`: `input folder` / `local path`): the source card's switch. Uploads lists ComfyUI's input folder. Outputs lists the videos in ComfyUI's output folder, newest first, so a video you just rendered can be picked without uploading it again. Both lists preview the video under the pointer.
|
||||
- **Load** streams to the input folder, so videos larger than the buffered image-upload limit can be selected here or dropped onto the node.
|
||||
- **Inputs** / **Outputs** (`source_mode`: `input folder` / `local path`): the source card's switch. Inputs lists ComfyUI's input folder. Outputs lists the videos in ComfyUI's output folder, newest first, so a video you just rendered can be picked without uploading it again. Both lists preview the video under the pointer.
|
||||
- **local_path**: The video picked in the Outputs list, named the way ComfyUI names a file
|
||||
in its output folder: `clip.mp4 [output]`, `video/take 3.mov [output]`. It is read in
|
||||
place, without an upload copy. A whole path such as `/home/me/ComfyUI/output/clip.mp4`
|
||||
is not read, for queued runs and the editor's live preview alike.
|
||||
- **Playhead**: The rail under the node's preview is the timeline: press or drag it to
|
||||
- **Play / Pause** beside the scrubber previews the video. Pause on the frame you want to output.
|
||||
- **Playhead**: The rail directly under the node's preview is the timeline: press or drag it to
|
||||
scrub, or type a frame in the **FRAME** box; the frame on the stage is the frame the
|
||||
node outputs (`frame_index`, with `frame_time` kept in step).
|
||||
- **Crop, pad & resize** (collapsible group under the timeline): folds the ratio buttons, **Fit to ratio**, **Align** and **Center**, **Fill** and **Feather**, and the **Resize** off | on switch, megapixel budget and **Step** into one drawer, showing the fill swatch and a live summary on its header when closed. The line under the picture names each step that sets the output size, as on the image node.
|
||||
- **seek_mode / frame_time**: Time-based seeking for graphs that drive the position by
|
||||
link; the editor itself always works in frames.
|
||||
|
||||
@@ -33,19 +34,19 @@ The editor has the same rail full width, plus first/last, ±1, ±25, ±50, ±100
|
||||
|
||||
## Transform and outputs
|
||||
|
||||
Rotation, crop, padding, feathering, fill, **Divisible by**, the megapixel resize, gestures, `image`, and `mask` match the image node. The output batch contains one frame.
|
||||
Rotation, crop, padding, feathering, fill, **Align**, the megapixel resize, gestures, `image`, and `mask` match the image node. The output batch contains one frame.
|
||||
|
||||
The handles work on both the node and the editor. Drag an orange corner to make
|
||||
the canvas bigger or smaller in its own shape (hold **Alt**, Option on a Mac, to
|
||||
change all four sides at once), and press **Center** next to Fill and Feather to
|
||||
put the frame in the middle, side to side or top to bottom. Both work as on the
|
||||
center the crop and balance any padding, side to side or top to bottom. Both work as on the
|
||||
image node. The ratio buttons, **Fit**,
|
||||
the padlock and the orientation button work as on the image node: a tap pads (or,
|
||||
with Fit on crop, crops) the frame to a ratio, a lit ratio is the shape the canvas
|
||||
has now, and the padlock keeps that shape while you drag (the row says **Held**).
|
||||
**Reset crop** restores the full crop while keeping rotation and padding. **Reset**
|
||||
clears rotation, crop and padding and turns the padlock off; fill, feather,
|
||||
**Divisible by** and the playhead stay. **Divisible by** makes the width and
|
||||
**Align** and the playhead stay. **Align** makes the width and
|
||||
height divide evenly by the number you pick: padding grows by a few pixels, and
|
||||
a side with no padding is trimmed by a few instead. Some models need sizes
|
||||
divisible by 8, 16 or 32; 1 turns it off.
|
||||
|
||||
@@ -11,33 +11,20 @@ model that paints black regions.
|
||||
|
||||
## Source and window
|
||||
|
||||
- **Uploads**: Pick an existing input video (the list plays the clip under the pointer, muted, and filters as you type), click **Upload**, or drop a video file
|
||||
- **Inputs**: Pick an existing input video (the list plays the clip under the pointer, muted, and filters as you type), click **Load**, or drop a video file
|
||||
onto the node. The old source widgets remain compatible with saved workflows, but
|
||||
are now driven by this compact card. Choosing another video keeps the canvas
|
||||
settings - Fill, Feather, **Divisible by**, Resize, Every nth, Frames for, Length - and
|
||||
settings - Fill, Feather, **Align**, Resize, Every nth, Frames for, Length - and
|
||||
fits the new clip to the lit ratio button, the way **Fit** is set. Rotation, crop,
|
||||
padding, IN/OUT and the playhead start over.
|
||||
- **Canvas row** (under the Fit row): the fill swatch, the feather amount and
|
||||
the Resize off | on switch, right on the node; turning Resize on opens the megapixel
|
||||
budget and its **Step**. These are what a video outpaint model keys on - the LTX IC-LoRA paints
|
||||
**pure black** at sizes rounded to 32 - so a wrong value shows here before a
|
||||
render is wasted. Feather only softens the mask and the stitch; the frames always
|
||||
meet the fill with a hard edge. The line under the picture names each step that
|
||||
sets the size (`576×1024 → pad 1821×1024 → resize 1280×704`) and warns in amber
|
||||
when the Step stretches the picture by more than 1% (`2.2% wider: each side rounds
|
||||
to 32 px`); the tooltip names a Step that avoids it, and in pad mode **Even out**
|
||||
adds a few pixels of padding so nothing stretches. While you drag, that amber
|
||||
line keeps still and comes back for the new size when you let go. The bands are drawn in the real
|
||||
fill colour with a faint hatch, so black bands show on the black stage. A fresh clip node starts
|
||||
with black and feather 0 and the editor's **Reset transform** returns to them;
|
||||
the image nodes keep their grey fill and feathered mask.
|
||||
- **Outputs** (`source_mode` / `local_path`): Pick a video from ComfyUI's output folder,
|
||||
such as one you just rendered. The list shows the newest first and previews the video
|
||||
under the pointer. The video is read in place, without an upload copy. In an API prompt
|
||||
it is named the way ComfyUI names a file there: `clip.mp4 [output]`. A whole path such
|
||||
as `/home/me/ComfyUI/output/clip.mp4` is not read, for queued runs and the editor's live
|
||||
preview alike.
|
||||
- **Timeline**: The rail on the node and in the editor is one timeline. Press or
|
||||
- **Play / Pause** beside the scrubber previews the selected frames and stops at OUT. Press Play at OUT to start from IN again.
|
||||
- **Timeline** (right under the preview and size line): The rail on the node and in the editor is one timeline. Press or
|
||||
drag anywhere on it to scrub the **playhead**, with Length on or off; the stage shows
|
||||
that frame. Drag the **IN** or **OUT** handle to trim: the playhead rides on the handle, so what you see
|
||||
is the first (IN) or last (OUT) frame the run keeps, and it stays there when you let
|
||||
@@ -53,6 +40,21 @@ model that paints black regions.
|
||||
**Play**. Arrow keys step one frame (10 with Shift), Home and End jump to the
|
||||
ends, and Space plays or pauses. **Save & close** keeps your edits; **Cancel** or
|
||||
Escape puts everything back, and asks first if anything changed.
|
||||
- **Crop, pad & resize** (collapsible group under the timeline): folds the ratio
|
||||
buttons, **Fit to ratio**, **Align** and **Center**, **Fill** and **Feather**, and
|
||||
the **Resize** switch, megapixel budget and **Step** into one drawer. While closed,
|
||||
its header bar shows the fill colour swatch and a live summary of the active
|
||||
ratio, feather, alignment and resize settings. These are what a video outpaint model keys on - the LTX IC-LoRA paints
|
||||
**pure black** at sizes rounded to 32 - so a wrong value shows here before a
|
||||
render is wasted. Feather only softens the mask and the stitch; the frames always
|
||||
meet the fill with a hard edge. The line under the picture names each step that
|
||||
sets the size (`576×1024 → pad 1821×1024 → resize 1280×704`) and warns in amber
|
||||
when the Step stretches the picture by more than 1% (`Resize makes the picture 2.2% wider`); the tooltip names a Step that avoids it, and in pad mode **Pad to keep shape**
|
||||
adds a few pixels of padding so nothing stretches. While you drag, that amber
|
||||
line keeps still and comes back for the new size when you let go. The bands are drawn in the real
|
||||
fill colour with a faint hatch, so black bands show on the black stage. A fresh clip node starts
|
||||
with black and feather 0 and the editor's **Reset transform** returns to them;
|
||||
the image nodes keep their grey fill and feathered mask.
|
||||
- **Length** (`max_frames`): the one other way to set how long the clip is. **Off**
|
||||
(the default): OUT ends the clip. **On**: a number of output frames from IN; OUT
|
||||
sits that many frames after IN and follows it when you drag IN, and dragging OUT or
|
||||
@@ -80,8 +82,8 @@ Crop squares, padding diamonds, the orange corners and the rotation handle also
|
||||
work directly on the node preview. A corner makes the canvas bigger or smaller in
|
||||
its own shape, for example to zoom out evenly before a video outpaint; hold **Alt**
|
||||
(Option on a Mac) to change all four sides at once. **Center**, next to Fill and
|
||||
Feather, puts the clip in the middle of the canvas side to side or top to bottom
|
||||
without changing the canvas size. The graph still owns wheel zoom and middle-button
|
||||
Feather, centers the crop on the source and balances any padding on the selected axis.
|
||||
The generated mask follows the crop and padding. The graph still owns wheel zoom and middle-button
|
||||
pan. Tap a ratio
|
||||
to pad the clip to it (or crop it, with **Fit** on crop); tap the lit ratio again to
|
||||
go back to the whole frame. A lit ratio is the shape the canvas has now: drag a
|
||||
@@ -92,31 +94,31 @@ ratio or turn it"); otherwise it names the lit ratio where it acted (`pad to 16:
|
||||
1821×1024`). The padlock keeps the shape while
|
||||
you drag, with new bands split evenly, and the row says **Held**; Reset and tapping
|
||||
the lit ratio turn it off, and it does nothing on the untouched clip. **Fit** is
|
||||
dimmed until a ratio is lit. The orientation button turns the shape on its side
|
||||
ready to set before picking a ratio. The orientation button turns the shape on its side
|
||||
(16:9 to 9:16), and dragging the picture itself moves it inside its padding: the
|
||||
cursor and small arrows show which ways it can go.
|
||||
**Reset crop** restores the full crop without changing rotation, padding or trim;
|
||||
with the padlock on it also takes away the bands the lock added.
|
||||
**Reset** on the node clears rotation, crop and padding and turns the padlock off;
|
||||
fill, feather, **Divisible by** and the timeline stay.
|
||||
fill, feather, **Align** and the timeline stay.
|
||||
**Reset transform** in the editor resets rotation, crop, padding, fill, feather and
|
||||
**Divisible by**; it keeps the source, current frame, trim, Length, resize and stitch
|
||||
settings. **Divisible by** makes the width and height divide evenly by the number
|
||||
**Align**; it keeps the source, current frame, trim, Length, resize and stitch
|
||||
settings. **Align** makes the width and height divide evenly by the number
|
||||
you pick: padding grows by a few pixels, and a side with no padding is trimmed by
|
||||
a few instead. Some models need sizes divisible by 8, 16 or 32; 1 turns it off.
|
||||
|
||||
Video Upload and file drop use a streaming route into ComfyUI's input folder,
|
||||
The **Load** button and file drop use a streaming route into ComfyUI's input folder,
|
||||
so the buffered image-upload size limit does not prevent long-video uploads.
|
||||
|
||||
The editor's sidebar uses the node's own ratio row, padlock and **Fit** switch, and
|
||||
the rotation is a number box (Shift for 0.1°). A ratio replaces the old crop and
|
||||
padding, keeping rotation and resize settings.
|
||||
Pixel rounding can differ by one pixel between opposite bands. **Divisible by** and
|
||||
Pixel rounding can differ by one pixel between opposite bands. **Align** and
|
||||
the resize **Step** can slightly change the final aspect ratio.
|
||||
|
||||
## Transform and outputs
|
||||
|
||||
Rotation, crop, padding, feathering, fill, **Divisible by**, and the editor's handles
|
||||
Rotation, crop, padding, feathering, fill, **Align**, and the editor's handles
|
||||
match the image node. **Resize output** in the editor scales the finished frames to a
|
||||
megapixel budget with a resolution step (32 keeps LTX and Wan sizes), the same trio as
|
||||
the core Scale Image to Total Pixels node. The frames are resized by themselves and the
|
||||
@@ -161,7 +163,8 @@ Five optional sockets sit on the left, above the editor:
|
||||
| `start_frame` | Zero-based source start frame. Overrides timeline IN. |
|
||||
| `end_frame` | Exclusive source end frame. 0 means the end of the source. Overrides timeline OUT. |
|
||||
| `fixed_frames` | Exact output frames from IN; the source must be long enough. Connected, it sets the length: OUT, Length and Frames for step aside. |
|
||||
| `frame_load_cap` | Maximum frames after rate conversion and Every nth, before Frames for. 0 means unlimited. Connected, it sets the length in place of Length. |
|
||||
| `frame_load_cap` | Maximum frames after rate conversion and Every nth, before Frames for. 0 means unlimited. Connected, it sets the length in place of Length. When the count is known, dragging IN stops at the last start that fits the kept frames. |
|
||||
| `last_seconds` | Load only this many seconds from the end of the clip, or of the part before OUT. 0 loads all of it. Use it to carry on from a clip: a long clip then costs no more memory than a short one. |
|
||||
|
||||
Frame bounds use the source's frame-rate grid, independent of `force_rate`.
|
||||
The footer labels **Source fps** separately from **Output fps**. A direct
|
||||
|
||||
@@ -18,14 +18,37 @@ Nothing runs: the node has no outputs, so ComfyUI never executes it.
|
||||
- **Title / Subtitle / Author** make the banner. **Layout → Banner** shows
|
||||
only that top block (the title, plus the subtitle and author if you filled
|
||||
them in), so the same node doubles as a label for a stage or column.
|
||||
- **Layout → Info tile** puts a single button on the canvas. Click the tile to read the full note, including models and links. A book marks a guide and a download arrow marks a model list. Click its pencil to edit, or press **Edit** inside the opened note. Drag the visible bottom-right grip to resize the tile without moving it. Focus the grip and use arrow keys for keyboard resizing; **Shift** makes smaller changes.
|
||||
- **Accent color** tints the banner bar, dots and buttons; empty keeps the
|
||||
pack teal. It takes a six-digit hex such as `#00b4aa`.
|
||||
- **How-to** is Markdown-lite: `#`/`##`/`###` headings, `**bold**`,
|
||||
`*italic*`, `` `code` ``, `[links](https://…)`, `-` and `1.` lists, `---`
|
||||
rules, fenced code. Only `http(s)` links become clickable.
|
||||
|
||||
## Formatting a how-to
|
||||
|
||||
Select a phrase in the How-to field and click **Teal**, **Green**, **Amber** or **Glow**. Click **Table** to insert an editable Markdown table. Open **Preview formatting** while you write. Use headings to break up steps and a pipe table for settings or file locations. Keep each row the same length. Use `\|` for a literal pipe inside a cell.
|
||||
|
||||
```markdown
|
||||
## Choose a size
|
||||
|
||||
| Size | When to use it |
|
||||
| --- | --- |
|
||||
| 480 × 864 | [teal]First run[/teal] |
|
||||
| 576 × 1024 | A larger portrait |
|
||||
|
||||
[green]Ready[/green] means the model was found.
|
||||
[amber]Missing[/amber] means you still need the file.
|
||||
```
|
||||
|
||||
For a short highlight, write `[glow]Preview the result[/glow]`. The glow is static. These four tags are AusBoss additions to Markdown. They style the enclosed plain text. Headings, lists, table cells and paragraphs can all contain them. Fenced code displays the original characters.
|
||||
|
||||
## Models
|
||||
|
||||
To give downloads their own button, choose **Layout → Info tile**, set the title to **Download models**, and add files under **Models**. To keep them with your instructions, add those same rows to the **How to use** note. Switching an existing card to an info tile keeps its model rows and links.
|
||||
|
||||
[Download the Krea 2 model tile example](https://raw.githubusercontent.com/ausboss/ComfyUI-AusBoss/main/js/docs/examples/model-download-tile.json) and open it as a workflow. Copy the tile into your graph and edit its rows.
|
||||
|
||||
Each row is a file, the model folder it belongs in, an optional size, a
|
||||
download URL, and an optional note that shows in grey under the file name.
|
||||
Rows group under a `📂 ComfyUI/models/<folder>` heading so the
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Workflow Note Tile
|
||||
|
||||
A compact help button that opens a full note. Click to read the instructions and model links; click the pencil to edit. Drag its visible corner to resize.
|
||||
|
||||
It uses the same note content as [Workflow Note](AUSBOSS_NODES_WorkflowNote.md). Choose **Card** or **Banner** in the editor to show the note directly on the canvas. Nothing runs and it has no outputs.
|
||||
|
||||
Tiles save with their own node type. If ComfyUI reports this node missing, install or update **ComfyUI-AusBoss**, restart ComfyUI, and reload the workflow. An older pack cannot draw the tile correctly. Keep the missing node so its instructions and settings are preserved.
|
||||
@@ -14,13 +14,43 @@ an upscale at the end.
|
||||
2. Press **+ Switch from 2 selected** on the card.
|
||||
3. Type a name and press Enter.
|
||||
|
||||
One click on its pill now turns every node you picked off, and the next click
|
||||
turns them on again.
|
||||
Click **off** to skip every node you picked, or **on** to run them again.
|
||||
Clicking the selected half leaves it selected.
|
||||
|
||||
Groups work too. Every group on the canvas gets a switch of its own, unless
|
||||
you change that under **Settings**. A group can also be part of a switch you
|
||||
make: select it together with the nodes.
|
||||
|
||||
## Pick between saved settings
|
||||
|
||||
Use this when the same node should be set one of a few ways and you want to
|
||||
pick by name: 8 steps or 20 steps, or which branch to run.
|
||||
|
||||
1. Set the node the way the first choice should be, and select it.
|
||||
2. Press **+ Choice**, type a name and press Enter.
|
||||
3. Set the node the second way and press **+ Choice** again. You don't need
|
||||
to select it this time.
|
||||
|
||||
The card is now one pill with your choices. Click one and the node goes back
|
||||
to how you saved it. Only the settings that differ between your choices are
|
||||
changed, so the rest stay yours. Add a third the same way.
|
||||
|
||||
To run one of two branches, wire both into ComfyUI's **If/Else Switch**, where
|
||||
they meet, and make one choice with its switch at true and one at false. Only
|
||||
the branch you pick runs. Both branches must end in the same type.
|
||||
|
||||
A missing model or picture stops the run even in the branch you didn't pick,
|
||||
because ComfyUI checks every node that is on. To avoid that, select the nodes
|
||||
only one choice needs, open that choice's three dots and pick **The selected
|
||||
run only with this choice**. They switch off while another choice is picked.
|
||||
Do this for each branch, and leave out any node both branches use.
|
||||
|
||||
A card holds either choices or switches, so **+ Choice** shows on a card with
|
||||
no switches yet, once a node is selected. To have both, use two cards.
|
||||
|
||||
To change a choice later, set the node, open the choice's three dots and pick
|
||||
**Save how it is set now**.
|
||||
|
||||
## A switch that changes settings
|
||||
|
||||
A switch can also change values instead of turning nodes off. One click can
|
||||
@@ -45,8 +75,13 @@ does not change keep whatever you give them. The same works for the LoRA
|
||||
rows on a subgraph box (LoRA Loader's gear, **Rows on the box**): select the
|
||||
box itself and save its on and off.
|
||||
|
||||
A LoRA row a switch sets shows a lock in the LoRA Loader. Click the lock to
|
||||
take that row over: this switch then leaves the row alone.
|
||||
A LoRA Loader group works the same way. Save the switch once with the group
|
||||
on and once with it off, and the switch turns the whole group on and off.
|
||||
Each LoRA in the group keeps its own on/off, so you can still leave one out
|
||||
by hand, and a LoRA added to the group later goes with it.
|
||||
|
||||
A LoRA row or group a switch sets shows a lock in the LoRA Loader. Click the
|
||||
lock to take it over: this switch then leaves it alone.
|
||||
|
||||
## A switch with two sides
|
||||
|
||||
@@ -125,8 +160,9 @@ the models only that part needs.
|
||||
|
||||
## The edit view
|
||||
|
||||
- **off | on**: click anywhere on the pill to flip the switch. Off turns off
|
||||
every node of the row, on sets them all to run. It is one undo step.
|
||||
- **off | on**: click **off** to turn off every node of the row, or **on** to
|
||||
set them all to run. Clicking the selected half leaves it selected. A change
|
||||
is one undo step.
|
||||
- **Mixed**: when only some of a row's nodes run (one bypassed by hand, say)
|
||||
the pill is striped and a count shows how many run, such as **1/2**. Click
|
||||
the half you want, off or on, to set them all. A settings switch reads **changed** when one of
|
||||
@@ -161,11 +197,19 @@ Open **Settings** at the bottom of the edit view.
|
||||
from the left and top to bottom inside a column. **title** sorts by title,
|
||||
with numbers in numeric order (*2 · …* before *10 · …*).
|
||||
- **One at a time**: switching a row on switches the other rows off. Use it
|
||||
for alternatives, such as two upscalers.
|
||||
for alternatives, such as two upscalers. Turn it on while several rows are
|
||||
on and the top one stays on. A new switch starts off while another row is
|
||||
on.
|
||||
- **Show as** (there while One at a time is on): **rows** gives every switch
|
||||
a row with its own off | on. **one pill** shows them as one pill to pick
|
||||
from, see above.
|
||||
|
||||
## Have an AI agent set it up
|
||||
|
||||
An AI agent can add switches for you by writing them into the workflow file.
|
||||
Point it at `js/docs/agents/workflow_switches.md` in the pack's folder. That
|
||||
page tells it what to write.
|
||||
|
||||
## Technical details
|
||||
|
||||
- **What bypass passes on.** A bypassed node, or box, hands on its first
|
||||
@@ -174,8 +218,14 @@ Open **Settings** at the bottom of the edit view.
|
||||
no input of that kind (a decode: latent in, picture out) passes nothing,
|
||||
so the nodes that read it belong in the same switch.
|
||||
- **A switch finds its nodes by id.** A deleted node drops out of its
|
||||
switch. A copy of a node is not in the switch, and a pasted copy of the
|
||||
card switches the same nodes as the original.
|
||||
switch. A copy of a node is not in the switch. A card copied together with
|
||||
its nodes and groups switches the copies, so a whole workflow pasted into
|
||||
a new tab keeps working switches. A card copied on its own switches the
|
||||
same nodes as the original.
|
||||
- **"empty" next to a switch** means none of its nodes are in this workflow,
|
||||
so there is nothing to turn on. A workflow pasted and saved on a version
|
||||
before 2.20.3 stays this way. Open the original file again, or add nodes
|
||||
from the switch's menu in the edit view.
|
||||
- **A node in two switches** is changed by either. The other row then reads
|
||||
mixed. A node that two parts share needs no switch: ComfyUI only runs what
|
||||
a result needs.
|
||||
@@ -212,6 +262,22 @@ Open **Settings** at the bottom of the edit view.
|
||||
- **One at a time** switches the other rows off, which runs their off sides.
|
||||
A node that one row runs and another switches off ends up running, and the
|
||||
row you switch on has the last word.
|
||||
- **What a choice stores.** One saved value set, with no off side. A choice
|
||||
changes the settings it holds differently from another choice on its card,
|
||||
so a third value on one control needs nothing extra. Picking one writes only
|
||||
its own values, and a node it does not hold stays as it was. A choice can
|
||||
also hold nodes, the way a switch does: they run while it is picked and are
|
||||
off otherwise. The node a choice sets can't be one of them, because switching
|
||||
it off would undo the choice. A value set by
|
||||
hand that matches no choice leaves none picked. A card with choices keeps
|
||||
One at a time on, shows one pill and lists no groups, so its Settings are
|
||||
hidden.
|
||||
- **When a card settles by itself.** Turning One at a time on, or making a
|
||||
switch while it is on, leaves one row on when more than one is. A row that
|
||||
is fully on goes before one that is partly on, then the top row. A new
|
||||
switch gives way to the rows that were there. Rows that a change to
|
||||
**Groups** or **Match** adds are left as they are, because the words typed
|
||||
into Match should not stop nodes. A click on a row settles those.
|
||||
- **A pill or a list.** Two to four rows are a pill when their names fit on
|
||||
a card no wider than 400 px. More rows, or longer names, are a list. With
|
||||
one row the card stays in the row view. Groups listed on the card are
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# AusBoss nodes: a guide for AI agents
|
||||
|
||||
Give this page to an AI agent and it can pick the right AusBoss node for a job,
|
||||
wire it into a workflow, and find the help for every input. It ships with the
|
||||
pack, so it is also in your ComfyUI install, under
|
||||
`custom_nodes/<this pack>/js/docs/agents/`.
|
||||
|
||||
**If you are the agent:** find the job in the table below, then read that
|
||||
node's help page before you wire it. Each help page is
|
||||
`js/docs/<node id>.md` in this pack. ComfyUI shows the same page in its node
|
||||
help panel.
|
||||
|
||||
## Which node for which job
|
||||
|
||||
| The job | Node (id) |
|
||||
|---|---|
|
||||
| Pick a picture size by shape and megapixels, in place of Empty Latent | Latent Size (`AUSBOSS_NODES_Resolution`) |
|
||||
| Resize a picture, or snap its size to a multiple | Image Resize (`AUSBOSS_NODES_ImageResize`), Align Image (`AUSBOSS_NODES_AlignImage`) |
|
||||
| Read a picture's width and height | Image Size (`AUSBOSS_NODES_ImageSize`) |
|
||||
| Outpaint: load a picture and add empty space around it | Load Image + Pad (`AUSBOSS_NODES_LoadImagePad`) |
|
||||
| Turn, crop and pad a picture in one node | Image Crop + Rotate + Pad (`AUSBOSS_NODES_ImageCropRotatePad`) |
|
||||
| Inpaint only the masked part, then put it back | Crop For Inpaint (`AUSBOSS_NODES_CropForInpaint`) then Stitch Inpaint (`AUSBOSS_NODES_StitchInpaint`) |
|
||||
| Remove a watermark or an object with no prompt | LaMa Inpaint (`AUSBOSS_NODES_LaMaInpaint`) |
|
||||
| Get a mask by typing what to find | Mask by Name (`AUSBOSS_NODES_MaskByName`) |
|
||||
| Get a mask by clicking the thing | Mask by Touch (`AUSBOSS_NODES_MaskByTouch`) |
|
||||
| Grow, shrink, fill or smooth a mask | Mask Refine (`AUSBOSS_NODES_RefineMask`) |
|
||||
| Line an edited picture back up with the original | Realign to Source (`AUSBOSS_NODES_RealignToSource`, experimental) |
|
||||
| Match one picture's colors to another | Color Match (`AUSBOSS_NODES_ColorMatch`) |
|
||||
| Upscale big pictures in tiles | Tiled Upscale (`AUSBOSS_NODES_TiledUpscale`) then Tiled Upscale Stitch (`AUSBOSS_NODES_TiledUpscaleStitch`) |
|
||||
| Compare two pictures | Image Compare A/B (`AUSBOSS_NODES_Compare`) |
|
||||
| Save pictures (PNG, lossless WebP or JPEG XL) | Save Image (`AUSBOSS_NODES_SaveImage`) |
|
||||
| Run a workflow on each picture in a folder | Image Folder (`AUSBOSS_NODES_ImageFolder`) |
|
||||
| Apply a stack of LoRAs | LoRA Loader (`AUSBOSS_NODES_LoraLoader`) |
|
||||
| Write a MiniMax H3 or Pika H3 Flex prompt from plain words | H3 Prompt Writer (`AUSBOSS_NODES_H3PromptWriter`): [node help](../AUSBOSS_NODES_H3PromptWriter.md) |
|
||||
| Krea 2 prompt, and Krea 2 outpainting | Krea 2 Encode (`AUSBOSS_NODES_Krea2Encode`), Krea 2 Outpaint Model Patch (`AUSBOSS_NODES_Krea2OutpaintModelPatch`) |
|
||||
| Load or save a video | Load Video (`AUSBOSS_NODES_LoadVideo`), Save Video (`AUSBOSS_NODES_SaveVideo`) |
|
||||
| Load a sound file and keep only part of it, up to a set length | Load Audio (`AUSBOSS_NODES_LoadAudio`) |
|
||||
| Turn, crop and pad a video: one frame, or the whole clip | Video Crop + Rotate + Pad → Frame (`AUSBOSS_NODES_VideoCropRotatePad`), → Clip (`AUSBOSS_NODES_VideoCropRotatePadClip`) |
|
||||
| Change a clip's frame rate | Frame Interpolate (`AUSBOSS_NODES_FrameInterpolate`) |
|
||||
| Work with frame batches | Select Frame, Select Every Nth, Split Batch, Merge Batches (`AUSBOSS_NODES_SelectFrame`, `_SelectEveryNth`, `_SplitBatch`, `_MergeBatches`) |
|
||||
| One shared seed, number or text | Seed, Integer, Float, Text (`AUSBOSS_NODES_Seed`, `_Integer`, `_Float`, `_Text`) |
|
||||
| Do maths on numbers | Math Expression (`AUSBOSS_NODES_MathExpression`) |
|
||||
| Show text on the node, or the picture while it is made | Show Text (`AUSBOSS_NODES_ShowText`), Step Preview (`AUSBOSS_NODES_StepPreview`) |
|
||||
| Free graphics memory between steps | Free Memory (`AUSBOSS_NODES_FreeMemory`) |
|
||||
| Time the whole run | Run Timer (`AUSBOSS_NODES_RunTimer`) |
|
||||
| Turn parts of a workflow off and on | Workflow Switches (`AUSBOSS_NODES_WorkflowSwitches`): [its own agent guide](workflow_switches.md) |
|
||||
| Explain a shared workflow on the canvas | Workflow Note (`AUSBOSS_NODES_WorkflowNote`), Callout (`AUSBOSS_NODES_Callout`) |
|
||||
| Add compact help or colored link buttons | Workflow Note Tile (`AUSBOSS_NODES_WorkflowNoteTile`), Callout Tile (`AUSBOSS_NODES_CalloutTile`) |
|
||||
| Lay out boxes of things and words on a canvas | Layout Boxes (`AUSBOSS_NODES_LayoutBoxes`) |
|
||||
|
||||
Every node's display name ends in 🆎 and sits under `🆎 AusBoss/<group>` in
|
||||
the node menu. In a workflow file and in the API, a node is named by its id,
|
||||
the `AUSBOSS_NODES_...` key above.
|
||||
|
||||
## Before you edit a workflow
|
||||
|
||||
- **Read settings by name.** A saved workflow keeps each node's settings
|
||||
twice: by position in `widgets_values`, and by name in
|
||||
`widgets_values_named`. Read and write by name, and keep both copies in
|
||||
step.
|
||||
- **Look at a node's inputs and outputs** in the running ComfyUI:
|
||||
`GET /object_info/<node id>` lists them with their types and defaults.
|
||||
- **Inputs never get renamed or reordered** after a release, so a workflow
|
||||
that worked keeps working.
|
||||
- **Media loaders start blank** in the example workflows. Put in the file the
|
||||
person wants to use before you queue.
|
||||
|
||||
## H3 swaps and clip duration
|
||||
|
||||
For a swap, keep the target selector on its own line. An optional separate line,
|
||||
`@1 looks like a woman with blonde hair and a black hoodie.`, describes the
|
||||
connected replacement person in Picture 1; use its actual picture number. This
|
||||
can help express appearance but does not guarantee identity. Do not add it to
|
||||
other jobs. Existing scripts without it keep their previous behavior.
|
||||
|
||||
An active video sets the duration for **swap the person**, **change something**
|
||||
and **new voice**. The card says **From the clip** and hides numeric timeline
|
||||
retiming; trim or limit the upstream clip instead. Do not infer the final frame
|
||||
count from raw source metadata: upstream processing and H3 frame rounding apply.
|
||||
Other jobs and video OFF still use **Length** normally.
|
||||
|
||||
## Example workflows
|
||||
|
||||
`example_workflows/` in this pack holds complete workflows, each with a
|
||||
picture of what it makes. Each one starts with a Workflow Note that lists the
|
||||
model files it needs and where they go. Start from the closest one rather
|
||||
than building from nothing.
|
||||
|
||||
## When something goes wrong
|
||||
|
||||
- Read the node's help page again, including its "Technical details" or "Notes" part when it has one.
|
||||
- Search the pack's GitHub issues: https://github.com/ausboss/ComfyUI-AusBoss/issues
|
||||
- A new bug report should name the ComfyUI version, the pack version from
|
||||
the console banner, the node, and the smallest workflow that shows it.
|
||||
@@ -0,0 +1,191 @@
|
||||
# Workflow Switches: a guide for AI agents
|
||||
|
||||
Give this page to an AI agent and it can add switches to a ComfyUI workflow
|
||||
for you. Nobody has to click through menus: the agent writes the card straight
|
||||
into the workflow file.
|
||||
|
||||
**If you are the agent:** the card is one node in the workflow file. It has no
|
||||
wires. It lists which nodes each switch controls, in one property. Pick the
|
||||
recipe below that fits, add the card, and set the nodes' starting state.
|
||||
|
||||
## Before you start
|
||||
|
||||
- **Edit the file ComfyUI saves with Save**, the one with a `nodes` list. The
|
||||
card never reaches a run, so it has no place in the API format. For an API
|
||||
prompt, leave out what should not run.
|
||||
- **Use the `id` of each node** in `nodes`, and of each box in `groups`.
|
||||
- **Stay on one level.** The card controls nodes on its own level of the
|
||||
graph. From the main graph it can switch a whole subgraph node, or change a
|
||||
control that subgraph node shows, but not reach a node inside it.
|
||||
- **Read a control's name** from the node's `widgets_values_named` in the file,
|
||||
or from `/object_info/<node type>` on the ComfyUI server.
|
||||
|
||||
## Add the card
|
||||
|
||||
Add this to `nodes`. Give it an `id` no other node has, and raise
|
||||
`last_node_id` if it is lower than that `id`. Pick a free `pos` so it covers
|
||||
nothing, and the next free number for `order`.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 50,
|
||||
"type": "AUSBOSS_NODES_WorkflowSwitches",
|
||||
"pos": [0, 0],
|
||||
"size": [320, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"title": "Workflow Switches",
|
||||
"properties": {
|
||||
"cnr_id": "ausboss-nodes",
|
||||
"ausboss_show_edit": false,
|
||||
"ausboss_workflow_switches": {
|
||||
"off": "bypass",
|
||||
"groups": "none",
|
||||
"exclusive": false,
|
||||
"show": "rows",
|
||||
"switches": []
|
||||
}
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
```
|
||||
|
||||
Then fill `switches` from a recipe. Keep `"groups": "none"`: the default,
|
||||
`"all"`, gives every group on the canvas a row of its own.
|
||||
|
||||
`show` is `"rows"`, one off | on row per switch, or `"pill"`, one pill to pick
|
||||
from. A row's `id` is a whole number from 1, new on its card only. A workflow
|
||||
can have several cards.
|
||||
|
||||
## Set the starting state
|
||||
|
||||
The card remembers nothing about what is on. It reads each node's own `mode`,
|
||||
so the file decides how the workflow opens.
|
||||
|
||||
| `mode` | What the node does |
|
||||
| --- | --- |
|
||||
| `0` | Runs |
|
||||
| `4` | Bypassed: passes its input on, and ComfyUI doesn't check it |
|
||||
| `2` | Muted: passes nothing on, and ComfyUI doesn't check it |
|
||||
|
||||
For a part that opens switched off, set `"mode": 4` on its nodes, Save Image
|
||||
nodes included. With `"off": "mute"` on the card, use `2`.
|
||||
|
||||
To change a value in the file, change it in both `widgets_values` and
|
||||
`widgets_values_named`. The two have to agree.
|
||||
|
||||
## Recipes
|
||||
|
||||
### An optional part
|
||||
|
||||
One row that turns some nodes off and on: an upscale at the end, say.
|
||||
|
||||
```json
|
||||
"switches": [
|
||||
{ "id": 1, "title": "Upscale", "nodes": [12, 13], "groups": [] }
|
||||
]
|
||||
```
|
||||
|
||||
To switch a whole group of nodes, put the group's `id` in `groups`.
|
||||
|
||||
### Pick one part
|
||||
|
||||
One pill: picking a name turns its nodes on and the other rows' nodes off. Open
|
||||
it with one row's nodes at `0` and the rest at `4`. Up to 4 names sit in a
|
||||
pill, more become a list.
|
||||
|
||||
```json
|
||||
"exclusive": true,
|
||||
"show": "pill",
|
||||
"switches": [
|
||||
{ "id": 1, "title": "Photo", "nodes": [20, 21], "groups": [] },
|
||||
{ "id": 2, "title": "Drawing", "nodes": [22], "groups": [] }
|
||||
]
|
||||
```
|
||||
|
||||
A node both parts use goes in neither row.
|
||||
|
||||
### One switch that changes settings
|
||||
|
||||
The row sets values instead of turning nodes off. `on` and `off` are keyed by
|
||||
node id, as text, then by control name. Give every node both sides.
|
||||
|
||||
```json
|
||||
"switches": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Fast",
|
||||
"nodes": [],
|
||||
"groups": [],
|
||||
"on": { "5": { "steps": 8 } },
|
||||
"off": { "5": { "steps": 20 } }
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Only values that differ between the two sides change. Values are text, numbers
|
||||
or true/false.
|
||||
|
||||
A LoRA Loader 🆎 keeps its rows in one text value, `loras`. Save the whole text
|
||||
on both sides with only the row's `enabled` different, and the switch moves
|
||||
that row alone.
|
||||
|
||||
### Named choices
|
||||
|
||||
The simplest way to pick between settings, and to run one of two branches. Each
|
||||
choice is one way of setting a node, so it has `on` and no `off`. A card of
|
||||
choices is always one pill, so give other switches a card of their own.
|
||||
|
||||
To run one of two branches, wire both into ComfyUI's If/Else Switch (type
|
||||
`ComfySwitchNode`, control `switch`, inputs `on_true` and `on_false`):
|
||||
|
||||
```json
|
||||
"exclusive": true,
|
||||
"show": "pill",
|
||||
"switches": [
|
||||
{ "id": 1, "title": "Soft", "choice": true, "nodes": [3], "groups": [], "on": { "6": { "switch": true } } },
|
||||
{ "id": 2, "title": "Big", "choice": true, "nodes": [4, 5], "groups": [], "on": { "6": { "switch": false } } }
|
||||
]
|
||||
```
|
||||
|
||||
Here node 6 is the If/Else, and each choice's `nodes` are the nodes only that
|
||||
branch needs. Open the file with the If/Else set to the picked choice, that
|
||||
choice's nodes at `0` and the others' at `4`.
|
||||
|
||||
List a branch's nodes every time. ComfyUI checks every node that is on before
|
||||
it runs anything, so a missing model in the branch nobody picked stops the run
|
||||
unless its nodes are off.
|
||||
|
||||
Keep these out of a choice's `nodes`: the node the choice sets, because
|
||||
switching it off would undo the choice, and any node both branches use. Both
|
||||
branches must end in the same type, such as a picture.
|
||||
|
||||
### A row with two sides
|
||||
|
||||
`nodes_off` holds nodes that run only while the row is off. Use it when turning
|
||||
one thing on should turn another off.
|
||||
|
||||
```json
|
||||
{ "id": 1, "title": "Edit a picture", "nodes": [7], "groups": [], "nodes_off": [8] }
|
||||
```
|
||||
|
||||
Open it with one side at `0` and the other at `4`.
|
||||
|
||||
## Check your work
|
||||
|
||||
Open the workflow in ComfyUI, then:
|
||||
|
||||
- **A row says "empty":** none of its ids match a node on the card's level.
|
||||
- **A row says "mixed", or a pill shows no pick:** the saved `mode`s or values
|
||||
don't match any one row. Fix the starting state.
|
||||
- **A row does nothing:** a value's control name is wrong, or both sides hold
|
||||
the same value.
|
||||
- **A choice says "needs one more":** no other choice differs from it.
|
||||
|
||||
## Limits
|
||||
|
||||
A card holds up to 40 rows. A title is up to 60 characters, and a row up to
|
||||
400 nodes. A row can set values on up to 40 nodes.
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"id": "27945e9e-3e7b-49c5-bcb7-434d5de9e689",
|
||||
"revision": 0,
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "AUSBOSS_NODES_WorkflowNoteTile",
|
||||
"pos": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"size": [
|
||||
300,
|
||||
88
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"title": "Download models",
|
||||
"properties": {
|
||||
"Node name for S&R": "AUSBOSS_NODES_WorkflowNoteTile"
|
||||
},
|
||||
"widgets_values": [
|
||||
"{\"v\": 1, \"title\": \"Download models\", \"subtitle\": \"Krea 2 Outpaint\", \"author\": \"\", \"layout\": \"tile\", \"accent\": \"\", \"body\": \"Get the four files below and put each one in its listed folder. Click **check files** after adding them.\", \"models\": [{\"name\": \"krea2_turbo_fp8_scaled.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"13.1 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/diffusion_models/krea2_turbo_fp8_scaled.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_4b_fp8_scaled.safetensors\", \"dir\": \"text_encoders\", \"size\": \"5.2 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/text_encoders/qwen3vl_4b_fp8_scaled.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_vae.safetensors\", \"dir\": \"vae\", \"size\": \"254 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/vae/qwen_image_vae.safetensors\", \"note\": \"\"}, {\"name\": \"krea2_anypaint_rank32.safetensors\", \"dir\": \"loras\", \"size\": \"229 MB\", \"url\": \"https://huggingface.co/yijunwang2/krea2-anypaint/resolve/main/krea2_anypaint_rank32.safetensors\", \"note\": \"by yijunwang2\"}], \"packs\": [], \"links\": []}"
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"note": "{\"v\": 1, \"title\": \"Download models\", \"subtitle\": \"Krea 2 Outpaint\", \"author\": \"\", \"layout\": \"tile\", \"accent\": \"\", \"body\": \"Get the four files below and put each one in its listed folder. Click **check files** after adding them.\", \"models\": [{\"name\": \"krea2_turbo_fp8_scaled.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"13.1 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/diffusion_models/krea2_turbo_fp8_scaled.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_4b_fp8_scaled.safetensors\", \"dir\": \"text_encoders\", \"size\": \"5.2 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/text_encoders/qwen3vl_4b_fp8_scaled.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_vae.safetensors\", \"dir\": \"vae\", \"size\": \"254 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/vae/qwen_image_vae.safetensors\", \"note\": \"\"}, {\"name\": \"krea2_anypaint_rank32.safetensors\", \"dir\": \"loras\", \"size\": \"229 MB\", \"url\": \"https://huggingface.co/yijunwang2/krea2-anypaint/resolve/main/krea2_anypaint_rank32.safetensors\", \"note\": \"by yijunwang2\"}], \"packs\": [], \"links\": []}"
|
||||
},
|
||||
"color": "#14424d",
|
||||
"bgcolor": "#161f21"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.9,
|
||||
"offset": [
|
||||
150,
|
||||
115
|
||||
]
|
||||
},
|
||||
"frontendVersion": "1.53.6"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { showToast } from "../shared/index.mjs";
|
||||
import { openTransformEditorForNode } from "../shared/transform_editor.mjs";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// hand-over waits one microtask, so the frontend has rebuilt the whole graph
|
||||
// before any panel moves.
|
||||
|
||||
import { app } from "/scripts/app.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { chainCallback, isAusbossNode } from "../shared/index.mjs";
|
||||
import { createFaceHandover } from "../shared/face_handover.mjs";
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ function mountTransformPanel(node, panel) {
|
||||
fillNodeHeight(widget, {
|
||||
minWidth: PANEL_MIN_WIDTH,
|
||||
minHeight: () => transformPanelFloor(node),
|
||||
minNodeSize: [PANEL_MIN_WIDTH, 470],
|
||||
minNodeSize: [PANEL_MIN_WIDTH, 396],
|
||||
exactMinWidth: true,
|
||||
});
|
||||
return widget;
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
// files to this site?"). A page can neither word that question nor skip it,
|
||||
// so the node says what it means here while the file explorer is open.
|
||||
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { BRAND, chainCallback, chainHandler, keepDomWidgetWidthAuto, notifyAusbossChange, showToast } from "../shared/index.mjs";
|
||||
import { fillNodeHeight } from "../shared/panel_layout.mjs";
|
||||
import { isForeignRun } from "../shared/prompt_scope.mjs";
|
||||
@@ -725,7 +725,7 @@ function buildPanel(node) {
|
||||
const now = settings(node);
|
||||
if (now.run !== "one per run" || isLinked(node, "position")) return;
|
||||
const count = pickedNames(parsePicked(now.pictures), state.names).length;
|
||||
const next = nextPosition(now.position, count, now.afterRun);
|
||||
const next = nextPosition(now.position, count, now.afterRun, now.atTheEnd);
|
||||
if (next !== now.position) { position.value = next; position.callback?.(next); }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// last text. The labels themselves are never saved, so an older version of
|
||||
// the pack shows the plain names.
|
||||
|
||||
import { app } from "/scripts/app.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { isForeignRun } from "../shared/prompt_scope.mjs";
|
||||
import { chainCallback } from "../shared/index.mjs";
|
||||
import { savedLabel, sizeLabel, sizesFromExecuted } from "../shared/image_size.mjs";
|
||||
|
||||
+160
-28
@@ -9,9 +9,14 @@
|
||||
//
|
||||
// The panel is fully inert (pointer-events: none) so clicks fall through, the
|
||||
// node stays draggable from its body, and wheel zoom belongs to the graph.
|
||||
//
|
||||
// Save Image can also show the picture while it is being made: its `steps`
|
||||
// switch lays each sampling step over the stage, with a Stop button on the
|
||||
// bar (shared/step_preview.mjs).
|
||||
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { panelSwitch } from "../shared/panel_controls.mjs";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { isForeignRun } from "../shared/prompt_scope.mjs";
|
||||
import { executionIdOf, nodeByExecutionId } from "../shared/graph_ids.mjs";
|
||||
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
|
||||
@@ -21,6 +26,7 @@ import { autoMaskValues } from "../shared/mask_auto.mjs";
|
||||
import { hideInputsInDef, hideWidget, setWidgetVisible } from "../shared/widget_visibility.mjs";
|
||||
import { makePictureStepper } from "../shared/picture_stepper.mjs";
|
||||
import { keepIndex, stepIndex } from "../shared/picture_steps.mjs";
|
||||
import { addStepConsumer, mountStepOverlay, showsStepsOn, stepRun, stopStepRun, watchSteps } from "../shared/step_preview.mjs";
|
||||
import {
|
||||
describeNodePreview,
|
||||
describeSourcePreview,
|
||||
@@ -107,7 +113,7 @@ const NODE_CONFIG = {
|
||||
},
|
||||
AUSBOSS_NODES_SelectFrame: { inputName: "frames", noun: "frames" },
|
||||
// Its result is the file it just saved; the card sits above the panel.
|
||||
AUSBOSS_NODES_SaveImage: { inputName: "images", noun: "an image", switchTitle: "Show the saved image here. The file is saved either way." },
|
||||
AUSBOSS_NODES_SaveImage: { inputName: "images", noun: "an image", switchTitle: "Show the saved image here. The file is saved either way.", steps: true },
|
||||
};
|
||||
|
||||
// The picture sits out of flow, centred in the stage, so it can never set
|
||||
@@ -137,12 +143,10 @@ function ensureCss() {
|
||||
.ausboss-input-preview-tool{box-sizing:border-box;height:${BAR_HEIGHT}px;min-width:24px;padding:0 6px;border:1px solid rgba(0,180,170,.52);border-radius:4px;background:rgba(0,0,0,.7);color:#c8dddd;font:700 9px/18px "Segoe UI",sans-serif;cursor:pointer;}
|
||||
.ausboss-input-preview-tool:hover{border-color:${BRAND};color:#fff;background:rgba(0,79,75,.78);}
|
||||
.ausboss-input-preview-tool.active{border-color:${BRAND};color:${BRAND};}
|
||||
.ausboss-input-preview-toggle{display:flex;align-items:center;gap:6px;margin-left:auto;pointer-events:auto;cursor:pointer;user-select:none;}
|
||||
.ausboss-input-preview-toggle span{color:#5f7674;font:10px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.08em;text-transform:uppercase;}
|
||||
.ausboss-input-preview-switch{box-sizing:border-box;width:24px;height:13px;border-radius:7px;border:none;background:#3a4047;position:relative;cursor:pointer;padding:0;flex:none;pointer-events:auto}
|
||||
.ausboss-input-preview-switch::after{content:"";position:absolute;top:2px;left:2px;width:9px;height:9px;border-radius:50%;background:#9ba2aa;transition:left .12s}
|
||||
.ausboss-input-preview-switch.on{background:${BRAND}}
|
||||
.ausboss-input-preview-switch.on::after{left:13px;background:#fff}
|
||||
.ausboss-input-preview:not(.preview-off) .ausboss-panel-switch.steps + .ausboss-panel-switch{margin-left:0;}
|
||||
.ausboss-input-preview.preview-off .ausboss-panel-switch.steps{display:none;}
|
||||
.ausboss-input-preview-tool.ausboss-steps-stop{visibility:hidden;}
|
||||
.ausboss-input-preview-tool.ausboss-steps-stop.live{visibility:visible;pointer-events:auto;}
|
||||
.ausboss-input-preview-readout{box-sizing:border-box;height:${BAR_HEIGHT}px;padding:0 7px;border:1px solid rgba(0,180,170,.5);border-radius:10px;background:rgba(0,180,170,.14);color:${BRAND};font:700 9.5px/18px "Segoe UI",sans-serif;white-space:nowrap;pointer-events:none;}
|
||||
.ausboss-input-preview-readout:empty{display:none;}
|
||||
.ausboss-input-preview-readout.warn{border-color:#c98a2b;background:rgba(201,138,43,.16);color:#ffd9a0;}
|
||||
@@ -288,18 +292,9 @@ function previewEnabled(state) {
|
||||
}
|
||||
|
||||
function makePreviewToggle(state, signal) {
|
||||
const toggle = document.createElement("div");
|
||||
toggle.className = "ausboss-input-preview-toggle";
|
||||
toggle.title = state.switchTitle ?? "Show this node's result here. Off also skips writing the preview file to the temp folder.";
|
||||
const caption = document.createElement("span");
|
||||
caption.textContent = "preview";
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "ausboss-input-preview-switch";
|
||||
button.setAttribute("role", "switch");
|
||||
toggle.append(caption, button);
|
||||
toggle.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
|
||||
toggle.addEventListener("click", (event) => {
|
||||
const button = panelSwitch("Preview", state.switchTitle ?? "Show this node's result here. Off also skips writing the preview file to the temp folder.");
|
||||
button.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const next = !previewEnabled(state);
|
||||
state.previewWidget.value = next;
|
||||
@@ -308,7 +303,7 @@ function makePreviewToggle(state, signal) {
|
||||
notifyAusbossChange();
|
||||
}, { signal });
|
||||
state.switchButton = button;
|
||||
return toggle;
|
||||
return button;
|
||||
}
|
||||
|
||||
function installPreviewSwitch(state, signal) {
|
||||
@@ -325,11 +320,126 @@ function installPreviewSwitch(state, signal) {
|
||||
syncPreviewMode(state, false);
|
||||
}
|
||||
|
||||
// The steps switch, left of the preview switch: the hidden `step_preview`
|
||||
// widget. On, the stage shows each step of the picture while it is being
|
||||
// made and the bar gets a Stop button for the run. Off (the default), the
|
||||
// node is exactly as it was. It goes away with the preview, which is where
|
||||
// the steps would show.
|
||||
function stepsEnabled(state) {
|
||||
return state.stepsWidget?.value === true && previewEnabled(state);
|
||||
}
|
||||
|
||||
function installStepsSwitch(state, signal) {
|
||||
const widget = widgetByName(state.node, "step_preview");
|
||||
if (!widget) return;
|
||||
state.stepsWidget = widget;
|
||||
hideWidget(widget);
|
||||
const button = panelSwitch("Steps", "Show the picture here while it is being made, one step at a time, with a Stop button. Off by default.");
|
||||
button.classList.add("steps");
|
||||
button.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const next = widget.value !== true;
|
||||
widget.value = next;
|
||||
widget.callback?.(next);
|
||||
syncSteps(state);
|
||||
notifyAusbossChange();
|
||||
}, { signal });
|
||||
state.stepsButton = button;
|
||||
const previewToggle = state.switchButton;
|
||||
if (previewToggle?.parentElement === state.bar) state.bar.insertBefore(button, previewToggle);
|
||||
else state.bar.append(button);
|
||||
|
||||
// Stop sits where the node's tools go; it keeps its place while hidden so
|
||||
// the bar never shifts when a run starts.
|
||||
const stop = document.createElement("button");
|
||||
stop.type = "button";
|
||||
stop.className = "ausboss-input-preview-tool ausboss-steps-stop";
|
||||
stop.textContent = "STOP";
|
||||
stop.title = "Stop this run now. The next run in the queue still starts.";
|
||||
stop.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
|
||||
stop.addEventListener("click", (event) => {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
Promise.resolve(stopStepRun()).catch(() => toast(state, "Could not stop the run."));
|
||||
}, { signal });
|
||||
state.stopButton = stop;
|
||||
state.bar.prepend(stop);
|
||||
|
||||
state.stepOverlay = mountStepOverlay(state.stage);
|
||||
state.forgetSteps = [
|
||||
addStepConsumer(state.node, () => stepsEnabled(state)),
|
||||
watchSteps((run) => syncSteps(state, run)),
|
||||
];
|
||||
chainCallback(state.node, "onConfigure", () => queueMicrotask(() => {
|
||||
if (!state.alive) return;
|
||||
hideWidget(widget);
|
||||
syncSteps(state);
|
||||
}));
|
||||
}
|
||||
|
||||
// Draw the run on this node: its steps while they feed it, where it stopped
|
||||
// if it never got to save, and the saved picture again once it has.
|
||||
function syncSteps(state, run = stepRun()) {
|
||||
const overlay = state.stepOverlay;
|
||||
if (!overlay) return;
|
||||
// An empty stage says when the run is on its way, not to start one.
|
||||
if (typeof state.placeholder === "boolean") state.hint.textContent = placeholderText(state.placeholder, state.noun, runningFor(state, run));
|
||||
const on = stepsEnabled(state);
|
||||
if (state.stepsButton) {
|
||||
state.stepsButton.setAttribute("aria-checked", String(state.stepsWidget?.value === true));
|
||||
}
|
||||
state.stopButton?.classList.toggle("live", on && run.status === "running");
|
||||
if (!on) {
|
||||
overlay.hide();
|
||||
return;
|
||||
}
|
||||
const thisRun = (id) => id !== null && id !== undefined && String(id) === String(run.promptId);
|
||||
if (run.status === "running") {
|
||||
// Saved already in this run: its own picture stays.
|
||||
if (thisRun(state.stepsSaved)) return;
|
||||
if (run.url && showsStepsOn(state.node, run)) {
|
||||
state.stepsShown = run;
|
||||
overlay.show(run, { saves: true });
|
||||
} else if (!thisRun(state.stepsShown?.promptId)) {
|
||||
overlay.hide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (run.status === "stopped" && thisRun(state.stepsShown?.promptId) && !thisRun(state.stepsSaved)) {
|
||||
overlay.show({ ...state.stepsShown, status: "stopped" }, { saves: true });
|
||||
return;
|
||||
}
|
||||
// The last step stays up until the saved picture has loaded under it.
|
||||
if (thisRun(state.stepsHeld)) return;
|
||||
overlay.hide();
|
||||
}
|
||||
|
||||
// What a run on its way says on an empty stage: false when none is.
|
||||
function runningFor(state, run = stepRun()) {
|
||||
if (!state.stepOverlay || run.status !== "running") return false;
|
||||
return stepsEnabled(state) ? "steps" : "result";
|
||||
}
|
||||
|
||||
// Saved while the steps were up: hold them until the stage has its picture,
|
||||
// or a few seconds at most.
|
||||
const HOLD_MS = 4000;
|
||||
function holdSteps(state) {
|
||||
clearTimeout(state.holdTimer);
|
||||
state.stepsHeld = stepRun().promptId;
|
||||
state.holdTimer = setTimeout(() => releaseSteps(state), HOLD_MS);
|
||||
}
|
||||
|
||||
function releaseSteps(state) {
|
||||
if (state.stepsHeld === null || state.stepsHeld === undefined) return;
|
||||
clearTimeout(state.holdTimer);
|
||||
state.stepsHeld = null;
|
||||
syncSteps(state);
|
||||
}
|
||||
|
||||
function syncPreviewMode(state, resize) {
|
||||
const enabled = previewEnabled(state);
|
||||
state.root.classList.toggle("preview-off", !enabled);
|
||||
if (state.switchButton) {
|
||||
state.switchButton.classList.toggle("on", enabled);
|
||||
state.switchButton.setAttribute("aria-checked", String(enabled));
|
||||
}
|
||||
if (!enabled) {
|
||||
@@ -355,6 +465,7 @@ function syncPreviewMode(state, resize) {
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
if (enabled && state.alive) scheduleRefresh(state);
|
||||
syncSteps(state);
|
||||
}
|
||||
|
||||
function buildTools(state, tools, signal) {
|
||||
@@ -383,11 +494,21 @@ function buildTools(state, tools, signal) {
|
||||
function showHint(state, text) {
|
||||
state.stage.classList.remove("show-image", "show-video");
|
||||
state.hint.textContent = text;
|
||||
state.placeholder = null;
|
||||
releaseSteps(state);
|
||||
}
|
||||
|
||||
// The placeholder, which changes with the run (syncSteps).
|
||||
function showPlaceholder(state, connected) {
|
||||
showHint(state, placeholderText(connected, state.noun, runningFor(state)));
|
||||
state.placeholder = connected;
|
||||
}
|
||||
|
||||
function showMedia(state, kind) {
|
||||
state.stage.classList.toggle("show-image", kind === "image");
|
||||
state.stage.classList.toggle("show-video", kind === "video");
|
||||
state.placeholder = null;
|
||||
releaseSteps(state);
|
||||
}
|
||||
|
||||
function clearMedia(state) {
|
||||
@@ -487,7 +608,7 @@ function refresh(state) {
|
||||
: describeNodePreview(state.node, state.inputName, storedResultUrl(state.node));
|
||||
if (!described) {
|
||||
clearMedia(state);
|
||||
showHint(state, placeholderText(!!source, state.noun));
|
||||
showPlaceholder(state, !!source);
|
||||
return;
|
||||
}
|
||||
const url = described.kind === "url"
|
||||
@@ -610,6 +731,8 @@ function buildPanel(node, config) {
|
||||
previewWidget,
|
||||
index: 0,
|
||||
stepper: null,
|
||||
// Whether the stage shows the placeholder (true: something is connected).
|
||||
placeholder: null,
|
||||
};
|
||||
livePanels.add(state);
|
||||
if (config.tools?.length) bar.append(buildTools(state, config.tools, abort.signal));
|
||||
@@ -629,18 +752,19 @@ function buildPanel(node, config) {
|
||||
bar.append(state.stepper.root);
|
||||
syncAdvanced(state);
|
||||
installPreviewSwitch(state, abort.signal);
|
||||
if (config.steps) installStepsSwitch(state, abort.signal);
|
||||
|
||||
img.addEventListener("load", () => showMedia(state, "image"));
|
||||
img.addEventListener("error", () => {
|
||||
if (img.getAttribute("src")) showHint(state, placeholderText(true, state.noun));
|
||||
if (img.getAttribute("src")) showPlaceholder(state, true);
|
||||
});
|
||||
video.addEventListener("loadeddata", () => showMedia(state, "video"));
|
||||
video.addEventListener("error", () => {
|
||||
if (video.getAttribute("src")) showHint(state, placeholderText(true, state.noun));
|
||||
if (video.getAttribute("src")) showPlaceholder(state, true);
|
||||
});
|
||||
|
||||
watchOwnResult(node, state);
|
||||
showHint(state, placeholderText(false, state.noun));
|
||||
showPlaceholder(state, false);
|
||||
scheduleRefresh(state);
|
||||
return state;
|
||||
}
|
||||
@@ -691,7 +815,7 @@ app.registerExtension({
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
const config = NODE_CONFIG[nodeData?.name];
|
||||
if (!config) return;
|
||||
hideInputsInDef(nodeData, ["preview"]);
|
||||
hideInputsInDef(nodeData, ["preview", "step_preview"]);
|
||||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||
buildPanel(this, config);
|
||||
});
|
||||
@@ -716,6 +840,12 @@ app.registerExtension({
|
||||
const state = buildPanel(this, config);
|
||||
// A result arrived (Save Image sends one even with its preview off).
|
||||
if (state) { state.stale = false; state.resultGone = false; scheduleRefresh(state); }
|
||||
// Saved: the file's picture replaces the steps.
|
||||
if (state?.stepOverlay) {
|
||||
state.stepsSaved = stepRun().promptId;
|
||||
if (state.stepOverlay.shown) holdSteps(state);
|
||||
syncSteps(state);
|
||||
}
|
||||
if (state && config.readout) {
|
||||
setNote(state, "");
|
||||
const readout = config.readout(output);
|
||||
@@ -733,6 +863,8 @@ app.registerExtension({
|
||||
state.abort?.abort();
|
||||
unwatchSource(state);
|
||||
clearMedia(state);
|
||||
for (const forget of state.forgetSteps ?? []) forget();
|
||||
clearTimeout(state.holdTimer);
|
||||
this.__ausbossInputPreview = null;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
// it. Ctrl (or Alt) while dragging leaves the grid off. The grid starts off
|
||||
// over a picture on `background`; a click on Grid is saved with the node.
|
||||
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange, showToast } from "../shared/index.mjs";
|
||||
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
|
||||
import { describeSourcePreview, outputLocatorId } from "../shared/input_preview.mjs";
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { BRAND, notifyAusbossChange, showToast } from "../shared/index.mjs";
|
||||
import { enoughFor, lineUp, matchWidth, sameSize, sharedSides, spreadEvenly } from "../shared/line_up.mjs";
|
||||
import { outlineOf } from "../shared/outline.mjs";
|
||||
|
||||
// Line up: with two or more nodes or groups selected, ComfyUI's selection
|
||||
// toolbar gets one button that pops out a small panel. The panel lines the
|
||||
// selection up, gives it one width or height, or spaces it evenly. Every tool
|
||||
// is also a command, so it can be found in the command palette and given a key.
|
||||
//
|
||||
// Resize together: with two or more nodes selected, pulling the edge or corner
|
||||
// of one resizes them all, in both looks. A column shares the width, a row
|
||||
// shares the height, and a grid shares both.
|
||||
//
|
||||
// A node inside a selected group moves with its group, the way a drag moves
|
||||
// it. Pinned items count when the selection is measured but never move.
|
||||
|
||||
const PANEL = "AusBoss.LineUp.Panel";
|
||||
const SETTING = "AusBoss.Canvas.LineUpButton";
|
||||
const TOGETHER_SETTING = "AusBoss.Canvas.ResizeTogether";
|
||||
const ICON_CLASS = "ausboss-line-up-icon";
|
||||
|
||||
const TOOLS = [
|
||||
{ kind: "lineUp", how: "left", id: "AusBoss.LineUp.Left", label: "Line up left edges" },
|
||||
{ kind: "lineUp", how: "middleX", id: "AusBoss.LineUp.MiddleX", label: "Line up middles in a column" },
|
||||
{ kind: "lineUp", how: "right", id: "AusBoss.LineUp.Right", label: "Line up right edges" },
|
||||
{ kind: "lineUp", how: "top", id: "AusBoss.LineUp.Top", label: "Line up top edges" },
|
||||
{ kind: "lineUp", how: "middleY", id: "AusBoss.LineUp.MiddleY", label: "Line up middles in a row" },
|
||||
{ kind: "lineUp", how: "bottom", id: "AusBoss.LineUp.Bottom", label: "Line up bottom edges" },
|
||||
{ kind: "size", how: "widest", id: "AusBoss.LineUp.Widest", label: "Same width as the widest" },
|
||||
{ kind: "size", how: "narrowest", id: "AusBoss.LineUp.Narrowest", label: "Same width as the narrowest" },
|
||||
{ kind: "size", how: "tallest", id: "AusBoss.LineUp.Tallest", label: "Same height as the tallest" },
|
||||
{ kind: "size", how: "shortest", id: "AusBoss.LineUp.Shortest", label: "Same height as the shortest" },
|
||||
{ kind: "spacing", how: "across", id: "AusBoss.LineUp.SpaceAcross", label: "Even spacing across" },
|
||||
{ kind: "spacing", how: "down", id: "AusBoss.LineUp.SpaceDown", label: "Even spacing down" },
|
||||
];
|
||||
|
||||
const SECTIONS = [
|
||||
{ title: "Line up", kind: "lineUp" },
|
||||
{ title: "Same size", kind: "size" },
|
||||
{ title: "Even spacing", kind: "spacing" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icons: grey boxes in the text color, and the line or arrows they line up on
|
||||
// in the pack's teal, the color of the snap lines.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const box = (x, y, w, h) => ({ x, y, w, h });
|
||||
const turn = (parts) => ({
|
||||
boxes: parts.boxes.map(({ x, y, w, h }) => box(y, x, h, w)),
|
||||
lines: parts.lines.map(([x1, y1, x2, y2]) => [y1, x1, y2, x2]),
|
||||
heads: parts.heads.map(([x, y, dir]) => [y, x, { l: "u", r: "d", u: "l", d: "r" }[dir]]),
|
||||
});
|
||||
|
||||
const WIDEST = {
|
||||
boxes: [box(3, 4, 18, 5), box(8, 15, 8, 5)],
|
||||
lines: [[7, 12, 3.5, 12], [17, 12, 20.5, 12]],
|
||||
heads: [[3.5, 12, "l"], [20.5, 12, "r"]],
|
||||
};
|
||||
const NARROWEST = {
|
||||
boxes: [box(8, 4, 8, 5), box(3, 15, 18, 5)],
|
||||
lines: [[3, 12, 7, 12], [21, 12, 17, 12]],
|
||||
heads: [[7, 12, "r"], [17, 12, "l"]],
|
||||
};
|
||||
const ACROSS = {
|
||||
boxes: [box(2, 7, 4.5, 10), box(9.75, 7, 4.5, 10), box(17.5, 7, 4.5, 10)],
|
||||
lines: [[7.5, 12, 8.75, 12], [15.25, 12, 16.5, 12], [7.5, 9.5, 7.5, 14.5], [8.75, 9.5, 8.75, 14.5], [15.25, 9.5, 15.25, 14.5], [16.5, 9.5, 16.5, 14.5]],
|
||||
heads: [],
|
||||
};
|
||||
|
||||
const ICONS = {
|
||||
left: { boxes: [box(7, 5, 13, 5), box(7, 14, 8, 5)], lines: [[4, 3, 4, 21]], heads: [] },
|
||||
middleX: { boxes: [box(5, 5, 14, 5), box(8, 14, 8, 5)], lines: [[12, 3, 12, 21]], heads: [] },
|
||||
right: { boxes: [box(4, 5, 13, 5), box(9, 14, 8, 5)], lines: [[20, 3, 20, 21]], heads: [] },
|
||||
top: { boxes: [box(5, 7, 5, 13), box(14, 7, 5, 8)], lines: [[3, 4, 21, 4]], heads: [] },
|
||||
middleY: { boxes: [box(5, 5, 5, 14), box(14, 8, 5, 8)], lines: [[3, 12, 21, 12]], heads: [] },
|
||||
bottom: { boxes: [box(5, 4, 5, 13), box(14, 9, 5, 8)], lines: [[3, 20, 21, 20]], heads: [] },
|
||||
widest: WIDEST,
|
||||
narrowest: NARROWEST,
|
||||
tallest: turn(WIDEST),
|
||||
shortest: turn(NARROWEST),
|
||||
across: ACROSS,
|
||||
down: turn(ACROSS),
|
||||
};
|
||||
|
||||
function headPath([x, y, dir]) {
|
||||
const s = 2.6;
|
||||
const points = {
|
||||
l: [[x + s, y - s], [x, y], [x + s, y + s]],
|
||||
r: [[x - s, y - s], [x, y], [x - s, y + s]],
|
||||
u: [[x - s, y + s], [x, y], [x + s, y + s]],
|
||||
d: [[x - s, y - s], [x, y], [x + s, y - s]],
|
||||
}[dir];
|
||||
return `M${points.map((p) => p.join(" ")).join(" L")}`;
|
||||
}
|
||||
|
||||
function boxesSvg(parts, color, fillOpacity) {
|
||||
return parts.boxes
|
||||
.map(({ x, y, w, h }) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="1.4" fill="${color}" fill-opacity="${fillOpacity}" stroke="${color}" stroke-width="1.5"/>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function marksSvg(parts, color) {
|
||||
const lines = parts.lines.map(([x1, y1, x2, y2]) => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"/>`).join("");
|
||||
const heads = parts.heads.map((head) => `<path d="${headPath(head)}" fill="none"/>`).join("");
|
||||
return `<g stroke="${color}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${lines}${heads}</g>`;
|
||||
}
|
||||
|
||||
function iconSvg(how) {
|
||||
const parts = ICONS[how];
|
||||
return `<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">${boxesSvg(parts, "currentColor", 0.2)}${marksSvg(parts, BRAND)}</svg>`;
|
||||
}
|
||||
|
||||
// The toolbar button is an <i> with a class, so its icon is drawn in CSS:
|
||||
// the boxes take the button's text color, the line stays teal.
|
||||
function maskUrl(body) {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">${body}</svg>`;
|
||||
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||
}
|
||||
|
||||
function styleSheet() {
|
||||
const parts = ICONS.left;
|
||||
const boxes = maskUrl(boxesSvg(parts, "black", 0.25));
|
||||
const marks = maskUrl(marksSvg(parts, "black"));
|
||||
return `
|
||||
.${ICON_CLASS} { position: relative; display: inline-block; width: 1rem; height: 1rem; }
|
||||
.${ICON_CLASS}::before, .${ICON_CLASS}::after {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
-webkit-mask: var(--ausboss-mask) center / contain no-repeat; mask: var(--ausboss-mask) center / contain no-repeat;
|
||||
}
|
||||
.${ICON_CLASS}::before { --ausboss-mask: ${boxes}; background: currentColor; }
|
||||
.${ICON_CLASS}::after { --ausboss-mask: ${marks}; background: ${BRAND}; }
|
||||
.ausboss-line-up {
|
||||
position: fixed; z-index: 1000; padding: 6px 6px 4px; border-radius: 10px;
|
||||
background: var(--interface-panel-surface, #1f2225); color: var(--base-foreground, #e6edf3);
|
||||
border: 1px solid var(--interface-stroke, #3a3f44); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
font-size: 11px; line-height: 1.2; user-select: none;
|
||||
}
|
||||
.ausboss-line-up-title { margin: 2px 6px 3px; color: var(--muted-foreground, #8b949e); }
|
||||
.ausboss-line-up-row { display: flex; gap: 2px; }
|
||||
.ausboss-line-up-rule { height: 1px; margin: 5px 2px; background: var(--interface-stroke, #30363d); }
|
||||
.ausboss-line-up button {
|
||||
display: grid; place-items: center; width: 30px; height: 30px; padding: 0;
|
||||
border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer;
|
||||
}
|
||||
.ausboss-line-up button:hover:not(:disabled) { background: color-mix(in srgb, ${BRAND} 18%, transparent); }
|
||||
.ausboss-line-up button:focus-visible { outline: 1px solid ${BRAND}; }
|
||||
.ausboss-line-up button:disabled { opacity: 0.35; cursor: default; }
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function graphSets(graph) {
|
||||
return {
|
||||
nodes: new Set(graph?._nodes ?? graph?.nodes ?? []),
|
||||
groups: new Set(graph?._groups ?? graph?.groups ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
function pickedCount(canvas) {
|
||||
const { nodes, groups } = graphSets(canvas?.graph);
|
||||
let count = 0;
|
||||
for (const item of canvas?.selectedItems ?? []) if (nodes.has(item) || groups.has(item)) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
// The selected nodes and groups that move on their own, each with the
|
||||
// outline people see. A node or group inside a selected group is left out:
|
||||
// it rides along with that group.
|
||||
function selectionUnits(canvas) {
|
||||
const { nodes, groups } = graphSets(canvas?.graph);
|
||||
const picked = [...(canvas?.selectedItems ?? [])].filter((item) => nodes.has(item) || groups.has(item));
|
||||
const carried = new Set();
|
||||
for (const item of picked) {
|
||||
if (!groups.has(item)) continue;
|
||||
item.recomputeInsideNodes?.();
|
||||
for (const child of item.children ?? item._children ?? []) carried.add(child);
|
||||
}
|
||||
const units = [];
|
||||
for (const item of picked) {
|
||||
if (carried.has(item)) continue;
|
||||
const group = groups.has(item);
|
||||
if (!group) item.updateArea?.(canvas.ctx); // fresh after a size change this frame
|
||||
const rect = outlineOf(item, canvas, { node: !group });
|
||||
if (rect) units.push({ item, group, rect });
|
||||
}
|
||||
return units;
|
||||
}
|
||||
|
||||
function isPinned(item) {
|
||||
return item.pinned === true || item.flags?.pinned === true;
|
||||
}
|
||||
|
||||
function moveUnit({ item, group, rect }, { x, y }) {
|
||||
const dx = x - rect.x;
|
||||
const dy = y - rect.y;
|
||||
if ((!dx && !dy) || isPinned(item)) return;
|
||||
if (group) item.move(dx, dy);
|
||||
else item.pos = [item.pos[0] + dx, item.pos[1] + dy];
|
||||
}
|
||||
|
||||
function resizeUnit({ item, group, rect }, { w, h }) {
|
||||
if (isPinned(item) || item.flags?.collapsed) return;
|
||||
if (w === rect.w && h === rect.h) return;
|
||||
if (group) {
|
||||
item.resize(w, h);
|
||||
return;
|
||||
}
|
||||
// A node's outline includes its title bar and any frame drawn around it;
|
||||
// its size does not, so set the size that gives the outline asked for.
|
||||
const extraW = rect.w - item.size[0];
|
||||
const extraH = rect.h - item.size[1];
|
||||
const least = item.computeSize?.() ?? [0, 0];
|
||||
item.setSize([Math.max(w - extraW, least[0]), Math.max(h - extraH, least[1])]);
|
||||
}
|
||||
|
||||
function run(tool) {
|
||||
const canvas = app.canvas;
|
||||
if (!canvas?.graph) return;
|
||||
const units = selectionUnits(canvas);
|
||||
if (!enoughFor(tool.kind, units.length)) {
|
||||
showToast({ detail: `Select ${tool.kind === "spacing" ? "three" : "two"} or more nodes or groups first.`, life: 3000 });
|
||||
return;
|
||||
}
|
||||
const rects = units.map((unit) => unit.rect);
|
||||
if (tool.kind === "size") {
|
||||
const sizes = sameSize(rects, tool.how);
|
||||
units.forEach((unit, index) => resizeUnit(unit, sizes[index]));
|
||||
} else {
|
||||
const places = tool.kind === "lineUp" ? lineUp(rects, tool.how) : spreadEvenly(rects, tool.how);
|
||||
units.forEach((unit, index) => moveUnit(unit, places[index]));
|
||||
}
|
||||
canvas.setDirty?.(true, true);
|
||||
notifyAusbossChange();
|
||||
refreshPanel(true);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resize together
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The frontend resizes only the node under the pointer: the canvas's own
|
||||
// pointermove in the classic look, a pointermove on the window in Nodes 2.0.
|
||||
// This listens on the window and catches up a frame later, so it always reads
|
||||
// the size the frontend (and Snap into line) settled on. Which sizes are
|
||||
// shared comes from how the selection was laid out when the button went down
|
||||
// (sharedSides in js/shared/line_up.mjs). The grabbed node is
|
||||
// the canvas's resizing node, or in Nodes 2.0 the node whose corner handle
|
||||
// took the press. Every other selected node takes its size from where it
|
||||
// stood when the button went down, so nothing builds up across steps.
|
||||
|
||||
const together = { enabled: true, before: null, grabbed: null, handleOf: null, sides: null, frame: 0 };
|
||||
|
||||
function resizableSelection(canvas) {
|
||||
const { nodes } = graphSets(canvas?.graph);
|
||||
return [...(canvas?.selectedItems ?? [])].filter(
|
||||
(item) => nodes.has(item) && item.size && item.pos && !isPinned(item) && !item.flags?.collapsed,
|
||||
);
|
||||
}
|
||||
|
||||
function onTogetherDown(event) {
|
||||
endTogether();
|
||||
if (event.button !== 0 || !together.enabled) return;
|
||||
const picked = resizableSelection(app.canvas);
|
||||
if (picked.length < 2) return;
|
||||
together.before = new Map(picked.map((node) => [node, { pos: [node.pos[0], node.pos[1]], size: [node.size[0], node.size[1]] }]));
|
||||
// Nodes 2.0 draws a corner handle on each node (a div with data-corner).
|
||||
const handle = event.target?.closest?.("[data-corner]");
|
||||
together.handleOf = handle?.closest?.("[data-node-id]")?.dataset?.nodeId ?? null;
|
||||
}
|
||||
|
||||
function grabbedNode(canvas) {
|
||||
const classic = canvas?.resizing_node;
|
||||
if (classic) return together.before.has(classic) ? classic : null;
|
||||
if (together.handleOf == null) return null;
|
||||
for (const node of together.before.keys()) if (String(node.id) === together.handleOf) return node;
|
||||
return null;
|
||||
}
|
||||
|
||||
function syncSizes() {
|
||||
const canvas = app.canvas;
|
||||
if (!together.before || !canvas?.graph) return;
|
||||
together.grabbed ??= grabbedNode(canvas);
|
||||
const grabbed = together.grabbed;
|
||||
if (!grabbed) return;
|
||||
const was = together.before.get(grabbed);
|
||||
together.sides ??= sharedSides(
|
||||
[...together.before.values()].map(({ pos, size }) => ({ x: pos[0], y: pos[1], w: size[0], h: size[1] })),
|
||||
);
|
||||
const others = [...together.before].filter(([node]) => node !== grabbed);
|
||||
const least = others.map(([node]) => node.computeSize?.() ?? [0, 0]);
|
||||
// Heights go through the same rule as widths, with y for x.
|
||||
const widths = together.sides.width
|
||||
? matchWidth(
|
||||
grabbed.size[0],
|
||||
others.map(([, start], i) => ({ x: start.pos[0], w: start.size[0], least: least[i][0] })),
|
||||
{ fromLeft: grabbed.pos[0] !== was.pos[0] },
|
||||
)
|
||||
: null;
|
||||
const heights = together.sides.height
|
||||
? matchWidth(
|
||||
grabbed.size[1],
|
||||
others.map(([, start], i) => ({ x: start.pos[1], w: start.size[1], least: least[i][1] })),
|
||||
{ fromLeft: grabbed.pos[1] !== was.pos[1] },
|
||||
)
|
||||
: null;
|
||||
let moved = false;
|
||||
others.forEach(([node], index) => {
|
||||
const x = widths ? widths[index].x : node.pos[0];
|
||||
const y = heights ? heights[index].x : node.pos[1];
|
||||
const w = widths ? widths[index].w : node.size[0];
|
||||
const h = heights ? heights[index].w : node.size[1];
|
||||
if (node.pos[0] !== x || node.pos[1] !== y) {
|
||||
node.pos = [x, y];
|
||||
moved = true;
|
||||
}
|
||||
if (node.size[0] !== w || node.size[1] !== h) {
|
||||
node.setSize([w, h]);
|
||||
moved = true;
|
||||
}
|
||||
});
|
||||
if (moved) canvas.setDirty?.(true, true);
|
||||
}
|
||||
|
||||
function onTogetherMove(event) {
|
||||
if (!together.before) return;
|
||||
if (!(event.buttons & 1)) return endTogether();
|
||||
if (together.frame) return;
|
||||
together.frame = requestAnimationFrame(() => {
|
||||
together.frame = 0;
|
||||
syncSizes();
|
||||
});
|
||||
}
|
||||
|
||||
function onTogetherUp() {
|
||||
if (together.before) syncSizes(); // the last step, before the frontend records the change
|
||||
endTogether();
|
||||
}
|
||||
|
||||
function endTogether() {
|
||||
if (together.frame) cancelAnimationFrame(together.frame);
|
||||
together.frame = 0;
|
||||
together.before = null;
|
||||
together.grabbed = null;
|
||||
together.handleOf = null;
|
||||
together.sides = null;
|
||||
}
|
||||
|
||||
function installTogether() {
|
||||
window.addEventListener("pointerdown", onTogetherDown, true);
|
||||
window.addEventListener("pointermove", onTogetherMove);
|
||||
// Capture phase: the classic canvas stops its pointerup from bubbling.
|
||||
window.addEventListener("pointerup", onTogetherUp, true);
|
||||
window.addEventListener("pointercancel", endTogether, true);
|
||||
window.addEventListener("blur", endTogether);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const panel = { el: null, frame: 0, buttons: [], count: -1 };
|
||||
|
||||
function anchorButton() {
|
||||
return document.querySelector(`.selection-toolbox .${ICON_CLASS}`)?.closest("button") ?? null;
|
||||
}
|
||||
|
||||
function labelAnchor() {
|
||||
const button = anchorButton();
|
||||
if (!button || button.title) return;
|
||||
button.title = "Line up, same size, even spacing";
|
||||
button.setAttribute("aria-label", button.title);
|
||||
}
|
||||
|
||||
function buildPanel() {
|
||||
const el = document.createElement("div");
|
||||
el.className = "ausboss-line-up";
|
||||
el.setAttribute("role", "toolbar");
|
||||
el.setAttribute("aria-label", "Line up the selection");
|
||||
panel.buttons = [];
|
||||
SECTIONS.forEach((section, index) => {
|
||||
if (index) el.append(Object.assign(document.createElement("div"), { className: "ausboss-line-up-rule" }));
|
||||
el.append(Object.assign(document.createElement("div"), { className: "ausboss-line-up-title", textContent: section.title }));
|
||||
const row = Object.assign(document.createElement("div"), { className: "ausboss-line-up-row" });
|
||||
for (const tool of TOOLS.filter((t) => t.kind === section.kind)) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.title = tool.label;
|
||||
button.setAttribute("aria-label", tool.label);
|
||||
button.innerHTML = iconSvg(tool.how);
|
||||
button.addEventListener("click", () => run(tool));
|
||||
panel.buttons.push({ button, tool });
|
||||
row.append(button);
|
||||
}
|
||||
el.append(row);
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function refreshPanel(force = false) {
|
||||
if (!panel.el) return;
|
||||
const count = pickedCount(app.canvas);
|
||||
if (!force && count === panel.count) return;
|
||||
panel.count = count;
|
||||
const units = selectionUnits(app.canvas).length;
|
||||
for (const { button, tool } of panel.buttons) button.disabled = !enoughFor(tool.kind, units);
|
||||
}
|
||||
|
||||
// Above the selection toolbar, or below it when there is no room above. The
|
||||
// toolbar follows the selection, so the panel follows the toolbar.
|
||||
function placePanel() {
|
||||
const anchor = anchorButton();
|
||||
if (!panel.el || !anchor) return closePanel();
|
||||
const bar = (anchor.closest(".selection-toolbox") ?? anchor).getBoundingClientRect();
|
||||
const at = anchor.getBoundingClientRect();
|
||||
const size = panel.el.getBoundingClientRect();
|
||||
const left = Math.min(Math.max(8, at.left + at.width / 2 - size.width / 2), window.innerWidth - size.width - 8);
|
||||
let top = bar.top - size.height - 6;
|
||||
if (top < 8) top = bar.bottom + 6;
|
||||
panel.el.style.left = `${Math.round(left)}px`;
|
||||
panel.el.style.top = `${Math.round(top)}px`;
|
||||
refreshPanel();
|
||||
}
|
||||
|
||||
function follow() {
|
||||
panel.frame = 0;
|
||||
if (!panel.el) return;
|
||||
placePanel();
|
||||
if (panel.el) panel.frame = requestAnimationFrame(follow);
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
if (panel.el || !anchorButton()) return;
|
||||
panel.el = buildPanel();
|
||||
panel.count = -1;
|
||||
document.body.append(panel.el);
|
||||
window.addEventListener("pointerdown", onOutside, true);
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
follow();
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
if (!panel.el) return;
|
||||
panel.el.remove();
|
||||
panel.el = null;
|
||||
panel.buttons = [];
|
||||
if (panel.frame) cancelAnimationFrame(panel.frame);
|
||||
panel.frame = 0;
|
||||
window.removeEventListener("pointerdown", onOutside, true);
|
||||
window.removeEventListener("keydown", onKey, true);
|
||||
}
|
||||
|
||||
function onOutside(event) {
|
||||
if (!panel.el || panel.el.contains(event.target)) return;
|
||||
if (anchorButton()?.contains(event.target)) return; // its own click closes the panel
|
||||
closePanel();
|
||||
}
|
||||
|
||||
function onKey(event) {
|
||||
if (event.key === "Escape") closePanel();
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
if (panel.el) closePanel();
|
||||
else openPanel();
|
||||
}
|
||||
|
||||
function buttonOn() {
|
||||
return app.ui?.settings?.getSettingValue?.(SETTING) !== false;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "AusBoss.LineUp",
|
||||
commands: [
|
||||
{ id: PANEL, label: "AusBoss: Line up, same size, even spacing", icon: ICON_CLASS, function: togglePanel },
|
||||
...TOOLS.map((tool) => ({ id: tool.id, label: `AusBoss: ${tool.label}`, function: () => run(tool) })),
|
||||
],
|
||||
getSelectionToolboxCommands() {
|
||||
if (!buttonOn() || pickedCount(app.canvas) < 2) return [];
|
||||
// The toolbar names extension buttons from its own translations, which
|
||||
// have no entry for this one; name it once it is on the page.
|
||||
requestAnimationFrame(labelAnchor);
|
||||
return [PANEL];
|
||||
},
|
||||
settings: [
|
||||
{
|
||||
id: SETTING,
|
||||
name: "Line-up button on the selection toolbar",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
category: ["🆎 AusBoss", "Canvas", "Line-up button"],
|
||||
tooltip:
|
||||
"With two or more nodes or groups selected, the toolbar over them gets a button " +
|
||||
"that lines them up, gives them one size or spaces them evenly.",
|
||||
onChange(value) {
|
||||
if (value === false) closePanel();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: TOGETHER_SETTING,
|
||||
name: "Resize selected nodes together",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
category: ["🆎 AusBoss", "Canvas", "Resize together"],
|
||||
tooltip:
|
||||
"With two or more nodes selected, pulling the edge or corner of one resizes them all: " +
|
||||
"a column gets one width, a row one height, a grid both.",
|
||||
onChange(value) {
|
||||
together.enabled = value !== false;
|
||||
if (!together.enabled) endTogether();
|
||||
},
|
||||
},
|
||||
],
|
||||
setup() {
|
||||
const style = document.createElement("style");
|
||||
style.id = "ausboss-line-up-style";
|
||||
style.textContent = styleSheet();
|
||||
document.head.append(style);
|
||||
// onChange only fires on later edits on some frontends, so read the
|
||||
// stored value here.
|
||||
together.enabled = app.ui?.settings?.getSettingValue?.(TOGETHER_SETTING) !== false;
|
||||
installTogether();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// Load Audio's window rules for the face, mirroring audio_window in
|
||||
// nodes/_audio_trim_helpers.py so the wave shows exactly what one Run hands
|
||||
// on. Pure: tests/load_audio_window.test.mjs runs it without a browser.
|
||||
|
||||
function finite(value, fallback = 0) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
}
|
||||
|
||||
// The pick, and what comes out of it. `end` 0 means "to the end of the
|
||||
// file". A max above 0 keeps the start and cuts off anything past
|
||||
// start + max. All times in seconds; duration is the file's length.
|
||||
export function outputWindow(durationValue, startValue, endValue, maxValue = 0) {
|
||||
const duration = Math.max(0, finite(durationValue));
|
||||
const start = Math.min(Math.max(0, finite(startValue)), duration);
|
||||
const pickedEnd = finite(endValue) > 0 ? Math.min(finite(endValue), duration) : duration;
|
||||
const end = Math.max(start, pickedEnd);
|
||||
const max = Math.max(0, finite(maxValue));
|
||||
const keepEnd = max > 0 && end - start > max ? Math.min(duration, start + max) : end;
|
||||
return { start, end, keepEnd, length: keepEnd - start, cut: Math.max(0, end - keepEnd) };
|
||||
}
|
||||
|
||||
// "0:12.0" style, the way the IN and OUT fields write a time.
|
||||
export function secondsLabel(value) {
|
||||
const seconds = Math.max(0, finite(value));
|
||||
if (seconds < 60) return `${seconds.toFixed(1)} s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${minutes}:${(seconds - minutes * 60).toFixed(1).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
// The line between IN and OUT: what comes out, and what the max cuts off.
|
||||
export function outputSummary(window, { maxFromLink = false } = {}) {
|
||||
if (!window || window.length <= 0) return "";
|
||||
const parts = [secondsLabel(window.length)];
|
||||
if (window.cut > 0.005) parts.push(`cut ${secondsLabel(window.cut)}`);
|
||||
if (maxFromLink) parts.push("max wired");
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
// Loud and quiet across the file: for each of `buckets` slices, the lowest
|
||||
// and highest sample of the channels mixed down. Channel data is a list of
|
||||
// Float32Arrays (AudioBuffer.getChannelData). Returns a Float32Array of
|
||||
// [min0, max0, min1, max1, ...].
|
||||
export function wavePeaks(channels, bucketsValue = 1200) {
|
||||
const list = Array.isArray(channels) ? channels.filter(Boolean) : [];
|
||||
const buckets = Math.max(1, Math.floor(finite(bucketsValue, 1200)));
|
||||
const peaks = new Float32Array(buckets * 2);
|
||||
const length = list.length ? list[0].length : 0;
|
||||
if (!length) return peaks;
|
||||
const count = list.length;
|
||||
for (let bucket = 0; bucket < buckets; bucket += 1) {
|
||||
const from = Math.floor((bucket * length) / buckets);
|
||||
const to = Math.max(from + 1, Math.floor(((bucket + 1) * length) / buckets));
|
||||
let low = 0;
|
||||
let high = 0;
|
||||
for (let index = from; index < to && index < length; index += 1) {
|
||||
let sample = 0;
|
||||
for (let channel = 0; channel < count; channel += 1) sample += list[channel][index] || 0;
|
||||
sample /= count;
|
||||
if (sample < low) low = sample;
|
||||
if (sample > high) high = sample;
|
||||
}
|
||||
peaks[bucket * 2] = low;
|
||||
peaks[bucket * 2 + 1] = high;
|
||||
}
|
||||
return peaks;
|
||||
}
|
||||
|
||||
// Which part of the wave a press at `fraction` (0..1 across it) grabs: a
|
||||
// handle within `hitPixels` of it, the inside of the pick to slide it, or
|
||||
// "jump" to move the pick there.
|
||||
export function grabZone(window, durationValue, fraction, widthValue, hitPixels = 10) {
|
||||
const duration = Math.max(0, finite(durationValue));
|
||||
const width = Math.max(0, finite(widthValue));
|
||||
if (duration <= 0 || width <= 0) return "none";
|
||||
const x = Math.min(1, Math.max(0, finite(fraction))) * width;
|
||||
const startX = (window.start / duration) * width;
|
||||
const endX = (window.end / duration) * width;
|
||||
const toStart = Math.abs(x - startX);
|
||||
const toEnd = Math.abs(x - endX);
|
||||
if (Math.min(toStart, toEnd) <= hitPixels) return toStart <= toEnd ? "start" : "end";
|
||||
if (x > startX && x < endX) return "move";
|
||||
return "jump";
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
// Load Audio 🆎 - the wave, the trim and the player.
|
||||
//
|
||||
// The card above (js/widget_cards) holds Source and Max length. This panel
|
||||
// draws the whole file as a wave: the part that comes out is bright, the
|
||||
// part a Max length cuts off is red, and the rest is dim. Drag a handle to
|
||||
// move IN or OUT, drag the middle to slide the pick, or click elsewhere to
|
||||
// move it there. Play plays only what comes out. The widgets underneath
|
||||
// (start_seconds, end_seconds, max_seconds) stay the source of truth.
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange, showToast } from "../shared/index.mjs";
|
||||
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
|
||||
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
|
||||
import { formatTimecode, parseTimecode } from "../shared/timecode.mjs";
|
||||
import { parseImageReference } from "../shared/pad_canvas.mjs";
|
||||
import { mediaViewQuery, splitMediaName } from "../shared/video_preview.mjs";
|
||||
import { ensureVideoCss, hideCanvasWidget, makeToolButton } from "../shared/video_ui.mjs";
|
||||
import { MIN_TRIM_SECONDS, dragTrimHandle, slideTrimWindow } from "../load_video/trim_preview.mjs";
|
||||
import { grabZone, outputSummary, outputWindow, secondsLabel, wavePeaks } from "./audio_window.mjs";
|
||||
|
||||
const NODE_NAME = "AUSBOSS_NODES_LoadAudio";
|
||||
const PANEL_WIDGET = "ausboss_load_audio_wave";
|
||||
// The frontend's own player for an audio_upload input. The wave replaces it.
|
||||
const CORE_AUDIO_WIDGET = "audioUI";
|
||||
const MIN_WIDTH = 280;
|
||||
// 8px root padding + the wave's 28px floor + 6px gap + the 22px play, IN/OUT and loop row.
|
||||
const PANEL_MIN_HEIGHT = 64;
|
||||
// Opens small and neat: 300 px wide, with a wave about as tall as a field
|
||||
// (32 px). Drag the corner down for a taller wave.
|
||||
const DEFAULT_WIDTH = 300;
|
||||
const DEFAULT_WAVE_EXTRA = 4;
|
||||
const CUT_RED = "#e0525a";
|
||||
const PEAK_BUCKETS = 1600;
|
||||
const END_EPSILON = 0.03;
|
||||
|
||||
// Decoded once per file and kept small: only the peaks and the length.
|
||||
const peakCache = new Map();
|
||||
|
||||
function findWidget(node, name) {
|
||||
return node.widgets?.find((widget) => widget.name === name);
|
||||
}
|
||||
|
||||
function roundHundredth(value) {
|
||||
return Math.round(Number(value || 0) * 100) / 100;
|
||||
}
|
||||
|
||||
function hideCoreAudio(node) {
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (widget?.name === CORE_AUDIO_WIDGET) hideCanvasWidget(widget, widget.element);
|
||||
}
|
||||
}
|
||||
|
||||
// A wired Max length: the number the wire carries, when the far end is a
|
||||
// node that shows it (Float, Integer, a Primitive). null when it can't be known.
|
||||
function wiredMax(node) {
|
||||
const input = node.inputs?.find((slot) => slot?.widget?.name === "max_seconds");
|
||||
if (!input || input.link == null) return { wired: false, value: null };
|
||||
const graph = node.graph;
|
||||
const link = graph?.getLink?.(input.link) ?? graph?.links?.get?.(input.link) ?? graph?.links?.[input.link];
|
||||
const origin = link ? graph?.getNodeById?.(link.origin_id) : null;
|
||||
const shown = origin?.widgets?.find((widget) => typeof widget?.value === "number");
|
||||
return { wired: true, value: shown ? Number(shown.value) : null };
|
||||
}
|
||||
|
||||
function maxSeconds(state) {
|
||||
const wired = wiredMax(state.node);
|
||||
if (wired.wired) return { value: wired.value ?? 0, fromLink: true, known: wired.value != null };
|
||||
return { value: Number(state.maxWidget?.value) || 0, fromLink: false, known: true };
|
||||
}
|
||||
|
||||
function currentWindow(state) {
|
||||
return outputWindow(state.duration, state.startWidget.value, state.endWidget.value, maxSeconds(state).value);
|
||||
}
|
||||
|
||||
async function loadPeaks(name, route) {
|
||||
const key = name;
|
||||
if (peakCache.has(key)) return peakCache.get(key);
|
||||
const job = (async () => {
|
||||
const response = await api.fetchApi(route, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`the file could not be read (${response.status})`);
|
||||
const bytes = await response.arrayBuffer();
|
||||
const Context = window.AudioContext || window.webkitAudioContext;
|
||||
const context = new Context();
|
||||
try {
|
||||
const buffer = await context.decodeAudioData(bytes);
|
||||
const channels = [];
|
||||
for (let index = 0; index < buffer.numberOfChannels; index += 1) channels.push(buffer.getChannelData(index));
|
||||
return { duration: buffer.duration, peaks: wavePeaks(channels, PEAK_BUCKETS) };
|
||||
} finally {
|
||||
context.close?.();
|
||||
}
|
||||
})();
|
||||
peakCache.set(key, job);
|
||||
job.catch(() => peakCache.delete(key));
|
||||
return job;
|
||||
}
|
||||
|
||||
function ensureAudioCss() {
|
||||
ensureVideoCss();
|
||||
if (document.getElementById("ausboss-load-audio-css")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "ausboss-load-audio-css";
|
||||
style.textContent = `
|
||||
.ausboss-audio-root .ausboss-video-stage{min-height:28px;background:#050b0b;cursor:pointer;touch-action:none;}
|
||||
.ausboss-audio-error{pointer-events:auto;border:0;background:transparent;color:#e6bf87;font:11px system-ui;cursor:pointer;white-space:nowrap}
|
||||
.ausboss-audio-root canvas.ausboss-audio-wave{position:absolute;inset:0;width:100%;height:100%;display:block;}
|
||||
.ausboss-audio-root .ausboss-video-stage:not(.is-empty) .ausboss-video-status{display:none;}
|
||||
.ausboss-audio-root .ausboss-audio-row{display:flex;align-items:center;gap:6px;flex:none;padding:0 2px;}
|
||||
.ausboss-audio-root .ausboss-audio-row .ausboss-video-values{flex:1 1 auto;min-width:0;}
|
||||
.ausboss-audio-root .ausboss-audio-play{min-width:30px;font-size:10px;}
|
||||
.ausboss-audio-root .ausboss-audio-length{flex:none;padding:0 2px;color:#7fd9d2;font-size:10px;font-variant-numeric:tabular-nums;white-space:nowrap;cursor:default;}
|
||||
.ausboss-audio-root .ausboss-audio-length:empty{display:none;}
|
||||
.ausboss-audio-root .ausboss-audio-length.has-cut{color:#ff9aa0;cursor:pointer;text-decoration:underline dotted rgba(224,82,90,.8);text-underline-offset:2px;}
|
||||
.ausboss-audio-root .ausboss-audio-row .ausboss-video-tool{flex:none;}
|
||||
.ausboss-audio-root .ausboss-video-values{display:flex;gap:8px;}
|
||||
.ausboss-audio-root .ausboss-video-value input{width:48px;}
|
||||
.ausboss-audio-root .ausboss-audio-length{margin-left:auto;}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function draw(state) {
|
||||
const { canvas } = state;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scale = window.devicePixelRatio || 1;
|
||||
const width = Math.max(1, Math.round(rect.width * scale));
|
||||
const height = Math.max(1, Math.round(rect.height * scale));
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
if (!state.peaks || state.duration <= 0) return;
|
||||
const win = currentWindow(state);
|
||||
const xOf = (seconds) => (seconds / state.duration) * width;
|
||||
const startX = xOf(win.start);
|
||||
const keepX = xOf(win.keepEnd);
|
||||
const endX = xOf(win.end);
|
||||
// Backdrops: the part that comes out, then the part cut off.
|
||||
ctx.fillStyle = "rgba(0,180,170,0.13)";
|
||||
ctx.fillRect(startX, 0, keepX - startX, height);
|
||||
if (endX > keepX) {
|
||||
ctx.fillStyle = "rgba(224,82,90,0.16)";
|
||||
ctx.fillRect(keepX, 0, endX - keepX, height);
|
||||
}
|
||||
// The wave, one bar per pixel column.
|
||||
const mid = height / 2;
|
||||
const reach = mid * 0.92;
|
||||
const buckets = state.peaks.length / 2;
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const from = Math.floor((x / width) * buckets);
|
||||
const to = Math.max(from + 1, Math.floor(((x + 1) / width) * buckets));
|
||||
let low = 0;
|
||||
let high = 0;
|
||||
for (let bucket = from; bucket < to && bucket < buckets; bucket += 1) {
|
||||
low = Math.min(low, state.peaks[bucket * 2]);
|
||||
high = Math.max(high, state.peaks[bucket * 2 + 1]);
|
||||
}
|
||||
if (x >= startX && x < keepX) ctx.fillStyle = BRAND;
|
||||
else if (x >= keepX && x < endX) ctx.fillStyle = CUT_RED;
|
||||
else ctx.fillStyle = "#3a4948";
|
||||
const top = mid - high * reach;
|
||||
ctx.fillRect(x, top, 1, Math.max(1, (high - low) * reach));
|
||||
}
|
||||
// The handles: IN and OUT, and a dashed line where the max cuts off.
|
||||
// The grips stay small on a short wave so they don't hide it.
|
||||
const grip = Math.round(Math.max(3, Math.min(7, rect.height * 0.12)) * scale);
|
||||
const handle = (x, color) => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(Math.round(x) - scale, 0, 2 * scale, height);
|
||||
ctx.fillRect(Math.round(x) - 4 * scale, 0, 8 * scale, grip);
|
||||
ctx.fillRect(Math.round(x) - 4 * scale, height - grip, 8 * scale, grip);
|
||||
};
|
||||
handle(startX, "#f4ffff");
|
||||
handle(endX, "#f4ffff");
|
||||
if (endX > keepX + 1) {
|
||||
ctx.strokeStyle = CUT_RED;
|
||||
ctx.lineWidth = 2 * scale;
|
||||
ctx.setLineDash([4 * scale, 3 * scale]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(keepX, 0);
|
||||
ctx.lineTo(keepX, height);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
// The playhead while it plays, or where it paused inside the pick.
|
||||
if (state.audio && !state.audio.paused) {
|
||||
const x = xOf(state.audio.currentTime);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(x - scale / 2, 0, scale, height);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFace(state) {
|
||||
const win = currentWindow(state);
|
||||
const max = maxSeconds(state);
|
||||
if (document.activeElement !== state.startInput) state.startInput.value = formatTimecode(win.start);
|
||||
if (document.activeElement !== state.endInput) state.endInput.value = formatTimecode(win.end);
|
||||
if (state.duration > 0) {
|
||||
const summary = outputSummary(win, { maxFromLink: max.fromLink && !max.known });
|
||||
state.summary.textContent = win.length > 0 ? secondsLabel(win.length) : "";
|
||||
state.summary.classList.toggle("has-cut", win.cut > 0.005);
|
||||
state.summary.title = win.cut > 0.005
|
||||
? `${summary}. Max length is ${max.value} s, so ${formatTimecode(win.keepEnd)} to ${formatTimecode(win.end)} is cut off (red). Click to move OUT to ${formatTimecode(win.keepEnd)}.`
|
||||
: `${summary}: ${formatTimecode(win.start)} to ${formatTimecode(win.keepEnd)} comes out.`;
|
||||
} else {
|
||||
state.summary.textContent = "";
|
||||
}
|
||||
draw(state);
|
||||
}
|
||||
|
||||
function writeWidget(widget, value) {
|
||||
const next = roundHundredth(value);
|
||||
if (Math.abs(Number(widget.value || 0) - next) < 0.0001) return;
|
||||
widget.value = next;
|
||||
widget.callback?.(next);
|
||||
}
|
||||
|
||||
function writeTrim(state, bounds) {
|
||||
state.writing = true;
|
||||
try {
|
||||
writeWidget(state.startWidget, bounds.start);
|
||||
// OUT at the very end stays 0, the widget's own "to the end".
|
||||
writeWidget(state.endWidget, bounds.end >= state.duration - 0.005 ? 0 : bounds.end);
|
||||
} finally {
|
||||
state.writing = false;
|
||||
}
|
||||
updateFace(state);
|
||||
state.node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
function installDrag(state) {
|
||||
const surface = state.stage;
|
||||
const infoAt = (clientX) => {
|
||||
const rect = state.canvas.getBoundingClientRect();
|
||||
const fraction = rect.width ? Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)) : 0;
|
||||
const win = currentWindow(state);
|
||||
return { fraction, win, zone: grabZone(win, state.duration, fraction, rect.width) };
|
||||
};
|
||||
surface.addEventListener("pointermove", (event) => {
|
||||
if (state.dragging || state.duration <= 0) return;
|
||||
const { zone } = infoAt(event.clientX);
|
||||
surface.style.cursor = zone === "start" || zone === "end" ? "ew-resize" : zone === "move" ? "grab" : "pointer";
|
||||
});
|
||||
surface.addEventListener("pointerdown", (event) => {
|
||||
if (event.button !== 0 || state.dragging || state.duration <= 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const initial = infoAt(event.clientX);
|
||||
const bounds = { start: initial.win.start, end: initial.win.end };
|
||||
const anchor = initial.fraction * state.duration;
|
||||
const downX = event.clientX;
|
||||
let moved = false;
|
||||
state.dragging = true;
|
||||
try { surface.setPointerCapture(event.pointerId); } catch (_error) { /* mouse fallback */ }
|
||||
const centred = (fraction) => {
|
||||
const span = bounds.end - bounds.start;
|
||||
return slideTrimWindow(state.duration, bounds, fraction * state.duration - span / 2 - bounds.start);
|
||||
};
|
||||
const apply = (clientX) => {
|
||||
const next = infoAt(clientX);
|
||||
if (initial.zone === "start" || initial.zone === "end") {
|
||||
const now = currentWindow(state);
|
||||
writeTrim(state, dragTrimHandle(state.duration, { start: now.start, end: now.end }, initial.zone, next.fraction, MIN_TRIM_SECONDS));
|
||||
} else if (initial.zone === "move") {
|
||||
writeTrim(state, slideTrimWindow(state.duration, bounds, next.fraction * state.duration - anchor));
|
||||
} else {
|
||||
writeTrim(state, centred(next.fraction));
|
||||
}
|
||||
};
|
||||
const move = (moveEvent) => {
|
||||
if (!(moveEvent.buttons & 1)) { finish(moveEvent); return; }
|
||||
if (!moved && Math.abs(moveEvent.clientX - downX) < 3) return;
|
||||
moved = true;
|
||||
apply(moveEvent.clientX);
|
||||
};
|
||||
const finish = (finishEvent) => {
|
||||
if (!state.dragging) return;
|
||||
state.dragging = false;
|
||||
surface.removeEventListener("pointermove", move);
|
||||
try { surface.releasePointerCapture(event.pointerId); } catch (_error) { /* already released */ }
|
||||
if (!moved && initial.zone === "jump") writeTrim(state, centred(infoAt(finishEvent?.clientX ?? downX).fraction));
|
||||
if (moved || initial.zone === "jump") {
|
||||
if (state.audio && !state.audio.paused) state.audio.currentTime = currentWindow(state).start;
|
||||
notifyAusbossChange();
|
||||
}
|
||||
};
|
||||
surface.addEventListener("pointermove", move);
|
||||
surface.addEventListener("pointerup", finish, { once: true });
|
||||
surface.addEventListener("pointercancel", finish, { once: true });
|
||||
surface.addEventListener("lostpointercapture", finish, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function loopEnabled(node) {
|
||||
node.properties ??= {};
|
||||
return !!node.properties.ausboss_load_audio_loop;
|
||||
}
|
||||
|
||||
function installPlayer(state) {
|
||||
const audio = state.audio;
|
||||
const stop = () => {
|
||||
if (state.raf) cancelAnimationFrame(state.raf);
|
||||
state.raf = 0;
|
||||
state.playButton.textContent = "▶";
|
||||
state.playButton.classList.remove("active");
|
||||
draw(state);
|
||||
};
|
||||
const tick = () => {
|
||||
if (audio.paused) { stop(); return; }
|
||||
const win = currentWindow(state);
|
||||
if (audio.currentTime >= win.keepEnd - END_EPSILON || audio.currentTime < win.start - 0.25) {
|
||||
if (loopEnabled(state.node)) audio.currentTime = win.start;
|
||||
else { audio.pause(); audio.currentTime = win.start; stop(); return; }
|
||||
}
|
||||
draw(state);
|
||||
state.raf = requestAnimationFrame(tick);
|
||||
};
|
||||
audio.addEventListener("play", () => {
|
||||
state.playButton.textContent = "❚❚";
|
||||
state.playButton.classList.add("active");
|
||||
if (state.raf) cancelAnimationFrame(state.raf);
|
||||
state.raf = requestAnimationFrame(tick);
|
||||
}, { signal: state.abort.signal });
|
||||
audio.addEventListener("pause", stop, { signal: state.abort.signal });
|
||||
state.playButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (state.duration <= 0) return;
|
||||
if (!audio.paused) { audio.pause(); return; }
|
||||
const win = currentWindow(state);
|
||||
if (audio.currentTime < win.start || audio.currentTime >= win.keepEnd - END_EPSILON) audio.currentTime = win.start;
|
||||
audio.play().catch(() => stop());
|
||||
});
|
||||
state.stopPlayer = () => { audio.pause(); stop(); };
|
||||
}
|
||||
|
||||
function commitTimeInput(state, edge, input) {
|
||||
if (state.duration <= 0) return;
|
||||
const seconds = parseTimecode(input.value);
|
||||
const win = currentWindow(state);
|
||||
if (seconds === null) {
|
||||
input.value = formatTimecode(edge === "start" ? win.start : win.end);
|
||||
return;
|
||||
}
|
||||
writeTrim(state, dragTrimHandle(state.duration, { start: win.start, end: win.end }, edge, seconds / state.duration, MIN_TRIM_SECONDS));
|
||||
notifyAusbossChange();
|
||||
input.blur();
|
||||
}
|
||||
|
||||
function watchWidget(state, widget) {
|
||||
if (!widget || widget.__ausbossAudioWatch) return;
|
||||
widget.__ausbossAudioWatch = true;
|
||||
const prior = widget.callback;
|
||||
widget.callback = function (...args) {
|
||||
const result = prior?.apply(this, args);
|
||||
if (!state.writing) updateFace(state);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
function buildPanel(node) {
|
||||
if (node.__ausbossLoadAudio) return node.__ausbossLoadAudio;
|
||||
const audioWidget = findWidget(node, "audio");
|
||||
const startWidget = findWidget(node, "start_seconds");
|
||||
const endWidget = findWidget(node, "end_seconds");
|
||||
if (!audioWidget || !startWidget || !endWidget) return null;
|
||||
ensureAudioCss();
|
||||
hideCoreAudio(node);
|
||||
hideCanvasWidget(startWidget);
|
||||
hideCanvasWidget(endWidget);
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "ausboss-video-root ausboss-audio-root";
|
||||
const stage = document.createElement("div");
|
||||
stage.className = "ausboss-video-stage is-empty";
|
||||
stage.title = "Drag a handle to set where the sound starts or stops. Drag the middle to slide it.";
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "ausboss-audio-wave";
|
||||
const status = document.createElement("div");
|
||||
status.className = "ausboss-video-status";
|
||||
status.textContent = "Choose a sound file";
|
||||
const playButton = makeToolButton("▶", "Play the part that comes out");
|
||||
playButton.classList.add("ausboss-audio-play");
|
||||
const loopButton = makeToolButton("LOOP", "Play it again and again");
|
||||
stage.append(canvas, status);
|
||||
|
||||
const values = document.createElement("div");
|
||||
values.className = "ausboss-video-values";
|
||||
const makeValue = (labelText, title) => {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "ausboss-video-value";
|
||||
const label = document.createElement("label");
|
||||
label.textContent = labelText;
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.inputMode = "decimal";
|
||||
input.placeholder = "m:ss.s";
|
||||
input.title = title;
|
||||
input.addEventListener("keydown", (event) => {
|
||||
keepKeyInField(event);
|
||||
if (event.key === "Enter") input.dispatchEvent(new Event("change"));
|
||||
});
|
||||
wrap.append(label, input);
|
||||
return { wrap, input };
|
||||
};
|
||||
const startValue = makeValue("IN", "Where the sound starts. Type seconds (12.5) or a time (0:12.5).");
|
||||
const endValue = makeValue("OUT", "Where the sound stops. Type seconds (24) or a time (0:24.0).");
|
||||
// How long the output is, beside OUT and off the wave. Red when Max
|
||||
// length cuts the pick short; the tooltip says how much.
|
||||
const summary = document.createElement("div");
|
||||
summary.className = "ausboss-audio-length";
|
||||
values.append(startValue.wrap, endValue.wrap);
|
||||
const row = document.createElement("div");
|
||||
row.className = "ausboss-audio-row";
|
||||
row.append(playButton, values, summary, loopButton);
|
||||
root.append(stage, row);
|
||||
|
||||
const widget = node.addDOMWidget(PANEL_WIDGET, "ausboss_audio", root, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
getMinHeight: () => PANEL_MIN_HEIGHT + WIDGET_FRAME,
|
||||
});
|
||||
keepDomWidgetWidthAuto(widget);
|
||||
widget.serialize = false;
|
||||
fillNodeHeight(widget, {
|
||||
minWidth: MIN_WIDTH,
|
||||
minHeight: PANEL_MIN_HEIGHT + WIDGET_FRAME,
|
||||
minNodeSize: [MIN_WIDTH, 200],
|
||||
exactMinWidth: true,
|
||||
});
|
||||
|
||||
const audio = new Audio();
|
||||
audio.preload = "auto";
|
||||
const abort = new AbortController();
|
||||
const state = node.__ausbossLoadAudio = {
|
||||
node, root, stage, canvas, status, playButton, loopButton, summary, audio,
|
||||
startInput: startValue.input, endInput: endValue.input,
|
||||
audioWidget, startWidget, endWidget, maxWidget: findWidget(node, "max_seconds"),
|
||||
duration: 0, peaks: null, abort, writing: false, dragging: false, raf: 0, loading: 0,
|
||||
};
|
||||
|
||||
// Clicking the red length moves OUT to where the sound really ends.
|
||||
summary.addEventListener("click", (event) => {
|
||||
const win = currentWindow(state);
|
||||
if (win.cut <= 0.005) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
writeTrim(state, { start: win.start, end: win.keepEnd });
|
||||
notifyAusbossChange();
|
||||
});
|
||||
|
||||
const updateLoop = () => loopButton.classList.toggle("active", loopEnabled(node));
|
||||
updateLoop();
|
||||
loopButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
node.properties.ausboss_load_audio_loop = !loopEnabled(node);
|
||||
updateLoop();
|
||||
notifyAusbossChange();
|
||||
});
|
||||
|
||||
const refresh = async () => {
|
||||
state.stopPlayer?.();
|
||||
const name = audioWidget.value;
|
||||
const ticket = ++state.loading;
|
||||
state.duration = 0;
|
||||
state.peaks = null;
|
||||
if (!name || typeof name !== "string") {
|
||||
audio.removeAttribute("src");
|
||||
stage.classList.add("is-empty");
|
||||
status.textContent = "Choose a sound file";
|
||||
updateFace(state);
|
||||
return;
|
||||
}
|
||||
const parts = splitMediaName(name);
|
||||
stage.classList.add("is-empty");
|
||||
status.textContent = `Loading ${parts.filename}…`;
|
||||
const type = parseImageReference(name)?.type ?? "input";
|
||||
const route = `/view?${mediaViewQuery({ ...parts, type }, "input")}`;
|
||||
audio.src = api.apiURL(route);
|
||||
try {
|
||||
const result = await loadPeaks(name, route);
|
||||
if (ticket !== state.loading) return;
|
||||
state.duration = result.duration;
|
||||
state.peaks = result.peaks;
|
||||
stage.classList.remove("is-empty");
|
||||
updateFace(state);
|
||||
} catch (error) {
|
||||
if (ticket !== state.loading) return;
|
||||
const info = document.createElement("button");
|
||||
info.type = "button";
|
||||
info.className = "ausboss-audio-error";
|
||||
info.textContent = "Audio preview unavailable ⓘ";
|
||||
info.title = String(error?.message || error);
|
||||
info.setAttribute("aria-label", "Audio preview unavailable. Show details.");
|
||||
info.onclick = event => {
|
||||
event.stopPropagation();
|
||||
showToast({ summary: "Audio preview unavailable", detail: info.title });
|
||||
};
|
||||
status.replaceChildren(info);
|
||||
}
|
||||
};
|
||||
state.refresh = refresh;
|
||||
|
||||
installDrag(state);
|
||||
installPlayer(state);
|
||||
watchWidget(state, startWidget);
|
||||
watchWidget(state, endWidget);
|
||||
watchWidget(state, state.maxWidget);
|
||||
startValue.input.addEventListener("change", () => commitTimeInput(state, "start", startValue.input));
|
||||
endValue.input.addEventListener("change", () => commitTimeInput(state, "end", endValue.input));
|
||||
const observer = new ResizeObserver(() => draw(state));
|
||||
observer.observe(stage);
|
||||
state.observer = observer;
|
||||
|
||||
const priorAudioCallback = audioWidget.callback;
|
||||
audioWidget.callback = function (...args) {
|
||||
const result = priorAudioCallback?.apply(this, args);
|
||||
peakCache.delete(audioWidget.value);
|
||||
refresh();
|
||||
return result;
|
||||
};
|
||||
setTimeout(refresh, 0);
|
||||
return state;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ausboss.load_audio",
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== NODE_NAME) return;
|
||||
// The frontend's Upload button for an audio_upload input looks for its
|
||||
// player widget and fails without one, and it adds that player only to
|
||||
// its own audio nodes. Ask for it here, ahead of the button; the panel
|
||||
// hides it, since the wave plays the sound instead.
|
||||
const required = nodeData.input?.required;
|
||||
if (required && !required.audioUI) {
|
||||
const entries = Object.entries(required);
|
||||
const at = entries.findIndex(([name]) => name === "audio") + 1;
|
||||
entries.splice(at, 0, ["audioUI", ["AUDIO_UI", {}]]);
|
||||
nodeData.input.required = Object.fromEntries(entries);
|
||||
}
|
||||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||
buildPanel(this);
|
||||
// Only for a genuinely new node: onConfigure restores a saved size
|
||||
// after this runs, so a workflow's own size still wins.
|
||||
const floor = Number(this.computeSize?.()?.[1]) || 0;
|
||||
if (floor > 0) this.setSize?.([DEFAULT_WIDTH, floor + DEFAULT_WAVE_EXTRA]);
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onConfigure", function () {
|
||||
queueMicrotask(() => {
|
||||
const state = buildPanel(this);
|
||||
if (state) {
|
||||
hideCoreAudio(this);
|
||||
state.refresh?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
// A wire into Max length changes what comes out; redraw when one comes or goes.
|
||||
chainCallback(nodeType.prototype, "onConnectionsChange", function () {
|
||||
const state = this.__ausbossLoadAudio;
|
||||
if (state) queueMicrotask(() => updateFace(state));
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onRemoved", function () {
|
||||
const state = this.__ausbossLoadAudio;
|
||||
state?.stopPlayer?.();
|
||||
state?.abort?.abort();
|
||||
state?.observer?.disconnect();
|
||||
if (state) state.audio.removeAttribute("src");
|
||||
this.__ausbossLoadAudio = null;
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { isForeignRun } from "../shared/prompt_scope.mjs";
|
||||
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
|
||||
import { fillNodeHeight } from "../shared/panel_layout.mjs";
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
|
||||
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
|
||||
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
|
||||
import { formatTimecode, parseTimecode } from "../shared/timecode.mjs";
|
||||
import { parseImageReference } from "../shared/pad_canvas.mjs";
|
||||
import {
|
||||
mediaInfo,
|
||||
mediaViewQuery,
|
||||
@@ -590,7 +591,9 @@ function buildPreview(node) {
|
||||
const parts = splitMediaName(name);
|
||||
setEmpty(state, `Loading ${parts.filename}…`);
|
||||
video.pause();
|
||||
video.src = api.apiURL(`/view?${mediaViewQuery({ ...parts, type: "input" }, "input")}`);
|
||||
// A video ComfyUI made ("clip.mp4 [output]") plays from its own folder.
|
||||
const type = parseImageReference(name)?.type ?? "input";
|
||||
video.src = api.apiURL(`/view?${mediaViewQuery({ ...parts, type }, "input")}`);
|
||||
video.load();
|
||||
probeSourceFps(state, name);
|
||||
};
|
||||
|
||||
+917
-119
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,8 @@
|
||||
// node, runs the node alone when the face asks for the picture or for what
|
||||
// SAM 3 finds at a click, and hands it what a run sent back.
|
||||
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { chainCallback } from "../shared/index.mjs";
|
||||
import { executionIdOf, isSwitchedOff, nodeByExecutionId } from "../shared/graph_ids.mjs";
|
||||
import { outputLocatorId } from "../shared/input_preview.mjs";
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@
|
||||
// setting turns it on. The chime is built once as a small WAV in memory
|
||||
// and played through an ordinary audio element, so the pack ships no sound
|
||||
// file and nothing is built until the first chime plays.
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { showToast } from "../shared/index.mjs";
|
||||
import { noticesFromExecuted, queueRemaining, shouldChime } from "../shared/notify.mjs";
|
||||
import { chimeSamples, wavBytes } from "./chime.mjs";
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
// It also notes the runs a node starts for itself (Mask by Touch looking at
|
||||
// its picture), so the Run Timer and the runtime badges can leave them out.
|
||||
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { isAusbossNode } from "../shared/index.mjs";
|
||||
import { nodeByExecutionId } from "../shared/graph_ids.mjs";
|
||||
import { isNodeOwnRun, outputKey, ownRuns, runScope } from "../shared/prompt_scope.mjs";
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// position/size/title/colors/flags/mode/properties survive. Slots or
|
||||
// widgets that no longer exist are skipped silently. Any failure rolls
|
||||
// the graph back and keeps the original node.
|
||||
import { app } from "/scripts/app.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { linkSlots, restoreOutputLinks, snapshotLinks } from "../shared/graph_links.mjs";
|
||||
import { isAusbossNode } from "../shared/index.mjs";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// node, carry translated widgets, remap links, and roll back on failure.
|
||||
// This is purely a graph rewrite; no third-party class name is ever
|
||||
// registered on the backend.
|
||||
import { app } from "/scripts/app.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { linkSlots, restoreOutputLinks, snapshotLinks } from "../shared/graph_links.mjs";
|
||||
import { BRAND, notifyAusbossChange, showToast } from "../shared/index.mjs";
|
||||
import {
|
||||
|
||||
+179
-68
@@ -21,7 +21,8 @@
|
||||
// at the selected pixel budget and follow the explicit orientation toggle. Snap governs gestures only — typed
|
||||
// values are never re-snapped.
|
||||
|
||||
import { app } from "/scripts/app.js";
|
||||
import { CAPTION_TYPE, HINT_TYPE, compactAction } from "../shared/panel_controls.mjs";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import {
|
||||
chainCallback,
|
||||
keepDomWidgetWidthAuto,
|
||||
@@ -29,8 +30,10 @@ import {
|
||||
} from "../shared/index.mjs";
|
||||
import { WIDGET_FRAME, fillNodeHeight, holdVueNodeMinWidth } from "../shared/panel_layout.mjs";
|
||||
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
|
||||
import { markSocketless } from "../shared/widget_card_math.mjs";
|
||||
import { makeScrubInput } from "../shared/scrub_input.mjs";
|
||||
import {
|
||||
closeSettingsMenu,
|
||||
gearIconSvg,
|
||||
loadSettings,
|
||||
openSettingsMenu,
|
||||
@@ -51,12 +54,16 @@ import {
|
||||
offGrid,
|
||||
orientRatio,
|
||||
parseRail,
|
||||
parseWorkflowSizes,
|
||||
readWorkflowPolicy,
|
||||
savedWorkflowPolicy,
|
||||
workflowRatios,
|
||||
quickSizes,
|
||||
ratioLabel,
|
||||
reduceRatio,
|
||||
scrubDim,
|
||||
sizeForMegapixels,
|
||||
stageScale,
|
||||
fitStage,
|
||||
} from "../shared/resolution_math.mjs";
|
||||
|
||||
const NODE_CLASS = "AUSBOSS_NODES_Resolution";
|
||||
@@ -132,13 +139,19 @@ function installStyles() {
|
||||
.ausboss-res-panel { display: flex; flex-direction: column; gap: ${SECTION_GAP}px;
|
||||
width: 100%; height: 100%; padding: ${PANEL_PADDING}px; overflow: hidden;
|
||||
font: 400 11px ${MONO}; color: #d7dde2; }
|
||||
.ausboss-size-dialog{color-scheme:dark;width:min(365px,88vw);border:1px solid #345554;border-radius:10px;background:#162022;color:#d7e4e3;padding:18px;font:13px system-ui}
|
||||
.ausboss-size-dialog::backdrop{background:#0008}
|
||||
.ausboss-size-dialog textarea{display:block;box-sizing:border-box;width:100%;margin:8px 0;padding:8px;font:13px monospace}
|
||||
.ausboss-size-dialog input{accent-color:#00b4aa}
|
||||
.ausboss-size-dialog [role=alert]{color:#ffd49e}
|
||||
.ausboss-size-dialog p{color:#adc1be;font:12px/1.45 system-ui;margin:7px 0 12px}
|
||||
.ausboss-size-dialog footer{display:flex;justify-content:flex-end;gap:8px;margin-top:12px}
|
||||
.ausboss-res-sec { flex: none; background: #141515; border: 1px solid #252727;
|
||||
border-radius: 8px; padding: 6px 8px 8px; }
|
||||
.ausboss-res-sechead { display: flex; align-items: baseline;
|
||||
justify-content: space-between; margin-bottom: 6px; gap: 8px; }
|
||||
.ausboss-res-sechead .label { font: 500 9px ${MONO}; letter-spacing: .14em;
|
||||
text-transform: uppercase; color: #6a7070; white-space: nowrap; }
|
||||
.ausboss-res-sechead .hint { font: 400 9px ${MONO}; color: #4e5454;
|
||||
.ausboss-res-sechead .label { ${CAPTION_TYPE} white-space: nowrap; }
|
||||
.ausboss-res-sechead .hint { ${HINT_TYPE}
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ausboss-res-shaperow { display: flex; gap: 6px; align-items: stretch; }
|
||||
.ausboss-res-subbox { background: #101111; border: 1px solid #252727;
|
||||
@@ -174,12 +187,12 @@ function installStyles() {
|
||||
background-image: radial-gradient(#242727 1px, transparent 1px);
|
||||
background-size: 22px 22px; background-position: center; opacity: .55; }
|
||||
.ausboss-res-rings { position: absolute; inset: 0; pointer-events: none; }
|
||||
.ausboss-res-rect { position: absolute; inset: 0; margin: auto;
|
||||
border: 1.5px solid #00b4aa; background: rgba(0,180,170,.05);
|
||||
.ausboss-res-rect { position: absolute; inset: var(--ausboss-res-inset,0) 0 0; margin: auto;
|
||||
border: 1.5px solid #00b4aa; background: rgba(0,180,170,.10);
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 2px; }
|
||||
.ausboss-res-rect.easing { transition: width ${REFIT_MS}ms ease, height ${REFIT_MS}ms ease; }
|
||||
.ausboss-res-ghost { position: absolute; inset: 0; margin: auto;
|
||||
.ausboss-res-ghost { position: absolute; inset: var(--ausboss-res-inset,0) 0 0; margin: auto;
|
||||
border: 1px dashed #4d5555; pointer-events: none; display: none; }
|
||||
.ausboss-res-readout { position: absolute; top: 6px; left: 9px; right: 9px; z-index: 2;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
@@ -207,6 +220,8 @@ function installStyles() {
|
||||
height: ${CONTROL_H}px; background: #1c1e1e; border-color: #313434; font: 500 13px ${MONO}; }
|
||||
.ausboss-res-field .ausboss-scrub > .ausboss-scrub-input { text-align: left;
|
||||
padding-left: 8px; color: #eef1f1; }
|
||||
.ausboss-res-field[inert] .ausboss-scrub,.ausboss-res-field .ausboss-scrub[inert]{border-color:transparent;background:transparent}
|
||||
.ausboss-res-field[inert] .ausboss-scrub-step,.ausboss-res-field .ausboss-scrub[inert] .ausboss-scrub-step{display:none}
|
||||
.ausboss-res-field.linked { opacity: .5; pointer-events: none; }
|
||||
.ausboss-res-axis { flex: none; font: 400 9px ${MONO}; color: #565c5c; }
|
||||
.ausboss-res-btn { width: ${CONTROL_H}px; height: ${CONTROL_H}px; flex: none;
|
||||
@@ -214,7 +229,7 @@ function installStyles() {
|
||||
border-radius: 5px; color: #9aa0a0; cursor: pointer; font-size: 12px; padding: 0; }
|
||||
.ausboss-res-btn:hover { border-color: #00b4aa; color: #00b4aa; }
|
||||
.ausboss-res-quick { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px;
|
||||
min-height: ${QUICK_H * 2 + 5}px; align-content: start; }
|
||||
height: ${QUICK_H * 2 + 5}px; overflow-y: auto; align-content: start; }
|
||||
.ausboss-res-chip.customchip { opacity: .45; cursor: default;
|
||||
pointer-events: none; border-style: dashed; grid-column: span 2; }
|
||||
.ausboss-res-quick .ausboss-res-chip { height: ${QUICK_H}px; font-size: 9.5px;
|
||||
@@ -256,6 +271,7 @@ function getSize(state) {
|
||||
}
|
||||
|
||||
function commitSize(state, width, height, { gesture = false } = {}) {
|
||||
if (state.strict && (!isListed(state, width, height) || axisLinked(state, "width") || axisLinked(state, "height"))) return;
|
||||
if (!axisLinked(state, "width")) state.widthWidget.value = clampDim(width);
|
||||
if (!axisLinked(state, "height")) state.heightWidget.value = clampDim(height);
|
||||
repaint(state);
|
||||
@@ -350,7 +366,7 @@ function stageBox(state) {
|
||||
|
||||
// Equal-cost curves, log-sampled across the plausible width range so they
|
||||
// sweep the whole quadrant (the design's rendering).
|
||||
function paintRings(state, scale) {
|
||||
function paintRings(state, scale, shift) {
|
||||
const svg = state.rings;
|
||||
svg.replaceChildren();
|
||||
const show = state.settings?.show_mp_rings !== false;
|
||||
@@ -358,7 +374,7 @@ function paintRings(state, scale) {
|
||||
const box = stageBox(state);
|
||||
svg.setAttribute("viewBox", `0 0 ${box.w} ${box.h}`);
|
||||
const cx = box.w / 2;
|
||||
const cy = box.h / 2;
|
||||
const cy = box.h / 2 + shift;
|
||||
for (const mp of [0.5, 1, 2]) {
|
||||
const points = [];
|
||||
for (let i = 0; i <= 60; i += 1) {
|
||||
@@ -396,7 +412,7 @@ function paintRings(state, scale) {
|
||||
|
||||
// The quick list regenerates when the ratio or snap changes; repaint only
|
||||
// re-highlights it otherwise.
|
||||
function rebuildQuick(state, width, height, snap, custom) {
|
||||
function rebuildQuick(state, width, height, snap, custom, picks) {
|
||||
const wrap = state.quickWrap;
|
||||
wrap.replaceChildren();
|
||||
state.quickButtons = [];
|
||||
@@ -409,7 +425,8 @@ function rebuildQuick(state, width, height, snap, custom) {
|
||||
wrap.append(chip);
|
||||
return;
|
||||
}
|
||||
const list = quickSizes(width, height, snap);
|
||||
const list = picks ?? quickSizes(width, height, snap);
|
||||
if (state.strict && !list.length) return;
|
||||
if (!list.length) {
|
||||
wrap.append(el("span", "ausboss-res-noquick",
|
||||
"No on-grid sizes for this ratio at this snap — try a finer snap."));
|
||||
@@ -420,6 +437,7 @@ function rebuildQuick(state, width, height, snap, custom) {
|
||||
chip.type = "button";
|
||||
chip.title = `Apply ${size.width}×${size.height} exactly.`;
|
||||
chip.addEventListener("click", () => {
|
||||
if (picks && size.width !== size.height) state.node.properties.ausbossResolutionPortrait = size.height > size.width;
|
||||
commitSize(state, size.width, size.height);
|
||||
easeRefit(state);
|
||||
});
|
||||
@@ -430,26 +448,98 @@ function rebuildQuick(state, width, height, snap, custom) {
|
||||
}
|
||||
}
|
||||
|
||||
function isListed(state, width, height) {
|
||||
return state.sizes.some(([w, h]) => w === width && h === height);
|
||||
}
|
||||
|
||||
function restorePolicy(state) {
|
||||
const widget = state.policyWidget;
|
||||
if (!widget) return;
|
||||
const legacy = state.node.properties?.ausbossWorkflowSizes;
|
||||
widget.value = savedWorkflowPolicy(widget.value, legacy);
|
||||
if (state.node.properties) delete state.node.properties.ausbossWorkflowSizes;
|
||||
try {
|
||||
Object.assign(state, readWorkflowPolicy(widget.value), { policyError: false });
|
||||
} catch {
|
||||
Object.assign(state, { sizes: [], strict: true, policyError: true });
|
||||
}
|
||||
const { width, height } = getSize(state);
|
||||
if (state.strict && state.sizes.length && !isListed(state, width, height)
|
||||
&& !axisLinked(state, "width") && !axisLinked(state, "height")) {
|
||||
[state.widthWidget.value, state.heightWidget.value] = state.sizes[0];
|
||||
}
|
||||
}
|
||||
|
||||
function editWorkflowSizes(state) {
|
||||
closeSettingsMenu();
|
||||
const dialog = el("dialog", "ausboss-size-dialog");
|
||||
dialog.setAttribute("aria-label", "Workflow resolutions");
|
||||
const label = el("label", "", "Workflow resolutions");
|
||||
const area = el("textarea");
|
||||
area.rows = 6;
|
||||
area.placeholder = "576 × 1024";
|
||||
area.value = state.sizes.map(pair => pair.join(" × ")).join("\n");
|
||||
label.append(area);
|
||||
const hint = el("p", "", "One width × height per line. These quick picks are saved with this node.");
|
||||
const lock = el("label");
|
||||
const checkbox = el("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = state.strict;
|
||||
lock.append(checkbox, " Limit choices to this list");
|
||||
const description = el("p", "", "Only listed sizes and their exact aspect ratios can be selected. Linked dimensions are checked when the workflow runs.");
|
||||
const error = el("div");
|
||||
error.setAttribute("role", "alert");
|
||||
const footer = el("footer");
|
||||
const cancel = compactAction("Cancel");
|
||||
const save = compactAction("Save sizes");
|
||||
cancel.onclick = () => dialog.close();
|
||||
save.onclick = () => {
|
||||
let sizes;
|
||||
try {
|
||||
sizes = parseWorkflowSizes(area.value);
|
||||
if (checkbox.checked && !sizes.length) throw new Error("Add a resolution, or turn off the limit to allow any size.");
|
||||
} catch (e) {
|
||||
error.textContent = e.message;
|
||||
area.focus();
|
||||
return;
|
||||
}
|
||||
state.policyWidget.value = sizes.length ? JSON.stringify({ strict: checkbox.checked, sizes }) : "";
|
||||
restorePolicy(state);
|
||||
buildPanel(state);
|
||||
fitNode(state);
|
||||
notifyAusbossChange();
|
||||
dialog.close();
|
||||
};
|
||||
footer.append(cancel, save);
|
||||
dialog.append(label, hint, lock, description, error, footer);
|
||||
document.body.append(dialog);
|
||||
dialog.addEventListener("close", () => dialog.remove(), { once:true });
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
function repaint(state) {
|
||||
const { width, height } = getSize(state);
|
||||
const snap = currentSnap(state);
|
||||
const box = stageBox(state);
|
||||
const scale = state.frozenScale ?? stageScale(width, height, box.w, box.h);
|
||||
const portrait = orientation(state);
|
||||
const ratio = reduceRatio(width, height);
|
||||
|
||||
state.rect.style.width = `${Math.max(8, width * scale)}px`;
|
||||
state.rect.style.height = `${Math.max(8, height * scale)}px`;
|
||||
state.dimLabel.textContent = `${width} × ${height}`;
|
||||
state.subMp.textContent = `${megapixels(width, height).toFixed(2)} MP`;
|
||||
state.subRatio.textContent = ratioLabel(width, height);
|
||||
state.offGridDot.style.display = offGrid(width, height, snap) ? "" : "none";
|
||||
state.subRatio.textContent = state.strict ? `${ratio.x}:${ratio.y}` : ratioLabel(width, height);
|
||||
state.offGridDot.style.display = !state.strict && offGrid(width, height, snap) ? "" : "none";
|
||||
state.dots.style.display = state.settings?.show_grid === false ? "none" : "";
|
||||
|
||||
paintRings(state, scale);
|
||||
const box = stageBox(state);
|
||||
const sideRoom = 14 + Math.max(state.dimLabel.offsetWidth, state.subMp.parentElement.offsetWidth);
|
||||
const frame = state.frame = state.frozenFrame ?? fitStage(width, height, box.w, box.h, sideRoom);
|
||||
const { scale, shift } = frame;
|
||||
state.stage.style.setProperty("--ausboss-res-inset", `${2 * shift}px`);
|
||||
state.rect.style.width = `${Math.max(8, width * scale)}px`;
|
||||
state.rect.style.height = `${Math.max(8, height * scale)}px`;
|
||||
paintRings(state, scale, shift);
|
||||
|
||||
// Section labels.
|
||||
state.orientLabel.textContent = width === height
|
||||
state.orientLabel.textContent = state.strict ? "Workflow ratios" : width === height
|
||||
? `square · chips ${portrait ? "portrait" : "landscape"}`
|
||||
: portrait ? "portrait" : "landscape";
|
||||
const flipFit = portrait ? { w: 11, h: 15 } : { w: 15, h: 11 };
|
||||
@@ -463,14 +553,17 @@ function repaint(state) {
|
||||
|
||||
// Ratio chips render in the selected orientation; filled = exact, dashed = near.
|
||||
state.chipButtons.forEach(({ button, ratio: chipRatio, glyph, label }) => {
|
||||
const oriented = orientRatio(chipRatio, portrait);
|
||||
const oriented = state.strict ? chipRatio : orientRatio(chipRatio, portrait);
|
||||
const fitted = fitRectInBox(oriented.x, oriented.y, 14, 14);
|
||||
glyph.style.width = `${fitted.w.toFixed(1)}px`;
|
||||
glyph.style.height = `${fitted.h.toFixed(1)}px`;
|
||||
label.textContent = oriented.label;
|
||||
button.title = `${oriented.label}: reshape to this ratio. The flip button on the left sets the orientation.`;
|
||||
button.title = state.strict ? `Show approved ${oriented.label} sizes` : `${oriented.label}: reshape to this ratio. The flip button on the left sets the orientation.`;
|
||||
const candidate = chipMatch(width, height, oriented);
|
||||
const match = candidate?.flipped ? null : candidate;
|
||||
const match = state.strict
|
||||
? (ratio.x === chipRatio.x && ratio.y === chipRatio.y ? { state: "exact" } : null)
|
||||
: candidate?.flipped ? null : candidate;
|
||||
button.setAttribute("aria-pressed", String(match?.state === "exact"));
|
||||
button.classList.toggle("exact", match?.state === "exact");
|
||||
button.classList.toggle("near", match?.state === "near");
|
||||
});
|
||||
@@ -479,49 +572,52 @@ function repaint(state) {
|
||||
const widthLinked = axisLinked(state, "width");
|
||||
const heightLinked = axisLinked(state, "height");
|
||||
const linked = widthLinked || heightLinked;
|
||||
state.flipButton.disabled = linked;
|
||||
state.swapButton.disabled = linked;
|
||||
state.budgetControl.root.style.pointerEvents = linked ? "none" : "";
|
||||
state.budgetControl.root.style.opacity = linked ? ".5" : "";
|
||||
state.flipButton.disabled = linked || state.strict;
|
||||
state.swapButton.disabled = linked || (state.strict && (width === height || !isListed(state, height, width)));
|
||||
state.budgetControl.root.inert = linked || state.strict;
|
||||
state.budgetControl.root.style.opacity = linked || state.strict ? ".65" : "";
|
||||
for (const { button } of state.chipButtons) button.disabled = linked;
|
||||
state.widthControl.set(width);
|
||||
state.heightControl.set(height);
|
||||
state.budgetControl?.set(megapixels(width, height));
|
||||
state.widthField.classList.toggle("linked", widthLinked);
|
||||
state.heightField.classList.toggle("linked", heightLinked);
|
||||
state.widthField.title = widthLinked ? "Width is driven by its input link." : "";
|
||||
state.heightField.title = heightLinked ? "Height is driven by its input link." : "";
|
||||
state.budgetControl.set(megapixels(width, height));
|
||||
for (const [field, wired] of [[state.widthField, widthLinked], [state.heightField, heightLinked]]) {
|
||||
field.inert = wired || state.strict;
|
||||
field.classList.toggle("linked", wired);
|
||||
}
|
||||
state.handles.e.classList.toggle("linked", widthLinked);
|
||||
state.handles.s.classList.toggle("linked", heightLinked);
|
||||
state.handles.se.classList.toggle("linked", widthLinked || heightLinked);
|
||||
state.handles.se.classList.toggle("linked", linked);
|
||||
|
||||
// Quick sizes follow the CURRENT ratio - the Pixaroma behavior, but the
|
||||
// list is generated on-ratio/on-grid instead of hand-curated. Frozen
|
||||
// Quick sizes follow the current ratio and snap grid. Frozen
|
||||
// while a gesture is live (a drag sweeps ratios; regenerating per move
|
||||
// read as glitching), and a shape off the rail shows one "custom" chip.
|
||||
if (!state.dragging) {
|
||||
const onRail = state.railRatios?.some(
|
||||
(railRatio) => chipMatch(width, height, railRatio)?.state === "exact",
|
||||
);
|
||||
const quickKey = onRail ? `${ratio.x}:${ratio.y}/${snap}` : `custom/${snap}`;
|
||||
const group = state.strict && state.railRatios.find(r => r.x === ratio.x && r.y === ratio.y);
|
||||
const picks = state.strict ? (group?.sizes ?? []) : state.sizes.length
|
||||
? state.sizes.map(([w, h]) => ({ width: w, height: h })) : null;
|
||||
const quickKey = picks ? JSON.stringify(picks) : onRail ? `${ratio.x}:${ratio.y}/${snap}` : `custom/${snap}`;
|
||||
if (state.quickKey !== quickKey) {
|
||||
state.quickKey = quickKey;
|
||||
rebuildQuick(state, width, height, snap, !onRail);
|
||||
state.quickHint.textContent = onRail
|
||||
? `for ${ratioLabel(width, height)} · /${snap}`
|
||||
: `custom · /${snap}`;
|
||||
rebuildQuick(state, width, height, snap, !picks && !onRail, picks);
|
||||
}
|
||||
state.quickHint.textContent = state.policyError ? "Invalid list · edit in settings"
|
||||
: linked && state.strict ? "Linked size checked at run"
|
||||
: state.strict ? `Allowed · ${ratio.x}:${ratio.y}` : picks ? "Workflow recommendations"
|
||||
: onRail ? `for ${ratioLabel(width, height)} · /${snap}` : `custom · /${snap}`;
|
||||
}
|
||||
state.quickButtons.forEach(({ button, size }) => {
|
||||
button.disabled = widthLinked || heightLinked;
|
||||
button.classList.toggle("exact", size.width === width && size.height === height);
|
||||
button.disabled = linked;
|
||||
const selected = size.width === width && size.height === height;
|
||||
button.classList.toggle("exact", selected);
|
||||
button.setAttribute("aria-pressed", String(selected));
|
||||
});
|
||||
}
|
||||
|
||||
function showGhost(state, width, height) {
|
||||
const box = stageBox(state);
|
||||
const { width: w, height: h } = getSize(state);
|
||||
const scale = stageScale(w, h, box.w, box.h);
|
||||
const { scale } = state.frame;
|
||||
state.ghost.style.width = `${width * scale}px`;
|
||||
state.ghost.style.height = `${height * scale}px`;
|
||||
state.ghost.style.display = "";
|
||||
@@ -532,7 +628,7 @@ function hideGhost(state) {
|
||||
}
|
||||
|
||||
function easeRefit(state) {
|
||||
state.frozenScale = null;
|
||||
state.frozenFrame = null;
|
||||
state.rect.classList.add("easing");
|
||||
repaint(state);
|
||||
clearTimeout(state.easeTimer);
|
||||
@@ -542,15 +638,14 @@ function easeRefit(state) {
|
||||
// ---------- gestures ----------
|
||||
|
||||
function beginHandleDrag(state, handle, event) {
|
||||
if (event.button !== 0 || state.disposed) return;
|
||||
if (state.strict || event.button !== 0 || state.disposed) return;
|
||||
if ((handle !== "s" && axisLinked(state, "width")) || (handle !== "e" && axisLinked(state, "height"))) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const start = getSize(state);
|
||||
const box = stageBox(state);
|
||||
// Frozen at pointerdown: the rect may overflow the stage mid-gesture and
|
||||
// re-fits with a short ease on release - auto-fit never squirms mid-drag.
|
||||
state.frozenScale = stageScale(start.width, start.height, box.w, box.h);
|
||||
state.frozenFrame = state.frame;
|
||||
state.dragging = true;
|
||||
const origin = { x: event.clientX, y: event.clientY };
|
||||
state.dragAbort?.abort();
|
||||
@@ -563,7 +658,7 @@ function beginHandleDrag(state, handle, event) {
|
||||
startHeight: start.height,
|
||||
dx: (moveEvent.clientX - origin.x) / zoom,
|
||||
dy: (moveEvent.clientY - origin.y) / zoom,
|
||||
scale: state.frozenScale,
|
||||
scale: state.frozenFrame.scale,
|
||||
snap: currentSnap(state),
|
||||
snapOn: !moveEvent.altKey,
|
||||
// The corner scales at the exact locked ratio; Shift frees both axes.
|
||||
@@ -644,7 +739,7 @@ function sectionHead(label, hint) {
|
||||
function buildPanel(state) {
|
||||
state.dragAbort?.abort();
|
||||
state.dragging = false;
|
||||
state.frozenScale = null;
|
||||
state.frozenFrame = null;
|
||||
const panel = state.panel;
|
||||
panel.textContent = "";
|
||||
state.quickKey = null; // force the quick list to regenerate
|
||||
@@ -668,19 +763,16 @@ function buildPanel(state) {
|
||||
state.flipButton = flip;
|
||||
state.flipGlyph = flipGlyph;
|
||||
state.chipButtons = [];
|
||||
state.railRatios = parseRail(state.settings?.rail);
|
||||
state.railRatios = state.strict ? workflowRatios(state.sizes) : parseRail(state.settings?.rail);
|
||||
for (const ratio of state.railRatios) {
|
||||
const chip = el("button", "ausboss-res-chip");
|
||||
chip.type = "button";
|
||||
const glyph = el("span", "glyph");
|
||||
const label = el("span");
|
||||
chip.append(glyph, label);
|
||||
// A chip click lands on that ratio's canonical ~1MP default (the
|
||||
// Pixaroma behavior, derived instead of table-driven); the gear's
|
||||
// "keep current budget" toggle restores the budget-preserving mode.
|
||||
// Chips only pick the shape; orientation belongs to the flip button.
|
||||
const chipTarget = () => {
|
||||
const size = getSize(state);
|
||||
if (state.strict) return ratio.sizes.find(s => s.width === size.width && s.height === size.height) ?? ratio.sizes[0];
|
||||
const portrait = orientation(state);
|
||||
if (state.settings?.chips_keep_budget) {
|
||||
return applyRatioAtBudget(size.width, size.height, ratio, currentSnap(state), portrait);
|
||||
@@ -701,13 +793,15 @@ function buildPanel(state) {
|
||||
state.chipButtons.push({ button: chip, ratio, glyph, label });
|
||||
}
|
||||
chipBox.append(chipGrid);
|
||||
shapeRow.append(flipBox, chipBox);
|
||||
if (!state.strict) shapeRow.append(flipBox);
|
||||
shapeRow.append(chipBox);
|
||||
shapeSec.append(shapeRow);
|
||||
panel.append(shapeSec);
|
||||
|
||||
// (2) STAGE
|
||||
const stage = el("div", "ausboss-res-stage");
|
||||
stage.tabIndex = 0;
|
||||
stage.tabIndex = state.strict ? -1 : 0;
|
||||
if (state.strict) stage.title = "Choose from the workflow’s ratio and size buttons.";
|
||||
const dots = el("div", "ausboss-res-dots");
|
||||
const rings = svgEl("svg");
|
||||
rings.classList.add("ausboss-res-rings");
|
||||
@@ -734,14 +828,14 @@ function buildPanel(state) {
|
||||
? "Drag width - Alt unsnaps, Esc cancels"
|
||||
: "Drag height - Alt unsnaps, Esc cancels";
|
||||
grip.addEventListener("pointerdown", (event) => beginHandleDrag(state, handle, event));
|
||||
rect.append(grip);
|
||||
if (!state.strict) rect.append(grip);
|
||||
state.handles[handle] = grip;
|
||||
}
|
||||
stage.append(dots, rings, ghost, rect, readout);
|
||||
stage.addEventListener("keydown", (event) => {
|
||||
const keys = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] };
|
||||
const move = keys[event.key];
|
||||
if (!move) return;
|
||||
if (!move || state.strict) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const step = event.shiftKey ? 8 : currentSnap(state);
|
||||
@@ -803,10 +897,13 @@ function buildPanel(state) {
|
||||
gear.addEventListener("click", () => {
|
||||
openSettingsMenu({
|
||||
scope: SETTINGS_SCOPE,
|
||||
schema: SETTINGS_SCHEMA.map((entry) => {
|
||||
schema: [{ section: "This workflow" }, {
|
||||
key: "workflow_sizes", type: "action", label: "Workflow resolutions", button: "Edit list",
|
||||
onAction: () => editWorkflowSizes(state),
|
||||
}, ...SETTINGS_SCHEMA.filter(e => !state.strict || !["default_snap", "chips_keep_budget", "rail"].includes(e.key)).map((entry) => {
|
||||
const input = entry.key === "latent" ? "latent" : entry.key === "batch" ? "batch_size" : null;
|
||||
return input && axisLinked(state, input) ? { section: `${entry.label} is linked` } : entry;
|
||||
}),
|
||||
})],
|
||||
anchor: gear.getBoundingClientRect(),
|
||||
title: "Latent Size settings",
|
||||
initial: {
|
||||
@@ -869,7 +966,7 @@ function buildPanel(state) {
|
||||
state.resizeObserver?.disconnect();
|
||||
state.resizeObserver = new ResizeObserver(() => {
|
||||
holdVueNodeMinWidth(state.panel, MIN_WIDTH);
|
||||
if (!state.frozenScale) repaint(state);
|
||||
if (!state.frozenFrame) repaint(state);
|
||||
});
|
||||
state.resizeObserver.observe(stage);
|
||||
|
||||
@@ -907,6 +1004,8 @@ function installResolutionNode(node) {
|
||||
// the API format carries them like any other value.
|
||||
const latentWidget = node.widgets?.find((widget) => widget.name === "latent");
|
||||
const batchWidget = node.widgets?.find((widget) => widget.name === "batch_size");
|
||||
const policyWidget = node.widgets?.find(widget => widget.name === "workflow_sizes");
|
||||
hideWidget(policyWidget);
|
||||
if (latentWidget) hideWidget(latentWidget);
|
||||
if (batchWidget) hideWidget(batchWidget);
|
||||
|
||||
@@ -928,9 +1027,12 @@ function installResolutionNode(node) {
|
||||
heightWidget,
|
||||
latentWidget,
|
||||
batchWidget,
|
||||
policyWidget,
|
||||
sizes: [],
|
||||
strict: false,
|
||||
panel,
|
||||
settings: loadSettings(SETTINGS_SCOPE, SETTINGS_SCHEMA),
|
||||
frozenScale: null,
|
||||
frozenFrame: null,
|
||||
quickKey: null,
|
||||
};
|
||||
node.__ausbossResolution = state;
|
||||
@@ -953,6 +1055,7 @@ function installResolutionNode(node) {
|
||||
exactMinWidth: true,
|
||||
});
|
||||
|
||||
restorePolicy(state);
|
||||
buildPanel(state);
|
||||
for (const widget of [widthWidget, heightWidget, latentWidget, batchWidget]) {
|
||||
if (widget) chainCallback(widget, "callback", () => {
|
||||
@@ -980,11 +1083,18 @@ function installResolutionNode(node) {
|
||||
// empty placeholder in their slots; put the defaults back.
|
||||
if (latentWidget && !["16ch", "4ch", "128ch"].includes(latentWidget.value)) latentWidget.value = "16ch";
|
||||
if (batchWidget && !(Number.isInteger(batchWidget.value) && batchWidget.value >= 1)) batchWidget.value = 1;
|
||||
restorePolicy(state);
|
||||
buildPanel(state);
|
||||
requestAnimationFrame(() => { if (!state.disposed) { syncPrimitiveLinks(state); repaint(state); } });
|
||||
});
|
||||
chainCallback(node, "onConnectionsChange", function () {
|
||||
// Convert-to-input honesty: link changes flip the read-only axes.
|
||||
requestAnimationFrame(() => { if (!state.disposed) { syncPrimitiveLinks(state); repaint(state); } });
|
||||
requestAnimationFrame(() => {
|
||||
if (state.disposed) return;
|
||||
syncPrimitiveLinks(state);
|
||||
// A removed Primitive can leave its last value in the backing widget.
|
||||
if (state.strict) restorePolicy(state);
|
||||
repaint(state);
|
||||
});
|
||||
});
|
||||
chainCallback(node, "onRemoved", () => {
|
||||
state.disposed = true;
|
||||
@@ -998,7 +1108,8 @@ app.registerExtension({
|
||||
name: "AusBoss.Resolution",
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== NODE_CLASS) return;
|
||||
hideInputsInDef(nodeData, ["width", "height", "latent", "batch_size"]);
|
||||
markSocketless(nodeData, ["workflow_sizes"]);
|
||||
hideInputsInDef(nodeData, ["width", "height", "latent", "batch_size", "workflow_sizes"]);
|
||||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||
installResolutionNode(this);
|
||||
});
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
// check once per input the check reads, and collapseRepeatedErrors keeps
|
||||
// one per message on AusBoss nodes before the frontend shows them.
|
||||
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { showToast } from "../shared/index.mjs";
|
||||
import { nodeByExecutionId } from "../shared/graph_ids.mjs";
|
||||
import {
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
// node.properties so a saved workflow reopens showing its author's time.
|
||||
// Decision logic lives in js/shared/run_timer.mjs under node:test.
|
||||
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { BRAND, chainCallback, keepDomWidgetWidthAuto } from "../shared/index.mjs";
|
||||
import { holdNodeMinWidth } from "../shared/panel_layout.mjs";
|
||||
import { isOwnRun, ownRuns } from "../shared/prompt_scope.mjs";
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
// When the `filename` input is linked the name comes from upstream: the
|
||||
// field shows {{filename}} and the tags fold away, since an exact name is
|
||||
// never decorated.
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { PANEL_LABELS } from "../shared/panel_controls.mjs";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { isForeignRun } from "../shared/prompt_scope.mjs";
|
||||
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
|
||||
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
|
||||
@@ -275,13 +276,13 @@ function buildCard(node) {
|
||||
embedSeg.setAttribute("role", "radiogroup");
|
||||
const embedButtons = new Map();
|
||||
const embedTitle = "Embed stores the prompt and workflow in the image for drag-back. Off writes a clean image for sharing or datasets.";
|
||||
for (const [on, text] of [[false, "no"], [true, "yes"]]) {
|
||||
for (const [on, text] of [[false, PANEL_LABELS.off], [true, PANEL_LABELS.on]]) {
|
||||
const button = el("button", "", text);
|
||||
button.type = "button"; button.title = embedTitle;
|
||||
button.addEventListener("click", () => { if (setValue(node, "save_metadata", on)) notifyAusbossChange(); state.refresh(); });
|
||||
embedSeg.append(button); embedButtons.set(on, button);
|
||||
}
|
||||
embedRow.append(el("span", "ausboss-save-embed-label", "Embed workflow"), embedSeg);
|
||||
embedRow.append(el("span", "ausboss-save-embed-label", PANEL_LABELS.embed), embedSeg);
|
||||
root.append(embedRow);
|
||||
|
||||
root.addEventListener("pointerdown", (event) => {
|
||||
|
||||
+81
-7
@@ -1,9 +1,11 @@
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { panelSwitch } from "../shared/panel_controls.mjs";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { isForeignRun, outputKey } from "../shared/prompt_scope.mjs";
|
||||
import { chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
|
||||
import { installNameTokens } from "../shared/name_tokens.mjs";
|
||||
import { fillNodeHeight } from "../shared/panel_layout.mjs";
|
||||
import { addStepConsumer, mountStepOverlay, showsStepsOn, stepRun, stopStepRun, watchSteps } from "../shared/step_preview.mjs";
|
||||
import {
|
||||
findVideoMetadata,
|
||||
isStillImage,
|
||||
@@ -20,7 +22,7 @@ import {
|
||||
|
||||
const NODE_NAME = "AUSBOSS_NODES_SaveVideo";
|
||||
const PREVIEW_WIDGET = "ausboss_save_video_viewer";
|
||||
const PREVIEW_CHROME = 12;
|
||||
const PREVIEW_CHROME = 38;
|
||||
// Height the node opens at. It used to fall out of the panel's computeSize;
|
||||
// with the panel free to follow the node, the default has to be stated. Same
|
||||
// 16:9-ish stage the width-derived formula produced, plus the widget rows.
|
||||
@@ -37,6 +39,40 @@ function getLoopEnabled(node) {
|
||||
return !!node.properties.ausboss_save_video_loop;
|
||||
}
|
||||
|
||||
// The STEPS button: the hidden `step_preview` widget (the card hides it).
|
||||
// On, the player shows each step of the clip's first frame while it is
|
||||
// being made, and a STOP button joins the tools for the run.
|
||||
function stepsOn(state) {
|
||||
return state.stepsWidget?.value === true;
|
||||
}
|
||||
|
||||
function syncSteps(state, run = stepRun()) {
|
||||
const on = stepsOn(state);
|
||||
state.stepsButton?.setAttribute("aria-checked", String(on));
|
||||
state.stopButton?.classList.toggle("live", on && run.status === "running");
|
||||
const overlay = state.stepOverlay;
|
||||
if (!on) {
|
||||
overlay.hide();
|
||||
return;
|
||||
}
|
||||
const thisRun = (id) => id !== null && id !== undefined && String(id) === String(run.promptId);
|
||||
if (run.status === "running") {
|
||||
if (thisRun(state.stepsSaved)) return;
|
||||
if (run.url && showsStepsOn(state.node, run)) {
|
||||
state.stepsShown = run;
|
||||
overlay.show(run, { saves: true });
|
||||
} else if (!thisRun(state.stepsShown?.promptId)) {
|
||||
overlay.hide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (run.status === "stopped" && thisRun(state.stepsShown?.promptId) && !thisRun(state.stepsSaved)) {
|
||||
overlay.show({ ...state.stepsShown, status: "stopped" }, { saves: true });
|
||||
return;
|
||||
}
|
||||
overlay.hide();
|
||||
}
|
||||
|
||||
function setEmpty(state, text) {
|
||||
state.stage.classList.add("is-empty");
|
||||
state.status.textContent = text;
|
||||
@@ -92,9 +128,14 @@ function buildPreview(node) {
|
||||
// button only ever re-fetched what was on screen, and after a page reload
|
||||
// there is no saved file in memory for it to fetch at all.
|
||||
const loopButton = makeToolButton("LOOP", "Toggle looping for this saved preview");
|
||||
tools.append(loopButton);
|
||||
loopButton.classList.add("ausboss-steps-loop");
|
||||
const stopButton = makeToolButton("STOP", "Stop this run now. The next run in the queue still starts.");
|
||||
stopButton.classList.add("ausboss-steps-stop");
|
||||
const stepsButton = panelSwitch("Steps", "Show the clip's first frame here while it is being made, one step at a time, with a Stop button. Off by default.");
|
||||
tools.classList.add("ausboss-steps-tools");
|
||||
tools.append(stopButton, loopButton);
|
||||
stage.append(video, image, status, tools);
|
||||
root.append(stage);
|
||||
root.append(stepsButton, stage);
|
||||
|
||||
const widget = node.addDOMWidget(PREVIEW_WIDGET, "ausboss_video", root, {
|
||||
serialize: false,
|
||||
@@ -107,7 +148,7 @@ function buildPreview(node) {
|
||||
widget.serialize = false;
|
||||
fillNodeHeight(widget, {
|
||||
minWidth: VIDEO_MIN_WIDTH,
|
||||
minHeight: 144,
|
||||
minHeight: 158,
|
||||
minNodeSize: [VIDEO_MIN_WIDTH, 270],
|
||||
exactMinWidth: true,
|
||||
});
|
||||
@@ -116,7 +157,36 @@ function buildPreview(node) {
|
||||
const state = node.__ausbossSaveVideo = {
|
||||
node, root, stage, video, image, status, tools, loopButton,
|
||||
widget, abort, meta: null,
|
||||
stepsWidget: node.widgets?.find((item) => item?.name === "step_preview") ?? null,
|
||||
stepsButton, stopButton,
|
||||
stepOverlay: mountStepOverlay(stage),
|
||||
};
|
||||
// Under the tools, so they stay on top of the picture.
|
||||
stage.insertBefore(state.stepOverlay.root, tools);
|
||||
if (!state.stepsWidget) stepsButton.style.display = "none";
|
||||
for (const button of [stopButton, stepsButton]) {
|
||||
button.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal: abort.signal });
|
||||
}
|
||||
stepsButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!state.stepsWidget) return;
|
||||
const next = !stepsOn(state);
|
||||
state.stepsWidget.value = next;
|
||||
state.stepsWidget.callback?.(next);
|
||||
syncSteps(state);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
notifyAusbossChange();
|
||||
}, { signal: abort.signal });
|
||||
stopButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
Promise.resolve(stopStepRun()).catch(() => {});
|
||||
}, { signal: abort.signal });
|
||||
state.forgetSteps = [
|
||||
addStepConsumer(node, () => stepsOn(state)),
|
||||
watchSteps((run) => syncSteps(state, run)),
|
||||
];
|
||||
const updateLoopButton = () => {
|
||||
const enabled = getLoopEnabled(node);
|
||||
video.loop = enabled;
|
||||
@@ -183,14 +253,17 @@ app.registerExtension({
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onConfigure", function () {
|
||||
queueMicrotask(() => {
|
||||
buildPreview(this);
|
||||
const state = buildPreview(this);
|
||||
suppressCoreVideoPreview(this);
|
||||
if (state) syncSteps(state);
|
||||
});
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onExecuted", function (message) {
|
||||
if (isForeignRun()) return;
|
||||
const state = buildPreview(this);
|
||||
const meta = findVideoMetadata(message);
|
||||
// Saved: the file replaces the steps.
|
||||
if (state) { state.stepsSaved = stepRun().promptId; syncSteps(state); }
|
||||
if (state && meta) loadMetadata(state, meta);
|
||||
else if (state) setEmpty(state, "Execution finished without video metadata");
|
||||
app.canvas?.setDirty?.(true, true);
|
||||
@@ -199,6 +272,7 @@ app.registerExtension({
|
||||
const state = this.__ausbossSaveVideo;
|
||||
state?.video?.pause();
|
||||
state?.abort?.abort();
|
||||
for (const forget of state?.forgetSteps ?? []) forget();
|
||||
this.__ausbossSaveVideo = null;
|
||||
});
|
||||
},
|
||||
|
||||
+124
-13
@@ -11,7 +11,7 @@
|
||||
// is what "last run" shows, so it stays right even after randomize has
|
||||
// already moved the box on to the next number.
|
||||
|
||||
import { app } from "/scripts/app.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { isForeignRun } from "../shared/prompt_scope.mjs";
|
||||
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
|
||||
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
|
||||
@@ -19,6 +19,8 @@ import { copyToClipboard } from "../shared/clipboard.mjs";
|
||||
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
|
||||
import { holdNodeMinWidth, pinVuePanelHeight } from "../shared/panel_layout.mjs";
|
||||
import { formatSeed, pushSeed, seedFromExecuted } from "../shared/seed_history.mjs";
|
||||
import { SEED_LOOK_MENU, SEED_LOOK_SCOPE, controlChip, nextControl, seedLook, setSeedLook } from "../shared/seed_look.mjs";
|
||||
import { gearIconSvg, loadSettings, openSettingsMenu } from "../shared/settings_menu.mjs";
|
||||
|
||||
const NODE_CLASS = "AUSBOSS_NODES_Seed";
|
||||
const CSS_ID = "ausboss-seed-css";
|
||||
@@ -28,8 +30,19 @@ const PROPERTY = "ausboss_seed_history"; // [seed, newest first]
|
||||
// in a double; a fresh seed rolled here obeys the same ceiling.
|
||||
const ROLL_MAX = 0x4000000000000;
|
||||
const CARD_HEIGHT = 158;
|
||||
const PANEL_DECLARED_HEIGHT = CARD_HEIGHT + 20; // + the frontend's wrapper insets
|
||||
// The compact look is one row: 8px card padding, a 32px row, 8px padding.
|
||||
const COMPACT_CARD_HEIGHT = 50;
|
||||
const PANEL_INSETS = 20; // the frontend's wrapper insets
|
||||
const NODE_WIDTH = 300;
|
||||
const COMPACT_NODE_WIDTH = 290;
|
||||
|
||||
function panelHeight(node) {
|
||||
return (seedLook(node) === "compact" ? COMPACT_CARD_HEIGHT : CARD_HEIGHT) + PANEL_INSETS;
|
||||
}
|
||||
|
||||
function nodeWidthFloor(node) {
|
||||
return seedLook(node) === "compact" ? COMPACT_NODE_WIDTH : NODE_WIDTH;
|
||||
}
|
||||
|
||||
let openMenu = null;
|
||||
|
||||
@@ -59,6 +72,21 @@ function ensureCss() {
|
||||
.ausboss-seed-btn:disabled{opacity:.4;cursor:default;pointer-events:none;}
|
||||
.ausboss-seed-hint{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#6f8886;font-size:10.5px;text-align:center;}
|
||||
.ausboss-seed-hint b{color:#9fe3dc;font-weight:600;font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;}
|
||||
.ausboss-seed-top{display:flex;align-items:center;gap:6px;min-width:0;}
|
||||
.ausboss-seed-top .ausboss-seed-field{flex:1 1 auto;min-width:0;}
|
||||
.ausboss-seed-gear{display:grid;place-items:center;}
|
||||
.ausboss-seed-chip,.ausboss-seed-dice{display:none;flex:none;height:32px;border:1px solid #2a3437;border-radius:7px;background:#0f1516;color:#d8ecea;font:600 11.5px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;pointer-events:auto;}
|
||||
.ausboss-seed-chip{min-width:56px;padding:0 7px;}
|
||||
.ausboss-seed-chip.random{border-color:rgba(0,180,170,.55);color:${BRAND};}
|
||||
.ausboss-seed-dice{width:32px;font-size:15px;}
|
||||
.ausboss-seed-chip:hover,.ausboss-seed-dice:hover{border-color:${BRAND};color:#fff;}
|
||||
.ausboss-seed-card.compact{gap:0;}
|
||||
.ausboss-seed-card.compact .ausboss-seed-seg,.ausboss-seed-card.compact .ausboss-seed-row,.ausboss-seed-card.compact .ausboss-seed-hint{display:none;}
|
||||
.ausboss-seed-card.compact .ausboss-seed-chip,.ausboss-seed-card.compact .ausboss-seed-dice{display:block;}
|
||||
.ausboss-seed-card.compact .ausboss-seed-field{height:32px;padding:0 3px 0 9px;}
|
||||
.ausboss-seed-card.compact .ausboss-seed-field input{font-size:14px;}
|
||||
.ausboss-seed-card.compact .ausboss-seed-tag,.ausboss-seed-card.compact .ausboss-seed-icon:not(.ausboss-seed-gear){display:none;}
|
||||
.ausboss-seed-card.compact .ausboss-seed-field input{font-size:13.5px;letter-spacing:0;}
|
||||
.ausboss-seed-menu{position:fixed;z-index:10000;min-width:230px;padding:4px;border:1px solid #3a4047;border-radius:7px;background:#1c1f23;box-shadow:0 8px 28px rgba(0,0,0,.5);font:12px/1.3 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#d7dde2;}
|
||||
.ausboss-seed-menu-head{padding:5px 8px 6px;border-bottom:1px solid #2c3238;color:#78908e;font:10px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.08em;text-transform:uppercase;}
|
||||
.ausboss-seed-menu-item{display:block;width:100%;padding:6px 8px;border:none;border-radius:5px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;}
|
||||
@@ -124,6 +152,7 @@ function render(state) {
|
||||
const linked = seedLinked(node);
|
||||
const value = seedWidget(node)?.value;
|
||||
if (document.activeElement !== state.input) state.input.value = formatSeed(Number(value));
|
||||
fitCompactNumber(state);
|
||||
state.input.disabled = linked;
|
||||
state.field.classList.toggle("linked", linked);
|
||||
state.field.title = linked ? "The seed comes from its input link" : "Click to type a seed";
|
||||
@@ -136,6 +165,11 @@ function render(state) {
|
||||
state.segStep.title = stepping
|
||||
? "Click again to step the other way"
|
||||
: "Increment the seed after each queue (click again for decrement)";
|
||||
const chip = controlChip(control);
|
||||
state.chip.textContent = chip.text;
|
||||
state.chip.title = chip.title;
|
||||
state.chip.classList.toggle("random", control === "randomize");
|
||||
state.card.classList.toggle("compact", seedLook(node) === "compact");
|
||||
state.useLast.disabled = !seeds.length;
|
||||
state.more.disabled = seeds.length < 2;
|
||||
state.hint.textContent = "";
|
||||
@@ -145,6 +179,8 @@ function render(state) {
|
||||
b.textContent = formatSeed(last);
|
||||
state.hint.append(b);
|
||||
if (control === "randomize") state.hint.append(" · rolls a new seed each queue");
|
||||
// The compact look has no hint line: the last run shows on the number.
|
||||
if (seedLook(node) === "compact") state.field.title = `${state.field.title}. Last run: ${formatSeed(last)}`;
|
||||
} else {
|
||||
state.hint.textContent = control === "randomize"
|
||||
? "rolls a new seed each queue"
|
||||
@@ -152,6 +188,16 @@ function render(state) {
|
||||
}
|
||||
}
|
||||
|
||||
// The compact row is narrow, so a long seed gets smaller digits instead of
|
||||
// being cut off: monospace digits are about 0.6 of the font size wide.
|
||||
function fitCompactNumber(state) {
|
||||
const { input } = state;
|
||||
if (seedLook(state.node) !== "compact") { input.style.fontSize = ""; return; }
|
||||
const digits = Math.max(1, String(input.value || "").length);
|
||||
const room = input.clientWidth || 100;
|
||||
input.style.fontSize = `${Math.max(8, Math.min(13.5, room / (0.64 * digits))).toFixed(1)}px`;
|
||||
}
|
||||
|
||||
function showMenu(state) {
|
||||
closeMenu();
|
||||
const seeds = history(state.node);
|
||||
@@ -241,7 +287,9 @@ function buildPanel(node) {
|
||||
input.inputMode = "numeric";
|
||||
input.spellcheck = false;
|
||||
const copy = button("ausboss-seed-icon", "⧉", "Copy the seed");
|
||||
field.append(tag, input, copy);
|
||||
const gear = button("ausboss-seed-icon ausboss-seed-gear", "", "Seed settings: full or compact look");
|
||||
gear.innerHTML = gearIconSvg(12);
|
||||
field.append(tag, input, copy, gear);
|
||||
field.addEventListener("pointerdown", (event) => event.stopPropagation());
|
||||
|
||||
const seg = el("div", "ausboss-seed-seg");
|
||||
@@ -257,11 +305,16 @@ function buildPanel(node) {
|
||||
row.append(newSeed, useLast, more);
|
||||
|
||||
const hint = el("div", "ausboss-seed-hint");
|
||||
card.append(field, seg, row, hint);
|
||||
// Compact look: the mode chip and a dice sit on the number's row.
|
||||
const chip = button("ausboss-seed-chip", "Fixed");
|
||||
const dice = button("ausboss-seed-dice", "🎲", "Roll a fresh seed and pin it (Fixed)");
|
||||
const top = el("div", "ausboss-seed-top");
|
||||
top.append(field, chip, dice);
|
||||
card.append(top, seg, row, hint);
|
||||
root.append(card);
|
||||
|
||||
const state = (node.__ausbossSeed = {
|
||||
node, root, field, tag, input, copy, segRandom, segFixed, segStep, newSeed, useLast, more, hint,
|
||||
node, root, card, field, tag, input, copy, gear, chip, dice, segRandom, segFixed, segStep, newSeed, useLast, more, hint,
|
||||
});
|
||||
|
||||
const commitTyped = () => {
|
||||
@@ -304,6 +357,31 @@ function buildPanel(node) {
|
||||
notifyAusbossChange();
|
||||
});
|
||||
newSeed.addEventListener("click", () => { setSeed(node, rollSeed()); render(state); });
|
||||
dice.addEventListener("click", () => { setSeed(node, rollSeed()); render(state); });
|
||||
// The chip walks the same modes as the full card's buttons, and starts a
|
||||
// new run the same way: into Random rolls now, Fixed into +1 steps now.
|
||||
chip.addEventListener("click", () => {
|
||||
const current = controlWidget(node)?.value ?? "fixed";
|
||||
const next = nextControl(current);
|
||||
setControl(node, next);
|
||||
if (!seedLinked(node)) {
|
||||
const value = Number(seed.value) || 0;
|
||||
if (next === "randomize") setSeed(node, rollSeed(), { pin: false });
|
||||
else if (current === "fixed" && next === "increment") setSeed(node, value + 1, { pin: false });
|
||||
}
|
||||
render(state);
|
||||
notifyAusbossChange();
|
||||
});
|
||||
gear.addEventListener("click", () => {
|
||||
openSettingsMenu({
|
||||
scope: SEED_LOOK_SCOPE,
|
||||
schema: SEED_LOOK_MENU,
|
||||
anchor: gear.getBoundingClientRect(),
|
||||
title: "Seed settings",
|
||||
initial: { look: seedLook(node) },
|
||||
onChange: (values) => applyLook(state, values.look),
|
||||
});
|
||||
});
|
||||
useLast.addEventListener("click", () => {
|
||||
const seeds = history(node);
|
||||
if (seeds.length) { setSeed(node, seeds[0]); render(state); }
|
||||
@@ -318,7 +396,7 @@ function buildPanel(node) {
|
||||
const widget = node.addDOMWidget(WIDGET_NAME, "ausboss_seed", root, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
getMinHeight: () => PANEL_DECLARED_HEIGHT,
|
||||
getMinHeight: () => panelHeight(node),
|
||||
});
|
||||
keepDomWidgetWidthAuto(widget);
|
||||
// Not saved with the workflow either: options.serialize only keeps it out
|
||||
@@ -327,18 +405,20 @@ function buildPanel(node) {
|
||||
// A constant-height card: pinned on purpose (see
|
||||
// tests/panel_guards.test.mjs, fixedByDesign).
|
||||
widget.computeSize = (width) => [
|
||||
Math.max(NODE_WIDTH, Number(width || node.size?.[0] || NODE_WIDTH)),
|
||||
PANEL_DECLARED_HEIGHT,
|
||||
Math.max(nodeWidthFloor(node), Number(width || node.size?.[0] || nodeWidthFloor(node))),
|
||||
panelHeight(node),
|
||||
];
|
||||
// Pinned in Nodes 2.0 as well, where a node can be made taller than its card.
|
||||
pinVuePanelHeight(widget);
|
||||
widget.options.minNodeSize = [NODE_WIDTH, 120];
|
||||
widget.options.minNodeSize = [nodeWidthFloor(node), 60];
|
||||
// The frontend skips a pinned card's width, so its floor is held here.
|
||||
holdNodeMinWidth(widget, NODE_WIDTH);
|
||||
holdNodeMinWidth(widget, nodeWidthFloor(node));
|
||||
state.widget = widget;
|
||||
|
||||
new ResizeObserver(() => fitCompactNumber(state)).observe(input);
|
||||
render(state);
|
||||
node.setSize?.([
|
||||
Math.max(node.size?.[0] ?? 0, NODE_WIDTH),
|
||||
Math.max(node.size?.[0] ?? 0, nodeWidthFloor(node)),
|
||||
node.computeSize?.()[1] ?? node.size?.[1],
|
||||
]);
|
||||
// The card is a fixed-height row: width may grow, but a corner drag
|
||||
@@ -356,18 +436,49 @@ function buildPanel(node) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// Switch looks: the card, the node's floor and its height follow. Going
|
||||
// compact also makes the node as narrow as the compact row allows, and
|
||||
// going full widens it back to the full card's floor.
|
||||
function applyLook(state, look) {
|
||||
const { node } = state;
|
||||
const before = seedLook(node);
|
||||
const after = setSeedLook(node, look);
|
||||
if (before === after) return;
|
||||
render(state);
|
||||
if (state.widget) {
|
||||
state.widget.options.minNodeSize = [nodeWidthFloor(node), 60];
|
||||
holdNodeMinWidth(state.widget, nodeWidthFloor(node));
|
||||
}
|
||||
const width = after === "compact" ? COMPACT_NODE_WIDTH : Math.max(node.size?.[0] ?? 0, NODE_WIDTH);
|
||||
node.setSize?.([width, node.computeSize?.()[1] ?? node.size?.[1]]);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
notifyAusbossChange();
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "AusBoss.Seed",
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== NODE_CLASS) return;
|
||||
hideInputsInDef(nodeData, ["seed"]);
|
||||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||
buildPanel(this);
|
||||
// A new node starts in the look last picked from any Seed's gear. A
|
||||
// loaded one keeps its own: onConfigure restores its properties after.
|
||||
if (loadSettings(SEED_LOOK_SCOPE, SEED_LOOK_MENU).look === "compact") setSeedLook(this, "compact");
|
||||
const state = buildPanel(this);
|
||||
if (state && seedLook(this) === "compact") {
|
||||
this.setSize?.([COMPACT_NODE_WIDTH, this.computeSize?.()[1] ?? this.size?.[1]]);
|
||||
}
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onConfigure", function () {
|
||||
queueMicrotask(() => {
|
||||
const state = buildPanel(this);
|
||||
if (state) render(state);
|
||||
if (!state) return;
|
||||
render(state);
|
||||
// A saved compact Seed: lower the floor to the compact row's.
|
||||
if (state.widget) {
|
||||
state.widget.options.minNodeSize = [nodeWidthFloor(this), 60];
|
||||
holdNodeMinWidth(state.widget, nodeWidthFloor(this));
|
||||
}
|
||||
});
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onConnectionsChange", function () {
|
||||
|
||||
@@ -45,6 +45,24 @@ const LINK = `#(${ID})`;
|
||||
const WORDS_LINK_RE = /\[([^\[\]\n]+)\]\(([^\s()]+(?:\([^\s()]*\)[^\s()]*)*)\)/g;
|
||||
const URL_MAX = 2000;
|
||||
|
||||
// Only a whole, ordinary web link gets the simple label/address form. Keep
|
||||
// multi-paragraph or mixed-content notes in the text editor, without loss.
|
||||
export function tileLinkFields(text) {
|
||||
const value = String(text ?? "");
|
||||
const match = /^\[([^\[\]\n]+)\]\((.+)\)$/.exec(value);
|
||||
if (!match || !linkTarget(match[2])?.url) return null;
|
||||
const blocks = parseBlocks(value);
|
||||
if (blocks.length !== 1 || blocks[0].length !== 1
|
||||
|| blocks[0][0].some(piece => piece.url !== match[2])) return null;
|
||||
return { label: match[1], url: match[2] };
|
||||
}
|
||||
|
||||
export function tileLinkText(label, url) {
|
||||
const text = `[${label}](${url.trim()})`;
|
||||
if (!tileLinkFields(text)) throw new Error("Enter a label without square brackets and a complete https:// or http:// address.");
|
||||
return text;
|
||||
}
|
||||
|
||||
// Where a link goes: { url }, { link } (a node's id) or { group } (a group's
|
||||
// id), or null when the text is none of them. `raw` is what stands in the
|
||||
// round brackets, or after an arrow's #.
|
||||
@@ -250,3 +268,24 @@ export function pastedLink(selected, pasted) {
|
||||
if (linkTarget(words.trim())?.url) return null; // an address over an address is just a paste
|
||||
return `[${words}](${target.url})`;
|
||||
}
|
||||
|
||||
// A pasted note's text, its links pointed at the pasted copies of the nodes
|
||||
// and groups they name. `nodes` and `groups` map an old id (as text) to its
|
||||
// copy's id (js/shared/paste_ids.mjs). A link to anything the paste did not
|
||||
// copy is kept as typed. Returns null when no link changes.
|
||||
export function repointLinks(text, nodes, groups) {
|
||||
let changed = false;
|
||||
const follow = (raw) => {
|
||||
const target = linkTarget(raw);
|
||||
const isGroup = target?.group !== undefined;
|
||||
const old = isGroup ? target.group : target?.link;
|
||||
const copy = old === undefined ? undefined : (isGroup ? groups : nodes)?.get(String(old));
|
||||
if (copy === undefined || copy === null) return raw;
|
||||
changed = true;
|
||||
return `#${isGroup ? "g" : ""}${copy}`;
|
||||
};
|
||||
const next = String(text ?? "")
|
||||
.replace(WORDS_LINK_RE, (_all, words, address) => `[${words}](${follow(address)})`)
|
||||
.replace(ARROW_RE, (all, _arrow, id) => (id === undefined ? all : `${all.slice(0, -id.length - 1)}${follow(`#${id}`)}`));
|
||||
return changed ? next : null;
|
||||
}
|
||||
|
||||
@@ -72,12 +72,14 @@ export function panelRoot(target) {
|
||||
return root;
|
||||
}
|
||||
|
||||
// A drag that carries files, from the file manager or the desktop. A row
|
||||
// being dragged inside a panel carries none and stays the panel's own.
|
||||
// A drag that carries files, from the file manager or the desktop, or a card
|
||||
// from ComfyUI's Assets panel, which names a picture it made ("... [output]")
|
||||
// instead of carrying the file. A row being dragged inside a panel carries
|
||||
// neither and stays the panel's own.
|
||||
export function dragCarriesFiles(event) {
|
||||
const types = event?.dataTransfer?.types;
|
||||
if (!types) return false;
|
||||
return Array.from(types).includes("Files");
|
||||
return Array.from(types).some((type) => type === "Files" || type === "application/x-comfy-asset-info");
|
||||
}
|
||||
|
||||
// Does a file dropped here have to be handed to its node by the pack? The
|
||||
|
||||
@@ -26,6 +26,81 @@ export function parseCivitaiBody(text) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
// What to tell the person when a Civitai call fails. A browser that cannot
|
||||
// reach the site at all only says "Failed to fetch", which explains nothing.
|
||||
export function civitaiFailureText(error) {
|
||||
if (error instanceof TypeError) {
|
||||
return "Could not reach civitai.com. A firewall, a VPN or starting ComfyUI "
|
||||
+ "with --disable-api-nodes can block it.";
|
||||
}
|
||||
return String(error?.message || error || "The Civitai lookup failed.");
|
||||
}
|
||||
|
||||
export function civitaiButtonLabel(hasCivitai) {
|
||||
return hasCivitai ? "Refresh Civitai info" : "Fetch Civitai info";
|
||||
}
|
||||
|
||||
// ---------- the LoRA's preview picture ----------
|
||||
// After the lookup, the browser also downloads the LoRA's first picture from
|
||||
// Civitai's picture server, and the pack's server saves it beside the LoRA.
|
||||
|
||||
export const PICTURE_HOST = "image.civitai.com";
|
||||
export const PICTURE_WIDTH = 450;
|
||||
export const MAX_PICTURE_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
// Civitai rates each picture. The number is a set of flags: 1 = PG,
|
||||
// 2 = PG-13, 4 = R, 8 = X, 16 = XXX. Older answers give a word instead.
|
||||
const MILD_LEVEL = 2;
|
||||
const MILD_WORDS = new Set(["none", "soft"]);
|
||||
|
||||
export function isMildPicture(image) {
|
||||
const level = Number(image?.nsfwLevel);
|
||||
if (Number.isFinite(level) && level > 0) return level <= MILD_LEVEL;
|
||||
if (typeof image?.nsfw === "string") return MILD_WORDS.has(image.nsfw.toLowerCase());
|
||||
if (image?.nsfw === false) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// The first still picture in Civitai's answer that may be saved: PG and
|
||||
// PG-13 only, unless any rating is allowed.
|
||||
export function pickCivitaiPicture(info, anyRating = false) {
|
||||
const images = Array.isArray(info?.images) ? info.images : [];
|
||||
return images.find((image) =>
|
||||
image && typeof image.url === "string"
|
||||
&& (image.type ?? "image") === "image"
|
||||
&& (anyRating || isMildPicture(image))
|
||||
&& smallPictureUrl(image.url) !== null) ?? null;
|
||||
}
|
||||
|
||||
// The address of a 450-pixel copy of the picture, or null for any address
|
||||
// that is not Civitai's picture server over https.
|
||||
export function smallPictureUrl(url) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.protocol !== "https:" || parsed.hostname !== PICTURE_HOST) return null;
|
||||
const parts = parsed.pathname.split("/");
|
||||
const sizing = parts.findIndex((part, index) => index > 1 && index < parts.length - 1 && part.includes("="));
|
||||
if (sizing >= 0) parts[sizing] = `width=${PICTURE_WIDTH}`;
|
||||
else if (parts.length >= 3) parts.splice(parts.length - 1, 0, `width=${PICTURE_WIDTH}`);
|
||||
else return null;
|
||||
parsed.pathname = parts.join("/");
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
return parsed.href;
|
||||
}
|
||||
|
||||
// Where the picture really came from, after Civitai's redirect to its file
|
||||
// store: Civitai's own servers, over https.
|
||||
export function isCivitaiAddress(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "https:"
|
||||
&& (parsed.hostname === "civitai.com" || parsed.hostname.endsWith(".civitai.com"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,18 @@ export function fitRect(boxWidth, boxHeight, width, height) {
|
||||
return { left: (boxWidth - fitWidth) / 2, top: (boxHeight - fitHeight) / 2, width: fitWidth, height: fitHeight, scale };
|
||||
}
|
||||
|
||||
// A see-through picture shows over a checkerboard, as in an editor: its
|
||||
// squares' side in CSS pixels and their two colours.
|
||||
export const CHECKER = { side: 8, light: "#4d4d4d", dark: "#383838" };
|
||||
|
||||
// The padding that holds a contained picture's own background to the
|
||||
// picture: the bars beside it, as CSS. Empty when there is nothing to fit.
|
||||
export function backdropPadding(boxWidth, boxHeight, width, height) {
|
||||
const fit = fitRect(boxWidth, boxHeight, width, height);
|
||||
if (!(fit.scale > 0)) return "";
|
||||
return `${fit.top}px ${fit.left}px`;
|
||||
}
|
||||
|
||||
// A place in the box as a place on the picture, in picture pixels.
|
||||
export function toPicture(fit, x, y) {
|
||||
if (!(fit?.scale > 0)) return null;
|
||||
|
||||
@@ -5,15 +5,16 @@
|
||||
// a mask canvas holds what has been rubbed away, and the view shows the
|
||||
// picture underneath with the top picture, less that mask, over it. Save
|
||||
// sends the strokes to the server, which builds the mix at full size.
|
||||
import { api } from "/scripts/api.js";
|
||||
import { compactAction } from "./panel_controls.mjs";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { BRAND, notifyAusbossChange, showToast } from "./index.mjs";
|
||||
import { brushAdjusts, brushFlips, brushKey, brushWords, toolKey } from "./brush.mjs";
|
||||
import { keepKeyInField } from "./canvas_passthrough.mjs";
|
||||
import { makeScrubInput } from "./scrub_input.mjs";
|
||||
import { makeSlideSwitch } from "./slide_switch.mjs";
|
||||
import { extendStroke } from "./touch_math.mjs";
|
||||
import { makeToolButton } from "./video_ui.mjs";
|
||||
import {
|
||||
CHECKER,
|
||||
ERASE_OUTSIDE,
|
||||
ERASE_SIZE,
|
||||
ERASE_SOFT_DEFAULT,
|
||||
@@ -81,6 +82,7 @@ const CAN_FILTER = typeof CanvasRenderingContext2D !== "undefined" && "filter" i
|
||||
export const ERASE_CSS = `
|
||||
.ausboss-compare-erasebar{display:none;box-sizing:border-box;flex-wrap:wrap;align-items:center;gap:4px 6px;flex:none;min-height:${ERASE_BAR_HEIGHT}px;}
|
||||
.ausboss-compare-root.is-erasing .ausboss-compare-erasebar{display:flex;}
|
||||
.ausboss-compare-brush-swatch{box-sizing:border-box;flex:none;width:24px;height:24px;border-radius:4px;background:#0b1113;border:1px solid rgba(0,180,170,.3);}
|
||||
.ausboss-compare-erasebar .ausboss-scrub{flex:none;}
|
||||
.ausboss-compare-field{display:flex;align-items:center;gap:4px;flex:none;}
|
||||
.ausboss-compare-label{flex:none;color:#9fb6b4;font-size:10px;line-height:22px;white-space:nowrap;user-select:none;}
|
||||
@@ -227,9 +229,51 @@ function sizeView(er) {
|
||||
}
|
||||
}
|
||||
|
||||
// See-through parts show over a checkerboard, as on the Slide view, its
|
||||
// squares as big on screen.
|
||||
function drawCheckerboard(er, ctx, fit) {
|
||||
const side = Math.max(2, Math.round(CHECKER.side * er.canvas.width / Math.max(1, er.canvas.clientWidth)));
|
||||
if (er.checker?.side !== side) {
|
||||
const tile = document.createElement("canvas");
|
||||
const tileCtx = sized(tile, side * 2, side * 2);
|
||||
tileCtx.fillStyle = CHECKER.dark;
|
||||
tileCtx.fillRect(0, 0, side * 2, side * 2);
|
||||
tileCtx.fillStyle = CHECKER.light;
|
||||
tileCtx.fillRect(0, 0, side, side);
|
||||
tileCtx.fillRect(side, side, side, side);
|
||||
er.checker = { side, pattern: ctx.createPattern(tile, "repeat") };
|
||||
}
|
||||
er.checker.pattern.setTransform?.(new DOMMatrix([1, 0, 0, 1, fit.left, fit.top]));
|
||||
ctx.fillStyle = er.checker.pattern;
|
||||
ctx.fillRect(fit.left, fit.top, fit.width, fit.height);
|
||||
}
|
||||
|
||||
// Use the cursor's actual dab. A compressed size scale keeps both a
|
||||
// two-pixel brush and a two-thousand-pixel brush legible in the toolbar.
|
||||
function drawBrushSwatch(er) {
|
||||
const size = brushSize(er);
|
||||
const soft = brushSoft(er);
|
||||
const back = er.back !== Boolean(er.flipKey);
|
||||
const key = `${size}|${soft}|${back}`;
|
||||
if (er.swatchKey === key) return;
|
||||
er.swatchKey = key;
|
||||
const swatch = er.swatch;
|
||||
const side = swatch.width;
|
||||
const fraction = Math.log(size / ERASE_SIZE[0]) / Math.log(ERASE_SIZE[1] / ERASE_SIZE[0]);
|
||||
const diameter = side * (0.22 + 0.66 * fraction);
|
||||
const dab = dabOf(er, size, soft, diameter / size, back ? BACK_TINT : RUB_TINT);
|
||||
const ctx = swatch.getContext("2d");
|
||||
ctx.clearRect(0, 0, side, side);
|
||||
ctx.drawImage(dab.canvas, side / 2 - dab.reach, side / 2 - dab.reach);
|
||||
swatch.title = `${back ? "Restore" : "Erase"} brush: ${size} px, ${soft}% soft`;
|
||||
swatch.setAttribute("aria-label", swatch.title);
|
||||
}
|
||||
|
||||
function draw(er) {
|
||||
er.frame = 0;
|
||||
if (!er.active || !er.refs) return;
|
||||
if (!er.active) return;
|
||||
drawBrushSwatch(er);
|
||||
if (!er.refs) return;
|
||||
sizeView(er);
|
||||
const { canvas } = er;
|
||||
const ctx = canvas.getContext("2d");
|
||||
@@ -256,6 +300,7 @@ function draw(er) {
|
||||
cut.drawImage(top, 0, 0, width, height);
|
||||
cut.globalCompositeOperation = "destination-out";
|
||||
cut.drawImage(mask, 0, 0, width, height);
|
||||
if (er.refs.a?.alpha || er.refs.b?.alpha) drawCheckerboard(er, ctx, fit);
|
||||
ctx.drawImage(under, fit.left, fit.top, width, height);
|
||||
ctx.drawImage(er.cut, fit.left, fit.top);
|
||||
|
||||
@@ -356,7 +401,7 @@ function syncTools(er) {
|
||||
er.layersSwitch.set(layers ? "on" : "off");
|
||||
const aligned = Boolean(er.refs?.aligned);
|
||||
er.alignButton.disabled = !ready || er.aligning;
|
||||
er.alignButton.textContent = er.aligning ? "ALIGNING" : "ALIGN";
|
||||
er.alignButton.textContent = er.aligning ? "Aligning" : "Align";
|
||||
er.alignButton.classList.toggle("active", aligned);
|
||||
er.alignButton.title = !ready
|
||||
? "Run the workflow first: Align needs the two pictures."
|
||||
@@ -806,6 +851,11 @@ export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved,
|
||||
return wrap;
|
||||
};
|
||||
const sizeField = scrubField("Size", er.sizeBox, SIZE_HELP);
|
||||
er.swatch = document.createElement("canvas");
|
||||
er.swatch.className = "ausboss-compare-brush-swatch";
|
||||
er.swatch.width = er.swatch.height = 64;
|
||||
er.swatch.setAttribute("role", "img");
|
||||
sizeField.prepend(er.swatch);
|
||||
const softField = scrubField("Soft", er.softBox, SOFT_HELP);
|
||||
const TOP_HELP = "Which picture you start from. The brush shows the other one through it. Swapping keeps your paint.";
|
||||
er.topSwitch = makeSlideSwitch({
|
||||
@@ -825,7 +875,7 @@ export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved,
|
||||
// Align, Undo and Clear stay together at the right end, also on a second line.
|
||||
const steps = document.createElement("div");
|
||||
steps.className = "ausboss-compare-steps";
|
||||
er.undoButton = click(makeToolButton("UNDO", "Take back the last stroke, or a Clear."), () => {
|
||||
er.undoButton = click(compactAction("Undo", "Take back the last stroke, or a Clear."), () => {
|
||||
if (!er.history.length && !er.strokes.length) return;
|
||||
// Paint that came with the workflow has no steps of its own to go back
|
||||
// through: its last stroke is taken off.
|
||||
@@ -835,13 +885,13 @@ export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved,
|
||||
syncTools(er);
|
||||
schedule(er);
|
||||
});
|
||||
er.clearButton = click(makeToolButton("CLEAR", "Take all the paint off. Undo brings it back."), () => {
|
||||
er.clearButton = click(compactAction("Clear", "Take all the paint off. Undo brings it back."), () => {
|
||||
if (!er.strokes.length) return;
|
||||
change(er, []);
|
||||
rebuildMask(er);
|
||||
schedule(er);
|
||||
});
|
||||
er.alignButton = click(makeToolButton("ALIGN", ""), () => align(er));
|
||||
er.alignButton = click(compactAction("Align", ""), () => align(er));
|
||||
steps.append(er.alignButton, er.undoButton, er.clearButton);
|
||||
er.bar.append(er.kindSwitch.root, sizeField, softField, topField, steps);
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// The row of chips on H3 Prompt Writer: each picture plugged in, with its @
|
||||
// name, a small copy of it and its job, then the video and its job. A slot with nothing plugged in is a faint
|
||||
// dashed chip, so it reads as optional. The widgets underneath stay the
|
||||
// source of truth: a chip only changes their value.
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { BRAND, chainCallback } from "./index.mjs";
|
||||
import { liftSocket } from "./widget_card.mjs";
|
||||
import { commitWidgetValue, comboValues } from "./widget_card_math.mjs";
|
||||
|
||||
const CSS_ID = "ausboss-cast-chips-css";
|
||||
const WATCH = 1000; // Workflow Switches can turn a loader off without touching this node.
|
||||
|
||||
function ensureCss() {
|
||||
if (document.getElementById(CSS_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = CSS_ID;
|
||||
style.textContent = `
|
||||
.ausboss-chips{display:flex;flex-wrap:wrap;align-content:flex-start;gap:5px;height:100%;min-width:0;font:600 10.5px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||
.ausboss-chip{position:relative;display:flex;align-items:center;gap:4px;height:24px;max-width:100%;padding:0 8px;box-sizing:border-box;border:1px solid #2a3437;border-radius:12px;background:#0b0f10;color:#d8ecea;white-space:nowrap}
|
||||
.ausboss-chip:hover{border-color:${BRAND}}
|
||||
.ausboss-chip img{flex:none;width:18px;height:18px;margin-left:-5px;border-radius:9px;object-fit:cover}
|
||||
.ausboss-chip b{flex:none;color:${BRAND};font:700 10px ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
.ausboss-chip em{flex:none;color:#6f8886;font-style:normal;font-weight:500}
|
||||
.ausboss-chip select{field-sizing:content;min-width:0;max-width:120px;height:22px;padding:0 11px 0 0;border:none;background:transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' fill='none' stroke='%236f8886' stroke-width='1.4'/%3E%3C/svg%3E") no-repeat right 1px center;color:#d8ecea;font:inherit;appearance:none;-webkit-appearance:none;cursor:pointer;text-overflow:ellipsis}
|
||||
.ausboss-chip select:focus-visible{outline:2px solid ${BRAND};outline-offset:2px}
|
||||
.ausboss-chip select option{background:#0f1516;color:#d8ecea}
|
||||
.ausboss-chip.off{border-style:dashed;background:transparent;color:#56706d}
|
||||
.ausboss-chip.off:hover{border-color:#2a3437}
|
||||
.ausboss-chip.wired{color:#8ba3a1}
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
function linkSource(node, name) {
|
||||
const slot = node.inputs?.find((input) => input?.widget?.name === name || input?.name === name);
|
||||
if (slot?.link === null || slot?.link === undefined) return null;
|
||||
const graph = node.graph;
|
||||
const link = graph?.links?.[slot.link] ?? graph?._links?.get?.(slot.link);
|
||||
const source = graph?.getNodeById?.(link?.origin_id);
|
||||
return (source?.mode ?? 0) === 0 ? source ?? {} : null;
|
||||
}
|
||||
|
||||
const widgetOf = (node, name) => node.widgets?.find((widget) => widget.name === name) ?? null;
|
||||
|
||||
function chip(className = "") {
|
||||
const element = document.createElement("label");
|
||||
element.className = `ausboss-chip ${className}`.trim();
|
||||
return element;
|
||||
}
|
||||
|
||||
function add(parent, tag, text, className) {
|
||||
const child = document.createElement(tag);
|
||||
if (className) child.className = className;
|
||||
if (text !== undefined) child.textContent = text;
|
||||
parent.append(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
function choice(node, name, into) {
|
||||
const widget = widgetOf(node, name);
|
||||
const select = add(into, "select");
|
||||
for (const value of comboValues(widget)) add(select, "option", value).value = value;
|
||||
select.value = widget?.value ?? "";
|
||||
select.title = widget?.options?.tooltip ?? "";
|
||||
select.addEventListener("change", () => {
|
||||
if (widget) commitWidgetValue(node, widget, select.value, app.canvas);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
});
|
||||
return (values) => { if (document.activeElement !== select) select.value = values[name] ?? ""; };
|
||||
}
|
||||
|
||||
// The jobs have no socket of their own, but a page saved by an earlier
|
||||
// release may have one wired: that link stays, in the slot column.
|
||||
const JOBS = ["job_1", "job_2", "job_3", "job_4", "video_job"];
|
||||
const keepOldJobLinks = (node) => {
|
||||
for (const name of JOBS) {
|
||||
const slot = node.inputs?.find((input) => input?.widget?.name === name);
|
||||
if (slot && slot.link !== null && slot.link !== undefined) liftSocket(node, name);
|
||||
}
|
||||
};
|
||||
|
||||
// A card row: { panel: castChips, height }.
|
||||
export function castChips(node) {
|
||||
ensureCss();
|
||||
const element = document.createElement("div");
|
||||
element.className = "ausboss-chips";
|
||||
queueMicrotask(() => keepOldJobLinks(node));
|
||||
|
||||
let shape = "";
|
||||
let syncs = [];
|
||||
let latest = {};
|
||||
const build = (values) => {
|
||||
element.textContent = "";
|
||||
syncs = [];
|
||||
for (const number of [1, 2, 3, 4]) {
|
||||
const source = linkSource(node, `picture_${number}`);
|
||||
if (!source) {
|
||||
const empty = chip("off");
|
||||
empty.textContent = `+ @${number}`;
|
||||
empty.title = `Plug a picture into picture_${number} to name it @${number} in your lines.`;
|
||||
element.append(empty);
|
||||
continue;
|
||||
}
|
||||
const item = chip();
|
||||
const url = source.imgs?.[0]?.src;
|
||||
if (url) add(item, "img").src = url;
|
||||
add(item, "b", `@${number}`);
|
||||
if (linkSource(node, `job_${number}`)) add(item, "span", "job from its wire");
|
||||
else syncs.push(choice(node, `job_${number}`, item));
|
||||
element.append(item);
|
||||
}
|
||||
if (linkSource(node, "video")) {
|
||||
const item = chip();
|
||||
add(item, "b", "video");
|
||||
if (linkSource(node, "video_job")) add(item, "span", "job from its wire");
|
||||
else syncs.push(choice(node, "video_job", item));
|
||||
element.append(item);
|
||||
} else {
|
||||
const empty = chip("off");
|
||||
empty.textContent = "+ video";
|
||||
empty.title = "Plug a video into video to copy its moves, swap the person, keep it going or give it a new voice.";
|
||||
element.append(empty);
|
||||
}
|
||||
|
||||
for (const run of syncs) run(values);
|
||||
};
|
||||
|
||||
const shapeOf = () => JSON.stringify(["picture_1", "picture_2", "picture_3", "picture_4", "video", ...JOBS].map((name) => {
|
||||
const source = linkSource(node, name);
|
||||
return source ? [source.id ?? 1, source.imgs?.[0]?.src ?? ""] : 0;
|
||||
}));
|
||||
const sync = (values) => {
|
||||
latest = values;
|
||||
const now = shapeOf();
|
||||
if (now !== shape) { shape = now; build(values); return; }
|
||||
for (const run of syncs) run(values);
|
||||
};
|
||||
const watch = setInterval(() => sync(latest), WATCH);
|
||||
chainCallback(node, "onRemoved", () => clearInterval(watch));
|
||||
chainCallback(node, "onConfigure", () => queueMicrotask(() => keepOldJobLinks(node)));
|
||||
return { element, sync };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// The same live-input rule used by the writer timeline: muted/bypassed
|
||||
// sources do not reach this node. This inspects the wire, never media bytes.
|
||||
export function linkSource(node, name) {
|
||||
const slot = node.inputs?.find((input) => input?.widget?.name === name || input?.name === name);
|
||||
if (slot?.link === null || slot?.link === undefined) return null;
|
||||
const graph = node.graph;
|
||||
const link = graph?.links?.[slot.link] ?? graph?._links?.get?.(slot.link);
|
||||
const source = graph?.getNodeById?.(link?.origin_id);
|
||||
return (source?.mode ?? 0) === 0 ? source ?? null : null;
|
||||
}
|
||||
|
||||
// Keep in step with Python KEEPS_ITS_LENGTH. Other jobs use seconds normally.
|
||||
export function clipSetsDuration(node, values) {
|
||||
return ["swap the person", "new voice", "change something"].includes(values.video_job)
|
||||
&& linkSource(node, "video") !== null;
|
||||
}
|
||||
@@ -87,14 +87,18 @@ export function nameAt(picked, names, position, atTheEnd) {
|
||||
return atTheEnd === "start over" ? list[(place - 1) % list.length] : null;
|
||||
}
|
||||
|
||||
// Picture after a run is queued. "next" may reach one past the last picture,
|
||||
// where the node stops the run; it never runs further than that. "random"
|
||||
// jumps to any picked picture (`roll` is 0..1, for tests).
|
||||
export function nextPosition(position, count, afterRun, roll = Math.random()) {
|
||||
// Picture after a run is queued. With "stop the run", "next" may reach one
|
||||
// past the last picture, where the node stops the run; it never runs further
|
||||
// than that. With "start over", "next" wraps from the last picture back to
|
||||
// the first, so Picture always names a real picture. A Picture already past
|
||||
// the end (saved by an older version) steps on from the picture it loaded.
|
||||
// "random" jumps to any picked picture (`roll` is 0..1, for tests).
|
||||
export function nextPosition(position, count, afterRun, atTheEnd = "stop the run", roll = Math.random()) {
|
||||
const place = Math.max(1, Math.round(Number(position) || 1));
|
||||
if (count < 1) return place;
|
||||
if (afterRun === "random") return Math.min(count, Math.floor(roll * count) + 1);
|
||||
if (afterRun !== "next") return place;
|
||||
if (atTheEnd === "start over") return (place % count) + 1;
|
||||
return Math.min(place + 1, count + 1);
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -4,12 +4,12 @@
|
||||
// which makes this the right home for import-only utilities. Per-node
|
||||
// entry points live in js/<node>/index.js and import from here.
|
||||
|
||||
import { app } from "/scripts/app.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
// Must match `version` in pyproject.toml — scripts/release_preflight.py
|
||||
// enforces it. The backend serves its copy at /ausboss/pack_version; a
|
||||
// mismatch means the browser cached JavaScript from an older install.
|
||||
export const AUSBOSS_JS_VERSION = "2.17.0";
|
||||
export const AUSBOSS_JS_VERSION = "2.21.1";
|
||||
|
||||
export const BRAND = "#00b4aa"; // AusBoss teal — keep node accents consistent.
|
||||
export const BRAND_DARK = "#007f78";
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// 2. an uploadable image/video file combo widget -> deterministic /view query
|
||||
// 3. nothing -> the caller shows a quiet placeholder
|
||||
|
||||
import { parseImageReference } from "./pad_canvas.mjs";
|
||||
import { splitMediaName } from "./video_preview.mjs";
|
||||
|
||||
// Core loader nodes keep the uploaded file name in a combo widget with one of
|
||||
@@ -37,11 +38,13 @@ export function sourceFileWidget(sourceNode) {
|
||||
return sourceNode?.widgets?.find((widget) => FILE_WIDGET_NAMES.has(widget?.name)) ?? null;
|
||||
}
|
||||
|
||||
// Deterministic /view query (no cache buster) for an input-folder file name,
|
||||
// tolerating Windows separators and " [input]" annotations.
|
||||
// Deterministic /view query (no cache buster) for a file widget's name,
|
||||
// tolerating Windows separators. A name without a tag is in the input
|
||||
// folder; "photo.png [output]" or "[temp]" is read from that folder.
|
||||
export function viewQueryForFile(name) {
|
||||
const { filename, subfolder } = splitMediaName(name);
|
||||
return new URLSearchParams({ filename, subfolder, type: "input" }).toString();
|
||||
const type = parseImageReference(name)?.type ?? "input";
|
||||
return new URLSearchParams({ filename, subfolder, type }).toString();
|
||||
}
|
||||
|
||||
// Describe how to preview a source node:
|
||||
@@ -117,9 +120,13 @@ export function describeNodePreview(node, inputName, storedUrl = null) {
|
||||
?? describeSourcePreview(upstreamNode(node, inputName));
|
||||
}
|
||||
|
||||
// Quiet ASCII placeholder copy; never an error.
|
||||
export function placeholderText(connected, noun = "an image") {
|
||||
return connected ? "Run the workflow to see it here" : `Connect ${noun} to see it here`;
|
||||
// Quiet ASCII placeholder copy; never an error. `running`: a run is on its
|
||||
// way to this node, which shows its steps ("steps") or only the result.
|
||||
export function placeholderText(connected, noun = "an image", running = false) {
|
||||
if (!connected) return `Connect ${noun} to see it here`;
|
||||
if (running === "steps") return "Running. The steps show here once the picture starts";
|
||||
if (running) return "Running. The picture shows here when it is saved";
|
||||
return "Run the workflow to see it here";
|
||||
}
|
||||
|
||||
// The node ran while its preview was off, so its last picture is older
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// Line up, same size and even spacing for a selection of nodes and groups.
|
||||
// No LiteGraph here; js/line_up/index.js reads the selection and moves it.
|
||||
//
|
||||
// A rect is { x, y, w, h } in graph units: the outline people see, title bar
|
||||
// included. Each function returns one result per rect, in the order given.
|
||||
|
||||
import { unionOf } from "./snap_lines.mjs";
|
||||
|
||||
export const LINE_UP = ["left", "middleX", "right", "top", "middleY", "bottom"];
|
||||
export const SAME_SIZE = ["widest", "narrowest", "tallest", "shortest"];
|
||||
export const SPACING = ["across", "down"];
|
||||
|
||||
// Where each rect goes, as { x, y }, so they all share one edge or middle.
|
||||
// Edges line up on the outermost edge of the selection, middles on its middle.
|
||||
export function lineUp(rects, how) {
|
||||
const bounds = unionOf(rects);
|
||||
if (!bounds) return [];
|
||||
return rects.map((rect) => {
|
||||
switch (how) {
|
||||
case "left":
|
||||
return { x: bounds.x, y: rect.y };
|
||||
case "middleX":
|
||||
return { x: bounds.x + bounds.w / 2 - rect.w / 2, y: rect.y };
|
||||
case "right":
|
||||
return { x: bounds.x + bounds.w - rect.w, y: rect.y };
|
||||
case "top":
|
||||
return { x: rect.x, y: bounds.y };
|
||||
case "middleY":
|
||||
return { x: rect.x, y: bounds.y + bounds.h / 2 - rect.h / 2 };
|
||||
case "bottom":
|
||||
return { x: rect.x, y: bounds.y + bounds.h - rect.h };
|
||||
default:
|
||||
return { x: rect.x, y: rect.y };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The size each rect takes, as { w, h }, so they share the width or height of
|
||||
// the widest, narrowest, tallest or shortest one.
|
||||
export function sameSize(rects, how) {
|
||||
if (!rects.length) return [];
|
||||
const widths = rects.map((rect) => rect.w);
|
||||
const heights = rects.map((rect) => rect.h);
|
||||
const w = how === "widest" ? Math.max(...widths) : how === "narrowest" ? Math.min(...widths) : null;
|
||||
const h = how === "tallest" ? Math.max(...heights) : how === "shortest" ? Math.min(...heights) : null;
|
||||
return rects.map((rect) => ({ w: w ?? rect.w, h: h ?? rect.h }));
|
||||
}
|
||||
|
||||
// Nodes never end up on top of each other: a gap under this, overlaps
|
||||
// included, becomes this, and the selection grows from its first edge.
|
||||
export const MIN_GAP = 20;
|
||||
|
||||
// Equal gaps between the rects along one axis, as { x, y } per rect. They keep
|
||||
// their order (by middle) and the selection keeps its outer edges, unless the
|
||||
// gaps would come out under MIN_GAP. Fewer than three rects stay put.
|
||||
export function spreadEvenly(rects, axis) {
|
||||
const [start, size] = axis === "across" ? ["x", "w"] : ["y", "h"];
|
||||
const out = rects.map((rect) => ({ x: rect.x, y: rect.y }));
|
||||
if (rects.length < 3) return out;
|
||||
const order = rects.map((rect, index) => index).sort((a, b) => {
|
||||
const middle = (rect) => rect[start] + rect[size] / 2;
|
||||
return middle(rects[a]) - middle(rects[b]) || rects[a][start] - rects[b][start];
|
||||
});
|
||||
const first = Math.min(...rects.map((rect) => rect[start]));
|
||||
const last = Math.max(...rects.map((rect) => rect[start] + rect[size]));
|
||||
const filled = rects.reduce((sum, rect) => sum + rect[size], 0);
|
||||
const gap = Math.max(MIN_GAP, (last - first - filled) / (rects.length - 1));
|
||||
let at = first;
|
||||
for (const index of order) {
|
||||
out[index][start] = at;
|
||||
at += rects[index][size] + gap;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// How many rects each kind of tool needs before it can do anything.
|
||||
export function enoughFor(kind, count) {
|
||||
return kind === "spacing" ? count >= 3 : count >= 2;
|
||||
}
|
||||
|
||||
// One node of a selection is being resized: every other one takes its width.
|
||||
// `starts` are where the others were when the button went down, as
|
||||
// { x, w, least } (least is the narrowest that node can be). A pull on the
|
||||
// left edge keeps each right edge where it was; any other pull keeps each
|
||||
// left edge. Returns { x, w } per start, in the order given.
|
||||
export function matchWidth(width, starts, { fromLeft = false } = {}) {
|
||||
return starts.map(({ x, w, least = 0 }) => {
|
||||
const next = Math.max(width, least);
|
||||
return { x: fromLeft ? x + w - next : x, w: next };
|
||||
});
|
||||
}
|
||||
|
||||
// Which sizes a selection shares when one of it is resized, read from how it
|
||||
// is laid out: nodes stacked in a column share a width, nodes side by side in
|
||||
// a row share a height, and a grid shares both. A loose scatter shares a
|
||||
// width, the way a column does.
|
||||
export function sharedSides(rects) {
|
||||
const span = (a, b, start, size) => a[start] < b[start] + b[size] && b[start] < a[start] + a[size];
|
||||
let column = false;
|
||||
let row = false;
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
for (let j = i + 1; j < rects.length; j++) {
|
||||
const sameX = span(rects[i], rects[j], "x", "w");
|
||||
const sameY = span(rects[i], rects[j], "y", "h");
|
||||
if (sameX && !sameY) column = true;
|
||||
if (sameY && !sameX) row = true;
|
||||
}
|
||||
}
|
||||
return { width: column || !row, height: row };
|
||||
}
|
||||
+272
-32
@@ -12,7 +12,6 @@ export const FINE_STEP = 0.01;
|
||||
// large enough that a shaky click cannot change the value.
|
||||
export const SCRUB_PIXELS_PER_STEP = 4;
|
||||
export const SCRUB_DEAD_ZONE = 3;
|
||||
export const MAX_ROWS = 64;
|
||||
|
||||
let rowSerial = 0;
|
||||
|
||||
@@ -40,7 +39,7 @@ export function newRow() {
|
||||
|
||||
export function normalizeRows(parsed) {
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.slice(0, MAX_ROWS).map((entry) => {
|
||||
return tidyGroups(parsed.map((entry) => {
|
||||
const row = newRow();
|
||||
if (entry && typeof entry === "object") {
|
||||
row.id = typeof entry.id === "string" && entry.id ? entry.id : row.id;
|
||||
@@ -49,9 +48,12 @@ export function normalizeRows(parsed) {
|
||||
row.strength_clip = roundStrength(entry.strength_clip ?? row.strength);
|
||||
row.enabled = entry.enabled !== false;
|
||||
row.triggers = typeof entry.triggers === "string" ? entry.triggers : "";
|
||||
const group = groupName(entry.group);
|
||||
if (group) row.group = group;
|
||||
if (group && entry.group_off === true) row.group_off = true;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
export function parseRows(raw) {
|
||||
@@ -79,6 +81,232 @@ export function moveRow(rows, index, delta) {
|
||||
return reorderRows(rows, index, index + delta);
|
||||
}
|
||||
|
||||
// ---------- groups ----------
|
||||
// A group is a name carried on its rows (`group`); a row without one sits at
|
||||
// the top level. A group's rows always stand together, so the stack still
|
||||
// runs top to bottom as shown, and a group lasts while it has a row.
|
||||
// A group switched off carries `group_off` on every row: none of them runs,
|
||||
// and each keeps its own on/off for when the group comes back on.
|
||||
|
||||
export const MAX_GROUP_NAME = 40;
|
||||
|
||||
export function groupName(value) {
|
||||
return typeof value === "string" ? value.trim().slice(0, MAX_GROUP_NAME) : "";
|
||||
}
|
||||
|
||||
function withoutGroup(row) {
|
||||
const { group, group_off, ...rest } = row;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function withoutGroupOff(row) {
|
||||
const { group_off, ...rest } = row;
|
||||
return rest;
|
||||
}
|
||||
|
||||
// A row joining another group takes that group's on/off (see tidyGroups).
|
||||
function withGroup(row, name) {
|
||||
if (!name) return withoutGroup(row);
|
||||
if (row.group === name) return row;
|
||||
return { ...withoutGroupOff(row), group: name };
|
||||
}
|
||||
|
||||
// Whether a row runs: on itself, and not in a group that is switched off.
|
||||
export function rowRuns(row) {
|
||||
return row?.enabled !== false && !row?.group_off;
|
||||
}
|
||||
|
||||
// Gathers each group's rows at the place of its first row, keeping every
|
||||
// other row where it is. A group is off when any of its rows says so, and
|
||||
// then all of them do: a row that just joined an off group goes off with it.
|
||||
export function tidyGroups(rows) {
|
||||
const out = [];
|
||||
const runs = new Map();
|
||||
for (const row of rows) {
|
||||
const name = row.group || "";
|
||||
if (!name) { out.push(row.group_off ? withoutGroupOff(row) : row); continue; }
|
||||
let run = runs.get(name);
|
||||
if (!run) {
|
||||
run = [];
|
||||
runs.set(name, run);
|
||||
out.push(run);
|
||||
}
|
||||
run.push(row);
|
||||
}
|
||||
return out.flatMap((item) => {
|
||||
if (!Array.isArray(item)) return [item];
|
||||
const off = item.some((row) => row.group_off);
|
||||
return item.map((row) => off === Boolean(row.group_off) ? row
|
||||
: off ? { ...row, group_off: true } : withoutGroupOff(row));
|
||||
});
|
||||
}
|
||||
|
||||
export function groupNames(rows) {
|
||||
return [...new Set(rows.map((row) => row.group).filter(Boolean))];
|
||||
}
|
||||
|
||||
export function hasGroupName(rows, name, except = "") {
|
||||
const key = groupName(name).toLowerCase();
|
||||
return groupNames(rows).some((group) => group !== except && group.toLowerCase() === key);
|
||||
}
|
||||
|
||||
export function uniqueGroupName(rows, base = "Group") {
|
||||
for (let n = 1; ; n += 1) {
|
||||
const name = `${base} ${n}`;
|
||||
if (!hasGroupName(rows, name)) return name;
|
||||
}
|
||||
}
|
||||
|
||||
// What the stack shows, in order: a header before each group's first row,
|
||||
// then every row with the group it sits in.
|
||||
export function stackLayout(rows) {
|
||||
const items = [];
|
||||
rows.forEach((row, index) => {
|
||||
const group = row.group || "";
|
||||
if (group && rows[index - 1]?.group !== group) {
|
||||
let end = index;
|
||||
while (end < rows.length && rows[end].group === group) end += 1;
|
||||
const members = rows.slice(index, end);
|
||||
items.push({
|
||||
kind: "group", group, start: index, end,
|
||||
on: members.filter((member) => member.enabled).length, total: members.length,
|
||||
off: members.some((member) => member.group_off),
|
||||
});
|
||||
}
|
||||
items.push({ kind: "row", index, group });
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
// A new group takes the place of the topmost chosen row. It never lands
|
||||
// inside another group: from the middle of one it goes just below it.
|
||||
export function groupRows(rows, ids, name) {
|
||||
const chosen = new Set(ids);
|
||||
const top = rows.findIndex((row) => chosen.has(row.id));
|
||||
if (top < 0 || !name) return rows;
|
||||
const rest = rows.filter((row) => !chosen.has(row.id));
|
||||
let at = top;
|
||||
while (at > 0 && at < rest.length && rest[at].group && rest[at].group === rest[at - 1].group) at += 1;
|
||||
const grouped = rows.filter((row) => chosen.has(row.id)).map((row) => withGroup(row, name));
|
||||
return tidyGroups([...rest.slice(0, at), ...grouped, ...rest.slice(at)]);
|
||||
}
|
||||
|
||||
// Rows moved into a group join it at its end.
|
||||
export function moveRowsToGroup(rows, ids, name) {
|
||||
const chosen = new Set(ids);
|
||||
const rest = rows.filter((row) => !chosen.has(row.id));
|
||||
let last = -1;
|
||||
rest.forEach((row, index) => { if (row.group === name) last = index; });
|
||||
if (last < 0) return tidyGroups(rows.map((row) => chosen.has(row.id) ? withGroup(row, name) : row));
|
||||
const moving = rows.filter((row) => chosen.has(row.id)).map((row) => withGroup(row, name));
|
||||
return tidyGroups([...rest.slice(0, last + 1), ...moving, ...rest.slice(last + 1)]);
|
||||
}
|
||||
|
||||
// Rows taken out of a group stand just outside it: the ones at its top go
|
||||
// above it, the rest below it, so a row at either edge does not move.
|
||||
export function ungroupRows(rows, ids) {
|
||||
const chosen = new Set(ids);
|
||||
const out = [];
|
||||
for (let start = 0; start < rows.length;) {
|
||||
const name = rows[start].group;
|
||||
if (!name) { out.push(rows[start]); start += 1; continue; }
|
||||
let end = start;
|
||||
while (end < rows.length && rows[end].group === name) end += 1;
|
||||
const run = rows.slice(start, end);
|
||||
let lead = 0;
|
||||
while (lead < run.length && chosen.has(run[lead].id)) lead += 1;
|
||||
const stay = run.filter((row) => !chosen.has(row.id));
|
||||
const below = run.slice(lead).filter((row) => chosen.has(row.id));
|
||||
out.push(...run.slice(0, lead).map(withoutGroup), ...stay, ...below.map(withoutGroup));
|
||||
start = end;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A renamed group keeps its on/off.
|
||||
export function renameGroup(rows, from, to) {
|
||||
const name = groupName(to);
|
||||
if (!name) return rows;
|
||||
return tidyGroups(rows.map((row) => row.group === from ? { ...row, group: name } : row));
|
||||
}
|
||||
|
||||
// Every row of a group on or off by itself.
|
||||
export function setGroupEnabled(rows, name, enabled) {
|
||||
return rows.map((row) => row.group === name ? { ...row, enabled: !!enabled } : row);
|
||||
}
|
||||
|
||||
// The group's own switch: off stops all of its rows, and each row keeps its
|
||||
// own on/off underneath.
|
||||
export function setGroupOff(rows, name, off) {
|
||||
return rows.map((row) => row.group !== name ? row
|
||||
: off ? { ...row, group_off: true } : withoutGroupOff(row));
|
||||
}
|
||||
|
||||
export function isGroupOff(rows, name) {
|
||||
return rows.some((row) => row.group === name && row.group_off);
|
||||
}
|
||||
|
||||
// Move up/down for one row, one step at a time: a row at the edge of its
|
||||
// group steps out of it first, and a row next to a group steps into it.
|
||||
export function moveRowInStack(rows, index, delta) {
|
||||
const row = rows[index];
|
||||
if (!row) return rows;
|
||||
const next = rows[index + delta];
|
||||
const group = row.group || "";
|
||||
if (group && next?.group !== group) {
|
||||
return rows.map((entry, i) => i === index ? withoutGroup(entry) : entry);
|
||||
}
|
||||
if (!next) return rows;
|
||||
if (!group && next.group) {
|
||||
return tidyGroups(rows.map((entry, i) => i === index ? withGroup(entry, next.group) : entry));
|
||||
}
|
||||
return reorderRows(rows, index, index + delta);
|
||||
}
|
||||
|
||||
// Drop one row: before the row `beforeId` (or at the end of `group`, or at
|
||||
// the very end), sitting in `group` (empty = top level).
|
||||
export function placeRow(rows, id, { group = "", beforeId = null } = {}) {
|
||||
const row = rows.find((entry) => entry.id === id);
|
||||
if (!row) return rows;
|
||||
const rest = rows.filter((entry) => entry.id !== id);
|
||||
let at = beforeId ? rest.findIndex((entry) => entry.id === beforeId) : -1;
|
||||
if (at < 0 && group) {
|
||||
rest.forEach((entry, index) => { if (entry.group === group) at = index + 1; });
|
||||
}
|
||||
if (at < 0) at = rest.length;
|
||||
return tidyGroups([...rest.slice(0, at), withGroup(row, group), ...rest.slice(at)]);
|
||||
}
|
||||
|
||||
// Move a whole group before the row `beforeId` (a top-level row or the
|
||||
// first row of another group), or to the end.
|
||||
export function placeGroup(rows, name, beforeId = null) {
|
||||
const members = rows.filter((row) => row.group === name);
|
||||
if (!members.length || members.some((row) => row.id === beforeId)) return rows;
|
||||
const rest = rows.filter((row) => row.group !== name);
|
||||
let at = beforeId ? rest.findIndex((row) => row.id === beforeId) : -1;
|
||||
if (at < 0) at = rest.length;
|
||||
return [...rest.slice(0, at), ...members, ...rest.slice(at)];
|
||||
}
|
||||
|
||||
// Move a group past the item above or below it (a row or a whole group).
|
||||
export function moveGroup(rows, name, delta) {
|
||||
const start = rows.findIndex((row) => row.group === name);
|
||||
if (start < 0) return rows;
|
||||
let end = start;
|
||||
while (end < rows.length && rows[end].group === name) end += 1;
|
||||
if (delta < 0) {
|
||||
if (start === 0) return rows;
|
||||
const above = rows[start - 1];
|
||||
const beforeId = above.group ? rows.find((row) => row.group === above.group).id : above.id;
|
||||
return placeGroup(rows, name, beforeId);
|
||||
}
|
||||
if (end >= rows.length) return rows;
|
||||
const below = rows[end];
|
||||
let after = end + 1;
|
||||
if (below.group) while (after < rows.length && rows[after].group === below.group) after += 1;
|
||||
return placeGroup(rows, name, rows[after]?.id ?? null);
|
||||
}
|
||||
|
||||
// Drag-to-reorder model: which visual slot the pointer is over, given the
|
||||
// vertical centers of the rows in their current on-screen order. Past the
|
||||
// last center means the last slot; an empty stack has no slot.
|
||||
@@ -229,25 +457,33 @@ export function toggleTrigger(triggers, word) {
|
||||
}
|
||||
|
||||
export function summarizeRows(rows) {
|
||||
const on = rows.filter((row) => row.enabled && row.name).length;
|
||||
const on = rows.filter((row) => rowRuns(row) && row.name).length;
|
||||
const total = rows.filter((row) => row.name).length;
|
||||
return total === 0 ? "no LoRAs" : `${on} / ${total} on`;
|
||||
}
|
||||
|
||||
// Master toggle model: "on" when every named row is enabled, "off" when none
|
||||
// are, "mixed" otherwise. Blank rows don't vote.
|
||||
// Master toggle model: "on" when every named row runs, "off" when none
|
||||
// does, "mixed" otherwise. Blank rows don't vote. A row in a group that is
|
||||
// switched off does not run.
|
||||
export function toggleAllState(rows) {
|
||||
const named = rows.filter((row) => row.name);
|
||||
if (!named.length) return "off";
|
||||
const on = named.filter((row) => row.enabled).length;
|
||||
const on = named.filter(rowRuns).length;
|
||||
return on === 0 ? "off" : on === named.length ? "on" : "mixed";
|
||||
}
|
||||
|
||||
// Every row on (groups switched back on too) or every row off.
|
||||
function setAllRows(rows, enabled) {
|
||||
return rows.map((row) => {
|
||||
const next = enabled ? withoutGroupOff(row) : row;
|
||||
return row.name ? { ...next, enabled } : next;
|
||||
});
|
||||
}
|
||||
|
||||
// Clicking the master pill: anything not fully-on turns everything on;
|
||||
// fully-on turns everything off.
|
||||
export function toggleAllRows(rows) {
|
||||
const enabled = toggleAllState(rows) !== "on";
|
||||
return rows.map((row) => (row.name ? { ...row, enabled } : row));
|
||||
return setAllRows(rows, toggleAllState(rows) !== "on");
|
||||
}
|
||||
|
||||
// Longest common folder prefix (whole segments only) across displayed names,
|
||||
@@ -363,9 +599,8 @@ export function loraPickerEntries(names, query = "", collapsedFolders = new Set(
|
||||
return visit(tree);
|
||||
}
|
||||
|
||||
// Folder add is one append, or no change when the whole selection cannot
|
||||
// fit. Existing rows (even duplicates, disabled rows and empty slots) stay
|
||||
// untouched. Deduplicate by full path, never basename or moved-file guess.
|
||||
// Append the folder in one step, preserving duplicates, disabled rows and
|
||||
// empty slots already in the stack. Deduplicate new files by full path.
|
||||
export function appendFolderRows(rows, names, defaultStrength = 1) {
|
||||
const seen = new Set(rows.map((row) => loraPath(row.name)));
|
||||
const pending = [];
|
||||
@@ -376,12 +611,10 @@ export function appendFolderRows(rows, names, defaultStrength = 1) {
|
||||
seen.add(key);
|
||||
pending.push(name);
|
||||
}
|
||||
const room = Math.max(0, MAX_ROWS - rows.length);
|
||||
const needed = pending.length;
|
||||
if (needed > room || !needed) return { rows, added: 0, skipped, needed, room };
|
||||
if (!pending.length) return { rows, added: 0, skipped };
|
||||
const strength = roundStrength(defaultStrength);
|
||||
const addedRows = pending.map((name) => ({ ...newRow(), name, strength, strength_clip: strength }));
|
||||
return { rows: [...rows, ...addedRows], added: needed, skipped, needed, room };
|
||||
return { rows: [...rows, ...addedRows], added: addedRows.length, skipped };
|
||||
}
|
||||
|
||||
// A suggested range is advisory: null/absent bounds never flag.
|
||||
@@ -469,7 +702,7 @@ const BAR_EPSILON = 0.005;
|
||||
export function strengthBarScale(rows) {
|
||||
let peak = 0;
|
||||
for (const row of rows ?? []) {
|
||||
if (!row?.name || row.enabled === false) continue;
|
||||
if (!row?.name || !rowRuns(row)) continue;
|
||||
const value = Math.abs(Number(row.strength));
|
||||
if (Number.isFinite(value)) peak = Math.max(peak, value);
|
||||
}
|
||||
@@ -619,29 +852,36 @@ export function duplicateLoraKeys(rows) {
|
||||
// snapshotEnabled back in), so an accidental master click is always one more
|
||||
// click from the setup it destroyed.
|
||||
|
||||
// A row in a group that is switched off is remembered with its group's state.
|
||||
export function snapshotEnabled(rows) {
|
||||
const map = {};
|
||||
for (const row of rows ?? []) {
|
||||
if (row?.name) map[row.id] = row.enabled !== false;
|
||||
if (!row?.name) continue;
|
||||
const enabled = row.enabled !== false;
|
||||
map[row.id] = row.group_off ? { enabled, group_off: true } : enabled;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function restoreRow(row, saved) {
|
||||
if (saved && typeof saved === "object") {
|
||||
const next = { ...row, enabled: saved.enabled !== false };
|
||||
return saved.group_off && next.group ? { ...next, group_off: true } : withoutGroupOff(next);
|
||||
}
|
||||
return { ...withoutGroupOff(row), enabled: Boolean(saved) };
|
||||
}
|
||||
|
||||
export function cycleMasterToggle(rows, snapshot) {
|
||||
const set = (enabled) =>
|
||||
rows.map((row) => (row.name ? { ...row, enabled } : row));
|
||||
const state = toggleAllState(rows);
|
||||
if (state === "mixed") return { rows: set(true), snapshot: snapshotEnabled(rows) };
|
||||
if (state === "on") return { rows: set(false), snapshot };
|
||||
if (state === "mixed") return { rows: setAllRows(rows, true), snapshot: snapshotEnabled(rows) };
|
||||
if (state === "on") return { rows: setAllRows(rows, false), snapshot };
|
||||
// All off: restore the remembered setup when it still lights anything;
|
||||
// otherwise this is a plain binary toggle and everything turns on.
|
||||
const restored = rows.map((row) =>
|
||||
row.name && snapshot && row.id in snapshot
|
||||
? { ...row, enabled: snapshot[row.id] }
|
||||
: row,
|
||||
);
|
||||
const remembersAnything = restored.some((row) => row.name && row.enabled);
|
||||
return remembersAnything ? { rows: restored, snapshot } : { rows: set(true), snapshot };
|
||||
const restored = tidyGroups(rows.map((row) =>
|
||||
row.name && snapshot && row.id in snapshot ? restoreRow(row, snapshot[row.id]) : row,
|
||||
));
|
||||
const remembersAnything = restored.some((row) => row.name && rowRuns(row));
|
||||
return remembersAnything ? { rows: restored, snapshot } : { rows: setAllRows(rows, true), snapshot };
|
||||
}
|
||||
|
||||
// ---------- absorb the loader chain ----------
|
||||
@@ -736,7 +976,7 @@ export function loaderLoraEntries(nodeLike) {
|
||||
.filter((row) => row && typeof row === "object" && row.name)
|
||||
.map((row) => entry(row.name, row.strength,
|
||||
row.strength_clip ?? row.strength,
|
||||
row.enabled, row.triggers));
|
||||
row.enabled !== false && !row.group_off, row.triggers));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -787,9 +1027,9 @@ export function mergeImportedRows(existing, imported,
|
||||
have.add(key);
|
||||
added.push({ ...makeRow(), ...item });
|
||||
}
|
||||
const rows = (position === "after"
|
||||
const rows = position === "after"
|
||||
? [...(existing ?? []), ...added]
|
||||
: [...added, ...(existing ?? [])]).slice(0, MAX_ROWS);
|
||||
: [...added, ...(existing ?? [])];
|
||||
return { rows, added: added.length, skipped };
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export function filterMedia(values, query) {
|
||||
export function unlistedMedia(values, value) {
|
||||
const text = String(value ?? "");
|
||||
if (!text || !Array.isArray(values)) return "";
|
||||
const bare = text.replace(/\s+\[(input|output)\]$/, "");
|
||||
const bare = text.replace(/\s+\[input\]$/, "");
|
||||
return values.includes(text) || values.includes(bare) ? "" : text;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
// The timeline on H3 Prompt Writer: each moment of the script as a block on a
|
||||
// bar as long as the clip, with the cuts as marks. The server reads the script with the same code a run uses,
|
||||
// so the bar shows what will happen. It also colours the script box, so the
|
||||
// words the node reads as instructions (@1, 3s:, a cut) stand out.
|
||||
import { clipSetsDuration, linkSource } from "./h3_duration.mjs";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { BRAND, chainCallback } from "./index.mjs";
|
||||
import { dragRange, moveMoment, retime, rulerSeconds, stripCaption, stripRows, toggleCut, wiredJobs } from "./moment_strip_math.mjs";
|
||||
import { attachScriptAssist } from "./script_assist.mjs";
|
||||
import { attachScriptColours } from "./script_colours.mjs";
|
||||
import { commitWidgetValue } from "./widget_card_math.mjs";
|
||||
|
||||
const CSS_ID = "ausboss-moment-strip-css";
|
||||
const WAIT = 200; // Lets someone finish a word before asking.
|
||||
const WATCH = 1000; // A Workflow Switches card turns a picture off without touching this node.
|
||||
|
||||
function ensureCss() {
|
||||
if (document.getElementById(CSS_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = CSS_ID;
|
||||
style.textContent = `
|
||||
.ausboss-strip{display:flex;flex-direction:column;gap:3px;height:100%;min-width:0;padding:5px 6px 3px;box-sizing:border-box;border-radius:6px;background:#0b0f10;font:600 10px/1.15 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;user-select:none}
|
||||
.ausboss-strip-ruler{position:relative;flex:none;height:11px;color:#56706d;font-size:9px;line-height:11px}
|
||||
.ausboss-strip-ruler span{position:absolute;transform:translateX(-50%);white-space:nowrap}
|
||||
.ausboss-strip-ruler span:first-child{transform:none}
|
||||
.ausboss-strip-ruler span:last-child{transform:translateX(-100%)}
|
||||
.ausboss-strip-track{position:relative;flex:1 1 0;min-height:30px}
|
||||
.ausboss-strip-block{position:absolute;top:0;bottom:0;display:flex;align-items:center;gap:4px;padding:0 4px;box-sizing:border-box;overflow:hidden;border:1px solid rgba(0,180,170,.5);border-radius:4px;background:rgba(0,180,170,.2);color:#cfe8e5}
|
||||
.ausboss-strip-block.shot-odd{border-color:rgba(111,192,170,.42);background:rgba(111,192,170,.1)}
|
||||
.ausboss-strip-block.speech{border-color:rgba(245,181,61,.55);background:rgba(245,181,61,.14);color:#f0dcae}
|
||||
.ausboss-strip-faces{flex:none;display:flex;height:100%;max-height:28px}
|
||||
.ausboss-strip-faces img{width:18px;height:100%;border-radius:3px;object-fit:cover;box-shadow:0 0 0 1px #0b0f10}
|
||||
.ausboss-strip-faces img+img{margin-left:2px}
|
||||
.ausboss-strip-words{flex:1 1 0;min-width:0;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word}
|
||||
.ausboss-strip-cut{position:absolute;top:-13px;bottom:-2px;width:2px;margin-left:-1px;border-radius:1px;background:${BRAND}}
|
||||
.ausboss-strip-handle{position:absolute;top:-2px;bottom:-2px;z-index:2;width:12px;margin-left:-6px;cursor:ew-resize;touch-action:none}
|
||||
.ausboss-strip-handle::before{content:"";position:absolute;left:5px;top:5px;bottom:5px;width:2px;border-radius:1px;background:rgba(207,232,229,.35)}
|
||||
.ausboss-strip-handle:hover::before,.ausboss-strip-handle.dragging::before{background:#cfe8e5}
|
||||
.ausboss-strip-handle span{position:absolute;bottom:100%;left:50%;transform:translateX(-50%);padding:1px 4px;border-radius:3px;background:#cfe8e5;color:#0b0f10;font-size:9px;white-space:nowrap;display:none}
|
||||
.ausboss-strip-handle.dragging span{display:block}
|
||||
.ausboss-strip-block.movable{cursor:grab;touch-action:none}
|
||||
.ausboss-strip-block.moving{z-index:3;cursor:grabbing;opacity:.85;box-shadow:0 4px 14px rgba(0,0,0,.5)}
|
||||
.ausboss-strip-drop{position:absolute;top:-3px;bottom:-3px;z-index:4;width:3px;margin-left:-2px;border-radius:2px;background:#f5b53d;pointer-events:none}
|
||||
.ausboss-strip-note{flex:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#6f8886;font-size:10px;font-weight:500;line-height:12px}
|
||||
.ausboss-strip-note.warn{color:#d9a441}
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
function wired(node, name) {
|
||||
return linkSource(node, name) !== null;
|
||||
}
|
||||
|
||||
// The picture a loader shows, to put on the blocks that name it.
|
||||
function pictureUrl(node, number) {
|
||||
const source = linkSource(node, `picture_${number}`);
|
||||
return source?.imgs?.[0]?.src ?? "";
|
||||
}
|
||||
|
||||
function el(className, text) {
|
||||
const node = document.createElement("div");
|
||||
node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
// A card row: { panel: momentStrip, height }.
|
||||
export function momentStrip(node) {
|
||||
ensureCss();
|
||||
const element = el("ausboss-strip");
|
||||
const ruler = el("ausboss-strip-ruler");
|
||||
const track = el("ausboss-strip-track");
|
||||
const note = el("ausboss-strip-note");
|
||||
element.append(ruler, track, note);
|
||||
const colours = attachScriptColours(node);
|
||||
const choices = (name) => (node.widgets?.find((widget) => widget.name === name)?.options?.values ?? []).filter((value) => value !== "as typed" && value !== "from the picture");
|
||||
const assist = attachScriptAssist(node, colours.area, colours.layer, () => ({
|
||||
pictures: [1, 2, 3, 4].map((number) => ({
|
||||
number,
|
||||
plugged: wired(node, `picture_${number}`),
|
||||
job: node.widgets?.find((widget) => widget.name === `job_${number}`)?.value ?? "",
|
||||
url: pictureUrl(node, number),
|
||||
})),
|
||||
cameras: choices("camera"),
|
||||
looks: choices("look"),
|
||||
seconds: clipSetsDuration(node, lastValues) ? 0 : Number(lastSeconds) || 8,
|
||||
}));
|
||||
|
||||
let lastValues = {};
|
||||
let lastSeconds = 8;
|
||||
// The left edge of a moment that opens a typed line can be dragged; letting
|
||||
// go types its second into that line (3s:), so the words stay the truth.
|
||||
const commitScript = (change) => {
|
||||
const widget = node.widgets?.find((entry) => entry.name === "script");
|
||||
if (!widget) return;
|
||||
const next = change(String(widget.value ?? ""));
|
||||
if (next === widget.value) return;
|
||||
commitWidgetValue(node, widget, next, app.canvas);
|
||||
sync({ ...lastValues, script: widget.value });
|
||||
};
|
||||
// The left edge of a moment that opens a typed line: drag it to set when
|
||||
// the moment starts (it types 3s: into that line), or click it to add or
|
||||
// take away a cut there (a blank line). The words stay the truth.
|
||||
const addHandle = (rows, index, seconds) => {
|
||||
const range = dragRange(rows, index, seconds);
|
||||
const handle = el("ausboss-strip-handle");
|
||||
const label = document.createElement("span");
|
||||
handle.append(label);
|
||||
handle.style.left = `${rows[index].from}%`;
|
||||
handle.title = range ? "Drag to set when this moment starts. Click to add or remove a cut." : "Click to add or remove a cut.";
|
||||
track.append(handle);
|
||||
handle.addEventListener("pointerdown", (event) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handle.setPointerCapture?.(event.pointerId);
|
||||
const startX = event.clientX;
|
||||
let moved = false;
|
||||
let second = null;
|
||||
const move = (moveEvent) => {
|
||||
if (!moved && Math.abs(moveEvent.clientX - startX) < 4) return;
|
||||
if (!range) return;
|
||||
moved = true;
|
||||
handle.classList.add("dragging");
|
||||
const box = track.getBoundingClientRect();
|
||||
const raw = ((moveEvent.clientX - box.left) / Math.max(box.width, 1)) * seconds;
|
||||
second = Math.min(range[1], Math.max(range[0], Math.round(raw)));
|
||||
handle.style.left = `${(second / seconds) * 100}%`;
|
||||
label.textContent = `${second}s`;
|
||||
};
|
||||
const stop = () => {
|
||||
handle.removeEventListener("pointermove", move);
|
||||
handle.removeEventListener("pointerup", stop);
|
||||
handle.removeEventListener("pointercancel", stop);
|
||||
handle.classList.remove("dragging");
|
||||
if (!moved) commitScript((text) => toggleCut(text, rows[index].line));
|
||||
else if (second !== null && second !== rows[index].start) commitScript((text) => retime(text, rows[index].line, second));
|
||||
};
|
||||
handle.addEventListener("pointermove", move);
|
||||
handle.addEventListener("pointerup", stop);
|
||||
handle.addEventListener("pointercancel", stop);
|
||||
});
|
||||
};
|
||||
// A click on a block selects its line in the words, ready to change.
|
||||
const selectLine = (line) => {
|
||||
const area = colours.area;
|
||||
if (!area || !Number.isInteger(line)) return;
|
||||
const lines = area.value.split("\n");
|
||||
if (line >= lines.length) return;
|
||||
const start = lines.slice(0, line).reduce((total, text) => total + text.length + 1, 0);
|
||||
area.focus({ preventScroll: true });
|
||||
area.setSelectionRange(start, start + lines[line].length);
|
||||
const height = parseFloat(getComputedStyle(area).lineHeight) || 17;
|
||||
area.scrollTop = Math.max(0, line * height - area.clientHeight / 3);
|
||||
};
|
||||
// A block that opens a typed line can be dragged along the bar: its words
|
||||
// swap places with the others; times and cuts stay where they are.
|
||||
const makeMovable = (block, rows, entries, order) => {
|
||||
block.classList.add("movable");
|
||||
block.addEventListener("pointerdown", (event) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
block.setPointerCapture?.(event.pointerId);
|
||||
const startX = event.clientX;
|
||||
let target = order;
|
||||
let moved = false;
|
||||
const drop = el("ausboss-strip-drop");
|
||||
const move = (moveEvent) => {
|
||||
const shift = moveEvent.clientX - startX;
|
||||
if (!moved && Math.abs(shift) < 4) return;
|
||||
if (!moved) { moved = true; block.classList.add("moving"); track.append(drop); }
|
||||
block.style.transform = `translateX(${shift}px)`;
|
||||
const centre = block.getBoundingClientRect().left + block.offsetWidth / 2;
|
||||
const others = entries.filter((entry) => entry.order !== order);
|
||||
target = others.filter((entry) => {
|
||||
const box = entry.block.getBoundingClientRect();
|
||||
return centre > box.left + box.width / 2;
|
||||
}).length;
|
||||
const after = others[target - 1];
|
||||
const before = others[target];
|
||||
const at = before ? rows[before.index].from : after ? rows[after.index].to : 0;
|
||||
drop.style.left = `${at}%`;
|
||||
};
|
||||
const stop = () => {
|
||||
block.removeEventListener("pointermove", move);
|
||||
block.removeEventListener("pointerup", stop);
|
||||
block.removeEventListener("pointercancel", stop);
|
||||
block.classList.remove("moving");
|
||||
block.style.transform = "";
|
||||
drop.remove();
|
||||
if (!moved) selectLine(rows[entries[order].index].line);
|
||||
else if (target !== order) commitScript((text) => moveMoment(text, entries.map((entry) => rows[entry.index].line), order, target));
|
||||
};
|
||||
block.addEventListener("pointermove", move);
|
||||
block.addEventListener("pointerup", stop);
|
||||
block.addEventListener("pointercancel", stop);
|
||||
});
|
||||
};
|
||||
const show = (report) => {
|
||||
const seconds = Number(report?.seconds) || Number(lastValues.seconds) || 8;
|
||||
ruler.textContent = "";
|
||||
for (const mark of rulerSeconds(seconds)) {
|
||||
const tick = document.createElement("span");
|
||||
tick.style.left = `${(mark / seconds) * 100}%`;
|
||||
tick.textContent = `${mark}s`;
|
||||
ruler.append(tick);
|
||||
}
|
||||
track.textContent = "";
|
||||
let shot = 0;
|
||||
const rows = stripRows(report);
|
||||
const movable = [];
|
||||
rows.forEach((moment, index) => {
|
||||
if (moment.cut && index) shot += 1;
|
||||
const block = el(`ausboss-strip-block${shot % 2 ? " shot-odd" : ""}`);
|
||||
block.classList.toggle("speech", moment.speech);
|
||||
block.style.left = `${moment.from}%`;
|
||||
block.style.width = `calc(${moment.to - moment.from}% - 2px)`;
|
||||
// Every picture the line names, in order, up to three.
|
||||
const faces = el("ausboss-strip-faces");
|
||||
for (const number of [...new Set([...moment.words.matchAll(/@([1-4])\b/g)].map((match) => match[1]))].slice(0, 3)) {
|
||||
const url = pictureUrl(node, number);
|
||||
if (!url) continue;
|
||||
const picture = document.createElement("img");
|
||||
picture.src = url;
|
||||
picture.alt = `@${number}`;
|
||||
faces.append(picture);
|
||||
}
|
||||
if (faces.childElementCount) block.append(faces);
|
||||
block.append(el("ausboss-strip-words", moment.words));
|
||||
block.title = `${moment.second}s: ${moment.words}`;
|
||||
track.append(block);
|
||||
if (moment.line !== null) movable.push({ block, index, order: movable.length });
|
||||
const opens = rows.slice(0, index + 1).findLast((row) => row.line !== null)?.line ?? null;
|
||||
block.addEventListener("click", () => { if (!block.classList.contains("movable")) selectLine(opens); });
|
||||
if (index && moment.line !== null && !wired(node, "script")) addHandle(rows, index, seconds);
|
||||
if (moment.cut && index) {
|
||||
const cut = el("ausboss-strip-cut");
|
||||
cut.style.left = `${moment.from}%`;
|
||||
cut.title = `Cut at ${moment.second}s`;
|
||||
track.append(cut);
|
||||
}
|
||||
});
|
||||
if (movable.length > 1 && !wired(node, "script")) {
|
||||
for (const entry of movable) {
|
||||
makeMovable(entry.block, rows, movable, entry.order);
|
||||
entry.block.title += " · drag to move it";
|
||||
}
|
||||
}
|
||||
const caption = stripCaption(report);
|
||||
note.textContent = caption.text;
|
||||
note.title = caption.text;
|
||||
note.classList.toggle("warn", caption.warn);
|
||||
};
|
||||
|
||||
let shown = null;
|
||||
let timer = null;
|
||||
let asked = 0;
|
||||
const ask = async (script, seconds, jobs, video) => {
|
||||
const turn = ++asked;
|
||||
try {
|
||||
const response = await api.fetchApi("/ausboss/h3_writer/read", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ script, seconds, jobs, video }),
|
||||
});
|
||||
const report = response.ok ? await response.json() : null;
|
||||
if (turn === asked && !clipSetsDuration(node, lastValues)) show(report);
|
||||
} catch {
|
||||
if (turn === asked && !clipSetsDuration(node, lastValues)) show(null);
|
||||
}
|
||||
};
|
||||
|
||||
let latest = null;
|
||||
let fromClip = false;
|
||||
const sync = (values) => {
|
||||
latest = values;
|
||||
lastValues = values;
|
||||
const nextFromClip = clipSetsDuration(node, values);
|
||||
if (fromClip !== nextFromClip) {
|
||||
fromClip = nextFromClip;
|
||||
// Also notice a source switched off without changing this node's wires.
|
||||
queueMicrotask(() => node.__ausbossCard?.refresh?.());
|
||||
}
|
||||
colours.sync(values, [1, 2, 3, 4].filter((number) => wired(node, `picture_${number}`)));
|
||||
if (fromClip) {
|
||||
shown = null;
|
||||
clearTimeout(timer);
|
||||
asked += 1;
|
||||
ruler.textContent = "";
|
||||
track.textContent = "";
|
||||
const message = el("ausboss-strip-words", "Length comes from the clip");
|
||||
message.style.cssText = "display:flex;align-items:center;height:100%";
|
||||
track.append(message);
|
||||
note.textContent = "Set the clip's length upstream. Timing controls are unavailable here.";
|
||||
note.title = note.textContent;
|
||||
note.classList.remove("warn");
|
||||
return;
|
||||
}
|
||||
if (wired(node, "script")) {
|
||||
shown = null;
|
||||
clearTimeout(timer);
|
||||
asked += 1;
|
||||
show(null);
|
||||
note.textContent = "The words come from the wire.";
|
||||
return;
|
||||
}
|
||||
const script = String(values.script ?? "");
|
||||
// A length wired from a number box shows that box's value.
|
||||
const source = linkSource(node, "seconds");
|
||||
const fromWire = Number(source?.widgets?.find?.((widget) => widget.name === "value")?.value);
|
||||
const seconds = Math.min(15, Math.max(5, Math.round(fromWire || Number(values.seconds) || 8)));
|
||||
lastSeconds = seconds;
|
||||
const jobs = wiredJobs(values, (name) => wired(node, name));
|
||||
const video = wired(node, "video") ? values.video_job : "";
|
||||
const key = JSON.stringify([seconds, script, jobs, video]);
|
||||
if (key === shown) return;
|
||||
shown = key;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => ask(script, seconds, jobs, video), WAIT);
|
||||
};
|
||||
const watch = setInterval(() => latest && sync(latest), WATCH);
|
||||
chainCallback(node, "onRemoved", () => { clearInterval(watch); colours.dispose(); assist.dispose(); });
|
||||
return { element, sync };
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Pure decisions behind js/shared/moment_strip.mjs: what the rows and the
|
||||
// line under them say. No DOM, no app import, so tests run it under node:test.
|
||||
|
||||
const trimmed = (seconds) => String(Math.round(seconds * 10) / 10);
|
||||
const percent = (part, whole) => Math.min(Math.max((part / whole) * 100, 0), 100);
|
||||
|
||||
// The job of each picture that is connected, by its number, for the server to
|
||||
// check the @ numbers in a script against.
|
||||
export function wiredJobs(values, wired) {
|
||||
const jobs = {};
|
||||
for (const number of [1, 2, 3, 4]) {
|
||||
if (wired(`picture_${number}`)) jobs[number] = values[`job_${number}`];
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
// One row for each moment the server read: its second, its words, and where
|
||||
// its bar sits in the clip, as percentages.
|
||||
export function stripRows(report) {
|
||||
const moments = report?.moments ?? [];
|
||||
const seconds = report?.seconds || moments.at(-1)?.end || 1;
|
||||
return moments.map((moment) => ({
|
||||
second: trimmed(moment.start),
|
||||
words: moment.text,
|
||||
from: percent(moment.start, seconds),
|
||||
to: Math.max(percent(moment.end, seconds), percent(moment.start, seconds) + 1),
|
||||
cut: Boolean(moment.cut),
|
||||
speech: Boolean(moment.speech),
|
||||
line: Number.isInteger(moment.line) ? moment.line : null,
|
||||
start: Number(moment.start) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// A time typed at the start of a line: 3s:, 3 s, at 3 seconds, [3s].
|
||||
const TYPED_TIME = /^\[?(?:at\s+)?\d+(?:\.\d+)?\s*(?:seconds?|secs?|s)\b\]?\s*[:,\-]?\s*/i;
|
||||
|
||||
// The script with one line set to start at a second: its old time, if any,
|
||||
// gives way to the new one. line counts typed lines from 0.
|
||||
export function retime(text, line, second) {
|
||||
const lines = String(text ?? "").split("\n");
|
||||
if (!Number.isInteger(line) || line < 0 || line >= lines.length) return String(text ?? "");
|
||||
const lead = lines[line].match(/^\s*/)[0];
|
||||
const words = lines[line].slice(lead.length).replace(TYPED_TIME, "");
|
||||
lines[line] = `${lead}${trimmed(second)}s: ${words}`;
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// The whole seconds a moment's start can be dragged to: after the moment
|
||||
// before it, before the one after it (or the end of the clip).
|
||||
export function dragRange(rows, index, seconds) {
|
||||
const before = rows[index - 1]?.start ?? 0;
|
||||
const after = rows[index + 1]?.start ?? seconds;
|
||||
const low = Math.floor(before) + 1;
|
||||
const high = Math.ceil(after) - 1;
|
||||
return low <= high ? [low, high] : null;
|
||||
}
|
||||
|
||||
// The line under the rows: a warning first, then what the script adds up to.
|
||||
export function stripCaption(report) {
|
||||
if (report?.warning) return { text: report.warning, warn: true };
|
||||
const moments = report?.moments ?? [];
|
||||
if (!moments.length) return { text: "Each line becomes a moment.", warn: false };
|
||||
const count = moments.length === 1 ? "1 moment" : `${moments.length} moments`;
|
||||
const cuts = moments.filter((moment) => moment.cut).length;
|
||||
if (cuts) return { text: `${count}, ${cuts === 1 ? "1 cut" : `${cuts} cuts`}.`, warn: false };
|
||||
const each = moments.map((moment) => trimmed(moment.end - moment.start));
|
||||
if (moments.length === 1) return { text: `${count}, ${each[0]} s, one take.`, warn: false };
|
||||
const even = each.every((length) => length === each[0]);
|
||||
return { text: even ? `${count}, ${each[0]} s each, one take.` : `${count}, one take.`, warn: false };
|
||||
}
|
||||
|
||||
// The seconds the bar under the script marks: every second up to 10 s,
|
||||
// every other one after, always with the end.
|
||||
export function rulerSeconds(seconds) {
|
||||
const total = Math.max(1, Math.round(Number(seconds) || 0));
|
||||
const step = total > 10 ? 2 : 1;
|
||||
const marks = [];
|
||||
for (let mark = 0; mark < total; mark += step) marks.push(mark);
|
||||
if (total - marks.at(-1) < step) marks.pop();
|
||||
return [...marks, total];
|
||||
}
|
||||
|
||||
|
||||
// The script with one moment's words moved to another place on the bar.
|
||||
// slots: the typed line of each draggable moment, in bar order. The words
|
||||
// move; each place keeps its own time (3s:) and the cuts stay where they
|
||||
// are, so the bar's marks don't jump.
|
||||
export function moveMoment(text, slots, from, to) {
|
||||
const lines = String(text ?? "").split("\n");
|
||||
if (from === to || !slots.every((line) => Number.isInteger(line) && line >= 0 && line < lines.length)) return String(text ?? "");
|
||||
const split = (line) => {
|
||||
const lead = lines[line].match(/^\s*/)[0];
|
||||
const body = lines[line].slice(lead.length);
|
||||
const time = body.match(TYPED_TIME)?.[0] ?? "";
|
||||
return { lead, time, words: body.slice(time.length) };
|
||||
};
|
||||
const parts = slots.map(split);
|
||||
const words = parts.map((part) => part.words);
|
||||
const [moved] = words.splice(from, 1);
|
||||
words.splice(Math.max(0, Math.min(to, words.length)), 0, moved);
|
||||
slots.forEach((line, index) => { lines[line] = parts[index].lead + parts[index].time + words[index]; });
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// The script with a cut added or taken away just before a typed line: a cut
|
||||
// is a blank line.
|
||||
export function toggleCut(text, line) {
|
||||
const lines = String(text ?? "").split("\n");
|
||||
if (!Number.isInteger(line) || line <= 0 || line >= lines.length) return String(text ?? "");
|
||||
let top = line;
|
||||
while (top > 0 && lines[top - 1].trim() === "") top -= 1;
|
||||
if (top < line) lines.splice(top, line - top);
|
||||
else lines.splice(line, 0, "");
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// A tile needs its renderer. Give saved tiles a real node identity so an old
|
||||
// install reports a missing node instead of stretching a plain note over it.
|
||||
export const CALLOUT_TYPES = new Set(["AUSBOSS_NODES_Callout", "AUSBOSS_NODES_CalloutTile"]);
|
||||
export const NOTE_TYPES = new Set(["AUSBOSS_NODES_WorkflowNote", "AUSBOSS_NODES_WorkflowNoteTile"]);
|
||||
|
||||
export function serializeNoteIdentity(saved, tile) {
|
||||
const types = CALLOUT_TYPES.has(saved.type) ? [...CALLOUT_TYPES]
|
||||
: NOTE_TYPES.has(saved.type) ? [...NOTE_TYPES] : null;
|
||||
if (!types) return;
|
||||
saved.type = types[tile ? 1 : 0];
|
||||
// LiteGraph's serialized properties can still be the live object's properties.
|
||||
saved.properties = { ...saved.properties, "Node name for S&R": saved.type };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// The outline of a node or group as people see it: { x, y, w, h } in graph
|
||||
// units, title bar included.
|
||||
//
|
||||
// The classic look draws a node at its measured bounds (boundingRect). Nodes
|
||||
// 2.0 draws each node as an element on the page, which can run past those
|
||||
// bounds (its frame, a footer), so there the element itself is measured.
|
||||
// Groups are drawn on the canvas in both looks.
|
||||
|
||||
function graphRectOf(element, canvas) {
|
||||
const box = element.getBoundingClientRect();
|
||||
if (!(box.width > 0) || !(box.height > 0)) return null;
|
||||
const view = canvas.canvas.getBoundingClientRect();
|
||||
const { scale, offset } = canvas.ds;
|
||||
return {
|
||||
x: (box.left - view.left) / scale - offset[0],
|
||||
y: (box.top - view.top) / scale - offset[1],
|
||||
w: box.width / scale,
|
||||
h: box.height / scale,
|
||||
};
|
||||
}
|
||||
|
||||
export function boundsOf(item) {
|
||||
const box = item?.boundingRect;
|
||||
if (!box || !(box[2] > 0) || !(box[3] > 0)) return null;
|
||||
return { x: box[0], y: box[1], w: box[2], h: box[3] };
|
||||
}
|
||||
|
||||
// Nodes 2.0's node elements by node id, found in one pass for measuring many.
|
||||
export function nodeElements() {
|
||||
const elements = new Map();
|
||||
if (globalThis.LiteGraph?.vueNodesMode !== true) return elements;
|
||||
for (const element of document.querySelectorAll("[data-node-id]")) elements.set(element.getAttribute("data-node-id"), element);
|
||||
return elements;
|
||||
}
|
||||
|
||||
export function outlineOf(item, canvas, { node = true, elements = null } = {}) {
|
||||
if (node && globalThis.LiteGraph?.vueNodesMode === true && item?.id != null && canvas?.canvas && canvas.ds) {
|
||||
const element = elements
|
||||
? elements.get(String(item.id))
|
||||
: document.querySelector(`[data-node-id="${CSS.escape(String(item.id))}"]`);
|
||||
const rect = element && graphRectOf(element, canvas);
|
||||
if (rect) return rect;
|
||||
}
|
||||
return boundsOf(item);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Shared controls for compact node panels. Only elements explicitly given one
|
||||
// of these classes are styled; the owning panel decides where they sit.
|
||||
import { BRAND } from "./index.mjs";
|
||||
|
||||
export const PANEL_LABELS = Object.freeze({
|
||||
load: "Load", sources: { input: "Inputs", output: "Outputs" },
|
||||
embed: "Embed workflow", off: "Off", on: "On",
|
||||
});
|
||||
|
||||
const controlFont = 'system-ui, sans-serif';
|
||||
export const CAPTION_TYPE = `font:600 11px/1.25 ${controlFont};color:#b2c6c3;`;
|
||||
export const HINT_TYPE = `font:400 10px/1.25 ${controlFont};color:#95aba8;`;
|
||||
|
||||
export function ensurePanelControls() {
|
||||
if (document.getElementById("ausboss-panel-controls")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "ausboss-panel-controls";
|
||||
style.textContent = `
|
||||
.ausboss-compact-action{box-sizing:border-box;flex:none;height:22px;min-width:24px;padding:0 7px;border:1px solid #365957;border-radius:4px;background:#111b1c;color:#bdd8d5;font:600 11px/1 ${controlFont};white-space:nowrap;cursor:pointer;pointer-events:auto}
|
||||
.ausboss-compact-action:hover:enabled,.ausboss-compact-action.active{color:#dbfffb;border-color:${BRAND};background:#173332}
|
||||
.ausboss-compact-action:disabled{opacity:.5;cursor:default}
|
||||
.ausboss-panel-switch{display:inline-flex;align-items:center;gap:6px;align-self:flex-end;flex:none;box-sizing:border-box;min-height:20px;margin-left:auto;padding:0 2px;background:transparent;border:0;color:#b2c6c3;font:600 11px/1 ${controlFont};cursor:pointer;user-select:none;pointer-events:auto}
|
||||
.ausboss-panel-switch i{position:relative;width:24px;height:13px;border-radius:7px;background:#3a4445}
|
||||
.ausboss-panel-switch i::after{content:"";position:absolute;top:2px;left:2px;width:9px;height:9px;border-radius:50%;background:#b6c4c3}
|
||||
.ausboss-panel-switch[aria-checked=true] i{background:${BRAND}}
|
||||
.ausboss-panel-switch[aria-checked=true] i::after{left:13px;background:#fff}
|
||||
.ausboss-compact-action:focus-visible,.ausboss-panel-switch:focus-visible{outline:2px solid ${BRAND};outline-offset:1px}
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
export function compactAction(label, title = "") {
|
||||
ensurePanelControls();
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "ausboss-compact-action";
|
||||
button.textContent = label;
|
||||
button.title = title;
|
||||
return button;
|
||||
}
|
||||
|
||||
export function panelSwitch(label, title) {
|
||||
ensurePanelControls();
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "ausboss-panel-switch";
|
||||
button.title = title;
|
||||
button.setAttribute("role", "switch");
|
||||
button.setAttribute("aria-checked", "false");
|
||||
const thumb = document.createElement("i");
|
||||
thumb.setAttribute("aria-hidden", "true");
|
||||
button.append(label, thumb);
|
||||
return button;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// What a paste did to ids. Copy and paste give every pasted node and group a
|
||||
// new id, and so do duplicate, Alt + drag and dropping a saved workflow on the
|
||||
// canvas: the frontend sends all of them through
|
||||
// LGraphCanvas._deserializeItems. A node that remembers other nodes by id
|
||||
// (Workflow Switches, a Callout's links) would keep naming the old ones.
|
||||
// Pasted on their own, its copies point at nothing; pasted into another
|
||||
// workflow, they can point at an unrelated node that has the old number.
|
||||
//
|
||||
// The paste returns which new node each old id became. onPaste hands that
|
||||
// on, so a pasted node can follow the copies of what it points at. A frontend
|
||||
// without the method pastes as before and no handler runs.
|
||||
//
|
||||
// No app import, so tests/paste_ids.test.mjs runs it under node:test.
|
||||
|
||||
const handlers = new Set();
|
||||
let installed = false;
|
||||
|
||||
// Old id (as text) -> the copy's id, for the nodes and the groups of one
|
||||
// paste. The frontend adds the groups it is handed one by one, in order, so
|
||||
// the groups it made are matched to the old ids by position; when the counts
|
||||
// differ, no group is matched rather than a wrong one.
|
||||
export function pastedIds(result, groupIds = [], isGroup = () => false) {
|
||||
const nodes = new Map();
|
||||
for (const [id, node] of result?.nodes ?? []) {
|
||||
if (node?.id !== undefined && node.id !== null) nodes.set(String(id), node.id);
|
||||
}
|
||||
const created = [...(result?.created ?? [])];
|
||||
const groups = new Map();
|
||||
const made = created.filter(isGroup);
|
||||
if (made.length === groupIds.length) {
|
||||
groupIds.forEach((id, index) => {
|
||||
if (id !== undefined && id !== null && made[index]?.id !== undefined) groups.set(String(id), made[index].id);
|
||||
});
|
||||
}
|
||||
return { nodes, groups, created };
|
||||
}
|
||||
|
||||
// Runs `handler({ nodes, groups, created })` after every paste.
|
||||
export function onPaste(handler) {
|
||||
if (typeof handler === "function") handlers.add(handler);
|
||||
install();
|
||||
}
|
||||
|
||||
function install() {
|
||||
if (installed) return;
|
||||
const lite = globalThis.LiteGraph;
|
||||
const proto = lite?.LGraphCanvas?.prototype;
|
||||
const original = proto?._deserializeItems;
|
||||
if (typeof original !== "function") return;
|
||||
installed = true;
|
||||
const isGroup = (item) => Boolean(lite.LGraphGroup && item instanceof lite.LGraphGroup);
|
||||
// A method whose result is needed, not a callback: chainCallback would drop
|
||||
// the result, so wrap it and always hand the frontend's own result back.
|
||||
function deserializeItemsWithIds(items, ...rest) {
|
||||
// Read before the paste, which gives the groups it is handed new ids.
|
||||
const groupIds = Array.isArray(items?.groups) ? items.groups.map((group) => group?.id) : [];
|
||||
const result = original.call(this, items, ...rest);
|
||||
if (!result || !handlers.size) return result;
|
||||
const ids = pastedIds(result, groupIds, isGroup);
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(ids);
|
||||
} catch (error) {
|
||||
console.warn("[AusBoss] after a paste:", error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
proto._deserializeItems = deserializeItemsWithIds;
|
||||
}
|
||||
@@ -129,13 +129,17 @@ export function ratioSizeNear(ratio, width, height, step = 1) {
|
||||
// modifiers: snap (default true; false = 1px freedom),
|
||||
// lockRatio (true = keep the grab-time ratio exactly).
|
||||
|
||||
export function stageScale(width, height, boxW, boxH) {
|
||||
// Fit with fixed headroom for the handles, corner hints, and the label
|
||||
// (the design's 90px horizontal / 60px vertical margins), never below a
|
||||
// sliver on a tiny stage.
|
||||
const usableW = Math.max(40, boxW - 90);
|
||||
const usableH = Math.max(40, boxH - 60);
|
||||
return Math.max(0.002, Math.min(usableW / width, usableH / height));
|
||||
// Fit the selection inside its handles. If both corner readouts fit beside
|
||||
// the rectangle (or already fit above it), the full stage height is usable.
|
||||
export function fitStage(width, height, boxW, boxH, cornerRoom = 126) {
|
||||
const pad = 10; // the outer half of an 18px handle plus a pixel of air
|
||||
const fit = top => Math.min(Math.max(1, boxW - 2 * pad) / width,
|
||||
Math.max(1, boxH - top - pad) / height);
|
||||
const full = fit(pad);
|
||||
const header = 26;
|
||||
if ((boxW - width * full) / 2 >= cornerRoom || (boxH - height * full) / 2 >= header)
|
||||
return { scale: full, shift: 0 };
|
||||
return { scale: fit(header), shift: (header - pad) / 2 };
|
||||
}
|
||||
|
||||
const HANDLE_AXES = {
|
||||
@@ -406,3 +410,51 @@ export function sizeForMegapixels(width, height, targetMp, step = DEFAULT_SNAP)
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// A workflow's list keeps the author's order and the exact orientation of each
|
||||
// pair. A near match, including a reversed pair, is a different model input.
|
||||
export function parseWorkflowSizes(text) {
|
||||
const lines = text.trim().split(/\n/).map(line => line.trim()).filter(Boolean);
|
||||
if (lines.length > 24) throw new Error("Choose up to 24 resolutions.");
|
||||
const sizes = [];
|
||||
for (const line of lines) {
|
||||
const match = /^(\d{1,4})\s*[x×]\s*(\d{1,4})$/i.exec(line);
|
||||
const pair = match?.slice(1).map(Number);
|
||||
if (!pair || pair.some(n => n < DIM_MIN || n > DIM_MAX)) {
|
||||
throw new Error(`Enter a width × height between ${DIM_MIN} and ${DIM_MAX} px on each line.`);
|
||||
}
|
||||
if (!sizes.some(([w, h]) => w === pair[0] && h === pair[1])) sizes.push(pair);
|
||||
}
|
||||
return sizes;
|
||||
}
|
||||
|
||||
export function workflowRatios(sizes) {
|
||||
const ratios = new Map();
|
||||
for (const [width, height] of sizes) {
|
||||
const { x, y } = reduceRatio(width, height);
|
||||
const label = `${x}:${y}`;
|
||||
if (!ratios.has(label)) ratios.set(label, { x, y, label, sizes: [] });
|
||||
ratios.get(label).sizes.push({ width, height });
|
||||
}
|
||||
return [...ratios.values()];
|
||||
}
|
||||
|
||||
export function readWorkflowPolicy(value) {
|
||||
if (value === "") return { sizes: [], strict: false };
|
||||
const invalid = () => { throw new Error("Invalid workflow resolutions."); };
|
||||
if (typeof value !== "string" || value.length > 2048) invalid();
|
||||
const policy = JSON.parse(value);
|
||||
if (typeof policy?.strict !== "boolean" || !Array.isArray(policy.sizes)
|
||||
|| !policy.sizes.length || policy.sizes.length > 24
|
||||
|| !policy.sizes.every(pair => Array.isArray(pair) && pair.length === 2
|
||||
&& pair.every(n => Number.isInteger(n) && n >= DIM_MIN && n <= DIM_MAX))) invalid();
|
||||
return { sizes: policy.sizes, strict: policy.strict };
|
||||
}
|
||||
|
||||
|
||||
// The panel used to occupy the appended widget's position in older saves.
|
||||
// Keep any other malformed value intact so strict settings cannot fail open.
|
||||
export function savedWorkflowPolicy(value, legacy) {
|
||||
if (value != null && value !== "" && value !== "image") return value;
|
||||
return Array.isArray(legacy) && legacy.length ? JSON.stringify({ strict: true, sizes: legacy }) : "";
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ const emptyWidget = (inputs, name) => !linked(inputs?.[name]) && blank(inputs?.[
|
||||
|
||||
const picture = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "picture" } : null);
|
||||
const video = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "video" } : null);
|
||||
const sound = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "sound file" } : null);
|
||||
|
||||
function videoSource(inputs) {
|
||||
const mode = inputs?.source_mode;
|
||||
@@ -38,6 +39,7 @@ export const SOURCE_LOADERS = Object.freeze({
|
||||
AUSBOSS_NODES_ImageCropRotatePad: picture("image"),
|
||||
// A wired source_image replaces the file, so the file may stay blank.
|
||||
AUSBOSS_NODES_LoadImagePad: (inputs) => (linked(inputs?.source_image) ? null : picture("image")(inputs)),
|
||||
AUSBOSS_NODES_LoadAudio: sound("audio"),
|
||||
AUSBOSS_NODES_LoadVideo: video("video"),
|
||||
AUSBOSS_NODES_VideoCropRotatePad: videoSource,
|
||||
AUSBOSS_NODES_VideoCropRotatePadClip: videoSource,
|
||||
@@ -110,7 +112,8 @@ export function emptySourceMessage(found) {
|
||||
if (!first) return "";
|
||||
const more = found.length - 1;
|
||||
const tail = more > 0 ? ` (and ${more} more node${more === 1 ? "" : "s"})` : "";
|
||||
return `Load a ${first.kind === "video" ? "video" : "picture"} first: ${first.title}${tail}`;
|
||||
const noun = first.kind === "video" || first.kind === "sound file" ? first.kind : "picture";
|
||||
return `Load a ${noun} first: ${first.title}${tail}`;
|
||||
}
|
||||
|
||||
// "Save Image: exact_name may not ..." names the input it is about, after
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Help while typing in H3 Prompt Writer's script box:
|
||||
// - a "?" beside "What happens" opens a card of everything the node reads in
|
||||
// the words (@1, 3s:, a blank line, quotes, sound:, music:, camera:, look:);
|
||||
// - typing @ lists the pictures plugged in, with their job; the first letters
|
||||
// of a line offer sound:, music:, camera: and look:; a number offers a time;
|
||||
// after camera: or look: the node's own choices are listed.
|
||||
// Suggestions only open on those, so they stay out of other packs' way.
|
||||
// Picking one types it into the box the browser's way, so undo still works.
|
||||
import { CHEAT_SHEET, suggestAt } from "./script_assist_math.mjs";
|
||||
|
||||
const CSS_ID = "ausboss-script-assist-css";
|
||||
|
||||
function ensureCss() {
|
||||
if (document.getElementById(CSS_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = CSS_ID;
|
||||
style.textContent = `
|
||||
.ausboss-help-dot{margin-left:auto;pointer-events:auto;width:15px;height:15px;padding:0;border:1px solid #2f4a48;border-radius:50%;background:transparent;color:#6f8886;font:700 9.5px/13px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:0;text-align:center;cursor:help}
|
||||
.ausboss-help-dot:hover,.ausboss-help-dot.open{border-color:#00b4aa;color:#a5f3ec}
|
||||
.ausboss-assist-pop{position:fixed;z-index:10000;box-sizing:border-box;border:1px solid #2a3437;border-radius:8px;background:#0f1516;color:#d8ecea;box-shadow:0 8px 28px rgba(0,0,0,.55);font:500 12px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||
.ausboss-assist-help{width:300px;padding:10px 12px}
|
||||
.ausboss-assist-help h4{margin:0 0 6px;color:#a5f3ec;font-size:12px;font-weight:700}
|
||||
.ausboss-assist-help dl{display:grid;grid-template-columns:auto 1fr;gap:5px 10px;margin:0}
|
||||
.ausboss-assist-help dt{color:#f5b53d;font:600 11px/1.35 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap}
|
||||
.ausboss-assist-help dd{margin:0;color:#c3d6d4}
|
||||
.ausboss-assist-help p{margin:8px 0 0;color:#6f8886;font-size:11px}
|
||||
.ausboss-assist-list{min-width:170px;max-width:280px;padding:4px}
|
||||
.ausboss-assist-item{display:flex;align-items:center;gap:7px;padding:4px 6px;border-radius:5px;cursor:pointer;white-space:nowrap}
|
||||
.ausboss-assist-item.active{background:rgba(0,180,170,.22)}
|
||||
.ausboss-assist-item img{flex:none;width:20px;height:20px;border-radius:4px;object-fit:cover}
|
||||
.ausboss-assist-item b{color:#a5f3ec;font:700 11.5px ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
.ausboss-assist-item em{overflow:hidden;color:#6f8886;font-style:normal;text-overflow:ellipsis}
|
||||
.ausboss-assist-foot{padding:3px 6px 1px;color:#56706d;font-size:10px}
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
const make = (tag, className, text) => {
|
||||
const element = document.createElement(tag);
|
||||
if (className) element.className = className;
|
||||
if (text !== undefined) element.textContent = text;
|
||||
return element;
|
||||
};
|
||||
|
||||
// Screen position of the caret: a hidden copy of the box with a marker where
|
||||
// the caret is. The copy sits in the box's own place, so zoom is included.
|
||||
function caretPoint(area, layer) {
|
||||
const copy = layer.cloneNode(false);
|
||||
copy.className = "ausboss-colours";
|
||||
copy.style.visibility = "hidden";
|
||||
copy.textContent = area.value.slice(0, area.selectionEnd);
|
||||
const mark = make("span", "", "");
|
||||
copy.append(mark);
|
||||
area.parentElement.append(copy);
|
||||
copy.scrollTop = area.scrollTop;
|
||||
const box = mark.getBoundingClientRect();
|
||||
copy.remove();
|
||||
return { x: box.left, y: box.bottom };
|
||||
}
|
||||
|
||||
// node: the H3 Prompt Writer; area: its script box; layer: the coloured copy
|
||||
// under it; context(): { pictures, cameras, looks, seconds }.
|
||||
export function attachScriptAssist(node, area, layer, context) {
|
||||
const none = { dispose: () => {} };
|
||||
if (!area?.parentElement) return none;
|
||||
ensureCss();
|
||||
|
||||
// The "?" card.
|
||||
const row = area.closest(".ausboss-card-row");
|
||||
const header = row?.previousElementSibling?.classList?.contains("ausboss-card-section") ? row.previousElementSibling : null;
|
||||
let help = null;
|
||||
let dot = null;
|
||||
let closeHelpTimer = null;
|
||||
const closeHelp = () => { help?.remove(); help = null; dot?.classList.remove("open"); };
|
||||
const openHelp = () => {
|
||||
clearTimeout(closeHelpTimer);
|
||||
if (help) return;
|
||||
help = make("div", "ausboss-assist-pop ausboss-assist-help");
|
||||
help.append(make("h4", "", "What you can type"));
|
||||
const list = make("dl");
|
||||
for (const entry of CHEAT_SHEET) list.append(make("dt", "", entry.code), make("dd", "", entry.text));
|
||||
help.append(list, make("p", "", "Typing @ lists your pictures. Tab picks a suggestion, Esc closes it."));
|
||||
help.addEventListener("mouseenter", () => clearTimeout(closeHelpTimer));
|
||||
help.addEventListener("mouseleave", () => { closeHelpTimer = setTimeout(closeHelp, 250); });
|
||||
document.body.append(help);
|
||||
const box = dot.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(window.innerWidth - help.offsetWidth - 8, box.right - help.offsetWidth));
|
||||
const below = box.bottom + 6;
|
||||
help.style.left = `${left}px`;
|
||||
help.style.top = `${below + help.offsetHeight > window.innerHeight - 8 ? Math.max(8, box.top - help.offsetHeight - 6) : below}px`;
|
||||
dot.classList.add("open");
|
||||
};
|
||||
if (header) {
|
||||
dot = make("button", "ausboss-help-dot", "?");
|
||||
dot.type = "button";
|
||||
dot.title = "";
|
||||
dot.setAttribute("aria-label", "What you can type in the words");
|
||||
dot.addEventListener("mouseenter", openHelp);
|
||||
dot.addEventListener("mouseleave", () => { closeHelpTimer = setTimeout(closeHelp, 250); });
|
||||
dot.addEventListener("pointerdown", (event) => event.stopPropagation());
|
||||
dot.addEventListener("click", (event) => { event.stopPropagation(); if (help) closeHelp(); else openHelp(); });
|
||||
header.append(dot);
|
||||
}
|
||||
|
||||
// Suggestions.
|
||||
let pop = null;
|
||||
let offer = null;
|
||||
let active = 0;
|
||||
const close = () => { pop?.remove(); pop = null; offer = null; };
|
||||
const take = (item) => {
|
||||
if (!offer) return;
|
||||
area.focus();
|
||||
area.setSelectionRange(offer.from, offer.to);
|
||||
// insertText keeps the browser's own undo.
|
||||
if (!document.execCommand?.("insertText", false, item.insert)) {
|
||||
area.setRangeText(item.insert, offer.from, offer.to, "end");
|
||||
area.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
close();
|
||||
};
|
||||
const draw = () => {
|
||||
if (!pop || !offer) return;
|
||||
pop.textContent = "";
|
||||
offer.items.forEach((item, index) => {
|
||||
const line = make("div", `ausboss-assist-item${index === active ? " active" : ""}`);
|
||||
if (item.number && item.picture) { const img = make("img"); img.src = item.picture; line.append(img); }
|
||||
line.append(make("b", "", item.label));
|
||||
if (item.hint) line.append(make("em", "", item.hint));
|
||||
line.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); take(item); });
|
||||
pop.append(line);
|
||||
});
|
||||
pop.append(make("div", "ausboss-assist-foot", offer.strong ? "Enter or Tab to pick · Esc to close" : "Tab to pick · Esc to close"));
|
||||
};
|
||||
const update = () => {
|
||||
if (document.activeElement !== area || area.selectionStart !== area.selectionEnd) return close();
|
||||
const ctx = context();
|
||||
const next = suggestAt(area.value, area.selectionEnd, ctx);
|
||||
if (!next) return close();
|
||||
for (const item of next.items) if (item.number) item.picture = ctx.pictures.find((picture) => picture.number === item.number)?.url ?? "";
|
||||
const same = offer && offer.from === next.from && offer.items.map((item) => item.label).join() === next.items.map((item) => item.label).join();
|
||||
offer = next;
|
||||
if (!same) active = 0;
|
||||
if (!pop) { pop = make("div", "ausboss-assist-pop ausboss-assist-list"); document.body.append(pop); }
|
||||
draw();
|
||||
const point = caretPoint(area, layer);
|
||||
pop.style.left = `${Math.max(8, Math.min(window.innerWidth - pop.offsetWidth - 8, point.x))}px`;
|
||||
pop.style.top = `${point.y + pop.offsetHeight + 4 > window.innerHeight ? point.y - pop.offsetHeight - 22 : point.y + 4}px`;
|
||||
};
|
||||
const onKey = (event) => {
|
||||
if (!offer) return;
|
||||
const count = offer.items.length;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
active = (active + (event.key === "ArrowDown" ? 1 : count - 1)) % count;
|
||||
offer.strong = true; // Choosing with the arrows makes Enter pick too.
|
||||
draw();
|
||||
} else if (event.key === "Tab" || (event.key === "Enter" && offer.strong && !event.shiftKey)) {
|
||||
take(offer.items[active]);
|
||||
} else if (event.key === "Escape") {
|
||||
close();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
const onBlur = () => setTimeout(() => { if (document.activeElement !== area) close(); }, 0);
|
||||
area.addEventListener("input", update);
|
||||
area.addEventListener("keydown", onKey, true);
|
||||
area.addEventListener("blur", onBlur);
|
||||
area.addEventListener("click", update);
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
close();
|
||||
closeHelp();
|
||||
dot?.remove();
|
||||
area.removeEventListener("input", update);
|
||||
area.removeEventListener("keydown", onKey, true);
|
||||
area.removeEventListener("blur", onBlur);
|
||||
area.removeEventListener("click", update);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Pure decisions behind js/shared/script_assist.mjs: what the help card says,
|
||||
// and what to suggest at the caret in H3 Prompt Writer's script box. No DOM,
|
||||
// so tests run it under node:test.
|
||||
|
||||
// The help card: each thing the node reads in the words, in plain words.
|
||||
export const CHEAT_SHEET = [
|
||||
{ code: "@1 to @4", text: "A picture you plugged in. @1 is Picture 1." },
|
||||
{ code: "3s:", text: "At the start of a line: this moment starts 3 seconds in. Lines with no time share what is left evenly. Dragging a block's left edge on the bar types this for you." },
|
||||
{ code: "blank line", text: "A cut to a new shot. No blank lines means one take." },
|
||||
{ code: "\"in quotes\"", text: "Said out loud, word for word." },
|
||||
{ code: "sound:", text: "On its own line: what the clip sounds like. Same as the Sound box." },
|
||||
{ code: "music:", text: "On its own line: the music. Music makes the clip louder." },
|
||||
{ code: "camera:", text: "On its own line: how the camera moves, like push in or handheld." },
|
||||
{ code: "look:", text: "On its own line: a style, like anime or watercolour." },
|
||||
{ code: "no cuts", text: "Anywhere in the words: keep it all one shot." },
|
||||
];
|
||||
|
||||
const LABELS = [
|
||||
{ word: "sound", hint: "what the clip sounds like" },
|
||||
{ word: "music", hint: "the music, makes it louder" },
|
||||
{ word: "camera", hint: "how the camera moves" },
|
||||
{ word: "look", hint: "a style for the whole clip" },
|
||||
];
|
||||
|
||||
const starts = (value, typed) => value.toLowerCase().startsWith(typed.toLowerCase());
|
||||
|
||||
// What to offer at the caret, or null.
|
||||
// pictures: [{ number, job, plugged }] for @1 to @4
|
||||
// cameras, looks: the choices the node knows, for camera: and look: lines
|
||||
// seconds: the clip's length, so a time past the end isn't offered
|
||||
// The answer replaces text[from, to) with an item's insert. `strong` means
|
||||
// the person typed a trigger (@, camera:, look:), so Enter may take an item;
|
||||
// a guess from the first letters of a line only takes Tab or a click, so
|
||||
// Enter still starts a new line.
|
||||
export function suggestAt(text, caret, { pictures = [], cameras = [], looks = [], seconds = 8 } = {}) {
|
||||
const value = String(text ?? "");
|
||||
const before = value.slice(0, caret);
|
||||
const line = before.slice(before.lastIndexOf("\n") + 1);
|
||||
|
||||
const at = before.match(/(?:^|[^\w@])@(\d?)$/);
|
||||
if (at) {
|
||||
const typed = at[1];
|
||||
const plugged = pictures.filter((picture) => picture.plugged);
|
||||
const offer = (plugged.length ? plugged : pictures).filter((picture) => String(picture.number).startsWith(typed));
|
||||
if (!offer.length) return null;
|
||||
return {
|
||||
from: caret - typed.length - 1,
|
||||
to: caret,
|
||||
strong: true,
|
||||
items: offer.map((picture) => ({
|
||||
label: `@${picture.number}`,
|
||||
insert: `@${picture.number}`,
|
||||
hint: picture.plugged ? picture.job || "" : "not plugged in",
|
||||
number: picture.number,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const choice = line.match(/^\s*(camera|look)\s*:\s*([\w ]*)$/i);
|
||||
if (choice) {
|
||||
const typed = choice[2];
|
||||
const list = choice[1].toLowerCase() === "camera" ? cameras : looks;
|
||||
const offer = list.filter((name) => starts(name, typed) && name.toLowerCase() !== typed.toLowerCase());
|
||||
if (!offer.length) return null;
|
||||
return { from: caret - typed.length, to: caret, strong: true, items: offer.map((name) => ({ label: name, insert: name, hint: "" })) };
|
||||
}
|
||||
|
||||
const time = line.match(/^(\s*)(\d{1,2})$/);
|
||||
if (time) {
|
||||
const second = Number(time[2]);
|
||||
if (second < 1 || second >= seconds) return null;
|
||||
return {
|
||||
from: caret - time[2].length,
|
||||
to: caret,
|
||||
strong: false,
|
||||
items: [{ label: `${second}s:`, insert: `${second}s: `, hint: `this moment starts ${second} seconds in` }],
|
||||
};
|
||||
}
|
||||
|
||||
const word = line.match(/^(\s*)([a-z]{2,6})$/i);
|
||||
if (word) {
|
||||
const offer = LABELS.filter((label) => starts(label.word, word[2]) && label.word !== word[2].toLowerCase());
|
||||
if (!offer.length) return null;
|
||||
return {
|
||||
from: caret - word[2].length,
|
||||
to: caret,
|
||||
strong: false,
|
||||
items: offer.map((label) => ({ label: `${label.word}:`, insert: `${label.word}: `, hint: label.hint })),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Colours in H3 Prompt Writer's script box: the words the node reads as
|
||||
// instructions stand out while you type. @1 is a picture, 3s: is a time, a
|
||||
// blank line is a cut, sound: and music: lines go to their own part, and
|
||||
// words in quotes are spoken.
|
||||
//
|
||||
// The real textarea stays on top with see-through text, so typing, undo,
|
||||
// copy and the caret are the browser's own. A copy of the text sits under it
|
||||
// with the colours. Only colours and backgrounds change, never the font or
|
||||
// the spacing, so both lay out the same lines.
|
||||
import { scriptMarkup } from "./script_colours_math.mjs";
|
||||
|
||||
const CSS_ID = "ausboss-script-colours-css";
|
||||
|
||||
function ensureCss() {
|
||||
if (document.getElementById(CSS_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = CSS_ID;
|
||||
style.textContent = `
|
||||
.ausboss-colours-host{position:relative}
|
||||
.ausboss-colours-host>:not(.ausboss-colours):not(textarea){display:none !important}
|
||||
.ausboss-colours{position:absolute;inset:0;box-sizing:border-box;margin:0;overflow:hidden;white-space:pre-wrap;overflow-wrap:break-word;word-break:normal;pointer-events:none;color:#d8ecea;border:1px solid transparent;border-radius:6px;background:#0b0f10}
|
||||
.ausboss-colours-host>textarea.ausboss-colours-on{position:relative;background:transparent !important;color:transparent !important;caret-color:#d8ecea;-webkit-text-fill-color:transparent}
|
||||
.ausboss-colours-host>textarea.ausboss-colours-on::placeholder{color:#4f6462;-webkit-text-fill-color:#4f6462}
|
||||
.ausboss-colours-host>textarea.ausboss-colours-on::selection{background:rgba(0,180,170,.35)}
|
||||
.ausboss-colours .at{border-radius:3px;background:rgba(0,180,170,.3);box-shadow:0 0 0 1px rgba(0,180,170,.45);color:#a5f3ec}
|
||||
.ausboss-colours .at.unknown{background:rgba(245,181,61,.18);box-shadow:0 0 0 1px rgba(245,181,61,.5);color:#f5c76b}
|
||||
.ausboss-colours .time{color:#f5b53d}
|
||||
.ausboss-colours .key{color:#6f8886}
|
||||
.ausboss-colours .say{color:#f0dcae}
|
||||
.ausboss-colours .cut{display:inline-block;width:100%;background:linear-gradient(transparent calc(50% - 1px),#2f4a48 calc(50% - 1px),#2f4a48 50%,transparent 50%);position:relative}
|
||||
.ausboss-colours .cut::after{content:"cut";position:absolute;right:0;top:0;padding-left:4px;background:#0b0f10;color:#56706d;font-size:9px;letter-spacing:.1em;text-transform:uppercase}
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
// The box's own text settings, copied so the coloured copy wraps the same.
|
||||
const COPIED = ["fontFamily", "fontSize", "fontWeight", "fontStyle", "lineHeight", "letterSpacing", "wordSpacing", "tabSize", "textIndent", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth"];
|
||||
|
||||
export function attachScriptColours(node) {
|
||||
const none = { sync: () => {}, dispose: () => {} };
|
||||
const area = node.__ausbossCard?.root?.querySelector?.("textarea.ausboss-card-area");
|
||||
if (!area?.parentElement) return none;
|
||||
ensureCss();
|
||||
const host = area.parentElement;
|
||||
const layer = document.createElement("div");
|
||||
layer.className = "ausboss-colours";
|
||||
layer.setAttribute("aria-hidden", "true");
|
||||
host.classList.add("ausboss-colours-host");
|
||||
host.insertBefore(layer, area);
|
||||
area.classList.add("ausboss-colours-on");
|
||||
// Another pack that colours every text box puts its own copy beside the
|
||||
// box; the rule above hides it, so the text never shows twice.
|
||||
area.dataset.ausbossColoured = "true";
|
||||
|
||||
let wiredNumbers = new Set([1, 2, 3, 4]);
|
||||
const draw = () => {
|
||||
const style = getComputedStyle(area);
|
||||
for (const key of COPIED) layer.style[key] = style[key];
|
||||
// A scroll bar takes width from the text; the copy gives up the same.
|
||||
const bar = area.offsetWidth - area.clientWidth - (parseFloat(style.borderLeftWidth) || 0) - (parseFloat(style.borderRightWidth) || 0);
|
||||
layer.style.paddingRight = `${(parseFloat(style.paddingRight) || 0) + Math.max(0, bar)}px`;
|
||||
layer.innerHTML = scriptMarkup(area.value, (number) => wiredNumbers.has(number));
|
||||
layer.scrollTop = area.scrollTop;
|
||||
};
|
||||
const onScroll = () => { layer.scrollTop = area.scrollTop; };
|
||||
area.addEventListener("input", draw);
|
||||
area.addEventListener("scroll", onScroll);
|
||||
const observer = typeof ResizeObserver === "function" ? new ResizeObserver(draw) : null;
|
||||
observer?.observe(area);
|
||||
queueMicrotask(draw);
|
||||
return {
|
||||
area,
|
||||
layer,
|
||||
// values: the node's widget values; numbers: the pictures plugged in.
|
||||
sync(values, numbers) {
|
||||
if (numbers) wiredNumbers = new Set(numbers);
|
||||
if (document.activeElement !== area && typeof values?.script === "string" && area.value !== values.script) area.value = values.script;
|
||||
draw();
|
||||
},
|
||||
dispose() {
|
||||
observer?.disconnect();
|
||||
area.removeEventListener("input", draw);
|
||||
area.removeEventListener("scroll", onScroll);
|
||||
},
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user