Compare commits

..
Author SHA1 Message Date
ausboss 3569a1a24e Load Audio: review fixes
- Only plain audio files are decoded; files that point the decoder at
  other files (playlists and the like) are refused.
- A wired Max length is previewed only when its value is known;
  otherwise the face says it is wired.
- Typing 0 for OUT keeps to the end of the file, as documented.
- Playback pauses in a hidden tab and LOOP restarts at the file's end.
- Help page and README no longer claim a late selection decodes fast.
2026-11-04 00:26:50 -06:00
ausboss 3b16d1e254 Load Audio: shorter wave, length moves off the wave next to OUT 2026-11-03 23:09:43 +00:00
ausboss dff58e6b88 Load Audio: open smaller and go down to 280 wide 2026-11-03 22:57:38 +00:00
ausboss ef06093d56 Merge remote-tracking branch 'origin/main' into feat/load-audio-trim
# Conflicts:
#	CHANGELOG.md
2026-11-03 22:40:25 +00:00
ausboss 8582a0219d Add Load Audio node: wave, trim, preview and max length
Load a sound file and keep only the part you want. The node shows the
whole file as a wave. Drag the IN and OUT handles (or type the times),
press play to hear just that part, and set Max length to the longest
sound the next step takes: anything past it turns red and is cut off,
so you see what comes out before you run. Max length can be wired.

Only the picked part is decoded. Outputs the audio and its seconds.
Help page, README, changelog, Chinese names, tests and the API snapshot
are included.
2026-11-03 20:51:32 +00:00
231 changed files with 1547 additions and 16552 deletions
-33
View File
@@ -11,7 +11,6 @@ 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:
@@ -29,7 +28,6 @@ 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:
@@ -46,7 +44,6 @@ 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")
@@ -87,33 +84,3 @@ 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
@@ -1,82 +0,0 @@
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
changelog:
description: Release note for this version, shown in the Registry's Updates section
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: ${{ inputs.changelog }}
run: |
if [ -z "$REGISTRY_ACCESS_TOKEN" ]; then
echo '::error::REGISTRY_ACCESS_TOKEN is not configured.'
exit 1
fi
if [ -z "${COMFY_NODE_CHANGELOG// /}" ]; then
echo '::error::The changelog input is empty; the Registry shows it as the release note.'
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
+3 -290
View File
@@ -2,295 +2,8 @@
All notable changes to ComfyUI-AusBoss are documented here.
## 2.25.0 - 2026-11-12
## Unreleased
- **New node: Load Image.** Loads a picture and sets its size in the same
node, so a workflow needs no resize node after its loader. Size can be a
megapixel count, a longest side, a scale, a box the picture fits inside, or
a box it fills by cropping, rounded to a multiple you choose. It can also
cut a piece or a shape such as 16:9 out of the picture without resizing,
and you pick which part is kept: the center, a side or a corner. Arrows
step through the pictures in the input folder. Two small pictures on the
node show what goes in and what comes out, with both sizes and what is
cut off, before you run. It also gives the file's name and its original
size. With Size off the picture comes out exactly as the file has it.
- **Clear VRAM from the right-click menu.** Right-click the empty canvas
and pick **Clear VRAM 🆎** to unload every model and free memory on the
ComfyUI server, the same as ComfyUI's own "Unload Models and Execution
Cache". A short message says when it is done. Turn the entry off in
Settings, 🆎 AusBoss, Memory.
- **Callout 🆎: Info card.** Right-click a Callout and pick **Info card**
for a small card with a title, a list of labels and values, and small
print, such as a table of run times. Write a row as `label | value`; a
row in **bold** is drawn in teal so the one to pick stands out. The first
line is the title, and a ⏱️ ℹ️ ⚠️ 💡 or ✅ at its start becomes a small
icon. An older pack shows the card as a plain note with its rows written
out.
- **Save Image shows big pictures at once.** The node now shows a small
copy of each picture, a few hundred kilobytes, where it used to load the
whole file. A 30 MB sheet on a ComfyUI opened from another computer took
half a minute to show, or never did; it now shows as the run ends. The
saved file is the same full-size file as before.
- **Save Image: the Save button is hard to miss.** With **Auto save** off,
a row over the picture now says **Not saved yet** and holds a solid
**Save picture** button. Before, it was a small SAVE label in the corner.
With several pictures the button reads **Save all 4**, or **Save this
one** when the arrows are on one picture. After a save the row says the
name the picture was saved under.
- **A picture that loads slowly no longer leaves the old one up.** When a
result took more than about 16 seconds to reach the browser, the node
kept the picture from the run before. Now the new picture always shows,
and the old one dims while the new one loads. This fixes Select Frame,
Mask Refine, Mask by Name and LaMa Inpaint as well as Save Image.
## 2.24.0 - 2026-11-12
- **New node: Edit Lock.** Keeps every part of your picture that an edit
did not change exactly where it was, with the colors it had. Edit models
redraw the whole picture, so untouched parts often come back a little
zoomed, shifted or tinted. Put Edit Lock between the model and the
sampler and give it the picture you are editing. It needs no mask. It
steps aside when the edit changes the whole picture, and lets rain,
snow and haze through while holding the picture in place. Tested on
MiniMax-H3-Image.
- **New node: Edit Lock Restore.** Puts your picture's own pixels back in
every part the edit did not change. Add it after the VAE Decode with
Edit Lock's `edit_lock` output. It also gives a mask of what the edit
changed and a one-line report.
- **New node: Text Lines.** It runs what comes after once for each line of
your text. Type five prompts, one on each line, and one run makes five
pictures. It also takes a list a text model wrote: empty lines are
skipped, a leading number or bullet comes off, and **Limit** stops a list
that ran long.
- **Save Image shows every picture of a run at once.** When a run saves
several pictures, they now show side by side in the node, the way the
core Save Image does. The arrows still go to each picture by itself, and
round again to all of them. Select Frame, Mask Refine, Mask by Name and
LaMa Inpaint show a batch the same way.
- **Save Image can wait for a click before it saves.** Switch **Auto save**
off and a run only shows its pictures on the node. **SAVE ALL** over them
keeps every one; step to one picture with the arrows and **SAVE** keeps
just that one. Good for making a batch and keeping the best. Auto save is
on unless you switch it off, so nothing changes for workflows you have.
- **New node: Prompt Variations.** A prompt box that picks one choice from
each set in curly brackets, such as `a {red|blue|green} {sweater|jacket}`,
on every run. The seed decides the picks, so the same seed gives the same
prompt again. Queue 50 runs with a random seed and get 50 different
prompts, with no wildcard pack.
- **Mask by Touch: type a name to take it out of the mask.** The name field
under the picture now has a sign in front. Click it to switch between **+**
(add by name) and **−** (take away by name), then type "face" or "hair" and
press Enter. Typing "-face" takes it away whatever the sign says. Before,
a typed name was only taken away while Erase was chosen, which nothing on
the node said.
- **Mask by Touch: Ctrl-click only takes away what you point at.** With a
sweater picked, a Ctrl-click on the face offered the whole person, and the
sweater went with it. Now a spot where nothing is picked says so and
changes nothing.
- **Mask by Touch works on big pictures.** A look at a 3840 pixel picture took
twice as long as at 2048, and at 8192 pixels it ran a 32 GB graphics card
out of memory. The node now asks SAM 3 about a copy of at most 2048 pixels
and brings the answer back to full size. Pictures up to 2048 pixels come
out exactly as before. On bigger ones a mask's edge can sit a pixel or two
differently.
- **Mask by Touch no longer fills the queue.** With Image Crop + Rotate + Pad
or Load Image + Pad wired in, every change to the loader (each step of its
megapixels, for one) queued one more look at a picture that was already
replaced. Now only the newest look waits. The button that pauses the
looking also says **Pause** and **Look on**.
- **New node: FlashVSR Upscale 🆎.** Makes a video larger and sharper with
the FlashVSR v1.1 model, from 1x to 4x. It needs one model file and no
other pack: the help page has the download link. A long clip needs no more
graphics memory than a short one, and a result too large for the free
memory is made in tiles.
The new example `FlashVSR Video Upscale (AusBoss)` shows it between Load
Video and Save Video.
- **Two errors from outside a workflow now say what to do.** When a MiniMax H3
run stops with `KeyError: 'latent'` because the Muse MiniMax Director pack is
installed, a message names that pack and says to turn it off. When an older
ComfyUI cannot read a `w6a8` model file, a message says to update ComfyUI.
ComfyUI's own error report stays as it is.
- **ComfyUI starts when the pack is installed twice.** A Manager install
(`custom_nodes/ausboss-nodes`) beside a git clone
(`custom_nodes/ComfyUI-AusBoss`) stopped ComfyUI while it started. Now one
copy loads and the other steps aside, and the console says which folder to
delete. A copy from 2.23.0 or older is the one that loads when it sits
beside a newer one, so delete the older folder.
- **A Workflow Note popup closes when you click outside it.** Opening an
info tile (a Tips note, say) showed the full note, and only its Close
button or Escape put it away. A click on the dark area around it now
closes it too. Selecting text in the note, or a drag that starts on it,
keeps it open, and the Edit form still asks before it drops a change.
## 2.23.0 - 2026-11-09
- **Mask by Touch keeps your picks when the picture changes.** Your clicks
and paint move onto the new picture, in the same places, so an edit before
the node no longer means picking everything again. On a picture of another
size they are scaled to fit. A click finds whatever is at that spot now;
paint stays exactly as painted. For the old way, set **New picture**, under
More, to **start over**.
- **Workflow Switches: a switch that changes a setting no longer turns its
node off.** The node it sets keeps running. LoRA choices keep every field
that differs between them, and the add-choice button shows as soon as a
node is selected.
- **Mask by Touch clears a stopped run's message once a run works.** When
a run stopped at the node, its message stayed on the picture after the
next run from the Run button had worked. It now goes as soon as the new
result comes in.
- **Video Crop + Rotate + Pad → Clip: the fixed_frames input sits on its own
row again.** In workflows saved by an earlier release, newer ComfyUI drew
fixed_frames on top of force_rate, so a link could not be dropped on it.
Every input the pack moves into the input column now gets its own row.
- **Node colours stay after a save and reload.** A colour picked from the
right-click menu now survives saving, reloading and pasting. In Nodes 2.0,
About this node is in the right-click menu, and dragging a node's corner
snaps to nearby lines (hold Shift to skip the snap).
- **Image Folder shows the right pictures when you change folders.**
Switching folders or reloading a workflow no longer shows old or mixed-up
results, a very large folder keeps its newest files, and thumbnails refresh
when a picture is saved over.
- **Crop for Inpaint keeps the whole soft edge inside the crop.** The blend
at the seam is now complete. Crops and blend masks can come out slightly
different from earlier releases for the same inputs.
- **Compare, the preview bar and Save Image no longer show a stale picture.**
A late reply is ignored once the picture has changed, and Save Image
forgets its old saved file when the folder changes. Save Image also takes
bfloat16 and one-channel images, and a blank linked caption writes no
caption file.
- **Color Match leaves a picture alone when matched to itself.** When the
reference is the same picture, or the same colours at another size, the
picture now comes back unchanged instead of shifting colour. Other matches
can differ from earlier releases by less than one shade step.
- **Krea 2 Outpaint Model Patch no longer reuses an old reference.** On a
fresh server's first run, its reference cache could hand back a stale
reference, so the new area came out different from a run without the
cache. Now both match exactly. Other model patches on the same model also
apply alongside it.
- **Three example workflow notes now match their graphs.** Krea 2 Rotate +
Outpaint says its tone match is on, Qwen Image 2.1 Inpaint says its seed is
fixed, and Qwen Image 2.1 Upscale + Restore says the redraw is at least
about 2 MP.
- **The pack's own checks are stricter.** They read pyproject.toml properly
and catch more ways a node could be hidden from the Registry's scanners.
Nothing changes in your workflows.
## 2.22.0 - 2026-11-07
- **Node cards have a calmer, easier-to-read look.** Number boxes, choices,
switches, fields and buttons are now one solid gray with no outlines, on a
dark card. Labels, units and section names are lighter and easier to read,
and every control in a card row is the same height. The bar above a result
picture (the picture arrows, the Preview steps and Preview switches, and
buttons such as AUTO) and the picture's frame match the cards. The colors
live in one shared file, so the rest of the pack can follow.
- **Mask by Touch 🆎 no longer fills holes unless you ask.** Fill small
holes, under More, now starts off, so a hole you pick stays a hole. Saved
workflows keep their setting.
- **H3 Prompt Writer: a swap no longer makes the new person talk to noise.** With
**Keep the clip's sound** on and no words typed, the writer told H3 the new
person speaks as in the video whenever the clip's sound was loud, so a train
or a beat made their mouth move. It now does that only when it hears a real
voice: half a second or more of clear speech.
- **Mask by Touch shows a changed picture without unplugging it.** When the
picture coming in changes and the run stops because the picks were made on
the old one, the new picture now shows on the node, ready to pick from. A
new reload button beside Undo shows it any time, without running the
workflow.
- **Save Image and Save Video: the Steps switch is now called Preview steps.** It
shows the picture or the clip's first frame while it is being made, and "Steps"
read like a sampler's step count. It works the same, and saved workflows keep
their setting.
## 2.21.2 - 2026-11-07
- **H3 Prompt Writer reads a plain swap sentence.** "Replace the man in the red
shirt with @1", "Swap the woman for @1" and similar phrasings now name only the
person who goes, instead of putting the whole sentence into the prompt.
- **A swapped-in person is there from the first frame.** Swaps with a picture
always ask for the replacement throughout, with no transition or morph.
- **A text model on H3 Prompt Writer's `clip` input writes the `@1 looks like`
line in a swap.** It describes the person's hair and face from the picture
when no line is typed. Without a model on `clip`, nothing changes.
- **Workflow Note tiles start compact in the classic canvas again.** A leftover
image preview filed under the node's id made the classic canvas grow a new
tile to hold the picture; notes now stand that preview down.
- **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
@@ -332,8 +45,8 @@ All notable changes to ComfyUI-AusBoss are documented here.
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.
a node does not snap yet. If another pack's snapping is on, such as
Pixaroma's Align, 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
+1 -16
View File
@@ -198,11 +198,6 @@ example_workflows/ # example workflows (regular workflow JSON, not API JSON)
(`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
@@ -345,17 +340,7 @@ before a release that follows a ComfyUI update.
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.
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
+11 -54
View File
@@ -3,13 +3,12 @@
<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">
<!-- 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>
<!-- 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">
<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>
<a href="https://huggingface.co/ausboss"><img src="https://img.shields.io/badge/Hugging%20Face-ausboss-00b4aa?style=flat-square&logo=huggingface&logoColor=white" alt="Hugging Face: ausboss"></a>
<a href="https://x.com/Zanzibased"><img src="https://img.shields.io/badge/X-%40Zanzibased-00b4aa?style=flat-square&logo=x&logoColor=white" alt="X @Zanzibased"></a>
</p>
</div>
@@ -20,7 +19,6 @@ Most of them are for image and video work. ComfyUI's core nodes already cover th
- **[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.
- **[Edit Lock](#edit-lock):** an edit changes what you asked for, and the rest of the picture stays exactly where it was. No mask needed.
- **[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.
@@ -30,7 +28,7 @@ Most of them are for image and video work. ComfyUI's core nodes already cover th
- **[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 classic node's title bar opens a short card with its inputs and outputs. In Nodes 2.0, right-click the node and choose **About this node**. Every node also 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.
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
@@ -43,8 +41,6 @@ git clone https://github.com/ausboss/ComfyUI-AusBoss.git
Restart ComfyUI and search the node library for **AusBoss**.
Install it one way, not both. Manager puts the pack in `custom_nodes/ausboss-nodes` and a clone in `custom_nodes/ComfyUI-AusBoss`. They are the same pack, so if both folders are there, delete one. The console says which copy it skipped.
After an update, through Manager or `git pull`, restart ComfyUI and hard-refresh the browser with **Ctrl+Shift+R**. Otherwise the browser keeps the old JavaScript, and the pack warns you about it. Manager can install a version or two behind while the Comfy Registry reviews a new release. To get the newest one, pick it in Manager's version list, or update by hand: delete the pack's folder in `ComfyUI/custom_nodes` and clone the repository as shown above.
The pack uses the Pillow, NumPy, Torch and PyAV that come with ComfyUI. Each example lists the models it needs on its Workflow Note. LaMa Inpaint also needs [`big-lama.pt`](https://github.com/Sanster/models/releases/download/add_big_lama/big-lama.pt) in `ComfyUI/models/lama/`. The pack never downloads weights by itself.
@@ -70,7 +66,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.
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.
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.
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.
@@ -80,12 +76,6 @@ It returns the `image`, a `mask` of everything the model paints (padding, see-th
Krea 2 Rotate + Outpaint turns a photo, pads it and paints the new area in one pass. [Try the Krea 2 Rotate + Outpaint example →](example_workflows/Krea%202%20Rotate%20%2B%20Outpaint%20%28AusBoss%29.json)
### Load Image
Loads a picture and sets its size in one node. Pick a file, upload one, or step through the input folder with the arrows. **Size** can stay off, or bring the picture to a megapixel count, a longest side, a scale, a box it must fit inside, or a box it fills by cropping. It can also cut a piece or a shape such as 16:9 out of the picture without resizing, and **Keep** picks which part stays: the center, a side or a corner. **Round to** keeps the result on a multiple of 8, 16 or whatever the model wants. The node shows the picture going in and coming out, with both sizes, before you run, such as `912 × 1632 → 1144 × 2048`.
It returns the `image`, a `mask` of its see-through parts, the new `width` and `height`, the `filename` for a save prefix, and the file's `original_width` and `original_height`.
### Load Image + Pad
Makes a canvas for outpainting: drag the edges out to add room around a picture. The Source list shows the picture under the pointer. The new area can be a solid color, the average color of the nearest edge, the edge pixels smeared outward, a blurred copy of the picture, or a mirror of it. The card also has a seam feather, a canvas multiple and a megapixel budget, and **Exact padding** opens one control per side. A budget of 0 keeps the picture's size, and anything higher resizes the picture before padding. A picture wired into `source_image` is padded instead of the chosen file.
@@ -146,18 +136,6 @@ The same watercolor edit without and with Realign to Source: 61 px off before, 0
Qwen drew this watercolor 5.6% taller, and Realign to Source lined it back up with the original.
### Edit Lock
Keeps every part of your picture that an edit did not change exactly where it was, with the colors it had. An edit model redraws the whole picture, so the parts you did not ask about often come back a little zoomed in, shifted or tinted, and the picture jumps when you flip between before and after. Put Edit Lock between the model and the sampler and give it the picture you are editing. It needs no mask: it sees what the edit changes while the picture is being made, and holds on to everything else.
When the edit changes the whole picture (a new style, new light), there is nothing to keep, so the node steps aside and the edit runs as usual. A soft effect over the whole picture, such as rain or falling snow, is let through as well: the node then only holds the picture in place. **Lock** (`lock`) set to `off` does nothing at all, `always` locks even a whole-picture edit, and an optional `mask` says where the edit may happen. **Tolerance** (`tolerance`) sets how big a change must be to count as part of the edit.
On MiniMax-H3-Image, 4 megapixel edits moved the picture by up to 14 px without the lock (measured at 1 megapixel) and by 0.2 px or less with it (0.08 px or less on 35 of the 36 locked runs), on 19 local edits at two seeds each. After five edits in a row on one picture the plain chain had drifted 1.65 px and turned visibly pink; the locked chain sat at 0.04 px with no tint.
### Edit Lock Restore
Puts your picture's own pixels back in every part the edit did not change. Edit Lock keeps those parts in place, but they still pass through the VAE, which softens fine detail a little. Add this right after the VAE Decode and wire in Edit Lock's `edit_lock` output. **Feather** (`feather`) sets how softly the original fades into the edit. It also returns `edit_mask`, white where the edit changed the picture, and a one-line `report` of how much was kept.
### 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 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.
@@ -188,7 +166,7 @@ It returns the frames, audio, frame count, fps, width, height, duration and a co
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).
It returns the `audio` you kept and its length in `seconds`. Decoding reads from the beginning to the cut, so later selections take longer to load. [Help page](js/docs/AUSBOSS_NODES_LoadAudio.md).
### Video Crop + Rotate + Pad → Frame
@@ -198,7 +176,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 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.
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.
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.
@@ -218,17 +196,11 @@ Changes a clip's frame rate by making in-between frames. You set the source and
It returns the new frames and their fps. It doesn't touch audio, so carry the source audio to Save Video separately. The [help page](js/docs/AUSBOSS_NODES_FrameInterpolate.md) covers the weights and timing.
### FlashVSR Upscale
Makes a video larger and sharper with the FlashVSR model. Set **Scale** from 1 to 4 and it returns every frame at the new size, with fine detail drawn back in. It reads the clip a few frames at a time, so a long clip needs no more graphics memory than a short one, and a result too large for the free memory is made in tiles.
It needs one model file in `ComfyUI/models/diffusion_models` and no other pack. The [help page](js/docs/AUSBOSS_NODES_FlashVSRUpscale.md) has the download link, the memory it needs and the speed. [Try the FlashVSR Video Upscale example →](example_workflows/FlashVSR%20Video%20Upscale%20%28AusBoss%29.json)
### Save Video
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, and **Preview 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.
**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
@@ -369,20 +341,10 @@ 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.
### Text Lines
Runs what comes after once for each line of your text. Type five prompts, one on each line, and one run makes five pictures. It also takes a list another node wrote, skips empty lines, and can take a leading `1.` or `-` off each line. `number` counts the lines from 1, to give each its own seed.
### Prompt Variations
A prompt box that changes on every run. Write choices in curly brackets, split by `|`, such as `a {red|blue|green} {sweater|jacket}`, and the node picks one from each set. Wire in the seed your sampler uses: a new seed gives a new mix, and the same seed gives the same prompt again. Set the seed to randomize and queue 50 runs to get 50 different prompts. Text without brackets comes out exactly as typed, so a JSON prompt is safe.
### 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`.
@@ -411,10 +373,6 @@ 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.
@@ -429,7 +387,7 @@ Turns parts of a workflow off and on from one small card. Select the nodes of on
## 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 or with a click, removing a watermark from a clip, and making a clip larger and sharper. 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.
![The Krea 2 Rotate + Outpaint workflow: a setup note on the left, then groups for your input, the two boxes and the model loaders, and the result with a before-and-after slider and Save Image.](assets/readme/workflow-layout.webp)
@@ -450,7 +408,6 @@ The workflows open with their image and video loaders empty, so you load your ow
| [Mask by Name](example_workflows/Mask%20by%20Name%20%28AusBoss%29.json) | Type what to find in a picture, and get its mask and a cut-out with a see-through background | ComfyUI's own SAM 3.1 (ComfyUI 0.20 or newer) |
| [Mask by Touch](example_workflows/Mask%20by%20Touch%20%28AusBoss%29.json) | Click a thing in a picture, or its whole subject, and get its mask and a cut-out with hair-fine edges | ComfyUI's own SAM 3.1 and its background-removal model (ComfyUI 0.21 or newer) |
| [Simple Video Watermark Remover](example_workflows/Simple%20Video%20Watermark%20Remover%20%28AusBoss%29.json) | Find an overlay in a clip and remove it, with a one-frame comparison branch | ComfyUI's own SAM 3.1 (ComfyUI 0.20 or newer), plus `big-lama.pt` |
| [FlashVSR Video Upscale](example_workflows/FlashVSR%20Video%20Upscale%20%28AusBoss%29.json) | Make a clip larger and sharper with one node | `flashvsr_v1.1_fp16.safetensors` |
Every example uses only core nodes and this pack. Speed and memory use depend on the models, the sizes, the frame count and whatever else is using the GPU.
@@ -471,7 +428,7 @@ People who download your workflow get the nodes through ComfyUI-Manager's missin
## Pack-wide tools
- **Help cards:** the **?** in a classic node's title bar opens its description, inputs and outputs. Nodes 2.0 and titleless nodes such as Run Timer have **About this node** in their right-click menu.
- **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.
+11 -43
View File
@@ -11,7 +11,7 @@ of taking the whole pack down, so one broken node never hides the rest.
import importlib
import os
import tomllib
import re
import traceback
# One entry per file in nodes/, kept alphabetical. The order has no
@@ -22,8 +22,6 @@ NODE_MODULES = [
"node_callout",
"node_color_match",
"node_compare",
"node_edit_lock",
"node_flashvsr_upscale",
"node_frame_interpolate",
"node_free_memory",
"node_h3_prompt_writer",
@@ -37,7 +35,6 @@ NODE_MODULES = [
"node_lama_inpaint",
"node_layout_boxes",
"node_literals",
"node_load_image",
"node_load_image_pad",
"node_load_audio",
"node_load_video",
@@ -45,7 +42,6 @@ NODE_MODULES = [
"node_mask_by_name",
"node_mask_by_touch",
"node_math",
"node_prompt_variations",
"node_realign_to_source",
"node_refine_mask",
"node_resolution",
@@ -56,7 +52,6 @@ NODE_MODULES = [
"node_select_frame",
"node_show_text",
"node_step_preview",
"node_text_lines",
"node_tiled_upscale",
"node_video_crop_rotate_pad",
"node_video_crop_rotate_pad_clip",
@@ -68,19 +63,7 @@ NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
_failed_modules = []
# Two copies of the pack (a Manager install beside a git clone) add every
# route twice and ComfyUI stops while starting. Only one copy loads.
_other_copy = None
try:
from .nodes._single_copy import other_copy_that_loads
_other_copy = other_copy_that_loads(os.path.dirname(__file__))
except Exception:
traceback.print_exc()
for _module_name in NODE_MODULES:
if _other_copy:
break
try:
_module = importlib.import_module(f".nodes.{_module_name}", __name__)
NODE_CLASS_MAPPINGS.update(getattr(_module, "NODE_CLASS_MAPPINGS", {}))
@@ -93,7 +76,7 @@ for _module_name in NODE_MODULES:
# Folder ComfyUI serves to the browser; every .js file in it is loaded as a
# frontend extension. Shared modules use .mjs so they are import-only.
WEB_DIRECTORY = None if _other_copy else "./js"
WEB_DIRECTORY = "./js"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
@@ -102,9 +85,9 @@ def _read_version():
"""Version straight from pyproject.toml so it is never duplicated."""
try:
pyproject = os.path.join(os.path.dirname(__file__), "pyproject.toml")
with open(pyproject, "rb") as f:
version = tomllib.load(f).get("project", {}).get("version")
return version if isinstance(version, str) and version.strip() else "unknown"
with open(pyproject, "r", encoding="utf-8") as f:
match = re.search(r'^version\s*=\s*"([^"]+)"', f.read(), re.MULTILINE)
return match.group(1) if match else "unknown"
except Exception:
return "unknown"
@@ -129,26 +112,11 @@ def _print_banner():
print(bar)
def _print_second_copy():
# ASCII only, as in the banner.
red = "\033[0;31m"
reset = "\033[0m"
own = os.path.basename(os.path.dirname(os.path.realpath(__file__)))
other = os.path.basename(_other_copy["folder"])
print(f"{red}AusBoss: this pack is installed twice.{reset}")
print(f" Using: custom_nodes/{other} (v{_other_copy['version']})")
print(f" Skipping: custom_nodes/{own} (v{_read_version()})")
print(" Delete one of the two folders, then restart ComfyUI.")
try:
from .nodes._frontend_cache import register_frontend_cache_guard
register_frontend_cache_guard()
except Exception:
traceback.print_exc()
if _other_copy:
_print_second_copy()
else:
try:
from .nodes._frontend_cache import register_frontend_cache_guard
register_frontend_cache_guard()
except Exception:
traceback.print_exc()
_print_banner()
_print_banner()
-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="105" height="20" role="img" aria-label="release: v2.25.0">
<title>release: v2.25.0</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.25.0</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

@@ -1,526 +0,0 @@
{
"id": "0b455c1f-6b0c-4f69-8a7e-86f8de61df5c",
"revision": 0,
"last_node_id": 5,
"last_link_id": 4,
"nodes": [
{
"id": 1,
"type": "AUSBOSS_NODES_WorkflowNote",
"pos": [
0,
30
],
"size": [
440,
640
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "note",
"type": "STRING",
"link": null,
"widget": {
"name": "note"
}
}
],
"outputs": [],
"title": "🆎 AusBoss",
"properties": {
"cnr_id": "ausboss-nodes",
"ver": "2.24.0",
"Node name for S&R": "AUSBOSS_NODES_WorkflowNote"
},
"widgets_values": [
"{\"v\": 1, \"title\": \"FlashVSR Video Upscale\", \"subtitle\": \"Make a video larger and sharper with one node and one model file.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your video.\\n2. Set **Scale**: how many times larger each side gets. At 1 the video keeps its size and is only sharpened.\\n3. Press **Run**, then play the result in **Save Video**.\\n\\nTo try a short piece first, drag the right end of the bar under the player in Load Video.\\n\\nA long video needs no more graphics memory than a short one. A large result is made in tiles, so it fits the memory you have and takes longer.\\n\\nNeeds AusBoss nodes 2.24.0 or newer. No other pack is needed.\", \"models\": [{\"name\": \"flashvsr_v1.1_fp16.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"3.5 GB\", \"url\": \"https://huggingface.co/ausboss/FlashVSR-v1.1-ComfyUI/resolve/main/flashvsr_v1.1_fp16.safetensors\", \"note\": \"FlashVSR v1.1 by Junhao Zhuang and the OpenImagingLab team, in one file\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_FlashVSRUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}",
""
],
"widgets_values_named": {
"note": "{\"v\": 1, \"title\": \"FlashVSR Video Upscale\", \"subtitle\": \"Make a video larger and sharper with one node and one model file.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your video.\\n2. Set **Scale**: how many times larger each side gets. At 1 the video keeps its size and is only sharpened.\\n3. Press **Run**, then play the result in **Save Video**.\\n\\nTo try a short piece first, drag the right end of the bar under the player in Load Video.\\n\\nA long video needs no more graphics memory than a short one. A large result is made in tiles, so it fits the memory you have and takes longer.\\n\\nNeeds AusBoss nodes 2.24.0 or newer. No other pack is needed.\", \"models\": [{\"name\": \"flashvsr_v1.1_fp16.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"3.5 GB\", \"url\": \"https://huggingface.co/ausboss/FlashVSR-v1.1-ComfyUI/resolve/main/flashvsr_v1.1_fp16.safetensors\", \"note\": \"FlashVSR v1.1 by Junhao Zhuang and the OpenImagingLab team, in one file\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_FlashVSRUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}",
"ausboss_workflow_note_panel": ""
},
"color": "#14424d",
"bgcolor": "#161f21"
},
{
"id": 2,
"type": "AUSBOSS_NODES_LoadVideo",
"pos": [
500,
80
],
"size": [
420,
760
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "video",
"type": "COMBO",
"link": null,
"widget": {
"name": "video"
}
},
{
"name": "start_seconds",
"type": "FLOAT",
"link": null,
"widget": {
"name": "start_seconds"
}
},
{
"name": "end_seconds",
"type": "FLOAT",
"link": null,
"widget": {
"name": "end_seconds"
}
},
{
"name": "custom_width",
"type": "INT",
"link": null,
"widget": {
"name": "custom_width"
}
},
{
"name": "custom_height",
"type": "INT",
"link": null,
"widget": {
"name": "custom_height"
}
},
{
"name": "every_nth",
"type": "INT",
"link": null,
"widget": {
"name": "every_nth"
}
},
{
"name": "max_frames",
"type": "INT",
"link": null,
"widget": {
"name": "max_frames"
}
},
{
"name": "single_frame",
"type": "BOOLEAN",
"link": null,
"widget": {
"name": "single_frame"
}
},
{
"name": "upload",
"type": "IMAGEUPLOAD",
"link": null,
"widget": {
"name": "upload"
}
}
],
"outputs": [
{
"name": "frames",
"type": "IMAGE",
"links": [
1
]
},
{
"name": "audio",
"type": "AUDIO",
"links": [
3
]
},
{
"name": "frame_count",
"type": "INT",
"links": []
},
{
"name": "fps",
"type": "FLOAT",
"links": [
4
]
},
{
"name": "width",
"type": "INT",
"links": []
},
{
"name": "height",
"type": "INT",
"links": []
},
{
"name": "duration",
"type": "FLOAT",
"links": []
},
{
"name": "video",
"type": "VIDEO",
"links": []
}
],
"title": "Load Video - Your video",
"properties": {
"cnr_id": "ausboss-nodes",
"ver": "2.24.0",
"Node name for S&R": "AUSBOSS_NODES_LoadVideo",
"ausboss_load_video_loop": true
},
"widgets_values": {
"video": "",
"start_seconds": 0,
"end_seconds": 0,
"custom_width": 0,
"custom_height": 0,
"every_nth": 1,
"max_frames": 0,
"single_frame": false
},
"widgets_values_named": {
"video": "",
"start_seconds": 0,
"end_seconds": 0,
"custom_width": 0,
"custom_height": 0,
"every_nth": 1,
"max_frames": 0,
"single_frame": false,
"upload": "image",
"ausboss_widget_card": "",
"ausboss_load_video_viewer": ""
},
"color": "#14424d",
"bgcolor": "#161f21"
},
{
"id": 3,
"type": "AUSBOSS_NODES_FlashVSRUpscale",
"pos": [
980,
80
],
"size": [
360,
190
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "frames",
"type": "IMAGE",
"link": 1
},
{
"name": "vae",
"type": "VAE",
"shape": 7,
"link": null
},
{
"name": "model",
"type": "COMBO",
"link": null,
"widget": {
"name": "model"
}
},
{
"name": "scale",
"type": "FLOAT",
"link": null,
"widget": {
"name": "scale"
}
},
{
"name": "color_match",
"type": "BOOLEAN",
"link": null,
"widget": {
"name": "color_match"
}
}
],
"outputs": [
{
"name": "frames",
"type": "IMAGE",
"links": [
2
]
}
],
"title": "FlashVSR Upscale",
"properties": {
"cnr_id": "ausboss-nodes",
"ver": "2.24.0",
"Node name for S&R": "AUSBOSS_NODES_FlashVSRUpscale",
"models": [
{
"name": "flashvsr_v1.1_fp16.safetensors",
"url": "https://huggingface.co/ausboss/FlashVSR-v1.1-ComfyUI/resolve/main/flashvsr_v1.1_fp16.safetensors",
"directory": "diffusion_models"
}
]
},
"widgets_values": [
"flashvsr_v1.1_fp16.safetensors",
2,
true,
""
],
"widgets_values_named": {
"model": "flashvsr_v1.1_fp16.safetensors",
"scale": 2,
"color_match": true,
"ausboss_widget_card": ""
},
"color": "#14424d",
"bgcolor": "#161f21"
},
{
"id": 4,
"type": "AUSBOSS_NODES_SaveVideo",
"pos": [
1400,
80
],
"size": [
440,
640
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "frames",
"type": "IMAGE",
"link": 2
},
{
"name": "audio",
"type": "AUDIO",
"link": 3
},
{
"name": "video",
"type": "VIDEO",
"link": null
},
{
"name": "fps",
"type": "FLOAT",
"link": 4
},
{
"name": "filename_prefix",
"type": "STRING",
"link": null,
"widget": {
"name": "filename_prefix"
}
},
{
"name": "crf",
"type": "INT",
"link": null
},
{
"name": "format",
"type": "COMBO",
"link": null,
"widget": {
"name": "format"
}
},
{
"name": "pingpong",
"type": "BOOLEAN",
"link": null,
"widget": {
"name": "pingpong"
}
},
{
"name": "save_metadata",
"type": "BOOLEAN",
"link": null,
"widget": {
"name": "save_metadata"
}
}
],
"outputs": [
{
"name": "file_path",
"type": "STRING",
"links": []
}
],
"title": "Save Video",
"properties": {
"cnr_id": "ausboss-nodes",
"ver": "2.24.0",
"Node name for S&R": "AUSBOSS_NODES_SaveVideo",
"ausboss_save_video_loop": true
},
"widgets_values": {
"fps": 24,
"filename_prefix": "flashvsr_upscaled",
"crf": 19,
"format": "mp4 h264",
"pingpong": false,
"save_metadata": true
},
"widgets_values_named": {
"fps": 24,
"filename_prefix": "flashvsr_upscaled",
"crf": 19,
"format": "mp4 h264",
"pingpong": false,
"save_metadata": true,
"ausboss_widget_card": "",
"ausboss_save_video_viewer": ""
},
"color": "#14424d",
"bgcolor": "#161f21"
},
{
"id": 5,
"type": "AUSBOSS_NODES_RunTimer",
"pos": [
1515,
770
],
"size": [
210,
67
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"title": "Run Timer",
"properties": {
"cnr_id": "ausboss-nodes",
"ver": "2.24.0",
"Node name for S&R": "AUSBOSS_NODES_RunTimer"
},
"color": "#14424d",
"bgcolor": "#161f21"
}
],
"links": [
[
1,
2,
0,
3,
0,
"IMAGE"
],
[
2,
3,
0,
4,
0,
"IMAGE"
],
[
3,
2,
1,
4,
1,
"AUDIO"
],
[
4,
2,
3,
4,
3,
"FLOAT"
]
],
"groups": [
{
"id": 1,
"title": "1 · Your video",
"bounding": [
480,
0,
460,
880
],
"color": "#1d5560",
"flags": {}
},
{
"id": 2,
"title": "2 · Make it larger",
"bounding": [
960,
0,
400,
880
],
"color": "#1d5560",
"flags": {}
},
{
"id": 3,
"title": "3 · Result",
"bounding": [
1380,
0,
480,
880
],
"color": "#1d5560",
"flags": {}
}
],
"definitions": {
"subgraphs": []
},
"config": {},
"extra": {
"ds": {
"scale": 0.73,
"offset": [
137,
20
]
}
},
"version": 0.4
}
@@ -1913,7 +1913,7 @@
"The painted area, grown a little, is filled with flat gray, so Krea 2 knows where to paint. The plain crop goes along too."
],
"widgets_values_named": {
"text": "The painted area, grown a little, is filled with flat gray, so Krea 2 knows where to paint. The plain crop goes along too."
"text": "The painted area, grown a little, is filled with flat gray. Krea 2 never sees what you are replacing."
},
"color": "#1f2c38",
"bgcolor": "#2a3b4a"
@@ -3052,7 +3052,7 @@
"The gray-filled crop is the reference. Painting starts from the plain crop, inside the noise mask: the painted area, widened by 24 px."
],
"widgets_values_named": {
"text": "The gray-filled crop is the reference. Painting starts from the plain crop, inside the noise mask: the painted area, widened by 24 px."
"text": "The gray-filled crop is the reference. The noise mask is the grown painted area, widened by 24 px. Krea 2 paints only inside it."
},
"color": "#1f2c38",
"bgcolor": "#2a3b4a"
@@ -3482,4 +3482,4 @@
"frontendVersion": "1.53.6"
},
"version": 0.4
}
}
@@ -2139,10 +2139,10 @@
"title": "Note",
"properties": {},
"widgets_values": [
"Blend in fades the model's picture into yours just inside its edge. Tone match is fully on, so the new area is adjusted to your picture's tone."
"Blend in fades the model's picture into yours just inside its edge, with no tone shift. The new area stays as painted."
],
"widgets_values_named": {
"text": "Blend in fades the model's picture into yours just inside its edge. Tone match is fully on, so the new area is adjusted to your picture's tone."
"text": "Blend in fades the model's picture into yours just inside its edge, with no tone shift. The new area stays as painted."
},
"color": "#1f2c38",
"bgcolor": "#2a3b4a"
@@ -439,7 +439,7 @@
0,
0,
0,
false,
true,
"stop the run",
false,
false,
@@ -451,7 +451,7 @@
"grow": 0,
"soften": 0,
"smooth": 0,
"fill_holes": false,
"fill_holes": true,
"if_nothing": "stop the run",
"free_vram": false,
"invert": false,
@@ -37,10 +37,10 @@
"Node name for S&R": "AUSBOSS_NODES_WorkflowNote"
},
"widgets_values": [
"{\"v\": 1, \"title\": \"Qwen Image 2.1 Inpaint\", \"subtitle\": \"Click a thing in your picture and say what to change. Only that part is repainted.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"1. Load your image. You can crop it right there with the blue handles.\\n2. On **Mask by Touch**, click the thing you want to change. It lights up before you click.\\n3. In **What to change**, say it the way you would ask a person: \\\"Replace the cup with a glass of orange juice.\\\"\\n4. Press **Run**.\\n\\nOnly the picked part is repainted. Everything outside it stays your own picture.\\n\\n### Tips\\n- Wrong thing picked? The arrows under the picture swap it for the next thing it could mean.\\n- Name everything that should go: \\\"the iced coffee and its straw\\\". What you do not name can stay.\\n- To remove something, pick it and write \\\"Remove the cup.\\\" Pick its shadow too, or the shadow stays.\\n- To add something, switch to **Paint** and paint where it should go, with some room. Say how big: \\\"Add small black sunglasses on her eyes.\\\"\\n- Not what you wanted? Change **Seed** and run it again. It stays fixed between runs.\\n\\n### Switches\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA from the list below. Off: 25 steps without it, about twice as long.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.12.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"sam3.1_multiplex_fp16.safetensors\", \"dir\": \"checkpoints\", \"size\": \"1.75 GB\", \"url\": \"https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors\", \"note\": \"by Meta, packaged by Comfy-Org\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"optional, for Fast · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MaskByTouch\"}], \"links\": [{\"label\": \"YouTube · this workflow\", \"url\": \"https://youtu.be/iMgaiXoB0v4\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
"{\"v\": 1, \"title\": \"Qwen Image 2.1 Inpaint\", \"subtitle\": \"Click a thing in your picture and say what to change. Only that part is repainted.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"1. Load your image. You can crop it right there with the blue handles.\\n2. On **Mask by Touch**, click the thing you want to change. It lights up before you click.\\n3. In **What to change**, say it the way you would ask a person: \\\"Replace the cup with a glass of orange juice.\\\"\\n4. Press **Run**.\\n\\nOnly the picked part is repainted. Everything outside it stays your own picture.\\n\\n### Tips\\n- Wrong thing picked? The arrows under the picture swap it for the next thing it could mean.\\n- Name everything that should go: \\\"the iced coffee and its straw\\\". What you do not name can stay.\\n- To remove something, pick it and write \\\"Remove the cup.\\\" Pick its shadow too, or the shadow stays.\\n- To add something, switch to **Paint** and paint where it should go, with some room. Say how big: \\\"Add small black sunglasses on her eyes.\\\"\\n- Not what you wanted? Run it again. The seed changes each run.\\n\\n### Switches\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA from the list below. Off: 25 steps without it, about twice as long.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.12.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"sam3.1_multiplex_fp16.safetensors\", \"dir\": \"checkpoints\", \"size\": \"1.75 GB\", \"url\": \"https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors\", \"note\": \"by Meta, packaged by Comfy-Org\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"optional, for Fast · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MaskByTouch\"}], \"links\": [{\"label\": \"YouTube · this workflow\", \"url\": \"https://youtu.be/iMgaiXoB0v4\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
],
"widgets_values_named": {
"note": "{\"v\": 1, \"title\": \"Qwen Image 2.1 Inpaint\", \"subtitle\": \"Click a thing in your picture and say what to change. Only that part is repainted.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"1. Load your image. You can crop it right there with the blue handles.\\n2. On **Mask by Touch**, click the thing you want to change. It lights up before you click.\\n3. In **What to change**, say it the way you would ask a person: \\\"Replace the cup with a glass of orange juice.\\\"\\n4. Press **Run**.\\n\\nOnly the picked part is repainted. Everything outside it stays your own picture.\\n\\n### Tips\\n- Wrong thing picked? The arrows under the picture swap it for the next thing it could mean.\\n- Name everything that should go: \\\"the iced coffee and its straw\\\". What you do not name can stay.\\n- To remove something, pick it and write \\\"Remove the cup.\\\" Pick its shadow too, or the shadow stays.\\n- To add something, switch to **Paint** and paint where it should go, with some room. Say how big: \\\"Add small black sunglasses on her eyes.\\\"\\n- Not what you wanted? Change **Seed** and run it again. It stays fixed between runs.\\n\\n### Switches\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA from the list below. Off: 25 steps without it, about twice as long.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.12.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"sam3.1_multiplex_fp16.safetensors\", \"dir\": \"checkpoints\", \"size\": \"1.75 GB\", \"url\": \"https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors\", \"note\": \"by Meta, packaged by Comfy-Org\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"optional, for Fast · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MaskByTouch\"}], \"links\": [{\"label\": \"YouTube · this workflow\", \"url\": \"https://youtu.be/iMgaiXoB0v4\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
"note": "{\"v\": 1, \"title\": \"Qwen Image 2.1 Inpaint\", \"subtitle\": \"Click a thing in your picture and say what to change. Only that part is repainted.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"1. Load your image. You can crop it right there with the blue handles.\\n2. On **Mask by Touch**, click the thing you want to change. It lights up before you click.\\n3. In **What to change**, say it the way you would ask a person: \\\"Replace the cup with a glass of orange juice.\\\"\\n4. Press **Run**.\\n\\nOnly the picked part is repainted. Everything outside it stays your own picture.\\n\\n### Tips\\n- Wrong thing picked? The arrows under the picture swap it for the next thing it could mean.\\n- Name everything that should go: \\\"the iced coffee and its straw\\\". What you do not name can stay.\\n- To remove something, pick it and write \\\"Remove the cup.\\\" Pick its shadow too, or the shadow stays.\\n- To add something, switch to **Paint** and paint where it should go, with some room. Say how big: \\\"Add small black sunglasses on her eyes.\\\"\\n- Not what you wanted? Run it again. The seed changes each run.\\n\\n### Switches\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA from the list below. Off: 25 steps without it, about twice as long.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.12.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"sam3.1_multiplex_fp16.safetensors\", \"dir\": \"checkpoints\", \"size\": \"1.75 GB\", \"url\": \"https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors\", \"note\": \"by Meta, packaged by Comfy-Org\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"optional, for Fast · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MaskByTouch\"}], \"links\": [{\"label\": \"YouTube · this workflow\", \"url\": \"https://youtu.be/iMgaiXoB0v4\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
},
"color": "#14424d",
"bgcolor": "#161f21"
@@ -37,10 +37,10 @@
"ver": "2.9.0"
},
"widgets_values": [
"{\"v\": 1, \"title\": \"Qwen Image 2.1 Upscale + Restore\", \"subtitle\": \"Load a small, soft or worn picture. Qwen Image 2.1 redraws it sharp at the megapixels you set, in its own colors.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your image. You can crop it right there with the blue handles.\\n2. Set **Megapixels**: the size of the result.\\n3. Press **Run**.\\n4. Slide **Before and after** to compare. The result is saved as a PNG.\\n\\n### What you get\\n\\n- Your picture redrawn sharp, at the size you asked for.\\n- Its own colors, also on a noisy or faded photo.\\n\\n### Megapixels\\n\\n- Up to about 2: one pass.\\n- Over that: redrawn in tiles of about 2 megapixels and joined. 4 takes two tiles, 8 takes four.\\n- The redraw limit for a small picture is 8 times its own megapixels or one roughly 2 megapixel tile, whichever is larger. The rest is enlarged the plain way.\\n- A picture of 1 megapixel or more only looks better when it grows about 1.6 times per side.\\n\\n### Switches\\n\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA. Off: 25 steps without it, about three times as long.\\n\\n### Tips\\n\\n- It is a redraw. Fine detail is made up to fit, so a face stays the same person, not the same pixels.\\n- Small lettering on signs and labels can come back misspelled.\\n- Each tile is matched to your picture's colors. To let Qwen's own colors through, lower **Keep colors** inside the box.\\n- If the run stops at a red **LoRA Loader**, a LoRA that is switched on is missing. Download it from the list below.\\n- Not tested on blur from a moving camera or missed focus.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.9.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"qwen-image-2.1-restore.safetensors\", \"dir\": \"loras\", \"size\": \"159 MB\", \"url\": \"https://huggingface.co/ausboss/Qwen-Image-2.1-Restore-LoRA/resolve/main/qwen-image-2.1-restore.safetensors\", \"note\": \"keeps the result close to your picture\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"for Fast on · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_TiledUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
"{\"v\": 1, \"title\": \"Qwen Image 2.1 Upscale + Restore\", \"subtitle\": \"Load a small, soft or worn picture. Qwen Image 2.1 redraws it sharp at the megapixels you set, in its own colors.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your image. You can crop it right there with the blue handles.\\n2. Set **Megapixels**: the size of the result.\\n3. Press **Run**.\\n4. Slide **Before and after** to compare. The result is saved as a PNG.\\n\\n### What you get\\n\\n- Your picture redrawn sharp, at the size you asked for.\\n- Its own colors, also on a noisy or faded photo.\\n\\n### Megapixels\\n\\n- Up to about 2: one pass.\\n- Over that: redrawn in tiles of about 2 megapixels and joined. 4 takes two tiles, 8 takes four.\\n- A small picture is redrawn at up to 8 times its own megapixels, then enlarged the plain way.\\n- A picture of 1 megapixel or more only looks better when it grows about 1.6 times per side.\\n\\n### Switches\\n\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA. Off: 25 steps without it, about three times as long.\\n\\n### Tips\\n\\n- It is a redraw. Fine detail is made up to fit, so a face stays the same person, not the same pixels.\\n- Small lettering on signs and labels can come back misspelled.\\n- Each tile is matched to your picture's colors. To let Qwen's own colors through, lower **Keep colors** inside the box.\\n- If the run stops at a red **LoRA Loader**, a LoRA that is switched on is missing. Download it from the list below.\\n- Not tested on blur from a moving camera or missed focus.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.9.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"qwen-image-2.1-restore.safetensors\", \"dir\": \"loras\", \"size\": \"159 MB\", \"url\": \"https://huggingface.co/ausboss/Qwen-Image-2.1-Restore-LoRA/resolve/main/qwen-image-2.1-restore.safetensors\", \"note\": \"keeps the result close to your picture\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"for Fast on · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_TiledUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
],
"widgets_values_named": {
"note": "{\"v\": 1, \"title\": \"Qwen Image 2.1 Upscale + Restore\", \"subtitle\": \"Load a small, soft or worn picture. Qwen Image 2.1 redraws it sharp at the megapixels you set, in its own colors.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your image. You can crop it right there with the blue handles.\\n2. Set **Megapixels**: the size of the result.\\n3. Press **Run**.\\n4. Slide **Before and after** to compare. The result is saved as a PNG.\\n\\n### What you get\\n\\n- Your picture redrawn sharp, at the size you asked for.\\n- Its own colors, also on a noisy or faded photo.\\n\\n### Megapixels\\n\\n- Up to about 2: one pass.\\n- Over that: redrawn in tiles of about 2 megapixels and joined. 4 takes two tiles, 8 takes four.\\n- The redraw limit for a small picture is 8 times its own megapixels or one roughly 2 megapixel tile, whichever is larger. The rest is enlarged the plain way.\\n- A picture of 1 megapixel or more only looks better when it grows about 1.6 times per side.\\n\\n### Switches\\n\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA. Off: 25 steps without it, about three times as long.\\n\\n### Tips\\n\\n- It is a redraw. Fine detail is made up to fit, so a face stays the same person, not the same pixels.\\n- Small lettering on signs and labels can come back misspelled.\\n- Each tile is matched to your picture's colors. To let Qwen's own colors through, lower **Keep colors** inside the box.\\n- If the run stops at a red **LoRA Loader**, a LoRA that is switched on is missing. Download it from the list below.\\n- Not tested on blur from a moving camera or missed focus.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.9.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"qwen-image-2.1-restore.safetensors\", \"dir\": \"loras\", \"size\": \"159 MB\", \"url\": \"https://huggingface.co/ausboss/Qwen-Image-2.1-Restore-LoRA/resolve/main/qwen-image-2.1-restore.safetensors\", \"note\": \"keeps the result close to your picture\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"for Fast on · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_TiledUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
"note": "{\"v\": 1, \"title\": \"Qwen Image 2.1 Upscale + Restore\", \"subtitle\": \"Load a small, soft or worn picture. Qwen Image 2.1 redraws it sharp at the megapixels you set, in its own colors.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your image. You can crop it right there with the blue handles.\\n2. Set **Megapixels**: the size of the result.\\n3. Press **Run**.\\n4. Slide **Before and after** to compare. The result is saved as a PNG.\\n\\n### What you get\\n\\n- Your picture redrawn sharp, at the size you asked for.\\n- Its own colors, also on a noisy or faded photo.\\n\\n### Megapixels\\n\\n- Up to about 2: one pass.\\n- Over that: redrawn in tiles of about 2 megapixels and joined. 4 takes two tiles, 8 takes four.\\n- A small picture is redrawn at up to 8 times its own megapixels, then enlarged the plain way.\\n- A picture of 1 megapixel or more only looks better when it grows about 1.6 times per side.\\n\\n### Switches\\n\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA. Off: 25 steps without it, about three times as long.\\n\\n### Tips\\n\\n- It is a redraw. Fine detail is made up to fit, so a face stays the same person, not the same pixels.\\n- Small lettering on signs and labels can come back misspelled.\\n- Each tile is matched to your picture's colors. To let Qwen's own colors through, lower **Keep colors** inside the box.\\n- If the run stops at a red **LoRA Loader**, a LoRA that is switched on is missing. Download it from the list below.\\n- Not tested on blur from a moving camera or missed focus.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.9.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"qwen-image-2.1-restore.safetensors\", \"dir\": \"loras\", \"size\": \"159 MB\", \"url\": \"https://huggingface.co/ausboss/Qwen-Image-2.1-Restore-LoRA/resolve/main/qwen-image-2.1-restore.safetensors\", \"note\": \"keeps the result close to your picture\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"for Fast on · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_TiledUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
},
"color": "#14424d",
"bgcolor": "#161f21"
+19 -32
View File
@@ -1,4 +1,4 @@
import { app } from "../../../scripts/app.js";
import { app } from "/scripts/app.js";
import {
CUSTOM_SCHEME,
DEFAULT_SCHEME,
@@ -23,7 +23,6 @@ import { openInfoCard } from "../shared/settings_menu.mjs";
const SETTING_ID = "AusBoss.Appearance.NodeColor";
const CUSTOM_SETTING_ID = "AusBoss.Appearance.CustomColor";
const COLOR_OVERRIDE = "ausbossColor";
// Stored bare (no "#") to match the color setting's storage contract.
const DEFAULT_CUSTOM_COLOR = "00b4aa";
@@ -41,7 +40,6 @@ let activeCustomColor = DEFAULT_CUSTOM_COLOR;
let activeCustomTitle = normalizeHexColor(DEFAULT_CUSTOM_COLOR);
function setActiveCustomColor(value) {
if (!normalizeHexColor(value)) return;
activeCustomColor = value;
activeCustomTitle = normalizeHexColor(value);
}
@@ -58,7 +56,6 @@ function applyScheme(node, colors) {
function repaintAll(next, previous) {
for (const node of collectGraphNodes(app.graph)) {
if (!isAusbossNode(node)) continue;
if (Object.hasOwn(node.properties ?? {}, COLOR_OVERRIDE)) continue;
if (!shouldRecolor(node, previous)) continue;
applyScheme(node, next);
}
@@ -152,8 +149,8 @@ app.registerExtension({
},
getNodeMenuItems(node) {
if (!isAusbossNode(node)) return [];
// Vue nodes skip the canvas badge callbacks; title-less nodes have no bar.
const about = isTitleless(node) || globalThis.LiteGraph?.vueNodesMode === true
// A title-less node has no bar for the "?" badge; its card opens here.
const about = isTitleless(node)
? [{ content: "About this node", callback: () => openHelpCard(node, null) }]
: [];
return [
@@ -165,14 +162,12 @@ app.registerExtension({
options: SCHEME_NAMES.map((name) => ({
content: name,
callback: () => {
// Keep an explicit choice, including no colors, through saves
// and setting changes. Custom stores the pair picked today.
// A menu pick writes the colors directly; shouldRecolor then
// treats them as the user's choice during setting sweeps.
const colors = schemeColors(name, activeCustomColor);
// Custom with an unusable stored color resolves to null, which
// would wrongly read as "Theme default" — do nothing instead.
if (name === CUSTOM_SCHEME && !colors) return;
node.properties ??= {};
node.properties[COLOR_OVERRIDE] = colors ? { ...colors } : null;
applyScheme(node, colors);
app.graph?.setDirtyCanvas?.(true, true);
},
@@ -184,28 +179,18 @@ app.registerExtension({
nodeCreated(node) {
if (!isAusbossNode(node)) return;
installHelpBadge(node);
restoreAppearance(node);
// Construction precedes saved fields; reload, paste and undo restore
// their colors and properties before this callback runs.
chainCallback(node, "onConfigure", function (saved) {
restoreAppearance(this, saved);
});
// Colors restored from a saved workflow (and manual picks) land before
// this hook runs — an already-colored node is left alone, unless it
// wears a retired flagship pair: those colors were ours, so the node
// upgrades to the active scheme instead of keeping them forever.
if ((node.color || node.bgcolor) && !wearsLegacyScheme(node)) return;
const colors = schemeColors(activeScheme, activeCustomColor);
if (colors) applyScheme(node, colors);
},
});
function restoreAppearance(node, saved) {
if (Object.hasOwn(node.properties ?? {}, COLOR_OVERRIDE)) {
// A later pick in ComfyUI's own color menu is saved in the native fields.
if (saved?.color || saved?.bgcolor) return;
applyScheme(node, node.properties[COLOR_OVERRIDE]);
return;
}
if ((node.color || node.bgcolor) && !wearsLegacyScheme(node)) return;
applyScheme(node, schemeColors(activeScheme, activeCustomColor));
}
function isTitleless(node) {
return (node.title_mode ?? node.constructor?.title_mode) === (globalThis.LiteGraph?.NO_TITLE ?? 1);
return node.constructor?.title_mode === (globalThis.LiteGraph?.NO_TITLE ?? 1);
}
function openHelpCard(node, event) {
@@ -219,13 +204,15 @@ function openHelpCard(node, event) {
});
}
// Classic nodes get a quiet "?" in the title bar. Vue nodes open the same
// card from their context menu. Its text comes from DESCRIPTION and tooltips.
// Every AusBoss node gets a quiet "?" in the title bar; clicking it opens a
// card built from the node's own DESCRIPTION and tooltips, so the docs on
// screen are exactly the docs in the source.
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 (isTitleless(this) || !showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return;
if (!showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return;
const [x, y] = badgeCenter(this.size[0]);
ctx.save();
ctx.beginPath();
@@ -241,7 +228,7 @@ function installHelpBadge(node) {
ctx.restore();
});
chainHandler(node, "onMouseDown", function (event, pos) {
if (isTitleless(this) || !showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return false;
if (!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
+2 -2
View File
@@ -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;
+27 -266
View File
@@ -6,25 +6,19 @@
// 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 { setTileFace, TILE_FACE_CSS } from "../shared/tile_face.mjs";
import { CALLOUT_TYPES, serializeNoteIdentity } from "../shared/note_identity.mjs";
import { app } from "/scripts/app.js";
import { BRAND, chainCallback, keepDomWidgetWidthAuto } from "../shared/index.mjs";
import {
ARROW_ANGLE,
ARROW_CHOICES,
CARD_TEMPLATE,
DOUBLE_ARROWS,
arrowText,
fitFontSize,
parseBlocks,
parseCard,
pastedLink,
repointLinks,
targetId,
toggleSmall,
tileLinkFields,
tileLinkText,
urlLabel,
wrapLink,
} from "../shared/callout.mjs";
@@ -33,9 +27,9 @@ 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];
@@ -43,14 +37,6 @@ 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" },
};
// A new Info card opens at this size, which shows its starter text whole.
const CARD_SIZE = [300, 230];
// The card's text grows with the node like a note's, but a card stays a card.
const CARD_FONT_MAX = 20;
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';
@@ -69,7 +55,6 @@ 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;}
@@ -78,29 +63,6 @@ ${TILE_FACE_CSS}
.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-card[data-card]{background:#111818;border:1px solid #2e3c3b;}
.ausboss-callout-card[data-card] .ausboss-callout-inner{padding:.8em 1em;color:#d9e4e3;font-weight:400;line-height:1.45;}
.ausboss-callout-card[data-card] b{color:#f2f7f6;font-weight:650;}
.ausboss-card-block{margin:0 0 .85em;}
.ausboss-card-block:last-child{margin-bottom:0;}
.ausboss-card-block>*+*{margin-top:.3em;}
.ausboss-card-title{display:flex;align-items:center;gap:.4em;color:#f2f7f6;font-size:1.08em;font-weight:650;}
.ausboss-card-icon{flex:none;width:1em;height:1em;color:#c9d6d4;}
.ausboss-card-icon svg{display:block;width:100%;height:100%;}
.ausboss-card-row{display:flex;justify-content:space-between;align-items:baseline;gap:1em;}
.ausboss-card-row>span:last-child{flex:none;text-align:right;white-space:nowrap;}
.ausboss-card-row.is-mark,.ausboss-card-row.is-mark b{color:#6fe0d6;font-weight:500;}
.ausboss-card-small,.ausboss-card-small b{color:#93a6a4;}
.ausboss-card-small{font-size:.92em;}
.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;}
@@ -113,7 +75,7 @@ ${TILE_FACE_CSS}
.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:is(:hover,:focus-within) .ausboss-callout-tools,.ausboss-callout.is-selected .ausboss-callout-tools{opacity:.95;}
.ausboss-callout-card:hover .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;}
@@ -128,17 +90,6 @@ ${TILE_FACE_CSS}
.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;}}
`;
@@ -174,25 +125,6 @@ function arrowIcon(kind) {
return root;
}
// The small line icons an Info card's title can start with.
const CARD_ICON_PATHS = {
clock: ["M12 7v5l3.2 2"],
info: ["M12 11v6", "M12 7.6v.01"],
warning: ["M12 3.5 2.8 19.5h18.4Z", "M12 10v4", "M12 16.9v.01"],
tip: ["M9.5 18h5", "M10.5 21h3", "M12 3a6 6 0 0 0-3.6 10.8c.7.6 1.1 1.3 1.1 2.2h5c0-.9.4-1.6 1.1-2.2A6 6 0 0 0 12 3Z"],
check: ["M8 12.4l2.8 2.8 5.2-5.6"],
};
const CARD_ICON_RING = new Set(["clock", "info", "check"]);
function cardIcon(kind) {
const span = el("span", "ausboss-card-icon");
const root = svg("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" });
if (CARD_ICON_RING.has(kind)) root.append(svg("circle", { cx: 12, cy: 12, r: 9 }));
for (const d of CARD_ICON_PATHS[kind] ?? []) root.append(svg("path", { d }));
span.append(root);
return span;
}
// The node a link names, looked up in the graph the note sits in.
function linkedNode(state, id) {
const graph = state.node.graph ?? app.graph;
@@ -245,18 +177,6 @@ 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";
@@ -301,79 +221,13 @@ function bringIntoView(target) {
}
}
// One line's pieces into an element. The pieces of one link sit in one
// element; a link that leaves ComfyUI ends with a small ↗.
function appendPieces(state, into, line) {
let run = null;
let wrap = null;
const close = () => {
if (wrap?.classList.contains("ausboss-callout-url")) wrap.append(el("span", "ausboss-callout-out", "↗"));
};
for (const piece of line) {
if (piece.run !== run) {
close();
run = piece.run;
wrap = run === undefined ? null : linkRun(state, piece);
if (wrap) into.append(wrap);
}
const target = wrap ?? into;
if (piece.arrow) target.append(arrowSpan(state, piece.arrow, piece));
else target.append(piece.bold ? el("b", "", piece.text) : document.createTextNode(piece.text));
}
close();
}
// An Info card: a title, rows of label and value, and small print.
function renderCard(state, blocks) {
for (const lines of blocks) {
const block = el("div", "ausboss-card-block");
for (const line of lines) {
let row;
if (line.kind === "title") {
row = el("div", "ausboss-card-title");
if (line.icon) row.append(cardIcon(line.icon));
const words = el("span");
appendPieces(state, words, line.pieces);
row.append(words);
} else if (line.kind === "row") {
row = el("div", `ausboss-card-row${line.highlight ? " is-mark" : ""}`);
for (const cell of line.cells) {
const side = el("span");
appendPieces(state, side, cell);
row.append(side);
}
} else {
row = el("div", "ausboss-card-text");
appendPieces(state, row, line.pieces);
}
if (line.small) row.classList.add("ausboss-card-small");
block.append(row);
}
state.inner.append(block);
}
}
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;
// An Info card has no title bar, like a tile, but keeps the full editor
// and saves as a plain Callout: an older pack shows its text as a note.
if (style === "card") state.card.dataset.card = "";
else delete state.card.dataset.card;
const isCard = style === "card";
setTileFace(state.node, state.root, Boolean(state.card.dataset.tile) || isCard, () => startEdit(state));
const blocks = isCard ? parseCard(state.valueWidget.value) : parseBlocks(state.valueWidget.value);
const blocks = parseBlocks(state.valueWidget.value);
state.inner.replaceChildren();
state.empty = blocks.length === 0;
if (state.empty) {
state.inner.append(el("div", "ausboss-callout-empty", "Double-click to write a note"));
}
if (isCard) {
renderCard(state, blocks);
fit(state);
return;
}
for (const lines of blocks) {
const point = el("div", "ausboss-callout-point");
lines.forEach((line, index) => {
@@ -381,7 +235,21 @@ function renderText(state) {
// lines are told apart by a break, as before; a block needs none.
const into = line.small ? el("div", "ausboss-callout-small") : point;
if (index && !line.small && !lines[index - 1].small) point.append(document.createElement("br"));
appendPieces(state, into, line);
let run = null;
let wrap = null;
for (const piece of line) {
// The pieces of one link sit in one element.
if (piece.run !== run) {
if (wrap?.classList.contains("ausboss-callout-url")) wrap.append(el("span", "ausboss-callout-out", "↗"));
run = piece.run;
wrap = run === undefined ? null : linkRun(state, piece);
if (wrap) into.append(wrap);
}
const target = wrap ?? into;
if (piece.arrow) target.append(arrowSpan(state, piece.arrow, piece));
else target.append(piece.bold ? el("b", "", piece.text) : document.createTextNode(piece.text));
}
if (wrap?.classList.contains("ausboss-callout-url")) wrap.append(el("span", "ausboss-callout-out", "↗"));
if (line.small) point.append(into);
});
state.inner.append(point);
@@ -451,9 +319,7 @@ function fit(state) {
inner.style.fontSize = `${px}px`;
return inner.offsetHeight <= height && inner.scrollWidth <= width + 1;
};
const size = fitFontSize(fits, undefined, card.dataset.card === undefined ? undefined : CARD_FONT_MAX);
const social = card.dataset.tile === "youtube" || card.dataset.tile === "discord";
inner.style.fontSize = `${social ? Math.min(size, 16) : size}px`;
inner.style.fontSize = `${fitFontSize(fits)}px`;
}
function markChanged() {
@@ -465,84 +331,12 @@ 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 ?? "";
area.placeholder = state.card.dataset.card === undefined
? "One idea per paragraph. **Bold** a word to stress it. The buttons below add an arrow, make a line small or make words a link."
: "The first line is the title. Write label | value for a row. **Bold** a whole row to make it teal. Small makes a line small print.";
area.placeholder = "One idea per paragraph. **Bold** a word to stress it. The buttons below add an arrow, make a line small or make words a link.";
area.spellcheck = true;
const bar = el("div", "ausboss-callout-arrowbar");
bar.append(el("span", "", "Arrow"));
@@ -705,17 +499,16 @@ 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: panelHeight,
getMinHeight: () => PANEL_MIN_HEIGHT + WIDGET_FRAME,
});
keepDomWidgetWidthAuto(domWidget);
domWidget.serialize = false;
fillNodeHeight(domWidget, {
minWidth: PANEL_MIN_WIDTH,
minHeight: panelHeight,
minHeight: () => PANEL_MIN_HEIGHT + WIDGET_FRAME,
minNodeSize: [PANEL_MIN_WIDTH, PANEL_MIN_HEIGHT + 40],
exactMinWidth: true,
});
@@ -723,9 +516,6 @@ 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));
@@ -745,7 +535,7 @@ app.registerExtension({
// 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;
if (node?.comfyClass !== NODE_CLASS && node?.type !== NODE_CLASS) continue;
const state = buildPanel(node);
if (!state) continue;
const text = repointLinks(state.valueWidget.value, nodes, groups);
@@ -756,29 +546,16 @@ app.registerExtension({
});
},
beforeRegisterNodeDef(nodeType, nodeData) {
if (!CALLOUT_TYPES.has(nodeData?.name)) return;
if (nodeData?.name !== NODE_CLASS) 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, "onSerialize", function (saved) {
serializeNoteIdentity(saved, ["youtube", "discord", "title"].includes(this.properties?.ausbossCalloutTile));
});
chainCallback(nodeType.prototype, "onConfigure", function (saved) {
chainCallback(nodeType.prototype, "onConfigure", function () {
// Saved values land after onNodeCreated; show them.
queueMicrotask(() => {
const state = buildPanel(this);
if (!state) return;
renderText(state);
if ((state.card.dataset.tile || state.card.dataset.card !== undefined) && Number.isFinite(saved?.size?.[1])) {
this.setSize?.([this.size[0], saved.size[1]]);
}
if (state) renderText(state);
});
});
chainCallback(nodeType.prototype, "onDblClick", function () {
@@ -788,28 +565,12 @@ 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"],["card","Info card"],["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);
// An empty card starts with text that shows how one is written.
const starter = style === "card" && !String(state.valueWidget.value ?? "").trim();
if (starter) commitWidgetValue(this, state.valueWidget, CARD_TEMPLATE, app.canvas);
renderText(state);
if (starter) this.setSize?.([Math.max(this.size[0], CARD_SIZE[0]), CARD_SIZE[1]]);
else if (style !== "plain" && style !== "card") 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;
});
+1 -1
View File
@@ -12,7 +12,7 @@
// 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 { app } from "/scripts/app.js";
import {
dragCarriesFiles, graphDragStarts, panelHost, panelKeepsWheel, panelNeedsDropHelp, panelRoot,
} from "../shared/canvas_passthrough.mjs";
+2 -2
View File
@@ -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";
-53
View File
@@ -1,53 +0,0 @@
// Clear VRAM 🆎 — a right-click entry on the empty canvas that unloads every
// model and frees memory on the ComfyUI server this page is connected to.
//
// It sends the same request as ComfyUI's own "Unload Models and Execution
// Cache" command (top menu, Edit): POST /free with unload_models and
// free_memory. That command is not on the right-click menu, so this entry
// puts it one click away. A setting hides the entry; a ComfyUI that turns
// manual unloading off (Comfy.Memory.AllowManualUnload) hides it too.
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { showToast } from "../shared/index.mjs";
const SETTING_ID = "AusBoss.Memory.ClearVramMenu";
const MENU_LABEL = "Clear VRAM 🆎";
function setting(id) {
return app.ui?.settings?.getSettingValue?.(id);
}
async function clearVram() {
try {
const response = await api.fetchApi("/free", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ unload_models: true, free_memory: true }),
});
if (!response.ok) throw new Error(`the server answered ${response.status}`);
showToast({ severity: "success", summary: MENU_LABEL, detail: "Models unloaded and memory freed.", life: 3000 });
} catch (error) {
showToast({ severity: "error", summary: MENU_LABEL, detail: `Could not clear VRAM: ${error?.message ?? error}` });
}
}
app.registerExtension({
name: "ausboss.clear.vram",
settings: [
{
id: SETTING_ID,
name: "Clear VRAM on the right-click menu",
type: "boolean",
defaultValue: true,
category: ["🆎 AusBoss", "Memory", "Clear VRAM menu"],
tooltip:
"Right-click the empty canvas for Clear VRAM: it unloads every model and frees "
+ "memory on the ComfyUI server. Off hides the entry.",
},
],
getCanvasMenuItems() {
if (setting(SETTING_ID) === false) return [];
if (setting("Comfy.Memory.AllowManualUnload") === false) return [];
return [{ content: MENU_LABEL, callback: clearVram }];
},
});
+3 -4
View File
@@ -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";
@@ -463,9 +463,8 @@ app.registerExtension({
]);
});
chainCallback(nodeType.prototype, "onConfigure", function () {
const state = this.__ausbossCompare;
queueMicrotask(() => {
if (!state || this.__ausbossCompare !== state || state.abort.signal.aborted) return;
const state = buildPanel(this);
state.showingB = this.properties?.ausboss_compare_side === "B";
syncErase(state);
// The saved paint, if the pictures are already up.
+2 -49
View File
@@ -7,48 +7,9 @@ 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
## Writing
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.
## Info card
A small card for facts people look up: run times, settings, what each choice
does. Right-click the Callout and choose **Info card**. An empty note starts
with sample text that shows each part.
Write it like this:
```text
⏱️ Too slow?
###### Redraw time on an RTX 5090. Smaller cards take longer.
2x, 18 steps | ~4 min
2x, 10 steps | ~3 min
1.5x, 18 steps | ~2.5 min
**1.5x, 10 steps (default) | ~2 min**
###### 1.5x is a 4608-wide sheet, 2x is 6144.
```
- The first line is the card's title. Start it with ⏱️ ℹ️ ⚠️ 💡 or ✅ and
that emoji is drawn as a small icon.
- A line with a `|` in it is a row: the words before the `|` on the left,
the rest on the right.
- Put the whole row in two stars, `**like this | value**`, and it is drawn
in teal. Use it for the default or the one to pick.
- Small print (the **Small** button) is a grey line, for a subtitle or a note
at the bottom.
- A blank line leaves a gap. Arrows and links work as in any callout.
- Double-click the card to edit it. Choose **Plain callout** to turn it back
into a note; the text stays.
- On a plain callout, double-click the note, or press **Edit**, to type. Click outside the box to
- 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.**`
@@ -121,13 +82,5 @@ In the text a link is written `[the words](https://example.com)`, or
it to read the address before you click.
- An older version of the pack shows a link as typed, brackets and address
included, so the address can still be read and copied there.
- An Info card is saved as an ordinary Callout with the text you typed.
The card look is a setting on the node, so an older version of the pack
shows the same text as a plain note, rows written out as `label | value`.
A plain callout never reads `|` as a row, so notes written before look
the same.
- A row splits at its first `|` that is not inside a link. A row pasted
from a Markdown table loses its outer pipes, and the table's `---` line
is skipped. An Info card's text grows with the card, up to 20 px.
- The font is IBM Plex Sans when the computer has it, otherwise the system
font. The node loads no fonts and makes no network requests.
-7
View File
@@ -1,7 +0,0 @@
# 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
View File
@@ -64,7 +64,6 @@ Pick **Erase** on the switch. A row of tools opens above the 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
+1 -1
View File
@@ -110,7 +110,7 @@ parts back.
## Technical details
- Starting values: Context `1.2`, Blend `16` px, Multiple `32`; Grow, Blur,
- Starting values: Context `1.2`, Blend `16` px, Multiple `8`; Grow, Blur,
Extra context, the Target rows and the Extend rows start at `0`.
- With no Target size set, the crop is still rounded up to **Multiple**, so
with **Stay in picture** on it can still reach a few pixels (at most
-142
View File
@@ -1,142 +0,0 @@
# Edit Lock
Keeps every part of your picture that an edit did not change exactly where
it was, with the colors it had.
An edit model redraws the whole picture, also the parts you did not ask it
to change. Those parts often come back a little zoomed in, shifted or
tinted. Flip between the original and the edit and the whole picture
jumps. Edit Lock stops that: only the edit is new.
It needs no mask. It sees what the edit changes while the picture is being
made, and holds on to everything else.
## Quick start
1. Put **Edit Lock 🆎** between the model and the sampler: the model (after
any LoRA loader) goes in, and its `model` output goes to the sampler or
guider.
2. Wire the VAE into `vae` and the picture you are editing into `image`.
Use the same picture the edit model gets, at the size of the result.
3. After the VAE Decode, add **Edit Lock Restore 🆎**. Wire the decoded
picture into `image` and Edit Lock's `edit_lock` output into
`edit_lock`. This puts your own pixels back in the untouched parts.
4. Wire Restore's `report` into **Show Text 🆎** to read how much was kept.
Run your edit as usual. Prompt, steps and seed stay as they were.
## What it does with each kind of edit
- **A local edit** (add glasses, repaint the car, change the sign): the
edit happens, and the rest of the picture stays put.
- **An edit of the whole picture** (a new style, new light, fog):
there is nothing to keep, so Edit Lock steps aside and the edit runs as
if the node were not there. The report says so.
- **A soft effect over the whole picture** (rain, falling snow, haze):
it touches everything a little, so there is no untouched part to put
back. Edit Lock lets the effect through and only holds the picture in
place, so it does not come back zoomed or shifted. The report says so.
- **A soft effect and a clear change in one edit** (rain and an umbrella):
the node may lock on the umbrella and hold the rain back everywhere
else. If the effect is missing outside the changed part, set **Lock** to
`off`.
- **Something in between** (a new hair color on a close-up, a new
background behind a person): it may lock or step aside. The report says
which. These edits often change the light on the face too. `auto` steps
aside for most of them, so the new light comes with the new hair.
`always` keeps the old light on everything it keeps, which can look
wrong on a face. A mask settles it: paint the hair and the face, and
the rest is locked.
- **The model ignores your picture and draws a new one:** that reads as a
whole-picture edit, so `auto` steps aside. `always` holds the model to
your picture.
## Controls
- **model**: the edit model. Patch last, after any LoRAs.
- **vae**: the same VAE the workflow decodes with.
- **image**: the picture you are editing, at the size of the result. If
the sizes differ by more than a few percent the lock does nothing, and
the report says so.
- **Lock** (`lock`): `auto` (the default) locks the untouched parts and
steps aside when the whole picture changes. `always` locks even then.
`off` does nothing, which makes a quick before and after.
- **Tolerance** (`tolerance`): how big a change must be to count as part of
the edit. 1 is the default. Raise it if small unwanted changes get
through. Lower it if the lock holds back part of your edit.
- **mask** (optional): white marks where the edit may happen; everything
else is locked. With a mask the node does not look for the change
itself. A mask from **Mask by Touch 🆎** or **Mask by Name 🆎** works.
## Outputs
- **model**: the model with the lock on.
- **edit_lock**: what the lock kept, for Edit Lock Restore 🆎.
## Good to know
- The lock belongs to one picture. For an edit with two pictures, give it
the one the result should line up with.
- It works on single pictures, not video.
- The node watches the first steps of a run and locks for the rest, so
the model finishes the edit on top of your real picture. Shadows and
reflections the edit adds next to the changed part still come through.
- If your edit is missing and the report says nearly all of the picture
was kept, set **Lock** to `off`.
- Tested on MiniMax-H3-Image. The lock itself is not tied to one model, but
the line between "local edit" and "whole picture" was measured there.
## Technical details
At every step an edit model predicts the finished picture. Edit Lock
compares that prediction with your picture in latent space.
**Watching.** The first prediction of a run is a blur, and rain, snow or
the light a new hair color throws on a face only show a few steps in. So
the node watches until the noise is down to 0.8 of where it began: step 4
of a 6-step MiniMax-H3-Image run. A run that never gets that low decides
at its last step. While it watches, the model draws its own picture and
only one thing is corrected. The model often draws the whole picture about
one percent larger, or a little shifted. The node measures that zoom and
shift at every step, on the calmer half of the picture, and moves the
prediction back so it sits where your picture does.
**Deciding.** Change is measured against the quiet part of the picture:
the tenth where the prediction differs least. An even tint over the whole
picture is taken out first, so a color cast is not read as a change. A
spot counts as changed when it differs by 3 times the quiet level, or by 2
times while it touches a spot that does. So a faint change beside a clear
one belongs to the edit, and a faint change alone is redraw noise.
**Tolerance** multiplies both numbers.
**Locking.** From the decision on, wherever the picture did not change,
the original latent is put back before the sampler takes its next step. A
part once read as changed stays changed for the rest of the run. The last
step ends on the original there, so what was kept is the original itself,
not a redrawn copy.
**Stepping aside.** `auto` does not lock in three cases:
- At the first step the whole picture is already changing (a new style,
new light). The node reads the calmest fifth of a smoothed copy, where a
slightly larger redraw nearly vanishes and a new style does not. On
MiniMax-H3-Image, local edits at 1, 4 and 16 megapixels read 0.22 or
less there and whole-picture edits 0.27 or more; the line sits at 0.243.
The model then runs untouched.
- At the decision no place has changed clearly: the strongest spot of the
picture is under 4 times the quiet level. That is light rain or fog.
Local edits read 4.7 or more there, glasses and makeup included.
- At the decision the lock would hand a tenth of the picture or more to
the edit, yet the strongest hundredth of the picture is under 4 times
the quiet level. That is heavier rain or snow, and a lock would let it
through in patches. Local edits that wide read 4.4 or more; rain and
snow read 3.6 or less.
In the last two cases the node keeps undoing the zoom and shift to the end
of the run, and puts nothing back. An edit close to one of these lines (a
hair color change on a close-up, a new background, smoke) can fall on
either side. The report says which, and **Lock** overrides it.
This is the target the TailSteer paper trains a model toward (the source
where nothing changed, the model's own edit elsewhere), applied while
sampling. It needs no LoRA and no training.
-48
View File
@@ -1,48 +0,0 @@
# Edit Lock Restore
Puts your picture's own pixels back in every part the edit did not change.
**Edit Lock 🆎** keeps the untouched parts in place while the picture is
made. They still pass through the VAE, which softens fine detail a little.
This node swaps your original pixels in for those parts, so they match
your picture pixel for pixel. Only the edit is new.
## Quick start
1. Add it right after the VAE Decode, before any upscaler.
2. Wire the decoded picture into `image`.
3. Wire the `edit_lock` output of Edit Lock 🆎 into `edit_lock`.
4. Wire `report` into **Show Text 🆎** to read what was kept.
## Controls
- **image**: the edited picture, straight from the VAE Decode.
- **edit_lock**: from Edit Lock 🆎. It carries your original picture and
the map of what the edit changed, so there is nothing else to wire.
- **Feather** (`feather`): how softly your original fades into the edit,
in pixels. 16 is the default. The fade lies outside the edit, so the
edit itself is never mixed with the old picture.
## Outputs
- **image**: the edit, with your original pixels in the untouched parts.
- **edit_mask**: white where the edit changed the picture. Use it to see
what the lock decided, or for a later step that should only touch the
edit.
- **report**: one line, such as "Edit Lock kept 82% of your picture as it
was and left 18% to the edit".
## When nothing was locked
The picture passes through unchanged, the mask is all white, and the
report says why: the edit changed the whole picture, the lock was off, or
the picture and the result were different sizes.
## Technical details
The map of what changed is made while sampling, on the latent grid. Here
it is enlarged to the picture's size, widened by the feather and blurred
by the same amount, and used to blend the decoded picture with your
original. If the decoded picture is not the size of your original (after
an upscale), the original is resized to fit first, which costs it some
sharpness. That is why this node goes before an upscaler.
-69
View File
@@ -1,69 +0,0 @@
# FlashVSR Upscale
Makes a video larger and sharper. Give it the frames of a clip, set how much
larger, and it returns the same clip with more detail. Use it as the last
step before Save Video, for a generated clip or one you loaded.
It needs one model file and no other pack.
## The model file
Download `flashvsr_v1.1_fp16.safetensors` (3.5 GB) from
[ausboss/FlashVSR-v1.1-ComfyUI](https://huggingface.co/ausboss/FlashVSR-v1.1-ComfyUI)
and put it in `ComfyUI/models/diffusion_models`. Then refresh the page, or
press `R`, and pick it under **Model**.
The file holds the official FlashVSR v1.1 weights in one piece. Another
FlashVSR file will not load here: the node stops and says which part is
missing.
## Controls
- **frames**: The clip, as a batch of frames. Load Video's frames output
goes here.
- **Model** (`model`): The model file above. The list shows the files in
`diffusion_models` that have FlashVSR in their name.
- **Scale** (`scale`, starts at `2`): How many times larger each side gets,
from 1 to 4. At `1` the clip keeps its size and is only sharpened.
- **Keep colors** (`color_match`, starts on): Brings each frame's colours
back to those of your clip. Leave it on unless you want the model's own
colours.
- **vae** (optional): Connect the Wan 2.1 VAE for a slower, finer finish.
With nothing connected, the fast decoder inside the model file makes the
picture.
## Outputs
- **frames**: The larger frames, as many as went in, in the same order.
The frame rate and the sound are not touched, so wire those from Load
Video to Save Video as before.
## What to expect
FlashVSR redraws fine detail: skin, hair, cloth and edges come back
sharper than the clip had them. It keeps the scene, the motion and the
timing.
A long clip needs no more graphics memory than a short one. The node reads
the clip a few frames at a time and keeps only the last few in memory.
## Technical details
- **Size.** The result is exactly your size times **Scale**, rounded to
even numbers. Inside, the model works at a multiple of 128 pixels each
way; the node pads the picture for that and cuts the padding off again,
so nothing of your picture is cropped.
- **Graphics memory.** About 10 GB for a 576 x 1024 result. A result too
large for the free memory is made in overlapping tiles and blended, and
the tile size is chosen from the memory that is free when the node runs.
1152 x 2048 ran in two tiles at about 16 GB. On an 8 GB card (RTX 3070
Laptop) the same result ran in smaller tiles at about 6.5 GB, at about 3
seconds a frame.
- **Speed.** On an RTX 5090: about 7 frames a second for a 576 x 1024
result and about 2 frames a second for 1152 x 2048.
- **One step.** The model runs once per group of frames. There are no
steps, sampler, prompt or seed to set.
- **The model.** FlashVSR v1.1 by Junhao Zhuang and the OpenImagingLab
team, released under Apache-2.0. It is built on the Wan 2.1 1.3B video
model. This node is the pack's own code in plain PyTorch.
- The node makes no network requests and writes no files.
+4 -41
View File
@@ -89,42 +89,14 @@ Connect a video, then pick what it is for:
`@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.
place, the moves and the camera stay. You can leave the box empty.
- **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.
Swap and new voice ignore **Length** and keep the video's own length.
## What you can type
@@ -153,10 +125,8 @@ 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.
- **video** and **video_sound**: the frames and the sound of a video. Only keep
going uses the sound.
- **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
@@ -180,12 +150,6 @@ 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.
@@ -244,7 +208,6 @@ as not plugged in.
| `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
+32 -32
View File
@@ -10,7 +10,7 @@ original back over the result.
## Quick start
1. Choose a file above the preview, or click **Load**. Dropping a
1. Pick a picture under **Image source**, or click **Upload**. 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,25 +28,22 @@ uses what the node shows.
From top to bottom:
- **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
- **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. 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.
- **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.
- **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.
- **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, Align, Resize and a lit
(rotation, crop and padding); Fill, Feather, Divisible by, Resize and a lit
ratio stay.
### Ratios, Fit and the padlock
@@ -54,7 +51,8 @@ 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.
You can set **Fit to ratio** before picking a ratio. If a ratio is already lit, switching between Crop and Pad applies it immediately.
**Fit** only acts on a lit ratio, so it is dimmed and says "pick a ratio first"
until you tap one.
- **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
@@ -87,11 +85,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.
**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 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.
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
@@ -114,9 +112,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.
### Align
### Divisible by
**Align** (`canvas_multiple`) makes the width and height divide evenly by
**Divisible by** (`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.
@@ -132,23 +130,24 @@ 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 **Align** at work. An
that changes nothing is left out, and "round to" is **Divisible by** at work. An
amber line warns when rounding each side to the Step stretches the picture by
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
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
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. The space for the warning stays under the dimensions. On a narrow node, hover
over shortened text to read it in full.
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.
### 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 **Align** stay.
Fill, Feather and **Divisible by** stay.
## Drawing a mask
@@ -168,7 +167,8 @@ 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 next to **Load** and turn off **Show the mask on the picture**. The choice applies
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
to every Image Crop + Rotate + Pad in this browser.
## Outputs
@@ -232,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, Align and a **Center** row.
Fill, Feather, Divisible by 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 Align; the picture,
clears rotation, crop, padding, fill, feather and Divisible by; 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. **Align** and the resize **Step** can slightly change
resize settings. **Divisible by** and the resize **Step** can slightly change
the fitted shape. The size box on the stage sits clear of the handles.
### Handles and gestures
@@ -280,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 **Align** rounds it. With
Align on, **Center** counts the fill it adds, so the bands you see
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
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
@@ -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. **Align** on the Crop + Rotate + Pad nodes and
longer spans it. **Divisible by** 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.
+4 -2
View File
@@ -44,8 +44,10 @@ Float 🆎, the wave shows the cut for that number; otherwise the length's toolt
## 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.
- Only the picked part is returned. Decoding starts at the beginning,
discards sound before IN and stops at OUT or the Max length cut. A late
selection in a long recording takes longer to load.
- Preview playback pauses when you switch tabs or hide the page.
- 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
-108
View File
@@ -1,108 +0,0 @@
# Load Image
Loads a picture and sets its size in the same node, so a workflow does not
need a resize node and a size node after its loader.
Pick a file in **Source**, press **Load** to upload one, or use the arrows
under it to step through the pictures in ComfyUI's input folder. The line
beside the arrows shows the size of the file and, when the size changes, the
size that comes out:
`912 × 1632 → 1144 × 2048`
That is the real output size, worked out before you run.
Under that line are two small pictures: the file as it goes in, and the
picture as it comes out. On the first one the part that is kept is lit and
the part that is cut off is dark. Between them the node says the shape
(`3:4 → 1:1`) and what happens (`77% size`, `cuts 449 px of height`).
## Size
**Size** (`size`) picks how the picture is resized. Each choice shows only
the numbers it uses.
| Choice | What it does | Its number |
|---|---|---|
| **off** | The picture comes out exactly as the file has it. | none |
| **megapixels** | Brings the whole picture to a total size and keeps its shape. | **Megapixels** (`megapixels`): 1.0 is about 1024 × 1024 |
| **longest side** | Sets the longer edge; the other edge follows. | **Longest side** (`longest_side`), in pixels |
| **scale** | Multiplies both edges. | **Scale** (`scale`): 0.5 is half, 2 is double |
| **fit inside** | Makes the picture as big as it can be inside a box, with nothing cut off. | **Width** and **Height** (`width`, `height`) |
| **crop to fill** | Covers the box completely and cuts off what sticks out. | **Width** and **Height** |
| **crop** | Cuts a piece of that size out of the picture. Nothing is resized. | **Width** and **Height** |
| **crop to ratio** | Cuts the largest piece of a shape, such as 16:9, out of the picture. Nothing is resized. | **Ratio** (`ratio`) |
**Round to** (`multiple`) rounds the new width and height to a multiple of
that number. Most models want 8 or 16. Set it to 1 to keep the numbers as
they are. It does nothing while Size is off.
## Which part is kept
The three crop choices show **Keep** (`crop_from`). It picks the part of the
picture that stays: `center` cuts evenly from both sides, `top` keeps the top
and cuts from the bottom, `bottom left` keeps that corner, and so on.
You can also click the first small picture: click its top to keep the top,
a corner to keep that corner. The lit part moves so you see what you get.
In **Ratio**, `width : height` takes the shape from the Width and Height
boxes, for a shape that is not in the list (1600 and 900 give 16:9).
## More
- **Method** (`method`): how pixels are resampled. `lanczos` is the sharpest
for photos. `nearest` keeps pixel art crisp.
- **Enlarge** (`enlarge`): on, a small picture is made bigger to reach the
size you asked for. Off, a picture is only ever made smaller: one that is
already below the target comes out at its own size, and crop to fill cuts
the box's shape out of it without growing it.
## The picture on the node
The picture under the settings is ComfyUI's own preview, the same one the
stock Load Image shows. Drop a file on the node or paste one to upload it,
and right-click the node for the mask editor and for copy and paste.
## Outputs
- `image`: the picture at its new size.
- `mask`: white where the picture is see-through and black where it is
solid, as the stock loader gives it. A picture with no transparency gives
an all black mask. It is always the same size as `image`.
- `width`, `height`: the size that comes out.
- `filename`: the file's name without its folder or its ending
(`shots/hero.png` gives `hero`), ready for a save node's prefix.
- `original_width`, `original_height`: the size of the file before any
resize. Use them to bring a result back to the size you started from.
## Which loader to use
- **Load Image** (this node): load a picture and set its size.
- **Load Image + Pad**: add empty space around a picture for outpainting.
- **Image Crop + Rotate + Pad**: turn, crop and pad a picture by hand.
## Technical details
- Every frame of an animated file comes through as a batch, all at the same
new size. A phone photo is turned the way its EXIF data says.
- With Size off the pixels are the file's own, bit for bit, and Round to is
not applied.
- Rounding moves each edge to the nearest multiple, so the shape can change
by up to half a step. Fit inside rounds down instead, so the result never
leaves its box.
- Crop to fill resizes the picture until it covers the box, then cuts the
box out at the side Keep names. The mask goes through the same resize and
the same cut.
- Crop and crop to ratio only cut. A box larger than the picture is cut down
to the picture, and Round to rounds down so the piece stays inside it.
Enlarge does not apply to them.
- The small pictures are drawn on one scale, so a picture made half as large
is drawn half as large.
- With Enlarge off, a rounded size is rounded down so it never passes the
file's own size.
- The size line on the node uses the same arithmetic as the run. The pack's
tests hold the two to the same answers over a table of cases.
- Files are read only from ComfyUI's input, output and temp folders. A name
such as `photo.png [output]` loads a picture ComfyUI made.
- The node runs again when the file changes on disk or a setting changes.
+5 -2
View File
@@ -35,6 +35,7 @@ all. **Remove this LoRA** is red. **Delete all LoRAs…** is a separate red, tra
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
@@ -188,7 +189,9 @@ 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. Typing a filename and pressing Enter picks one file; use the arrow
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
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.
@@ -253,7 +256,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 leave the sources active
- linked LoRA settings and stacks beyond the row limit leave the sources active
- already-bypassed or muted loaders contribute no rows
- a toast summarizes what was imported, bypassed, remapped, and skipped
+11 -50
View File
@@ -19,8 +19,8 @@ What is lit in teal is what the mask will be. When it is right, press Run
for the whole workflow.
The first time the pointer rests on a picture, the node looks at it. A
button under the picture shows how far along it is, **❚❚ Pause · looking
40/104**, filling up as it goes. The first things light up after a few seconds, and
small button under the picture shows how far along it is, **❚❚ 40/104**,
filling up as it goes. The first things light up after a few seconds, and
it has seen the whole picture after about 20.
You do not have to wait for that. Whatever is lit under the pointer can be
@@ -28,7 +28,7 @@ picked: a click takes it at once, also while the node is still looking.
Click that button to pause the looking when what you need is already lit:
what is lit stays, and you go on picking and painting. It turns into
**▶ Look on**; click it to look on from where it stopped. A click on a spot where
**▶**; click it to look on from where it stopped. A click on a spot where
nothing is lit yet looks on as well.
A click on a spot where nothing is lit yet is not lost: a dot marks it, the
@@ -50,9 +50,6 @@ at that spot.
A stroke across two things, like both shoes, takes both.
- Did the thing not light up at all? Type its name into the field under the
picture, like "screen" or "earring", and press Enter.
- Is something in the mask that should not be, like the face or the hair of
a person you picked? Click the **+** in front of that field so it shows
**−**, type "face" and press Enter. Or hold Ctrl and click the face.
- When nothing fits, switch to **Paint** and paint the area yourself.
## The whole subject, or the background
@@ -62,10 +59,6 @@ click: the person, the animal, the product. **Background** picks everything
else. They are picks like any other: click more things to add them, or
Ctrl-click a thing to take it away again.
Ctrl-click takes away the thing under the pointer, where it is in the mask.
On a spot where nothing is picked it changes nothing, and the picture says
so.
Both need ComfyUI's background-removal model, see "The model for Fine
edges" below.
@@ -122,16 +115,11 @@ or an area you painted.
it.
- **all 3** on a chip takes every thing of that name instead of the one you
clicked: all five birds, both shoes.
- The field at the end picks every thing of the name you type. The sign in
front of it says what happens to the name: **+** adds it, **−** takes it
away. Click the sign to change it. A name typed with a minus in front,
like "-face", is taken away whatever the sign shows. With Erase chosen the
sign starts on **−**.
- The field at the end, **+ type a name**, picks every thing of the name
you type. With Erase chosen it takes them away.
## The tools on the node
- **Reload** (the round arrow) shows the picture coming in now: see
**Which picture the picks belong to**, below.
- **Undo** takes back the last change, or a Clear. Ctrl+Z works as well: it
is ComfyUI's own undo, and every change counts.
- **Clear** picks nothing.
@@ -164,15 +152,11 @@ or an area you painted.
- **More → Smooth** (`smooth`): rounds off a ragged outline by this many
pixels.
- **More → Fill small holes** (`fill_holes`): closes pinholes inside the
picked area. Larger openings stay open, like a mug's handle. Off by
default, so a hole you pick stays a hole.
picked area. Larger openings stay open, like a mug's handle.
- **More → Invert** (`invert`): turns the mask round. On, the mask is
everything except what you picked.
- **More → If nothing** (`if_nothing`): **stop the run** or **empty mask**,
see below.
- **More → New picture** (`new_picture`): **keep picks** or **start over**,
for when a different picture comes in. See "Which picture the picks belong
to" below.
- **More → Free VRAM** (`free_vram`): takes SAM 3 out of the graphics card's
memory right after the search, so the next model has the room. Turn it on
in a workflow that loads a big model after this node.
@@ -222,21 +206,10 @@ nothing instead.
## Which picture the picks belong to
Picks belong to the picture they were made on. When a different picture
comes in, **New picture** (under More) decides what happens to them:
- **Keep picks** (the default): your clicks and paint move onto the new
picture, in the same places. On a picture of another size they are
scaled to fit it. A click finds whatever is at that spot now; paint stays
exactly as painted. Press **Clear** to start over.
- **Start over**: the node shows the new picture with nothing picked. If the
workflow runs before you pick again, the run stops and says the picks
were made on another picture. The new picture shows on the node then,
ready to pick from.
- **The reload button** (the round arrow beside Undo) shows the picture
coming in now. Use it after you change the picture before this node, for
example with an edit. It runs only this node and what comes before it.
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.
@@ -283,15 +256,6 @@ then.
### How SAM 3 is asked
- A picture whose long side is over 2048 pixels is asked about on a copy
with its long side at 2048. SAM 3 reads every picture at 1008 pixels
anyway, and returns each answer at the size it was given, on the graphics
card: at 8192 pixels one word with many matches needed over 27 GB. The
answer is brought back to the picture's own size, so the mask, the cut-out
and your touches stay in pixels of the picture. Paint is never scaled.
Measured on a 3840 pixel picture, five words: the new masks share 95 to
99.6 % of their area with the old ones, the edge sits a median 0 to 2
pixels differently, and a look takes 31 seconds in place of 53.
- The search is ComfyUI's own SAM 3: SAM3 Detect, asked about points, or
about a word that CLIP Text Encode has read. Both use ComfyUI's own
settings (threshold 0.5, two edge passes). The node needs ComfyUI 0.20 or
@@ -352,10 +316,7 @@ no browser:
used.
- **`paint`**: exactly the stroke's own area. SAM 3 is not asked.
- **`key`** names the picture the picks were made on: its size and a
fingerprint of every pixel. Picks made on another picture are moved onto
the one that came in, scaled from the size in their key to its size, and
the face is sent the moved touches. With `new_picture` set to `start
over` they are ignored instead.
fingerprint of every pixel. Picks made on another picture are ignored.
Leave `key` out when you write `touches` by hand.
### How an area is tidied
-54
View File
@@ -1,54 +0,0 @@
# Prompt Variations
A prompt box that changes on every run. Write choices in curly brackets and
the node picks one from each set:
```text
A woman in a {plain|striped|patterned} {tank top|sweater|button-up shirt}
and {jeans|a pleated skirt|cargo shorts}, {standing|sitting on a stool|leaning on the wall}.
```
One run might give "A woman in a striped sweater and jeans, sitting on a
stool." The next gives another mix.
Use it to make many different pictures in one go. Wire in the seed, set the
Seed node to **randomize**, and set the number of runs next to the Run
button to 50. Each run gets a new seed, so each picture gets new choices.
## Controls
- The text box (`text`): your prompt. Put each set of choices in curly
brackets and split the choices with `|`. Write as many sets as you like,
each with as many choices as you like.
- `seed` (a socket): the seed your sampler uses. A new seed picks new
choices. The same seed picks the same ones again, so a picture you like
can be made again from its seed.
## Output
- **prompt**: your text with one choice picked from each set. Wire it to
Show Text to see what was picked.
## Tips
- An empty choice lets a part come and go: `a {|striped} shirt` gives "a
shirt" or "a striped shirt".
- Sets can go inside sets: `{a {red|blue} hat|a scarf}`.
- Text without curly brackets comes out exactly as typed.
- To make one choice come up more often, write it more than once:
`{jeans|jeans|skirt}`.
## Technical details
Only brackets that hold a `|` are choices. Brackets without one are left as
they are, so a JSON prompt such as `{"light": "soft"}` passes through
unchanged. A `|` inside JSON brackets still counts as a choice.
The innermost sets are picked first, from left to right, with Python's
`random.Random(seed)`. The same text and seed always give the same prompt,
on any machine. Spaces around each choice are trimmed, and an empty pick
also drops the one space in front of it.
The picks happen when the prompt runs, on the server, not in the browser.
ComfyUI's own `{a|b}` choices in some text boxes are picked by the browser
when you queue, with no seed; this node does not use them.
+4 -10
View File
@@ -31,8 +31,8 @@ names from here on; how it measures may still improve.
moved a boat keeps the boat where it drew it, so the result lines up
closely, not pixel for pixel.
- **Leaves alone:** a picture it can't measure, or a zoom bigger than
**Max zoom** after allowing for padded reframing. That picture only
comes out resized, and the report says why.
**Max zoom**. That picture only comes out resized, and the report says
why.
## Controls
@@ -50,10 +50,8 @@ names from here on; how it measures may still improve.
with nothing in it. `edge` (the default) stretches the nearest pixels
into it, `source` fills it from the original, `gray` fills it flat gray
for inpainting. With a big enough margin there is no strip.
- **Max zoom**: the zoom limit in percent (20 by default). For a padded
picture, zooming in to fill the padding gets an extra allowance; the
setting limits zoom beyond that. Zooming out still uses the stated limit.
Anything beyond those limits is treated as a change you meant and left alone.
- **Max zoom**: the biggest zoom it will undo, in percent (20 by default).
Anything bigger is treated as a change you meant and left alone.
## Outputs
@@ -123,10 +121,6 @@ new cloud, are left out. It trusts the fit when at least 10 blocks, and at
least a quarter of them, agree. With a stitcher, the blocks tile the
picture's area only.
For padded reframing, the extra zoom-in allowance on each axis is the
canvas size divided by the source picture's size, minus one. It is added
to **Max zoom**; zooming out gets no extra allowance.
It runs on the CPU in a fraction of a second per 1 MP frame. No new
dependencies, and no model.
-8
View File
@@ -8,14 +8,6 @@ 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.
+5 -19
View File
@@ -41,32 +41,18 @@ 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`): **On** stores the prompt and
- **Embed workflow** (`save_metadata`): **yes** stores the prompt and
workflow in the file (PNG text chunks, EXIF in webp and jxl) so it drags
back into ComfyUI; **Off** writes a clean file for sharing or datasets.
back into ComfyUI; **no** 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
shorter; on again, the node returns to its height with the last saved
image. It is display only: the file is saved either way and the queue
still lists it. When a run saves more than one image (a batch, or a list
such as a folder of images), they all show side by side. The arrows
beside the switch (‹ all 5 ›) go on to each one by itself (‹ 2 / 5 ›) and
back round to all of them. A run of more than 48 shows one at a time.
The node shows a small copy of each picture (up to 2048 pixels on the
long side), so a big file shows at once on a slow connection too. The
saved file is always full size. The copies are in ComfyUI's temp folder.
- **Auto save** (`autosave`, on by default): on, every run saves its
pictures. Off, a run only shows them here and saves nothing. A row over
the picture then says **Not saved yet**, with a **Save picture** button.
With several pictures it reads **Save all 4**, and with the arrows on one
picture, **Save this one**. After a save the row says what is saved.
Use it to make a batch, look, and keep only the good ones. The pictures
are saved under the name and folder set above, in the format picked,
with the workflow inside if **Embed workflow** is on. They wait in
ComfyUI's temp folder, which is emptied when ComfyUI restarts, so save
what you want before that.
- **Preview steps** (`step_preview`, off by default): the switch left of
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;
+4 -4
View File
@@ -9,9 +9,9 @@ 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.
**Preview steps** above the player (`step_preview`, off by default) shows the clip's first frame in
**STEPS** (`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
button beside it. 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.
@@ -37,7 +37,7 @@ page has the details.
- **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.
- **Embed workflow** (`save_metadata`): On by default. Turn it off to share a file
- **Metadata** (`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
@@ -83,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 **Embed workflow** row disappears for `gif` and `webp`,
*ignored* above), and the **Metadata** 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
-9
View File
@@ -27,15 +27,6 @@ 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.
+3 -5
View File
@@ -67,11 +67,9 @@ are in the model's own picture.
Tone match 0 the new area is bit-identical to what the model painted, and
the whole stitch is the 2.4.0 blend in, pixel for pixel.
- With the classic seam, feeding the crop back unchanged reproduces the
original image exactly only when there is no resizing in either direction,
with Tone match at 0 and `fix_edge_halo` off. Resizing can lose detail even
when the crop comes back unchanged; the halo fix rewrites the feathered
band on purpose. Blend in does not promise this: it keeps the model's
version of the thin strip at your picture's edge.
original image exactly (with `fix_edge_halo` off; the fix rewrites the
feathered band on purpose). Blend in does not promise this: it keeps the
model's version of the thin strip at your picture's edge.
- A stitcher built from one image broadcasts over any number of inpainted
frames. A stitcher built from **more** frames than came back is trimmed to
the leading ones with a console note - video models keep 8n+1 (LTX) or
-60
View File
@@ -1,60 +0,0 @@
# Text Lines
Runs what comes after once for each line of your text. Type five prompts,
one on each line, press Run once, and you get five pictures.
Use it for a list of prompts you typed, or for a list another node wrote,
such as a text model asked for "five different poses".
## How to use it
1. Type your lines in the box, or wire text into **text**.
2. Wire **line** to where one prompt would go, such as a text encoder's
prompt.
3. Run. Every node after it runs once for each line, and a Save Image at the
end saves one picture for each.
Empty lines are skipped. Models and pictures that do not depend on the line
are loaded once, not once for each line.
## Controls
- The text box (`text`): One line for each run.
- **Split at** (`split`): **each line** makes every line its own run.
**blank line** keeps lines together and starts the next run at an empty
line. Pick it when one prompt is several lines long.
- **Numbering** (`remove_numbering`): **remove** takes a number or bullet
off the start of each line, such as `1.`, `2)`, `-` or `[prompt 3]`.
Lists a text model writes usually have them. **keep** leaves every line
as written.
- **Limit** (`limit`): The most lines to run. 0 runs them all. Set it when
a text model might write more lines than you asked for.
## Outputs
- **line**: Each line, one at a time.
- **number**: The line's place in the list, starting at 1. Add it to a seed
with Math Expression (`a + b`) to give each line its own seed.
- **count**: How many lines run.
## Notes
- With no lines at all, the run stops at this node and says so.
- Every run uses the same seed unless you add **number** to it. Different
prompts still give different pictures.
- A line such as "2 women on a beach" or "1.5x zoom" keeps its number: only
a number followed by a period, bracket or colon counts as numbering.
## Technical details
**line** and **number** are ComfyUI lists (`OUTPUT_IS_LIST`), the same way
Image Folder hands out pictures. ComfyUI runs each later node once for each
item and keeps the results in order. A node that takes a whole list at once,
such as core Rebatch Images, gets all of them together.
Numbering that is removed: a number in square, round or CJK brackets with an
optional word before it (`[prompt 1]`, `(2)`), a word and a number followed by
a colon, period, bracket or dash (`Shot 3:`), a bare number followed by a
period, bracket or colon that has no digit right after it, and the bullets
`-`, `*` and `•` followed by a space. Only one marker is removed from each
line.
+9 -10
View File
@@ -11,22 +11,21 @@ generated-area `mask`, a `stitcher` for Stitch Inpaint, the selected
## Source and frame
- **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
- **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
drop a video file onto the node. Picking another video keeps Fill, Feather,
**Align**, the Resize settings and the lit ratio button (the new frame is
**Divisible by**, 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.
- **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.
- **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.
- **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.
- **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
- **Playhead**: The rail 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.
@@ -34,19 +33,19 @@ The editor has the same rail full width, plus first/last, ±1, ±25, ±50, ±100
## Transform and outputs
Rotation, crop, padding, feathering, fill, **Align**, the megapixel resize, gestures, `image`, and `mask` match the image node. The output batch contains one frame.
Rotation, crop, padding, feathering, fill, **Divisible by**, 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
center the crop and balance any padding, side to side or top to bottom. Both work as on the
put the frame in the middle, 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,
**Align** and the playhead stay. **Align** makes the width and
**Divisible by** and the playhead stay. **Divisible by** 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.
+27 -29
View File
@@ -11,20 +11,33 @@ model that paints black regions.
## Source and window
- **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
- **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
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, **Align**, Resize, Every nth, Frames for, Length - and
settings - Fill, Feather, **Divisible by**, 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.
- **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
- **Timeline**: 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
@@ -40,21 +53,6 @@ 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
@@ -82,8 +80,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, 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
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
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
@@ -94,31 +92,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
ready to set before picking a ratio. The orientation button turns the shape on its side
dimmed until a ratio is lit. 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, **Align** and the timeline stay.
fill, feather, **Divisible by** and the timeline stay.
**Reset transform** in the editor resets rotation, crop, padding, fill, feather and
**Align**; it keeps the source, current frame, trim, Length, resize and stitch
settings. **Align** makes the width and height divide evenly by the number
**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
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.
The **Load** button and file drop use a streaming route into ComfyUI's input folder,
Video Upload 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. **Align** and
Pixel rounding can differ by one pixel between opposite bands. **Divisible by** and
the resize **Step** can slightly change the final aspect ratio.
## Transform and outputs
Rotation, crop, padding, feathering, fill, **Align**, and the editor's handles
Rotation, crop, padding, feathering, fill, **Divisible by**, 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
@@ -163,7 +161,7 @@ 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. When the count is known, dragging IN stops at the last start that fits the kept frames. |
| `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. |
| `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`.
-23
View File
@@ -18,37 +18,14 @@ 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
@@ -1,7 +0,0 @@
# 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.
+4 -5
View File
@@ -14,8 +14,8 @@ an upscale at the end.
2. Press **+ Switch from 2 selected** on the card.
3. Type a name and press Enter.
Click **off** to skip every node you picked, or **on** to run them again.
Clicking the selected half leaves it selected.
One click on its pill now turns every node you picked off, and the next click
turns them on again.
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
@@ -160,9 +160,8 @@ the models only that part needs.
## The edit view
- **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.
- **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.
- **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
-21
View File
@@ -17,7 +17,6 @@ help panel.
| 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`) |
| Load a picture and set its size or crop it (megapixels, longest side, a box, a ratio) in one node | Load Image (`AUSBOSS_NODES_LoadImage`) |
| 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`) |
@@ -25,7 +24,6 @@ help panel.
| 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`) |
| Keep the untouched parts of a picture exactly as they were while an edit model edits it | Edit Lock (`AUSBOSS_NODES_EditLock`) then Edit Lock Restore (`AUSBOSS_NODES_EditLockRestore`) |
| 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`) |
@@ -33,24 +31,19 @@ help panel.
| 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`) |
| Make a clip larger and sharper | FlashVSR Upscale (`AUSBOSS_NODES_FlashVSRUpscale`) |
| Work with frame batches | Select Frame, Select Every Nth, Split Batch, Merge Batches (`AUSBOSS_NODES_SelectFrame`, `_SelectEveryNth`, `_SplitBatch`, `_MergeBatches`) |
| Run a workflow once for each line of text, such as a list of prompts | Text Lines (`AUSBOSS_NODES_TextLines`) |
| One shared seed, number or text | Seed, Integer, Float, Text (`AUSBOSS_NODES_Seed`, `_Integer`, `_Float`, `_Text`) |
| A prompt that picks from `{a\|b\|c}` choices on each run, by seed | Prompt Variations (`AUSBOSS_NODES_PromptVariations`) |
| 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
@@ -70,20 +63,6 @@ the `AUSBOSS_NODES_...` key above.
- **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
-51
View File
@@ -1,51 +0,0 @@
{
"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 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -33,7 +33,7 @@ function mountTransformPanel(node, panel) {
fillNodeHeight(widget, {
minWidth: PANEL_MIN_WIDTH,
minHeight: () => transformPanelFloor(node),
minNodeSize: [PANEL_MIN_WIDTH, 396],
minNodeSize: [PANEL_MIN_WIDTH, 470],
exactMinWidth: true,
});
return widget;
+15 -59
View File
@@ -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";
@@ -26,7 +26,6 @@ import {
childOf,
countLine,
foldersAddress,
folderContext,
isPicked,
listAddress,
nameAt,
@@ -275,7 +274,6 @@ async function refresh(state) {
}
function scheduleRefresh(state, delay = 160) {
++state.token;
clearTimeout(state.timer);
state.timer = setTimeout(() => refresh(state), delay);
}
@@ -344,7 +342,6 @@ async function upload(file, subfolder, overwrite) {
// Loose pictures go into the folder the node shows.
async function addFiles(state, files) {
if (!state.alive) return false;
const now = settings(state.node);
const images = [...(files ?? [])].filter(isPicture);
if (!images.length) return false;
@@ -355,13 +352,10 @@ async function addFiles(state, files) {
const added = [];
let failed = 0;
for (const file of images) {
if (!state.alive) return true;
const name = await upload(file, now.folder, false);
if (!state.alive) return true;
if (name) added.push(name); else failed += 1;
}
await refresh(state);
if (!state.alive) return true;
// A hand-made pick takes the new pictures in: they were just put there to be used.
const picked = parsePicked(settings(state.node).pictures);
if (picked !== null && added.length) setPicked(state, [...picked, ...added.filter((name) => state.names.includes(name))]);
@@ -394,7 +388,6 @@ function closeAsk(answer = false) {
}
function askBig(state, name, pictures) {
if (!state.alive) return Promise.resolve(false);
const bytes = pictures.reduce((sum, item) => sum + (Number(item.file?.size) || 0), 0);
if (pictures.length <= BIG_COUNT && bytes <= BIG_BYTES) return Promise.resolve(true);
closeAsk(false);
@@ -411,7 +404,7 @@ function askBig(state, name, pictures) {
foot.append(cancel, go);
root.append(foot);
const abort = new AbortController();
openAsk = { root, abort, done, state };
openAsk = { root, abort, done };
cancel.addEventListener("click", () => closeAsk(false));
go.addEventListener("click", () => closeAsk(true));
document.body.append(root);
@@ -427,17 +420,7 @@ function askBig(state, name, pictures) {
// input/<its name>, folders inside it too, and the node moves there. Copying
// the same folder again writes over its copies instead of piling up doubles.
async function copyFolder(state, name, pictures) {
if (!state.alive || state.copyLocked) return;
state.copyLocked = true;
try {
if (!(await askBig(state, name, pictures)) || !state.alive) return;
await copyPictures(state, name, pictures);
} finally {
state.copyLocked = false;
}
}
async function copyPictures(state, name, pictures) {
if (!(await askBig(state, name, pictures)) || !state.alive) return;
const target = folderName(name);
const queue = pictures.slice(0, COPY_LIMIT);
state.copying = { done: 0, total: queue.length, name, target };
@@ -448,9 +431,7 @@ async function copyPictures(state, name, pictures) {
for (;;) {
const next = queue.shift();
if (!next || !state.alive) return;
const added = await upload(next.file, next.folder ? `${target}/${next.folder}` : target, true);
if (!state.alive) return;
if (added) copied += 1;
if (await upload(next.file, next.folder ? `${target}/${next.folder}` : target, true)) copied += 1;
state.copying.done += 1;
paint(state);
cover(state);
@@ -463,7 +444,6 @@ async function copyPictures(state, name, pictures) {
if (!state.alive) return;
const moved = write(state.node, "source", "input") | write(state.node, "folder", target);
if (moved) notifyAusbossChange(); else await refresh(state);
if (!state.alive) return;
paint(state);
showToast({ detail: `Copied ${copied} picture${copied === 1 ? "" : "s"} from "${name}" into your ComfyUI input folder (input/${target}).` });
if (copied < total) showToast({ severity: "warn", detail: `${total - copied} could not be copied.` });
@@ -526,7 +506,7 @@ function addMenu(state, anchor) {
closeMenu();
const root = el("div", "ausboss-if-menu");
const abort = new AbortController();
openMenu = { root, abort, state };
openMenu = { root, abort };
const row = (text, title, run) => {
const button = el("button", "ausboss-if-browse-row", text);
button.type = "button"; button.title = title;
@@ -565,25 +545,19 @@ async function browse(state, anchor) {
foot.append(cancel, use);
root.append(path, list, foot);
const abort = new AbortController();
openBrowse = { root, abort, state };
openBrowse = { root, abort };
let at = now.folder;
let source = now.source;
let navigation = 0;
const show = async (folder) => {
const token = ++navigation;
use.disabled = true;
let data = null;
try {
const response = await api.fetchApi(foldersAddress({ source, folder }));
const response = await api.fetchApi(foldersAddress({ source: now.source, folder }));
if (response.ok) data = await response.json();
} catch { /* shown as an empty list below */ }
if (openBrowse?.root !== root || token !== navigation || !state.alive) return;
if (openBrowse?.root !== root) return;
if (!data && folder) return show(""); // a folder that is gone: start at the top
at = data?.folder ?? "";
source = data?.source ?? source;
use.disabled = false;
path.replaceChildren(document.createTextNode(`${source} / `), el("b", "", at || "(the folder itself)"));
path.replaceChildren(document.createTextNode(`${now.source} / `), el("b", "", at || "(the folder itself)"));
list.replaceChildren();
if (at) {
const up = el("button", "ausboss-if-browse-row up", "↑ up");
@@ -608,9 +582,7 @@ async function browse(state, anchor) {
};
use.addEventListener("click", () => {
if (use.disabled || !state.alive) return;
const moved = write(state.node, "source", source) | write(state.node, "folder", at);
if (moved) notifyAusbossChange();
if (write(state.node, "folder", at)) notifyAusbossChange();
closeBrowse();
});
cancel.addEventListener("click", closeBrowse);
@@ -734,12 +706,6 @@ function buildPanel(node) {
const now = settings(node);
const key = `${now.source}|${now.folder}|${now.subfolders}|${now.sort}`;
if (key === state.key) { paint(state); return; }
const context = folderContext(now);
if (context !== state.context) {
state.done.clear();
state.loadedLine = "";
state.context = context;
}
// Another folder holds other names: a pick made in the old one means nothing there.
const moved = state.key && (now.source !== state.shown.source || now.folder !== state.shown.folder || now.subfolders !== state.shown.subfolders);
state.key = key;
@@ -759,9 +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;
// The folder can change on disk or arrive through a link. Only the
// backend knows where it ends; a cached gallery must not cap "next".
const next = nextPosition(now.position, now.afterRun === "next" ? null : count, now.afterRun, now.atTheEnd);
const next = nextPosition(now.position, count, now.afterRun, now.atTheEnd);
if (next !== now.position) { position.value = next; position.callback?.(next); }
});
}
@@ -784,7 +748,6 @@ function buildPanel(node) {
});
const now = settings(node);
state.context = folderContext(now);
state.key = `${now.source}|${now.folder}|${now.subfolders}|${now.sort}`;
rebuild(state);
scheduleRefresh(state, 60);
@@ -810,18 +773,12 @@ app.registerExtension({
});
chainCallback(nodeType.prototype, "onConfigure", function (info) {
const state = buildPanel(this);
++state.token;
clearTimeout(state.timer);
state.done.clear();
state.loadedLine = "";
// A node that comes with a size keeps it, the smallest one too.
if (info?.size) state.sized = true;
// A loaded workflow brings its own folder and its own pick.
state.restoring = true;
queueMicrotask(() => {
if (!state.alive) return;
const now = settings(this);
state.context = folderContext(now);
state.key = `${now.source}|${now.folder}|${now.subfolders}|${now.sort}`;
state.restoring = false;
scheduleRefresh(state, 30);
@@ -832,7 +789,6 @@ app.registerExtension({
const state = this.__ausbossImageFolder;
const note = output?.ausboss_image_folder?.[0];
if (!state?.alive || !note) return;
if (!note.context || folderContext(note.context) !== folderContext(settings(this))) return;
for (const name of note.names ?? []) state.done.add(name);
const loaded = note.loaded ?? [];
state.loadedLine = loaded.length === 1
@@ -849,9 +805,9 @@ app.registerExtension({
state.abort.abort();
state.choosing?.abort();
clearTimeout(state.dropTimer);
if (openBrowse?.state === state) closeBrowse();
if (openMenu?.state === state) closeMenu();
if (openAsk?.state === state) closeAsk(false);
if (openBrowse) closeBrowse();
if (openMenu) closeMenu();
if (openAsk?.root && state.grid.isConnected === false) closeAsk(false);
});
},
});
+1 -1
View File
@@ -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";
+84 -361
View File
@@ -14,28 +14,24 @@
// switch lays each sampling step over the stage, with a Stop button on the
// bar (shared/step_preview.mjs).
import { panelSwitch, primaryAction } from "../shared/panel_controls.mjs";
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 { executionIdOf, nodeByExecutionId } from "../shared/graph_ids.mjs";
import { FONT_UI, RADIUS_CONTROL, RADIUS_XS, THEME as T } from "../shared/theme.mjs";
import { chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { ensureNodeMinHeight, fillNodeHeight, growToNodeFloor } from "../shared/panel_layout.mjs";
import { suppressCoreImagePreview } from "../shared/core_preview.mjs";
import { autoMaskValues } from "../shared/mask_auto.mjs";
import { hideInputsInDef, hideWidget, setWidgetVisible } from "../shared/widget_visibility.mjs";
import { makePictureStepper } from "../shared/picture_stepper.mjs";
import { ALL, allLabel, gridShape, heldPlaces, keepIndex, keepWords, stepIndex, stepWithAll } from "../shared/picture_steps.mjs";
import { keepIndex, stepIndex } from "../shared/picture_steps.mjs";
import { addStepConsumer, mountStepOverlay, showsStepsOn, stepRun, stopStepRun, watchSteps } from "../shared/step_preview.mjs";
import {
describeNodePreview,
describeSourcePreview,
lightPreviewQuery,
outputLocatorId,
outputRecordCount,
outputRecordQuery,
ownResultIsCurrent,
placeholderText,
staleText,
sourceFileWidget,
@@ -56,15 +52,7 @@ const BAR_GAP = 6;
const BAR_PADDING = 2;
const PANEL_HEIGHT = STAGE_HEIGHT + BAR_HEIGHT + 4 + 16;
const OFF_HEIGHT = BAR_HEIGHT + 16;
// The row over pictures that wait for a Save, and the gap under it.
const KEEP_HEIGHT = 30;
const KEEP_SPACE = KEEP_HEIGHT + 4;
const INPUT_SIDE = 1; // LiteGraph.INPUT
// A run that left this many pictures or fewer opens with all of them side
// by side. More than that and each would be a speck, so the arrows go
// through them one at a time, as before.
const GRID_MAX = 48;
const GRID_GAP = 4;
// The node's height from before its preview was switched off, so switching
// it back on returns the node to it (saved with the workflow).
const HEIGHT_PROPERTY = "ausboss_preview_height";
@@ -124,17 +112,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.
// SAVE shows only for a run made with Auto save off, which holds its
// pictures until this button keeps them.
AUSBOSS_NODES_SaveImage: {
inputName: "images",
noun: "an image",
switchTitle: "Show the saved image here. The file is saved either way.",
steps: true,
// Auto save off: a row over the picture says it is not saved yet and
// holds the button that saves it.
keep: "Save what is showing: every picture, or the one the arrows picked.",
},
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
@@ -151,40 +129,34 @@ function ensureCss() {
style.textContent = `
.ausboss-input-preview{box-sizing:border-box;display:flex;flex-direction:column;gap:4px;width:100%;height:100%;padding:0 6px 6px;pointer-events:none;overflow:hidden;}
.ausboss-input-preview-bar{box-sizing:border-box;flex:none;display:flex;align-items:center;gap:${BAR_GAP}px;height:${BAR_HEIGHT}px;padding:0 ${BAR_PADDING}px;pointer-events:none;}
.ausboss-input-preview-stage{position:relative;flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;width:100%;overflow:hidden;border:1px solid ${T.rule};border-radius:${RADIUS_CONTROL}px;background:${T.well};}
.ausboss-input-preview-stage{position:relative;flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;width:100%;overflow:hidden;border:1px solid rgba(0,180,170,.27);border-radius:6px;background:rgba(0,0,0,.28);}
[data-node-id] .ausboss-input-preview-stage{box-sizing:border-box;min-height:${STAGE_HEIGHT}px;}
.ausboss-input-preview.preview-off .ausboss-input-preview-stage{display:none;}
.ausboss-input-preview.preview-off .ausboss-stepper{display:none;}
.ausboss-input-preview-stage img,.ausboss-input-preview-stage video{display:none;position:absolute;inset:0;margin:auto;max-width:100%;max-height:100%;object-fit:contain;}
.ausboss-input-preview-stage.show-image img{display:block;}
.ausboss-input-preview-stage.loading-image img{opacity:.4;transition:opacity .3s ease 1.2s;}
.ausboss-input-preview-stage.show-video video{display:block;}
.ausboss-input-preview-grid{display:none;position:absolute;inset:0;box-sizing:border-box;gap:${GRID_GAP}px;padding:${GRID_GAP}px;justify-content:center;align-content:center;}
.ausboss-input-preview-stage.show-grid .ausboss-input-preview-grid{display:grid;}
.ausboss-input-preview-stage .ausboss-input-preview-grid img{display:block;position:static;margin:0;width:100%;height:100%;min-width:0;min-height:0;max-width:none;max-height:none;object-fit:contain;}
.ausboss-input-preview-hint{display:none;max-width:86%;color:${T.faint};font:11px/1.35 ${FONT_UI};text-align:center;}
.ausboss-input-preview-stage:not(.show-image):not(.show-video):not(.show-grid) .ausboss-input-preview-hint{display:block;}
.ausboss-input-preview-keep{box-sizing:border-box;flex:none;display:flex;align-items:center;justify-content:space-between;gap:10px;height:${KEEP_HEIGHT}px;padding:0 4px 0 10px;border-radius:${RADIUS_CONTROL}px;background:${T.brandSoft};pointer-events:none;}
.ausboss-input-preview-keep[hidden]{display:none;}
.ausboss-input-preview-keep span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${T.brandInk};font:600 11px/1.2 ${FONT_UI};}
.ausboss-input-preview-keep.done{background:${T.fill};}
.ausboss-input-preview-keep.done span{color:${T.label};}
.ausboss-input-preview-hint{display:none;max-width:86%;color:#78908e;font:11px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:center;}
.ausboss-input-preview-stage:not(.show-image):not(.show-video) .ausboss-input-preview-hint{display:block;}
.ausboss-input-preview-tools{display:flex;gap:4px;pointer-events:auto;}
.ausboss-input-preview-tool{box-sizing:border-box;height:${BAR_HEIGHT}px;min-width:24px;padding:0 6px;border:1px solid transparent;border-radius:${RADIUS_XS}px;background:${T.fill};color:${T.label};font:600 9.5px/18px ${FONT_UI};letter-spacing:.02em;cursor:pointer;}
.ausboss-input-preview-tool[hidden]{display:none;}
.ausboss-input-preview-stage .ausboss-input-preview-grid img.kept{outline:2px solid ${T.brand};outline-offset:-2px;}
.ausboss-input-preview-tool:hover{color:${T.ink};background:${T.fillHover};}
.ausboss-input-preview-tool.active{color:${T.brandInk};background:${T.brandSoft};}
.ausboss-input-preview-tool:focus-visible{outline:2px solid ${T.brand};outline-offset:1px;}
.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{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:not(.preview-off) .ausboss-input-preview-toggle.steps + .ausboss-input-preview-toggle{margin-left:0;}
.ausboss-input-preview.preview-off .ausboss-input-preview-toggle.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 transparent;border-radius:999px;background:${T.brandSoft};color:${T.brandInk};font:600 9.5px/18px ${FONT_UI};font-variant-numeric:tabular-nums;white-space:nowrap;pointer-events: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-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{background:${T.warnSoft};color:${T.warnInk};}
.ausboss-input-preview-note{display:none;position:absolute;left:6px;right:6px;bottom:6px;z-index:6;padding:6px 8px;border:1px solid ${T.warn};border-radius:${RADIUS_CONTROL}px;background:${T.warnSoft};color:${T.warnInk};font:600 11px/1.35 ${FONT_UI};text-align:center;pointer-events:none;}
.ausboss-input-preview-toast{position:absolute;left:50%;bottom:6px;z-index:5;max-width:88%;padding:3px 7px;border-radius:${RADIUS_XS}px;background:${T.fill};color:${T.inkSoft};font:10px/1.3 ${FONT_UI};text-align:center;transform:translateX(-50%);pointer-events:none;}
.ausboss-input-preview-readout.warn{border-color:#c98a2b;background:rgba(201,138,43,.16);color:#ffd9a0;}
.ausboss-input-preview-note{display:none;position:absolute;left:6px;right:6px;bottom:6px;z-index:6;padding:6px 8px;border:1px solid #c98a2b;border-radius:6px;background:rgba(20,10,0,.86);color:#ffd9a0;font:600 11px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:center;pointer-events:none;}
.ausboss-input-preview-toast{position:absolute;left:50%;bottom:6px;z-index:5;max-width:88%;padding:3px 7px;border-radius:4px;background:rgba(0,0,0,.78);color:#b8d3d1;font:10px/1.3 "Segoe UI",sans-serif;text-align:center;transform:translateX(-50%);pointer-events:none;}
`;
document.head.appendChild(style);
}
@@ -325,9 +297,18 @@ function previewEnabled(state) {
}
function makePreviewToggle(state, signal) {
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) => {
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) => {
event.preventDefault(); event.stopPropagation();
const next = !previewEnabled(state);
state.previewWidget.value = next;
@@ -336,7 +317,7 @@ function makePreviewToggle(state, signal) {
notifyAusbossChange();
}, { signal });
state.switchButton = button;
return button;
return toggle;
}
function installPreviewSwitch(state, signal) {
@@ -367,10 +348,18 @@ function installStepsSwitch(state, signal) {
if (!widget) return;
state.stepsWidget = widget;
hideWidget(widget);
const button = panelSwitch("Preview 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) => {
const toggle = document.createElement("div");
toggle.className = "ausboss-input-preview-toggle steps";
toggle.title = "Show the picture here while it is being made, one step at a time, with a Stop button. Off by default.";
const caption = document.createElement("span");
caption.textContent = "steps";
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) => {
event.preventDefault(); event.stopPropagation();
const next = widget.value !== true;
widget.value = next;
@@ -379,9 +368,9 @@ function installStepsSwitch(state, signal) {
notifyAusbossChange();
}, { signal });
state.stepsButton = button;
const previewToggle = state.switchButton;
if (previewToggle?.parentElement === state.bar) state.bar.insertBefore(button, previewToggle);
else state.bar.append(button);
const previewToggle = state.switchButton?.parentElement;
if (previewToggle?.parentElement === state.bar) state.bar.insertBefore(toggle, previewToggle);
else state.bar.append(toggle);
// Stop sits where the node's tools go; it keeps its place while hidden so
// the bar never shifts when a run starts.
@@ -419,6 +408,7 @@ function syncSteps(state, run = stepRun()) {
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.classList.toggle("on", state.stepsWidget?.value === true);
state.stepsButton.setAttribute("aria-checked", String(state.stepsWidget?.value === true));
}
state.stopButton?.classList.toggle("live", on && run.status === "running");
@@ -473,16 +463,17 @@ 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) {
// Nothing to show and nothing to fetch while it is off.
clearMedia(state);
state.img.removeAttribute("src");
}
if (resize) {
const node = state.node;
node.properties ??= {};
const floor = node.computeSize?.()[1] ?? panelFloor(state);
const floor = node.computeSize?.()[1] ?? (enabled ? PANEL_HEIGHT : OFF_HEIGHT);
if (!enabled) {
// Off: remember the height, then shrink to what is left.
const height = Number(node.size?.[1]);
@@ -525,7 +516,7 @@ function buildTools(state, tools, signal) {
}
function showHint(state, text) {
state.stage.classList.remove("show-image", "show-video", "show-grid");
state.stage.classList.remove("show-image", "show-video");
state.hint.textContent = text;
state.placeholder = null;
releaseSteps(state);
@@ -540,76 +531,24 @@ function showPlaceholder(state, connected) {
function showMedia(state, kind) {
state.stage.classList.toggle("show-image", kind === "image");
state.stage.classList.toggle("show-video", kind === "video");
state.stage.classList.toggle("show-grid", kind === "grid");
state.placeholder = null;
releaseSteps(state);
}
function clearMedia(state) {
state.img.removeAttribute("src");
state.stage.classList.remove("loading-image");
state.video.pause?.();
state.video.removeAttribute("src");
clearGrid(state);
}
function clearGrid(state) {
if (!state.gridKey) return;
state.gridKey = "";
state.grid?.replaceChildren();
}
// Every picture of the run at once, each as big as the stage allows.
function layoutGrid(state) {
const cells = [...(state.grid?.children ?? [])];
if (!cells.length) return;
const loaded = cells.find((cell) => cell.naturalWidth > 0 && cell.naturalHeight > 0);
const aspect = loaded ? loaded.naturalWidth / loaded.naturalHeight : 1;
const { columns, rows } = gridShape(cells.length, state.stage.clientWidth, state.stage.clientHeight, aspect);
// Tracks the size of one picture, so the pictures sit together in the
// middle of the stage with no bands between the rows.
const wide = (state.stage.clientWidth - GRID_GAP * (columns + 1)) / columns;
const tall = (state.stage.clientHeight - GRID_GAP * (rows + 1)) / rows;
if (!(wide > 0) || !(tall > 0)) return;
const width = Math.floor(Math.min(wide, tall * aspect));
const height = Math.floor(width / aspect);
state.grid.style.gridTemplateColumns = `repeat(${columns}, ${width}px)`;
state.grid.style.gridTemplateRows = `repeat(${rows}, ${height}px)`;
}
function showGrid(state, count) {
const urls = Array.from({ length: count }, (_, index) => storedResultUrl(state.node, index) ?? "");
const key = urls.join("\n");
if (state.gridKey !== key) {
state.gridKey = key;
state.grid.replaceChildren(...urls.map((url, index) => {
const cell = document.createElement("img");
cell.alt = `Picture ${index + 1} of ${count}`;
cell.decoding = "async";
// The first picture to load tells the grid the shape to lay out for.
cell.addEventListener("load", () => layoutGrid(state), { once: true });
cell.addEventListener("error", () => dropLightPreview(state), { once: true });
cell.src = url;
return cell;
}));
}
state.img.removeAttribute("src");
state.video.pause?.();
state.video.removeAttribute("src");
layoutGrid(state);
showMedia(state, "grid");
}
// Each source has one hook, with subscribers that can leave in any order.
const sourceWatchers = new WeakMap();
// Restore the watched widget's callback if our hook is still the tail of the
// chain; if someone chained after us the hook simply goes inert via
// state.alive. We never create object URLs, so there is nothing to revoke.
function unwatchSource(state) {
const watched = state.watched;
state.watched = null;
if (!watched) return;
watched.subscribers.delete(state);
if (!watched.subscribers.size && watched.widget.callback === watched.hook) {
if (watched && watched.widget.callback === watched.hook) {
watched.widget.callback = watched.prior;
sourceWatchers.delete(watched.widget);
}
}
@@ -618,21 +557,14 @@ function rewatchSource(state, source) {
if (state.watched?.widget === widget) return;
unwatchSource(state);
if (!widget) return;
let watched = sourceWatchers.get(widget);
if (!watched) {
const prior = widget.callback;
const subscribers = new Set();
const hook = function (...args) {
const result = prior?.apply(this, args);
for (const subscriber of subscribers) if (subscriber.alive) scheduleRefresh(subscriber);
return result;
};
watched = { widget, prior, hook, subscribers };
sourceWatchers.set(widget, watched);
widget.callback = hook;
}
watched.subscribers.add(state);
state.watched = watched;
const prior = widget.callback;
const hook = function (...args) {
const result = prior?.apply(this, args);
if (state.alive) scheduleRefresh(state);
return result;
};
widget.callback = hook;
state.watched = { widget, prior, hook };
}
// The node's stored output as a /view URL, for Nodes 2.0, where node.imgs
@@ -641,36 +573,17 @@ function rewatchSource(state, source) {
// record: an overwritten file reloads after the next run, and a plain refresh
// never reloads the picture.
const storedRand = new WeakMap();
const outputRecord = (node) => app.nodeOutputs?.[outputLocatorId(node)];
// Save Image also writes a small copy of each picture for its face, and the
// panel shows that one: a few hundred kilobytes where the file can be tens
// of megabytes. The copies are in the temp folder, which ComfyUI empties
// when it starts; a record whose copy is gone goes back to the file itself.
const lightGone = new WeakSet();
function lightQuery(record, index = null) {
return record && !lightGone.has(record) ? lightPreviewQuery(record, index) : null;
}
function storedResultUrl(node, index = null) {
const record = outputRecord(node);
const light = lightQuery(record, index);
const query = light ?? outputRecordQuery(record, index);
const record = app.nodeOutputs?.[outputLocatorId(node)];
const query = outputRecordQuery(record, index);
if (!query) return null;
if (!storedRand.has(record)) storedRand.set(record, app.getRandParam?.() ?? "");
return api.apiURL(`/view?${query}${light ? "" : app.getPreviewFormatParam?.() ?? ""}${storedRand.get(record)}`);
}
// A small copy that would not load: show the file itself from now on.
function dropLightPreview(state) {
const record = outputRecord(state.node);
if (!lightQuery(record)) return false;
lightGone.add(record);
scheduleRefresh(state);
return true;
return api.apiURL(`/view?${query}${app.getPreviewFormatParam?.() ?? ""}${storedRand.get(record)}`);
}
// Several pictures from one run (a list, or a batch Save Image saved one by
// one): they all show side by side, and the arrows in the bar go on to each
// one by itself and back round to all of them. One picture keeps the panel
// as it was, with no arrows.
// one): the arrows in the bar pick which one shows. One picture keeps the
// panel as it was, with no arrows.
function resultCount(state) {
if (state.stale || state.resultGone) return 0;
return outputRecordCount(app.nodeOutputs?.[outputLocatorId(state.node)]);
@@ -686,174 +599,21 @@ function previewMinWidth(state) {
return Math.max(PANEL_MIN_WIDTH, Math.ceil(state.node.size[0] - state.bar.clientWidth + needed));
}
// --- pictures held until Save (Save Image with Auto save off) ---------------
// The pictures this node is holding, in the order it shows them. Empty when
// the last run saved its pictures itself.
function heldPictures(state) {
if (state.stale || state.resultGone) return [];
return heldPlaces(app.nodeOutputs?.[outputLocatorId(state.node)]);
}
// Which of the shown pictures are kept, by their place on the node.
function keptOf(state, held) {
const key = held.map((picture) => `${picture.token}_${picture.index}`).join(" ");
if (state.kept?.key !== key) state.kept = { key, indexes: new Set() };
return state.kept.indexes;
}
// A click keeps every picture, or only the one the arrows picked. With the
// preview off there is no picture to pick, so it keeps them all.
function keepsAll(state, count) {
return count < 2 || showsAll(state, count) || !previewEnabled(state);
}
// The row shows while the node holds pictures. It says what is saved, and
// its button what a click will do (keepWords).
function syncHeld(state, count = resultCount(state)) {
const row = state.keepRow;
if (!row) return;
const held = heldPictures(state);
const hidden = !held.length || count < 1;
if (row.hidden !== hidden) {
row.hidden = hidden;
fitKeepRow(state);
}
if (hidden) return;
const kept = keptOf(state, held);
const all = keepsAll(state, count);
const done = all ? kept.size >= held.length : kept.has(state.index);
const words = keepWords({
held: held.length, kept: kept.size, all, done, saving: Boolean(state.keeping), name: state.kept?.name ?? "",
});
state.keepNote.textContent = words.note;
state.keepNote.title = state.kept?.path ? `ComfyUI/output/${state.kept.path}` : "";
state.keepButton.textContent = words.button;
state.keepButton.classList.toggle("quiet", words.quiet);
state.keepButton.disabled = Boolean(state.keeping);
row.classList.toggle("done", words.quiet && kept.size >= held.length);
[...(state.grid?.children ?? [])].forEach((cell, index) => cell.classList.toggle("kept", kept.has(index)));
}
// The row takes room of its own, so the picture keeps its floor: the node
// grows to hold it, and a node with the preview off gives the room back.
function fitKeepRow(state) {
const node = state.node;
if (previewEnabled(state)) {
ensureNodeMinHeight(node);
} else {
const floor = Number(node.computeSize?.()?.[1]);
if (Number.isFinite(floor)) node.setSize?.([node.size?.[0] ?? PANEL_MIN_WIDTH, floor]);
}
node.setDirtyCanvas?.(true, true);
}
function buildKeepRow(state, title, signal) {
const row = document.createElement("div");
row.className = "ausboss-input-preview-keep";
row.hidden = true;
const note = document.createElement("span");
const button = primaryAction("Save picture", title);
// Stop the graph from seeing the click as a canvas drag on the node.
button.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
keepHeld(state);
}, { signal });
row.append(note, button);
state.keepRow = row;
state.keepNote = note;
state.keepButton = button;
return row;
}
async function keepHeld(state) {
const held = heldPictures(state);
if (!held.length || state.keeping) return;
const count = resultCount(state);
const wanted = keepsAll(state, count) ? held.map((_, place) => place) : [state.index];
// One request for each run, in the order the pictures show.
const runs = new Map();
for (const place of wanted) {
const picture = held[place];
if (!picture) continue;
if (!runs.has(picture.token)) runs.set(picture.token, []);
runs.get(picture.token).push(picture.index);
}
state.keeping = true;
syncHeld(state);
const kept = keptOf(state, held);
const saved = [];
let skipped = 0;
try {
for (const [token, picks] of runs) {
const response = await api.fetchApi("/ausboss/save_image/keep", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, picks }),
});
const answer = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(answer.error || "Could not save.");
if (!state.alive) return;
const done = new Set(answer.kept ?? []);
held.forEach((picture, place) => {
if (picture.token === token && done.has(picture.index)) kept.add(place);
});
saved.push(...(answer.saved ?? []));
// One picture: the row says the name it was saved under.
const path = held.length === 1 ? answer.saved?.[0]?.path : null;
if (typeof path === "string" && path && state.kept) {
state.kept.path = path;
state.kept.name = path.slice(path.lastIndexOf("/") + 1);
}
skipped += answer.skipped?.length ?? 0;
}
toast(state, saved.length === 1
? `Saved ${saved[0].path}`
: saved.length
? `Saved ${saved.length} pictures`
: skipped ? "Not saved: the file already exists" : "Already saved");
} catch (error) {
if (state.alive) toast(state, error?.message || "Could not save.");
} finally {
state.keeping = false;
if (state.alive) syncHeld(state);
}
}
function showsAll(state, count) {
return count > 1 && count <= GRID_MAX && state.all !== false;
}
function stepPicture(state, step) {
const count = resultCount(state);
if (count < 2) return;
if (count > GRID_MAX) {
state.index = stepIndex(state.index, count, step);
} else {
const at = stepWithAll(showsAll(state, count) ? ALL : state.index, count, step);
state.all = at === ALL;
if (!state.all) state.index = at;
}
state.index = stepIndex(state.index, count, step);
refresh(state);
}
function refresh(state) {
if (!state.alive) return;
if (!previewEnabled(state)) {
clearMedia(state);
syncHeld(state);
return;
}
const source = upstreamNode(state.node, state.inputName);
rewatchSource(state, source);
const count = resultCount(state);
const hidden = state.stepper.root.hidden;
state.index = keepIndex(state.index, count);
const all = showsAll(state, count);
state.stepper.set(state.index, count, all ? allLabel(count) : undefined);
syncHeld(state, count);
state.stepper.set(state.index, count);
if (state.stepper.root.hidden !== hidden) growToNodeFloor(state.widget);
if (state.stale) {
// The node ran while its preview was off, so the picture it still has
@@ -865,20 +625,11 @@ function refresh(state) {
// A node that just stopped the run has no result for this picture: the
// one it still holds is from an earlier run (Mask by Name's last find,
// tinted), so show what feeds it instead.
if (all) {
showGrid(state, count);
// The grid's cells are new: mark the ones already kept.
syncHeld(state, count);
return;
}
clearGrid(state);
const described = state.resultGone
? describeSourcePreview(source)
: count > 1
? { kind: "url", url: storedResultUrl(state.node, state.index) }
: describeNodePreview(state.node, state.inputName, storedResultUrl(state.node),
// The small copy goes first: node.imgs is the full file.
!lightQuery(outputRecord(state.node)) && ownResultIsCurrent(state.node, outputRecord(state.node), state.imgsFor));
: describeNodePreview(state.node, state.inputName, storedResultUrl(state.node));
if (!described) {
clearMedia(state);
showPlaceholder(state, !!source);
@@ -900,9 +651,6 @@ function refresh(state) {
state.video.removeAttribute("src");
if (state.img.getAttribute("src") !== url) {
state.img.src = url;
// The picture before stays up while this one loads. A slow load
// dims it, so an old picture is never taken for the new one.
state.stage.classList.toggle("loading-image", true);
} else if (state.img.complete && state.img.naturalWidth > 0) {
showMedia(state, "image");
}
@@ -925,19 +673,8 @@ function scheduleRefresh(state) {
// Watching the property rather than an api event catches both routes that set
// it - the progress frames streamed during a run and the ui payload delivered
// when the node finishes - without this file having to know either event name.
// The panel's floor: the bar and the picture, the bar alone with the
// preview off, and the Save row on top while it shows.
function panelFloor(state, enabled = state ? previewEnabled(state) : true) {
const keep = state?.keepRow && !state.keepRow.hidden ? KEEP_SPACE : 0;
return (enabled ? PANEL_HEIGHT : OFF_HEIGHT) + keep;
}
function watchOwnResult(node, state) {
let images = node.imgs;
// Which output the pictures in node.imgs were loaded for. The frontend
// gives up on a picture that loads slowly and keeps the run before, so
// the panel checks this before it trusts node.imgs (ownResultIsCurrent).
state.imgsFor = outputRecord(node);
try {
Object.defineProperty(node, "imgs", {
configurable: true,
@@ -945,7 +682,6 @@ function watchOwnResult(node, state) {
get: () => images,
set: (value) => {
images = value;
state.imgsFor = outputRecord(node);
if (state.alive) scheduleRefresh(state);
},
});
@@ -974,9 +710,7 @@ function buildPanel(node, config) {
const toastEl = document.createElement("div");
toastEl.className = "ausboss-input-preview-toast";
toastEl.style.display = "none";
const grid = document.createElement("div");
grid.className = "ausboss-input-preview-grid";
stage.append(img, video, grid, hint, toastEl);
stage.append(img, video, hint, toastEl);
const bar = document.createElement("div");
bar.className = "ausboss-input-preview-bar";
root.append(bar, stage);
@@ -986,7 +720,7 @@ function buildPanel(node, config) {
const widget = node.addDOMWidget(WIDGET_NAME, "ausboss_input_preview", root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => panelFloor(node.__ausbossInputPreview, enabled()),
getMinHeight: () => (enabled() ? PANEL_HEIGHT : OFF_HEIGHT),
});
keepDomWidgetWidthAuto(widget);
// Not saved with the workflow either: options.serialize only keeps it out
@@ -998,7 +732,7 @@ function buildPanel(node, config) {
// dragged taller. With the preview off the floor is the bar alone.
fillNodeHeight(widget, {
minWidth: () => previewMinWidth(node.__ausbossInputPreview),
minHeight: () => panelFloor(node.__ausbossInputPreview, enabled()),
minHeight: () => (enabled() ? PANEL_HEIGHT : OFF_HEIGHT),
minNodeSize: [PANEL_MIN_WIDTH, 90],
exactMinWidth: true,
});
@@ -1010,10 +744,7 @@ function buildPanel(node, config) {
noun: config.noun,
switchTitle: config.switchTitle,
advanced: config.advanced,
root, bar, stage, img, video, grid, hint, widget, abort,
// Several pictures open side by side; the arrows pick one (false).
all: true,
gridKey: "",
root, bar, stage, img, video, hint, widget, abort,
toast: toastEl,
toastTimer: 0,
toolButtons: null,
@@ -1029,7 +760,6 @@ function buildPanel(node, config) {
};
livePanels.add(state);
if (config.tools?.length) bar.append(buildTools(state, config.tools, abort.signal));
if (config.keep) bar.after(buildKeepRow(state, config.keep, abort.signal));
if (config.readout) {
state.readout = document.createElement("span");
state.readout.className = "ausboss-input-preview-readout";
@@ -1048,16 +778,9 @@ function buildPanel(node, config) {
installPreviewSwitch(state, abort.signal);
if (config.steps) installStepsSwitch(state, abort.signal);
// The grid is laid out for the stage's shape, so a resized node lays it out again.
if (typeof ResizeObserver === "function") {
const watcher = new ResizeObserver(() => { if (state.alive && state.gridKey) layoutGrid(state); });
watcher.observe(stage);
abort.signal.addEventListener("abort", () => watcher.disconnect(), { once: true });
}
img.addEventListener("load", () => { stage.classList.remove("loading-image"); showMedia(state, "image"); });
img.addEventListener("load", () => showMedia(state, "image"));
img.addEventListener("error", () => {
stage.classList.remove("loading-image");
if (img.getAttribute("src") && !dropLightPreview(state)) showPlaceholder(state, true);
if (img.getAttribute("src")) showPlaceholder(state, true);
});
video.addEventListener("loadeddata", () => showMedia(state, "video"));
video.addEventListener("error", () => {
@@ -1126,9 +849,9 @@ app.registerExtension({
if (state) scheduleRefresh(state);
});
chainCallback(nodeType.prototype, "onConfigure", function () {
const state = this.__ausbossInputPreview;
queueMicrotask(() => {
if (!state?.alive || this.__ausbossInputPreview !== state) return;
const state = buildPanel(this, config);
if (!state) return;
// onNodeCreated already built the panel before this node's saved
// properties existed, so the advanced toggle has to be re-applied
// here or a workflow always reopens in the simple state.
+2 -2
View File
@@ -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";
+1 -4
View File
@@ -1,4 +1,4 @@
import { app } from "../../../scripts/app.js";
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";
@@ -288,9 +288,6 @@ function syncSizes() {
const grabbed = together.grabbed;
if (!grabbed) return;
const was = together.before.get(grabbed);
// A handle click is not a resize. Once a drag starts, keep syncing even
// when it returns to its starting size so the other nodes follow it back.
if (!together.sides && grabbed.size[0] === was.size[0] && grabbed.size[1] === was.size[1]) return;
together.sides ??= sharedSides(
[...together.before.values()].map(({ pos, size }) => ({ x: pos[0], y: pos[1], w: size[0], h: size[1] })),
);
+15
View File
@@ -1,6 +1,21 @@
// 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.
import { inputNumber } from "../shared/clip_rate.mjs";
import { dragTrimHandle } from "../load_video/trim_preview.mjs";
// Calculated outputs stay unknown until execution, even if the source has numbers.
export function wiredMax(node) {
const input = node.inputs?.find((slot) => slot.name === "max_seconds" || slot.widget?.name === "max_seconds");
if (input?.link == null) return { wired: false, value: null };
return { wired: true, value: inputNumber(node, "max_seconds", null) };
}
export function typedTrimBounds(duration, bounds, edge, seconds) {
// OUT 0 is the saved widget's "to the end", before handle clamping.
if (edge === "end" && seconds === 0) return { start: bounds.start, end: duration };
return dragTrimHandle(duration, bounds, edge, seconds / duration);
}
function finite(value, fallback = 0) {
const number = Number(value);
+20 -31
View File
@@ -6,9 +6,9 @@
// 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 { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { BRAND, 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";
@@ -16,7 +16,7 @@ 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";
import { grabZone, outputSummary, outputWindow, secondsLabel, typedTrimBounds, wavePeaks, wiredMax } from "./audio_window.mjs";
const NODE_NAME = "AUSBOSS_NODES_LoadAudio";
const PANEL_WIDGET = "ausboss_load_audio_wave";
@@ -50,18 +50,6 @@ function hideCoreAudio(node) {
}
}
// 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 };
@@ -102,7 +90,6 @@ function ensureAudioCss() {
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;}
@@ -318,12 +305,24 @@ function installPlayer(state) {
state.raf = requestAnimationFrame(tick);
};
audio.addEventListener("play", () => {
// Hidden tabs suspend animation frames, so never keep playing there.
if (document.hidden) { audio.pause(); return; }
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 });
document.addEventListener("visibilitychange", () => {
if (document.hidden) audio.pause();
}, { signal: state.abort.signal });
audio.addEventListener("ended", () => {
const win = currentWindow(state);
if (!document.hidden && loopEnabled(state.node) && win.length > 0) {
audio.currentTime = win.start;
audio.play().catch(() => stop());
}
}, { signal: state.abort.signal });
state.playButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
@@ -344,7 +343,7 @@ function commitTimeInput(state, edge, input) {
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));
writeTrim(state, typedTrimBounds(state.duration, { start: win.start, end: win.end }, edge, seconds));
notifyAusbossChange();
input.blur();
}
@@ -380,7 +379,7 @@ function buildPanel(node) {
canvas.className = "ausboss-audio-wave";
const status = document.createElement("div");
status.className = "ausboss-video-status";
status.textContent = "Choose a sound file";
status.textContent = "Choose or upload 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");
@@ -470,7 +469,7 @@ function buildPanel(node) {
if (!name || typeof name !== "string") {
audio.removeAttribute("src");
stage.classList.add("is-empty");
status.textContent = "Choose a sound file";
status.textContent = "Choose or upload a sound file";
updateFace(state);
return;
}
@@ -489,17 +488,7 @@ function buildPanel(node) {
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);
status.textContent = `This sound could not be shown: ${error?.message || error}`;
}
};
state.refresh = refresh;
+2 -2
View File
@@ -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";
+2 -2
View File
@@ -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 { chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
+28 -6
View File
@@ -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 { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange, showToast } from "../shared/index.mjs";
import { WIDGET_FRAME, fillNodeHeight, holdVueNodeMinWidth } from "../shared/panel_layout.mjs";
import { hideInputsInDef, hideWidget as collapseWidget } from "../shared/widget_visibility.mjs";
@@ -20,6 +20,7 @@ import {
BYPASS_MODE,
DEFAULT_STEP,
FINE_STEP,
MAX_ROWS,
MUTE_MODE,
STRENGTH_MAX,
STRENGTH_MIN,
@@ -1077,6 +1078,10 @@ function openPicker(state, index, anchor, { keyboard = false } = {}) {
const { name, kind } = entry;
if (kind === "folder") {
const result = appendFolderRows(state.rows, folders.get(name) ?? [], state.settings?.default_strength ?? 1);
if (result.needed > result.room) {
loraToast(`This folder has ${result.needed} new LoRAs, but only ${result.room} of ${MAX_ROWS} rows are free. Nothing was added. Remove rows or choose a smaller folder.`);
return;
}
if (!result.added) {
loraToast("Every LoRA in this folder is already in this stack. Nothing was added.");
return;
@@ -1090,6 +1095,10 @@ function openPicker(state, index, anchor, { keyboard = false } = {}) {
if (!pointer) backToStack(firstAdded);
return;
}
if (adding && state.rows.length >= MAX_ROWS) {
loraToast(`This stack already has ${MAX_ROWS} rows. Remove a row before adding another LoRA.`);
return;
}
closePopup();
const strength = roundStrength(state.settings?.default_strength ?? 1);
const rows = adding
@@ -1105,6 +1114,10 @@ function openPicker(state, index, anchor, { keyboard = false } = {}) {
const addTicked = ({ pointer = false } = {}) => {
const result = appendFolderRows(state.rows, ticked, state.settings?.default_strength ?? 1);
if (result.needed > result.room) {
loraToast(`You ticked ${result.needed} new LoRAs, but only ${result.room} of ${MAX_ROWS} rows are free. Nothing was added. Untick some or remove rows.`);
return;
}
if (!result.added) {
loraToast("Every ticked LoRA is already in this stack. Nothing was added.");
return;
@@ -1756,6 +1769,7 @@ function openRowMenu(state, index, event) {
}
menu.append(el("div", "ausboss-lora-menu-rule"));
const insertEmpty = (position) => {
if (state.rows.length >= MAX_ROWS) return;
const strength = roundStrength(state.settings?.default_strength ?? 1);
const rows = state.rows.slice();
// A row added next to a grouped one joins its group.
@@ -1765,8 +1779,8 @@ function openRowMenu(state, index, event) {
commitRows(state, rows, { structural: true });
fitNode(state);
};
item("Insert empty LoRA above", () => insertEmpty(index));
item("Insert empty LoRA below", () => insertEmpty(index + 1));
item("Insert empty LoRA above", () => insertEmpty(index), state.rows.length >= MAX_ROWS);
item("Insert empty LoRA below", () => insertEmpty(index + 1), state.rows.length >= MAX_ROWS);
item("Move up", () => commitGroups(state, moveRowInStack(state.rows, index, -1)),
index === 0 && !row.group);
item("Move down", () => commitGroups(state, moveRowInStack(state.rows, index, 1)),
@@ -1777,7 +1791,7 @@ function openRowMenu(state, index, event) {
rows.splice(index + 1, 0, copy);
commitRows(state, rows, { structural: true });
fitNode(state);
});
}, state.rows.length >= MAX_ROWS);
item(count > 1 ? `Remove these ${count} LoRAs` : "Remove this LoRA", () => {
commitGroups(state, state.rows.filter((entry) => !ids.includes(entry.id)));
}).classList.add("ausboss-lora-danger");
@@ -1790,7 +1804,7 @@ function openStackMenu(state, event) {
const menu = el("div", "ausboss-lora-menu");
const item = (label, action, disabled = false) => menuItem(menu, label, action, disabled);
const add = state.panel.querySelector(".ausboss-lora-add, .ausboss-lora-blank");
item("+ Add LoRAs…", () => openPicker(state, null, add), !add);
item("+ Add LoRAs…", () => openPicker(state, null, add), !add || state.rows.length >= MAX_ROWS);
const ids = state.rows.filter((row) => selectedIds(state).has(row.id)).map((row) => row.id);
if (ids.length) {
item(ids.length > 1 ? `Group the ${ids.length} selected LoRAs` : "Group the selected LoRA", () => {
@@ -2475,6 +2489,11 @@ async function runImportChain(state) {
{ position: "before", deduplicate: false });
const downMerge = mergeImportedRows(upMerge.rows, downResolved,
{ position: "after", deduplicate: false });
// Never bypass a source if any of its rows would be lost to the stack limit.
if (state.rows.length + upMerge.added + downMerge.added > MAX_ROWS) {
loraToast(`Absorb needs more than ${MAX_ROWS} rows. Free some stack rows first; source loaders remain active.`);
return;
}
// Rows that patch model and CLIP differently need both boxes visible, or
// the difference is invisible and lost on the first scrub. The flip is
// node-local (unified stays the default for new nodes) and the toast
@@ -2651,6 +2670,7 @@ function installStackKeyboard(state) {
if (action.kind === "focus") {
focusStackRow(state, visibleRowIndex(state, index, action.index));
} else if (action.kind === "pick") {
if (atAdd && state.rows.length >= MAX_ROWS) return;
openPicker(state, atAdd ? null : index,
atAdd ? state.panel.querySelector(".ausboss-lora-add") : rowElement.querySelector(".ausboss-lora-name"),
{ keyboard: true });
@@ -2688,6 +2708,7 @@ function renderRows(state) {
panel.append(body);
const addRowAndPick = (event) => {
if (state.rows.length >= MAX_ROWS) return;
// detail is 0 for a click made with Enter or Space.
openPicker(state, null, event.currentTarget, { keyboard: event.detail === 0 });
};
@@ -2915,6 +2936,7 @@ function renderRows(state) {
const actions = el("div", "ausboss-lora-actions");
const add = el("button", "ausboss-lora-add", "+ LoRA");
add.type = "button";
add.disabled = state.rows.length >= MAX_ROWS;
add.addEventListener("click", addRowAndPick);
add.title = "Pick one LoRA, or click a folder name to add all its LoRAs.";
actions.append(add);
+3 -27
View File
@@ -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";
@@ -47,24 +47,6 @@ async function stopLook(node) {
return Boolean(running || waiting.length);
}
// Take this node's own runs that still wait out of the queue: the looks and
// touches its picture asked for (they carry an `ask`), never a run of the
// workflow. Says how many went. The one that is running is left alone.
async function dropWaiting(node) {
const id = executionIdOf(app.rootGraph ?? app.graph, node);
if (!id) return 0;
const queue = await (await api.fetchApi("/queue")).json();
const ours = (entry) => {
const step = entry?.[2]?.[id];
return step?.class_type === NODE_CLASS && String(step.inputs?.ask ?? "") !== "";
};
const waiting = (queue?.queue_pending ?? []).filter(ours).map((entry) => entry[1]);
if (waiting.length) {
await api.fetchApi("/queue", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: waiting }) });
}
return waiting.length;
}
// The picture goes above the settings: the stage widget sits ahead of the
// node's card, whichever of the two was built first.
function stageFirst(node) {
@@ -110,7 +92,6 @@ function build(node) {
const face = mountTouchStage(node, {
lookOnButton: () => lookOnButton,
stopLook: () => stopLook(node).catch(() => false),
dropWaiting: () => dropWaiting(node).catch(() => 0),
// A workflow is being opened: nothing is run on the person's behalf.
loading: () => Boolean(app.configuringGraph),
// Bypassed or muted, or inside a box that is: nothing is run for it.
@@ -187,18 +168,13 @@ api.addEventListener("execution_error", ({ detail }) => {
api.addEventListener("execution_interrupted", () => settleAll());
// A look is one long, quiet run. The node sends what it has found every
// second or two, so things light up while it is still looking.
// A run that stops at the node (nothing picked on this picture) sends the
// picture that came in first, so a picture that changed upstream shows at
// once. It is shown like a kept note: it starts no run of its own.
api.addEventListener(LOOK_EVENT, ({ detail }) => {
try {
if (!faces.size || !detail?.note) return;
const id = String(detail.node ?? "");
const root = app.rootGraph ?? app.graph;
for (const face of faces) {
if (!face.alive || executionIdOf(root, face.node) !== id) continue;
if (detail.stopped) face.takeNote(detail.note, { restored: true });
else if (face.inFlight) face.takeNote(detail.note, { interim: true });
if (face.alive && face.inFlight && executionIdOf(root, face.node) === id) face.takeNote(detail.note, { interim: true });
}
} catch {
// Never in the way of a run.
+2 -2
View File
@@ -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";
+2 -2
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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 {
+72 -182
View File
@@ -21,8 +21,7 @@
// at the selected pixel budget and follow the explicit orientation toggle. Snap governs gestures only — typed
// values are never re-snapped.
import { CAPTION_TYPE, HINT_TYPE, compactAction } from "../shared/panel_controls.mjs";
import { app } from "../../../scripts/app.js";
import { app } from "/scripts/app.js";
import {
chainCallback,
keepDomWidgetWidthAuto,
@@ -30,10 +29,8 @@ import {
} from "../shared/index.mjs";
import { WIDGET_FRAME, fillNodeHeight, holdVueNodeMinWidth } from "../shared/panel_layout.mjs";
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
import { liftSlot, markSocketless } from "../shared/widget_card_math.mjs";
import { makeScrubInput } from "../shared/scrub_input.mjs";
import {
closeSettingsMenu,
gearIconSvg,
loadSettings,
openSettingsMenu,
@@ -54,16 +51,12 @@ import {
offGrid,
orientRatio,
parseRail,
parseWorkflowSizes,
readWorkflowPolicy,
savedWorkflowPolicy,
workflowRatios,
quickSizes,
ratioLabel,
reduceRatio,
scrubDim,
sizeForMegapixels,
fitStage,
stageScale,
} from "../shared/resolution_math.mjs";
const NODE_CLASS = "AUSBOSS_NODES_Resolution";
@@ -139,19 +132,13 @@ 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 { ${CAPTION_TYPE} white-space: nowrap; }
.ausboss-res-sechead .hint { ${HINT_TYPE}
.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;
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;
@@ -187,12 +174,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: var(--ausboss-res-inset,0) 0 0; margin: auto;
border: 1.5px solid #00b4aa; background: rgba(0,180,170,.10);
.ausboss-res-rect { position: absolute; inset: 0; margin: auto;
border: 1.5px solid #00b4aa; background: rgba(0,180,170,.05);
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: var(--ausboss-res-inset,0) 0 0; margin: auto;
.ausboss-res-ghost { position: absolute; inset: 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;
@@ -220,8 +207,6 @@ 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;
@@ -229,7 +214,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;
height: ${QUICK_H * 2 + 5}px; overflow-y: auto; align-content: start; }
min-height: ${QUICK_H * 2 + 5}px; 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;
@@ -271,7 +256,6 @@ 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);
@@ -366,7 +350,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, shift) {
function paintRings(state, scale) {
const svg = state.rings;
svg.replaceChildren();
const show = state.settings?.show_mp_rings !== false;
@@ -374,7 +358,7 @@ function paintRings(state, scale, shift) {
const box = stageBox(state);
svg.setAttribute("viewBox", `0 0 ${box.w} ${box.h}`);
const cx = box.w / 2;
const cy = box.h / 2 + shift;
const cy = box.h / 2;
for (const mp of [0.5, 1, 2]) {
const points = [];
for (let i = 0; i <= 60; i += 1) {
@@ -412,7 +396,7 @@ function paintRings(state, scale, shift) {
// The quick list regenerates when the ratio or snap changes; repaint only
// re-highlights it otherwise.
function rebuildQuick(state, width, height, snap, custom, picks) {
function rebuildQuick(state, width, height, snap, custom) {
const wrap = state.quickWrap;
wrap.replaceChildren();
state.quickButtons = [];
@@ -425,8 +409,7 @@ function rebuildQuick(state, width, height, snap, custom, picks) {
wrap.append(chip);
return;
}
const list = picks ?? quickSizes(width, height, snap);
if (state.strict && !list.length) return;
const list = quickSizes(width, height, snap);
if (!list.length) {
wrap.append(el("span", "ausboss-res-noquick",
"No on-grid sizes for this ratio at this snap — try a finer snap."));
@@ -437,7 +420,6 @@ function rebuildQuick(state, width, height, snap, custom, picks) {
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);
});
@@ -448,98 +430,26 @@ function rebuildQuick(state, width, height, snap, custom, picks) {
}
}
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.dimLabel.textContent = `${width} × ${height}`;
state.subMp.textContent = `${megapixels(width, height).toFixed(2)} MP`;
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" : "";
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);
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.dots.style.display = state.settings?.show_grid === false ? "none" : "";
paintRings(state, scale);
// Section labels.
state.orientLabel.textContent = state.strict ? "Workflow ratios" : width === height
state.orientLabel.textContent = width === height
? `square · chips ${portrait ? "portrait" : "landscape"}`
: portrait ? "portrait" : "landscape";
const flipFit = portrait ? { w: 11, h: 15 } : { w: 15, h: 11 };
@@ -553,17 +463,14 @@ function repaint(state) {
// Ratio chips render in the selected orientation; filled = exact, dashed = near.
state.chipButtons.forEach(({ button, ratio: chipRatio, glyph, label }) => {
const oriented = state.strict ? chipRatio : orientRatio(chipRatio, portrait);
const oriented = 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 = state.strict ? `Show approved ${oriented.label} sizes` : `${oriented.label}: reshape to this ratio. The flip button on the left sets the orientation.`;
button.title = `${oriented.label}: reshape to this ratio. The flip button on the left sets the orientation.`;
const candidate = chipMatch(width, height, oriented);
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"));
const match = candidate?.flipped ? null : candidate;
button.classList.toggle("exact", match?.state === "exact");
button.classList.toggle("near", match?.state === "near");
});
@@ -572,52 +479,49 @@ function repaint(state) {
const widthLinked = axisLinked(state, "width");
const heightLinked = axisLinked(state, "height");
const linked = widthLinked || heightLinked;
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" : "";
state.flipButton.disabled = linked;
state.swapButton.disabled = linked;
state.budgetControl.root.style.pointerEvents = linked ? "none" : "";
state.budgetControl.root.style.opacity = linked ? ".5" : "";
for (const { button } of state.chipButtons) button.disabled = linked;
state.widthControl.set(width);
state.heightControl.set(height);
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.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.handles.e.classList.toggle("linked", widthLinked);
state.handles.s.classList.toggle("linked", heightLinked);
state.handles.se.classList.toggle("linked", linked);
state.handles.se.classList.toggle("linked", widthLinked || heightLinked);
// Quick sizes follow the current ratio and snap grid. Frozen
// Quick sizes follow the CURRENT ratio - the Pixaroma behavior, but the
// list is generated on-ratio/on-grid instead of hand-curated. 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 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}`;
const quickKey = onRail ? `${ratio.x}:${ratio.y}/${snap}` : `custom/${snap}`;
if (state.quickKey !== quickKey) {
state.quickKey = quickKey;
rebuildQuick(state, width, height, snap, !picks && !onRail, picks);
rebuildQuick(state, width, height, snap, !onRail);
state.quickHint.textContent = onRail
? `for ${ratioLabel(width, height)} · /${snap}`
: `custom · /${snap}`;
}
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 = linked;
const selected = size.width === width && size.height === height;
button.classList.toggle("exact", selected);
button.setAttribute("aria-pressed", String(selected));
button.disabled = widthLinked || heightLinked;
button.classList.toggle("exact", size.width === width && size.height === height);
});
}
function showGhost(state, width, height) {
const { scale } = state.frame;
const box = stageBox(state);
const { width: w, height: h } = getSize(state);
const scale = stageScale(w, h, box.w, box.h);
state.ghost.style.width = `${width * scale}px`;
state.ghost.style.height = `${height * scale}px`;
state.ghost.style.display = "";
@@ -628,7 +532,7 @@ function hideGhost(state) {
}
function easeRefit(state) {
state.frozenFrame = null;
state.frozenScale = null;
state.rect.classList.add("easing");
repaint(state);
clearTimeout(state.easeTimer);
@@ -638,14 +542,15 @@ function easeRefit(state) {
// ---------- gestures ----------
function beginHandleDrag(state, handle, event) {
if (state.strict || event.button !== 0 || state.disposed) return;
if (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.frozenFrame = state.frame;
state.frozenScale = stageScale(start.width, start.height, box.w, box.h);
state.dragging = true;
const origin = { x: event.clientX, y: event.clientY };
state.dragAbort?.abort();
@@ -658,7 +563,7 @@ function beginHandleDrag(state, handle, event) {
startHeight: start.height,
dx: (moveEvent.clientX - origin.x) / zoom,
dy: (moveEvent.clientY - origin.y) / zoom,
scale: state.frozenFrame.scale,
scale: state.frozenScale,
snap: currentSnap(state),
snapOn: !moveEvent.altKey,
// The corner scales at the exact locked ratio; Shift frees both axes.
@@ -739,7 +644,7 @@ function sectionHead(label, hint) {
function buildPanel(state) {
state.dragAbort?.abort();
state.dragging = false;
state.frozenFrame = null;
state.frozenScale = null;
const panel = state.panel;
panel.textContent = "";
state.quickKey = null; // force the quick list to regenerate
@@ -763,16 +668,19 @@ function buildPanel(state) {
state.flipButton = flip;
state.flipGlyph = flipGlyph;
state.chipButtons = [];
state.railRatios = state.strict ? workflowRatios(state.sizes) : parseRail(state.settings?.rail);
state.railRatios = 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);
@@ -793,15 +701,13 @@ function buildPanel(state) {
state.chipButtons.push({ button: chip, ratio, glyph, label });
}
chipBox.append(chipGrid);
if (!state.strict) shapeRow.append(flipBox);
shapeRow.append(chipBox);
shapeRow.append(flipBox, chipBox);
shapeSec.append(shapeRow);
panel.append(shapeSec);
// (2) STAGE
const stage = el("div", "ausboss-res-stage");
stage.tabIndex = state.strict ? -1 : 0;
if (state.strict) stage.title = "Choose from the workflow’s ratio and size buttons.";
stage.tabIndex = 0;
const dots = el("div", "ausboss-res-dots");
const rings = svgEl("svg");
rings.classList.add("ausboss-res-rings");
@@ -828,14 +734,14 @@ function buildPanel(state) {
? "Drag width - Alt unsnaps, Esc cancels"
: "Drag height - Alt unsnaps, Esc cancels";
grip.addEventListener("pointerdown", (event) => beginHandleDrag(state, handle, event));
if (!state.strict) rect.append(grip);
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 || state.strict) return;
if (!move) return;
event.preventDefault();
event.stopPropagation();
const step = event.shiftKey ? 8 : currentSnap(state);
@@ -897,13 +803,10 @@ function buildPanel(state) {
gear.addEventListener("click", () => {
openSettingsMenu({
scope: SETTINGS_SCOPE,
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) => {
schema: SETTINGS_SCHEMA.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: {
@@ -966,7 +869,7 @@ function buildPanel(state) {
state.resizeObserver?.disconnect();
state.resizeObserver = new ResizeObserver(() => {
holdVueNodeMinWidth(state.panel, MIN_WIDTH);
if (!state.frozenFrame) repaint(state);
if (!state.frozenScale) repaint(state);
});
state.resizeObserver.observe(stage);
@@ -1004,8 +907,6 @@ 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);
@@ -1014,7 +915,8 @@ function installResolutionNode(node) {
const liftSockets = () => {
for (const input of node.inputs ?? []) {
if (["width", "height", "latent", "batch_size"].includes(input.widget?.name)) {
liftSlot(input);
delete input.widget;
input._widget = undefined;
}
}
};
@@ -1026,12 +928,9 @@ function installResolutionNode(node) {
heightWidget,
latentWidget,
batchWidget,
policyWidget,
sizes: [],
strict: false,
panel,
settings: loadSettings(SETTINGS_SCOPE, SETTINGS_SCHEMA),
frozenFrame: null,
frozenScale: null,
quickKey: null,
};
node.__ausbossResolution = state;
@@ -1054,7 +953,6 @@ function installResolutionNode(node) {
exactMinWidth: true,
});
restorePolicy(state);
buildPanel(state);
for (const widget of [widthWidget, heightWidget, latentWidget, batchWidget]) {
if (widget) chainCallback(widget, "callback", () => {
@@ -1082,18 +980,11 @@ 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 () {
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);
});
// Convert-to-input honesty: link changes flip the read-only axes.
requestAnimationFrame(() => { if (!state.disposed) { syncPrimitiveLinks(state); repaint(state); } });
});
chainCallback(node, "onRemoved", () => {
state.disposed = true;
@@ -1107,8 +998,7 @@ app.registerExtension({
name: "AusBoss.Resolution",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_CLASS) return;
markSocketless(nodeData, ["workflow_sizes"]);
hideInputsInDef(nodeData, ["width", "height", "latent", "batch_size", "workflow_sizes"]);
hideInputsInDef(nodeData, ["width", "height", "latent", "batch_size"]);
chainCallback(nodeType.prototype, "onNodeCreated", function () {
installResolutionNode(this);
});
+2 -13
View File
@@ -19,14 +19,13 @@
// 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 {
collapseRepeatedErrors,
emptySourceMessage,
explainRunError,
findEmptySources,
nodeLacksSource,
} from "../shared/run_check.mjs";
@@ -175,20 +174,10 @@ function collapseAnswers() {
api.queuePrompt = collapsing;
}
// A run that stops on an error the pack knows the cause of gets one toast
// that says what to do. ComfyUI's own error report is left as it is.
function explainKnownErrors() {
api.addEventListener("execution_error", ({ detail }) => {
const known = explainRunError(detail);
if (known) showToast({ severity: "warn", summary: known.summary, detail: known.detail, life: 30000 });
});
}
app.registerExtension({
name: "ausboss.run_check",
setup() {
guardRuns();
collapseAnswers();
explainKnownErrors();
},
});
+2 -2
View File
@@ -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 -38
View File
@@ -8,9 +8,8 @@
// 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 { PANEL_LABELS } from "../shared/panel_controls.mjs";
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 { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
@@ -33,7 +32,7 @@ const CARD_HEIGHT = 288;
const WRAPPER_INSET = 16;
const HIDDEN = [
"filename_prefix", "format", "save_metadata", "exact_name", "on_existing",
"output_dir", "caption", "name_counter", "name_date", "name_time", "name_size", "name_batch", "autosave",
"output_dir", "caption", "name_counter", "name_date", "name_time", "name_size", "name_batch",
];
const FONT = `-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif`;
const MONO = `ui-monospace,SFMono-Regular,Menlo,Consolas,monospace`;
@@ -60,10 +59,7 @@ function ensureCss() {
.ausboss-save-chip:hover{color:#fff;border-color:${BRAND}}
.ausboss-save-chip.on{background:${BRAND};border-color:${BRAND};color:#04201d}
.ausboss-save-chips.off .ausboss-save-chip{opacity:.4;pointer-events:none}
.ausboss-save-embed-label{flex:none;color:#8ba3a1;font-size:11px;white-space:nowrap;user-select:none}
.ausboss-save-half{display:flex;align-items:center;gap:6px;flex:1 1 auto;min-width:0}
.ausboss-save-half .ausboss-card-seg{flex:1 1 0;min-width:0}
.ausboss-save-switches{gap:12px}
.ausboss-save-embed-label{flex:0 0 30%;color:#8ba3a1;font-size:11px;user-select:none}
.ausboss-save-browse{position:fixed;z-index:10000;width:260px;max-height:320px;overflow:auto;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 ${FONT};color:#d8ecea}
.ausboss-save-browse-head{padding:5px 8px 6px;border-bottom:1px solid #2c3238;color:#78908e;font:10px/1 ${FONT};letter-spacing:.08em;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ausboss-save-browse button{display:block;width:100%;padding:6px 8px;border:none;border-radius:5px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}
@@ -149,7 +145,7 @@ async function openBrowse(state, anchor) {
}
path = listing.path;
const use = el("button", "use", path ? `Use “${path}”` : "Use the output folder");
use.addEventListener("click", () => { state.savedPath = null; setValue(state.node, "output_dir", path); state.refresh(); notifyAusbossChange(); closeBrowse(); });
use.addEventListener("click", () => { setValue(state.node, "output_dir", path); state.refresh(); notifyAusbossChange(); closeBrowse(); });
popup.append(use);
if (path) {
const up = el("button", "", "↑ up one level");
@@ -279,35 +275,15 @@ 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, PANEL_LABELS.off], [true, PANEL_LABELS.on]]) {
for (const [on, text] of [[false, "no"], [true, "yes"]]) {
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);
}
const embedHalf = el("div", "ausboss-save-half");
embedHalf.append(el("span", "ausboss-save-embed-label", PANEL_LABELS.embed), embedSeg);
embedRow.classList.add("ausboss-save-switches");
embedRow.append(embedHalf);
embedRow.append(el("span", "ausboss-save-embed-label", "Embed workflow"), embedSeg);
root.append(embedRow);
// Auto save: off shows the pictures without saving them; the Save button
// over the picture (js/input_preview) then keeps the ones that are wanted.
// It shares the Embed workflow row, so the card is no taller than before.
const autoHalf = el("div", "ausboss-save-half");
const autoSeg = el("div", "ausboss-card-seg ausboss-card-bool");
autoSeg.setAttribute("role", "radiogroup");
const autoButtons = new Map();
const autoTitle = "On: every run saves its pictures. Off: the pictures only show here, and the Save button over them keeps the ones you want.";
for (const [on, text] of [[false, PANEL_LABELS.off], [true, PANEL_LABELS.on]]) {
const button = el("button", "", text);
button.type = "button"; button.title = autoTitle;
button.addEventListener("click", () => { if (setValue(node, "autosave", on)) { state.savedPath = null; notifyAusbossChange(); } state.refresh(); });
autoSeg.append(button); autoButtons.set(on, button);
}
autoHalf.append(el("span", "ausboss-save-embed-label", "Auto save"), autoSeg);
embedRow.append(autoHalf);
root.addEventListener("pointerdown", (event) => {
if (event.target.closest("button,input,select")) event.stopPropagation();
});
@@ -353,11 +329,6 @@ function buildCard(node) {
button.classList.toggle("on", on === embed);
button.setAttribute("aria-checked", String(on === embed));
}
const auto = values.autosave !== false;
for (const [on, button] of autoButtons) {
button.classList.toggle("on", on === auto);
button.setAttribute("aria-checked", String(on === auto));
}
};
const domWidget = node.addDOMWidget(WIDGET_NAME, WIDGET_NAME, root, {
@@ -398,8 +369,7 @@ function buildCard(node) {
chainCallback(node, "onExecuted", (output) => {
if (isForeignRun()) return;
const saved = output?.ausboss_saved_path?.[0];
state.savedPath = typeof saved === "string" && saved ? saved : null;
state.refresh();
if (typeof saved === "string" && saved) { state.savedPath = saved; state.refresh(); }
});
chainCallback(node, "onRemoved", () => { state.alive = false; closeBrowse(); });
state.refresh();
+9 -10
View File
@@ -1,6 +1,5 @@
import { panelSwitch } from "../shared/panel_controls.mjs";
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 { chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { installNameTokens } from "../shared/name_tokens.mjs";
@@ -22,7 +21,7 @@ import {
const NODE_NAME = "AUSBOSS_NODES_SaveVideo";
const PREVIEW_WIDGET = "ausboss_save_video_viewer";
const PREVIEW_CHROME = 38;
const PREVIEW_CHROME = 12;
// 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.
@@ -39,7 +38,7 @@ function getLoopEnabled(node) {
return !!node.properties.ausboss_save_video_loop;
}
// The Preview steps switch: the hidden `step_preview` widget (the card hides it).
// 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) {
@@ -48,7 +47,7 @@ function stepsOn(state) {
function syncSteps(state, run = stepRun()) {
const on = stepsOn(state);
state.stepsButton?.setAttribute("aria-checked", String(on));
state.stepsButton?.classList.toggle("active", on);
state.stopButton?.classList.toggle("live", on && run.status === "running");
const overlay = state.stepOverlay;
if (!on) {
@@ -131,11 +130,11 @@ function buildPreview(node) {
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("Preview 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.");
const stepsButton = makeToolButton("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);
tools.append(stopButton, stepsButton, loopButton);
stage.append(video, image, status, tools);
root.append(stepsButton, stage);
root.append(stage);
const widget = node.addDOMWidget(PREVIEW_WIDGET, "ausboss_video", root, {
serialize: false,
@@ -148,7 +147,7 @@ function buildPreview(node) {
widget.serialize = false;
fillNodeHeight(widget, {
minWidth: VIDEO_MIN_WIDTH,
minHeight: 158,
minHeight: 144,
minNodeSize: [VIDEO_MIN_WIDTH, 270],
exactMinWidth: true,
});
+13 -124
View File
@@ -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,8 +19,6 @@ 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";
@@ -30,19 +28,8 @@ 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;
// 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 PANEL_DECLARED_HEIGHT = CARD_HEIGHT + 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;
@@ -72,21 +59,6 @@ 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;}
@@ -152,7 +124,6 @@ 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";
@@ -165,11 +136,6 @@ 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 = "";
@@ -179,8 +145,6 @@ 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"
@@ -188,16 +152,6 @@ 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);
@@ -287,9 +241,7 @@ function buildPanel(node) {
input.inputMode = "numeric";
input.spellcheck = false;
const copy = button("ausboss-seed-icon", "⧉", "Copy the seed");
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.append(tag, input, copy);
field.addEventListener("pointerdown", (event) => event.stopPropagation());
const seg = el("div", "ausboss-seed-seg");
@@ -305,16 +257,11 @@ function buildPanel(node) {
row.append(newSeed, useLast, more);
const hint = el("div", "ausboss-seed-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);
card.append(field, seg, row, hint);
root.append(card);
const state = (node.__ausbossSeed = {
node, root, card, field, tag, input, copy, gear, chip, dice, segRandom, segFixed, segStep, newSeed, useLast, more, hint,
node, root, field, tag, input, copy, segRandom, segFixed, segStep, newSeed, useLast, more, hint,
});
const commitTyped = () => {
@@ -357,31 +304,6 @@ 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); }
@@ -396,7 +318,7 @@ function buildPanel(node) {
const widget = node.addDOMWidget(WIDGET_NAME, "ausboss_seed", root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => panelHeight(node),
getMinHeight: () => PANEL_DECLARED_HEIGHT,
});
keepDomWidgetWidthAuto(widget);
// Not saved with the workflow either: options.serialize only keeps it out
@@ -405,20 +327,18 @@ function buildPanel(node) {
// A constant-height card: pinned on purpose (see
// tests/panel_guards.test.mjs, fixedByDesign).
widget.computeSize = (width) => [
Math.max(nodeWidthFloor(node), Number(width || node.size?.[0] || nodeWidthFloor(node))),
panelHeight(node),
Math.max(NODE_WIDTH, Number(width || node.size?.[0] || NODE_WIDTH)),
PANEL_DECLARED_HEIGHT,
];
// Pinned in Nodes 2.0 as well, where a node can be made taller than its card.
pinVuePanelHeight(widget);
widget.options.minNodeSize = [nodeWidthFloor(node), 60];
widget.options.minNodeSize = [NODE_WIDTH, 120];
// The frontend skips a pinned card's width, so its floor is held here.
holdNodeMinWidth(widget, nodeWidthFloor(node));
state.widget = widget;
holdNodeMinWidth(widget, NODE_WIDTH);
new ResizeObserver(() => fitCompactNumber(state)).observe(input);
render(state);
node.setSize?.([
Math.max(node.size?.[0] ?? 0, nodeWidthFloor(node)),
Math.max(node.size?.[0] ?? 0, NODE_WIDTH),
node.computeSize?.()[1] ?? node.size?.[1],
]);
// The card is a fixed-height row: width may grow, but a corner drag
@@ -436,49 +356,18 @@ 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 () {
// 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]]);
}
buildPanel(this);
});
chainCallback(nodeType.prototype, "onConfigure", function () {
queueMicrotask(() => {
const state = buildPanel(this);
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));
}
if (state) render(state);
});
});
chainCallback(nodeType.prototype, "onConnectionsChange", function () {
-101
View File
@@ -45,24 +45,6 @@ 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 #.
@@ -289,86 +271,3 @@ export function repointLinks(text, nodes, groups) {
.replace(ARROW_RE, (all, _arrow, id) => (id === undefined ? all : `${all.slice(0, -id.length - 1)}${follow(`#${id}`)}`));
return changed ? next : null;
}
// Info card: a small card with a title, rows and small print, chosen from the
// note's menu. Its text is the note's ordinary text with one addition: a line
// with a | in it is a row, the words before it on the left and the rest on
// the right. A row in **bold** is the one to notice and is drawn in teal. The
// first line is the card's title; a clock, info, warning, bulb or tick emoji
// at its start is drawn as a small icon. A plain callout reads none of this,
// so every note written before looks as it did, and a release before this
// one shows the card as a plain note with its rows written out.
const CARD_ICONS = new Map([
["⏱", "clock"], ["⏲", "clock"], ["⏰", "clock"], ["\u{1F552}", "clock"], ["⌛", "clock"], ["⏳", "clock"],
["ℹ", "info"], ["⚠", "warning"], ["\u{1F4A1}", "tip"], ["✅", "check"], ["✔", "check"],
]);
const CARD_ICON_RE = new RegExp(`^(${[...CARD_ICONS.keys()].join("|")})${VS16}?\\s*`, "u");
export const CARD_TEMPLATE = [
"ℹ️ Title",
`${SMALL_MARK}A line of small print under the title.`,
"",
"Label | value",
"**The one to notice | value**",
"",
`${SMALL_MARK}A note at the bottom.`,
].join("\n");
// Where a row splits: the first | that is not inside a link, or -1.
function cellSplit(line) {
const masked = line.replace(WORDS_LINK_RE, (all) => " ".repeat(all.length));
return masked.indexOf("|");
}
// A row's two sides, or null when the line is not a row. The outer pipes of
// a pasted Markdown table row are dropped.
export function cardCells(line) {
const bare = String(line ?? "").trim().replace(/^\|/, "").replace(/\|$/, "");
const at = cellSplit(bare);
if (at < 0) return null;
let label = bare.slice(0, at).trim();
let value = bare.slice(at + 1).trim();
if (!label || !value) return null;
// **label | value** marks the whole row.
if (label.startsWith("**") && !label.slice(2).includes("**") && value.endsWith("**") && !value.slice(0, -2).includes("**")) {
label = `${label}**`;
value = `**${value}`;
}
return [label, value];
}
// Text -> blocks of lines. A line is { kind: "title", icon, pieces },
// { kind: "row", cells: [pieces, pieces], highlight } or { kind: "text",
// pieces }; small print carries `small: true`. A Markdown table's ---|---
// line is dropped.
export function parseCard(text) {
const blocks = [];
let lines = [];
let first = true;
const flush = () => {
if (lines.length) blocks.push(lines);
lines = [];
};
for (const raw of String(text ?? "").replace(/\r\n?/g, "\n").split("\n")) {
const line = raw.replace(HEADING_RE, "").trim();
if (!line) { flush(); continue; }
if (/^\|?[\s:|-]*-[\s:|-]*\|?$/.test(line) && line.includes("|")) continue;
const small = SMALL_RE.test(raw);
const cells = cardCells(line);
let item;
if (first && !small && !cells) {
const icon = CARD_ICON_RE.exec(line);
item = { kind: "title", icon: icon ? CARD_ICONS.get(icon[1]) : null, pieces: pieces(icon ? line.slice(icon[0].length) : line) };
} else if (cells) {
const sides = cells.map((cell) => pieces(cell));
const shown = sides.flat().filter((piece) => piece.arrow || piece.text?.trim());
item = { kind: "row", cells: sides, highlight: shown.length > 0 && shown.every((piece) => piece.bold) };
} else {
item = { kind: "text", pieces: pieces(line) };
}
first = false;
if (small) item.small = true;
lines.push(item);
}
flush();
return blocks;
}
+5 -12
View File
@@ -10,15 +10,11 @@
// The wheel belongs to the panel while it can still scroll that way (a long
// text box, a list of rows), or while a text field has the keyboard.
export function canScrollFurther(element, delta, axis = "y") {
if (!element || !delta) return false;
const horizontal = axis === "x";
const size = horizontal ? element.scrollWidth : element.scrollHeight;
const visible = horizontal ? element.clientWidth : element.clientHeight;
const start = horizontal ? element.scrollLeft : element.scrollTop;
const room = size - visible;
export function canScrollFurther(element, deltaY) {
if (!element || !deltaY) return false;
const room = element.scrollHeight - element.clientHeight;
if (room <= 1) return false;
return delta > 0 ? start + visible < size - 1 : start > 0;
return deltaY > 0 ? element.scrollTop + element.clientHeight < element.scrollHeight - 1 : element.scrollTop > 0;
}
// Does the wheel belong to something inside the panel, between the target
@@ -29,11 +25,8 @@ export function panelKeepsWheel(target, event, { root, host, styleOf, activeElem
for (let element = target; element && element !== root && element !== host; element = element.parentElement) {
const tag = String(element.tagName ?? "").toUpperCase();
if ((tag === "TEXTAREA" || tag === "INPUT") && element === activeElement) return true;
const style = styleOf(element);
const overflow = style?.overflowY;
const overflow = styleOf(element)?.overflowY;
if ((overflow === "auto" || overflow === "scroll") && canScrollFurther(element, event.deltaY)) return true;
const overflowX = style?.overflowX;
if ((overflowX === "auto" || overflowX === "scroll") && canScrollFurther(element, event.deltaX, "x")) return true;
}
return false;
}
+2 -2
View File
@@ -9,7 +9,7 @@ export function inputNumber(node, name, fallback) {
for (let depth = 0; depth < 16; depth++) {
if (seen.has(linkId)) return null;
seen.add(linkId);
const link = graph?.links?.[linkId] ?? graph?._links?.get?.(linkId);
const link = graph?.getLink?.(linkId) ?? graph?.links?.get?.(linkId) ?? graph?.links?.[linkId] ?? graph?._links?.get?.(linkId);
const source = graph?.getNodeById?.(link?.origin_id);
if (!source || (source.mode ?? 0) !== 0) return null;
if (source.type === "Reroute") {
@@ -18,7 +18,7 @@ export function inputNumber(node, name, fallback) {
continue;
}
if (!["PrimitiveFloat", "PrimitiveInt", "AUSBOSS_NODES_Integer", "AUSBOSS_NODES_Float"].includes(source.type) || link.origin_slot !== 0) return null;
if (source.inputs?.some((slot) => slot.name === "value" && slot.link != null)) return null;
if (source.inputs?.some((slot) => (slot.name === "value" || slot.widget?.name === "value") && slot.link != null)) return null;
const value = source.widgets?.find((widget) => widget.name === "value")?.value;
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
+15 -52
View File
@@ -5,14 +5,14 @@
// 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 { compactAction } from "./panel_controls.mjs";
import { api } from "../../../scripts/api.js";
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,
@@ -82,7 +82,6 @@ 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;}
@@ -248,32 +247,9 @@ function drawCheckerboard(er, ctx, fit) {
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) return;
drawBrushSwatch(er);
if (!er.refs) return;
if (!er.active || !er.refs) return;
sizeView(er);
const { canvas } = er;
const ctx = canvas.getContext("2d");
@@ -401,7 +377,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."
@@ -628,7 +604,7 @@ function installPointer(er, signal) {
}
async function save(er) {
if (er.signal.aborted || er.saving || !er.refs) return;
if (er.saving || !er.refs) return;
er.saving = true;
syncTools(er);
try {
@@ -644,7 +620,6 @@ async function save(er) {
})),
});
const answer = await response.json().catch(() => null);
if (er.signal.aborted) return;
if (!response.ok) {
// No words from the route: this ComfyUI started before the update.
const missing = (response.status === 404 || response.status === 405) && !answer?.error;
@@ -653,11 +628,10 @@ async function save(er) {
showToast({ severity: "success", summary: "Image Compare", detail: savedMessage(answer?.files) });
er.onSaved?.(answer?.files ?? []);
} catch (error) {
if (er.signal.aborted) return;
showToast({ severity: "error", summary: "Image Compare", detail: String(error?.message ?? error), life: 8000 });
} finally {
er.saving = false;
if (!er.signal.aborted) syncTools(er);
syncTools(er);
}
}
@@ -665,24 +639,20 @@ async function save(er) {
// to B as it came. The panel swaps in the pictures; Save and Copy then use
// the lined-up B, and the paint stays where it is.
async function align(er) {
if (er.signal.aborted || er.aligning || !er.refs) return;
if (er.aligning || !er.refs) return;
if (er.refs.aligned) {
er.onAligned?.(er.refs.original);
return;
}
er.aligning = true;
const refs = er.refs;
const picturesVersion = er.picturesVersion;
const current = () => !er.signal.aborted && er.refs === refs && er.picturesVersion === picturesVersion;
syncTools(er);
try {
const response = await api.fetchApi(ALIGN_ROUTE, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pair: refs.pair }),
body: JSON.stringify({ pair: er.refs.pair }),
});
const answer = await response.json().catch(() => null);
if (!current()) return;
if (!response.ok) {
// No words from the route: this ComfyUI started before the update.
const missing = (response.status === 404 || response.status === 405) && !answer?.error;
@@ -690,16 +660,15 @@ async function align(er) {
}
if (answer?.aligned && answer.b?.filename && typeof answer.pair === "string") {
showToast({ severity: "success", summary: "Image Compare", detail: answer.message, life: 4000 });
er.onAligned?.({ ...refs, b: answer.b, pair: answer.pair, aligned: true, original: refs });
er.onAligned?.({ ...er.refs, b: answer.b, pair: answer.pair, aligned: true, original: er.refs });
} else {
showToast({ severity: "info", summary: "Image Compare", detail: answer?.message || "B was left as it is.", life: 6000 });
}
} catch (error) {
if (!current()) return;
showToast({ severity: "error", summary: "Image Compare", detail: String(error?.message ?? error), life: 8000 });
} finally {
er.aligning = false;
if (!er.signal.aborted) syncTools(er);
syncTools(er);
}
}
@@ -771,7 +740,7 @@ function copy(er) {
// pictures change.
export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved, onAligned }) {
const er = {
node, root, stage, imageA, imageB, signal, onSaved, onAligned,
node, root, stage, imageA, imageB, onSaved, onAligned,
canvas: document.createElement("canvas"),
mask: document.createElement("canvas"),
live: document.createElement("canvas"),
@@ -779,7 +748,7 @@ export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved,
dab: document.createElement("canvas"),
dabKey: "",
maskScale: 1,
refs: null, picturesVersion: 0, width: 0, height: 0, fit: null,
refs: null, width: 0, height: 0, fit: null,
strokes: [], history: [],
active: false, stroke: null, adjust: null, pointer: null, back: false, flipKey: false,
saving: false, copying: false, frame: 0, keys: null,
@@ -858,11 +827,6 @@ 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({
@@ -882,7 +846,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(compactAction("Undo", "Take back the last stroke, or a Clear."), () => {
er.undoButton = click(makeToolButton("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.
@@ -892,13 +856,13 @@ export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved,
syncTools(er);
schedule(er);
});
er.clearButton = click(compactAction("Clear", "Take all the paint off. Undo brings it back."), () => {
er.clearButton = click(makeToolButton("CLEAR", "Take all the paint off. Undo brings it back."), () => {
if (!er.strokes.length) return;
change(er, []);
rebuildMask(er);
schedule(er);
});
er.alignButton = click(compactAction("Align", ""), () => align(er));
er.alignButton = click(makeToolButton("ALIGN", ""), () => align(er));
steps.append(er.alignButton, er.undoButton, er.clearButton);
er.bar.append(er.kindSwitch.root, sizeField, softField, topField, steps);
@@ -992,7 +956,6 @@ export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved,
// Both pictures are loaded (or, with null, on their way). The paint is
// read from the node, and kept only while the picture keeps its size.
setPictures(refs) {
er.picturesVersion += 1;
const ready = Boolean(refs && imageA.naturalWidth > 0 && imageB.naturalWidth > 0);
er.refs = ready ? refs : null;
finishStroke(er, null, true);
-24
View File
@@ -1,24 +0,0 @@
// A click on the dark area around a modal <dialog> closes it, the way people
// expect any popup to work. The browser reports a click on a dialog's
// backdrop as a click on the dialog element itself, so a click counts as
// outside when it lands on the dialog element beyond its own box. Both the
// press and the release must be outside: a text selection or a drag that
// starts on the card and ends on the backdrop keeps the popup open.
export function pointOutside(rect, x, y) {
return x < rect.left || x > rect.right || y < rect.top || y > rect.bottom;
}
export function closeOnBackdropClick(dialog, close = () => dialog.close()) {
const outside = (event) => event.target === dialog
&& pointOutside(dialog.getBoundingClientRect(), event.clientX, event.clientY);
let pressedOutside = false;
dialog.addEventListener("pointerdown", (event) => {
pressedOutside = outside(event);
});
dialog.addEventListener("click", (event) => {
const started = pressedOutside;
pressedOutside = false;
if (started && outside(event)) close();
});
}
+1 -1
View File
@@ -2,7 +2,7 @@
// 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 { 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";
-16
View File
@@ -1,16 +0,0 @@
// 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;
}
-8
View File
@@ -93,10 +93,8 @@ export function nameAt(picked, names, position, atTheEnd) {
// 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).
// With an unknown count, "next" keeps advancing and the backend handles the end.
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 == null) return afterRun === "next" ? place + 1 : place;
if (count < 1) return place;
if (afterRun === "random") return Math.min(count, Math.floor(roll * count) + 1);
if (afterRun !== "next") return place;
@@ -126,12 +124,6 @@ export function childOf(folder, name) {
return [...String(folder ?? "").split("/").filter(Boolean), name].join("/");
}
// A relative name belongs to one folder, even when another has the same name.
export function folderContext({ source, folder, subfolders }) {
const relative = String(folder ?? "").trim().replaceAll("\\", "/").split("/").filter(part => part && part !== ".").join("/");
return JSON.stringify([source, relative, Boolean(subfolders)]);
}
// A query string. Every value is typed by a person or saved in a workflow.
export function query(params) {
return Object.entries(params)
+2 -2
View File
@@ -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.25.0";
export const AUSBOSS_JS_VERSION = "2.20.5";
export const BRAND = "#00b4aa"; // AusBoss teal — keep node accents consistent.
export const BRAND_DARK = "#007f78";
+3 -46
View File
@@ -107,59 +107,16 @@ export function outputRecordQuery(record, index = null) {
}).toString();
}
// The /view query of the small copy Save Image writes for its face
// ({ ausboss_preview: ["ausboss_preview/<name>.webp"] }, one for each entry
// of images, in the temp folder). Null when the record has none: an older
// run, another node, or the preview switched off.
export function lightPreviewQuery(record, index = null) {
const places = record?.ausboss_preview;
const count = outputRecordCount(record);
if (!count || !Array.isArray(places) || places.length !== count) return null;
const at = Number.isInteger(index) && index >= 0 && index < count ? index : count - 1;
const place = places[at];
if (typeof place !== "string" || !place) return null;
const slash = place.lastIndexOf("/");
return new URLSearchParams({
filename: place.slice(slash + 1),
subfolder: slash >= 0 ? place.slice(0, slash) : "",
type: "temp",
}).toString();
}
// How many pictures the node's last run left in its output record.
export function outputRecordCount(record) {
return Array.isArray(record?.images) ? record.images.length : 0;
}
// Is node.imgs the picture of this output record?
//
// The frontend loads a result into node.imgs with a time limit (two tries of
// about 8 seconds). A picture that takes longer, a big one over a slow link,
// is dropped without a word and node.imgs keeps the run before. `loadedFor`
// is the record that was current when node.imgs was last set; the file name
// is compared as well, for a newer run that arrived while a picture loaded.
export function ownResultIsCurrent(node, record, loadedFor) {
const images = node?.imgs;
const src = Array.isArray(images) && images.length ? images[images.length - 1]?.src : null;
if (typeof src !== "string" || !src) return false;
const wanted = outputRecordQuery(record);
// No stored picture to compare with, or a frame streamed during the run.
if (!wanted || !/[?&]filename=/.test(src)) return true;
if (loadedFor !== record) return false;
const have = new URLSearchParams(src.slice(src.indexOf("?") + 1));
const want = new URLSearchParams(wanted);
return ["filename", "subfolder", "type"].every((key) => (have.get(key) ?? "") === (want.get(key) ?? ""));
}
// What the panel shows, best first: this node's result (node.imgs, then the
// URL of its stored output), then whatever feeds it, then nothing.
// `ownIsCurrent` false: node.imgs is an older run's (ownResultIsCurrent), so
// the stored output goes first.
export function describeNodePreview(node, inputName, storedUrl = null, ownIsCurrent = true) {
const stored = storedUrl ? { kind: "url", url: storedUrl } : null;
return (ownIsCurrent ? null : stored)
?? describeOwnResult(node)
?? stored
export function describeNodePreview(node, inputName, storedUrl = null) {
return describeOwnResult(node)
?? (storedUrl ? { kind: "url", url: storedUrl } : null)
?? describeSourcePreview(upstreamNode(node, inputName));
}
-211
View File
@@ -1,211 +0,0 @@
// The row under the file picker on Load Image: arrows that step through the
// input folder, where the file is in the list ("14 / 212"), and the size the
// picture goes in at and comes out at ("912 × 1632 → 1144 × 2048"). The
// widgets underneath stay the source of truth: an arrow only changes the
// image widget's value, the way picking from the list does.
//
// Under it, the preview: the picture as it goes in with the part that is
// kept lit, and the picture as it comes out, both on one scale, with what
// the size choice does in words. While the choice cuts something, a click
// on the first picture picks the part to keep.
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { CROP_MODES, changeText, cropSideAt, keptPart, placeText, planLoadSize, shapeText, sizeText, stepFile, thumbSizes } from "./load_size.mjs";
import { mediaViewQuery } from "./media_list.mjs";
import { FONT_UI, RADIUS_CONTROL, THEME as T } from "./theme.mjs";
import { commitWidgetValue, comboValues } from "./widget_card_math.mjs";
const CSS_ID = "ausboss-load-strip-css";
// Sizes already read, by file: stepping back to a picture shows its size at once.
const SIZES_KEPT = 200;
const sizes = new Map();
function ensureCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-load-strip{display:flex;align-items:center;gap:6px;height:100%;min-width:0;font:500 11px/1 ${FONT_UI};color:${T.label}}
.ausboss-load-step{flex:none;width:26px;height:100%;padding:0;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};color:${T.inkSoft};font:600 11px/1 ${FONT_UI};cursor:pointer}
.ausboss-load-step:hover{background:${T.fillHover};color:${T.inkHover}}
.ausboss-load-step:focus-visible{outline:2px solid ${T.brand};outline-offset:1px}
.ausboss-load-step:disabled{opacity:.4;cursor:default}
.ausboss-load-place{flex:none;color:${T.faint};font-variant-numeric:tabular-nums;white-space:nowrap;user-select:none}
.ausboss-load-size{flex:1 1 0;min-width:0;overflow:hidden;text-align:right;white-space:nowrap;text-overflow:ellipsis;color:${T.inkSoft};font-weight:600;font-variant-numeric:tabular-nums}
.ausboss-load-size b{color:${T.brandInk};font-weight:700}
.ausboss-load-size i{color:${T.faint};font-style:normal;margin:0 5px}
.ausboss-load-view{display:flex;align-items:center;justify-content:center;gap:10px;height:100%;min-width:0;font:500 10px/1.25 ${FONT_UI};color:${T.faint}}
.ausboss-load-thumb{position:relative;flex:none;overflow:hidden;border-radius:2px;background-color:${T.fill};background-repeat:no-repeat}
.ausboss-load-thumb.in{background-size:100% 100%}
.ausboss-load-thumb.in.pick{cursor:pointer}
.ausboss-load-thumb.out{outline:1px solid ${T.brand};outline-offset:0}
.ausboss-load-kept{position:absolute;box-sizing:border-box;border:1px solid ${T.brand};box-shadow:0 0 0 400px rgba(8,10,11,.68);pointer-events:none}
.ausboss-load-note{flex:0 1 auto;min-width:0;display:flex;flex-direction:column;align-items:center;gap:2px;text-align:center;user-select:none}
.ausboss-load-note b{color:${T.inkSoft};font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}
.ausboss-load-note span{white-space:nowrap}
`;
document.head.append(style);
}
const viewUrl = (value) => api.apiURL(`/view?${mediaViewQuery(value)}`);
// The picture's own size, read from the file the way the browser shows it.
function readSize(file, done) {
const known = sizes.get(file);
if (known) return known;
const picture = new Image();
picture.onload = () => {
if (sizes.size >= SIZES_KEPT) sizes.delete(sizes.keys().next().value);
sizes.set(file, { width: picture.naturalWidth, height: picture.naturalHeight });
done();
};
picture.src = viewUrl(file);
return null;
}
// The widest and tallest a preview picture is drawn.
const THUMB_WIDTH = 76;
const THUMB_HEIGHT = 68;
export function loadImagePreview(node) {
ensureCss();
const root = document.createElement("div");
root.className = "ausboss-load-view";
const input = document.createElement("div");
input.className = "ausboss-load-thumb in";
const kept = document.createElement("div");
kept.className = "ausboss-load-kept";
input.append(kept);
const note = document.createElement("div");
note.className = "ausboss-load-note";
const output = document.createElement("div");
output.className = "ausboss-load-thumb out";
root.append(input, note, output);
let last = {};
const side = () => node.widgets?.find((item) => item.name === "crop_from") ?? null;
const sideLinked = () => node.inputs?.find((slot) => slot?.widget?.name === "crop_from")?.link != null;
input.addEventListener("click", (event) => {
const widget = side();
if (!widget || !input.classList.contains("pick")) return;
const box = input.getBoundingClientRect();
if (!(box.width > 0 && box.height > 0)) return;
const next = cropSideAt((event.clientX - box.left) / box.width, (event.clientY - box.top) / box.height);
if (next === widget.value) return;
commitWidgetValue(node, widget, next, app.canvas);
node.setDirtyCanvas?.(true, true);
});
const line = (tag, text) => {
const item = document.createElement(tag);
item.textContent = text;
return item;
};
const sync = (values = last) => {
last = values;
const file = String(values.image ?? "");
const source = file ? readSize(file, () => { if (String(last.image ?? "") === file) sync(); }) : null;
const plan = source ? planLoadSize(source.width, source.height, values) : null;
root.style.visibility = plan ? "" : "hidden";
if (!plan) return;
const picture = `url(${JSON.stringify(viewUrl(file))})`;
const sizes = thumbSizes(source.width, source.height, plan, THUMB_WIDTH, THUMB_HEIGHT);
const part = keptPart(plan);
Object.assign(input.style, { width: `${sizes.input[0]}px`, height: `${sizes.input[1]}px`, backgroundImage: picture });
Object.assign(kept.style, {
left: `${part.x * 100}%`, top: `${part.y * 100}%`,
width: `${part.width * 100}%`, height: `${part.height * 100}%`,
});
const spareWide = plan.resizeWidth - plan.width;
const spareHigh = plan.resizeHeight - plan.height;
Object.assign(output.style, {
width: `${sizes.output[0]}px`, height: `${sizes.output[1]}px`, backgroundImage: picture,
backgroundSize: `${(plan.resizeWidth / plan.width) * 100}% ${(plan.resizeHeight / plan.height) * 100}%`,
backgroundPosition: `${spareWide > 0 ? (plan.cropX / spareWide) * 100 : 0}% ${spareHigh > 0 ? (plan.cropY / spareHigh) * 100 : 0}%`,
});
const picks = CROP_MODES.includes(values.size) && !sideLinked();
input.classList.toggle("pick", picks);
input.title = picks
? "The picture going in. The lit part is kept. Click the part you want to keep."
: "The picture going in. The lit part is kept.";
output.title = "The picture coming out.";
const from = shapeText(source.width, source.height);
const to = shapeText(plan.width, plan.height);
const change = changeText(source.width, source.height, plan);
note.replaceChildren(
line("b", from === to ? from : `${from} → ${to}`),
...(change ? change.split(" · ").map((text) => line("span", text)) : [line("span", "no change")]),
);
};
return { element: root, sync };
}
export function loadImageStrip(node) {
ensureCss();
const root = document.createElement("div");
root.className = "ausboss-load-strip";
const widget = () => node.widgets?.find((item) => item.name === "image") ?? null;
const linked = () => {
const slot = node.inputs?.find((input) => input?.widget?.name === "image" || input?.name === "image");
return slot?.link != null;
};
let last = {};
const step = (delta, label, title) => {
const button = document.createElement("button");
button.type = "button";
button.className = "ausboss-load-step";
button.textContent = label;
button.title = title;
button.addEventListener("click", () => {
const image = widget();
if (!image) return;
const next = stepFile(comboValues(image), image.value, delta);
if (next === null || next === image.value) return;
commitWidgetValue(node, image, next, app.canvas);
node.setDirtyCanvas?.(true, true);
});
root.append(button);
return button;
};
const back = step(-1, "◀", "The picture before this one in the input folder");
const on = step(1, "▶", "The next picture in the input folder");
const place = document.createElement("span");
place.className = "ausboss-load-place";
place.title = "Where this picture is in the input folder's list";
const size = document.createElement("span");
size.className = "ausboss-load-size";
root.append(place, size);
const sync = (values = last) => {
last = values;
const image = widget();
const files = comboValues(image);
const file = String(values.image ?? "");
place.textContent = placeText(files, file);
const stuck = linked() || stepFile(files, file, 1) === null;
back.disabled = stuck;
on.disabled = stuck;
const source = file ? readSize(file, () => { if (String(last.image ?? "") === file) sync(); }) : null;
size.replaceChildren();
if (!source) {
size.title = "";
return;
}
const plan = planLoadSize(source.width, source.height, values);
const text = sizeText(source.width, source.height, plan);
const [from, to] = text.split(" → ");
size.append(from);
if (to) {
const arrow = document.createElement("i");
arrow.textContent = "→";
const out = document.createElement("b");
out.textContent = to;
size.append(arrow, out);
}
size.title = to
? `The file is ${from}. The picture comes out at ${to}.`
: `The picture comes out at its own size, ${from}.`;
};
return { element: root, sync };
}
-206
View File
@@ -1,206 +0,0 @@
// Load Image: the size a picture comes out at, worked out in the browser so
// the node can show it before a run. This repeats plan_load_size in
// nodes/_load_image_helpers.py; tests/fixtures/load_size_cases.json holds
// both to the same answers. No DOM in here.
export const SIZE_MODES = Object.freeze(["off", "megapixels", "longest side", "scale", "fit inside", "crop to fill", "crop", "crop to ratio"]);
// Which part of the picture a crop keeps, as a 3 x 3 grid read row by row.
export const CROP_GRID = Object.freeze(["top left", "top", "top right", "left", "center", "right", "bottom left", "bottom", "bottom right"]);
// The choices that cut something off, so the kept part matters.
export const CROP_MODES = Object.freeze(["crop to fill", "crop", "crop to ratio"]);
const roundHalfUp = (value) => Math.floor(value + 0.5);
const whole = (value, fallback) => (Number.isFinite(Number(value)) ? Math.trunc(Number(value)) : fallback);
// Nearest multiple, never below one step.
const nearest = (value, step) => Math.max(step, roundHalfUp(value / step) * step);
// The largest multiple that is not above the value, never below one step.
const below = (value, step) => Math.max(step, Math.floor(value / step) * step);
// Rounded down to a multiple, or left alone when smaller than one step.
const inside = (value, step) => (value >= step ? Math.floor(value / step) * step : value);
// Where a cut starts along one edge with `spare` pixels to lose.
const cropStart = (spare, side, low, high) => (side.includes(low) ? 0 : side.includes(high) ? spare : Math.floor(spare / 2));
function ratioTerms(ratio, width, height) {
if (ratio === "width : height") return [Math.max(1, width), Math.max(1, height)];
const [across, down] = String(ratio ?? "1:1").split(":").map((term) => Math.trunc(Number(term)));
return [Math.max(1, across || 1), Math.max(1, down || 1)];
}
function target(sourceW, sourceH, size, { megapixels, longest_side: longestSide, scale, width, height }) {
const scaled = (factor) => [Math.max(1, roundHalfUp(sourceW * factor)), Math.max(1, roundHalfUp(sourceH * factor))];
if (size === "megapixels") {
const amount = Number(megapixels);
return amount > 0 ? scaled(Math.sqrt((amount * 1e6) / (sourceW * sourceH))) : [sourceW, sourceH];
}
if (size === "longest side") {
const edge = whole(longestSide, 0);
if (edge <= 0) return [sourceW, sourceH];
const [w, h] = scaled(edge / Math.max(sourceW, sourceH));
return sourceW >= sourceH ? [edge, h] : [w, edge];
}
if (size === "scale") {
const factor = Number(scale);
return factor > 0 ? scaled(factor) : [sourceW, sourceH];
}
const fit = Math.min(Math.max(1, whole(width, 1)) / sourceW, Math.max(1, whole(height, 1)) / sourceH);
return scaled(fit);
}
// { resizeWidth, resizeHeight, cropX, cropY, width, height } for a picture of
// sourceWidth x sourceHeight and the node's values, or null when the picture
// has no size yet or the choice is not one the node knows.
export function planLoadSize(sourceWidth, sourceHeight, values = {}) {
const sourceW = whole(sourceWidth, 0);
const sourceH = whole(sourceHeight, 0);
const size = values.size ?? "off";
if (sourceW <= 0 || sourceH <= 0 || !SIZE_MODES.includes(size)) return null;
const same = { resizeWidth: sourceW, resizeHeight: sourceH, cropX: 0, cropY: 0, width: sourceW, height: sourceH };
if (size === "off") return same;
const step = Math.max(1, whole(values.multiple, 1));
const enlarge = values.enlarge !== false;
const boxW = Math.max(1, whole(values.width, 1024));
const boxH = Math.max(1, whole(values.height, 1024));
const side = CROP_GRID.includes(values.crop_from) ? values.crop_from : "center";
const cut = (resizeWidth, resizeHeight, width, height) => ({
resizeWidth, resizeHeight,
cropX: cropStart(resizeWidth - width, side, "left", "right"),
cropY: cropStart(resizeHeight - height, side, "top", "bottom"),
width, height,
});
if (size === "crop") {
return cut(sourceW, sourceH, inside(Math.min(boxW, sourceW), step), inside(Math.min(boxH, sourceH), step));
}
if (size === "crop to ratio") {
const [across, down] = ratioTerms(values.ratio, boxW, boxH);
let width = sourceW;
let height = sourceH;
if (sourceW * down >= sourceH * across) width = Math.min(sourceW, Math.max(1, roundHalfUp((sourceH * across) / down)));
else height = Math.min(sourceH, Math.max(1, roundHalfUp((sourceW * down) / across)));
return cut(sourceW, sourceH, inside(width, step), inside(height, step));
}
if (size === "crop to fill") {
let width = boxW;
let height = boxH;
if (enlarge) {
width = nearest(width, step);
height = nearest(height, step);
} else {
const grow = Math.max(width / sourceW, height / sourceH);
if (grow > 1) {
width = Math.max(1, Math.floor(width / grow + 1e-6));
height = Math.max(1, Math.floor(height / grow + 1e-6));
}
width = Math.min(below(width, step), Math.max(step, sourceW));
height = Math.min(below(height, step), Math.max(step, sourceH));
}
const cover = Math.max(width / sourceW, height / sourceH);
const resizeWidth = Math.max(width, Math.ceil(sourceW * cover - 1e-6));
const resizeHeight = Math.max(height, Math.ceil(sourceH * cover - 1e-6));
return cut(resizeWidth, resizeHeight, width, height);
}
let [targetW, targetH] = target(sourceW, sourceH, size, { ...values, width: boxW, height: boxH });
if (!enlarge && (targetW > sourceW || targetH > sourceH)) [targetW, targetH] = [sourceW, sourceH];
const rounded = (wanted, source, box) => {
let value = nearest(wanted, step);
if (size === "fit inside" && value > box) value = below(box, step);
if (!enlarge && value > source) value = below(source, step);
return value;
};
const width = rounded(targetW, sourceW, boxW);
const height = rounded(targetH, sourceH, boxH);
return { resizeWidth: width, resizeHeight: height, cropX: 0, cropY: 0, width, height };
}
// "912 × 1632" for a picture that comes out as it went in, "912 × 1632 →
// 1144 × 2048" when its size changes, "" while the picture's size is unknown.
export function sizeText(sourceWidth, sourceHeight, plan) {
if (!(sourceWidth > 0 && sourceHeight > 0)) return "";
const from = `${sourceWidth} × ${sourceHeight}`;
if (!plan || (plan.width === sourceWidth && plan.height === sourceHeight)) return from;
return `${from} → ${plan.width} × ${plan.height}`;
}
// What a plan does to the picture, in a few words for the node: how much it
// is resized and how much is cut off, in the file's own pixels. "" when the
// picture comes out as it went in.
export function changeText(sourceWidth, sourceHeight, plan) {
if (!plan || !(sourceWidth > 0 && sourceHeight > 0)) return "";
const parts = [];
const percent = Math.round((plan.resizeWidth / sourceWidth) * 100);
if (plan.resizeWidth !== sourceWidth || plan.resizeHeight !== sourceHeight) parts.push(`${percent}% size`);
const cutWide = Math.round(((plan.resizeWidth - plan.width) * sourceWidth) / plan.resizeWidth);
const cutHigh = Math.round(((plan.resizeHeight - plan.height) * sourceHeight) / plan.resizeHeight);
if (cutWide > 0 && cutHigh > 0) parts.push(`cuts ${cutWide} × ${cutHigh} px`);
else if (cutWide > 0) parts.push(`cuts ${cutWide} px of width`);
else if (cutHigh > 0) parts.push(`cuts ${cutHigh} px of height`);
return parts.join(" · ");
}
const SHAPES = [[1, 1], [5, 4], [4, 3], [3, 2], [16, 10], [16, 9], [2, 1], [21, 9]];
// A picture's shape in words people use: "3:4", "16:9". A size within one
// percent of a common shape gets that name (rounding to a multiple moves a
// 16:9 picture a few pixels off it); any other gets "~1.34:1" or "~1:1.34".
export function shapeText(width, height) {
if (!(width > 0 && height > 0)) return "";
const wide = width >= height;
const value = wide ? width / height : height / width;
const near = SHAPES.find(([across, down]) => Math.abs(value - across / down) <= (across / down) * 0.01);
if (near) return wide ? `${near[0]}:${near[1]}` : `${near[1]}:${near[0]}`;
const rounded = Math.round(value * 100) / 100;
return wide ? `~${rounded}:1` : `~1:${rounded}`;
}
// The part of the file a plan keeps, as fractions of the file's width and
// height: { x, y, width, height }, all 0 to 1.
export function keptPart(plan) {
if (!plan) return { x: 0, y: 0, width: 1, height: 1 };
return {
x: plan.cropX / plan.resizeWidth,
y: plan.cropY / plan.resizeHeight,
width: Math.min(1, plan.width / plan.resizeWidth),
height: Math.min(1, plan.height / plan.resizeHeight),
};
}
// The sizes two small pictures are drawn at so their difference shows: both
// on one scale, the larger filling a box of maxWidth x maxHeight, and neither
// edge under `least` pixels.
export function thumbSizes(sourceWidth, sourceHeight, plan, maxWidth, maxHeight, least = 10) {
const outW = plan?.width ?? sourceWidth;
const outH = plan?.height ?? sourceHeight;
const scale = Math.min(maxWidth / Math.max(sourceWidth, outW), maxHeight / Math.max(sourceHeight, outH));
const at = (value) => Math.max(least, Math.round(value * scale));
return { input: [at(sourceWidth), at(sourceHeight)], output: [at(outW), at(outH)] };
}
// The crop side under a click at (x, y), each 0 to 1 across the picture.
export function cropSideAt(x, y) {
const third = (value) => (value < 1 / 3 ? 0 : value < 2 / 3 ? 1 : 2);
return CROP_GRID[third(y) * 3 + third(x)];
}
// The file one step before or after the current one in the list, going
// round at the ends. A file that is not in the list steps to the first or
// the last. null when the list has nothing to step to.
export function stepFile(files, current, delta) {
const list = (Array.isArray(files) ? files : []).filter((name) => name !== "" && name != null);
if (!list.length) return null;
const at = list.indexOf(current);
if (at < 0) return delta < 0 ? list[list.length - 1] : list[0];
if (list.length === 1) return null;
return list[(at + (delta < 0 ? -1 : 1) + list.length) % list.length];
}
// "14 / 212": where the current file is in the list. "" for an empty list,
// "– / 212" for a file that is not in it.
export function placeText(files, current) {
const list = (Array.isArray(files) ? files : []).filter((name) => name !== "" && name != null);
if (!list.length) return "";
const at = list.indexOf(current);
return `${at < 0 ? "–" : at + 1} / ${list.length}`;
}
+11 -7
View File
@@ -12,6 +12,7 @@ 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;
@@ -39,7 +40,7 @@ export function newRow() {
export function normalizeRows(parsed) {
if (!Array.isArray(parsed)) return [];
return tidyGroups(parsed.map((entry) => {
return tidyGroups(parsed.slice(0, MAX_ROWS).map((entry) => {
const row = newRow();
if (entry && typeof entry === "object") {
row.id = typeof entry.id === "string" && entry.id ? entry.id : row.id;
@@ -599,8 +600,9 @@ export function loraPickerEntries(names, query = "", collapsedFolders = new Set(
return visit(tree);
}
// Append the folder in one step, preserving duplicates, disabled rows and
// empty slots already in the stack. Deduplicate new files by full path.
// 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.
export function appendFolderRows(rows, names, defaultStrength = 1) {
const seen = new Set(rows.map((row) => loraPath(row.name)));
const pending = [];
@@ -611,10 +613,12 @@ export function appendFolderRows(rows, names, defaultStrength = 1) {
seen.add(key);
pending.push(name);
}
if (!pending.length) return { rows, added: 0, skipped };
const room = Math.max(0, MAX_ROWS - rows.length);
const needed = pending.length;
if (needed > room || !needed) return { rows, added: 0, skipped, needed, room };
const strength = roundStrength(defaultStrength);
const addedRows = pending.map((name) => ({ ...newRow(), name, strength, strength_clip: strength }));
return { rows: [...rows, ...addedRows], added: addedRows.length, skipped };
return { rows: [...rows, ...addedRows], added: needed, skipped, needed, room };
}
// A suggested range is advisory: null/absent bounds never flag.
@@ -1027,9 +1031,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 ?? [])];
: [...added, ...(existing ?? [])]).slice(0, MAX_ROWS);
return { rows, added: added.length, skipped };
}
+1 -1
View File
@@ -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\]$/, "");
const bare = text.replace(/\s+\[(input|output)\]$/, "");
return values.includes(text) || values.includes(bare) ? "" : text;
}
+15 -27
View File
@@ -2,9 +2,8 @@
// 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 { 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";
@@ -48,6 +47,16 @@ function ensureCss() {
document.head.append(style);
}
// A bypassed or muted source doesn't reach the node when it runs, so it doesn't count.
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;
}
function wired(node, name) {
return linkSource(node, name) !== null;
}
@@ -84,7 +93,7 @@ export function momentStrip(node) {
})),
cameras: choices("camera"),
looks: choices("look"),
seconds: clipSetsDuration(node, lastValues) ? 0 : Number(lastSeconds) || 8,
seconds: Number(lastSeconds) || 8,
}));
let lastValues = {};
@@ -266,38 +275,17 @@ export function momentStrip(node) {
body: JSON.stringify({ script, seconds, jobs, video }),
});
const report = response.ok ? await response.json() : null;
if (turn === asked && !clipSetsDuration(node, lastValues)) show(report);
if (turn === asked) show(report);
} catch {
if (turn === asked && !clipSetsDuration(node, lastValues)) show(null);
if (turn === asked) 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);
-13
View File
@@ -1,13 +0,0 @@
// 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 };
}
-72
View File
@@ -1,72 +0,0 @@
// Shared controls for compact node panels. Only elements explicitly given one
// of these classes are styled; the owning panel decides where they sit.
import { FONT_UI, RADIUS_XS, THEME as T } from "./theme.mjs";
export const PANEL_LABELS = Object.freeze({
load: "Load", sources: { input: "Inputs", output: "Outputs" },
embed: "Embed workflow", off: "Off", on: "On",
});
const controlFont = FONT_UI;
export const CAPTION_TYPE = `font:600 11px/1.25 ${controlFont};color:${T.label};`;
export const HINT_TYPE = `font:400 10px/1.25 ${controlFont};color:${T.faint};`;
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 transparent;border-radius:${RADIUS_XS}px;background:${T.fill};color:${T.inkSoft};font:600 11px/1 ${controlFont};white-space:nowrap;cursor:pointer;pointer-events:auto}
.ausboss-compact-action:hover:enabled{color:${T.ink};background:${T.fillHover}}
.ausboss-compact-action.active{color:${T.brandInk};background:${T.brandSoft}}
.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:${T.label};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:${T.switchOff}}
.ausboss-panel-switch i::after{content:"";position:absolute;top:2px;left:2px;width:9px;height:9px;border-radius:50%;background:${T.inkSoft}}
.ausboss-panel-switch[aria-checked=true] i{background:${T.brand}}
.ausboss-panel-switch[aria-checked=true] i::after{left:13px;background:${T.inkHover}}
.ausboss-primary-action{box-sizing:border-box;flex:none;height:22px;min-width:24px;padding:0 14px;border:0;border-radius:${RADIUS_XS}px;background:${T.brand};color:${T.onBrand};font:700 11px/1 ${controlFont};white-space:nowrap;cursor:pointer;pointer-events:auto}
.ausboss-primary-action:hover:enabled{filter:brightness(1.12)}
.ausboss-primary-action.quiet{background:${T.fill};color:${T.inkSoft}}
.ausboss-primary-action:disabled{opacity:.6;cursor:default}
.ausboss-compact-action:focus-visible,.ausboss-primary-action:focus-visible,.ausboss-panel-switch:focus-visible{outline:2px solid ${T.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;
}
// The one thing a panel wants done next, as a solid button: Save Image's
// "Save picture" over a picture that is not saved yet. `quiet` (a class the
// panel sets) is the same button once it has nothing left to do.
export function primaryAction(label, title = "") {
ensurePanelControls();
const button = document.createElement("button");
button.type = "button";
button.className = "ausboss-primary-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;
}
+8 -9
View File
@@ -3,7 +3,7 @@
// steps through its pairs with it, and the preview panel of Save Image and
// the other preview nodes through their results. It hides itself while
// there is one picture or none, so a single picture looks as it always did.
import { FONT_UI, RADIUS_CONTROL, RADIUS_XS, THEME as T } from "./theme.mjs";
import { BRAND } from "./index.mjs";
import { stepperLabel } from "./picture_steps.mjs";
const CSS_ID = "ausboss-picture-stepper-v1";
@@ -13,12 +13,12 @@ function ensureCss() {
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-stepper{box-sizing:border-box;flex:none;display:flex;align-items:stretch;height:var(--ausboss-stepper-height,24px);padding:1px;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};pointer-events:auto;user-select:none;}
.ausboss-stepper{box-sizing:border-box;flex:none;display:flex;align-items:stretch;height:var(--ausboss-stepper-height,24px);padding:1px;border:1px solid #2a3437;border-radius:6px;background:#0f1516;pointer-events:auto;user-select:none;}
.ausboss-stepper[hidden]{display:none;}
.ausboss-stepper button{box-sizing:border-box;width:20px;margin:0;padding:0;border:none;border-radius:${RADIUS_XS}px;background:transparent;color:${T.label};font:600 13px/1 ${FONT_UI};cursor:pointer;}
.ausboss-stepper button:hover{color:${T.ink};background:${T.fillHover};}
.ausboss-stepper button:focus-visible{outline:1px solid ${T.brand};outline-offset:-1px;}
.ausboss-stepper span{min-width:34px;padding:0 3px;color:${T.ink};font:600 10px/1 ${FONT_UI};font-variant-numeric:tabular-nums;text-align:center;white-space:nowrap;align-self:center;}
.ausboss-stepper button{box-sizing:border-box;width:20px;margin:0;padding:0;border:none;border-radius:4px;background:transparent;color:#8ba3a1;font:700 13px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;}
.ausboss-stepper button:hover{color:#fff;background:rgba(0,79,75,.78);}
.ausboss-stepper button:focus-visible{outline:1px solid ${BRAND};outline-offset:-1px;}
.ausboss-stepper span{min-width:34px;padding:0 3px;color:#d8eeee;font:700 10px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-variant-numeric:tabular-nums;text-align:center;white-space:nowrap;align-self:center;}
`;
document.head.appendChild(style);
}
@@ -52,10 +52,9 @@ export function makePictureStepper({ noun = "picture", height = 24, onStep, sign
root.append(arrow("‹", -1, `Previous ${noun}`), label, arrow("›", 1, `Next ${noun}`));
return {
root,
// `text` replaces the "2 / 5" for a stop that is not one picture.
set(index, count, text) {
set(index, count) {
root.hidden = !(count > 1);
label.textContent = text ?? stepperLabel(index, count);
label.textContent = stepperLabel(index, count);
},
};
}
-72
View File
@@ -19,75 +19,3 @@ export function stepIndex(index, count, step) {
export function stepperLabel(index, count) {
return count > 1 ? `${keepIndex(index, count) + 1} / ${count}` : "";
}
// The preview panel has one more stop, before picture 1: every picture at
// once. ALL stands for it where an index would be.
export const ALL = -1;
// One step back or on through "all, 1, 2 ... count", round at both ends.
export function stepWithAll(index, count, step) {
if (!(count > 0)) return ALL;
const stops = count + 1;
const at = index === ALL ? 0 : keepIndex(index, count) + 1;
const next = (((at + Math.sign(Number(step) || 0)) % stops) + stops) % stops;
return next === 0 ? ALL : next - 1;
}
export function allLabel(count) {
return count > 1 ? `all ${count}` : "";
}
// How to lay `count` pictures out in a box so each one draws as big as it
// can: the columns and rows that leave a picture of this shape (`aspect`,
// width over height) the most room.
export function gridShape(count, width, height, aspect = 1) {
const total = Math.max(1, Math.trunc(Number(count) || 0));
const shape = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
if (!(width > 0) || !(height > 0)) {
const columns = Math.ceil(Math.sqrt(total));
return { columns, rows: Math.ceil(total / columns) };
}
let best = { columns: 1, rows: total, size: 0 };
for (let columns = 1; columns <= total; columns += 1) {
const rows = Math.ceil(total / columns);
const size = Math.min(width / columns, (height / rows) * shape);
if (size > best.size) best = { columns, rows, size };
}
return { columns: best.columns, rows: best.rows };
}
// The held pictures in a Save Image result, in the order they show: each
// one's run token and its place in that run. A list run saves once for each
// item, so one result can hold several tokens. Empty when the result holds
// none, or when a picture in it is not one of the held ones.
export function heldPlaces(record) {
const tokens = new Set(Array.isArray(record?.ausboss_held) ? record.ausboss_held : []);
if (!tokens.size) return [];
const held = (Array.isArray(record.images) ? record.images : []).map((image) => {
const match = /^([0-9a-f]{16})_(\d{3})\./.exec(String(image?.filename ?? ""));
return match && tokens.has(match[1]) ? { token: match[1], index: Number(match[2]) } : null;
});
return held.length && held.every(Boolean) ? held : [];
}
// What the row over held pictures says, and what its button does.
// held how many pictures the node holds
// kept how many of them are saved already
// all a click keeps every picture (one picture, the grid, preview off)
// done nothing is left to keep where the click would act
// saving a save is on its way
// name the file a single picture was saved as, when known
// `quiet` is the button once it has nothing left to do.
export function keepWords({ held = 0, kept = 0, all = true, done = false, saving = false, name = "" } = {}) {
const note = !kept
? "Not saved yet"
: held < 2
? (name ? `Saved as ${name}` : "Saved")
: kept >= held ? `All ${held} saved` : `${kept} of ${held} saved`;
if (saving) return { note, button: "Saving...", quiet: false };
if (done) return { note, button: "Saved", quiet: true };
const button = held < 2
? "Save picture"
: !all ? "Save this one" : kept ? "Save the rest" : `Save all ${held}`;
return { note, button, quiet: false };
}
+7 -59
View File
@@ -129,17 +129,13 @@ export function ratioSizeNear(ratio, width, height, step = 1) {
// modifiers: snap (default true; false = 1px freedom),
// lockRatio (true = keep the grab-time ratio exactly).
// 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 };
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));
}
const HANDLE_AXES = {
@@ -410,51 +406,3 @@ 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 }) : "";
}
-49
View File
@@ -37,7 +37,6 @@ export const SOURCE_LOADERS = Object.freeze({
LoadImageOutput: picture("image"),
LoadVideo: video("file"),
AUSBOSS_NODES_ImageCropRotatePad: picture("image"),
AUSBOSS_NODES_LoadImage: 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"),
@@ -161,51 +160,3 @@ export function collapseRepeatedErrors(nodeErrors) {
}
return nodeErrors;
}
// ------------------------------------------------------------ known run errors
//
// Some errors that stop a run come from outside the workflow and say nothing
// a person can act on ("KeyError: 'latent'"). Each entry here names one such
// error by what is in ComfyUI's own report, and says in plain words what to
// do. An entry is added only after the cause was found for real.
const KNOWN_RUN_ERRORS = Object.freeze([
{
// Muse MiniMax Director replaces part of H3 for every workflow, and that
// part reads a picture from every guide. ComfyUI's own H3 nodes also make
// guides that hold sound alone, which is how a clip keeps its sound.
matches: ({ type, message, traceback }) =>
type === "KeyError" && message.includes("'latent'") && traceback.includes("muse_minimax_hybrid_patch"),
summary: "Another node pack stopped this run",
detail:
"This error comes from the Muse MiniMax Director pack, not from this workflow. Turn that pack off in "
+ "Manager, restart ComfyUI and run again.",
},
{
// The w6a8 H3 files load from ComfyUI 0.38.0 on; older versions stop in
// the model loader on the name of the format.
matches: ({ message }) => message.includes("w6a8_int8"),
summary: "This model needs a newer ComfyUI",
detail:
"Your ComfyUI is too old to read this model file. Update ComfyUI to 0.38 or newer and run again.",
},
]);
/**
* Plain advice for a run error the pack knows the cause of, from the
* `execution_error` event's detail. `null` for every other error.
*/
export function explainRunError(detail) {
const report = {
type: String(detail?.exception_type ?? "").split(".").pop(),
message: String(detail?.exception_message ?? ""),
traceback: [].concat(detail?.traceback ?? []).join("\n"),
};
for (const known of KNOWN_RUN_ERRORS) {
try {
if (known.matches(report)) return { summary: known.summary, detail: known.detail };
} catch (_error) {
// A report in a shape this entry did not expect is simply not a match.
}
}
return null;
}
+11 -9
View File
@@ -25,8 +25,10 @@
// Shift's fine step and typed values are never snapped.
import { keepKeyInField } from "./canvas_passthrough.mjs";
// theme.mjs has no imports, so this module's math stays testable in node:test.
import { FONT_UI, RADIUS_CONTROL, THEME as T } from "./theme.mjs";
// Keep in sync with BRAND in shared/index.mjs - importing it would pull
// /scripts/app.js into node:test, and this module's math must stay testable.
const BRAND = "#00b4aa";
// Horizontal pixels of drag per step; small enough to feel light, large
// enough that a shaky click cannot change the value. The dead zone keeps a
@@ -179,19 +181,19 @@ function ensureScrubCss() {
// for one) carry blanket `section input` rules, and these must outrank
// them wherever the control is mounted.
style.textContent = `
.ausboss-scrub{display:flex;width:72px;height:24px;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;
background:${T.fill};overflow:hidden;flex:none;color:${T.ink};font:500 12px ${FONT_UI};font-variant-numeric:tabular-nums}
.ausboss-scrub:focus-within{border-color:${T.brand}}
.ausboss-scrub{display:flex;width:72px;height:24px;border:1px solid #3a4047;border-radius:5px;
background:#23272c;overflow:hidden;flex:none;color:#d7dde2;font:12px system-ui}
.ausboss-scrub:focus-within{border-color:${BRAND}}
.ausboss-scrub>.ausboss-scrub-input{flex:1 1 auto;min-width:0;width:100%;height:100%;border:none;
padding:0;margin:0;background:transparent;color:inherit;text-align:center;cursor:ew-resize;
user-select:none;font:inherit;outline:none;border-radius:0}
.ausboss-scrub>.ausboss-scrub-input:focus{cursor:text;user-select:text}
.ausboss-scrub>.ausboss-scrub-unit{flex:none;align-self:center;padding:0 3px 0 1px;color:${T.faint};font:600 10px ${FONT_UI};letter-spacing:.04em;text-transform:uppercase;text-align:center;user-select:none;pointer-events:none}
.ausboss-scrub>.ausboss-scrub-unit{flex:none;align-self:center;padding:0 3px 0 1px;color:#5f7674;font-size:9.5px;letter-spacing:.06em;text-transform:uppercase;text-align:center;user-select:none;pointer-events:none}
.ausboss-scrub>.ausboss-scrub-step{flex:none;width:14px;display:flex;flex-direction:column;
border-left:1px solid transparent;background:${T.fillHover}}
border-left:1px solid #3a4047}
.ausboss-scrub>.ausboss-scrub-step>button{flex:1 1 0;min-height:0;border:none;background:transparent;
color:${T.label};cursor:pointer;padding:0;margin:0;display:grid;place-items:center;border-radius:0}
.ausboss-scrub>.ausboss-scrub-step>button:hover{color:${T.brand};background:rgba(255,255,255,.05)}
color:#9ba2aa;cursor:pointer;padding:0;margin:0;display:grid;place-items:center;border-radius:0}
.ausboss-scrub>.ausboss-scrub-step>button:hover{color:${BRAND};background:rgba(255,255,255,.05)}
`;
document.head.append(style);
}
-61
View File
@@ -1,61 +0,0 @@
// Seed 🆎's two looks, picked from the gear on its card: the full card
// (the default), or "compact", one short row with the number, a mode chip
// and a new-seed button. The look is kept in the node's properties, so the
// node's inputs, saved values and the prompt it sends are the same in both,
// and the last look picked is what new Seed nodes start with.
//
// Data and decisions only (tests/seed_look.test.mjs); js/seed/index.js
// draws the card.
export const SEED_LOOK_PROPERTY = "ausboss_seed_look";
export const SEED_LOOKS = ["full", "compact"];
export const SEED_LOOK_SCOPE = "seed_look";
export function seedLook(node) {
return node?.properties?.[SEED_LOOK_PROPERTY] === "compact" ? "compact" : "full";
}
// The full card leaves no property behind, so a node set back to it is
// saved exactly as one that was never changed.
export function setSeedLook(node, look) {
if (!node) return "full";
node.properties ??= {};
if (look === "compact") node.properties[SEED_LOOK_PROPERTY] = "compact";
else delete node.properties[SEED_LOOK_PROPERTY];
return seedLook(node);
}
// The gear menu (js/shared/settings_menu.mjs). The pick is also kept as the
// look new Seed nodes start with.
export const SEED_LOOK_MENU = [
{
key: "look",
label: "Look",
type: "choice",
options: SEED_LOOKS,
default: "full",
hint: "How this Seed node looks. New Seed nodes start the same way.",
optionHints: {
full: "the whole card: mode buttons, New seed, Use last run, and the seeds it ran with.",
compact: "one short row: the number, a mode chip you tap to change, and a dice for a new seed.",
},
},
];
// The compact row's chip: tap to go Random -> Fixed -> Step +1 -> Step -1
// -> Random. Values are the frontend's control_after_generate choices.
const CYCLE = ["randomize", "fixed", "increment", "decrement"];
export function nextControl(control) {
const index = CYCLE.indexOf(control);
return CYCLE[(index + 1) % CYCLE.length] ?? "fixed";
}
export function controlChip(control) {
switch (control) {
case "randomize": return { text: "Random", title: "Rolls a new seed after each run. Tap for Fixed." };
case "increment": return { text: "+1", title: "Adds 1 after each run. Tap for -1." };
case "decrement": return { text: "−1", title: "Takes 1 off after each run. Tap for Random." };
default: return { text: "Fixed", title: "Keeps this seed for every run. Tap for +1." };
}
}

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