Compare commits

...
Author SHA1 Message Date
ausboss 2285166ef1 Keep compact note tiles at their chosen size 2026-11-06 19:08:21 -06:00
ausboss cb6b947f75 Fix shared note tiles and H3 video edit controls 2026-11-06 18:46:32 -06:00
ausboss 28c4f6aeb5 Add manual Registry publishing for Model Downloader (#292) 2026-11-06 15:11:41 -06:00
ausboss 3a4ce52760 Release 2.21.1
Update release metadata and notes for the repository-hosted README badge fix.
2026-11-06 11:48:17 -06:00
ausboss e761b3dd0b Serve the release badge from the repository (#290)
Keep the release badge available from GitHub and reject stale generated badge versions during release preflight.
2026-11-05 20:28:47 -06:00
ausboss 3b0baf351a Release 2.21.0 and keep GitHub releases current (#289)
Publish matching pack and frontend versions, release notes, and automatic GitHub Releases after Registry uploads. Point the README badge at the latest release.
2026-11-05 20:19:37 -06:00
ausboss 50389b0cdb Improve crop controls, playback, and media selection (#288)
* Group crop controls and put video scrubbing below the preview

* Stop IN where the connected clip length still fits

* Add play and pause beside the crop scrubber and clarify resize padding

* Preview the current brush beside Size in Image Compare

* Center the crop and its padding together on each axis

* Polish crop controls and load images from Outputs

* Add crop controls preview
2026-11-05 20:07:33 -06:00
ausboss 28dd526447 Remove the LoRA Loader stack row limit (#287) 2026-11-05 20:07:24 -06:00
ausboss abf6cecd52 Style workflow guide and model download tiles (#286)
* Style workflow guide and model download tiles

* Add workflow tile preview
2026-11-05 20:04:58 -06:00
ausboss 12e7d8c33f Limit Latent Size to workflow-approved resolutions and share panel controls (#285) 2026-11-05 18:23:30 -06:00
ausboss de95db228c H3 Prompt Writer: a swap that keeps the clip's sound only gives the new person their own lines, timed from that sound (#277)
* 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.

* Load Audio: open smaller and go down to 280 wide

* Load Audio: shorter wave, length moves off the wave next to OUT

* Add the Pika H3 Flex example workflow

* H3 Prompt Writer: a swap puts each person picture in one person's place

* H3 Prompt Writer: keep quoted words in a swap and read who goes out of a longer line

A swap dropped every typed line once it had read who goes, so words in
quotes never reached H3 and the new person's lips were guessed. Those
lines now stay as the new person's words, landing on the lip movement of
the video, and a voice with a swap no longer says nobody speaks.

"Elon is @1, he replaces the person at the counter" went whole into the
description of who goes. The words after replaces (or takes the place of,
instead of, ...) are now who goes, and a name given to @1 is left out.

* H3 Prompt Writer: swap lines go to the right speaker

Names given with a comma ("Altman is @1, the man on the left. Dario is
@2, ...") no longer run into each other, and a later "Dario says" or
"He says" goes to that person. A thing that speaks, like the card
reader, says its words from off screen so nobody's lips move. "raps"
and a few other ways of speaking count as speech, so "and raps" stays
out of who goes.

* H3 Prompt Writer: keep how a swap line is said, and its exact time

"The card reader says in a flat robot voice" now names the card reader
and keeps "in a flat robot voice" as how it says the words. A time like
3.2s at the start of a line was read as a list number and lost its 3; it
now stays 3.2, a time typed inside a swap line counts too, and the first
words of a swap say when they start.

* H3 Prompt Writer: a person off screen gets their own voice

"A woman off screen says" went to the person on screen, as their voiceover,
and in a swap it landed on the swapped-in person. Now a person or a name
the line puts off screen (off screen, off camera, behind the camera, O.S.)
speaks from off screen as their own speaker, in a swap and outside one.
An unnamed voiceover, an @ or a he or she still stays the main person's.
Off-screen voices no longer get a 'stays silent with the lips closed' line.

* H3 Prompt Writer: a swap can keep the clip's clothes

A swap always dressed the new person in the picture's clothes. Typing
"keeps her outfit", "in the clip's clothes" or "keep the clothes from the
video" now takes only the face and hair from the picture and keeps the
clothes of whoever goes. "keeps her own clothes" still means the picture's.

* H3 Prompt Writer: a thing said to be off screen speaks for itself in every mode

"A card reader off screen says" got its own voice only in a swap. In copy
its moves and the other modes it became the person on screen's voiceover.
Anything a line puts off screen now speaks for itself; "a voice" and
"narrator" stay the main person's voiceover.

* H3 Prompt Writer: a swap that keeps the clip's sound gives the new person only their own lines, timed from that sound

With the clip's own sound under a swap, H3 heard every voice and moved the
new person's mouth to lines that weren't theirs. The new Clip's own sound
switch leaves out anyone else's words, says the new person keeps the lips
closed between their lines, and puts each untimed line where speech starts
in the video's sound. A typed time still wins.

* H3 Prompt Writer: tell voices apart by pitch when timing lines from the clip's sound

Loudness gaps alone put the new person's lines on the other speaker's words
when two people trade lines quickly. Each line now takes a run of speech
that fits its syllables, starts after a quiet, and keeps one voice at one
pitch. On the lip sync clips this lands every line within 0.15 s of
Whisper's times, except one soft word under a crowd.

* H3 Prompt Writer: a short name for who goes in a swap keeps their line

"The man in the car" for "the man in the back of the police car" was read
as someone else, so with the clip's sound on his line was left out. A
speaker is who goes when every word naming them appears in who goes.

* H3 Prompt Writer: a swap with the clip's sound and no words typed speaks as the video does

With nothing in quotes, it said the new person keeps the lips closed and
says nothing, even when the clip is full of speech, and the soundscape
was the place's steady sound. Now speech in the clip means the new person
speaks as in the video, and the soundscape is the clip's own sound. With no
speech heard and no words typed, it says nothing about the lips.

* Document two-person swaps and off-screen dialogue
2026-11-05 15:58:53 -06:00
ausboss 93f64d07d7 H3 Prompt Writer: swap lines keep their words, speaker, delivery and time (#275)
* 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.

* Load Audio: open smaller and go down to 280 wide

* Load Audio: shorter wave, length moves off the wave next to OUT

* Add the Pika H3 Flex example workflow

* H3 Prompt Writer: a swap puts each person picture in one person's place

* H3 Prompt Writer: keep quoted words in a swap and read who goes out of a longer line

A swap dropped every typed line once it had read who goes, so words in
quotes never reached H3 and the new person's lips were guessed. Those
lines now stay as the new person's words, landing on the lip movement of
the video, and a voice with a swap no longer says nobody speaks.

"Elon is @1, he replaces the person at the counter" went whole into the
description of who goes. The words after replaces (or takes the place of,
instead of, ...) are now who goes, and a name given to @1 is left out.

* H3 Prompt Writer: swap lines go to the right speaker

Names given with a comma ("Altman is @1, the man on the left. Dario is
@2, ...") no longer run into each other, and a later "Dario says" or
"He says" goes to that person. A thing that speaks, like the card
reader, says its words from off screen so nobody's lips move. "raps"
and a few other ways of speaking count as speech, so "and raps" stays
out of who goes.

* H3 Prompt Writer: keep how a swap line is said, and its exact time

"The card reader says in a flat robot voice" now names the card reader
and keeps "in a flat robot voice" as how it says the words. A time like
3.2s at the start of a line was read as a list number and lost its 3; it
now stays 3.2, a time typed inside a swap line counts too, and the first
words of a swap say when they start.

* H3 Prompt Writer: a person off screen gets their own voice

"A woman off screen says" went to the person on screen, as their voiceover,
and in a swap it landed on the swapped-in person. Now a person or a name
the line puts off screen (off screen, off camera, behind the camera, O.S.)
speaks from off screen as their own speaker, in a swap and outside one.
An unnamed voiceover, an @ or a he or she still stays the main person's.
Off-screen voices no longer get a 'stays silent with the lips closed' line.

* H3 Prompt Writer: a swap can keep the clip's clothes

A swap always dressed the new person in the picture's clothes. Typing
"keeps her outfit", "in the clip's clothes" or "keep the clothes from the
video" now takes only the face and hair from the picture and keeps the
clothes of whoever goes. "keeps her own clothes" still means the picture's.

* H3 Prompt Writer: a thing said to be off screen speaks for itself in every mode

"A card reader off screen says" got its own voice only in a swap. In copy
its moves and the other modes it became the person on screen's voiceover.
Anything a line puts off screen now speaks for itself; "a voice" and
"narrator" stay the main person's voiceover.

* Document two-person swaps and off-screen dialogue
2026-11-05 15:57:11 -06:00
ausboss b3a3d7a8ca Add frameless workflow tiles and branded Markdown guides (#284) 2026-11-05 15:55:47 -06:00
ausboss c1d6136663 Input loaders: share one os.scandir walk instead of per-file stat (#279) (#281)
* Input loaders: share one os.scandir walk instead of per-file stat (#279)

* Keep input file links that resolve inside the media folder
2026-11-05 00:44:28 -06:00
ausboss 515b9ea4bd Frontend: use relative /scripts/ imports for subpath deployments (#276) (#282) 2026-11-05 00:31:20 -06:00
ausboss c37386c1d4 Seed: add a compact look, picked from the gear (#274) 2026-11-03 23:59:16 -06:00
ausboss 7cb0abaacf Add Load Audio node: wave, trim, preview and max length (#272) 2026-11-03 23:58:16 -06:00
ausboss 939d320b8b Merge pull request #271 from ausboss/fix/resize-selected-together
Line up: resize selected nodes together
2026-11-03 14:38:57 -06:00
ausboss 9bb8a201fe Merge remote-tracking branch 'origin/main' into fix/resize-selected-together 2026-11-03 14:33:13 -06:00
ausboss 52c3353c69 Resize together: rows share height, grids share both
How the selection is laid out decides what it shares: nodes stacked in a
column get the grabbed node's width, nodes side by side in a row get its
height, and a grid gets both. A pull on the top edge keeps each bottom
edge in place.
2026-11-03 14:22:46 -06:00
ausboss ded731859c Integer and Float: a big look, like Text (#270)
Integer and Float get the same gear as Text, with a big look: one large centred number with its arrows, in a taller row. The Pika H3 Flex workflow uses it for Seconds.

The look is kept in the node's properties (ausboss_text_look: "big"), so saved values and what the node sends don't change. A row with its own height in a look now takes that height when the look changes. Docs and changelog updated.
2026-11-03 13:45:54 -06:00
ausboss e14682f54f Line up: resize selected nodes together
With two or more nodes selected, pulling the edge or corner of one now
gives every selected node its width, in the classic look and Nodes 2.0.
A pull on the left edge keeps each right edge in place. Heights are left
alone, one Ctrl+Z undoes it, and Settings > AusBoss > Canvas > Resize
together turns it off.
2026-11-03 11:54:56 -06:00
ausboss 1129232f83 Merge pull request #269 from ausboss/feat/h3-writer-timeline
H3 Prompt Writer: timeline, colored words, Sound and Music boxes
2026-11-03 11:18:36 -06:00
ausboss 9ba82c45b4 H3 Prompt Writer: timeline, colored words, Sound and Music boxes
- Words light up as you type (@1, times, cuts, quotes, labels), with @
  autocomplete and a ? cheat sheet
- Timeline bar shows every picture a line names; drag a block to reorder,
  drag its edge to set its time, click an edge for a cut, click a block to
  jump to its line
- Sound and Music boxes (new optional inputs, appended) fill H3's
  overall_soundscape and non_diegetic_music
- Pictures and video as chips with their jobs; Length, Camera and Look stay
  plain rows so they can still be wired
- Help page and changelog updated; tests for the colors, autocomplete,
  timeline edits and the new inputs
2026-11-03 11:09:26 -06:00
ausboss 099b57f755 Merge pull request #266 from ausboss/feat/h3-prompt-writer
H3 Prompt Writer node
2026-11-02 18:08:09 -06:00
ausboss f27e1293b0 H3 Prompt Writer: tidy imports, add its Chinese name 2026-11-02 16:53:00 -06:00
ausboss 75621c341f Merge remote-tracking branch 'origin/main' into feat/h3-prompt-writer
# Conflicts:
#	CHANGELOG.md
2026-11-02 16:49:47 -06:00
ausboss ba8d2b8048 Merge pull request #268 from ausboss/docs/agent-guide
Add an agent guide and move contributor rules to CONTRIBUTING.md
2026-11-02 16:49:03 -06:00
ausboss 00164f1df5 Merge pull request #264 from ausboss/feat/chinese-node-names
Chinese node names and descriptions (#245)
2026-11-02 16:47:55 -06:00
ausboss 2316afee62 Merge pull request #267 from ausboss/fix/inpaint-stitch-size
Crop For Inpaint: scale crops evenly and default to multiple 32; Tone…
2026-11-02 16:05:25 -06:00
ausboss 6b1b1cfabc Crop For Inpaint: scale crops evenly and default to multiple 32; Tone match skips see-through pixels 2026-11-02 16:02:53 -06:00
ausboss aaf63b9d9c Changelog: H3 Prompt Writer 2026-11-02 14:22:27 -06:00
ausboss 17fb493a66 Merge remote-tracking branch 'origin/main' into feat/h3-prompt-writer 2026-11-02 14:20:06 -06:00
ausboss 3e2ae322f8 H3 Prompt Writer: words typed for a swap say which person goes 2026-11-02 10:44:05 -06:00
ausboss a61d3d4ee0 H3 Prompt Writer node with width and height outputs 2026-11-02 02:07:29 -06:00
ausboss dff08a3b6e Merge pull request #265 from ausboss/feat/snap-into-line
Add an agent guide and move contributor rules to CONTRIBUTING.md
2026-11-01 22:22:55 -06:00
ausboss 91b53162cc Add an agent guide to the nodes and move contributor rules to CONTRIBUTING.md 2026-11-01 20:52:18 -06:00
ausboss 1ea57b6538 Snap into line and Line up: nodes and groups line up while dragging, and a toolbar panel lines up a selection 2026-11-02 02:29:33 +00:00
ausboss 6d6876dd38 Chinese node names and descriptions (#245)
locales/zh/nodeDefs.json gives every public node a Chinese name,
description and input and output names, which ComfyUI shows when its
language is set to Chinese. Cards and help pages stay English.
tests/test_locales.py keeps the entries matched to the node API, and the
preflight lets locales/ ship.
2026-11-02 01:26:38 +00:00
ausboss f9e17c1fdd Merge pull request #261 from ausboss/feat/stitch-to-original
Stitch Inpaint: Paste onto puts a hand crop back on the whole picture
2026-11-01 02:30:11 -06:00
ausboss 21ca6e9f47 Stitch Inpaint: Paste onto puts a hand crop back on the whole picture
Image Crop + Rotate + Pad's stitcher now remembers the picture and the
crop's position. With Paste onto on original, Stitch Inpaint lays the
result on the whole picture, straight, resized or turned. The default
stays crop, so saved workflows run as before.
2026-11-01 01:24:49 -06:00
ausboss 5a41046e3f Merge pull request #260 from ausboss/feat/switches-choice
Workflow Switches: + Choice, and a guide for AI agents
2026-10-31 21:14:00 -05:00
ausboss 264c133618 CHANGELOG: + Choice and the agent guide are unreleased 2026-10-31 21:01:23 -05:00
ausboss 310d2ba5c3 Merge remote-tracking branch 'origin/main' into feat/switches-choice 2026-10-31 21:01:08 -05:00
ausboss b5bce60c22 Workflow Switches: + Choice, and a guide for AI agents
Two named settings used to take about 20 clicks through a menu and
Settings. + Choice saves how a node is set under a name, and the second
one turns the card into a pill. What a choice changes is what it holds
differently from the other choices, so it needs no off side. A choice can
also hold the nodes only it needs, so a missing model in the branch nobody
picked no longer stops the run.

js/docs/agents/workflow_switches.md tells an AI agent how to write a card
into a workflow file. It sits under js/docs because that ships with the
pack. A test holds its examples to what the card really stores.
2026-10-31 21:01:07 -05:00
ausboss e532e56ed9 Merge pull request #259 from ausboss/release/2.20.5
Release 2.20.5: LaMa Inpaint only opens known model files
2026-10-31 19:22:38 -05:00
ausboss f7d6596937 Release 2.20.5: LaMa Inpaint only opens known model files
A LaMa model file can run code when it opens, so LaMa Inpaint now checks
the file first and stops on anything it doesn't know. Frame Interpolate
errors no longer show server folders, Image Folder's "start over" keeps
going past the last picture, and One at a time in Workflow Switches holds
for new switches and for the setting being turned on.
2026-10-31 19:21:09 -05:00
ausboss 5f32967ee5 Merge pull request #257 from ausboss/fix/switches-one-at-a-time
Workflow Switches: One at a time holds for new switches and the setting
2026-10-31 15:51:45 -05:00
ausboss b01e9a4c70 Merge pull request #258 from ausboss/docs/readme-discord
README: link the Discord server
2026-10-31 13:39:06 -05:00
ausboss c669f1100d README: link the Discord server 2026-10-31 13:36:40 -05:00
ausboss 39da7a3931 Workflow Switches: One at a time holds for new switches and the setting
A new switch read on beside the rows already on, and turning the setting
on left every row on. A new switch now gives way to the rows that were
there, and turning the setting on keeps one row on.

Fixes #256
2026-10-31 08:49:11 -05:00
ausboss 8b75948359 Merge pull request #254 from ausboss/fix/image-folder-start-over-docs
Image Folder: say what "start over" does
2026-10-31 01:20:48 -05:00
ausboss 33ce662398 Image Folder: say what "start over" does
The README, help page and tooltip now say that the pictures go round
after the last one, and the CHANGELOG has the fix from #252.
2026-10-30 23:23:08 -05:00
ausboss 7f7481c505 Merge pull request #252 from MarxMitX/fix/image-folder-start-over
Image Folder: "start over" fixed
2026-10-30 23:16:36 -05:00
ausboss 68f4f4b301 Merge pull request #253 from ausboss/fix/lama-official-model
LaMa Inpaint opens only known model files; Frame Interpolate errors name no folders
2026-10-30 19:41:28 -05:00
ausboss ff6706a918 LaMa Inpaint opens only known model files; Frame Interpolate errors name no folders
A LaMa model file can run code when it opens, so the node checks the file's
SHA-256 first and opens only the two common builds of big-lama.pt. Frame
Interpolate's errors now name the file, and the folder goes to the console.
2026-10-30 18:09:45 -05:00
MarxMitX 4afb60c921 Image Folder: "start over" fixed
With "Run" -> "one per run", "After a run" -> "next" and "At the end" -> "start over", Picture
stopped at count + 1 after the last picture. The backend maps that to
picture 1, but nextPosition() capped it at count + 1 again, so every
following run loaded picture 1.

nextPosition() now takes at_the_end and correctly wraps the number around after
the last picture.
2026-10-30 23:38:34 +01:00
ausboss c4a64366a6 Merge pull request #251 from ausboss/release/2.20.4
Release 2.20.4: Workflow Switches explain "empty"
2026-10-30 13:18:35 -05:00
ausboss e4b9a08cec Release 2.20.4: Workflow Switches explain "empty"
The paste fix in 2.20.3 works when the paste happens, so a workflow pasted
and saved on an older version keeps its old numbers and still reads
"empty" after an update. The word's hover text now says what happened and
what to do: open the original file again, or add nodes from the switch's
menu. The help page says the same.
2026-10-30 13:17:56 -05:00
ausboss a5358c4f78 Merge pull request #249 from ausboss/release/2.20.3
Release 2.20.3
2026-10-29 11:37:06 -05:00
ausboss d4df8f7508 Release 2.20.3 2026-10-29 11:36:28 -05:00
ausboss e93f3337a1 Merge pull request #248 from ausboss/fix/switches-after-paste
Workflow Switches and Callout: follow the copies after a paste
2026-10-29 11:36:04 -05:00
ausboss d0658ea8b2 Workflow Switches and Callout: follow the copies after a paste
Copy and paste give every pasted node and group a new id, and so do
duplicate, Alt + drag and dragging a saved workflow onto the canvas. A
Workflow Switches card remembers its parts by id, so a whole workflow
pasted into a new tab read "empty" on every row and nothing could be
switched on; pasted into another workflow it could switch unrelated nodes
that had the old numbers. A Callout's links to a node or group broke the
same way.

The frontend's paste (LGraphCanvas._deserializeItems) returns which new
node each old id became. js/shared/paste_ids.mjs hands that on after every
paste, and a pasted card or note points at the copies of what it names.
An id the paste did not copy is kept, so a card pasted on its own still
switches the original parts.
2026-10-29 11:05:44 -05:00
ausboss 0853872174 Merge pull request #246 from ausboss/fix/save-preview-states
Release 2.20.2: Save Image shows a run more clearly
2026-10-27 08:53:49 -05:00
ausboss 2545b82219 Release 2.20.2 2026-10-27 08:51:48 -05:00
ausboss 13145ed881 Save Image: say a run is going, hold the last step, keep tiles off the picture
While the models load, an empty picture area says the run is going instead
of asking for a run. With steps on, the last step stays up until the saved
picture has loaded, so it no longer blinks out. Between a tiled upscale's
split and stitch (and for Ultimate SD Upscale) the step pictures are tiles:
the whole picture stays and the chip says "upscaling in tiles".
2026-10-27 08:51:40 -05:00
ausboss fe04d93afa Merge pull request #244 from ausboss/release/2.20.1 2026-10-26 20:18:05 -05:00
ausboss 4591dd4ef9 Release 2.20.1 2026-10-26 19:52:35 -05:00
ausboss cd05a91e14 Merge pull request #242 from ausboss/fix/touch-other-picture-message 2026-10-26 19:43:49 -05:00
ausboss 1faca35331 Merge branch 'main' of https://github.com/ausboss/ComfyUI-AusBoss into fix/touch-other-picture-message
# Conflicts:
#	CHANGELOG.md
2026-10-26 19:31:42 -05:00
ausboss 3e79954bbe Merge pull request #243 from ausboss/fix/keep-transparency 2026-10-26 19:25:52 -05:00
ausboss 8837c75553 Keep see-through parts in Crop/Stitch Inpaint and Image Compare
Stitch Inpaint takes an RGB patch over an RGBA picture and keeps the
picture's own alpha, instead of stopping on a channel mismatch. The halo
fix spreads colours only.

Image Compare keeps alpha in a preview that has see-through parts and
flags it, so the panel shows them over a checkerboard (Slide, A/B and
Erase). The Erase mix lays the top picture over the other, the saved PNG
is RGBA, and the PSD carries each layer's transparency plus a merged
alpha, its flat colours stored over white as Photoshop does. A see-through
B is resized premultiplied. Align measures over gray and warps all four
channels. Solid pictures give byte-identical files.

Refs #232
2026-10-26 18:40:49 -05:00
ausboss 69fbceb0a1 Mask by Touch: say when the picks were made on another picture
When the picture coming into the node changed after picking, the run
stopped with "nothing is picked yet" while the picks still showed on the
node. It now says the picks were made on another picture. If nothing set
to empty mask still lets the run go on.
2026-10-26 18:05:42 -05:00
ausboss 714b638ee8 Merge pull request #241 from ausboss/release/2.20.0
Release 2.20.0
2026-10-25 20:52:58 -05:00
ausboss 5d00076254 Release 2.20.0 2026-10-25 18:20:43 -05:00
ausboss 8b7a717004 Merge pull request #240 from ausboss/feat/live-step-preview 2026-10-25 16:03:09 -05:00
ausboss 6af50147fc Step preview: the caption is the step count alone; Step Preview shows the finished picture once it is saved 2026-10-25 15:35:17 -05:00
ausboss c54ee8984f Step preview: Stop takes clicks on Save Image; the step picture fills its box; help notes from real runs 2026-10-25 15:35:06 -05:00
ausboss 3ba27901cb Step Preview node, and a steps switch on Save Image and Save Video
Watch the picture form one sampling step at a time and stop a run that is
going wrong. Step Preview needs no wires and shows whichever sampler is
working, with Stop and Stop all. Save Image and Save Video get a steps
switch, off by default, that shows the steps where the saved picture goes,
with a Stop button.

A queued workflow that shows its steps asks ComfyUI for the auto preview
method for that run, only when no method was chosen in the frontend or on
the command line.
2026-10-25 15:35:06 -05:00
ausboss f131718cf8 Merge pull request #239 from ausboss/release/2.19.2
Release 2.19.2
2026-10-25 13:01:59 -05:00
ausboss e8419b2667 Release 2.19.2 2026-10-25 13:01:02 -05:00
ausboss ef851094ba Merge pull request #238 from ausboss/fix/civitai-failure-text
LoRA Loader: say why a Civitai lookup failed
2026-10-25 13:01:00 -05:00
ausboss 9aefc53134 LoRA Loader: say why a Civitai lookup failed
The button still reads Civitai lookup failed, and a message now gives the
reason. A browser that cannot reach civitai.com at all names the usual
causes: a firewall, a VPN, or ComfyUI started with --disable-api-nodes.
2026-10-25 13:00:09 -05:00
ausboss 1ec9027413 Merge pull request #237 from ausboss/release/2.19.1
Release 2.19.1
2026-10-24 18:33:06 -05:00
ausboss 641b907796 Release 2.19.1 2026-10-24 18:32:11 -05:00
ausboss 1985103fa2 Merge pull request #236 from ausboss/feat/lora-civitai-picture
LoRA Loader: Fetch Civitai info also saves the LoRA's picture
2026-10-24 18:32:00 -05:00
ausboss acad58b613 Tests: skip the picture tests where Pillow is missing 2026-10-24 18:31:12 -05:00
ausboss 15718c07ee LoRA Loader: Fetch Civitai info also saves the LoRA's picture
The browser downloads a small copy of the LoRA's first picture from
Civitai's picture server (PG-13 or milder unless the gear menu's Civitai
picture option says any), and the server saves it beside the LoRA as
<model>.preview.jpg, never replacing a picture. The LoRA Previews add-on
is no longer needed, so its hooks and install hint are gone.
2026-10-24 18:30:09 -05:00
ausboss 438ab30b79 Merge pull request #235 from ausboss/release/2.19.0
Release 2.19.0
2026-10-24 16:57:48 -05:00
ausboss a37dfb93b6 Release 2.19.0 2026-10-24 16:56:41 -05:00
ausboss a6ea043462 Merge pull request #234 from ausboss/feat/lora-ticks-groups
LoRA Loader: add several at once, groups, Civitai pictures add-on
2026-10-24 16:56:16 -05:00
ausboss da98850591 LoRA Loader: hand Civitai info to the LoRA Previews add-on
After Fetch Civitai info saves its file, LoRA Loader sends a browser
event. The separate LoRA Previews add-on answers it by saving the LoRA's
preview picture, and the card and hover show it without a reload. The
info card links to the add-on when a LoRA has Civitai info but no picture.
2026-10-24 16:55:24 -05:00
ausboss 812a22c070 LoRA Loader: groups get their own switch; Workflow Switches can own a group
Turning a group off stops its LoRAs and keeps each row's own on/off for when
the group comes back on. A switch saved with a group on and off now moves only
the group, shows one lock on the group's header, and leaves the rows inside to
the person. Renaming the group keeps the switch; clicking the lock takes the
group back.
2026-10-24 15:18:36 -05:00
ausboss 0afae99b30 LoRA Loader: Shift-click ticks a run in the picker
Shift-click a tick box or name ticks every file from the last one ticked.
Ctrl+Enter ticks the highlighted file and Shift+Enter ticks the run up to it.
2026-10-24 14:58:33 -05:00
ausboss 840f0003dd LoRA Loader: fix picker arrows and drag-out, add stack menu
- The picker's arrow keys no longer stick when the list opens under a
  pointer that has not moved: only a real pointer move takes the
  highlight.
- A group's only LoRA can be dragged out, and a group's first row
  dropped above its own header lands there instead of at the bottom.
  Just below a group's last row is now always outside the group.
- Right-click on the space around the rows opens a stack menu (add,
  group the selection, fold or open all groups, delete all) instead of
  the browser's Copy / Select all; the group menu also ends with
  Delete all.
2026-10-24 14:22:27 -05:00
ausboss 7469c5869e LoRA Loader: tick several files to add, and group rows
The + LoRA list gets a tick box beside each file. Ticked files (also by
Ctrl/Shift-click or Ctrl/Shift+Enter) are added together from a bar at
the bottom; ticks survive a search, and files already in the stack read
"in stack".

Rows can be grouped: Ctrl/Shift-click to select, right-click to group,
move to a group or take out. A group header has a switch for all its
rows, a fold arrow that shortens the node, a double-click rename, and a
grip to drag the whole group. Rows drag in and out by their grip. The
group name is stored on each row, so the stack still runs top to bottom
and groups travel with templates and undo; folded groups are a node
property.
2026-10-24 14:03:06 -05:00
ausboss a1b14cc4a4 Merge pull request #233 from ausboss/fix/note-hint-wrap
Workflow Note: wrap the hint under a model file
2026-10-24 10:18:47 -05:00
ausboss 1051bee665 Workflow Note: wrap the hint under a model file
The line under a file name was cut off with an ellipsis. It now wraps,
and the note grows to fit as it already does for other content.
2026-10-24 10:13:10 -05:00
ausboss 0a098e1027 Merge pull request #230 from ausboss/fix/validators-extra-inputs
Validators: ignore inputs they do not name
2026-10-22 17:25:23 -05:00
ausboss 8f6ac16d27 Validators: ignore inputs they do not name
A pack that wraps node validators in a catch-all takes **kwargs, so
ComfyUI hands it every input and it passes them all on. Load Image + Pad
then stopped the run with "VALIDATE_INPUTS() got an unexpected keyword
argument 'pad_left'" (#229), and every other validator in the pack would
fail the same way.

ignore_extra_inputs drops the inputs a validator does not name and shows
ComfyUI the original signature, so on a plain ComfyUI nothing changes:
only the named inputs are passed, ComfyUI still runs its own range and
list checks on the rest, and a failed check is still reported once.
Every validator in the pack wears it, and a test holds new ones to it.
2026-10-22 15:10:09 -05:00
ausboss e808f4ff51 Merge pull request #226 from ausboss/release/2.18.1
Release 2.18.1
2026-10-21 15:14:53 -05:00
ausboss 35651e3445 Release 2.18.1 2026-10-21 15:14:00 -05:00
ausboss 571f4f1896 Merge pull request #225 from ausboss/fix/asset-drop-on-panels
Panels: hand an Assets card dropped on them to the node
2026-10-21 15:13:49 -05:00
ausboss 6c705b2e42 Panels: hand an Assets card dropped on them to the node
In the classic renderer a picture's card dragged from ComfyUI's Assets
panel loaded into Image Crop + Rotate + Pad only when dropped on the
node's title bar. Dropped on the picture or the controls, the drag never
reached the canvas, which is where ComfyUI finds the node under a drop,
so nothing happened.

The pack already hands a file dropped on one of its panels to the node
underneath. An Assets card carries no file, only
application/x-comfy-asset-info, so that drag now takes the same path.
Nodes 2.0 already handed it to the node.
2026-10-21 15:04:55 -05:00
ausboss b063e6f2c1 Merge pull request #224 from ausboss/release/2.18.0
Release 2.18.0
2026-10-21 14:47:14 -05:00
ausboss 9917dc3d15 Release 2.18.0 2026-10-21 14:46:33 -05:00
ausboss de88c8d5e4 Merge pull request #221 from ausboss/feat/compare-image-b-output
Image Compare A/B: pass on image_b too
2026-10-21 14:41:30 -05:00
ausboss 8062c35f70 Merge main into feat/compare-image-b-output
# Conflicts:
#	CHANGELOG.md
2026-10-21 14:35:56 -05:00
ausboss 1f07ad8ba1 Merge pull request #223 from ausboss/fix/touch-hidden-sockets
Mask by Touch and Image Folder: hidden values take no link
2026-10-21 14:34:22 -05:00
ausboss b6012c48fb Mask by Touch and Image Folder: hidden values take no link
A text link held over Mask by Touch snapped to its hidden `touches`
input, drawn at the top edge of its settings card, and letting go wired
the text in place of the picks. Image Folder's hidden `pictures` did the
same once Folder was wired. The frontend gives every widget an input
socket unless it is marked socketless, and a link dropped on a node
lands on its first free input of the link's type, hidden ones included.

The card's `socketless` option, which only made a combo, now also makes
a one-line text widget for a STRING (markSocketless moves to
widget_card_math.mjs so it can be tested). Mask by Touch's card lists
touches and ask, Image Folder's lists pictures. The backend inputs do
not change: saved values still load by position and the API prompt
still carries them. An older workflow brings the empty slots back on
load and the card drops them again, as it already did for Seam.
2026-10-21 14:32:58 -05:00
ausboss cc5617db04 Merge main into feat/compare-image-b-output 2026-10-21 14:26:12 -05:00
ausboss 3d94052489 Merge pull request #222 from ausboss/fix/load-pictures-comfyui-made
Loaders: take files ComfyUI made, as core Load Image does
2026-10-21 14:23:29 -05:00
ausboss 5b8cad57d7 Loaders: take files ComfyUI made, as core Load Image does
Image Crop + Rotate + Pad, Load Image + Pad and Load Video refused a
picture named the way ComfyUI names a generated file
("photo.png [output]", "[temp]") with "The selected source is outside
ComfyUI's input folder." Core Load Image takes those names.

resolve_input_path now reads the name the same way the video nodes'
Outputs list already does (resolve_server_file): the tag picks the
input, output or temp folder, and the rest is a name below it. A whole
path, "~", "." and "..", and a network path are refused from the text
alone; a link that leads out of the folder is refused once resolved.
Both functions share that code. A whole path inside the input folder,
accepted before, is now refused too, as the security rule asks.

Load Video's player and the shared upstream preview stripped the tag
and asked /view for the input folder; they now keep the folder.

Tests cover input, [input], [output] and [temp] names through the
resolver and through ComfyUI's own validation next to core Load Image,
and the refused escapes. Help pages, SECURITY.md and CHANGELOG say so.
2026-10-21 14:21:55 -05:00
ausboss 2b34005150 Image Compare A/B: pass on image_b too
The node gets a second output, image_b: the B batch, untouched, the
way image_a already passes through. image_a stays the first output, so
saved workflows keep their wires. The slider, its resting side, Erase
and Save are unchanged.

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

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="105" height="20" role="img" aria-label="release: v2.21.1">
<title>release: v2.21.1</title>
<path fill="#555" d="M0 0h48v20H0z"/>
<path fill="#00b4aa" d="M48 0h57v20H48z"/>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="24" y="14">release</text>
<text x="76.5" y="14">v2.21.1</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 425 B

+2 -2
View File
@@ -21,7 +21,7 @@ Every visible field and output must earn its place. Published IDs, input order,
1. Put independently testable processing in an underscore-prefixed helper under `nodes/`.
2. Add one V1 wrapper in `nodes/node_<purpose>.py` with `INPUT_TYPES`, `RETURN_TYPES`, `FUNCTION`, `CATEGORY`, and both mapping dictionaries — keys as string literals, never a `NODE_ID` variable, or registry scanners cannot see the node.
3. Add the module to `NODE_MODULES` in `__init__.py` and the key to `PUBLIC_NODE_IDS` in `scripts/validate_nodes.py`; the validator fails on a registered key it does not know. A key kept only so old workflows load goes in `LEGACY_NODE_IDS` instead.
4. Give the node its face. Any INT, FLOAT, BOOLEAN, COMBO or STRING widget on a public node is shown through a widget card entry in `js/widget_cards/index.js` (see AGENTS.md §Conventions: numbers scrub, booleans are off | on pills, one linkable widget per row, `top: true` or `forceInput` for values people wire). Write more frontend JavaScript only when the card grammar cannot provide the interaction (a stage, a player, an editor).
4. Give the node its face. Any INT, FLOAT, BOOLEAN, COMBO or STRING widget on a public node is shown through a widget card entry in `js/widget_cards/index.js` (see CONTRIBUTING.md, Conventions: numbers scrub, booleans are off | on pills, one linkable widget per row, `top: true` or `forceInput` for values people wire). Write more frontend JavaScript only when the card grammar cannot provide the interaction (a stage, a player, an editor).
5. Namespace every route, extension, event, DOM marker, CSS class, cache, and browser state with `ausboss`.
6. Chain lifecycle hooks through `chainCallback`; never replace core or third-party prototypes directly.
7. Keep normal workflow and API execution independent from the custom frontend.
@@ -31,7 +31,7 @@ Every visible field and output must earn its place. Published IDs, input order,
## Document and prove
- Add rich help at `js/docs/<exact-mapping-key>.md` (the validator checks it exists) and a section in `README.md`. Name card controls the way the node face shows them, with the widget name alongside: **Tone match** (`color_match`).
- Show the node in an example under `example_workflows/`, a new one or an existing one it fits: a Workflow Note, numbered groups holding every node, no overlaps, and a matching `.jpg` of a real output. AGENTS.md § Example workflows lists the rest.
- Show the node in an example under `example_workflows/`, a new one or an existing one it fits: a Workflow Note, numbered groups holding every node, no overlaps, and a matching `.jpg` of a real output. CONTRIBUTING.md, Example workflows, lists the rest.
- A DOM panel goes into `tests/panel_guards.test.mjs` as `mustGrow` or `fixedByDesign`.
- At the next release, name the node in the pyproject `description`: Registry and Manager search read it.
- Extend `scripts/validate_nodes.py` when the new contract needs a permanent assertion.
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+4 -5
View File
@@ -1,4 +1,4 @@
import { app } from "/scripts/app.js";
import { app } from "../../../scripts/app.js";
import {
CUSTOM_SCHEME,
DEFAULT_SCHEME,
@@ -190,7 +190,7 @@ app.registerExtension({
});
function isTitleless(node) {
return node.constructor?.title_mode === (globalThis.LiteGraph?.NO_TITLE ?? 1);
return (node.title_mode ?? node.constructor?.title_mode) === (globalThis.LiteGraph?.NO_TITLE ?? 1);
}
function openHelpCard(node, event) {
@@ -210,9 +210,8 @@ function openHelpCard(node, event) {
function installHelpBadge(node) {
// A title-less node (Run Timer) has no bar to hang the badge on; its
// card opens from the node's context menu instead.
if (isTitleless(node)) return;
chainCallback(node, "onDrawForeground", function (ctx) {
if (!showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return;
if (isTitleless(this) || !showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return;
const [x, y] = badgeCenter(this.size[0]);
ctx.save();
ctx.beginPath();
@@ -228,7 +227,7 @@ function installHelpBadge(node) {
ctx.restore();
});
chainHandler(node, "onMouseDown", function (event, pos) {
if (!showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return false;
if (isTitleless(this) || !showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return false;
if (!pos || !hitsBadge(pos, this.size[0])) return false;
openHelpCard(this, event);
return true; // consume the click so it does not start a drag
+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;
+171 -9
View File
@@ -6,7 +6,9 @@
// and copy ride the default path); the panel only shows it. Decisions live in
// js/shared/callout.mjs under node:test.
import { app } from "/scripts/app.js";
import { app } from "../../../scripts/app.js";
import { setTileFace, TILE_FACE_CSS } from "../shared/tile_face.mjs";
import { CALLOUT_TYPES, serializeNoteIdentity } from "../shared/note_identity.mjs";
import { BRAND, chainCallback, keepDomWidgetWidthAuto } from "../shared/index.mjs";
import {
ARROW_ANGLE,
@@ -16,18 +18,22 @@ import {
fitFontSize,
parseBlocks,
pastedLink,
repointLinks,
targetId,
toggleSmall,
tileLinkFields,
tileLinkText,
urlLabel,
wrapLink,
} from "../shared/callout.mjs";
import { onPaste } from "../shared/paste_ids.mjs";
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
import { commitWidgetValue } from "../shared/widget_card_math.mjs";
import { compactAction } from "../shared/panel_controls.mjs";
import { frameBounds } from "../shared/workflow_switches.mjs";
const NODE_CLASS = "AUSBOSS_NODES_Callout";
const CSS_ID = "ausboss-callout-css";
const WIDGET_NAME = "ausboss_callout_panel";
const DEFAULT_SIZE = [380, 160];
@@ -35,6 +41,10 @@ const PANEL_MIN_WIDTH = 200;
const PANEL_MIN_HEIGHT = 64;
const SVG_NS = "http://www.w3.org/2000/svg";
const ARROW_COLOR = "#1fd3c6";
const LINK_TILES = {
youtube: { label: "Watch Me on YouTube", url: "https://www.youtube.com/@ausbossdev" },
discord: { label: "Join for Help or More Workflows", url: "https://discord.gg/RwVxY9Fuaj" },
};
const FRAME_FILL = 0.8; // the share of the view a node brought into view takes, as in Workflow Switches
const FONT = '"IBM Plex Sans", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif';
@@ -53,6 +63,7 @@ function ensureCss() {
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
${TILE_FACE_CSS}
.ausboss-callout{box-sizing:border-box;width:100%;height:100%;padding:2px 4px 6px;overflow:hidden;pointer-events:none;}
.ausboss-callout-card{position:relative;box-sizing:border-box;width:100%;height:100%;overflow:hidden;border-radius:8px;background:linear-gradient(135deg,rgba(0,180,170,.07),rgba(0,0,0,.2));}
.ausboss-callout-inner{position:absolute;top:0;bottom:0;left:0;box-sizing:border-box;width:100%;height:fit-content;margin:auto 0;padding:.6em 1em .6em .85em;color:#d6e6e4;font-family:${FONT};font-weight:500;line-height:1.4;letter-spacing:.003em;overflow-wrap:anywhere;}
@@ -61,6 +72,15 @@ function ensureCss() {
.ausboss-callout-point b{color:#6fe0d6;font-weight:650;}
.ausboss-callout-small{margin-top:.3em;color:#9fb5b2;font-size:max(11px,.62em);font-weight:400;line-height:1.4;}
.ausboss-callout-small b{color:#8fd6cf;font-weight:600;}
.ausboss-callout-card[data-tile=youtube]{background:#ae2639}
.ausboss-callout-card[data-tile=discord]{background:#5146aa}
.ausboss-callout-card[data-tile=title]{background:#090909;border-left:4px solid #21c7b9}
.ausboss-callout-card[data-tile] .ausboss-callout-inner{padding-block:.15em;}
.ausboss-callout-card[data-tile] .ausboss-callout-point{border:0;padding:0;margin:0;font-weight:650;color:#fff}
.ausboss-callout-card[data-tile] .ausboss-callout-url{color:white;text-decoration:none;display:flex;align-items:center;gap:12px}
.ausboss-callout-card[data-tile] .ausboss-callout-url svg{width:36px;height:28px;flex:none}
.ausboss-callout-card[data-tile] .ausboss-callout-out{margin-left:auto}
.ausboss-callout-card[data-tile] .ausboss-callout-tools{right:27px;}
.ausboss-callout-empty{color:#6f8987;font-size:14px;font-weight:400;}
.ausboss-callout-arrow{display:inline-block;width:1.2em;height:1.2em;margin:0 .1em;vertical-align:-.26em;filter:drop-shadow(0 0 .3em rgba(0,180,170,.55));}
.ausboss-callout-arrow svg{display:block;width:100%;height:100%;overflow:visible;}
@@ -73,7 +93,7 @@ function ensureCss() {
.ausboss-callout-out{display:inline-block;margin-left:.2em;font-size:.7em;font-weight:600;vertical-align:.14em;text-decoration:none;opacity:.85;}
.ausboss-callout-jump.is-gone{opacity:.45;cursor:default;text-decoration-style:dotted;}
.ausboss-callout-tools{position:absolute;top:5px;right:5px;display:flex;gap:4px;opacity:0;transition:opacity .15s;pointer-events:auto;}
.ausboss-callout-card:hover .ausboss-callout-tools,.ausboss-callout.is-selected .ausboss-callout-tools{opacity:.95;}
.ausboss-callout-card:is(:hover,:focus-within) .ausboss-callout-tools,.ausboss-callout.is-selected .ausboss-callout-tools{opacity:.95;}
.ausboss-callout-tool{height:22px;padding:0 8px;border:1px solid rgba(0,180,170,.45);border-radius:5px;background:rgba(8,14,16,.86);color:#9fd8d3;font:600 11px ${FONT};letter-spacing:.02em;cursor:pointer;}
.ausboss-callout-tool:hover{color:#fff;border-color:${BRAND};}
.ausboss-callout-editor{position:absolute;inset:0;box-sizing:border-box;display:flex;flex-direction:column;gap:6px;padding:6px;border:1px solid ${BRAND};border-radius:8px;background:rgba(5,10,11,.95);pointer-events:auto;container-type:size;}
@@ -88,6 +108,17 @@ function ensureCss() {
.ausboss-callout-pick svg{display:block;width:100%;height:100%;}
.ausboss-callout-size{flex:none;height:24px;margin-left:4px;padding:0 7px;border:1px solid rgba(0,180,170,.35);border-radius:5px;background:rgba(8,14,16,.9);color:#9fd8d3;font:600 11px ${FONT};cursor:pointer;}
.ausboss-callout-size:hover{border-color:${BRAND};background:rgba(0,180,170,.14);}
.ausboss-tile-editor{box-sizing:border-box;width:min(520px,94vw);max-height:86vh;overflow:auto;margin:auto;padding:24px;border:1px solid #365957;border-top:3px solid ${BRAND};border-radius:10px;background:#111b1c;color:#e6f4f3;box-shadow:0 18px 64px #0008;font:14px/1.5 ${FONT};}
.ausboss-tile-editor::backdrop{background:#06090ab8;}
.ausboss-tile-editor h2{margin:0 0 18px;font-size:21px;}
.ausboss-tile-editor label{display:flex;flex-direction:column;gap:7px;margin-bottom:16px;font-weight:600;}
.ausboss-tile-editor input,.ausboss-tile-editor textarea{box-sizing:border-box;width:100%;padding:10px 12px;border:1px solid #365957;border-radius:5px;background:#080e10;color:#e6f4f3;font:16px/1.5 ${FONT};user-select:text;}
.ausboss-tile-editor textarea{min-height:120px;resize:vertical;}
.ausboss-tile-editor :is(input,textarea):focus{outline:2px solid ${BRAND};outline-offset:1px;}
.ausboss-tile-editor footer{display:flex;justify-content:flex-end;align-items:center;gap:8px;}
.ausboss-tile-editor footer span{margin-right:auto;color:#99b0ad;font-size:12px;}
.ausboss-tile-editor .ausboss-compact-action{height:32px;padding:0 15px;font-size:13px;}
.ausboss-tile-editor .ausboss-tile-error{color:#ffc36d;margin:0 0 12px;}
@container (max-width:395px){.ausboss-callout-arrowbar>span:first-child,.ausboss-callout-link:not(.has-node){display:none;}}
@container (max-width:395px) and (min-height:112px){.ausboss-callout-arrowbar{flex-wrap:wrap;}.ausboss-callout-pick{flex:none;}}
`;
@@ -175,6 +206,18 @@ function arrowSpan(state, kind, piece = {}) {
function linkRun(state, first) {
if (first.url !== undefined) {
const link = el("a", "ausboss-callout-url");
const tile = state.card.dataset.tile;
if (tile === "youtube" || tile === "discord") {
const icon = svg("svg", { viewBox:"0 0 28 20", "aria-hidden":"true" });
if (tile === "youtube") {
icon.append(svg("rect",{x:0,y:1,width:28,height:18,rx:5,fill:"white"}),
svg("path",{d:"M11 5.5 19 10 11 14.5Z",fill:"#ae2639"}));
} else {
icon.append(svg("path",{d:"M4 3 10 1l1 2a15 15 0 0 1 6 0l1-2 6 2c2 4 3 8 3 12l-6 4-2-3a17 17 0 0 1-10 0l-2 3-6-4c0-4 1-8 3-12Z",fill:"white"}));
for (const cx of [9,19]) icon.append(svg("ellipse",{cx,cy:10,rx:2,ry:2.6,fill:"#5146aa"}));
}
link.append(icon);
}
link.href = first.url;
link.target = "_blank";
link.rel = "noopener noreferrer";
@@ -220,6 +263,10 @@ function bringIntoView(target) {
}
function renderText(state) {
const style = state.node.properties?.ausbossCalloutTile;
if (["youtube","discord","title"].includes(style)) state.card.dataset.tile = style;
else delete state.card.dataset.tile;
setTileFace(state.node, state.root, Boolean(state.card.dataset.tile), () => startEdit(state));
const blocks = parseBlocks(state.valueWidget.value);
state.inner.replaceChildren();
state.empty = blocks.length === 0;
@@ -317,7 +364,9 @@ function fit(state) {
inner.style.fontSize = `${px}px`;
return inner.offsetHeight <= height && inner.scrollWidth <= width + 1;
};
inner.style.fontSize = `${fitFontSize(fits)}px`;
const size = fitFontSize(fits);
const social = card.dataset.tile === "youtube" || card.dataset.tile === "discord";
inner.style.fontSize = `${social ? Math.min(size, 16) : size}px`;
}
function markChanged() {
@@ -329,8 +378,78 @@ function markChanged() {
}
}
function startTileEdit(state) {
if (state.editor) return;
const original = String(state.valueWidget.value ?? "");
const social = ["youtube", "discord"].includes(state.card.dataset.tile);
const link = social ? tileLinkFields(original) : null;
const dialog = el("dialog", "ausboss-tile-editor");
const heading = social ? "Edit link tile" : "Edit title";
dialog.setAttribute("aria-label", heading);
dialog.append(el("h2", "", heading));
const label = el("label", "", link ? "Button text" : "Text");
const text = el(link ? "input" : "textarea");
text.value = link?.label ?? original;
label.append(text);
dialog.append(label);
let address;
if (link) {
const field = el("label", "", "Link address");
address = el("input");
address.type = "url";
address.value = link.url;
field.append(address);
dialog.append(field);
}
const error = el("p", "ausboss-tile-error");
error.setAttribute("role", "alert");
const foot = el("footer");
const cancel = compactAction("Cancel");
const save = compactAction("Save");
foot.append(el("span", "", "Ctrl + Enter to save"), cancel, save);
dialog.append(error, foot);
const commit = () => {
let value = text.value;
try {
if (address) value = tileLinkText(text.value, address.value);
} catch (problem) {
error.textContent = problem.message;
return;
}
if (value !== original) {
state.node.graph?.beforeChange?.();
commitWidgetValue(state.node, state.valueWidget, value, app.canvas);
renderText(state);
state.node.graph?.afterChange?.();
markChanged();
}
dialog.close();
};
save.addEventListener("click", commit);
cancel.addEventListener("click", () => dialog.close());
dialog.addEventListener("keydown", event => {
event.stopPropagation();
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
commit();
}
});
dialog.addEventListener("keyup", event => event.stopPropagation());
dialog.addEventListener("close", () => {
state.editor = null;
dialog.remove();
state.root.querySelector(".ausboss-callout-tool")?.focus();
}, { once: true });
state.editor = dialog;
document.body.append(dialog);
dialog.showModal();
text.focus();
text.select();
}
function startEdit(state) {
if (state.editor) return;
if (state.card.dataset.tile) return startTileEdit(state);
const wrap = el("div", "ausboss-callout-editor");
const area = el("textarea", "ausboss-callout-edit");
area.value = state.valueWidget.value ?? "";
@@ -497,16 +616,17 @@ function buildPanel(node) {
root.append(card);
tools.addEventListener("pointerdown", (event) => event.stopPropagation());
const panelHeight = () => (card.dataset.tile ? 36 : PANEL_MIN_HEIGHT) + WIDGET_FRAME;
const domWidget = node.addDOMWidget(WIDGET_NAME, "ausboss_callout", root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => PANEL_MIN_HEIGHT + WIDGET_FRAME,
getMinHeight: panelHeight,
});
keepDomWidgetWidthAuto(domWidget);
domWidget.serialize = false;
fillNodeHeight(domWidget, {
minWidth: PANEL_MIN_WIDTH,
minHeight: () => PANEL_MIN_HEIGHT + WIDGET_FRAME,
minHeight: panelHeight,
minNodeSize: [PANEL_MIN_WIDTH, PANEL_MIN_HEIGHT + 40],
exactMinWidth: true,
});
@@ -514,6 +634,9 @@ function buildPanel(node) {
const state = { node, valueWidget, domWidget, root, card, inner, empty: true, editor: null, frame: 0 };
node.__ausbossCallout = state;
edit.addEventListener("click", () => startEdit(state));
card.addEventListener("dblclick", (event) => {
if (card.dataset.tile && !event.target.closest("a,button,textarea,.ausboss-callout-editor")) startEdit(state);
});
state.observer = new ResizeObserver(() => {
cancelAnimationFrame(state.frame);
state.frame = requestAnimationFrame(() => fit(state));
@@ -529,17 +652,44 @@ function buildPanel(node) {
app.registerExtension({
name: "AusBoss.Callout",
setup() {
// A note copied with what its links name follows the copies.
onPaste(({ nodes, groups, created }) => {
for (const node of created) {
if (!CALLOUT_TYPES.has(node?.comfyClass) && !CALLOUT_TYPES.has(node?.type)) continue;
const state = buildPanel(node);
if (!state) continue;
const text = repointLinks(state.valueWidget.value, nodes, groups);
if (text === null) continue;
state.valueWidget.value = text;
renderText(state);
}
});
},
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_CLASS) return;
if (!CALLOUT_TYPES.has(nodeData?.name)) return;
hideInputsInDef(nodeData, ["text"]);
chainCallback(nodeType.prototype, "onNodeCreated", function () {
if (nodeData.name === "AUSBOSS_NODES_CalloutTile") {
this.properties ??= {};
this.properties.ausbossCalloutTile ??= "title";
}
buildPanel(this);
// Keep new tiles compact without changing ordinary or saved callouts.
if (nodeData.name === "AUSBOSS_NODES_CalloutTile") this.setSize?.([380, 80]);
});
chainCallback(nodeType.prototype, "onConfigure", function () {
chainCallback(nodeType.prototype, "onSerialize", function (saved) {
serializeNoteIdentity(saved, ["youtube", "discord", "title"].includes(this.properties?.ausbossCalloutTile));
});
chainCallback(nodeType.prototype, "onConfigure", function (saved) {
// Saved values land after onNodeCreated; show them.
queueMicrotask(() => {
const state = buildPanel(this);
if (state) renderText(state);
if (!state) return;
renderText(state);
if (state.card.dataset.tile && Number.isFinite(saved?.size?.[1])) {
this.setSize?.([this.size[0], saved.size[1]]);
}
});
});
chainCallback(nodeType.prototype, "onDblClick", function () {
@@ -549,12 +699,24 @@ app.registerExtension({
chainCallback(nodeType.prototype, "getExtraMenuOptions", function (_canvas, options) {
const state = this.__ausbossCallout;
if (!state || !Array.isArray(options)) return;
for (const [style, label] of [["plain","Plain callout"],["title","Title tile"],["youtube","Set YouTube link tile"],["discord","Set Discord link tile"]]) {
options.unshift({ content: label, callback: () => {
this.properties ??= {};
this.properties.ausbossCalloutTile = style;
const preset = LINK_TILES[style];
if (preset) commitWidgetValue(this, state.valueWidget, `[${preset.label}](${preset.url})`, app.canvas);
renderText(state);
if (style !== "plain") this.setSize?.([this.size[0], style === "title" ? 86 : 78]);
markChanged();
}});
}
options.unshift({ content: "Edit callout text", callback: () => startEdit(state) }, null);
});
chainCallback(nodeType.prototype, "onRemoved", function () {
const state = this.__ausbossCallout;
if (!state) return;
state.observer?.disconnect();
state.editor?.close?.();
cancelAnimationFrame(state.frame);
this.__ausbossCallout = null;
});
+6 -5
View File
@@ -7,11 +7,12 @@
// ComfyUI does for its own text boxes. A panel that wants the wheel for itself
// (a scrolling list, a focused text field, an editor) still keeps it.
//
// A file dragged in from the desktop has the same problem in the classic
// renderer: dropped on a node's title it loads into the node, dropped on the
// node's panel (the picture, the controls) it missed the node. The drop
// listeners here hand it to the node under the pointer.
import { app } from "/scripts/app.js";
// A file dragged in from the desktop, or a picture's card from the Assets
// panel, has the same problem in the classic renderer: dropped on a node's
// title it loads into the node, dropped on the node's panel (the picture, the
// controls) it missed the node. The drop listeners here hand it to the node
// under the pointer.
import { app } from "../../../scripts/app.js";
import {
dragCarriesFiles, graphDragStarts, panelHost, panelKeepsWheel, panelNeedsDropHelp, panelRoot,
} from "../shared/canvas_passthrough.mjs";
+2 -2
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";
+22 -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";
@@ -20,7 +20,7 @@ import {
normalizeCompareMode,
sizeAfterErase,
} from "../shared/compare.mjs";
import { savedLinks } from "../shared/compare_erase.mjs";
import { CHECKER, backdropPadding, savedLinks } from "../shared/compare_erase.mjs";
import { ERASE_CSS, makeEraser } from "../shared/compare_erase_stage.mjs";
const NODE_NAME = "AUSBOSS_NODES_Compare";
@@ -54,6 +54,7 @@ function ensureCompareCss() {
.ausboss-compare-stage{position:relative;flex:1 1 auto;min-height:112px;overflow:hidden;border:1px solid rgba(0,180,170,.34);border-radius:6px;background:#000;box-shadow:inset 0 0 0 1px rgba(255,255,255,.025);touch-action:none;}
.ausboss-compare-stage img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none;}
.ausboss-compare-stage.is-empty img{visibility:hidden;}
.ausboss-compare-stage img.has-alpha{box-sizing:border-box;background-image:repeating-conic-gradient(${CHECKER.light} 0 25%,${CHECKER.dark} 0 50%);background-size:${CHECKER.side * 2}px ${CHECKER.side * 2}px;background-origin:content-box;background-clip:content-box;}
.ausboss-compare-seam{position:absolute;top:0;bottom:0;z-index:2;width:1px;margin-left:-0.5px;background:${BRAND};box-shadow:0 0 4px rgba(0,180,170,.55);opacity:0;pointer-events:none;}
.ausboss-compare-status{position:absolute;left:7px;top:7px;z-index:3;max-width:calc(100% - 112px);padding:3px 6px;border-radius:4px;background:rgba(0,0,0,.7);color:#b8d3d1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none;backdrop-filter:blur(4px);}
/* Once the previews are up the status has nothing to say, and an empty chip
@@ -104,6 +105,19 @@ function applyClip(state) {
state.identity.setAttribute("aria-label", state.identity.title);
}
// A picture with see-through parts lies on a checkerboard. It is the
// picture's own background, held to the picture by padding the size of the
// bars beside it, so in Slide the cut that shows A hides B behind A's clear
// parts too.
function placeBackdrops(state) {
const { clientWidth, clientHeight } = state.stage;
for (const [image, ref] of [[state.imageA, state.refs?.a], [state.imageB, state.refs?.b]]) {
const clear = Boolean(ref?.alpha) && image.naturalWidth > 0;
image.classList.toggle("has-alpha", clear);
image.style.padding = clear ? backdropPadding(clientWidth, clientHeight, image.naturalWidth, image.naturalHeight) : "";
}
}
function setEmpty(state, text) {
state.stage.classList.add("is-empty");
state.status.textContent = text;
@@ -355,7 +369,10 @@ function buildPanel(node) {
updateModeButtons(state);
// The labels are placed in pixels, so they follow the stage's width.
const resized = new ResizeObserver(() => applyClip(state));
const resized = new ResizeObserver(() => {
applyClip(state);
placeBackdrops(state);
});
resized.observe(stage);
signal.addEventListener("abort", () => resized.disconnect());
// Movement belongs to the preview only; graph dragging and panning remain
@@ -378,6 +395,7 @@ function buildPanel(node) {
}, { signal });
const onImageLoad = () => {
placeBackdrops(state);
state.loaded += 1;
if (state.loaded >= 2) {
setReady(state);
+11 -1
View File
@@ -7,9 +7,17 @@ to point at the part you are talking about.
Use it to explain a step in a workflow you share. It never runs and has no
wires.
## Tile presets
Right-click the Callout and choose **Title tile** to give your existing text a black heading with a teal accent. **Set YouTube link tile** and **Set Discord link tile** fill in the AusBoss link and label. Click the label to open the link, or drag the tile to move it. Choose **Plain callout** to return to the standard style with the current text.
- Press **Edit** on a tile to open a larger editor. A link tile has separate **Button text** and **Link address** fields. Existing mixed text stays in a text box.
- Double-click a title to edit it. **Save** or `Ctrl+Enter` keeps the change; **Cancel** or `Esc` leaves the original text.
- Drag the visible bottom-right grip to resize. You can also focus the grip and use arrow keys; hold **Shift** for smaller changes. Resizing keeps the tile in place and supports Undo.
## Writing
- Double-click the note, or press **Edit**, to type. Click outside the box to
- On a plain callout, double-click the note, or press **Edit**, to type. Click outside the box to
keep the text. **Esc** drops what you typed.
- A blank line starts a new point. Each point gets a thin teal line beside it.
- Put two stars around a word to stress it: `**Resize is on.**`
@@ -64,6 +72,8 @@ In the text a link is written `[the words](https://example.com)`, or
- The text is stored in the node's one hidden `text` input, so saving,
undo and copy work like any other node. Nothing else is saved with it.
- A note copied together with the nodes and groups its links name links to
the copies. A link to anything left behind keeps its number.
- In the text, small print is a line that starts with six `#` and a space:
`###### Off, your words go in as typed.` Small print is 62% of the note's
text size and never under 11 px.
+7
View File
@@ -0,0 +1,7 @@
# Callout Tile
A compact title or colored link button for a workflow. Choose **Title tile**, **Set YouTube link tile**, or **Set Discord link tile** from its menu. Press **Edit** to change its text or link; you can also double-click a title. Drag its visible corner to resize.
It uses the same text, arrows and links as [Callout](AUSBOSS_NODES_Callout.md). **Plain callout** returns to the ordinary note face. Nothing runs and it has no outputs.
Tiles save with their own node type. If ComfyUI reports this node missing, install or update **ComfyUI-AusBoss**, restart ComfyUI, and reload the workflow. An older pack cannot draw the tile correctly. Keep the missing node so its text and settings are preserved.
+25 -6
View File
@@ -1,15 +1,16 @@
# Image Compare A/B
Shows two images on one panel so differences pop out instead of hiding in a
side-by-side. The first frame of each batch is previewed; `image_a` passes
through the output unchanged, so the node can sit in the middle of a graph
without breaking anything downstream.
side-by-side. The first frame of each batch is previewed; `image_a` and
`image_b` pass through their outputs unchanged, so the node can sit in the
middle of a graph without breaking anything downstream.
## Inputs
- **image_a**: The baseline batch. Its first frame is preview A, and the
whole batch is what the output carries.
- **image_b**: The comparison batch. Its first frame is preview B.
whole batch is what the `image_a` output carries.
- **image_b**: The comparison batch. Its first frame is preview B, and the
whole batch is what the `image_b` output carries.
- **filename** (optional): a name for the files that **Erase** saves, such
as the source picture's file name. Leave it unwired to type the name on
the node.
@@ -28,6 +29,9 @@ picks what the panel does, and its marker slides to your choice:
it and it rubs away, so the other one shows through. Then save the mix.
See **Erase** below.
A picture with see-through parts shows them over a checkerboard, as an
editor does.
The labels ride with the split line: **A** just left of it and **B** just right of
it, on the picture each one names. With one picture showing, its label sits alone
at the top centre. The chosen mode and locked side are
@@ -49,12 +53,18 @@ Use it when you want part of one picture and part of the other: the edit
is good but it changed the hands, so you paint the old hands back. Or you
only want the new sky.
It also brings back a clear background. When the picture underneath has
see-through parts, such as a cut-out the model filled in behind, rub the
top one away there and they come back clear. The saved PNG and PSD keep
them.
Pick **Erase** on the switch. A row of tools opens above the picture:
- **Erase | Restore**: what the brush does. Erase rubs the top picture
away. Restore paints it back. Holding **Ctrl**, or using the right
mouse button, does the other one for as long as you hold it.
- **Size** is the brush's width, in pixels of the picture.
- The dot beside **Size** previews one brush dab. It grows with Size and its edge fades as you increase Soft.
- **Soft** is the brush's edge: 0 is a hard edge, 100 fades all the way
from the middle.
- Drag left and right on **Size** or **Soft**, on the word, the number or
@@ -128,9 +138,12 @@ B, the "after" picture. If your graph makes A out of B's picture (B is the
picture you loaded and A is the edited one), the result is A. To keep one
picture up whatever happens, use the **A** or **B** button.
## Output
## Outputs
- **image_a**: The `image_a` batch, untouched.
- **image_b**: The `image_b` batch, untouched: the picture you compared
against, often the edited one. Wire it on when the picture you want to
keep is B.
Previews are written to ComfyUI's temp folder and vanish with it. Nothing
is saved to the output folder until you press **SAVE** in Erase, and no
@@ -150,6 +163,12 @@ network requests are made.
left, and a new run does not change it.
- **Erase works on the first picture of each batch**, at its full size.
The mix is always A's size: a B of another size is resized to fit A.
- **See-through parts** are kept in a preview only when a picture has some,
so solid pictures save exactly as before. The mix lays the top picture
over the other as an editor does. In the PSD each layer keeps its own
see-through parts, and the flat picture carries the mix's, its colours
stored over white the way Photoshop stores them. **Align** measures the
two pictures laid over gray and moves B's see-through parts with it.
- **What is kept is the strokes**, not a painted picture. The panel draws
them for you, and Save draws them again on the server at full size, so
the saved edge is as sharp as the picture however small the node is.
+16 -3
View File
@@ -32,13 +32,20 @@ side of the picture, which is how the pair outpaints.
the paste mask is widened by this many pixels and blurred. The sampling
mask sent to the inpainter stays hard-edged, so feathering never weakens
what the model sees. `0` pastes with the raw mask.
- **Multiple** (`output_multiple`): Crop and target dimensions are rounded
up to a multiple of this so samplers accept them.
- **Multiple** (`output_multiple`): The crop's size is rounded up to a
multiple of this. Keep it at `32` (the default) for Qwen Image 2.1, Flux
and most newer models. Their VAE trims a picture that is not a multiple
of 16 or 32, and the trimmed result, stretched back over the crop, looks
slightly bigger than the original. Stitch Inpaint says so in the console
when that happens.
- **Target width / Target height** (`target_width` / `target_height`, in
the **Target size, extend** fold): Rescale the crop to a fixed size for
the sampler. `0` keeps the native crop size; setting only one dimension
derives the other from the crop's aspect ratio. Explicit values here
override `target_megapixels`.
override `target_megapixels`. When the crop is rescaled, the crop area
takes in a few more pixels of context on one side so it has the exact
shape of the target size, and the picture is scaled the same amount
both ways, never stretched.
### Shaping the mask
@@ -95,6 +102,12 @@ Any inpainting sampler fits between the two nodes the same way — connect
the crop's `image` and `mask` to it, then its output to Stitch Inpaint's
`inpainted` input.
A picture with see-through parts keeps them through the pair. ComfyUI's
Load Image splits them off into its `mask`; wire its `IMAGE` and `MASK`
into **Join Image with Alpha** to put them back on before the crop. The
sampler gets the colours, and Stitch Inpaint gives the result its clear
parts back.
## Technical details
- Starting values: Context `1.2`, Blend `16` px, Multiple `8`; Grow, Blur,
+9
View File
@@ -8,6 +8,11 @@ every node that needs it, and change it in one place.
- **Value** (`value`): The number to send out. It starts at `0`. The box
shows the decimals its steps use (`0.00`) and holds up to three, so a
typed `0.125` stays `0.125`.
- The gear, right: **Look**. **normal** is the usual box with its label.
**big** shows the number large in the middle of the node, with an arrow
on each side, for a setting people change on every run, such as the
length of a clip. A drag and typing work the same. Each node keeps its own look, and it is
saved with the workflow.
## Limits
@@ -44,3 +49,7 @@ into **Math Expression 🆎** instead of retyping derived numbers.
API is passed on as it is.
- Wired only to inputs that share no value (one stops at 1, the other starts
at 2), the box keeps no limits.
- The look is stored in the node's properties as `ausboss_text_look: "big"`, the
same as Text 🆎. It is not an input, so the saved value and what the node
sends are the same in both looks, and an older version of the pack shows
the usual box.
+268
View File
@@ -0,0 +1,268 @@
# H3 Prompt Writer
Type what happens, one line for each moment. Leave a blank line to cut to a new
place. The node writes H3's full prompt for you, and you never see its format.
Use it with MiniMax H3 or Pika H3 Flex. Connect up to four pictures and give each
one a job. Copy the moves from a video, keep a clip going, or add a voice or a
song.
## How to write it
Every line is a moment, and the lines play in order in one shot. The bar under
the box shows the second each one starts, before you spend a run.
```
@1 walks through a crowded night market toward us, looking over her shoulder.
@1 stops and looks up at the lanterns.
@1 stands at the edge of a rooftop in the rain, her back to us.
@1 slowly turns her head toward us.
```
That is two shots in two places. `@1` is Picture 1. With one person you can
write `She` or `He` instead.
The node also fills in two things H3 misses by itself. A moment gets its own
sound when it names something that makes one, like rain, footsteps or thunder.
And what changes stays changed: when the lights go out, the next moments in that
place say they are still out.
## The words box and the bar
The words the node acts on light up as you type: `@1` in teal (amber when
nothing is plugged in there), a time like `3s:` in amber, words in quotes in
yellow, and a blank line shows as a line marked CUT. Hover the `?` beside
"What happens" for a short list of everything you can type.
Typing `@` lists the pictures you plugged in, with their job. The first letters
of a line offer `sound:`, `music:`, `camera:` or `look:`, and after `camera:` or
`look:` you get the node's own choices. Tab picks one, Esc closes the list.
The bar under the box is the clip from start to end, one block for each moment
with the pictures it names:
- **Drag a block** along the bar to move its line. The times and cuts stay
where they are.
- **Drag the left edge** of a block to set when that moment starts. The node
types the time (`3s:`) into its line for you.
- **Click the left edge** to add or take out a cut there.
- **Click a block** to pick out its line in the words, ready to change.
Everything on the bar changes the words, so Ctrl+Z undoes it.
## Give each picture a job
Pick a job for each picture under the words, then name it in your lines with
`@1`, `@2`, `@3` or `@4`.
- **person**: this person goes wherever your lines say. Name the place in the
line, or name none and they stay where the picture shows. Use a clear photo.
- **character sheet**: one character drawn from several sides. Only the
character is used, not the panels.
- **place**: where it happens. Use a picture with nobody in it.
- **outfit**: clothes for your person. Works best with no head in the picture.
- **thing**: an object or a product. Works best with nobody in the picture.
- **style**: only the look is used, for the whole clip.
- **start on it**: the clip starts from this picture and stays in its place.
- **end on it**: the clip lands on this picture. It can't be named in a line.
Some scripts, with the jobs they need:
```
@1 sits at the counter in @2. Picture 2 is a place
@1 and @2 sit side by side on a night bus. Picture 2 is a person
@1 walks down a runway wearing @2. Picture 2 is an outfit
@1 spins slowly on a black glass table. Picture 1 is a thing
```
A picture that is off simply isn't counted. If a line still names it, the node
stops and tells you which one. It needs at least one picture or a video to start
from.
## Use a video
Connect a video, then pick what it is for:
- **copy its moves**: your person does the moves in the video. It needs a
picture of your person. Say where in your lines:
`@1 dances on a rooftop at sunset.` The clothes can come from words or from an
outfit picture.
- **swap the person**: your person takes over from the one in the video. The
place, the moves and the camera stay. With two person pictures, the first
replaces the person on the left and the second replaces the person on the
right. To choose who goes, type one line per picture, for example
`@1 replaces the man in the hat`. Put spoken words in quotes and name
the speaker. If a voice comes from off screen, say so in the line.
- **change something**: keep the video and describe the change, for example
`She wears a red raincoat`. Be specific about what should change.
- **keep going**: more of the same clip, from where it ends. Say what happens
next, or leave the box empty and it just carries on. Connect the whole clip:
the node sends H3 only the last 2 seconds.
- **new voice**: the same clip with a new voice. Connect the voice, and type
what they say in quotes.
If your swap keeps the video's audio, connect it to **video_sound** and turn on
**More → Clip's own sound**. The writer uses the clip to estimate when each
line is spoken and keeps only the incoming people's words in the swap prompt.
Type a time such as `3.2s:` to set it yourself. This switch starts off.
With an active video, **swap the person**, **change something** and **new voice**
use the incoming clip to set the frame count. **Length** says **From the clip**;
set the clip's length upstream. The bar does not show a guessed duration or offer
retiming controls for these jobs. H3 rounds the frame count to a supported length.
Other video jobs, or turning the video off, use the editable **Length** again.
For a swap, you can optionally describe the replacement on its own line:
```
the woman with black hair walking toward the camera
@1 looks like a woman with blonde hair and a black hoodie.
```
The first line chooses who goes; the `@1 looks like` line describes the connected
replacement person in Picture 1. Use the number of that picture, with its job set
to **person** or **character sheet**. This helps describe the intended appearance;
it does not guarantee an exact identity match. The line is optional: existing
workflows and scripts keep their previous behavior when it is absent.
## What you can type
- **A time**: start a line with `3s:` and it happens at 3 seconds.
- **A cut in the same place**: start the line with a kind of shot, like
`a close-up of her hand`. Any other line after a blank line goes to a new
place.
- **One long take**: type `no cuts` and blank lines stop being cuts.
- **An ending**: start a line with `ends on`, wherever it sits:
`ends on her sitting by the window`.
- **Speech**: put the words in quotes: `@1 says "we are late."` Each person
keeps their own voice.
- **Words on a sign**: name the sign: `a neon sign reads "OPEN"`.
- **Sound and music**: use the **Sound** and **Music** boxes, or type
`sound: rain on a tin roof` and `music: slow sad piano`. With no music you get
none, and the clip comes out quieter.
- **Camera and look**: pick them on the node, or type your own:
`camera: slow push in`, `look: a 1970s film`.
A `camera:` or `sound:` line inside a group of lines belongs to that shot. On its
own, with blank lines around it, it is for the whole clip.
Already wrote H3's six parts yourself? Paste them in and they go through as
they are.
## What you can plug in
- **picture_1** to **picture_4**: your pictures.
- **video** and **video_sound**: the frames and the sound of a video. **keep
going** sends the last part of the sound to H3. A swap uses the sound to find
speech timing when **Clip's own sound** is on. To keep source audio in a swap
or **change something**, route the original loader's audio to the save node.
- **voice**: a clip of someone talking, about 10 seconds, plus words in quotes
in your script. It goes to whoever speaks first. It works with your own voice,
or one you have permission to use. Put the spoken line last: with time left
after it, H3 tends to keep talking.
- **song**: your music. It is cut to the length of the clip and plays as it is,
and each moment moves onto its beat.
- **clip**: a small text model such as Qwen3-VL 8B. Type one line and it adds
what happens next, and it names the sound of the place. Your line always stays
first.
A voice and a song work together. H3 can't keep a song while someone speaks, so
the node keeps the song out of H3's way and hands it on as **song_under**, turned
down, to mix under the finished clip.
Then wire the node's outputs into H3: **prompt** into its prompt, **frames** into
its length, each **picture** into its next `ref_image`, **video** into `ref_video`,
**video_sound** into `ref_video_audio`, **voice** into its first `ref_audio`,
**song** into the next one. The pictures come out in the order the prompt counts
them, so a picture that is off leaves no gap. **width** and **height** go into
H3's width and height: the clip takes the shape of your video, or else of your
first picture. **song_under** goes into `audio2` of Audio Merge, with the clip's
sound in `audio1`.
**keeps_clip_sound** is on when a video is connected and **Clip's own sound** is
on, or the video job is **change something**. Use it to select the source audio
for the finished clip. It is the last output; the existing outputs keep their
positions. Keeping source audio still needs the workflow to pass that audio to
its save node.
Nothing plugged in means nothing is added. So a page can turn each one on and off
with [Workflow Switches](#AUSBOSS_NODES_WorkflowSwitches): a bypassed loader reads
as not plugged in.
## The controls
- **Sound** (`sound`): what the clip sounds like, for the whole clip. Same as a
`sound:` line.
- **Music** (`music`): the music for the whole clip. Same as a `music:` line.
- **Length** (`seconds`): how long the clip is, 5 to 15.
- **Camera** (`camera`): how the camera moves the whole way through.
- **Look** (`look`): from the picture keeps your photo's look. The others redraw
the clip. Clay and 3D don't work from words, so change your picture first with
an edit model.
- **Picture 1** to **Picture 4** (`job_1` to `job_4`): what each picture is
for, on the picture chips under the bar. An empty slot shows as a faint `+ @3`.
- **Video** (`video_job`): what the video is for, on the video chip.
- **Song starts at** (`song_start`, under **More**): the second of the song to
start from, so the clip gets the good part.
- **Moments on the beat** (`cut_on_beat`, under **More**): moves each moment onto
the nearest beat of the song. Off keeps your own seconds.
- **Language** (`language`, under **More**): the language of the words in quotes.
## Tips
- About one moment a second is the most H3 can play. The line under the rows
warns you, and it warns you when a spoken line won't fit its moment.
- Say which way they face, and think about where they end up. If someone walks
away from us, we see their back until a line turns them.
- H3 draws each shot on its own. A cut to a new place hides that. A cut inside one
place shows every gap, so keep a place in one shot and move the camera instead.
- Nothing moves or exists unless a line says so. If the birds should fly, say so
in that shot.
- Say what you would see, not what someone feels. `She smiles` works.
`She feels happy` doesn't.
- A clip came out wrong? Run again with a new seed.
## Slots
| Slot | Direction | Type | Purpose |
| --- | --- | --- | --- |
| `script` | input | STRING | What happens, one line for each moment. |
| `seconds` | input | INT | How long the clip is. |
| `clip` | input, optional | CLIP | A small text model that fills in a one-line idea. |
| `picture_1` to `picture_4` | input, optional | IMAGE | Your pictures. |
| `video` | input, optional | IMAGE | The frames of a video. |
| `video_sound` | input, optional | AUDIO | The video's own sound. |
| `voice` | input, optional | AUDIO | A clip of the voice to use. |
| `song` | input, optional | AUDIO | The music to use. |
| `prompt` | output | STRING | The finished prompt. |
| `picture_1` to `picture_4` | output | IMAGE | Your pictures, in the order the prompt counts them. |
| `video` | output | IMAGE | The video, or only its end to keep going from. |
| `video_sound` | output | AUDIO | The video's sound, when it should reach H3. |
| `voice` | output | AUDIO | The voice, 15 seconds at most. |
| `song` | output | AUDIO | The song, cut to the clip. Empty when a voice is connected too. |
| `song_under` | output | AUDIO | With a voice and a song, the song to mix under the finished clip. |
| `frames` | output | INT | The clip's length in frames. |
| `width`, `height` | output | INT | The shape of your video, or else of your first picture. |
| `keeps_clip_sound` | output | BOOLEAN | Whether the finished clip should use the video's own sound. |
## Technical details
Code does the reading, so nothing typed can be lost or moved. It follows Pika's
and MiniMax's published prompt guides: every picture is defined once, each one
states what is kept and how, and a person who changes clothes or moves to a new
place is marked that way.
A timed shot mark makes H3 jump, whatever the words around it say: in tests 31 of
36 one-take clips jumped within half a second of a mark. So a mark is written only
for a real cut, and the moments of one take are sentences inside one shot. Clips
written that way had no jumps in 4 of 4 tries.
The prompt always tells H3 not to keep the background of a person's picture. With
a place in your lines the clip stayed there to the end on 2 of 2 tries; without
that sentence the picture's street came back halfway through on 2 of 2. With no
place in your lines H3 has nothing else to use, so it stayed in the picture's
place on 2 of 2.
H3's own rewriter runs only on paid services, and the open model reads a prompt as
it is. This node does that job on your machine, with no model needed.
+35 -34
View File
@@ -10,7 +10,7 @@ original back over the result.
## Quick start
1. Pick a picture under **Image source**, or click **Upload**. Dropping a
1. Choose a file above the preview, or click **Load**. Dropping a
picture onto the node works too.
2. Drag the handles on the picture. Cyan squares crop. Orange diamonds add space
on one side. Orange corners make the whole canvas bigger or smaller. The
@@ -28,21 +28,25 @@ uses what the node shows.
From top to bottom:
- **Image source**: the pictures in ComfyUI's input folder. The list shows the
picture under the pointer and filters as you type. **Upload** adds one. The
gear holds the node's display options.
- **File picker**: choose **Inputs** for uploaded pictures or **Outputs** for renders, newest first. The list shows the
picture under the pointer and filters as you type. **Load** adds one. A
picture ComfyUI made, named like `photo.png [output]`, loads too, as it does
in core Load Image. The gear holds the node's display options.
- **The picture**, with its handles.
- **The size line**: each step that sets the output size, ending with the size
the run gives you.
- **The ratio row**: the orientation button, the ratios (1:1, 4:3, 3:2, 16:9,
21:9) and the padlock.
- **Fit** (crop | pad) and **Divisible by**.
- **Fill**, **Feather**, **Resize** and the two **Center** buttons.
- **Crop, pad & resize**: a collapsible group (closed to start, with the fill
swatch and a short summary on its bar) holding:
- **Aspect ratio** and **Fit to ratio** (Crop or Pad), above the orientation button, the ratios (1:1, 4:3, 3:2, 16:9,
21:9) and the padlock.
- **Align** and the two **Center** buttons.
- **Fill** and **Feather**.
- **Resize**, with **Megapixels** and **Step** alongside it when Resize is on.
- **Open editor**, **Reset crop** and **Reset**.
Crop and padding have no number boxes. You set them by dragging, and the size
line shows the result. Picking another picture starts the framing over
(rotation, crop and padding); Fill, Feather, Divisible by, Resize and a lit
(rotation, crop and padding); Fill, Feather, Align, Resize and a lit
ratio stay.
### Ratios, Fit and the padlock
@@ -50,8 +54,7 @@ ratio stay.
Tap a ratio under the picture to pad the picture to it: every pixel stays and
fill bands are added around it, centred. Set **Fit** to crop and the ratio trims
the picture instead. Tap the lit ratio again to go back to the whole picture.
**Fit** only acts on a lit ratio, so it is dimmed and says "pick a ratio first"
until you tap one.
You can set **Fit to ratio** before picking a ratio. If a ratio is already lit, switching between Crop and Pad applies it immediately.
- **A lit ratio is the shape the canvas has now.** Drag a handle to another
shape and the ratio goes dark; the row says **Custom** and the size line under
@@ -84,11 +87,11 @@ sides at once, so the picture keeps its place in the middle. You can press or le
go of Alt during the drag. A corner stops when one of its two sides has no padding
left to take away.
The two **Center** buttons next to Fill, Feather and Resize put the picture in
the middle of the canvas: one side to side, one top to bottom. They move padding from
one side to the other, so the canvas keeps its size and a lit ratio stays lit. A
button is dimmed when the picture is already in the middle or that way has no
padding to move. On a narrow node they sit on a line of their own.
**Center** moves the crop to the middle of the rotated source, side to side or
top to bottom. If there is padding on that axis, the same click also balances
the bands around the crop. The crop and output keep their sizes, and the
generated mask follows the new placement. Each button dims when its axis is
as centered as the pixels allow.
The orange diamonds move one side at a time, and the padlock works on them as
described above. The corners keep the shape with or without the padlock. The
@@ -111,9 +114,9 @@ green rotate knob moves aside when a corner handle sits where it usually goes.
**Method** (`resize_method`) is in the editor, under **Resize output**.
The edge between your picture and the fill stays one clean line at any size.
### Divisible by
### Align
**Divisible by** (`canvas_multiple`) makes the width and height divide evenly by
**Align** (`canvas_multiple`) makes the width and height divide evenly by
the number you pick. Some models need sizes divisible by 8, 16 or 32. At 1 it is
off. Its arrows go 1, 8, 16, 24 and so on; hold Shift to step by 1.
@@ -129,24 +132,23 @@ or bottom.
The line under the picture names every step that sets the output size, in the
order the run applies them, with the size the run emits last and brightest:
`crop 2080×1170 → pad 2208×1298 → round to 64 2240×1344 → resize 1344×768`. A step
that changes nothing is left out, and "round to" is **Divisible by** at work. An
that changes nothing is left out, and "round to" is **Align** at work. An
amber line warns when rounding each side to the Step stretches the picture by
more than 1% (`1.5% taller: each side rounds to 32 px`); its tooltip names a
Step that avoids it, and with Fit on pad, **Even out** adds a few pixels of
padding so nothing stretches. It also warns when the resize undoes **Divisible
by**. Hover the line for the same breakdown line by line; the editor's right
more than 1% (`Resize makes the picture 1.5% taller`); its tooltip names a
Step that avoids it, and with Fit on pad, **Pad to keep shape** adds a few pixels of
padding so nothing stretches. It also warns when the resize undoes **Align**. Hover the line for the same breakdown line by line; the editor's right
panel shows it too.
The amber line keeps still while you drag a handle or a number. If the drag
makes it untrue it goes away, and the line for the new size comes back a
moment after you let go. With Resize on its row is always on the node, with
text or empty, so the controls under it stay where they are.
moment after you let go. The space for the warning stays under the dimensions. On a narrow node, hover
over shortened text to read it in full.
### Reset crop and Reset
**Reset crop** shows the whole picture again without changing rotation or
padding. **Reset** clears rotation, crop and padding and turns the padlock off;
Fill, Feather and **Divisible by** stay.
Fill, Feather and **Align** stay.
## Drawing a mask
@@ -166,8 +168,7 @@ your picture stays under them in **image**, **original** and
the teal.
See-through parts of a PNG show in teal the same way, because the node paints
them too. To hide the teal, click the gear at the top right of the image
source box and turn off **Show the mask on the picture**. The choice applies
them too. To hide the teal, click the gear next to **Load** and turn off **Show the mask on the picture**. The choice applies
to every Image Crop + Rotate + Pad in this browser.
## Outputs
@@ -175,7 +176,7 @@ to every Image Crop + Rotate + Pad in this browser.
- **image**: the turned, cropped and padded picture. Every frame of an animated
image gets the same framing.
- **mask**: White where the model paints: the padding, the empty corners a turn leaves, and the see-through parts of your picture.
- **stitcher**: Wire to Stitch Inpaint to restore the kept canvas around an outpaint result, blended into the source by **Blend** (32 px unless changed). It follows the final resized canvas.
- **stitcher**: Wire to Stitch Inpaint to restore the kept canvas around an outpaint result, blended into the source by **Blend** (32 px unless changed). It follows the final resized canvas. After a hand crop, set **Paste onto** to original in Stitch Inpaint's gear menu to get your whole picture back with the edit in place.
- **original**: your picture before rotation, crop, padding or resize, with
see-through parts shown as white (your picture there when **Painted area**
is on keep).
@@ -231,18 +232,18 @@ for fine steps). The editor adds a few things the node does not show:
- **Rotate → Degrees** (`rotation_degrees`): type an exact turn, clockwise.
Each step is 1°, or 0.1° with Shift. **Reset rotation** sets it back to 0.
- **Padding & mask → Reset padding** removes all padding. The same section has
Fill, Feather, Divisible by and a **Center** row.
Fill, Feather, Align and a **Center** row.
- **Resize output → Method** (`resize_method`): how the resize samples the
picture. `lanczos`, the default, is the sharp one.
- **More**: ratios you added in `ausboss_presets.json` that no button shows.
The list only appears when you have some.
- **Reset view** undoes zoom and pan. **Reset transform**
clears rotation, crop, padding, fill, feather and Divisible by; the picture,
clears rotation, crop, padding, fill, feather and Align; the picture,
resize and stitch settings stay.
- **Preview**: a small picture of the finished output while you work.
A ratio replaces the existing crop and padding and keeps rotation, fill and
resize settings. **Divisible by** and the resize **Step** can slightly change
resize settings. **Align** and the resize **Step** can slightly change
the fitted shape. The size box on the stage sits clear of the handles.
### Handles and gestures
@@ -279,8 +280,8 @@ The editor's right side holds the stitcher's settings, the same as on the clip n
- `fill_color`: `#RGB` or `#RRGGBB`, three numbers (`R, G, B`), one gray
number, or a colour name. Anything it cannot read becomes mid-gray.
- **Corners.** A corner follows the pointer along the canvas diagonal, and the
shape it keeps is the canvas before **Divisible by** rounds it. With
Divisible by on, **Center** counts the fill it adds, so the bands you see
shape it keeps is the canvas before **Align** rounds it. With
Align on, **Center** counts the fill it adds, so the bands you see
come out even.
- **Resize.** It works like core's *Scale Image to Total Pixels*: the budget is
`megapixels × 1024 × 1024`, the shape is kept, and each side rounds to a
+10 -3
View File
@@ -58,7 +58,8 @@ folders. A folder from anywhere else is copied in:
a tile makes it the next.
After the last picture, **At the end** either stops the run with a message
or starts over at the first.
or starts over at the first. With **start over**, Picture goes back to 1 and
the pictures repeat in order for as many runs as you queue.
## The card
@@ -73,7 +74,9 @@ or starts over at the first.
run loads, counted from 1.
- **After a run** (`after_run`): **next** moves Picture on, **stay** keeps
loading the same one, **random** jumps to any of the picked ones.
- **At the end** (`at_the_end`): **stop the run** or **start over**.
- **At the end** (`at_the_end`): after the last picture, **stop the run**
stops with a message, and **start over** goes back to the first and keeps
going.
## The tiles
@@ -104,7 +107,8 @@ or starts over at the first.
There is no setting that changes this.
- The picked pictures are saved in the workflow as a list of names
(`pictures`). Empty means every picture, so a picture added to the folder
later is in too. A name that is no longer in the folder is skipped.
later is in too. A name that is no longer in the folder is skipped. It
takes no wire: the ticks on the node write it.
- Pictures are PNG, JPG, WebP, BMP, GIF (first frame) and TIFF. A photo that
was taken turned comes out upright. Files whose names start with a dot are
skipped.
@@ -116,6 +120,9 @@ or starts over at the first.
their own sizes. They are not resized or stacked into one batch.
- Moving Picture on is done by the editor when a run is queued. A workflow
sent through the API loads exactly the picture `position` names.
- With **start over**, a Picture number past the last picture counts round
from the first. A workflow saved with such a number loads that picture and
moves on from there.
- The node runs again when a picked file is added, removed or rewritten.
- A folder lists up to 5,000 pictures, and a folder that is brought in is
copied up to 3,000.
+9
View File
@@ -8,6 +8,11 @@ wire it to every node that needs it.
- **Value** (`value`): The whole number to send out. It starts at `0`. The
arrows and a drag move it by 1, or click and type. It holds numbers as
big as a seed.
- The gear, right: **Look**. **normal** is the usual box with its label.
**big** shows the number large in the middle of the node, with an arrow
on each side, for a setting people change on every run, such as the
length of a clip. A drag and typing work the same. Each node keeps its own look, and it is
saved with the workflow.
## Limits
@@ -40,3 +45,7 @@ For arithmetic on the way — halving a size, adding a margin — feed this into
- On its own the box goes up to 9,007,199,254,740,991, the largest whole
number a browser holds exactly. It stopped at 2,147,483,647 before, which
most seeds are above.
- The look is stored in the node's properties as `ausboss_text_look: "big"`, the
same as Text 🆎. It is not an input, so the saved value and what the node
sends are the same in both looks, and an older version of the pack shows
the usual box.
@@ -82,7 +82,7 @@ new region can break up; the node says so in the console when it sees it,
and the cure is two passes: pad one axis, run, load the result, pad the
other. **A thin strip counts.** More than 8 px of padding on the other
axis, such as a few pixels left over from a drag, means the source no
longer spans it. **Divisible by** on the Crop + Rotate + Pad nodes and
longer spans it. **Align** on the Crop + Rotate + Pad nodes and
Load Image + Pad's **Multiple** never add a strip on a side you did not
pad. The warning reports the spare pixels on each axis.
+9 -2
View File
@@ -9,7 +9,8 @@ done one at a time, so a long clip does not need more graphics memory.
## Model setup
Place `big-lama.pt` in:
Download [`big-lama.pt`](https://github.com/Sanster/models/releases/download/add_big_lama/big-lama.pt)
and place it in:
```text
ComfyUI/models/lama/big-lama.pt
@@ -20,6 +21,12 @@ refresh the browser or restart ComfyUI so the model list is updated. Until
the file is there, the list still shows `big-lama.pt`, and a run stops with a
message that says where to put it.
The node only opens known copies of this file, whatever you name them: the
download above, or the one from
[fashn-ai/LaMa](https://huggingface.co/fashn-ai/LaMa). A LaMa model file can
run code when it opens, so any other file stops the run with a message and is
never loaded.
## Controls
- **image**: BHWC images or video frames.
@@ -28,7 +35,7 @@ message that says where to put it.
another size is stretched to fit the picture. A picture with no mask
painted on it stops the run with "No mask painted" instead of coming back
unchanged.
- **Model** (`model`): A `.pt` or `.pth` TorchScript checkpoint from `models/lama`.
- **Model** (`model`): A known copy of `big-lama.pt` from `models/lama`.
## Output
+52
View File
@@ -0,0 +1,52 @@
# Load Audio
Loads a sound file and hands on only the part you pick. Use it for a voice
clip, a song or any sound a video model or a save node takes, when you want
one part of the file and not the whole thing.
The node shows the whole file as a wave, so you can see where the loud parts
are. The part that comes out is bright. Drag the white **IN** and **OUT**
handles to set where it starts and stops, drag the bright part to slide it,
or click somewhere else on the wave to move it there. You can also type a time
under the wave, in seconds (`12.5`) or minutes and seconds (`1:02.5`).
Press **▶** to hear only the part that comes out. **LOOP** plays it again and
again.
## Max length
Set **Max length** to the longest sound the next step can take, such as the
length of the video you are making. The start stays where it is, and anything
past the limit turns red with a dashed line where it is cut. Next to OUT,
the node shows how long the sound that comes out is, such as `15.0 s`. It
turns red when part of your pick is cut; hold the mouse over it to see how
much, and click it to move OUT to the cut. Play stops at the cut, so what you
hear is what comes out. `0` means no limit.
Max length can be wired. When the wire comes from a number node such as
Float 🆎, the wave shows the cut for that number; otherwise the length's tooltip says
`max wired` and the run still cuts the sound to the wired length.
## Controls
- **Source** (`audio`): a sound file in ComfyUI's input folder. **Upload**
adds one. Videos are listed as well, for their sound.
- **Max length** (`max_seconds`): the longest the sound may be, in seconds.
`0` means no limit.
- **IN** (`start_seconds`): where the sound starts.
- **OUT** (`end_seconds`): where the sound stops. `0` means the end of the
file.
## Outputs
- `audio`: the part you picked, for any node that takes `AUDIO`.
- `seconds`: how long that sound is.
## Technical details
- Only the picked part is decoded: decoding stops once the window is full,
so a short pick from a long recording stays quick.
- The sound keeps its own sample rate and channels.
- A start past the end of the file stops the run with a message that says so.
- The wave is drawn in the browser from the file itself; nothing extra is
stored with the workflow besides the four values above.
+1 -1
View File
@@ -41,7 +41,7 @@ padding value unchanged.
## Controls
- **Source** (`image`): Choose or upload from ComfyUI's input folder; the list previews the image under the pointer and filters as you type.
- **Source** (`image`): Choose or upload from ComfyUI's input folder; the list previews the image under the pointer and filters as you type. A picture ComfyUI made, named like `photo.png [output]`, loads too, as it does in core Load Image.
- **source_image** (optional socket): Wire an image from another node here
and it is padded instead of the file; the Source row dims while the wire
is connected. The canvas can only show a wired picture after a run, so it
+1
View File
@@ -47,6 +47,7 @@ embedded workflow replaces the whole graph.
## Controls
- **Source** (`video`): A file in the input folder; **Upload** adds new ones.
A video ComfyUI made, named like `clip.mp4 [output]`, loads too.
- **IN** (`start_seconds`): Skip everything before this time.
- **OUT** (`end_seconds`): Stop at this time; `0` means the source end until a
handle is moved.
+85 -9
View File
@@ -29,11 +29,12 @@ come out of one socket, ready to wire into a prompt.
modified date, and trigger words for that LoRA.
Right-click a row to insert an empty LoRA above or below it, move, duplicate,
or remove it. **Remove this LoRA** is red. **Delete all LoRAs…** is a separate red, trash-marked action
or remove it, or to put it in a group (see Groups below). With several rows
picked (Ctrl/Cmd-click or Shift-click), **Remove these LoRAs** removes them
all. **Remove this LoRA** is red. **Delete all LoRAs…** is a separate red, trash-marked action
with confirmation; it clears this node's rows and keeps all files on disk.
Cancel or Escape keeps the stack. Inserting keeps the existing rows and their settings in order;
the new slot uses the gear menu's default strength (initially 1).
The stack holds up to 64 rows.
One strength drives both
model and CLIP by default; the gear menu's **Separate model / CLIP
strength** switch shows a second box per row. The switch is per-node — new
@@ -42,6 +43,60 @@ model/CLIP strengths flips it on automatically so the difference stays
visible. Turning it back off sets every row's CLIP strength to its model
strength.
## Groups
Groups keep a long stack tidy, like layer groups in an image editor. A
group has a header with its own on/off switch, so you can turn a whole set
of LoRAs (all your style LoRAs, say) off and on with one click. Each LoRA
in the group keeps its own switch too: turn the group off and back on, and
the same LoRAs are on as before.
- **Make a group:** right-click a row and choose **Group this LoRA**. To
group several, hold **Ctrl** (**Cmd** on a Mac) and click each row first,
or click one and **Shift**-click another to pick every row between them.
Then right-click one of them and choose **Group these LoRAs**. The new
group appears where the topmost row was, and you type its name.
- **Add to a group:** drag a row by its grip onto the group's name, or
between any two of its rows. Right-click → **Move to group** does the
same.
- **Take out of a group:** drag the row above or below the group, or
right-click → **Take out of** the group.
- **Fold:** the arrow next to the group's switch hides its rows and makes
the node shorter. Folded rows still run.
- **Rename:** double-click the group's name.
- Right-click a group's header to move the whole group, fold it, turn all
its LoRAs on or off, ungroup it (the LoRAs stay), or remove it together
with its LoRAs. Drag the header's grip to move the whole group.
- **Turn a group on and off from a Workflow Switch:** save the switch once
with the group on and once with it off. The group's header then shows a
lock, and the switch turns the whole group on and off. The LoRAs inside
stay yours to turn on and off. Click the lock to take the group back.
Right-click the empty space around the rows (or under **+ LoRA**) to add
LoRAs, group the rows you picked, fold or open every group at once, or
delete all LoRAs.
A plain click anywhere clears a Ctrl/Shift selection, and so does Escape.
The count on a header (`2/3 on`) shows how many of its LoRAs are on. While
the group is off it reads `group off`, and its LoRAs are dimmed.
### Technical details
LoRAs still apply in the order you see, top to bottom; a group changes
nothing about how the stack runs. Groups are one level deep, and a group
lasts while it has a LoRA in it. Each row keeps its group's name in the
saved stack, so groups travel with templates, undo, and rows shown on a
subgraph box. Which groups are folded is saved with the node. A group that
is off marks each of its rows with `group_off` in the saved stack: none of
them runs, and each keeps its own on/off. A row dragged into an off group
goes off with it, and a row taken out runs by its own switch again. A
renamed group keeps its on/off and its Workflow Switch. The switch at the
top of the node turns every row and every group on. Move up and Move down step a row out of the edge of
its group, or into a group next to it, one step at a time. While you drag,
the drop line starts indented when the row would join a group. Just below
a group's last row is outside the group; to add a row at a group's end,
drop it on the group's name.
## Rows on a subgraph box
When the loader sits inside a subgraph, its rows can show on the box
@@ -75,6 +130,10 @@ over: the switch forgets that row and leaves it alone from then on, and
stops reading "changed" because of it. Everything else the switch does
stays. A locked row still drags to a new place.
A group that a switch turns on and off shows the lock on its header
instead. The LoRAs in it keep plain grips, so each one is still yours to
turn on or off. Click the header's lock to take the group over.
## Strength bars
Every named row paints a center-zero bar behind its name: teal grows right
@@ -110,7 +169,14 @@ its height on rows, not chrome. Left to right:
**+ LoRA** opens one list of files grouped by folder. Click a file to add
one LoRA, or click a folder name to add all its LoRAs, including nested
folders. Use the arrow beside a folder to collapse or expand it without
folders. To add several files from different folders, tick the box beside
each one (or **Ctrl**-click its name), then click **Add N LoRAs** at the
bottom or press **Enter**. **Shift**-click ticks every file between the
last one you ticked and the one you click. Ticks stay while you search, so you can
gather files from several searches; **Clear** unticks them all. Files that
are already in the stack say **in stack** and have no box. With the
keyboard, **Ctrl+Enter** ticks the highlighted file and **Shift+Enter**
ticks the run up to it. Use the arrow beside a folder to collapse or expand it without
adding anything. Folded folders are remembered in this browser. Search
reveals matching files; clearing it brings back your folded view. You can
also fold folders while searching.
@@ -122,9 +188,7 @@ their order, strengths and switches. The same filename in different folders
counts as separate LoRAs.
The choice is one undo step. Closing the picker without choosing changes
nothing. If a folder would take the stack past 64 rows, nothing is added
and a message says how much room is needed. Remove rows or pick a smaller
folder. Typing a filename and pressing Enter picks one file; use the arrow
nothing. Typing a filename and pressing Enter picks one file; use the arrow
keys to highlight a folder before Enter if you want the whole folder.
Pointing at a folder with the mouse does not choose it for Enter.
Left and Right collapse and expand a highlighted folder.
@@ -189,7 +253,7 @@ Details that keep the absorb faithful:
rows from a loader without a connected CLIP import with CLIP strength 0
- connected auxiliary outputs, such as trigger words, block absorption because
bypassing their source would break those connections
- linked LoRA settings and stacks beyond the row limit leave the sources active
- linked LoRA settings leave the sources active
- already-bypassed or muted loaders contribute no rows
- a toast summarizes what was imported, bypassed, remapped, and skipped
@@ -236,6 +300,14 @@ or the bare `.png` / `.jpg` / `.jpeg` / `.webp`). No sidecar, no thumbnail —
nothing appears and nothing shifts. The gear menu's **Preview thumbnails**
switch turns the behavior off.
**Get the pictures from Civitai:** click **Fetch Civitai info** (or
**Refresh Civitai info**) in a LoRA's info card. It also saves the LoRA's
first picture from Civitai next to the file, and the hover and the info card
show it right away. Gear menu → **Civitai picture** picks which pictures:
**PG-13** (the default) skips pictures Civitai rates R or above, **any**
takes the first picture whatever its rating, and **off** saves none. It never
replaces a picture you already have.
## Trigger words
The info card gathers words from three places: the LoRA file's own metadata,
@@ -252,10 +324,14 @@ switched on for the old one.
- **Fetch Civitai info** looks this one LoRA up on civitai.com. Nothing is
sent until you click. Your browser then sends only the file's SHA256
fingerprint, and the answer is saved next to the LoRA as
`<model>.civitai.info`. After that the button reads **Refresh Civitai
`<model>.civitai.info`, with the LoRA's picture as `<model>.preview.jpg`
(see "Get the pictures from Civitai" above). After that the button reads **Refresh Civitai
info** and the card gets a **View on Civitai** link. A LoRA Civitai does
not know (unpublished or hidden) reads **Not found on Civitai**. Gear
menu → **Civitai lookup button** removes the button.
menu → **Civitai lookup button** removes the button. When a lookup fails,
a message says why. If it always says it could not reach civitai.com,
check whether ComfyUI was started with `--disable-api-nodes`: that setting
stops the browser from reaching any other site, Civitai included.
- **Suggested strength**: a lowest and a highest value at the bottom of the
card (leave one empty for "any"). It is only a reminder: a row set outside
it shows its number in orange, and the tooltip gives the range. Saved per
+4
View File
@@ -208,6 +208,8 @@ nothing instead.
Picks belong to the picture they were made on. Load another picture and the
node shows it with nothing picked, and says the old picks are not used.
If the workflow runs before the node has shown the new picture, the run
stops and says the picks were made on another picture.
- With a Load Image wired straight in, or through Reroute nodes, the
picture shows as soon as you choose a file.
@@ -277,6 +279,8 @@ no browser:
]}
```
- `touches` takes no wire, and neither does `ask` (below): the node's
picture writes them. From the API, send them as values.
- A `paint` touch may carry **`"s"`**, how soft the brush's edge was (1 to
100); without it the brush is hard. A soft brush gives a mask with values
between 0 and 1 along its edge.
+8
View File
@@ -8,6 +8,14 @@ Pick a picture size by shape and megapixels, or by dragging. Use it in place of
- **Gear:** set the **Snap step**, edit the **Ratio rail**, choose the **Latent family** (`latent`) and **Batch size** (`batch_size`), and hide the **Dot grid** or **MP cost curves**. **Snap step** (8, 16, 32 or 64; starts at 32) is the grid that drags, arrow keys and chips land on. The **Ratio rail** is the list of ratio chips, typed as `1:1, 21:9`. The display options apply to every Latent Size node in this browser; latent family and batch size (1 to 64) belong to this node.
- **Node size:** drag the node's corner to make it wider or taller, so it lines up with the nodes around it. The controls stretch with it, and the canvas preview takes any extra height. It can be as narrow as 320 pixels.
## Workflow resolutions
In the gear, choose **This workflow → Workflow resolutions → Edit list**. Enter one exact width × height per line, up to 24. The list belongs to this node and travels with the saved workflow. An empty list leaves the normal editor unchanged.
- **Recommendations:** leave **Limit choices to this list** off. The Sizes row shows your quick picks, and users can still type, drag or choose another shape.
- **Strict model sizes:** turn the limit on. The Shape row shows only the exact ratios in the list. Select a ratio to see its allowed sizes. For example, 576×1024 and 720×1280 share 9:16; 480×864 is a separate 5:9 option. Landscape is available only if you list a landscape size. The canvas stays visible, but it cannot be stretched. The swap button works only if the exact reverse size is listed.
- On enabling the limit, an unlinked size stays selected if allowed; otherwise the first listed size is selected. A linked width or height is checked at run time, before the latent is allocated. An unsupported pair produces an error naming the allowed sizes. For video workflows, connect these width and height outputs to the video latent node.
## Outputs and links
Outputs are **width**, **height**, and **latent**, in that order. The node has four input sockets above the panel: width, height, latent and batch_size. Linking width or height locks the controls that would change both dimensions; the other dimension remains editable. Linking latent or batch_size sets the latent family or batch size from another node, and the gear then shows that setting as linked. A linked value is read when the workflow runs, so the preview keeps showing the size stored on the node.
+12 -2
View File
@@ -41,9 +41,9 @@ file's name. A caption on the `caption_text` input writes the paired `.txt`.
workflow in EXIF. **JXL** (`jxl lossless`, lossless JPEG XL) is the most
compact; it needs the optional `pillow-jxl-plugin` in ComfyUI's python
(also the pack's `jxl` extra) and few browsers preview it.
- **Embed workflow** (`save_metadata`): **yes** stores the prompt and
- **Embed workflow** (`save_metadata`): **On** stores the prompt and
workflow in the file (PNG text chunks, EXIF in webp and jxl) so it drags
back into ComfyUI; **no** writes a clean file for sharing or datasets.
back into ComfyUI; **Off** writes a clean file for sharing or datasets.
`--disable-metadata` on the server wins.
- **Preview** (`preview`): the small switch under the card shows the saved
image on the node. Off, the picture's box goes away and the node is
@@ -52,6 +52,16 @@ file's name. A caption on the `caption_text` input writes the paired `.txt`.
still lists it. When a run saves more than one image (a batch, or a list
such as a folder of images), arrows beside the switch (‹ 2 / 5 ›) step
through them.
- **Steps** (`step_preview`, off by default): the switch left of
**preview** shows the picture while it is being made, one step at a time,
where the saved image goes, with a **STOP** button on the bar. Stop a run
that is going wrong after the first steps instead of waiting for the end;
a stopped run saves nothing and says so on the picture. Once the image is
saved it replaces the steps. With several samplers, the node shows the one
that feeds it. During an upscale in tiles it keeps the whole picture and
says "upscaling in tiles" instead of showing each tile. Display only: the
saved file is the same with it on or off.
The [Step Preview](AUSBOSS_NODES_StepPreview.md) page has the details.
## Inputs
+9 -2
View File
@@ -9,6 +9,13 @@ duration, shows a tall video whole, and has a **LOOP** control. `gif` and
`webp` are shown as pictures with just the file name, since a video player
cannot play either.
**Steps** above the player (`step_preview`, off by default) shows the clip's first frame in
the player while it is being made, one step at a time, with a **STOP**
button on the player. Stop a clip that is going wrong after the first steps
instead of waiting for the end; a stopped run saves nothing. The saved
video replaces the steps. The [Step Preview](AUSBOSS_NODES_StepPreview.md)
page has the details.
## Controls
- **frames**: BHWC frame batch to encode. For the subsampled formats, odd
@@ -30,7 +37,7 @@ cannot play either.
- **Ping-pong** (`pingpong`): Plays the clip forward then backward so it loops seamlessly.
Roughly doubles the frame count and the encode time. The first and last
frames are not repeated at the turnaround, so the loop does not hitch.
- **Metadata** (`save_metadata`): On by default. Turn it off to share a file
- **Embed workflow** (`save_metadata`): On by default. Turn it off to share a file
without your prompt and node graph embedded in it. If ComfyUI was started
with `--disable-metadata`, nothing is embedded whatever this says.
- **audio** (optional): A track to mux into the file, e.g. Load Video's
@@ -76,7 +83,7 @@ metadata, so an embedded workflow is not available for them.
The node's face follows the table: widgets the chosen format ignores are
hidden rather than left to look meaningful. The CRF field leaves the
**FPS · CRF** row for `mov prores`, `mkv ffv1` and `gif` (their rows read
*ignored* above), and the **Metadata** row disappears for `gif` and `webp`,
*ignored* above), and the **Embed workflow** row disappears for `gif` and `webp`,
which cannot carry it. Hidden is not removed:
the widget keeps its position and its value, saved workflows store and restore
it exactly as before, and switching the format back brings it back with the
+9
View File
@@ -27,6 +27,15 @@ needs.
- The line underneath shows the last run's seed. The history saves with the
workflow.
## Compact look
The **gear** beside the number picks the look. **Compact** is one short
row: the number, a mode chip and a dice. Tap the chip to go Random → Fixed →
+1 → −1. The dice rolls a new seed and pins it. A long seed shrinks to fit.
Hover the number to see the last run's seed. The look is saved on the node,
and new Seed nodes start in the look you picked last. **Full** brings back
the whole card, with the seeds it ran with.
## Output
- **seed**: The seed, unchanged.
+68
View File
@@ -0,0 +1,68 @@
# Step Preview
Shows the picture while it is being made, one step at a time, with a
**Stop** button. If the picture is going wrong (the pose, the framing, an
extra arm) you can see it after the first few steps and stop the run,
instead of waiting for the last step.
Put it anywhere on the canvas. It needs no wires: it shows whichever sampler
in the workflow is making a picture right now, and the caption under the
picture says how far it is ("step 3 of 8").
## The buttons
- **STOP**: stops this run now. Nothing after the sampler runs, so nothing
is saved. The next run in the queue still starts.
- **STOP ALL · N**: shows when more runs are waiting in the queue. It
empties the queue and stops this run.
As soon as the run saves the picture, the finished picture replaces the
blurry last step, and it stays on the node until the next run starts. The
word at the right of the bar says how the run ended: done, stopped or
failed. A stopped run keeps the last step it reached.
An upscale in tiles works on one small piece of the picture at a time.
Those pieces are not shown as the picture: the node keeps the last whole
picture and says "upscaling in tiles", with the steps of the piece being
worked on.
## Save Image and Save Video can do it too
Save Image 🆎 and Save Video 🆎 have a **steps** switch of their own (off by
default). On, the picture area of the save node shows the steps of the
picture it is about to save, with the same Stop button. Use the save node's
switch when you look at the result anyway, and this node when you want a
bigger view, or one view for a workflow with several samplers.
## Sharper step pictures
The step pictures are small and blurry. That is enough to judge the pose
and the layout. Some models have a small preview decoder (a TAESD file)
that makes them sharper: put it in ComfyUI's `models/vae_approx` folder and
ComfyUI uses it.
## Technical details
- ComfyUI only makes step pictures when its preview method is something
other than `none`, and a fresh install starts with `none`. When a queued
workflow holds this node, or a Save Image or Save Video with its steps
switch on, the pack asks for the `auto` method for that one run.
- It never overrides a choice you made: if you picked a method in
ComfyUI's **Live preview method** setting, or started ComfyUI with
`--preview-method`, that one is used. `--preview-method none` on the
command line is treated as "not chosen", since it is the default.
- `auto` uses the model's TAESD decoder from `models/vae_approx` when it is
installed, and a quick color estimate (latent2rgb) otherwise. Some models
have neither (SeedVR2, for one), and then no step pictures come.
- The pictures are at most 512 px on their long side (`--preview-size`).
They do not change the result: the same seed makes the same picture with
the steps shown or not.
- They cost next to nothing. Measured with Qwen Image 2.1 on one desktop:
8.50 s with the steps off and 8.40 s on for 8 steps, 17.62 s and 17.64 s
for 25 steps, the same picture every time.
- While previews are on, a ComfyUI sampler that sits on the top level of
the workflow (not inside a box) shows the steps on its own face too, as
it does whenever previews are turned on in ComfyUI. The node grows to
hold the picture and keeps the last step after the run.
- Runs queued from another workflow tab are not shown here.
- With video models the step picture is the clip's first frame.
+41
View File
@@ -7,6 +7,22 @@ from **Crop For Inpaint 🆎**, **Load Image + Pad 🆎** or any
result, it is resized to fit first, then joined to your picture the way
**Seam** says.
## Paste onto: crop or original
**Paste onto** picks what you get back. It lives in the gear menu, next to
Seam.
- **crop** (the default) gives back the picture you framed.
- **original** puts the edit on your whole picture, at its full size. Use it
after you crop by hand in **Image Crop + Rotate + Pad 🆎**: the model works
on the crop, and everything you did not paint stays exactly as it was. It
also works when you turned or resized the crop.
Padding has no place on your picture, so anything painted in added space is
left out. With **Crop For Inpaint** and **Load Image + Pad** the result is
your whole picture either way. The video nodes don't have this yet: their
result stays on the crop, and the console says so once.
## Seam: classic or blend in
**Seam** picks how the new area joins your picture. It lives in the gear
@@ -60,6 +76,17 @@ are in the model's own picture.
4n+1 (Wan) frames and drop the tail - so a long run is never thrown away
over the count. More inpainted frames than source frames is still an error.
## Pictures with see-through parts
A picture with a clear background, such as a cut-out, keeps it. Most
models paint colour only, so the new area takes your picture's own
see-through parts where it lands: what was clear stays clear, and the edit
shows where your picture is solid. A model that paints see-through parts
of its own (Qwen Image 2.1 can) has them blended in like the colours.
So with a colour-only model, new things painted into a clear area stay
hidden. Fill that area in first if the edit needs it.
## Controls
- **stitcher**: The stitcher from one of the nodes above. It carries the
@@ -70,6 +97,8 @@ are in the model's own picture.
- **Seam** (`seam`, in the gear menu): `classic` (the default) or
`blend in`. Read "Seam: classic or blend in" above. It has no socket,
so a link cannot drive it.
- **Paste onto** (`paste_onto`, in the gear menu): `crop` (the default) or
`original`. Read "Paste onto: crop or original" above. It has no socket.
- **Fix edge halo** (`fix_edge_halo`, classic seam only): Off by default. Recovers the true
color under the feathered seam before pasting, so half-transparent edge
pixels stop blending their background in a second time. It costs real
@@ -207,6 +236,8 @@ you brighten it first.
## Outputs
- **image**: The original-size image with the inpainted region blended in.
After a crop in a Crop + Rotate + Pad node, that means the crop's size,
unless **Paste onto** is on original.
- **blend_mask**: The feathered paste mask in the stitched image's own
coordinates — white where the inpaint blended in, zero where the original
survived untouched. Wire it into a color-match or compositing node to
@@ -222,6 +253,16 @@ you brighten it first.
The rest of this page is for people who want to know how blend in works
and how it was tested.
### How Paste onto original works
Image Crop + Rotate + Pad's stitcher carries your picture and the crop's
position. A straight crop at its own size is a plain paste, so every pixel
the mask does not touch is bit-identical. A turned or resized crop is sampled
back (bicubic) once per picture pixel, so the edit in those cases is as soft
as the canvas it was painted on. Untouched pixels stay bit-identical there
too, and in the feathered band your full-size picture replaces the canvas's
softer copy of it.
### How the empty check works
The new area is cut into 16 px blocks, and only blocks fully inside it are
+10 -9
View File
@@ -11,21 +11,22 @@ generated-area `mask`, a `stitcher` for Stitch Inpaint, the selected
## Source and frame
- **Uploads**: Pick an input video (the list plays the clip under the pointer, muted, and filters as you type), click **Upload** in the compact source card, or
- **Inputs**: Pick an input video (the list plays the clip under the pointer, muted, and filters as you type), click **Load** in the compact source card, or
drop a video file onto the node. Picking another video keeps Fill, Feather,
**Divisible by**, the Resize settings and the lit ratio button (the new frame is
**Align**, the Resize settings and the lit ratio button (the new frame is
fitted to it the way **Fit** is set); rotation, crop, padding and the playhead
start over.
- **Canvas row** under the Fit row: fill swatch, feather amount and the Resize off | on switch on the node face; turning Resize on opens the megapixel budget and its Step. The line under the picture names each step that sets the output size, as on the image node.
- **Upload** streams to the input folder, so videos larger than the buffered image-upload limit can be selected here or dropped onto the node.
- **Uploads** / **Outputs** (`source_mode`: `input folder` / `local path`): the source card's switch. Uploads lists ComfyUI's input folder. Outputs lists the videos in ComfyUI's output folder, newest first, so a video you just rendered can be picked without uploading it again. Both lists preview the video under the pointer.
- **Load** streams to the input folder, so videos larger than the buffered image-upload limit can be selected here or dropped onto the node.
- **Inputs** / **Outputs** (`source_mode`: `input folder` / `local path`): the source card's switch. Inputs lists ComfyUI's input folder. Outputs lists the videos in ComfyUI's output folder, newest first, so a video you just rendered can be picked without uploading it again. Both lists preview the video under the pointer.
- **local_path**: The video picked in the Outputs list, named the way ComfyUI names a file
in its output folder: `clip.mp4 [output]`, `video/take 3.mov [output]`. It is read in
place, without an upload copy. A whole path such as `/home/me/ComfyUI/output/clip.mp4`
is not read, for queued runs and the editor's live preview alike.
- **Playhead**: The rail under the node's preview is the timeline: press or drag it to
- **Play / Pause** beside the scrubber previews the video. Pause on the frame you want to output.
- **Playhead**: The rail directly under the node's preview is the timeline: press or drag it to
scrub, or type a frame in the **FRAME** box; the frame on the stage is the frame the
node outputs (`frame_index`, with `frame_time` kept in step).
- **Crop, pad & resize** (collapsible group under the timeline): folds the ratio buttons, **Fit to ratio**, **Align** and **Center**, **Fill** and **Feather**, and the **Resize** off | on switch, megapixel budget and **Step** into one drawer, showing the fill swatch and a live summary on its header when closed. The line under the picture names each step that sets the output size, as on the image node.
- **seek_mode / frame_time**: Time-based seeking for graphs that drive the position by
link; the editor itself always works in frames.
@@ -33,19 +34,19 @@ The editor has the same rail full width, plus first/last, ±1, ±25, ±50, ±100
## Transform and outputs
Rotation, crop, padding, feathering, fill, **Divisible by**, the megapixel resize, gestures, `image`, and `mask` match the image node. The output batch contains one frame.
Rotation, crop, padding, feathering, fill, **Align**, the megapixel resize, gestures, `image`, and `mask` match the image node. The output batch contains one frame.
The handles work on both the node and the editor. Drag an orange corner to make
the canvas bigger or smaller in its own shape (hold **Alt**, Option on a Mac, to
change all four sides at once), and press **Center** next to Fill and Feather to
put the frame in the middle, side to side or top to bottom. Both work as on the
center the crop and balance any padding, side to side or top to bottom. Both work as on the
image node. The ratio buttons, **Fit**,
the padlock and the orientation button work as on the image node: a tap pads (or,
with Fit on crop, crops) the frame to a ratio, a lit ratio is the shape the canvas
has now, and the padlock keeps that shape while you drag (the row says **Held**).
**Reset crop** restores the full crop while keeping rotation and padding. **Reset**
clears rotation, crop and padding and turns the padlock off; fill, feather,
**Divisible by** and the playhead stay. **Divisible by** makes the width and
**Align** and the playhead stay. **Align** makes the width and
height divide evenly by the number you pick: padding grows by a few pixels, and
a side with no padding is trimmed by a few instead. Some models need sizes
divisible by 8, 16 or 32; 1 turns it off.
+30 -27
View File
@@ -11,33 +11,20 @@ model that paints black regions.
## Source and window
- **Uploads**: Pick an existing input video (the list plays the clip under the pointer, muted, and filters as you type), click **Upload**, or drop a video file
- **Inputs**: Pick an existing input video (the list plays the clip under the pointer, muted, and filters as you type), click **Load**, or drop a video file
onto the node. The old source widgets remain compatible with saved workflows, but
are now driven by this compact card. Choosing another video keeps the canvas
settings - Fill, Feather, **Divisible by**, Resize, Every nth, Frames for, Length - and
settings - Fill, Feather, **Align**, Resize, Every nth, Frames for, Length - and
fits the new clip to the lit ratio button, the way **Fit** is set. Rotation, crop,
padding, IN/OUT and the playhead start over.
- **Canvas row** (under the Fit row): the fill swatch, the feather amount and
the Resize off | on switch, right on the node; turning Resize on opens the megapixel
budget and its **Step**. These are what a video outpaint model keys on - the LTX IC-LoRA paints
**pure black** at sizes rounded to 32 - so a wrong value shows here before a
render is wasted. Feather only softens the mask and the stitch; the frames always
meet the fill with a hard edge. The line under the picture names each step that
sets the size (`576×1024 → pad 1821×1024 → resize 1280×704`) and warns in amber
when the Step stretches the picture by more than 1% (`2.2% wider: each side rounds
to 32 px`); the tooltip names a Step that avoids it, and in pad mode **Even out**
adds a few pixels of padding so nothing stretches. While you drag, that amber
line keeps still and comes back for the new size when you let go. The bands are drawn in the real
fill colour with a faint hatch, so black bands show on the black stage. A fresh clip node starts
with black and feather 0 and the editor's **Reset transform** returns to them;
the image nodes keep their grey fill and feathered mask.
- **Outputs** (`source_mode` / `local_path`): Pick a video from ComfyUI's output folder,
such as one you just rendered. The list shows the newest first and previews the video
under the pointer. The video is read in place, without an upload copy. In an API prompt
it is named the way ComfyUI names a file there: `clip.mp4 [output]`. A whole path such
as `/home/me/ComfyUI/output/clip.mp4` is not read, for queued runs and the editor's live
preview alike.
- **Timeline**: The rail on the node and in the editor is one timeline. Press or
- **Play / Pause** beside the scrubber previews the selected frames and stops at OUT. Press Play at OUT to start from IN again.
- **Timeline** (right under the preview and size line): The rail on the node and in the editor is one timeline. Press or
drag anywhere on it to scrub the **playhead**, with Length on or off; the stage shows
that frame. Drag the **IN** or **OUT** handle to trim: the playhead rides on the handle, so what you see
is the first (IN) or last (OUT) frame the run keeps, and it stays there when you let
@@ -53,6 +40,21 @@ model that paints black regions.
**Play**. Arrow keys step one frame (10 with Shift), Home and End jump to the
ends, and Space plays or pauses. **Save & close** keeps your edits; **Cancel** or
Escape puts everything back, and asks first if anything changed.
- **Crop, pad & resize** (collapsible group under the timeline): folds the ratio
buttons, **Fit to ratio**, **Align** and **Center**, **Fill** and **Feather**, and
the **Resize** switch, megapixel budget and **Step** into one drawer. While closed,
its header bar shows the fill colour swatch and a live summary of the active
ratio, feather, alignment and resize settings. These are what a video outpaint model keys on - the LTX IC-LoRA paints
**pure black** at sizes rounded to 32 - so a wrong value shows here before a
render is wasted. Feather only softens the mask and the stitch; the frames always
meet the fill with a hard edge. The line under the picture names each step that
sets the size (`576×1024 → pad 1821×1024 → resize 1280×704`) and warns in amber
when the Step stretches the picture by more than 1% (`Resize makes the picture 2.2% wider`); the tooltip names a Step that avoids it, and in pad mode **Pad to keep shape**
adds a few pixels of padding so nothing stretches. While you drag, that amber
line keeps still and comes back for the new size when you let go. The bands are drawn in the real
fill colour with a faint hatch, so black bands show on the black stage. A fresh clip node starts
with black and feather 0 and the editor's **Reset transform** returns to them;
the image nodes keep their grey fill and feathered mask.
- **Length** (`max_frames`): the one other way to set how long the clip is. **Off**
(the default): OUT ends the clip. **On**: a number of output frames from IN; OUT
sits that many frames after IN and follows it when you drag IN, and dragging OUT or
@@ -80,8 +82,8 @@ Crop squares, padding diamonds, the orange corners and the rotation handle also
work directly on the node preview. A corner makes the canvas bigger or smaller in
its own shape, for example to zoom out evenly before a video outpaint; hold **Alt**
(Option on a Mac) to change all four sides at once. **Center**, next to Fill and
Feather, puts the clip in the middle of the canvas side to side or top to bottom
without changing the canvas size. The graph still owns wheel zoom and middle-button
Feather, centers the crop on the source and balances any padding on the selected axis.
The generated mask follows the crop and padding. The graph still owns wheel zoom and middle-button
pan. Tap a ratio
to pad the clip to it (or crop it, with **Fit** on crop); tap the lit ratio again to
go back to the whole frame. A lit ratio is the shape the canvas has now: drag a
@@ -92,31 +94,31 @@ ratio or turn it"); otherwise it names the lit ratio where it acted (`pad to 16:
1821×1024`). The padlock keeps the shape while
you drag, with new bands split evenly, and the row says **Held**; Reset and tapping
the lit ratio turn it off, and it does nothing on the untouched clip. **Fit** is
dimmed until a ratio is lit. The orientation button turns the shape on its side
ready to set before picking a ratio. The orientation button turns the shape on its side
(16:9 to 9:16), and dragging the picture itself moves it inside its padding: the
cursor and small arrows show which ways it can go.
**Reset crop** restores the full crop without changing rotation, padding or trim;
with the padlock on it also takes away the bands the lock added.
**Reset** on the node clears rotation, crop and padding and turns the padlock off;
fill, feather, **Divisible by** and the timeline stay.
fill, feather, **Align** and the timeline stay.
**Reset transform** in the editor resets rotation, crop, padding, fill, feather and
**Divisible by**; it keeps the source, current frame, trim, Length, resize and stitch
settings. **Divisible by** makes the width and height divide evenly by the number
**Align**; it keeps the source, current frame, trim, Length, resize and stitch
settings. **Align** makes the width and height divide evenly by the number
you pick: padding grows by a few pixels, and a side with no padding is trimmed by
a few instead. Some models need sizes divisible by 8, 16 or 32; 1 turns it off.
Video Upload and file drop use a streaming route into ComfyUI's input folder,
The **Load** button and file drop use a streaming route into ComfyUI's input folder,
so the buffered image-upload size limit does not prevent long-video uploads.
The editor's sidebar uses the node's own ratio row, padlock and **Fit** switch, and
the rotation is a number box (Shift for 0.1°). A ratio replaces the old crop and
padding, keeping rotation and resize settings.
Pixel rounding can differ by one pixel between opposite bands. **Divisible by** and
Pixel rounding can differ by one pixel between opposite bands. **Align** and
the resize **Step** can slightly change the final aspect ratio.
## Transform and outputs
Rotation, crop, padding, feathering, fill, **Divisible by**, and the editor's handles
Rotation, crop, padding, feathering, fill, **Align**, and the editor's handles
match the image node. **Resize output** in the editor scales the finished frames to a
megapixel budget with a resolution step (32 keeps LTX and Wan sizes), the same trio as
the core Scale Image to Total Pixels node. The frames are resized by themselves and the
@@ -161,7 +163,8 @@ Five optional sockets sit on the left, above the editor:
| `start_frame` | Zero-based source start frame. Overrides timeline IN. |
| `end_frame` | Exclusive source end frame. 0 means the end of the source. Overrides timeline OUT. |
| `fixed_frames` | Exact output frames from IN; the source must be long enough. Connected, it sets the length: OUT, Length and Frames for step aside. |
| `frame_load_cap` | Maximum frames after rate conversion and Every nth, before Frames for. 0 means unlimited. Connected, it sets the length in place of Length. |
| `frame_load_cap` | Maximum frames after rate conversion and Every nth, before Frames for. 0 means unlimited. Connected, it sets the length in place of Length. When the count is known, dragging IN stops at the last start that fits the kept frames. |
| `last_seconds` | Load only this many seconds from the end of the clip, or of the part before OUT. 0 loads all of it. Use it to carry on from a clip: a long clip then costs no more memory than a short one. |
Frame bounds use the source's frame-rate grid, independent of `force_rate`.
The footer labels **Source fps** separately from **Output fps**. A direct
+23
View File
@@ -18,14 +18,37 @@ Nothing runs: the node has no outputs, so ComfyUI never executes it.
- **Title / Subtitle / Author** make the banner. **Layout → Banner** shows
only that top block (the title, plus the subtitle and author if you filled
them in), so the same node doubles as a label for a stage or column.
- **Layout → Info tile** puts a single button on the canvas. Click the tile to read the full note, including models and links. A book marks a guide and a download arrow marks a model list. Click its pencil to edit, or press **Edit** inside the opened note. Drag the visible bottom-right grip to resize the tile without moving it. Focus the grip and use arrow keys for keyboard resizing; **Shift** makes smaller changes.
- **Accent color** tints the banner bar, dots and buttons; empty keeps the
pack teal. It takes a six-digit hex such as `#00b4aa`.
- **How-to** is Markdown-lite: `#`/`##`/`###` headings, `**bold**`,
`*italic*`, `` `code` ``, `[links](https://…)`, `-` and `1.` lists, `---`
rules, fenced code. Only `http(s)` links become clickable.
## Formatting a how-to
Select a phrase in the How-to field and click **Teal**, **Green**, **Amber** or **Glow**. Click **Table** to insert an editable Markdown table. Open **Preview formatting** while you write. Use headings to break up steps and a pipe table for settings or file locations. Keep each row the same length. Use `\|` for a literal pipe inside a cell.
```markdown
## Choose a size
| Size | When to use it |
| --- | --- |
| 480 × 864 | [teal]First run[/teal] |
| 576 × 1024 | A larger portrait |
[green]Ready[/green] means the model was found.
[amber]Missing[/amber] means you still need the file.
```
For a short highlight, write `[glow]Preview the result[/glow]`. The glow is static. These four tags are AusBoss additions to Markdown. They style the enclosed plain text. Headings, lists, table cells and paragraphs can all contain them. Fenced code displays the original characters.
## Models
To give downloads their own button, choose **Layout → Info tile**, set the title to **Download models**, and add files under **Models**. To keep them with your instructions, add those same rows to the **How to use** note. Switching an existing card to an info tile keeps its model rows and links.
[Download the Krea 2 model tile example](https://raw.githubusercontent.com/ausboss/ComfyUI-AusBoss/main/js/docs/examples/model-download-tile.json) and open it as a workflow. Copy the tile into your graph and edit its rows.
Each row is a file, the model folder it belongs in, an optional size, a
download URL, and an optional note that shows in grey under the file name.
Rows group under a `📂 ComfyUI/models/<folder>` heading so the
@@ -0,0 +1,7 @@
# Workflow Note Tile
A compact help button that opens a full note. Click to read the instructions and model links; click the pencil to edit. Drag its visible corner to resize.
It uses the same note content as [Workflow Note](AUSBOSS_NODES_WorkflowNote.md). Choose **Card** or **Banner** in the editor to show the note directly on the canvas. Nothing runs and it has no outputs.
Tiles save with their own node type. If ComfyUI reports this node missing, install or update **ComfyUI-AusBoss**, restart ComfyUI, and reload the workflow. An older pack cannot draw the tile correctly. Keep the missing node so its instructions and settings are preserved.
+75 -9
View File
@@ -14,13 +14,43 @@ an upscale at the end.
2. Press **+ Switch from 2 selected** on the card.
3. Type a name and press Enter.
One click on its pill now turns every node you picked off, and the next click
turns them on again.
Click **off** to skip every node you picked, or **on** to run them again.
Clicking the selected half leaves it selected.
Groups work too. Every group on the canvas gets a switch of its own, unless
you change that under **Settings**. A group can also be part of a switch you
make: select it together with the nodes.
## Pick between saved settings
Use this when the same node should be set one of a few ways and you want to
pick by name: 8 steps or 20 steps, or which branch to run.
1. Set the node the way the first choice should be, and select it.
2. Press **+ Choice**, type a name and press Enter.
3. Set the node the second way and press **+ Choice** again. You don't need
to select it this time.
The card is now one pill with your choices. Click one and the node goes back
to how you saved it. Only the settings that differ between your choices are
changed, so the rest stay yours. Add a third the same way.
To run one of two branches, wire both into ComfyUI's **If/Else Switch**, where
they meet, and make one choice with its switch at true and one at false. Only
the branch you pick runs. Both branches must end in the same type.
A missing model or picture stops the run even in the branch you didn't pick,
because ComfyUI checks every node that is on. To avoid that, select the nodes
only one choice needs, open that choice's three dots and pick **The selected
run only with this choice**. They switch off while another choice is picked.
Do this for each branch, and leave out any node both branches use.
A card holds either choices or switches, so **+ Choice** shows on a card with
no switches yet, once a node is selected. To have both, use two cards.
To change a choice later, set the node, open the choice's three dots and pick
**Save how it is set now**.
## A switch that changes settings
A switch can also change values instead of turning nodes off. One click can
@@ -45,8 +75,13 @@ does not change keep whatever you give them. The same works for the LoRA
rows on a subgraph box (LoRA Loader's gear, **Rows on the box**): select the
box itself and save its on and off.
A LoRA row a switch sets shows a lock in the LoRA Loader. Click the lock to
take that row over: this switch then leaves the row alone.
A LoRA Loader group works the same way. Save the switch once with the group
on and once with it off, and the switch turns the whole group on and off.
Each LoRA in the group keeps its own on/off, so you can still leave one out
by hand, and a LoRA added to the group later goes with it.
A LoRA row or group a switch sets shows a lock in the LoRA Loader. Click the
lock to take it over: this switch then leaves it alone.
## A switch with two sides
@@ -125,8 +160,9 @@ the models only that part needs.
## The edit view
- **off | on**: click anywhere on the pill to flip the switch. Off turns off
every node of the row, on sets them all to run. It is one undo step.
- **off | on**: click **off** to turn off every node of the row, or **on** to
set them all to run. Clicking the selected half leaves it selected. A change
is one undo step.
- **Mixed**: when only some of a row's nodes run (one bypassed by hand, say)
the pill is striped and a count shows how many run, such as **1/2**. Click
the half you want, off or on, to set them all. A settings switch reads **changed** when one of
@@ -161,11 +197,19 @@ Open **Settings** at the bottom of the edit view.
from the left and top to bottom inside a column. **title** sorts by title,
with numbers in numeric order (*2 · …* before *10 · …*).
- **One at a time**: switching a row on switches the other rows off. Use it
for alternatives, such as two upscalers.
for alternatives, such as two upscalers. Turn it on while several rows are
on and the top one stays on. A new switch starts off while another row is
on.
- **Show as** (there while One at a time is on): **rows** gives every switch
a row with its own off | on. **one pill** shows them as one pill to pick
from, see above.
## Have an AI agent set it up
An AI agent can add switches for you by writing them into the workflow file.
Point it at `js/docs/agents/workflow_switches.md` in the pack's folder. That
page tells it what to write.
## Technical details
- **What bypass passes on.** A bypassed node, or box, hands on its first
@@ -174,8 +218,14 @@ Open **Settings** at the bottom of the edit view.
no input of that kind (a decode: latent in, picture out) passes nothing,
so the nodes that read it belong in the same switch.
- **A switch finds its nodes by id.** A deleted node drops out of its
switch. A copy of a node is not in the switch, and a pasted copy of the
card switches the same nodes as the original.
switch. A copy of a node is not in the switch. A card copied together with
its nodes and groups switches the copies, so a whole workflow pasted into
a new tab keeps working switches. A card copied on its own switches the
same nodes as the original.
- **"empty" next to a switch** means none of its nodes are in this workflow,
so there is nothing to turn on. A workflow pasted and saved on a version
before 2.20.3 stays this way. Open the original file again, or add nodes
from the switch's menu in the edit view.
- **A node in two switches** is changed by either. The other row then reads
mixed. A node that two parts share needs no switch: ComfyUI only runs what
a result needs.
@@ -212,6 +262,22 @@ Open **Settings** at the bottom of the edit view.
- **One at a time** switches the other rows off, which runs their off sides.
A node that one row runs and another switches off ends up running, and the
row you switch on has the last word.
- **What a choice stores.** One saved value set, with no off side. A choice
changes the settings it holds differently from another choice on its card,
so a third value on one control needs nothing extra. Picking one writes only
its own values, and a node it does not hold stays as it was. A choice can
also hold nodes, the way a switch does: they run while it is picked and are
off otherwise. The node a choice sets can't be one of them, because switching
it off would undo the choice. A value set by
hand that matches no choice leaves none picked. A card with choices keeps
One at a time on, shows one pill and lists no groups, so its Settings are
hidden.
- **When a card settles by itself.** Turning One at a time on, or making a
switch while it is on, leaves one row on when more than one is. A row that
is fully on goes before one that is partly on, then the top row. A new
switch gives way to the rows that were there. Rows that a change to
**Groups** or **Match** adds are left as they are, because the words typed
into Match should not stop nodes. A click on a row settles those.
- **A pill or a list.** Two to four rows are a pill when their names fit on
a card no wider than 400 px. More rows, or longer names, are a list. With
one row the card stays in the row view. Groups listed on the card are
+94
View File
@@ -0,0 +1,94 @@
# AusBoss nodes: a guide for AI agents
Give this page to an AI agent and it can pick the right AusBoss node for a job,
wire it into a workflow, and find the help for every input. It ships with the
pack, so it is also in your ComfyUI install, under
`custom_nodes/<this pack>/js/docs/agents/`.
**If you are the agent:** find the job in the table below, then read that
node's help page before you wire it. Each help page is
`js/docs/<node id>.md` in this pack. ComfyUI shows the same page in its node
help panel.
## Which node for which job
| The job | Node (id) |
|---|---|
| Pick a picture size by shape and megapixels, in place of Empty Latent | Latent Size (`AUSBOSS_NODES_Resolution`) |
| Resize a picture, or snap its size to a multiple | Image Resize (`AUSBOSS_NODES_ImageResize`), Align Image (`AUSBOSS_NODES_AlignImage`) |
| Read a picture's width and height | Image Size (`AUSBOSS_NODES_ImageSize`) |
| Outpaint: load a picture and add empty space around it | Load Image + Pad (`AUSBOSS_NODES_LoadImagePad`) |
| Turn, crop and pad a picture in one node | Image Crop + Rotate + Pad (`AUSBOSS_NODES_ImageCropRotatePad`) |
| Inpaint only the masked part, then put it back | Crop For Inpaint (`AUSBOSS_NODES_CropForInpaint`) then Stitch Inpaint (`AUSBOSS_NODES_StitchInpaint`) |
| Remove a watermark or an object with no prompt | LaMa Inpaint (`AUSBOSS_NODES_LaMaInpaint`) |
| Get a mask by typing what to find | Mask by Name (`AUSBOSS_NODES_MaskByName`) |
| Get a mask by clicking the thing | Mask by Touch (`AUSBOSS_NODES_MaskByTouch`) |
| Grow, shrink, fill or smooth a mask | Mask Refine (`AUSBOSS_NODES_RefineMask`) |
| Line an edited picture back up with the original | Realign to Source (`AUSBOSS_NODES_RealignToSource`, experimental) |
| Match one picture's colors to another | Color Match (`AUSBOSS_NODES_ColorMatch`) |
| Upscale big pictures in tiles | Tiled Upscale (`AUSBOSS_NODES_TiledUpscale`) then Tiled Upscale Stitch (`AUSBOSS_NODES_TiledUpscaleStitch`) |
| Compare two pictures | Image Compare A/B (`AUSBOSS_NODES_Compare`) |
| Save pictures (PNG, lossless WebP or JPEG XL) | Save Image (`AUSBOSS_NODES_SaveImage`) |
| Run a workflow on each picture in a folder | Image Folder (`AUSBOSS_NODES_ImageFolder`) |
| Apply a stack of LoRAs | LoRA Loader (`AUSBOSS_NODES_LoraLoader`) |
| Write a MiniMax H3 or Pika H3 Flex prompt from plain words | H3 Prompt Writer (`AUSBOSS_NODES_H3PromptWriter`): [node help](../AUSBOSS_NODES_H3PromptWriter.md) |
| Krea 2 prompt, and Krea 2 outpainting | Krea 2 Encode (`AUSBOSS_NODES_Krea2Encode`), Krea 2 Outpaint Model Patch (`AUSBOSS_NODES_Krea2OutpaintModelPatch`) |
| Load or save a video | Load Video (`AUSBOSS_NODES_LoadVideo`), Save Video (`AUSBOSS_NODES_SaveVideo`) |
| Load a sound file and keep only part of it, up to a set length | Load Audio (`AUSBOSS_NODES_LoadAudio`) |
| Turn, crop and pad a video: one frame, or the whole clip | Video Crop + Rotate + Pad → Frame (`AUSBOSS_NODES_VideoCropRotatePad`), → Clip (`AUSBOSS_NODES_VideoCropRotatePadClip`) |
| Change a clip's frame rate | Frame Interpolate (`AUSBOSS_NODES_FrameInterpolate`) |
| Work with frame batches | Select Frame, Select Every Nth, Split Batch, Merge Batches (`AUSBOSS_NODES_SelectFrame`, `_SelectEveryNth`, `_SplitBatch`, `_MergeBatches`) |
| One shared seed, number or text | Seed, Integer, Float, Text (`AUSBOSS_NODES_Seed`, `_Integer`, `_Float`, `_Text`) |
| Do maths on numbers | Math Expression (`AUSBOSS_NODES_MathExpression`) |
| Show text on the node, or the picture while it is made | Show Text (`AUSBOSS_NODES_ShowText`), Step Preview (`AUSBOSS_NODES_StepPreview`) |
| Free graphics memory between steps | Free Memory (`AUSBOSS_NODES_FreeMemory`) |
| Time the whole run | Run Timer (`AUSBOSS_NODES_RunTimer`) |
| Turn parts of a workflow off and on | Workflow Switches (`AUSBOSS_NODES_WorkflowSwitches`): [its own agent guide](workflow_switches.md) |
| Explain a shared workflow on the canvas | Workflow Note (`AUSBOSS_NODES_WorkflowNote`), Callout (`AUSBOSS_NODES_Callout`) |
| Add compact help or colored link buttons | Workflow Note Tile (`AUSBOSS_NODES_WorkflowNoteTile`), Callout Tile (`AUSBOSS_NODES_CalloutTile`) |
| Lay out boxes of things and words on a canvas | Layout Boxes (`AUSBOSS_NODES_LayoutBoxes`) |
Every node's display name ends in 🆎 and sits under `🆎 AusBoss/<group>` in
the node menu. In a workflow file and in the API, a node is named by its id,
the `AUSBOSS_NODES_...` key above.
## Before you edit a workflow
- **Read settings by name.** A saved workflow keeps each node's settings
twice: by position in `widgets_values`, and by name in
`widgets_values_named`. Read and write by name, and keep both copies in
step.
- **Look at a node's inputs and outputs** in the running ComfyUI:
`GET /object_info/<node id>` lists them with their types and defaults.
- **Inputs never get renamed or reordered** after a release, so a workflow
that worked keeps working.
- **Media loaders start blank** in the example workflows. Put in the file the
person wants to use before you queue.
## H3 swaps and clip duration
For a swap, keep the target selector on its own line. An optional separate line,
`@1 looks like a woman with blonde hair and a black hoodie.`, describes the
connected replacement person in Picture 1; use its actual picture number. This
can help express appearance but does not guarantee identity. Do not add it to
other jobs. Existing scripts without it keep their previous behavior.
An active video sets the duration for **swap the person**, **change something**
and **new voice**. The card says **From the clip** and hides numeric timeline
retiming; trim or limit the upstream clip instead. Do not infer the final frame
count from raw source metadata: upstream processing and H3 frame rounding apply.
Other jobs and video OFF still use **Length** normally.
## Example workflows
`example_workflows/` in this pack holds complete workflows, each with a
picture of what it makes. Each one starts with a Workflow Note that lists the
model files it needs and where they go. Start from the closest one rather
than building from nothing.
## When something goes wrong
- Read the node's help page again, including its "Technical details" or "Notes" part when it has one.
- Search the pack's GitHub issues: https://github.com/ausboss/ComfyUI-AusBoss/issues
- A new bug report should name the ComfyUI version, the pack version from
the console banner, the node, and the smallest workflow that shows it.
+191
View File
@@ -0,0 +1,191 @@
# Workflow Switches: a guide for AI agents
Give this page to an AI agent and it can add switches to a ComfyUI workflow
for you. Nobody has to click through menus: the agent writes the card straight
into the workflow file.
**If you are the agent:** the card is one node in the workflow file. It has no
wires. It lists which nodes each switch controls, in one property. Pick the
recipe below that fits, add the card, and set the nodes' starting state.
## Before you start
- **Edit the file ComfyUI saves with Save**, the one with a `nodes` list. The
card never reaches a run, so it has no place in the API format. For an API
prompt, leave out what should not run.
- **Use the `id` of each node** in `nodes`, and of each box in `groups`.
- **Stay on one level.** The card controls nodes on its own level of the
graph. From the main graph it can switch a whole subgraph node, or change a
control that subgraph node shows, but not reach a node inside it.
- **Read a control's name** from the node's `widgets_values_named` in the file,
or from `/object_info/<node type>` on the ComfyUI server.
## Add the card
Add this to `nodes`. Give it an `id` no other node has, and raise
`last_node_id` if it is lower than that `id`. Pick a free `pos` so it covers
nothing, and the next free number for `order`.
```json
{
"id": 50,
"type": "AUSBOSS_NODES_WorkflowSwitches",
"pos": [0, 0],
"size": [320, 100],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"title": "Workflow Switches",
"properties": {
"cnr_id": "ausboss-nodes",
"ausboss_show_edit": false,
"ausboss_workflow_switches": {
"off": "bypass",
"groups": "none",
"exclusive": false,
"show": "rows",
"switches": []
}
},
"widgets_values": []
}
```
Then fill `switches` from a recipe. Keep `"groups": "none"`: the default,
`"all"`, gives every group on the canvas a row of its own.
`show` is `"rows"`, one off | on row per switch, or `"pill"`, one pill to pick
from. A row's `id` is a whole number from 1, new on its card only. A workflow
can have several cards.
## Set the starting state
The card remembers nothing about what is on. It reads each node's own `mode`,
so the file decides how the workflow opens.
| `mode` | What the node does |
| --- | --- |
| `0` | Runs |
| `4` | Bypassed: passes its input on, and ComfyUI doesn't check it |
| `2` | Muted: passes nothing on, and ComfyUI doesn't check it |
For a part that opens switched off, set `"mode": 4` on its nodes, Save Image
nodes included. With `"off": "mute"` on the card, use `2`.
To change a value in the file, change it in both `widgets_values` and
`widgets_values_named`. The two have to agree.
## Recipes
### An optional part
One row that turns some nodes off and on: an upscale at the end, say.
```json
"switches": [
{ "id": 1, "title": "Upscale", "nodes": [12, 13], "groups": [] }
]
```
To switch a whole group of nodes, put the group's `id` in `groups`.
### Pick one part
One pill: picking a name turns its nodes on and the other rows' nodes off. Open
it with one row's nodes at `0` and the rest at `4`. Up to 4 names sit in a
pill, more become a list.
```json
"exclusive": true,
"show": "pill",
"switches": [
{ "id": 1, "title": "Photo", "nodes": [20, 21], "groups": [] },
{ "id": 2, "title": "Drawing", "nodes": [22], "groups": [] }
]
```
A node both parts use goes in neither row.
### One switch that changes settings
The row sets values instead of turning nodes off. `on` and `off` are keyed by
node id, as text, then by control name. Give every node both sides.
```json
"switches": [
{
"id": 1,
"title": "Fast",
"nodes": [],
"groups": [],
"on": { "5": { "steps": 8 } },
"off": { "5": { "steps": 20 } }
}
]
```
Only values that differ between the two sides change. Values are text, numbers
or true/false.
A LoRA Loader 🆎 keeps its rows in one text value, `loras`. Save the whole text
on both sides with only the row's `enabled` different, and the switch moves
that row alone.
### Named choices
The simplest way to pick between settings, and to run one of two branches. Each
choice is one way of setting a node, so it has `on` and no `off`. A card of
choices is always one pill, so give other switches a card of their own.
To run one of two branches, wire both into ComfyUI's If/Else Switch (type
`ComfySwitchNode`, control `switch`, inputs `on_true` and `on_false`):
```json
"exclusive": true,
"show": "pill",
"switches": [
{ "id": 1, "title": "Soft", "choice": true, "nodes": [3], "groups": [], "on": { "6": { "switch": true } } },
{ "id": 2, "title": "Big", "choice": true, "nodes": [4, 5], "groups": [], "on": { "6": { "switch": false } } }
]
```
Here node 6 is the If/Else, and each choice's `nodes` are the nodes only that
branch needs. Open the file with the If/Else set to the picked choice, that
choice's nodes at `0` and the others' at `4`.
List a branch's nodes every time. ComfyUI checks every node that is on before
it runs anything, so a missing model in the branch nobody picked stops the run
unless its nodes are off.
Keep these out of a choice's `nodes`: the node the choice sets, because
switching it off would undo the choice, and any node both branches use. Both
branches must end in the same type, such as a picture.
### A row with two sides
`nodes_off` holds nodes that run only while the row is off. Use it when turning
one thing on should turn another off.
```json
{ "id": 1, "title": "Edit a picture", "nodes": [7], "groups": [], "nodes_off": [8] }
```
Open it with one side at `0` and the other at `4`.
## Check your work
Open the workflow in ComfyUI, then:
- **A row says "empty":** none of its ids match a node on the card's level.
- **A row says "mixed", or a pill shows no pick:** the saved `mode`s or values
don't match any one row. Fix the starting state.
- **A row does nothing:** a value's control name is wrong, or both sides hold
the same value.
- **A choice says "needs one more":** no other choice differs from it.
## Limits
A card holds up to 40 rows. A title is up to 60 characters, and a row up to
400 nodes. A row can set values on up to 40 nodes.
+51
View File
@@ -0,0 +1,51 @@
{
"id": "27945e9e-3e7b-49c5-bcb7-434d5de9e689",
"revision": 0,
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "AUSBOSS_NODES_WorkflowNoteTile",
"pos": [
0,
0
],
"size": [
300,
88
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"title": "Download models",
"properties": {
"Node name for S&R": "AUSBOSS_NODES_WorkflowNoteTile"
},
"widgets_values": [
"{\"v\": 1, \"title\": \"Download models\", \"subtitle\": \"Krea 2 Outpaint\", \"author\": \"\", \"layout\": \"tile\", \"accent\": \"\", \"body\": \"Get the four files below and put each one in its listed folder. Click **check files** after adding them.\", \"models\": [{\"name\": \"krea2_turbo_fp8_scaled.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"13.1 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/diffusion_models/krea2_turbo_fp8_scaled.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_4b_fp8_scaled.safetensors\", \"dir\": \"text_encoders\", \"size\": \"5.2 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/text_encoders/qwen3vl_4b_fp8_scaled.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_vae.safetensors\", \"dir\": \"vae\", \"size\": \"254 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/vae/qwen_image_vae.safetensors\", \"note\": \"\"}, {\"name\": \"krea2_anypaint_rank32.safetensors\", \"dir\": \"loras\", \"size\": \"229 MB\", \"url\": \"https://huggingface.co/yijunwang2/krea2-anypaint/resolve/main/krea2_anypaint_rank32.safetensors\", \"note\": \"by yijunwang2\"}], \"packs\": [], \"links\": []}"
],
"widgets_values_named": {
"note": "{\"v\": 1, \"title\": \"Download models\", \"subtitle\": \"Krea 2 Outpaint\", \"author\": \"\", \"layout\": \"tile\", \"accent\": \"\", \"body\": \"Get the four files below and put each one in its listed folder. Click **check files** after adding them.\", \"models\": [{\"name\": \"krea2_turbo_fp8_scaled.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"13.1 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/diffusion_models/krea2_turbo_fp8_scaled.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_4b_fp8_scaled.safetensors\", \"dir\": \"text_encoders\", \"size\": \"5.2 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/text_encoders/qwen3vl_4b_fp8_scaled.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_vae.safetensors\", \"dir\": \"vae\", \"size\": \"254 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/vae/qwen_image_vae.safetensors\", \"note\": \"\"}, {\"name\": \"krea2_anypaint_rank32.safetensors\", \"dir\": \"loras\", \"size\": \"229 MB\", \"url\": \"https://huggingface.co/yijunwang2/krea2-anypaint/resolve/main/krea2_anypaint_rank32.safetensors\", \"note\": \"by yijunwang2\"}], \"packs\": [], \"links\": []}"
},
"color": "#14424d",
"bgcolor": "#161f21"
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.9,
"offset": [
150,
115
]
},
"frontendVersion": "1.53.6"
},
"version": 0.4
}
+1 -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, 470],
minNodeSize: [PANEL_MIN_WIDTH, 396],
exactMinWidth: true,
});
return widget;
+3 -3
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";
@@ -725,7 +725,7 @@ function buildPanel(node) {
const now = settings(node);
if (now.run !== "one per run" || isLinked(node, "position")) return;
const count = pickedNames(parsePicked(now.pictures), state.names).length;
const next = nextPosition(now.position, count, now.afterRun);
const next = nextPosition(now.position, count, now.afterRun, now.atTheEnd);
if (next !== now.position) { position.value = next; position.callback?.(next); }
});
}
+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";
+160 -28
View File
@@ -9,9 +9,14 @@
//
// The panel is fully inert (pointer-events: none) so clicks fall through, the
// node stays draggable from its body, and wheel zoom belongs to the graph.
//
// Save Image can also show the picture while it is being made: its `steps`
// switch lays each sampling step over the stage, with a Stop button on the
// bar (shared/step_preview.mjs).
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { panelSwitch } from "../shared/panel_controls.mjs";
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { isForeignRun } from "../shared/prompt_scope.mjs";
import { executionIdOf, nodeByExecutionId } from "../shared/graph_ids.mjs";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
@@ -21,6 +26,7 @@ import { autoMaskValues } from "../shared/mask_auto.mjs";
import { hideInputsInDef, hideWidget, setWidgetVisible } from "../shared/widget_visibility.mjs";
import { makePictureStepper } from "../shared/picture_stepper.mjs";
import { keepIndex, stepIndex } from "../shared/picture_steps.mjs";
import { addStepConsumer, mountStepOverlay, showsStepsOn, stepRun, stopStepRun, watchSteps } from "../shared/step_preview.mjs";
import {
describeNodePreview,
describeSourcePreview,
@@ -107,7 +113,7 @@ const NODE_CONFIG = {
},
AUSBOSS_NODES_SelectFrame: { inputName: "frames", noun: "frames" },
// Its result is the file it just saved; the card sits above the panel.
AUSBOSS_NODES_SaveImage: { inputName: "images", noun: "an image", switchTitle: "Show the saved image here. The file is saved either way." },
AUSBOSS_NODES_SaveImage: { inputName: "images", noun: "an image", switchTitle: "Show the saved image here. The file is saved either way.", steps: true },
};
// The picture sits out of flow, centred in the stage, so it can never set
@@ -137,12 +143,10 @@ function ensureCss() {
.ausboss-input-preview-tool{box-sizing:border-box;height:${BAR_HEIGHT}px;min-width:24px;padding:0 6px;border:1px solid rgba(0,180,170,.52);border-radius:4px;background:rgba(0,0,0,.7);color:#c8dddd;font:700 9px/18px "Segoe UI",sans-serif;cursor:pointer;}
.ausboss-input-preview-tool:hover{border-color:${BRAND};color:#fff;background:rgba(0,79,75,.78);}
.ausboss-input-preview-tool.active{border-color:${BRAND};color:${BRAND};}
.ausboss-input-preview-toggle{display:flex;align-items:center;gap:6px;margin-left:auto;pointer-events:auto;cursor:pointer;user-select:none;}
.ausboss-input-preview-toggle span{color:#5f7674;font:10px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.08em;text-transform:uppercase;}
.ausboss-input-preview-switch{box-sizing:border-box;width:24px;height:13px;border-radius:7px;border:none;background:#3a4047;position:relative;cursor:pointer;padding:0;flex:none;pointer-events:auto}
.ausboss-input-preview-switch::after{content:"";position:absolute;top:2px;left:2px;width:9px;height:9px;border-radius:50%;background:#9ba2aa;transition:left .12s}
.ausboss-input-preview-switch.on{background:${BRAND}}
.ausboss-input-preview-switch.on::after{left:13px;background:#fff}
.ausboss-input-preview:not(.preview-off) .ausboss-panel-switch.steps + .ausboss-panel-switch{margin-left:0;}
.ausboss-input-preview.preview-off .ausboss-panel-switch.steps{display:none;}
.ausboss-input-preview-tool.ausboss-steps-stop{visibility:hidden;}
.ausboss-input-preview-tool.ausboss-steps-stop.live{visibility:visible;pointer-events:auto;}
.ausboss-input-preview-readout{box-sizing:border-box;height:${BAR_HEIGHT}px;padding:0 7px;border:1px solid rgba(0,180,170,.5);border-radius:10px;background:rgba(0,180,170,.14);color:${BRAND};font:700 9.5px/18px "Segoe UI",sans-serif;white-space:nowrap;pointer-events:none;}
.ausboss-input-preview-readout:empty{display:none;}
.ausboss-input-preview-readout.warn{border-color:#c98a2b;background:rgba(201,138,43,.16);color:#ffd9a0;}
@@ -288,18 +292,9 @@ function previewEnabled(state) {
}
function makePreviewToggle(state, signal) {
const toggle = document.createElement("div");
toggle.className = "ausboss-input-preview-toggle";
toggle.title = state.switchTitle ?? "Show this node's result here. Off also skips writing the preview file to the temp folder.";
const caption = document.createElement("span");
caption.textContent = "preview";
const button = document.createElement("button");
button.type = "button";
button.className = "ausboss-input-preview-switch";
button.setAttribute("role", "switch");
toggle.append(caption, button);
toggle.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
toggle.addEventListener("click", (event) => {
const button = panelSwitch("Preview", state.switchTitle ?? "Show this node's result here. Off also skips writing the preview file to the temp folder.");
button.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
button.addEventListener("click", (event) => {
event.preventDefault(); event.stopPropagation();
const next = !previewEnabled(state);
state.previewWidget.value = next;
@@ -308,7 +303,7 @@ function makePreviewToggle(state, signal) {
notifyAusbossChange();
}, { signal });
state.switchButton = button;
return toggle;
return button;
}
function installPreviewSwitch(state, signal) {
@@ -325,11 +320,126 @@ function installPreviewSwitch(state, signal) {
syncPreviewMode(state, false);
}
// The steps switch, left of the preview switch: the hidden `step_preview`
// widget. On, the stage shows each step of the picture while it is being
// made and the bar gets a Stop button for the run. Off (the default), the
// node is exactly as it was. It goes away with the preview, which is where
// the steps would show.
function stepsEnabled(state) {
return state.stepsWidget?.value === true && previewEnabled(state);
}
function installStepsSwitch(state, signal) {
const widget = widgetByName(state.node, "step_preview");
if (!widget) return;
state.stepsWidget = widget;
hideWidget(widget);
const button = panelSwitch("Steps", "Show the picture here while it is being made, one step at a time, with a Stop button. Off by default.");
button.classList.add("steps");
button.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
button.addEventListener("click", (event) => {
event.preventDefault(); event.stopPropagation();
const next = widget.value !== true;
widget.value = next;
widget.callback?.(next);
syncSteps(state);
notifyAusbossChange();
}, { signal });
state.stepsButton = button;
const previewToggle = state.switchButton;
if (previewToggle?.parentElement === state.bar) state.bar.insertBefore(button, previewToggle);
else state.bar.append(button);
// Stop sits where the node's tools go; it keeps its place while hidden so
// the bar never shifts when a run starts.
const stop = document.createElement("button");
stop.type = "button";
stop.className = "ausboss-input-preview-tool ausboss-steps-stop";
stop.textContent = "STOP";
stop.title = "Stop this run now. The next run in the queue still starts.";
stop.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
stop.addEventListener("click", (event) => {
event.preventDefault(); event.stopPropagation();
Promise.resolve(stopStepRun()).catch(() => toast(state, "Could not stop the run."));
}, { signal });
state.stopButton = stop;
state.bar.prepend(stop);
state.stepOverlay = mountStepOverlay(state.stage);
state.forgetSteps = [
addStepConsumer(state.node, () => stepsEnabled(state)),
watchSteps((run) => syncSteps(state, run)),
];
chainCallback(state.node, "onConfigure", () => queueMicrotask(() => {
if (!state.alive) return;
hideWidget(widget);
syncSteps(state);
}));
}
// Draw the run on this node: its steps while they feed it, where it stopped
// if it never got to save, and the saved picture again once it has.
function syncSteps(state, run = stepRun()) {
const overlay = state.stepOverlay;
if (!overlay) return;
// An empty stage says when the run is on its way, not to start one.
if (typeof state.placeholder === "boolean") state.hint.textContent = placeholderText(state.placeholder, state.noun, runningFor(state, run));
const on = stepsEnabled(state);
if (state.stepsButton) {
state.stepsButton.setAttribute("aria-checked", String(state.stepsWidget?.value === true));
}
state.stopButton?.classList.toggle("live", on && run.status === "running");
if (!on) {
overlay.hide();
return;
}
const thisRun = (id) => id !== null && id !== undefined && String(id) === String(run.promptId);
if (run.status === "running") {
// Saved already in this run: its own picture stays.
if (thisRun(state.stepsSaved)) return;
if (run.url && showsStepsOn(state.node, run)) {
state.stepsShown = run;
overlay.show(run, { saves: true });
} else if (!thisRun(state.stepsShown?.promptId)) {
overlay.hide();
}
return;
}
if (run.status === "stopped" && thisRun(state.stepsShown?.promptId) && !thisRun(state.stepsSaved)) {
overlay.show({ ...state.stepsShown, status: "stopped" }, { saves: true });
return;
}
// The last step stays up until the saved picture has loaded under it.
if (thisRun(state.stepsHeld)) return;
overlay.hide();
}
// What a run on its way says on an empty stage: false when none is.
function runningFor(state, run = stepRun()) {
if (!state.stepOverlay || run.status !== "running") return false;
return stepsEnabled(state) ? "steps" : "result";
}
// Saved while the steps were up: hold them until the stage has its picture,
// or a few seconds at most.
const HOLD_MS = 4000;
function holdSteps(state) {
clearTimeout(state.holdTimer);
state.stepsHeld = stepRun().promptId;
state.holdTimer = setTimeout(() => releaseSteps(state), HOLD_MS);
}
function releaseSteps(state) {
if (state.stepsHeld === null || state.stepsHeld === undefined) return;
clearTimeout(state.holdTimer);
state.stepsHeld = null;
syncSteps(state);
}
function syncPreviewMode(state, resize) {
const enabled = previewEnabled(state);
state.root.classList.toggle("preview-off", !enabled);
if (state.switchButton) {
state.switchButton.classList.toggle("on", enabled);
state.switchButton.setAttribute("aria-checked", String(enabled));
}
if (!enabled) {
@@ -355,6 +465,7 @@ function syncPreviewMode(state, resize) {
node.setDirtyCanvas?.(true, true);
}
if (enabled && state.alive) scheduleRefresh(state);
syncSteps(state);
}
function buildTools(state, tools, signal) {
@@ -383,11 +494,21 @@ function buildTools(state, tools, signal) {
function showHint(state, text) {
state.stage.classList.remove("show-image", "show-video");
state.hint.textContent = text;
state.placeholder = null;
releaseSteps(state);
}
// The placeholder, which changes with the run (syncSteps).
function showPlaceholder(state, connected) {
showHint(state, placeholderText(connected, state.noun, runningFor(state)));
state.placeholder = connected;
}
function showMedia(state, kind) {
state.stage.classList.toggle("show-image", kind === "image");
state.stage.classList.toggle("show-video", kind === "video");
state.placeholder = null;
releaseSteps(state);
}
function clearMedia(state) {
@@ -487,7 +608,7 @@ function refresh(state) {
: describeNodePreview(state.node, state.inputName, storedResultUrl(state.node));
if (!described) {
clearMedia(state);
showHint(state, placeholderText(!!source, state.noun));
showPlaceholder(state, !!source);
return;
}
const url = described.kind === "url"
@@ -610,6 +731,8 @@ function buildPanel(node, config) {
previewWidget,
index: 0,
stepper: null,
// Whether the stage shows the placeholder (true: something is connected).
placeholder: null,
};
livePanels.add(state);
if (config.tools?.length) bar.append(buildTools(state, config.tools, abort.signal));
@@ -629,18 +752,19 @@ function buildPanel(node, config) {
bar.append(state.stepper.root);
syncAdvanced(state);
installPreviewSwitch(state, abort.signal);
if (config.steps) installStepsSwitch(state, abort.signal);
img.addEventListener("load", () => showMedia(state, "image"));
img.addEventListener("error", () => {
if (img.getAttribute("src")) showHint(state, placeholderText(true, state.noun));
if (img.getAttribute("src")) showPlaceholder(state, true);
});
video.addEventListener("loadeddata", () => showMedia(state, "video"));
video.addEventListener("error", () => {
if (video.getAttribute("src")) showHint(state, placeholderText(true, state.noun));
if (video.getAttribute("src")) showPlaceholder(state, true);
});
watchOwnResult(node, state);
showHint(state, placeholderText(false, state.noun));
showPlaceholder(state, false);
scheduleRefresh(state);
return state;
}
@@ -691,7 +815,7 @@ app.registerExtension({
beforeRegisterNodeDef(nodeType, nodeData) {
const config = NODE_CONFIG[nodeData?.name];
if (!config) return;
hideInputsInDef(nodeData, ["preview"]);
hideInputsInDef(nodeData, ["preview", "step_preview"]);
chainCallback(nodeType.prototype, "onNodeCreated", function () {
buildPanel(this, config);
});
@@ -716,6 +840,12 @@ app.registerExtension({
const state = buildPanel(this, config);
// A result arrived (Save Image sends one even with its preview off).
if (state) { state.stale = false; state.resultGone = false; scheduleRefresh(state); }
// Saved: the file's picture replaces the steps.
if (state?.stepOverlay) {
state.stepsSaved = stepRun().promptId;
if (state.stepOverlay.shown) holdSteps(state);
syncSteps(state);
}
if (state && config.readout) {
setNote(state, "");
const readout = config.readout(output);
@@ -733,6 +863,8 @@ app.registerExtension({
state.abort?.abort();
unwatchSource(state);
clearMedia(state);
for (const forget of state.forgetSteps ?? []) forget();
clearTimeout(state.holdTimer);
this.__ausbossInputPreview = null;
});
},
+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";
+528
View File
@@ -0,0 +1,528 @@
import { app } from "../../../scripts/app.js";
import { BRAND, notifyAusbossChange, showToast } from "../shared/index.mjs";
import { enoughFor, lineUp, matchWidth, sameSize, sharedSides, spreadEvenly } from "../shared/line_up.mjs";
import { outlineOf } from "../shared/outline.mjs";
// Line up: with two or more nodes or groups selected, ComfyUI's selection
// toolbar gets one button that pops out a small panel. The panel lines the
// selection up, gives it one width or height, or spaces it evenly. Every tool
// is also a command, so it can be found in the command palette and given a key.
//
// Resize together: with two or more nodes selected, pulling the edge or corner
// of one resizes them all, in both looks. A column shares the width, a row
// shares the height, and a grid shares both.
//
// A node inside a selected group moves with its group, the way a drag moves
// it. Pinned items count when the selection is measured but never move.
const PANEL = "AusBoss.LineUp.Panel";
const SETTING = "AusBoss.Canvas.LineUpButton";
const TOGETHER_SETTING = "AusBoss.Canvas.ResizeTogether";
const ICON_CLASS = "ausboss-line-up-icon";
const TOOLS = [
{ kind: "lineUp", how: "left", id: "AusBoss.LineUp.Left", label: "Line up left edges" },
{ kind: "lineUp", how: "middleX", id: "AusBoss.LineUp.MiddleX", label: "Line up middles in a column" },
{ kind: "lineUp", how: "right", id: "AusBoss.LineUp.Right", label: "Line up right edges" },
{ kind: "lineUp", how: "top", id: "AusBoss.LineUp.Top", label: "Line up top edges" },
{ kind: "lineUp", how: "middleY", id: "AusBoss.LineUp.MiddleY", label: "Line up middles in a row" },
{ kind: "lineUp", how: "bottom", id: "AusBoss.LineUp.Bottom", label: "Line up bottom edges" },
{ kind: "size", how: "widest", id: "AusBoss.LineUp.Widest", label: "Same width as the widest" },
{ kind: "size", how: "narrowest", id: "AusBoss.LineUp.Narrowest", label: "Same width as the narrowest" },
{ kind: "size", how: "tallest", id: "AusBoss.LineUp.Tallest", label: "Same height as the tallest" },
{ kind: "size", how: "shortest", id: "AusBoss.LineUp.Shortest", label: "Same height as the shortest" },
{ kind: "spacing", how: "across", id: "AusBoss.LineUp.SpaceAcross", label: "Even spacing across" },
{ kind: "spacing", how: "down", id: "AusBoss.LineUp.SpaceDown", label: "Even spacing down" },
];
const SECTIONS = [
{ title: "Line up", kind: "lineUp" },
{ title: "Same size", kind: "size" },
{ title: "Even spacing", kind: "spacing" },
];
// ---------------------------------------------------------------------------
// Icons: grey boxes in the text color, and the line or arrows they line up on
// in the pack's teal, the color of the snap lines.
// ---------------------------------------------------------------------------
const box = (x, y, w, h) => ({ x, y, w, h });
const turn = (parts) => ({
boxes: parts.boxes.map(({ x, y, w, h }) => box(y, x, h, w)),
lines: parts.lines.map(([x1, y1, x2, y2]) => [y1, x1, y2, x2]),
heads: parts.heads.map(([x, y, dir]) => [y, x, { l: "u", r: "d", u: "l", d: "r" }[dir]]),
});
const WIDEST = {
boxes: [box(3, 4, 18, 5), box(8, 15, 8, 5)],
lines: [[7, 12, 3.5, 12], [17, 12, 20.5, 12]],
heads: [[3.5, 12, "l"], [20.5, 12, "r"]],
};
const NARROWEST = {
boxes: [box(8, 4, 8, 5), box(3, 15, 18, 5)],
lines: [[3, 12, 7, 12], [21, 12, 17, 12]],
heads: [[7, 12, "r"], [17, 12, "l"]],
};
const ACROSS = {
boxes: [box(2, 7, 4.5, 10), box(9.75, 7, 4.5, 10), box(17.5, 7, 4.5, 10)],
lines: [[7.5, 12, 8.75, 12], [15.25, 12, 16.5, 12], [7.5, 9.5, 7.5, 14.5], [8.75, 9.5, 8.75, 14.5], [15.25, 9.5, 15.25, 14.5], [16.5, 9.5, 16.5, 14.5]],
heads: [],
};
const ICONS = {
left: { boxes: [box(7, 5, 13, 5), box(7, 14, 8, 5)], lines: [[4, 3, 4, 21]], heads: [] },
middleX: { boxes: [box(5, 5, 14, 5), box(8, 14, 8, 5)], lines: [[12, 3, 12, 21]], heads: [] },
right: { boxes: [box(4, 5, 13, 5), box(9, 14, 8, 5)], lines: [[20, 3, 20, 21]], heads: [] },
top: { boxes: [box(5, 7, 5, 13), box(14, 7, 5, 8)], lines: [[3, 4, 21, 4]], heads: [] },
middleY: { boxes: [box(5, 5, 5, 14), box(14, 8, 5, 8)], lines: [[3, 12, 21, 12]], heads: [] },
bottom: { boxes: [box(5, 4, 5, 13), box(14, 9, 5, 8)], lines: [[3, 20, 21, 20]], heads: [] },
widest: WIDEST,
narrowest: NARROWEST,
tallest: turn(WIDEST),
shortest: turn(NARROWEST),
across: ACROSS,
down: turn(ACROSS),
};
function headPath([x, y, dir]) {
const s = 2.6;
const points = {
l: [[x + s, y - s], [x, y], [x + s, y + s]],
r: [[x - s, y - s], [x, y], [x - s, y + s]],
u: [[x - s, y + s], [x, y], [x + s, y + s]],
d: [[x - s, y - s], [x, y], [x + s, y - s]],
}[dir];
return `M${points.map((p) => p.join(" ")).join(" L")}`;
}
function boxesSvg(parts, color, fillOpacity) {
return parts.boxes
.map(({ x, y, w, h }) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="1.4" fill="${color}" fill-opacity="${fillOpacity}" stroke="${color}" stroke-width="1.5"/>`)
.join("");
}
function marksSvg(parts, color) {
const lines = parts.lines.map(([x1, y1, x2, y2]) => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"/>`).join("");
const heads = parts.heads.map((head) => `<path d="${headPath(head)}" fill="none"/>`).join("");
return `<g stroke="${color}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${lines}${heads}</g>`;
}
function iconSvg(how) {
const parts = ICONS[how];
return `<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">${boxesSvg(parts, "currentColor", 0.2)}${marksSvg(parts, BRAND)}</svg>`;
}
// The toolbar button is an <i> with a class, so its icon is drawn in CSS:
// the boxes take the button's text color, the line stays teal.
function maskUrl(body) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">${body}</svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
}
function styleSheet() {
const parts = ICONS.left;
const boxes = maskUrl(boxesSvg(parts, "black", 0.25));
const marks = maskUrl(marksSvg(parts, "black"));
return `
.${ICON_CLASS} { position: relative; display: inline-block; width: 1rem; height: 1rem; }
.${ICON_CLASS}::before, .${ICON_CLASS}::after {
content: ""; position: absolute; inset: 0;
-webkit-mask: var(--ausboss-mask) center / contain no-repeat; mask: var(--ausboss-mask) center / contain no-repeat;
}
.${ICON_CLASS}::before { --ausboss-mask: ${boxes}; background: currentColor; }
.${ICON_CLASS}::after { --ausboss-mask: ${marks}; background: ${BRAND}; }
.ausboss-line-up {
position: fixed; z-index: 1000; padding: 6px 6px 4px; border-radius: 10px;
background: var(--interface-panel-surface, #1f2225); color: var(--base-foreground, #e6edf3);
border: 1px solid var(--interface-stroke, #3a3f44); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
font-size: 11px; line-height: 1.2; user-select: none;
}
.ausboss-line-up-title { margin: 2px 6px 3px; color: var(--muted-foreground, #8b949e); }
.ausboss-line-up-row { display: flex; gap: 2px; }
.ausboss-line-up-rule { height: 1px; margin: 5px 2px; background: var(--interface-stroke, #30363d); }
.ausboss-line-up button {
display: grid; place-items: center; width: 30px; height: 30px; padding: 0;
border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer;
}
.ausboss-line-up button:hover:not(:disabled) { background: color-mix(in srgb, ${BRAND} 18%, transparent); }
.ausboss-line-up button:focus-visible { outline: 1px solid ${BRAND}; }
.ausboss-line-up button:disabled { opacity: 0.35; cursor: default; }
`;
}
// ---------------------------------------------------------------------------
// The selection
// ---------------------------------------------------------------------------
function graphSets(graph) {
return {
nodes: new Set(graph?._nodes ?? graph?.nodes ?? []),
groups: new Set(graph?._groups ?? graph?.groups ?? []),
};
}
function pickedCount(canvas) {
const { nodes, groups } = graphSets(canvas?.graph);
let count = 0;
for (const item of canvas?.selectedItems ?? []) if (nodes.has(item) || groups.has(item)) count++;
return count;
}
// The selected nodes and groups that move on their own, each with the
// outline people see. A node or group inside a selected group is left out:
// it rides along with that group.
function selectionUnits(canvas) {
const { nodes, groups } = graphSets(canvas?.graph);
const picked = [...(canvas?.selectedItems ?? [])].filter((item) => nodes.has(item) || groups.has(item));
const carried = new Set();
for (const item of picked) {
if (!groups.has(item)) continue;
item.recomputeInsideNodes?.();
for (const child of item.children ?? item._children ?? []) carried.add(child);
}
const units = [];
for (const item of picked) {
if (carried.has(item)) continue;
const group = groups.has(item);
if (!group) item.updateArea?.(canvas.ctx); // fresh after a size change this frame
const rect = outlineOf(item, canvas, { node: !group });
if (rect) units.push({ item, group, rect });
}
return units;
}
function isPinned(item) {
return item.pinned === true || item.flags?.pinned === true;
}
function moveUnit({ item, group, rect }, { x, y }) {
const dx = x - rect.x;
const dy = y - rect.y;
if ((!dx && !dy) || isPinned(item)) return;
if (group) item.move(dx, dy);
else item.pos = [item.pos[0] + dx, item.pos[1] + dy];
}
function resizeUnit({ item, group, rect }, { w, h }) {
if (isPinned(item) || item.flags?.collapsed) return;
if (w === rect.w && h === rect.h) return;
if (group) {
item.resize(w, h);
return;
}
// A node's outline includes its title bar and any frame drawn around it;
// its size does not, so set the size that gives the outline asked for.
const extraW = rect.w - item.size[0];
const extraH = rect.h - item.size[1];
const least = item.computeSize?.() ?? [0, 0];
item.setSize([Math.max(w - extraW, least[0]), Math.max(h - extraH, least[1])]);
}
function run(tool) {
const canvas = app.canvas;
if (!canvas?.graph) return;
const units = selectionUnits(canvas);
if (!enoughFor(tool.kind, units.length)) {
showToast({ detail: `Select ${tool.kind === "spacing" ? "three" : "two"} or more nodes or groups first.`, life: 3000 });
return;
}
const rects = units.map((unit) => unit.rect);
if (tool.kind === "size") {
const sizes = sameSize(rects, tool.how);
units.forEach((unit, index) => resizeUnit(unit, sizes[index]));
} else {
const places = tool.kind === "lineUp" ? lineUp(rects, tool.how) : spreadEvenly(rects, tool.how);
units.forEach((unit, index) => moveUnit(unit, places[index]));
}
canvas.setDirty?.(true, true);
notifyAusbossChange();
refreshPanel(true);
}
// ---------------------------------------------------------------------------
// Resize together
// ---------------------------------------------------------------------------
//
// The frontend resizes only the node under the pointer: the canvas's own
// pointermove in the classic look, a pointermove on the window in Nodes 2.0.
// This listens on the window and catches up a frame later, so it always reads
// the size the frontend (and Snap into line) settled on. Which sizes are
// shared comes from how the selection was laid out when the button went down
// (sharedSides in js/shared/line_up.mjs). The grabbed node is
// the canvas's resizing node, or in Nodes 2.0 the node whose corner handle
// took the press. Every other selected node takes its size from where it
// stood when the button went down, so nothing builds up across steps.
const together = { enabled: true, before: null, grabbed: null, handleOf: null, sides: null, frame: 0 };
function resizableSelection(canvas) {
const { nodes } = graphSets(canvas?.graph);
return [...(canvas?.selectedItems ?? [])].filter(
(item) => nodes.has(item) && item.size && item.pos && !isPinned(item) && !item.flags?.collapsed,
);
}
function onTogetherDown(event) {
endTogether();
if (event.button !== 0 || !together.enabled) return;
const picked = resizableSelection(app.canvas);
if (picked.length < 2) return;
together.before = new Map(picked.map((node) => [node, { pos: [node.pos[0], node.pos[1]], size: [node.size[0], node.size[1]] }]));
// Nodes 2.0 draws a corner handle on each node (a div with data-corner).
const handle = event.target?.closest?.("[data-corner]");
together.handleOf = handle?.closest?.("[data-node-id]")?.dataset?.nodeId ?? null;
}
function grabbedNode(canvas) {
const classic = canvas?.resizing_node;
if (classic) return together.before.has(classic) ? classic : null;
if (together.handleOf == null) return null;
for (const node of together.before.keys()) if (String(node.id) === together.handleOf) return node;
return null;
}
function syncSizes() {
const canvas = app.canvas;
if (!together.before || !canvas?.graph) return;
together.grabbed ??= grabbedNode(canvas);
const grabbed = together.grabbed;
if (!grabbed) return;
const was = together.before.get(grabbed);
together.sides ??= sharedSides(
[...together.before.values()].map(({ pos, size }) => ({ x: pos[0], y: pos[1], w: size[0], h: size[1] })),
);
const others = [...together.before].filter(([node]) => node !== grabbed);
const least = others.map(([node]) => node.computeSize?.() ?? [0, 0]);
// Heights go through the same rule as widths, with y for x.
const widths = together.sides.width
? matchWidth(
grabbed.size[0],
others.map(([, start], i) => ({ x: start.pos[0], w: start.size[0], least: least[i][0] })),
{ fromLeft: grabbed.pos[0] !== was.pos[0] },
)
: null;
const heights = together.sides.height
? matchWidth(
grabbed.size[1],
others.map(([, start], i) => ({ x: start.pos[1], w: start.size[1], least: least[i][1] })),
{ fromLeft: grabbed.pos[1] !== was.pos[1] },
)
: null;
let moved = false;
others.forEach(([node], index) => {
const x = widths ? widths[index].x : node.pos[0];
const y = heights ? heights[index].x : node.pos[1];
const w = widths ? widths[index].w : node.size[0];
const h = heights ? heights[index].w : node.size[1];
if (node.pos[0] !== x || node.pos[1] !== y) {
node.pos = [x, y];
moved = true;
}
if (node.size[0] !== w || node.size[1] !== h) {
node.setSize([w, h]);
moved = true;
}
});
if (moved) canvas.setDirty?.(true, true);
}
function onTogetherMove(event) {
if (!together.before) return;
if (!(event.buttons & 1)) return endTogether();
if (together.frame) return;
together.frame = requestAnimationFrame(() => {
together.frame = 0;
syncSizes();
});
}
function onTogetherUp() {
if (together.before) syncSizes(); // the last step, before the frontend records the change
endTogether();
}
function endTogether() {
if (together.frame) cancelAnimationFrame(together.frame);
together.frame = 0;
together.before = null;
together.grabbed = null;
together.handleOf = null;
together.sides = null;
}
function installTogether() {
window.addEventListener("pointerdown", onTogetherDown, true);
window.addEventListener("pointermove", onTogetherMove);
// Capture phase: the classic canvas stops its pointerup from bubbling.
window.addEventListener("pointerup", onTogetherUp, true);
window.addEventListener("pointercancel", endTogether, true);
window.addEventListener("blur", endTogether);
}
// ---------------------------------------------------------------------------
// The panel
// ---------------------------------------------------------------------------
const panel = { el: null, frame: 0, buttons: [], count: -1 };
function anchorButton() {
return document.querySelector(`.selection-toolbox .${ICON_CLASS}`)?.closest("button") ?? null;
}
function labelAnchor() {
const button = anchorButton();
if (!button || button.title) return;
button.title = "Line up, same size, even spacing";
button.setAttribute("aria-label", button.title);
}
function buildPanel() {
const el = document.createElement("div");
el.className = "ausboss-line-up";
el.setAttribute("role", "toolbar");
el.setAttribute("aria-label", "Line up the selection");
panel.buttons = [];
SECTIONS.forEach((section, index) => {
if (index) el.append(Object.assign(document.createElement("div"), { className: "ausboss-line-up-rule" }));
el.append(Object.assign(document.createElement("div"), { className: "ausboss-line-up-title", textContent: section.title }));
const row = Object.assign(document.createElement("div"), { className: "ausboss-line-up-row" });
for (const tool of TOOLS.filter((t) => t.kind === section.kind)) {
const button = document.createElement("button");
button.type = "button";
button.title = tool.label;
button.setAttribute("aria-label", tool.label);
button.innerHTML = iconSvg(tool.how);
button.addEventListener("click", () => run(tool));
panel.buttons.push({ button, tool });
row.append(button);
}
el.append(row);
});
return el;
}
function refreshPanel(force = false) {
if (!panel.el) return;
const count = pickedCount(app.canvas);
if (!force && count === panel.count) return;
panel.count = count;
const units = selectionUnits(app.canvas).length;
for (const { button, tool } of panel.buttons) button.disabled = !enoughFor(tool.kind, units);
}
// Above the selection toolbar, or below it when there is no room above. The
// toolbar follows the selection, so the panel follows the toolbar.
function placePanel() {
const anchor = anchorButton();
if (!panel.el || !anchor) return closePanel();
const bar = (anchor.closest(".selection-toolbox") ?? anchor).getBoundingClientRect();
const at = anchor.getBoundingClientRect();
const size = panel.el.getBoundingClientRect();
const left = Math.min(Math.max(8, at.left + at.width / 2 - size.width / 2), window.innerWidth - size.width - 8);
let top = bar.top - size.height - 6;
if (top < 8) top = bar.bottom + 6;
panel.el.style.left = `${Math.round(left)}px`;
panel.el.style.top = `${Math.round(top)}px`;
refreshPanel();
}
function follow() {
panel.frame = 0;
if (!panel.el) return;
placePanel();
if (panel.el) panel.frame = requestAnimationFrame(follow);
}
function openPanel() {
if (panel.el || !anchorButton()) return;
panel.el = buildPanel();
panel.count = -1;
document.body.append(panel.el);
window.addEventListener("pointerdown", onOutside, true);
window.addEventListener("keydown", onKey, true);
follow();
}
function closePanel() {
if (!panel.el) return;
panel.el.remove();
panel.el = null;
panel.buttons = [];
if (panel.frame) cancelAnimationFrame(panel.frame);
panel.frame = 0;
window.removeEventListener("pointerdown", onOutside, true);
window.removeEventListener("keydown", onKey, true);
}
function onOutside(event) {
if (!panel.el || panel.el.contains(event.target)) return;
if (anchorButton()?.contains(event.target)) return; // its own click closes the panel
closePanel();
}
function onKey(event) {
if (event.key === "Escape") closePanel();
}
function togglePanel() {
if (panel.el) closePanel();
else openPanel();
}
function buttonOn() {
return app.ui?.settings?.getSettingValue?.(SETTING) !== false;
}
app.registerExtension({
name: "AusBoss.LineUp",
commands: [
{ id: PANEL, label: "AusBoss: Line up, same size, even spacing", icon: ICON_CLASS, function: togglePanel },
...TOOLS.map((tool) => ({ id: tool.id, label: `AusBoss: ${tool.label}`, function: () => run(tool) })),
],
getSelectionToolboxCommands() {
if (!buttonOn() || pickedCount(app.canvas) < 2) return [];
// The toolbar names extension buttons from its own translations, which
// have no entry for this one; name it once it is on the page.
requestAnimationFrame(labelAnchor);
return [PANEL];
},
settings: [
{
id: SETTING,
name: "Line-up button on the selection toolbar",
type: "boolean",
defaultValue: true,
category: ["🆎 AusBoss", "Canvas", "Line-up button"],
tooltip:
"With two or more nodes or groups selected, the toolbar over them gets a button " +
"that lines them up, gives them one size or spaces them evenly.",
onChange(value) {
if (value === false) closePanel();
},
},
{
id: TOGETHER_SETTING,
name: "Resize selected nodes together",
type: "boolean",
defaultValue: true,
category: ["🆎 AusBoss", "Canvas", "Resize together"],
tooltip:
"With two or more nodes selected, pulling the edge or corner of one resizes them all: " +
"a column gets one width, a row one height, a grid both.",
onChange(value) {
together.enabled = value !== false;
if (!together.enabled) endTogether();
},
},
],
setup() {
const style = document.createElement("style");
style.id = "ausboss-line-up-style";
style.textContent = styleSheet();
document.head.append(style);
// onChange only fires on later edits on some frontends, so read the
// stored value here.
together.enabled = app.ui?.settings?.getSettingValue?.(TOGETHER_SETTING) !== false;
installTogether();
},
});
+84
View File
@@ -0,0 +1,84 @@
// Load Audio's window rules for the face, mirroring audio_window in
// nodes/_audio_trim_helpers.py so the wave shows exactly what one Run hands
// on. Pure: tests/load_audio_window.test.mjs runs it without a browser.
function finite(value, fallback = 0) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
// The pick, and what comes out of it. `end` 0 means "to the end of the
// file". A max above 0 keeps the start and cuts off anything past
// start + max. All times in seconds; duration is the file's length.
export function outputWindow(durationValue, startValue, endValue, maxValue = 0) {
const duration = Math.max(0, finite(durationValue));
const start = Math.min(Math.max(0, finite(startValue)), duration);
const pickedEnd = finite(endValue) > 0 ? Math.min(finite(endValue), duration) : duration;
const end = Math.max(start, pickedEnd);
const max = Math.max(0, finite(maxValue));
const keepEnd = max > 0 && end - start > max ? Math.min(duration, start + max) : end;
return { start, end, keepEnd, length: keepEnd - start, cut: Math.max(0, end - keepEnd) };
}
// "0:12.0" style, the way the IN and OUT fields write a time.
export function secondsLabel(value) {
const seconds = Math.max(0, finite(value));
if (seconds < 60) return `${seconds.toFixed(1)} s`;
const minutes = Math.floor(seconds / 60);
return `${minutes}:${(seconds - minutes * 60).toFixed(1).padStart(4, "0")}`;
}
// The line between IN and OUT: what comes out, and what the max cuts off.
export function outputSummary(window, { maxFromLink = false } = {}) {
if (!window || window.length <= 0) return "";
const parts = [secondsLabel(window.length)];
if (window.cut > 0.005) parts.push(`cut ${secondsLabel(window.cut)}`);
if (maxFromLink) parts.push("max wired");
return parts.join(" · ");
}
// Loud and quiet across the file: for each of `buckets` slices, the lowest
// and highest sample of the channels mixed down. Channel data is a list of
// Float32Arrays (AudioBuffer.getChannelData). Returns a Float32Array of
// [min0, max0, min1, max1, ...].
export function wavePeaks(channels, bucketsValue = 1200) {
const list = Array.isArray(channels) ? channels.filter(Boolean) : [];
const buckets = Math.max(1, Math.floor(finite(bucketsValue, 1200)));
const peaks = new Float32Array(buckets * 2);
const length = list.length ? list[0].length : 0;
if (!length) return peaks;
const count = list.length;
for (let bucket = 0; bucket < buckets; bucket += 1) {
const from = Math.floor((bucket * length) / buckets);
const to = Math.max(from + 1, Math.floor(((bucket + 1) * length) / buckets));
let low = 0;
let high = 0;
for (let index = from; index < to && index < length; index += 1) {
let sample = 0;
for (let channel = 0; channel < count; channel += 1) sample += list[channel][index] || 0;
sample /= count;
if (sample < low) low = sample;
if (sample > high) high = sample;
}
peaks[bucket * 2] = low;
peaks[bucket * 2 + 1] = high;
}
return peaks;
}
// Which part of the wave a press at `fraction` (0..1 across it) grabs: a
// handle within `hitPixels` of it, the inside of the pick to slide it, or
// "jump" to move the pick there.
export function grabZone(window, durationValue, fraction, widthValue, hitPixels = 10) {
const duration = Math.max(0, finite(durationValue));
const width = Math.max(0, finite(widthValue));
if (duration <= 0 || width <= 0) return "none";
const x = Math.min(1, Math.max(0, finite(fraction))) * width;
const startX = (window.start / duration) * width;
const endX = (window.end / duration) * width;
const toStart = Math.abs(x - startX);
const toEnd = Math.abs(x - endX);
if (Math.min(toStart, toEnd) <= hitPixels) return toStart <= toEnd ? "start" : "end";
if (x > startX && x < endX) return "move";
return "jump";
}
+574
View File
@@ -0,0 +1,574 @@
// Load Audio 🆎 - the wave, the trim and the player.
//
// The card above (js/widget_cards) holds Source and Max length. This panel
// draws the whole file as a wave: the part that comes out is bright, the
// part a Max length cuts off is red, and the rest is dim. Drag a handle to
// move IN or OUT, drag the middle to slide the pick, or click elsewhere to
// move it there. Play plays only what comes out. The widgets underneath
// (start_seconds, end_seconds, max_seconds) stay the source of truth.
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange, showToast } from "../shared/index.mjs";
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
import { formatTimecode, parseTimecode } from "../shared/timecode.mjs";
import { parseImageReference } from "../shared/pad_canvas.mjs";
import { mediaViewQuery, splitMediaName } from "../shared/video_preview.mjs";
import { ensureVideoCss, hideCanvasWidget, makeToolButton } from "../shared/video_ui.mjs";
import { MIN_TRIM_SECONDS, dragTrimHandle, slideTrimWindow } from "../load_video/trim_preview.mjs";
import { grabZone, outputSummary, outputWindow, secondsLabel, wavePeaks } from "./audio_window.mjs";
const NODE_NAME = "AUSBOSS_NODES_LoadAudio";
const PANEL_WIDGET = "ausboss_load_audio_wave";
// The frontend's own player for an audio_upload input. The wave replaces it.
const CORE_AUDIO_WIDGET = "audioUI";
const MIN_WIDTH = 280;
// 8px root padding + the wave's 28px floor + 6px gap + the 22px play, IN/OUT and loop row.
const PANEL_MIN_HEIGHT = 64;
// Opens small and neat: 300 px wide, with a wave about as tall as a field
// (32 px). Drag the corner down for a taller wave.
const DEFAULT_WIDTH = 300;
const DEFAULT_WAVE_EXTRA = 4;
const CUT_RED = "#e0525a";
const PEAK_BUCKETS = 1600;
const END_EPSILON = 0.03;
// Decoded once per file and kept small: only the peaks and the length.
const peakCache = new Map();
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function roundHundredth(value) {
return Math.round(Number(value || 0) * 100) / 100;
}
function hideCoreAudio(node) {
for (const widget of node.widgets ?? []) {
if (widget?.name === CORE_AUDIO_WIDGET) hideCanvasWidget(widget, widget.element);
}
}
// A wired Max length: the number the wire carries, when the far end is a
// node that shows it (Float, Integer, a Primitive). null when it can't be known.
function wiredMax(node) {
const input = node.inputs?.find((slot) => slot?.widget?.name === "max_seconds");
if (!input || input.link == null) return { wired: false, value: null };
const graph = node.graph;
const link = graph?.getLink?.(input.link) ?? graph?.links?.get?.(input.link) ?? graph?.links?.[input.link];
const origin = link ? graph?.getNodeById?.(link.origin_id) : null;
const shown = origin?.widgets?.find((widget) => typeof widget?.value === "number");
return { wired: true, value: shown ? Number(shown.value) : null };
}
function maxSeconds(state) {
const wired = wiredMax(state.node);
if (wired.wired) return { value: wired.value ?? 0, fromLink: true, known: wired.value != null };
return { value: Number(state.maxWidget?.value) || 0, fromLink: false, known: true };
}
function currentWindow(state) {
return outputWindow(state.duration, state.startWidget.value, state.endWidget.value, maxSeconds(state).value);
}
async function loadPeaks(name, route) {
const key = name;
if (peakCache.has(key)) return peakCache.get(key);
const job = (async () => {
const response = await api.fetchApi(route, { cache: "no-store" });
if (!response.ok) throw new Error(`the file could not be read (${response.status})`);
const bytes = await response.arrayBuffer();
const Context = window.AudioContext || window.webkitAudioContext;
const context = new Context();
try {
const buffer = await context.decodeAudioData(bytes);
const channels = [];
for (let index = 0; index < buffer.numberOfChannels; index += 1) channels.push(buffer.getChannelData(index));
return { duration: buffer.duration, peaks: wavePeaks(channels, PEAK_BUCKETS) };
} finally {
context.close?.();
}
})();
peakCache.set(key, job);
job.catch(() => peakCache.delete(key));
return job;
}
function ensureAudioCss() {
ensureVideoCss();
if (document.getElementById("ausboss-load-audio-css")) return;
const style = document.createElement("style");
style.id = "ausboss-load-audio-css";
style.textContent = `
.ausboss-audio-root .ausboss-video-stage{min-height:28px;background:#050b0b;cursor:pointer;touch-action:none;}
.ausboss-audio-error{pointer-events:auto;border:0;background:transparent;color:#e6bf87;font:11px system-ui;cursor:pointer;white-space:nowrap}
.ausboss-audio-root canvas.ausboss-audio-wave{position:absolute;inset:0;width:100%;height:100%;display:block;}
.ausboss-audio-root .ausboss-video-stage:not(.is-empty) .ausboss-video-status{display:none;}
.ausboss-audio-root .ausboss-audio-row{display:flex;align-items:center;gap:6px;flex:none;padding:0 2px;}
.ausboss-audio-root .ausboss-audio-row .ausboss-video-values{flex:1 1 auto;min-width:0;}
.ausboss-audio-root .ausboss-audio-play{min-width:30px;font-size:10px;}
.ausboss-audio-root .ausboss-audio-length{flex:none;padding:0 2px;color:#7fd9d2;font-size:10px;font-variant-numeric:tabular-nums;white-space:nowrap;cursor:default;}
.ausboss-audio-root .ausboss-audio-length:empty{display:none;}
.ausboss-audio-root .ausboss-audio-length.has-cut{color:#ff9aa0;cursor:pointer;text-decoration:underline dotted rgba(224,82,90,.8);text-underline-offset:2px;}
.ausboss-audio-root .ausboss-audio-row .ausboss-video-tool{flex:none;}
.ausboss-audio-root .ausboss-video-values{display:flex;gap:8px;}
.ausboss-audio-root .ausboss-video-value input{width:48px;}
.ausboss-audio-root .ausboss-audio-length{margin-left:auto;}
`;
document.head.appendChild(style);
}
function draw(state) {
const { canvas } = state;
const rect = canvas.getBoundingClientRect();
const scale = window.devicePixelRatio || 1;
const width = Math.max(1, Math.round(rect.width * scale));
const height = Math.max(1, Math.round(rect.height * scale));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, width, height);
if (!state.peaks || state.duration <= 0) return;
const win = currentWindow(state);
const xOf = (seconds) => (seconds / state.duration) * width;
const startX = xOf(win.start);
const keepX = xOf(win.keepEnd);
const endX = xOf(win.end);
// Backdrops: the part that comes out, then the part cut off.
ctx.fillStyle = "rgba(0,180,170,0.13)";
ctx.fillRect(startX, 0, keepX - startX, height);
if (endX > keepX) {
ctx.fillStyle = "rgba(224,82,90,0.16)";
ctx.fillRect(keepX, 0, endX - keepX, height);
}
// The wave, one bar per pixel column.
const mid = height / 2;
const reach = mid * 0.92;
const buckets = state.peaks.length / 2;
for (let x = 0; x < width; x += 1) {
const from = Math.floor((x / width) * buckets);
const to = Math.max(from + 1, Math.floor(((x + 1) / width) * buckets));
let low = 0;
let high = 0;
for (let bucket = from; bucket < to && bucket < buckets; bucket += 1) {
low = Math.min(low, state.peaks[bucket * 2]);
high = Math.max(high, state.peaks[bucket * 2 + 1]);
}
if (x >= startX && x < keepX) ctx.fillStyle = BRAND;
else if (x >= keepX && x < endX) ctx.fillStyle = CUT_RED;
else ctx.fillStyle = "#3a4948";
const top = mid - high * reach;
ctx.fillRect(x, top, 1, Math.max(1, (high - low) * reach));
}
// The handles: IN and OUT, and a dashed line where the max cuts off.
// The grips stay small on a short wave so they don't hide it.
const grip = Math.round(Math.max(3, Math.min(7, rect.height * 0.12)) * scale);
const handle = (x, color) => {
ctx.fillStyle = color;
ctx.fillRect(Math.round(x) - scale, 0, 2 * scale, height);
ctx.fillRect(Math.round(x) - 4 * scale, 0, 8 * scale, grip);
ctx.fillRect(Math.round(x) - 4 * scale, height - grip, 8 * scale, grip);
};
handle(startX, "#f4ffff");
handle(endX, "#f4ffff");
if (endX > keepX + 1) {
ctx.strokeStyle = CUT_RED;
ctx.lineWidth = 2 * scale;
ctx.setLineDash([4 * scale, 3 * scale]);
ctx.beginPath();
ctx.moveTo(keepX, 0);
ctx.lineTo(keepX, height);
ctx.stroke();
ctx.setLineDash([]);
}
// The playhead while it plays, or where it paused inside the pick.
if (state.audio && !state.audio.paused) {
const x = xOf(state.audio.currentTime);
ctx.fillStyle = "#ffffff";
ctx.fillRect(x - scale / 2, 0, scale, height);
}
}
function updateFace(state) {
const win = currentWindow(state);
const max = maxSeconds(state);
if (document.activeElement !== state.startInput) state.startInput.value = formatTimecode(win.start);
if (document.activeElement !== state.endInput) state.endInput.value = formatTimecode(win.end);
if (state.duration > 0) {
const summary = outputSummary(win, { maxFromLink: max.fromLink && !max.known });
state.summary.textContent = win.length > 0 ? secondsLabel(win.length) : "";
state.summary.classList.toggle("has-cut", win.cut > 0.005);
state.summary.title = win.cut > 0.005
? `${summary}. Max length is ${max.value} s, so ${formatTimecode(win.keepEnd)} to ${formatTimecode(win.end)} is cut off (red). Click to move OUT to ${formatTimecode(win.keepEnd)}.`
: `${summary}: ${formatTimecode(win.start)} to ${formatTimecode(win.keepEnd)} comes out.`;
} else {
state.summary.textContent = "";
}
draw(state);
}
function writeWidget(widget, value) {
const next = roundHundredth(value);
if (Math.abs(Number(widget.value || 0) - next) < 0.0001) return;
widget.value = next;
widget.callback?.(next);
}
function writeTrim(state, bounds) {
state.writing = true;
try {
writeWidget(state.startWidget, bounds.start);
// OUT at the very end stays 0, the widget's own "to the end".
writeWidget(state.endWidget, bounds.end >= state.duration - 0.005 ? 0 : bounds.end);
} finally {
state.writing = false;
}
updateFace(state);
state.node.setDirtyCanvas?.(true, true);
}
function installDrag(state) {
const surface = state.stage;
const infoAt = (clientX) => {
const rect = state.canvas.getBoundingClientRect();
const fraction = rect.width ? Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)) : 0;
const win = currentWindow(state);
return { fraction, win, zone: grabZone(win, state.duration, fraction, rect.width) };
};
surface.addEventListener("pointermove", (event) => {
if (state.dragging || state.duration <= 0) return;
const { zone } = infoAt(event.clientX);
surface.style.cursor = zone === "start" || zone === "end" ? "ew-resize" : zone === "move" ? "grab" : "pointer";
});
surface.addEventListener("pointerdown", (event) => {
if (event.button !== 0 || state.dragging || state.duration <= 0) return;
event.preventDefault();
event.stopPropagation();
const initial = infoAt(event.clientX);
const bounds = { start: initial.win.start, end: initial.win.end };
const anchor = initial.fraction * state.duration;
const downX = event.clientX;
let moved = false;
state.dragging = true;
try { surface.setPointerCapture(event.pointerId); } catch (_error) { /* mouse fallback */ }
const centred = (fraction) => {
const span = bounds.end - bounds.start;
return slideTrimWindow(state.duration, bounds, fraction * state.duration - span / 2 - bounds.start);
};
const apply = (clientX) => {
const next = infoAt(clientX);
if (initial.zone === "start" || initial.zone === "end") {
const now = currentWindow(state);
writeTrim(state, dragTrimHandle(state.duration, { start: now.start, end: now.end }, initial.zone, next.fraction, MIN_TRIM_SECONDS));
} else if (initial.zone === "move") {
writeTrim(state, slideTrimWindow(state.duration, bounds, next.fraction * state.duration - anchor));
} else {
writeTrim(state, centred(next.fraction));
}
};
const move = (moveEvent) => {
if (!(moveEvent.buttons & 1)) { finish(moveEvent); return; }
if (!moved && Math.abs(moveEvent.clientX - downX) < 3) return;
moved = true;
apply(moveEvent.clientX);
};
const finish = (finishEvent) => {
if (!state.dragging) return;
state.dragging = false;
surface.removeEventListener("pointermove", move);
try { surface.releasePointerCapture(event.pointerId); } catch (_error) { /* already released */ }
if (!moved && initial.zone === "jump") writeTrim(state, centred(infoAt(finishEvent?.clientX ?? downX).fraction));
if (moved || initial.zone === "jump") {
if (state.audio && !state.audio.paused) state.audio.currentTime = currentWindow(state).start;
notifyAusbossChange();
}
};
surface.addEventListener("pointermove", move);
surface.addEventListener("pointerup", finish, { once: true });
surface.addEventListener("pointercancel", finish, { once: true });
surface.addEventListener("lostpointercapture", finish, { once: true });
});
}
function loopEnabled(node) {
node.properties ??= {};
return !!node.properties.ausboss_load_audio_loop;
}
function installPlayer(state) {
const audio = state.audio;
const stop = () => {
if (state.raf) cancelAnimationFrame(state.raf);
state.raf = 0;
state.playButton.textContent = "▶";
state.playButton.classList.remove("active");
draw(state);
};
const tick = () => {
if (audio.paused) { stop(); return; }
const win = currentWindow(state);
if (audio.currentTime >= win.keepEnd - END_EPSILON || audio.currentTime < win.start - 0.25) {
if (loopEnabled(state.node)) audio.currentTime = win.start;
else { audio.pause(); audio.currentTime = win.start; stop(); return; }
}
draw(state);
state.raf = requestAnimationFrame(tick);
};
audio.addEventListener("play", () => {
state.playButton.textContent = "❚❚";
state.playButton.classList.add("active");
if (state.raf) cancelAnimationFrame(state.raf);
state.raf = requestAnimationFrame(tick);
}, { signal: state.abort.signal });
audio.addEventListener("pause", stop, { signal: state.abort.signal });
state.playButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (state.duration <= 0) return;
if (!audio.paused) { audio.pause(); return; }
const win = currentWindow(state);
if (audio.currentTime < win.start || audio.currentTime >= win.keepEnd - END_EPSILON) audio.currentTime = win.start;
audio.play().catch(() => stop());
});
state.stopPlayer = () => { audio.pause(); stop(); };
}
function commitTimeInput(state, edge, input) {
if (state.duration <= 0) return;
const seconds = parseTimecode(input.value);
const win = currentWindow(state);
if (seconds === null) {
input.value = formatTimecode(edge === "start" ? win.start : win.end);
return;
}
writeTrim(state, dragTrimHandle(state.duration, { start: win.start, end: win.end }, edge, seconds / state.duration, MIN_TRIM_SECONDS));
notifyAusbossChange();
input.blur();
}
function watchWidget(state, widget) {
if (!widget || widget.__ausbossAudioWatch) return;
widget.__ausbossAudioWatch = true;
const prior = widget.callback;
widget.callback = function (...args) {
const result = prior?.apply(this, args);
if (!state.writing) updateFace(state);
return result;
};
}
function buildPanel(node) {
if (node.__ausbossLoadAudio) return node.__ausbossLoadAudio;
const audioWidget = findWidget(node, "audio");
const startWidget = findWidget(node, "start_seconds");
const endWidget = findWidget(node, "end_seconds");
if (!audioWidget || !startWidget || !endWidget) return null;
ensureAudioCss();
hideCoreAudio(node);
hideCanvasWidget(startWidget);
hideCanvasWidget(endWidget);
const root = document.createElement("div");
root.className = "ausboss-video-root ausboss-audio-root";
const stage = document.createElement("div");
stage.className = "ausboss-video-stage is-empty";
stage.title = "Drag a handle to set where the sound starts or stops. Drag the middle to slide it.";
const canvas = document.createElement("canvas");
canvas.className = "ausboss-audio-wave";
const status = document.createElement("div");
status.className = "ausboss-video-status";
status.textContent = "Choose a sound file";
const playButton = makeToolButton("▶", "Play the part that comes out");
playButton.classList.add("ausboss-audio-play");
const loopButton = makeToolButton("LOOP", "Play it again and again");
stage.append(canvas, status);
const values = document.createElement("div");
values.className = "ausboss-video-values";
const makeValue = (labelText, title) => {
const wrap = document.createElement("div");
wrap.className = "ausboss-video-value";
const label = document.createElement("label");
label.textContent = labelText;
const input = document.createElement("input");
input.type = "text";
input.inputMode = "decimal";
input.placeholder = "m:ss.s";
input.title = title;
input.addEventListener("keydown", (event) => {
keepKeyInField(event);
if (event.key === "Enter") input.dispatchEvent(new Event("change"));
});
wrap.append(label, input);
return { wrap, input };
};
const startValue = makeValue("IN", "Where the sound starts. Type seconds (12.5) or a time (0:12.5).");
const endValue = makeValue("OUT", "Where the sound stops. Type seconds (24) or a time (0:24.0).");
// How long the output is, beside OUT and off the wave. Red when Max
// length cuts the pick short; the tooltip says how much.
const summary = document.createElement("div");
summary.className = "ausboss-audio-length";
values.append(startValue.wrap, endValue.wrap);
const row = document.createElement("div");
row.className = "ausboss-audio-row";
row.append(playButton, values, summary, loopButton);
root.append(stage, row);
const widget = node.addDOMWidget(PANEL_WIDGET, "ausboss_audio", root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => PANEL_MIN_HEIGHT + WIDGET_FRAME,
});
keepDomWidgetWidthAuto(widget);
widget.serialize = false;
fillNodeHeight(widget, {
minWidth: MIN_WIDTH,
minHeight: PANEL_MIN_HEIGHT + WIDGET_FRAME,
minNodeSize: [MIN_WIDTH, 200],
exactMinWidth: true,
});
const audio = new Audio();
audio.preload = "auto";
const abort = new AbortController();
const state = node.__ausbossLoadAudio = {
node, root, stage, canvas, status, playButton, loopButton, summary, audio,
startInput: startValue.input, endInput: endValue.input,
audioWidget, startWidget, endWidget, maxWidget: findWidget(node, "max_seconds"),
duration: 0, peaks: null, abort, writing: false, dragging: false, raf: 0, loading: 0,
};
// Clicking the red length moves OUT to where the sound really ends.
summary.addEventListener("click", (event) => {
const win = currentWindow(state);
if (win.cut <= 0.005) return;
event.preventDefault();
event.stopPropagation();
writeTrim(state, { start: win.start, end: win.keepEnd });
notifyAusbossChange();
});
const updateLoop = () => loopButton.classList.toggle("active", loopEnabled(node));
updateLoop();
loopButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
node.properties.ausboss_load_audio_loop = !loopEnabled(node);
updateLoop();
notifyAusbossChange();
});
const refresh = async () => {
state.stopPlayer?.();
const name = audioWidget.value;
const ticket = ++state.loading;
state.duration = 0;
state.peaks = null;
if (!name || typeof name !== "string") {
audio.removeAttribute("src");
stage.classList.add("is-empty");
status.textContent = "Choose a sound file";
updateFace(state);
return;
}
const parts = splitMediaName(name);
stage.classList.add("is-empty");
status.textContent = `Loading ${parts.filename}…`;
const type = parseImageReference(name)?.type ?? "input";
const route = `/view?${mediaViewQuery({ ...parts, type }, "input")}`;
audio.src = api.apiURL(route);
try {
const result = await loadPeaks(name, route);
if (ticket !== state.loading) return;
state.duration = result.duration;
state.peaks = result.peaks;
stage.classList.remove("is-empty");
updateFace(state);
} catch (error) {
if (ticket !== state.loading) return;
const info = document.createElement("button");
info.type = "button";
info.className = "ausboss-audio-error";
info.textContent = "Audio preview unavailable ⓘ";
info.title = String(error?.message || error);
info.setAttribute("aria-label", "Audio preview unavailable. Show details.");
info.onclick = event => {
event.stopPropagation();
showToast({ summary: "Audio preview unavailable", detail: info.title });
};
status.replaceChildren(info);
}
};
state.refresh = refresh;
installDrag(state);
installPlayer(state);
watchWidget(state, startWidget);
watchWidget(state, endWidget);
watchWidget(state, state.maxWidget);
startValue.input.addEventListener("change", () => commitTimeInput(state, "start", startValue.input));
endValue.input.addEventListener("change", () => commitTimeInput(state, "end", endValue.input));
const observer = new ResizeObserver(() => draw(state));
observer.observe(stage);
state.observer = observer;
const priorAudioCallback = audioWidget.callback;
audioWidget.callback = function (...args) {
const result = priorAudioCallback?.apply(this, args);
peakCache.delete(audioWidget.value);
refresh();
return result;
};
setTimeout(refresh, 0);
return state;
}
app.registerExtension({
name: "ausboss.load_audio",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_NAME) return;
// The frontend's Upload button for an audio_upload input looks for its
// player widget and fails without one, and it adds that player only to
// its own audio nodes. Ask for it here, ahead of the button; the panel
// hides it, since the wave plays the sound instead.
const required = nodeData.input?.required;
if (required && !required.audioUI) {
const entries = Object.entries(required);
const at = entries.findIndex(([name]) => name === "audio") + 1;
entries.splice(at, 0, ["audioUI", ["AUDIO_UI", {}]]);
nodeData.input.required = Object.fromEntries(entries);
}
chainCallback(nodeType.prototype, "onNodeCreated", function () {
buildPanel(this);
// Only for a genuinely new node: onConfigure restores a saved size
// after this runs, so a workflow's own size still wins.
const floor = Number(this.computeSize?.()?.[1]) || 0;
if (floor > 0) this.setSize?.([DEFAULT_WIDTH, floor + DEFAULT_WAVE_EXTRA]);
});
chainCallback(nodeType.prototype, "onConfigure", function () {
queueMicrotask(() => {
const state = buildPanel(this);
if (state) {
hideCoreAudio(this);
state.refresh?.();
}
});
});
// A wire into Max length changes what comes out; redraw when one comes or goes.
chainCallback(nodeType.prototype, "onConnectionsChange", function () {
const state = this.__ausbossLoadAudio;
if (state) queueMicrotask(() => updateFace(state));
});
chainCallback(nodeType.prototype, "onRemoved", function () {
const state = this.__ausbossLoadAudio;
state?.stopPlayer?.();
state?.abort?.abort();
state?.observer?.disconnect();
if (state) state.audio.removeAttribute("src");
this.__ausbossLoadAudio = null;
});
},
});
+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";
+6 -3
View File
@@ -1,9 +1,10 @@
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
import { formatTimecode, parseTimecode } from "../shared/timecode.mjs";
import { parseImageReference } from "../shared/pad_canvas.mjs";
import {
mediaInfo,
mediaViewQuery,
@@ -590,7 +591,9 @@ function buildPreview(node) {
const parts = splitMediaName(name);
setEmpty(state, `Loading ${parts.filename}…`);
video.pause();
video.src = api.apiURL(`/view?${mediaViewQuery({ ...parts, type: "input" }, "input")}`);
// A video ComfyUI made ("clip.mp4 [output]") plays from its own folder.
const type = parseImageReference(name)?.type ?? "input";
video.src = api.apiURL(`/view?${mediaViewQuery({ ...parts, type }, "input")}`);
video.load();
probeSourceFps(state, name);
};
+917 -119
View File
File diff suppressed because it is too large Load Diff
+2 -2
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";
+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 {
+179 -68
View File
@@ -21,7 +21,8 @@
// at the selected pixel budget and follow the explicit orientation toggle. Snap governs gestures only — typed
// values are never re-snapped.
import { app } from "/scripts/app.js";
import { CAPTION_TYPE, HINT_TYPE, compactAction } from "../shared/panel_controls.mjs";
import { app } from "../../../scripts/app.js";
import {
chainCallback,
keepDomWidgetWidthAuto,
@@ -29,8 +30,10 @@ import {
} from "../shared/index.mjs";
import { WIDGET_FRAME, fillNodeHeight, holdVueNodeMinWidth } from "../shared/panel_layout.mjs";
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
import { markSocketless } from "../shared/widget_card_math.mjs";
import { makeScrubInput } from "../shared/scrub_input.mjs";
import {
closeSettingsMenu,
gearIconSvg,
loadSettings,
openSettingsMenu,
@@ -51,12 +54,16 @@ import {
offGrid,
orientRatio,
parseRail,
parseWorkflowSizes,
readWorkflowPolicy,
savedWorkflowPolicy,
workflowRatios,
quickSizes,
ratioLabel,
reduceRatio,
scrubDim,
sizeForMegapixels,
stageScale,
fitStage,
} from "../shared/resolution_math.mjs";
const NODE_CLASS = "AUSBOSS_NODES_Resolution";
@@ -132,13 +139,19 @@ function installStyles() {
.ausboss-res-panel { display: flex; flex-direction: column; gap: ${SECTION_GAP}px;
width: 100%; height: 100%; padding: ${PANEL_PADDING}px; overflow: hidden;
font: 400 11px ${MONO}; color: #d7dde2; }
.ausboss-size-dialog{color-scheme:dark;width:min(365px,88vw);border:1px solid #345554;border-radius:10px;background:#162022;color:#d7e4e3;padding:18px;font:13px system-ui}
.ausboss-size-dialog::backdrop{background:#0008}
.ausboss-size-dialog textarea{display:block;box-sizing:border-box;width:100%;margin:8px 0;padding:8px;font:13px monospace}
.ausboss-size-dialog input{accent-color:#00b4aa}
.ausboss-size-dialog [role=alert]{color:#ffd49e}
.ausboss-size-dialog p{color:#adc1be;font:12px/1.45 system-ui;margin:7px 0 12px}
.ausboss-size-dialog footer{display:flex;justify-content:flex-end;gap:8px;margin-top:12px}
.ausboss-res-sec { flex: none; background: #141515; border: 1px solid #252727;
border-radius: 8px; padding: 6px 8px 8px; }
.ausboss-res-sechead { display: flex; align-items: baseline;
justify-content: space-between; margin-bottom: 6px; gap: 8px; }
.ausboss-res-sechead .label { font: 500 9px ${MONO}; letter-spacing: .14em;
text-transform: uppercase; color: #6a7070; white-space: nowrap; }
.ausboss-res-sechead .hint { font: 400 9px ${MONO}; color: #4e5454;
.ausboss-res-sechead .label { ${CAPTION_TYPE} white-space: nowrap; }
.ausboss-res-sechead .hint { ${HINT_TYPE}
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ausboss-res-shaperow { display: flex; gap: 6px; align-items: stretch; }
.ausboss-res-subbox { background: #101111; border: 1px solid #252727;
@@ -174,12 +187,12 @@ function installStyles() {
background-image: radial-gradient(#242727 1px, transparent 1px);
background-size: 22px 22px; background-position: center; opacity: .55; }
.ausboss-res-rings { position: absolute; inset: 0; pointer-events: none; }
.ausboss-res-rect { position: absolute; inset: 0; margin: auto;
border: 1.5px solid #00b4aa; background: rgba(0,180,170,.05);
.ausboss-res-rect { position: absolute; inset: var(--ausboss-res-inset,0) 0 0; margin: auto;
border: 1.5px solid #00b4aa; background: rgba(0,180,170,.10);
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 2px; }
.ausboss-res-rect.easing { transition: width ${REFIT_MS}ms ease, height ${REFIT_MS}ms ease; }
.ausboss-res-ghost { position: absolute; inset: 0; margin: auto;
.ausboss-res-ghost { position: absolute; inset: var(--ausboss-res-inset,0) 0 0; margin: auto;
border: 1px dashed #4d5555; pointer-events: none; display: none; }
.ausboss-res-readout { position: absolute; top: 6px; left: 9px; right: 9px; z-index: 2;
display: flex; align-items: center; justify-content: space-between; gap: 8px;
@@ -207,6 +220,8 @@ function installStyles() {
height: ${CONTROL_H}px; background: #1c1e1e; border-color: #313434; font: 500 13px ${MONO}; }
.ausboss-res-field .ausboss-scrub > .ausboss-scrub-input { text-align: left;
padding-left: 8px; color: #eef1f1; }
.ausboss-res-field[inert] .ausboss-scrub,.ausboss-res-field .ausboss-scrub[inert]{border-color:transparent;background:transparent}
.ausboss-res-field[inert] .ausboss-scrub-step,.ausboss-res-field .ausboss-scrub[inert] .ausboss-scrub-step{display:none}
.ausboss-res-field.linked { opacity: .5; pointer-events: none; }
.ausboss-res-axis { flex: none; font: 400 9px ${MONO}; color: #565c5c; }
.ausboss-res-btn { width: ${CONTROL_H}px; height: ${CONTROL_H}px; flex: none;
@@ -214,7 +229,7 @@ function installStyles() {
border-radius: 5px; color: #9aa0a0; cursor: pointer; font-size: 12px; padding: 0; }
.ausboss-res-btn:hover { border-color: #00b4aa; color: #00b4aa; }
.ausboss-res-quick { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px;
min-height: ${QUICK_H * 2 + 5}px; align-content: start; }
height: ${QUICK_H * 2 + 5}px; overflow-y: auto; align-content: start; }
.ausboss-res-chip.customchip { opacity: .45; cursor: default;
pointer-events: none; border-style: dashed; grid-column: span 2; }
.ausboss-res-quick .ausboss-res-chip { height: ${QUICK_H}px; font-size: 9.5px;
@@ -256,6 +271,7 @@ function getSize(state) {
}
function commitSize(state, width, height, { gesture = false } = {}) {
if (state.strict && (!isListed(state, width, height) || axisLinked(state, "width") || axisLinked(state, "height"))) return;
if (!axisLinked(state, "width")) state.widthWidget.value = clampDim(width);
if (!axisLinked(state, "height")) state.heightWidget.value = clampDim(height);
repaint(state);
@@ -350,7 +366,7 @@ function stageBox(state) {
// Equal-cost curves, log-sampled across the plausible width range so they
// sweep the whole quadrant (the design's rendering).
function paintRings(state, scale) {
function paintRings(state, scale, shift) {
const svg = state.rings;
svg.replaceChildren();
const show = state.settings?.show_mp_rings !== false;
@@ -358,7 +374,7 @@ function paintRings(state, scale) {
const box = stageBox(state);
svg.setAttribute("viewBox", `0 0 ${box.w} ${box.h}`);
const cx = box.w / 2;
const cy = box.h / 2;
const cy = box.h / 2 + shift;
for (const mp of [0.5, 1, 2]) {
const points = [];
for (let i = 0; i <= 60; i += 1) {
@@ -396,7 +412,7 @@ function paintRings(state, scale) {
// The quick list regenerates when the ratio or snap changes; repaint only
// re-highlights it otherwise.
function rebuildQuick(state, width, height, snap, custom) {
function rebuildQuick(state, width, height, snap, custom, picks) {
const wrap = state.quickWrap;
wrap.replaceChildren();
state.quickButtons = [];
@@ -409,7 +425,8 @@ function rebuildQuick(state, width, height, snap, custom) {
wrap.append(chip);
return;
}
const list = quickSizes(width, height, snap);
const list = picks ?? quickSizes(width, height, snap);
if (state.strict && !list.length) return;
if (!list.length) {
wrap.append(el("span", "ausboss-res-noquick",
"No on-grid sizes for this ratio at this snap — try a finer snap."));
@@ -420,6 +437,7 @@ function rebuildQuick(state, width, height, snap, custom) {
chip.type = "button";
chip.title = `Apply ${size.width}×${size.height} exactly.`;
chip.addEventListener("click", () => {
if (picks && size.width !== size.height) state.node.properties.ausbossResolutionPortrait = size.height > size.width;
commitSize(state, size.width, size.height);
easeRefit(state);
});
@@ -430,26 +448,98 @@ function rebuildQuick(state, width, height, snap, custom) {
}
}
function isListed(state, width, height) {
return state.sizes.some(([w, h]) => w === width && h === height);
}
function restorePolicy(state) {
const widget = state.policyWidget;
if (!widget) return;
const legacy = state.node.properties?.ausbossWorkflowSizes;
widget.value = savedWorkflowPolicy(widget.value, legacy);
if (state.node.properties) delete state.node.properties.ausbossWorkflowSizes;
try {
Object.assign(state, readWorkflowPolicy(widget.value), { policyError: false });
} catch {
Object.assign(state, { sizes: [], strict: true, policyError: true });
}
const { width, height } = getSize(state);
if (state.strict && state.sizes.length && !isListed(state, width, height)
&& !axisLinked(state, "width") && !axisLinked(state, "height")) {
[state.widthWidget.value, state.heightWidget.value] = state.sizes[0];
}
}
function editWorkflowSizes(state) {
closeSettingsMenu();
const dialog = el("dialog", "ausboss-size-dialog");
dialog.setAttribute("aria-label", "Workflow resolutions");
const label = el("label", "", "Workflow resolutions");
const area = el("textarea");
area.rows = 6;
area.placeholder = "576 × 1024";
area.value = state.sizes.map(pair => pair.join(" × ")).join("\n");
label.append(area);
const hint = el("p", "", "One width × height per line. These quick picks are saved with this node.");
const lock = el("label");
const checkbox = el("input");
checkbox.type = "checkbox";
checkbox.checked = state.strict;
lock.append(checkbox, " Limit choices to this list");
const description = el("p", "", "Only listed sizes and their exact aspect ratios can be selected. Linked dimensions are checked when the workflow runs.");
const error = el("div");
error.setAttribute("role", "alert");
const footer = el("footer");
const cancel = compactAction("Cancel");
const save = compactAction("Save sizes");
cancel.onclick = () => dialog.close();
save.onclick = () => {
let sizes;
try {
sizes = parseWorkflowSizes(area.value);
if (checkbox.checked && !sizes.length) throw new Error("Add a resolution, or turn off the limit to allow any size.");
} catch (e) {
error.textContent = e.message;
area.focus();
return;
}
state.policyWidget.value = sizes.length ? JSON.stringify({ strict: checkbox.checked, sizes }) : "";
restorePolicy(state);
buildPanel(state);
fitNode(state);
notifyAusbossChange();
dialog.close();
};
footer.append(cancel, save);
dialog.append(label, hint, lock, description, error, footer);
document.body.append(dialog);
dialog.addEventListener("close", () => dialog.remove(), { once:true });
dialog.showModal();
}
function repaint(state) {
const { width, height } = getSize(state);
const snap = currentSnap(state);
const box = stageBox(state);
const scale = state.frozenScale ?? stageScale(width, height, box.w, box.h);
const portrait = orientation(state);
const ratio = reduceRatio(width, height);
state.rect.style.width = `${Math.max(8, width * scale)}px`;
state.rect.style.height = `${Math.max(8, height * scale)}px`;
state.dimLabel.textContent = `${width} × ${height}`;
state.subMp.textContent = `${megapixels(width, height).toFixed(2)} MP`;
state.subRatio.textContent = ratioLabel(width, height);
state.offGridDot.style.display = offGrid(width, height, snap) ? "" : "none";
state.subRatio.textContent = state.strict ? `${ratio.x}:${ratio.y}` : ratioLabel(width, height);
state.offGridDot.style.display = !state.strict && offGrid(width, height, snap) ? "" : "none";
state.dots.style.display = state.settings?.show_grid === false ? "none" : "";
paintRings(state, scale);
const box = stageBox(state);
const sideRoom = 14 + Math.max(state.dimLabel.offsetWidth, state.subMp.parentElement.offsetWidth);
const frame = state.frame = state.frozenFrame ?? fitStage(width, height, box.w, box.h, sideRoom);
const { scale, shift } = frame;
state.stage.style.setProperty("--ausboss-res-inset", `${2 * shift}px`);
state.rect.style.width = `${Math.max(8, width * scale)}px`;
state.rect.style.height = `${Math.max(8, height * scale)}px`;
paintRings(state, scale, shift);
// Section labels.
state.orientLabel.textContent = width === height
state.orientLabel.textContent = state.strict ? "Workflow ratios" : width === height
? `square · chips ${portrait ? "portrait" : "landscape"}`
: portrait ? "portrait" : "landscape";
const flipFit = portrait ? { w: 11, h: 15 } : { w: 15, h: 11 };
@@ -463,14 +553,17 @@ function repaint(state) {
// Ratio chips render in the selected orientation; filled = exact, dashed = near.
state.chipButtons.forEach(({ button, ratio: chipRatio, glyph, label }) => {
const oriented = orientRatio(chipRatio, portrait);
const oriented = state.strict ? chipRatio : orientRatio(chipRatio, portrait);
const fitted = fitRectInBox(oriented.x, oriented.y, 14, 14);
glyph.style.width = `${fitted.w.toFixed(1)}px`;
glyph.style.height = `${fitted.h.toFixed(1)}px`;
label.textContent = oriented.label;
button.title = `${oriented.label}: reshape to this ratio. The flip button on the left sets the orientation.`;
button.title = state.strict ? `Show approved ${oriented.label} sizes` : `${oriented.label}: reshape to this ratio. The flip button on the left sets the orientation.`;
const candidate = chipMatch(width, height, oriented);
const match = candidate?.flipped ? null : candidate;
const match = state.strict
? (ratio.x === chipRatio.x && ratio.y === chipRatio.y ? { state: "exact" } : null)
: candidate?.flipped ? null : candidate;
button.setAttribute("aria-pressed", String(match?.state === "exact"));
button.classList.toggle("exact", match?.state === "exact");
button.classList.toggle("near", match?.state === "near");
});
@@ -479,49 +572,52 @@ function repaint(state) {
const widthLinked = axisLinked(state, "width");
const heightLinked = axisLinked(state, "height");
const linked = widthLinked || heightLinked;
state.flipButton.disabled = linked;
state.swapButton.disabled = linked;
state.budgetControl.root.style.pointerEvents = linked ? "none" : "";
state.budgetControl.root.style.opacity = linked ? ".5" : "";
state.flipButton.disabled = linked || state.strict;
state.swapButton.disabled = linked || (state.strict && (width === height || !isListed(state, height, width)));
state.budgetControl.root.inert = linked || state.strict;
state.budgetControl.root.style.opacity = linked || state.strict ? ".65" : "";
for (const { button } of state.chipButtons) button.disabled = linked;
state.widthControl.set(width);
state.heightControl.set(height);
state.budgetControl?.set(megapixels(width, height));
state.widthField.classList.toggle("linked", widthLinked);
state.heightField.classList.toggle("linked", heightLinked);
state.widthField.title = widthLinked ? "Width is driven by its input link." : "";
state.heightField.title = heightLinked ? "Height is driven by its input link." : "";
state.budgetControl.set(megapixels(width, height));
for (const [field, wired] of [[state.widthField, widthLinked], [state.heightField, heightLinked]]) {
field.inert = wired || state.strict;
field.classList.toggle("linked", wired);
}
state.handles.e.classList.toggle("linked", widthLinked);
state.handles.s.classList.toggle("linked", heightLinked);
state.handles.se.classList.toggle("linked", widthLinked || heightLinked);
state.handles.se.classList.toggle("linked", linked);
// Quick sizes follow the CURRENT ratio - the Pixaroma behavior, but the
// list is generated on-ratio/on-grid instead of hand-curated. Frozen
// Quick sizes follow the current ratio and snap grid. Frozen
// while a gesture is live (a drag sweeps ratios; regenerating per move
// read as glitching), and a shape off the rail shows one "custom" chip.
if (!state.dragging) {
const onRail = state.railRatios?.some(
(railRatio) => chipMatch(width, height, railRatio)?.state === "exact",
);
const quickKey = onRail ? `${ratio.x}:${ratio.y}/${snap}` : `custom/${snap}`;
const group = state.strict && state.railRatios.find(r => r.x === ratio.x && r.y === ratio.y);
const picks = state.strict ? (group?.sizes ?? []) : state.sizes.length
? state.sizes.map(([w, h]) => ({ width: w, height: h })) : null;
const quickKey = picks ? JSON.stringify(picks) : onRail ? `${ratio.x}:${ratio.y}/${snap}` : `custom/${snap}`;
if (state.quickKey !== quickKey) {
state.quickKey = quickKey;
rebuildQuick(state, width, height, snap, !onRail);
state.quickHint.textContent = onRail
? `for ${ratioLabel(width, height)} · /${snap}`
: `custom · /${snap}`;
rebuildQuick(state, width, height, snap, !picks && !onRail, picks);
}
state.quickHint.textContent = state.policyError ? "Invalid list · edit in settings"
: linked && state.strict ? "Linked size checked at run"
: state.strict ? `Allowed · ${ratio.x}:${ratio.y}` : picks ? "Workflow recommendations"
: onRail ? `for ${ratioLabel(width, height)} · /${snap}` : `custom · /${snap}`;
}
state.quickButtons.forEach(({ button, size }) => {
button.disabled = widthLinked || heightLinked;
button.classList.toggle("exact", size.width === width && size.height === height);
button.disabled = linked;
const selected = size.width === width && size.height === height;
button.classList.toggle("exact", selected);
button.setAttribute("aria-pressed", String(selected));
});
}
function showGhost(state, width, height) {
const box = stageBox(state);
const { width: w, height: h } = getSize(state);
const scale = stageScale(w, h, box.w, box.h);
const { scale } = state.frame;
state.ghost.style.width = `${width * scale}px`;
state.ghost.style.height = `${height * scale}px`;
state.ghost.style.display = "";
@@ -532,7 +628,7 @@ function hideGhost(state) {
}
function easeRefit(state) {
state.frozenScale = null;
state.frozenFrame = null;
state.rect.classList.add("easing");
repaint(state);
clearTimeout(state.easeTimer);
@@ -542,15 +638,14 @@ function easeRefit(state) {
// ---------- gestures ----------
function beginHandleDrag(state, handle, event) {
if (event.button !== 0 || state.disposed) return;
if (state.strict || event.button !== 0 || state.disposed) return;
if ((handle !== "s" && axisLinked(state, "width")) || (handle !== "e" && axisLinked(state, "height"))) return;
event.preventDefault();
event.stopPropagation();
const start = getSize(state);
const box = stageBox(state);
// Frozen at pointerdown: the rect may overflow the stage mid-gesture and
// re-fits with a short ease on release - auto-fit never squirms mid-drag.
state.frozenScale = stageScale(start.width, start.height, box.w, box.h);
state.frozenFrame = state.frame;
state.dragging = true;
const origin = { x: event.clientX, y: event.clientY };
state.dragAbort?.abort();
@@ -563,7 +658,7 @@ function beginHandleDrag(state, handle, event) {
startHeight: start.height,
dx: (moveEvent.clientX - origin.x) / zoom,
dy: (moveEvent.clientY - origin.y) / zoom,
scale: state.frozenScale,
scale: state.frozenFrame.scale,
snap: currentSnap(state),
snapOn: !moveEvent.altKey,
// The corner scales at the exact locked ratio; Shift frees both axes.
@@ -644,7 +739,7 @@ function sectionHead(label, hint) {
function buildPanel(state) {
state.dragAbort?.abort();
state.dragging = false;
state.frozenScale = null;
state.frozenFrame = null;
const panel = state.panel;
panel.textContent = "";
state.quickKey = null; // force the quick list to regenerate
@@ -668,19 +763,16 @@ function buildPanel(state) {
state.flipButton = flip;
state.flipGlyph = flipGlyph;
state.chipButtons = [];
state.railRatios = parseRail(state.settings?.rail);
state.railRatios = state.strict ? workflowRatios(state.sizes) : parseRail(state.settings?.rail);
for (const ratio of state.railRatios) {
const chip = el("button", "ausboss-res-chip");
chip.type = "button";
const glyph = el("span", "glyph");
const label = el("span");
chip.append(glyph, label);
// A chip click lands on that ratio's canonical ~1MP default (the
// Pixaroma behavior, derived instead of table-driven); the gear's
// "keep current budget" toggle restores the budget-preserving mode.
// Chips only pick the shape; orientation belongs to the flip button.
const chipTarget = () => {
const size = getSize(state);
if (state.strict) return ratio.sizes.find(s => s.width === size.width && s.height === size.height) ?? ratio.sizes[0];
const portrait = orientation(state);
if (state.settings?.chips_keep_budget) {
return applyRatioAtBudget(size.width, size.height, ratio, currentSnap(state), portrait);
@@ -701,13 +793,15 @@ function buildPanel(state) {
state.chipButtons.push({ button: chip, ratio, glyph, label });
}
chipBox.append(chipGrid);
shapeRow.append(flipBox, chipBox);
if (!state.strict) shapeRow.append(flipBox);
shapeRow.append(chipBox);
shapeSec.append(shapeRow);
panel.append(shapeSec);
// (2) STAGE
const stage = el("div", "ausboss-res-stage");
stage.tabIndex = 0;
stage.tabIndex = state.strict ? -1 : 0;
if (state.strict) stage.title = "Choose from the workflow’s ratio and size buttons.";
const dots = el("div", "ausboss-res-dots");
const rings = svgEl("svg");
rings.classList.add("ausboss-res-rings");
@@ -734,14 +828,14 @@ function buildPanel(state) {
? "Drag width - Alt unsnaps, Esc cancels"
: "Drag height - Alt unsnaps, Esc cancels";
grip.addEventListener("pointerdown", (event) => beginHandleDrag(state, handle, event));
rect.append(grip);
if (!state.strict) rect.append(grip);
state.handles[handle] = grip;
}
stage.append(dots, rings, ghost, rect, readout);
stage.addEventListener("keydown", (event) => {
const keys = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] };
const move = keys[event.key];
if (!move) return;
if (!move || state.strict) return;
event.preventDefault();
event.stopPropagation();
const step = event.shiftKey ? 8 : currentSnap(state);
@@ -803,10 +897,13 @@ function buildPanel(state) {
gear.addEventListener("click", () => {
openSettingsMenu({
scope: SETTINGS_SCOPE,
schema: SETTINGS_SCHEMA.map((entry) => {
schema: [{ section: "This workflow" }, {
key: "workflow_sizes", type: "action", label: "Workflow resolutions", button: "Edit list",
onAction: () => editWorkflowSizes(state),
}, ...SETTINGS_SCHEMA.filter(e => !state.strict || !["default_snap", "chips_keep_budget", "rail"].includes(e.key)).map((entry) => {
const input = entry.key === "latent" ? "latent" : entry.key === "batch" ? "batch_size" : null;
return input && axisLinked(state, input) ? { section: `${entry.label} is linked` } : entry;
}),
})],
anchor: gear.getBoundingClientRect(),
title: "Latent Size settings",
initial: {
@@ -869,7 +966,7 @@ function buildPanel(state) {
state.resizeObserver?.disconnect();
state.resizeObserver = new ResizeObserver(() => {
holdVueNodeMinWidth(state.panel, MIN_WIDTH);
if (!state.frozenScale) repaint(state);
if (!state.frozenFrame) repaint(state);
});
state.resizeObserver.observe(stage);
@@ -907,6 +1004,8 @@ function installResolutionNode(node) {
// the API format carries them like any other value.
const latentWidget = node.widgets?.find((widget) => widget.name === "latent");
const batchWidget = node.widgets?.find((widget) => widget.name === "batch_size");
const policyWidget = node.widgets?.find(widget => widget.name === "workflow_sizes");
hideWidget(policyWidget);
if (latentWidget) hideWidget(latentWidget);
if (batchWidget) hideWidget(batchWidget);
@@ -928,9 +1027,12 @@ function installResolutionNode(node) {
heightWidget,
latentWidget,
batchWidget,
policyWidget,
sizes: [],
strict: false,
panel,
settings: loadSettings(SETTINGS_SCOPE, SETTINGS_SCHEMA),
frozenScale: null,
frozenFrame: null,
quickKey: null,
};
node.__ausbossResolution = state;
@@ -953,6 +1055,7 @@ function installResolutionNode(node) {
exactMinWidth: true,
});
restorePolicy(state);
buildPanel(state);
for (const widget of [widthWidget, heightWidget, latentWidget, batchWidget]) {
if (widget) chainCallback(widget, "callback", () => {
@@ -980,11 +1083,18 @@ function installResolutionNode(node) {
// empty placeholder in their slots; put the defaults back.
if (latentWidget && !["16ch", "4ch", "128ch"].includes(latentWidget.value)) latentWidget.value = "16ch";
if (batchWidget && !(Number.isInteger(batchWidget.value) && batchWidget.value >= 1)) batchWidget.value = 1;
restorePolicy(state);
buildPanel(state);
requestAnimationFrame(() => { if (!state.disposed) { syncPrimitiveLinks(state); repaint(state); } });
});
chainCallback(node, "onConnectionsChange", function () {
// Convert-to-input honesty: link changes flip the read-only axes.
requestAnimationFrame(() => { if (!state.disposed) { syncPrimitiveLinks(state); repaint(state); } });
requestAnimationFrame(() => {
if (state.disposed) return;
syncPrimitiveLinks(state);
// A removed Primitive can leave its last value in the backing widget.
if (state.strict) restorePolicy(state);
repaint(state);
});
});
chainCallback(node, "onRemoved", () => {
state.disposed = true;
@@ -998,7 +1108,8 @@ app.registerExtension({
name: "AusBoss.Resolution",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_CLASS) return;
hideInputsInDef(nodeData, ["width", "height", "latent", "batch_size"]);
markSocketless(nodeData, ["workflow_sizes"]);
hideInputsInDef(nodeData, ["width", "height", "latent", "batch_size", "workflow_sizes"]);
chainCallback(nodeType.prototype, "onNodeCreated", function () {
installResolutionNode(this);
});
+2 -2
View File
@@ -19,8 +19,8 @@
// check once per input the check reads, and collapseRepeatedErrors keeps
// one per message on AusBoss nodes before the frontend shows them.
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { showToast } from "../shared/index.mjs";
import { nodeByExecutionId } from "../shared/graph_ids.mjs";
import {
+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";
+5 -4
View File
@@ -8,8 +8,9 @@
// When the `filename` input is linked the name comes from upstream: the
// field shows {{filename}} and the tags fold away, since an exact name is
// never decorated.
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { PANEL_LABELS } from "../shared/panel_controls.mjs";
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { isForeignRun } from "../shared/prompt_scope.mjs";
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
@@ -275,13 +276,13 @@ function buildCard(node) {
embedSeg.setAttribute("role", "radiogroup");
const embedButtons = new Map();
const embedTitle = "Embed stores the prompt and workflow in the image for drag-back. Off writes a clean image for sharing or datasets.";
for (const [on, text] of [[false, "no"], [true, "yes"]]) {
for (const [on, text] of [[false, PANEL_LABELS.off], [true, PANEL_LABELS.on]]) {
const button = el("button", "", text);
button.type = "button"; button.title = embedTitle;
button.addEventListener("click", () => { if (setValue(node, "save_metadata", on)) notifyAusbossChange(); state.refresh(); });
embedSeg.append(button); embedButtons.set(on, button);
}
embedRow.append(el("span", "ausboss-save-embed-label", "Embed workflow"), embedSeg);
embedRow.append(el("span", "ausboss-save-embed-label", PANEL_LABELS.embed), embedSeg);
root.append(embedRow);
root.addEventListener("pointerdown", (event) => {
+81 -7
View File
@@ -1,9 +1,11 @@
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { panelSwitch } from "../shared/panel_controls.mjs";
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { isForeignRun, outputKey } from "../shared/prompt_scope.mjs";
import { chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { installNameTokens } from "../shared/name_tokens.mjs";
import { fillNodeHeight } from "../shared/panel_layout.mjs";
import { addStepConsumer, mountStepOverlay, showsStepsOn, stepRun, stopStepRun, watchSteps } from "../shared/step_preview.mjs";
import {
findVideoMetadata,
isStillImage,
@@ -20,7 +22,7 @@ import {
const NODE_NAME = "AUSBOSS_NODES_SaveVideo";
const PREVIEW_WIDGET = "ausboss_save_video_viewer";
const PREVIEW_CHROME = 12;
const PREVIEW_CHROME = 38;
// Height the node opens at. It used to fall out of the panel's computeSize;
// with the panel free to follow the node, the default has to be stated. Same
// 16:9-ish stage the width-derived formula produced, plus the widget rows.
@@ -37,6 +39,40 @@ function getLoopEnabled(node) {
return !!node.properties.ausboss_save_video_loop;
}
// The STEPS button: the hidden `step_preview` widget (the card hides it).
// On, the player shows each step of the clip's first frame while it is
// being made, and a STOP button joins the tools for the run.
function stepsOn(state) {
return state.stepsWidget?.value === true;
}
function syncSteps(state, run = stepRun()) {
const on = stepsOn(state);
state.stepsButton?.setAttribute("aria-checked", String(on));
state.stopButton?.classList.toggle("live", on && run.status === "running");
const overlay = state.stepOverlay;
if (!on) {
overlay.hide();
return;
}
const thisRun = (id) => id !== null && id !== undefined && String(id) === String(run.promptId);
if (run.status === "running") {
if (thisRun(state.stepsSaved)) return;
if (run.url && showsStepsOn(state.node, run)) {
state.stepsShown = run;
overlay.show(run, { saves: true });
} else if (!thisRun(state.stepsShown?.promptId)) {
overlay.hide();
}
return;
}
if (run.status === "stopped" && thisRun(state.stepsShown?.promptId) && !thisRun(state.stepsSaved)) {
overlay.show({ ...state.stepsShown, status: "stopped" }, { saves: true });
return;
}
overlay.hide();
}
function setEmpty(state, text) {
state.stage.classList.add("is-empty");
state.status.textContent = text;
@@ -92,9 +128,14 @@ function buildPreview(node) {
// button only ever re-fetched what was on screen, and after a page reload
// there is no saved file in memory for it to fetch at all.
const loopButton = makeToolButton("LOOP", "Toggle looping for this saved preview");
tools.append(loopButton);
loopButton.classList.add("ausboss-steps-loop");
const stopButton = makeToolButton("STOP", "Stop this run now. The next run in the queue still starts.");
stopButton.classList.add("ausboss-steps-stop");
const stepsButton = panelSwitch("Steps", "Show the clip's first frame here while it is being made, one step at a time, with a Stop button. Off by default.");
tools.classList.add("ausboss-steps-tools");
tools.append(stopButton, loopButton);
stage.append(video, image, status, tools);
root.append(stage);
root.append(stepsButton, stage);
const widget = node.addDOMWidget(PREVIEW_WIDGET, "ausboss_video", root, {
serialize: false,
@@ -107,7 +148,7 @@ function buildPreview(node) {
widget.serialize = false;
fillNodeHeight(widget, {
minWidth: VIDEO_MIN_WIDTH,
minHeight: 144,
minHeight: 158,
minNodeSize: [VIDEO_MIN_WIDTH, 270],
exactMinWidth: true,
});
@@ -116,7 +157,36 @@ function buildPreview(node) {
const state = node.__ausbossSaveVideo = {
node, root, stage, video, image, status, tools, loopButton,
widget, abort, meta: null,
stepsWidget: node.widgets?.find((item) => item?.name === "step_preview") ?? null,
stepsButton, stopButton,
stepOverlay: mountStepOverlay(stage),
};
// Under the tools, so they stay on top of the picture.
stage.insertBefore(state.stepOverlay.root, tools);
if (!state.stepsWidget) stepsButton.style.display = "none";
for (const button of [stopButton, stepsButton]) {
button.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal: abort.signal });
}
stepsButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (!state.stepsWidget) return;
const next = !stepsOn(state);
state.stepsWidget.value = next;
state.stepsWidget.callback?.(next);
syncSteps(state);
node.setDirtyCanvas?.(true, true);
notifyAusbossChange();
}, { signal: abort.signal });
stopButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
Promise.resolve(stopStepRun()).catch(() => {});
}, { signal: abort.signal });
state.forgetSteps = [
addStepConsumer(node, () => stepsOn(state)),
watchSteps((run) => syncSteps(state, run)),
];
const updateLoopButton = () => {
const enabled = getLoopEnabled(node);
video.loop = enabled;
@@ -183,14 +253,17 @@ app.registerExtension({
});
chainCallback(nodeType.prototype, "onConfigure", function () {
queueMicrotask(() => {
buildPreview(this);
const state = buildPreview(this);
suppressCoreVideoPreview(this);
if (state) syncSteps(state);
});
});
chainCallback(nodeType.prototype, "onExecuted", function (message) {
if (isForeignRun()) return;
const state = buildPreview(this);
const meta = findVideoMetadata(message);
// Saved: the file replaces the steps.
if (state) { state.stepsSaved = stepRun().promptId; syncSteps(state); }
if (state && meta) loadMetadata(state, meta);
else if (state) setEmpty(state, "Execution finished without video metadata");
app.canvas?.setDirty?.(true, true);
@@ -199,6 +272,7 @@ app.registerExtension({
const state = this.__ausbossSaveVideo;
state?.video?.pause();
state?.abort?.abort();
for (const forget of state?.forgetSteps ?? []) forget();
this.__ausbossSaveVideo = null;
});
},
+124 -13
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,6 +19,8 @@ import { copyToClipboard } from "../shared/clipboard.mjs";
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
import { holdNodeMinWidth, pinVuePanelHeight } from "../shared/panel_layout.mjs";
import { formatSeed, pushSeed, seedFromExecuted } from "../shared/seed_history.mjs";
import { SEED_LOOK_MENU, SEED_LOOK_SCOPE, controlChip, nextControl, seedLook, setSeedLook } from "../shared/seed_look.mjs";
import { gearIconSvg, loadSettings, openSettingsMenu } from "../shared/settings_menu.mjs";
const NODE_CLASS = "AUSBOSS_NODES_Seed";
const CSS_ID = "ausboss-seed-css";
@@ -28,8 +30,19 @@ const PROPERTY = "ausboss_seed_history"; // [seed, newest first]
// in a double; a fresh seed rolled here obeys the same ceiling.
const ROLL_MAX = 0x4000000000000;
const CARD_HEIGHT = 158;
const PANEL_DECLARED_HEIGHT = CARD_HEIGHT + 20; // + the frontend's wrapper insets
// The compact look is one row: 8px card padding, a 32px row, 8px padding.
const COMPACT_CARD_HEIGHT = 50;
const PANEL_INSETS = 20; // the frontend's wrapper insets
const NODE_WIDTH = 300;
const COMPACT_NODE_WIDTH = 290;
function panelHeight(node) {
return (seedLook(node) === "compact" ? COMPACT_CARD_HEIGHT : CARD_HEIGHT) + PANEL_INSETS;
}
function nodeWidthFloor(node) {
return seedLook(node) === "compact" ? COMPACT_NODE_WIDTH : NODE_WIDTH;
}
let openMenu = null;
@@ -59,6 +72,21 @@ function ensureCss() {
.ausboss-seed-btn:disabled{opacity:.4;cursor:default;pointer-events:none;}
.ausboss-seed-hint{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#6f8886;font-size:10.5px;text-align:center;}
.ausboss-seed-hint b{color:#9fe3dc;font-weight:600;font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;}
.ausboss-seed-top{display:flex;align-items:center;gap:6px;min-width:0;}
.ausboss-seed-top .ausboss-seed-field{flex:1 1 auto;min-width:0;}
.ausboss-seed-gear{display:grid;place-items:center;}
.ausboss-seed-chip,.ausboss-seed-dice{display:none;flex:none;height:32px;border:1px solid #2a3437;border-radius:7px;background:#0f1516;color:#d8ecea;font:600 11.5px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;pointer-events:auto;}
.ausboss-seed-chip{min-width:56px;padding:0 7px;}
.ausboss-seed-chip.random{border-color:rgba(0,180,170,.55);color:${BRAND};}
.ausboss-seed-dice{width:32px;font-size:15px;}
.ausboss-seed-chip:hover,.ausboss-seed-dice:hover{border-color:${BRAND};color:#fff;}
.ausboss-seed-card.compact{gap:0;}
.ausboss-seed-card.compact .ausboss-seed-seg,.ausboss-seed-card.compact .ausboss-seed-row,.ausboss-seed-card.compact .ausboss-seed-hint{display:none;}
.ausboss-seed-card.compact .ausboss-seed-chip,.ausboss-seed-card.compact .ausboss-seed-dice{display:block;}
.ausboss-seed-card.compact .ausboss-seed-field{height:32px;padding:0 3px 0 9px;}
.ausboss-seed-card.compact .ausboss-seed-field input{font-size:14px;}
.ausboss-seed-card.compact .ausboss-seed-tag,.ausboss-seed-card.compact .ausboss-seed-icon:not(.ausboss-seed-gear){display:none;}
.ausboss-seed-card.compact .ausboss-seed-field input{font-size:13.5px;letter-spacing:0;}
.ausboss-seed-menu{position:fixed;z-index:10000;min-width:230px;padding:4px;border:1px solid #3a4047;border-radius:7px;background:#1c1f23;box-shadow:0 8px 28px rgba(0,0,0,.5);font:12px/1.3 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#d7dde2;}
.ausboss-seed-menu-head{padding:5px 8px 6px;border-bottom:1px solid #2c3238;color:#78908e;font:10px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.08em;text-transform:uppercase;}
.ausboss-seed-menu-item{display:block;width:100%;padding:6px 8px;border:none;border-radius:5px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;}
@@ -124,6 +152,7 @@ function render(state) {
const linked = seedLinked(node);
const value = seedWidget(node)?.value;
if (document.activeElement !== state.input) state.input.value = formatSeed(Number(value));
fitCompactNumber(state);
state.input.disabled = linked;
state.field.classList.toggle("linked", linked);
state.field.title = linked ? "The seed comes from its input link" : "Click to type a seed";
@@ -136,6 +165,11 @@ function render(state) {
state.segStep.title = stepping
? "Click again to step the other way"
: "Increment the seed after each queue (click again for decrement)";
const chip = controlChip(control);
state.chip.textContent = chip.text;
state.chip.title = chip.title;
state.chip.classList.toggle("random", control === "randomize");
state.card.classList.toggle("compact", seedLook(node) === "compact");
state.useLast.disabled = !seeds.length;
state.more.disabled = seeds.length < 2;
state.hint.textContent = "";
@@ -145,6 +179,8 @@ function render(state) {
b.textContent = formatSeed(last);
state.hint.append(b);
if (control === "randomize") state.hint.append(" · rolls a new seed each queue");
// The compact look has no hint line: the last run shows on the number.
if (seedLook(node) === "compact") state.field.title = `${state.field.title}. Last run: ${formatSeed(last)}`;
} else {
state.hint.textContent = control === "randomize"
? "rolls a new seed each queue"
@@ -152,6 +188,16 @@ function render(state) {
}
}
// The compact row is narrow, so a long seed gets smaller digits instead of
// being cut off: monospace digits are about 0.6 of the font size wide.
function fitCompactNumber(state) {
const { input } = state;
if (seedLook(state.node) !== "compact") { input.style.fontSize = ""; return; }
const digits = Math.max(1, String(input.value || "").length);
const room = input.clientWidth || 100;
input.style.fontSize = `${Math.max(8, Math.min(13.5, room / (0.64 * digits))).toFixed(1)}px`;
}
function showMenu(state) {
closeMenu();
const seeds = history(state.node);
@@ -241,7 +287,9 @@ function buildPanel(node) {
input.inputMode = "numeric";
input.spellcheck = false;
const copy = button("ausboss-seed-icon", "⧉", "Copy the seed");
field.append(tag, input, copy);
const gear = button("ausboss-seed-icon ausboss-seed-gear", "", "Seed settings: full or compact look");
gear.innerHTML = gearIconSvg(12);
field.append(tag, input, copy, gear);
field.addEventListener("pointerdown", (event) => event.stopPropagation());
const seg = el("div", "ausboss-seed-seg");
@@ -257,11 +305,16 @@ function buildPanel(node) {
row.append(newSeed, useLast, more);
const hint = el("div", "ausboss-seed-hint");
card.append(field, seg, row, hint);
// Compact look: the mode chip and a dice sit on the number's row.
const chip = button("ausboss-seed-chip", "Fixed");
const dice = button("ausboss-seed-dice", "🎲", "Roll a fresh seed and pin it (Fixed)");
const top = el("div", "ausboss-seed-top");
top.append(field, chip, dice);
card.append(top, seg, row, hint);
root.append(card);
const state = (node.__ausbossSeed = {
node, root, field, tag, input, copy, segRandom, segFixed, segStep, newSeed, useLast, more, hint,
node, root, card, field, tag, input, copy, gear, chip, dice, segRandom, segFixed, segStep, newSeed, useLast, more, hint,
});
const commitTyped = () => {
@@ -304,6 +357,31 @@ function buildPanel(node) {
notifyAusbossChange();
});
newSeed.addEventListener("click", () => { setSeed(node, rollSeed()); render(state); });
dice.addEventListener("click", () => { setSeed(node, rollSeed()); render(state); });
// The chip walks the same modes as the full card's buttons, and starts a
// new run the same way: into Random rolls now, Fixed into +1 steps now.
chip.addEventListener("click", () => {
const current = controlWidget(node)?.value ?? "fixed";
const next = nextControl(current);
setControl(node, next);
if (!seedLinked(node)) {
const value = Number(seed.value) || 0;
if (next === "randomize") setSeed(node, rollSeed(), { pin: false });
else if (current === "fixed" && next === "increment") setSeed(node, value + 1, { pin: false });
}
render(state);
notifyAusbossChange();
});
gear.addEventListener("click", () => {
openSettingsMenu({
scope: SEED_LOOK_SCOPE,
schema: SEED_LOOK_MENU,
anchor: gear.getBoundingClientRect(),
title: "Seed settings",
initial: { look: seedLook(node) },
onChange: (values) => applyLook(state, values.look),
});
});
useLast.addEventListener("click", () => {
const seeds = history(node);
if (seeds.length) { setSeed(node, seeds[0]); render(state); }
@@ -318,7 +396,7 @@ function buildPanel(node) {
const widget = node.addDOMWidget(WIDGET_NAME, "ausboss_seed", root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => PANEL_DECLARED_HEIGHT,
getMinHeight: () => panelHeight(node),
});
keepDomWidgetWidthAuto(widget);
// Not saved with the workflow either: options.serialize only keeps it out
@@ -327,18 +405,20 @@ function buildPanel(node) {
// A constant-height card: pinned on purpose (see
// tests/panel_guards.test.mjs, fixedByDesign).
widget.computeSize = (width) => [
Math.max(NODE_WIDTH, Number(width || node.size?.[0] || NODE_WIDTH)),
PANEL_DECLARED_HEIGHT,
Math.max(nodeWidthFloor(node), Number(width || node.size?.[0] || nodeWidthFloor(node))),
panelHeight(node),
];
// Pinned in Nodes 2.0 as well, where a node can be made taller than its card.
pinVuePanelHeight(widget);
widget.options.minNodeSize = [NODE_WIDTH, 120];
widget.options.minNodeSize = [nodeWidthFloor(node), 60];
// The frontend skips a pinned card's width, so its floor is held here.
holdNodeMinWidth(widget, NODE_WIDTH);
holdNodeMinWidth(widget, nodeWidthFloor(node));
state.widget = widget;
new ResizeObserver(() => fitCompactNumber(state)).observe(input);
render(state);
node.setSize?.([
Math.max(node.size?.[0] ?? 0, NODE_WIDTH),
Math.max(node.size?.[0] ?? 0, nodeWidthFloor(node)),
node.computeSize?.()[1] ?? node.size?.[1],
]);
// The card is a fixed-height row: width may grow, but a corner drag
@@ -356,18 +436,49 @@ function buildPanel(node) {
return state;
}
// Switch looks: the card, the node's floor and its height follow. Going
// compact also makes the node as narrow as the compact row allows, and
// going full widens it back to the full card's floor.
function applyLook(state, look) {
const { node } = state;
const before = seedLook(node);
const after = setSeedLook(node, look);
if (before === after) return;
render(state);
if (state.widget) {
state.widget.options.minNodeSize = [nodeWidthFloor(node), 60];
holdNodeMinWidth(state.widget, nodeWidthFloor(node));
}
const width = after === "compact" ? COMPACT_NODE_WIDTH : Math.max(node.size?.[0] ?? 0, NODE_WIDTH);
node.setSize?.([width, node.computeSize?.()[1] ?? node.size?.[1]]);
node.setDirtyCanvas?.(true, true);
notifyAusbossChange();
}
app.registerExtension({
name: "AusBoss.Seed",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_CLASS) return;
hideInputsInDef(nodeData, ["seed"]);
chainCallback(nodeType.prototype, "onNodeCreated", function () {
buildPanel(this);
// A new node starts in the look last picked from any Seed's gear. A
// loaded one keeps its own: onConfigure restores its properties after.
if (loadSettings(SEED_LOOK_SCOPE, SEED_LOOK_MENU).look === "compact") setSeedLook(this, "compact");
const state = buildPanel(this);
if (state && seedLook(this) === "compact") {
this.setSize?.([COMPACT_NODE_WIDTH, this.computeSize?.()[1] ?? this.size?.[1]]);
}
});
chainCallback(nodeType.prototype, "onConfigure", function () {
queueMicrotask(() => {
const state = buildPanel(this);
if (state) render(state);
if (!state) return;
render(state);
// A saved compact Seed: lower the floor to the compact row's.
if (state.widget) {
state.widget.options.minNodeSize = [nodeWidthFloor(this), 60];
holdNodeMinWidth(state.widget, nodeWidthFloor(this));
}
});
});
chainCallback(nodeType.prototype, "onConnectionsChange", function () {
+39
View File
@@ -45,6 +45,24 @@ const LINK = `#(${ID})`;
const WORDS_LINK_RE = /\[([^\[\]\n]+)\]\(([^\s()]+(?:\([^\s()]*\)[^\s()]*)*)\)/g;
const URL_MAX = 2000;
// Only a whole, ordinary web link gets the simple label/address form. Keep
// multi-paragraph or mixed-content notes in the text editor, without loss.
export function tileLinkFields(text) {
const value = String(text ?? "");
const match = /^\[([^\[\]\n]+)\]\((.+)\)$/.exec(value);
if (!match || !linkTarget(match[2])?.url) return null;
const blocks = parseBlocks(value);
if (blocks.length !== 1 || blocks[0].length !== 1
|| blocks[0][0].some(piece => piece.url !== match[2])) return null;
return { label: match[1], url: match[2] };
}
export function tileLinkText(label, url) {
const text = `[${label}](${url.trim()})`;
if (!tileLinkFields(text)) throw new Error("Enter a label without square brackets and a complete https:// or http:// address.");
return text;
}
// Where a link goes: { url }, { link } (a node's id) or { group } (a group's
// id), or null when the text is none of them. `raw` is what stands in the
// round brackets, or after an arrow's #.
@@ -250,3 +268,24 @@ export function pastedLink(selected, pasted) {
if (linkTarget(words.trim())?.url) return null; // an address over an address is just a paste
return `[${words}](${target.url})`;
}
// A pasted note's text, its links pointed at the pasted copies of the nodes
// and groups they name. `nodes` and `groups` map an old id (as text) to its
// copy's id (js/shared/paste_ids.mjs). A link to anything the paste did not
// copy is kept as typed. Returns null when no link changes.
export function repointLinks(text, nodes, groups) {
let changed = false;
const follow = (raw) => {
const target = linkTarget(raw);
const isGroup = target?.group !== undefined;
const old = isGroup ? target.group : target?.link;
const copy = old === undefined ? undefined : (isGroup ? groups : nodes)?.get(String(old));
if (copy === undefined || copy === null) return raw;
changed = true;
return `#${isGroup ? "g" : ""}${copy}`;
};
const next = String(text ?? "")
.replace(WORDS_LINK_RE, (_all, words, address) => `[${words}](${follow(address)})`)
.replace(ARROW_RE, (all, _arrow, id) => (id === undefined ? all : `${all.slice(0, -id.length - 1)}${follow(`#${id}`)}`));
return changed ? next : null;
}
+5 -3
View File
@@ -72,12 +72,14 @@ export function panelRoot(target) {
return root;
}
// A drag that carries files, from the file manager or the desktop. A row
// being dragged inside a panel carries none and stays the panel's own.
// A drag that carries files, from the file manager or the desktop, or a card
// from ComfyUI's Assets panel, which names a picture it made ("... [output]")
// instead of carrying the file. A row being dragged inside a panel carries
// neither and stays the panel's own.
export function dragCarriesFiles(event) {
const types = event?.dataTransfer?.types;
if (!types) return false;
return Array.from(types).includes("Files");
return Array.from(types).some((type) => type === "Files" || type === "application/x-comfy-asset-info");
}
// Does a file dropped here have to be handed to its node by the pack? The
+75
View File
@@ -26,6 +26,81 @@ export function parseCivitaiBody(text) {
return payload;
}
// What to tell the person when a Civitai call fails. A browser that cannot
// reach the site at all only says "Failed to fetch", which explains nothing.
export function civitaiFailureText(error) {
if (error instanceof TypeError) {
return "Could not reach civitai.com. A firewall, a VPN or starting ComfyUI "
+ "with --disable-api-nodes can block it.";
}
return String(error?.message || error || "The Civitai lookup failed.");
}
export function civitaiButtonLabel(hasCivitai) {
return hasCivitai ? "Refresh Civitai info" : "Fetch Civitai info";
}
// ---------- the LoRA's preview picture ----------
// After the lookup, the browser also downloads the LoRA's first picture from
// Civitai's picture server, and the pack's server saves it beside the LoRA.
export const PICTURE_HOST = "image.civitai.com";
export const PICTURE_WIDTH = 450;
export const MAX_PICTURE_BYTES = 8 * 1024 * 1024;
// Civitai rates each picture. The number is a set of flags: 1 = PG,
// 2 = PG-13, 4 = R, 8 = X, 16 = XXX. Older answers give a word instead.
const MILD_LEVEL = 2;
const MILD_WORDS = new Set(["none", "soft"]);
export function isMildPicture(image) {
const level = Number(image?.nsfwLevel);
if (Number.isFinite(level) && level > 0) return level <= MILD_LEVEL;
if (typeof image?.nsfw === "string") return MILD_WORDS.has(image.nsfw.toLowerCase());
if (image?.nsfw === false) return true;
return false;
}
// The first still picture in Civitai's answer that may be saved: PG and
// PG-13 only, unless any rating is allowed.
export function pickCivitaiPicture(info, anyRating = false) {
const images = Array.isArray(info?.images) ? info.images : [];
return images.find((image) =>
image && typeof image.url === "string"
&& (image.type ?? "image") === "image"
&& (anyRating || isMildPicture(image))
&& smallPictureUrl(image.url) !== null) ?? null;
}
// The address of a 450-pixel copy of the picture, or null for any address
// that is not Civitai's picture server over https.
export function smallPictureUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return null;
}
if (parsed.protocol !== "https:" || parsed.hostname !== PICTURE_HOST) return null;
const parts = parsed.pathname.split("/");
const sizing = parts.findIndex((part, index) => index > 1 && index < parts.length - 1 && part.includes("="));
if (sizing >= 0) parts[sizing] = `width=${PICTURE_WIDTH}`;
else if (parts.length >= 3) parts.splice(parts.length - 1, 0, `width=${PICTURE_WIDTH}`);
else return null;
parsed.pathname = parts.join("/");
parsed.search = "";
parsed.hash = "";
return parsed.href;
}
// Where the picture really came from, after Civitai's redirect to its file
// store: Civitai's own servers, over https.
export function isCivitaiAddress(url) {
try {
const parsed = new URL(url);
return parsed.protocol === "https:"
&& (parsed.hostname === "civitai.com" || parsed.hostname.endsWith(".civitai.com"));
} catch {
return false;
}
}
+12
View File
@@ -120,6 +120,18 @@ export function fitRect(boxWidth, boxHeight, width, height) {
return { left: (boxWidth - fitWidth) / 2, top: (boxHeight - fitHeight) / 2, width: fitWidth, height: fitHeight, scale };
}
// A see-through picture shows over a checkerboard, as in an editor: its
// squares' side in CSS pixels and their two colours.
export const CHECKER = { side: 8, light: "#4d4d4d", dark: "#383838" };
// The padding that holds a contained picture's own background to the
// picture: the bars beside it, as CSS. Empty when there is nothing to fit.
export function backdropPadding(boxWidth, boxHeight, width, height) {
const fit = fitRect(boxWidth, boxHeight, width, height);
if (!(fit.scale > 0)) return "";
return `${fit.top}px ${fit.left}px`;
}
// A place in the box as a place on the picture, in picture pixels.
export function toPicture(fit, x, y) {
if (!(fit?.scale > 0)) return null;
+57 -7
View File
@@ -5,15 +5,16 @@
// a mask canvas holds what has been rubbed away, and the view shows the
// picture underneath with the top picture, less that mask, over it. Save
// sends the strokes to the server, which builds the mix at full size.
import { api } from "/scripts/api.js";
import { compactAction } from "./panel_controls.mjs";
import { api } from "../../../scripts/api.js";
import { BRAND, notifyAusbossChange, showToast } from "./index.mjs";
import { brushAdjusts, brushFlips, brushKey, brushWords, toolKey } from "./brush.mjs";
import { keepKeyInField } from "./canvas_passthrough.mjs";
import { makeScrubInput } from "./scrub_input.mjs";
import { makeSlideSwitch } from "./slide_switch.mjs";
import { extendStroke } from "./touch_math.mjs";
import { makeToolButton } from "./video_ui.mjs";
import {
CHECKER,
ERASE_OUTSIDE,
ERASE_SIZE,
ERASE_SOFT_DEFAULT,
@@ -81,6 +82,7 @@ const CAN_FILTER = typeof CanvasRenderingContext2D !== "undefined" && "filter" i
export const ERASE_CSS = `
.ausboss-compare-erasebar{display:none;box-sizing:border-box;flex-wrap:wrap;align-items:center;gap:4px 6px;flex:none;min-height:${ERASE_BAR_HEIGHT}px;}
.ausboss-compare-root.is-erasing .ausboss-compare-erasebar{display:flex;}
.ausboss-compare-brush-swatch{box-sizing:border-box;flex:none;width:24px;height:24px;border-radius:4px;background:#0b1113;border:1px solid rgba(0,180,170,.3);}
.ausboss-compare-erasebar .ausboss-scrub{flex:none;}
.ausboss-compare-field{display:flex;align-items:center;gap:4px;flex:none;}
.ausboss-compare-label{flex:none;color:#9fb6b4;font-size:10px;line-height:22px;white-space:nowrap;user-select:none;}
@@ -227,9 +229,51 @@ function sizeView(er) {
}
}
// See-through parts show over a checkerboard, as on the Slide view, its
// squares as big on screen.
function drawCheckerboard(er, ctx, fit) {
const side = Math.max(2, Math.round(CHECKER.side * er.canvas.width / Math.max(1, er.canvas.clientWidth)));
if (er.checker?.side !== side) {
const tile = document.createElement("canvas");
const tileCtx = sized(tile, side * 2, side * 2);
tileCtx.fillStyle = CHECKER.dark;
tileCtx.fillRect(0, 0, side * 2, side * 2);
tileCtx.fillStyle = CHECKER.light;
tileCtx.fillRect(0, 0, side, side);
tileCtx.fillRect(side, side, side, side);
er.checker = { side, pattern: ctx.createPattern(tile, "repeat") };
}
er.checker.pattern.setTransform?.(new DOMMatrix([1, 0, 0, 1, fit.left, fit.top]));
ctx.fillStyle = er.checker.pattern;
ctx.fillRect(fit.left, fit.top, fit.width, fit.height);
}
// Use the cursor's actual dab. A compressed size scale keeps both a
// two-pixel brush and a two-thousand-pixel brush legible in the toolbar.
function drawBrushSwatch(er) {
const size = brushSize(er);
const soft = brushSoft(er);
const back = er.back !== Boolean(er.flipKey);
const key = `${size}|${soft}|${back}`;
if (er.swatchKey === key) return;
er.swatchKey = key;
const swatch = er.swatch;
const side = swatch.width;
const fraction = Math.log(size / ERASE_SIZE[0]) / Math.log(ERASE_SIZE[1] / ERASE_SIZE[0]);
const diameter = side * (0.22 + 0.66 * fraction);
const dab = dabOf(er, size, soft, diameter / size, back ? BACK_TINT : RUB_TINT);
const ctx = swatch.getContext("2d");
ctx.clearRect(0, 0, side, side);
ctx.drawImage(dab.canvas, side / 2 - dab.reach, side / 2 - dab.reach);
swatch.title = `${back ? "Restore" : "Erase"} brush: ${size} px, ${soft}% soft`;
swatch.setAttribute("aria-label", swatch.title);
}
function draw(er) {
er.frame = 0;
if (!er.active || !er.refs) return;
if (!er.active) return;
drawBrushSwatch(er);
if (!er.refs) return;
sizeView(er);
const { canvas } = er;
const ctx = canvas.getContext("2d");
@@ -256,6 +300,7 @@ function draw(er) {
cut.drawImage(top, 0, 0, width, height);
cut.globalCompositeOperation = "destination-out";
cut.drawImage(mask, 0, 0, width, height);
if (er.refs.a?.alpha || er.refs.b?.alpha) drawCheckerboard(er, ctx, fit);
ctx.drawImage(under, fit.left, fit.top, width, height);
ctx.drawImage(er.cut, fit.left, fit.top);
@@ -356,7 +401,7 @@ function syncTools(er) {
er.layersSwitch.set(layers ? "on" : "off");
const aligned = Boolean(er.refs?.aligned);
er.alignButton.disabled = !ready || er.aligning;
er.alignButton.textContent = er.aligning ? "ALIGNING" : "ALIGN";
er.alignButton.textContent = er.aligning ? "Aligning" : "Align";
er.alignButton.classList.toggle("active", aligned);
er.alignButton.title = !ready
? "Run the workflow first: Align needs the two pictures."
@@ -806,6 +851,11 @@ export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved,
return wrap;
};
const sizeField = scrubField("Size", er.sizeBox, SIZE_HELP);
er.swatch = document.createElement("canvas");
er.swatch.className = "ausboss-compare-brush-swatch";
er.swatch.width = er.swatch.height = 64;
er.swatch.setAttribute("role", "img");
sizeField.prepend(er.swatch);
const softField = scrubField("Soft", er.softBox, SOFT_HELP);
const TOP_HELP = "Which picture you start from. The brush shows the other one through it. Swapping keeps your paint.";
er.topSwitch = makeSlideSwitch({
@@ -825,7 +875,7 @@ export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved,
// Align, Undo and Clear stay together at the right end, also on a second line.
const steps = document.createElement("div");
steps.className = "ausboss-compare-steps";
er.undoButton = click(makeToolButton("UNDO", "Take back the last stroke, or a Clear."), () => {
er.undoButton = click(compactAction("Undo", "Take back the last stroke, or a Clear."), () => {
if (!er.history.length && !er.strokes.length) return;
// Paint that came with the workflow has no steps of its own to go back
// through: its last stroke is taken off.
@@ -835,13 +885,13 @@ export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved,
syncTools(er);
schedule(er);
});
er.clearButton = click(makeToolButton("CLEAR", "Take all the paint off. Undo brings it back."), () => {
er.clearButton = click(compactAction("Clear", "Take all the paint off. Undo brings it back."), () => {
if (!er.strokes.length) return;
change(er, []);
rebuildMask(er);
schedule(er);
});
er.alignButton = click(makeToolButton("ALIGN", ""), () => align(er));
er.alignButton = click(compactAction("Align", ""), () => align(er));
steps.append(er.alignButton, er.undoButton, er.clearButton);
er.bar.append(er.kindSwitch.root, sizeField, softField, topField, steps);
+142
View File
@@ -0,0 +1,142 @@
// The row of chips on H3 Prompt Writer: each picture plugged in, with its @
// name, a small copy of it and its job, then the video and its job. A slot with nothing plugged in is a faint
// dashed chip, so it reads as optional. The widgets underneath stay the
// source of truth: a chip only changes their value.
import { app } from "../../../scripts/app.js";
import { BRAND, chainCallback } from "./index.mjs";
import { liftSocket } from "./widget_card.mjs";
import { commitWidgetValue, comboValues } from "./widget_card_math.mjs";
const CSS_ID = "ausboss-cast-chips-css";
const WATCH = 1000; // Workflow Switches can turn a loader off without touching this node.
function ensureCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-chips{display:flex;flex-wrap:wrap;align-content:flex-start;gap:5px;height:100%;min-width:0;font:600 10.5px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
.ausboss-chip{position:relative;display:flex;align-items:center;gap:4px;height:24px;max-width:100%;padding:0 8px;box-sizing:border-box;border:1px solid #2a3437;border-radius:12px;background:#0b0f10;color:#d8ecea;white-space:nowrap}
.ausboss-chip:hover{border-color:${BRAND}}
.ausboss-chip img{flex:none;width:18px;height:18px;margin-left:-5px;border-radius:9px;object-fit:cover}
.ausboss-chip b{flex:none;color:${BRAND};font:700 10px ui-monospace,SFMono-Regular,Menlo,monospace}
.ausboss-chip em{flex:none;color:#6f8886;font-style:normal;font-weight:500}
.ausboss-chip select{field-sizing:content;min-width:0;max-width:120px;height:22px;padding:0 11px 0 0;border:none;background:transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' fill='none' stroke='%236f8886' stroke-width='1.4'/%3E%3C/svg%3E") no-repeat right 1px center;color:#d8ecea;font:inherit;appearance:none;-webkit-appearance:none;cursor:pointer;text-overflow:ellipsis}
.ausboss-chip select:focus-visible{outline:2px solid ${BRAND};outline-offset:2px}
.ausboss-chip select option{background:#0f1516;color:#d8ecea}
.ausboss-chip.off{border-style:dashed;background:transparent;color:#56706d}
.ausboss-chip.off:hover{border-color:#2a3437}
.ausboss-chip.wired{color:#8ba3a1}
`;
document.head.append(style);
}
function linkSource(node, name) {
const slot = node.inputs?.find((input) => input?.widget?.name === name || input?.name === name);
if (slot?.link === null || slot?.link === undefined) return null;
const graph = node.graph;
const link = graph?.links?.[slot.link] ?? graph?._links?.get?.(slot.link);
const source = graph?.getNodeById?.(link?.origin_id);
return (source?.mode ?? 0) === 0 ? source ?? {} : null;
}
const widgetOf = (node, name) => node.widgets?.find((widget) => widget.name === name) ?? null;
function chip(className = "") {
const element = document.createElement("label");
element.className = `ausboss-chip ${className}`.trim();
return element;
}
function add(parent, tag, text, className) {
const child = document.createElement(tag);
if (className) child.className = className;
if (text !== undefined) child.textContent = text;
parent.append(child);
return child;
}
function choice(node, name, into) {
const widget = widgetOf(node, name);
const select = add(into, "select");
for (const value of comboValues(widget)) add(select, "option", value).value = value;
select.value = widget?.value ?? "";
select.title = widget?.options?.tooltip ?? "";
select.addEventListener("change", () => {
if (widget) commitWidgetValue(node, widget, select.value, app.canvas);
node.setDirtyCanvas?.(true, true);
});
return (values) => { if (document.activeElement !== select) select.value = values[name] ?? ""; };
}
// The jobs have no socket of their own, but a page saved by an earlier
// release may have one wired: that link stays, in the slot column.
const JOBS = ["job_1", "job_2", "job_3", "job_4", "video_job"];
const keepOldJobLinks = (node) => {
for (const name of JOBS) {
const slot = node.inputs?.find((input) => input?.widget?.name === name);
if (slot && slot.link !== null && slot.link !== undefined) liftSocket(node, name);
}
};
// A card row: { panel: castChips, height }.
export function castChips(node) {
ensureCss();
const element = document.createElement("div");
element.className = "ausboss-chips";
queueMicrotask(() => keepOldJobLinks(node));
let shape = "";
let syncs = [];
let latest = {};
const build = (values) => {
element.textContent = "";
syncs = [];
for (const number of [1, 2, 3, 4]) {
const source = linkSource(node, `picture_${number}`);
if (!source) {
const empty = chip("off");
empty.textContent = `+ @${number}`;
empty.title = `Plug a picture into picture_${number} to name it @${number} in your lines.`;
element.append(empty);
continue;
}
const item = chip();
const url = source.imgs?.[0]?.src;
if (url) add(item, "img").src = url;
add(item, "b", `@${number}`);
if (linkSource(node, `job_${number}`)) add(item, "span", "job from its wire");
else syncs.push(choice(node, `job_${number}`, item));
element.append(item);
}
if (linkSource(node, "video")) {
const item = chip();
add(item, "b", "video");
if (linkSource(node, "video_job")) add(item, "span", "job from its wire");
else syncs.push(choice(node, "video_job", item));
element.append(item);
} else {
const empty = chip("off");
empty.textContent = "+ video";
empty.title = "Plug a video into video to copy its moves, swap the person, keep it going or give it a new voice.";
element.append(empty);
}
for (const run of syncs) run(values);
};
const shapeOf = () => JSON.stringify(["picture_1", "picture_2", "picture_3", "picture_4", "video", ...JOBS].map((name) => {
const source = linkSource(node, name);
return source ? [source.id ?? 1, source.imgs?.[0]?.src ?? ""] : 0;
}));
const sync = (values) => {
latest = values;
const now = shapeOf();
if (now !== shape) { shape = now; build(values); return; }
for (const run of syncs) run(values);
};
const watch = setInterval(() => sync(latest), WATCH);
chainCallback(node, "onRemoved", () => clearInterval(watch));
chainCallback(node, "onConfigure", () => queueMicrotask(() => keepOldJobLinks(node)));
return { element, sync };
}
+16
View File
@@ -0,0 +1,16 @@
// The same live-input rule used by the writer timeline: muted/bypassed
// sources do not reach this node. This inspects the wire, never media bytes.
export function linkSource(node, name) {
const slot = node.inputs?.find((input) => input?.widget?.name === name || input?.name === name);
if (slot?.link === null || slot?.link === undefined) return null;
const graph = node.graph;
const link = graph?.links?.[slot.link] ?? graph?._links?.get?.(slot.link);
const source = graph?.getNodeById?.(link?.origin_id);
return (source?.mode ?? 0) === 0 ? source ?? null : null;
}
// Keep in step with Python KEEPS_ITS_LENGTH. Other jobs use seconds normally.
export function clipSetsDuration(node, values) {
return ["swap the person", "new voice", "change something"].includes(values.video_job)
&& linkSource(node, "video") !== null;
}
+8 -4
View File
@@ -87,14 +87,18 @@ export function nameAt(picked, names, position, atTheEnd) {
return atTheEnd === "start over" ? list[(place - 1) % list.length] : null;
}
// Picture after a run is queued. "next" may reach one past the last picture,
// where the node stops the run; it never runs further than that. "random"
// jumps to any picked picture (`roll` is 0..1, for tests).
export function nextPosition(position, count, afterRun, roll = Math.random()) {
// Picture after a run is queued. With "stop the run", "next" may reach one
// past the last picture, where the node stops the run; it never runs further
// than that. With "start over", "next" wraps from the last picture back to
// the first, so Picture always names a real picture. A Picture already past
// the end (saved by an older version) steps on from the picture it loaded.
// "random" jumps to any picked picture (`roll` is 0..1, for tests).
export function nextPosition(position, count, afterRun, atTheEnd = "stop the run", roll = Math.random()) {
const place = Math.max(1, Math.round(Number(position) || 1));
if (count < 1) return place;
if (afterRun === "random") return Math.min(count, Math.floor(roll * count) + 1);
if (afterRun !== "next") return place;
if (atTheEnd === "start over") return (place % count) + 1;
return Math.min(place + 1, count + 1);
}
+2 -2
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.17.0";
export const AUSBOSS_JS_VERSION = "2.21.1";
export const BRAND = "#00b4aa"; // AusBoss teal — keep node accents consistent.
export const BRAND_DARK = "#007f78";
+13 -6
View File
@@ -7,6 +7,7 @@
// 2. an uploadable image/video file combo widget -> deterministic /view query
// 3. nothing -> the caller shows a quiet placeholder
import { parseImageReference } from "./pad_canvas.mjs";
import { splitMediaName } from "./video_preview.mjs";
// Core loader nodes keep the uploaded file name in a combo widget with one of
@@ -37,11 +38,13 @@ export function sourceFileWidget(sourceNode) {
return sourceNode?.widgets?.find((widget) => FILE_WIDGET_NAMES.has(widget?.name)) ?? null;
}
// Deterministic /view query (no cache buster) for an input-folder file name,
// tolerating Windows separators and " [input]" annotations.
// Deterministic /view query (no cache buster) for a file widget's name,
// tolerating Windows separators. A name without a tag is in the input
// folder; "photo.png [output]" or "[temp]" is read from that folder.
export function viewQueryForFile(name) {
const { filename, subfolder } = splitMediaName(name);
return new URLSearchParams({ filename, subfolder, type: "input" }).toString();
const type = parseImageReference(name)?.type ?? "input";
return new URLSearchParams({ filename, subfolder, type }).toString();
}
// Describe how to preview a source node:
@@ -117,9 +120,13 @@ export function describeNodePreview(node, inputName, storedUrl = null) {
?? describeSourcePreview(upstreamNode(node, inputName));
}
// Quiet ASCII placeholder copy; never an error.
export function placeholderText(connected, noun = "an image") {
return connected ? "Run the workflow to see it here" : `Connect ${noun} to see it here`;
// Quiet ASCII placeholder copy; never an error. `running`: a run is on its
// way to this node, which shows its steps ("steps") or only the result.
export function placeholderText(connected, noun = "an image", running = false) {
if (!connected) return `Connect ${noun} to see it here`;
if (running === "steps") return "Running. The steps show here once the picture starts";
if (running) return "Running. The picture shows here when it is saved";
return "Run the workflow to see it here";
}
// The node ran while its preview was off, so its last picture is older
+110
View File
@@ -0,0 +1,110 @@
// Line up, same size and even spacing for a selection of nodes and groups.
// No LiteGraph here; js/line_up/index.js reads the selection and moves it.
//
// A rect is { x, y, w, h } in graph units: the outline people see, title bar
// included. Each function returns one result per rect, in the order given.
import { unionOf } from "./snap_lines.mjs";
export const LINE_UP = ["left", "middleX", "right", "top", "middleY", "bottom"];
export const SAME_SIZE = ["widest", "narrowest", "tallest", "shortest"];
export const SPACING = ["across", "down"];
// Where each rect goes, as { x, y }, so they all share one edge or middle.
// Edges line up on the outermost edge of the selection, middles on its middle.
export function lineUp(rects, how) {
const bounds = unionOf(rects);
if (!bounds) return [];
return rects.map((rect) => {
switch (how) {
case "left":
return { x: bounds.x, y: rect.y };
case "middleX":
return { x: bounds.x + bounds.w / 2 - rect.w / 2, y: rect.y };
case "right":
return { x: bounds.x + bounds.w - rect.w, y: rect.y };
case "top":
return { x: rect.x, y: bounds.y };
case "middleY":
return { x: rect.x, y: bounds.y + bounds.h / 2 - rect.h / 2 };
case "bottom":
return { x: rect.x, y: bounds.y + bounds.h - rect.h };
default:
return { x: rect.x, y: rect.y };
}
});
}
// The size each rect takes, as { w, h }, so they share the width or height of
// the widest, narrowest, tallest or shortest one.
export function sameSize(rects, how) {
if (!rects.length) return [];
const widths = rects.map((rect) => rect.w);
const heights = rects.map((rect) => rect.h);
const w = how === "widest" ? Math.max(...widths) : how === "narrowest" ? Math.min(...widths) : null;
const h = how === "tallest" ? Math.max(...heights) : how === "shortest" ? Math.min(...heights) : null;
return rects.map((rect) => ({ w: w ?? rect.w, h: h ?? rect.h }));
}
// Nodes never end up on top of each other: a gap under this, overlaps
// included, becomes this, and the selection grows from its first edge.
export const MIN_GAP = 20;
// Equal gaps between the rects along one axis, as { x, y } per rect. They keep
// their order (by middle) and the selection keeps its outer edges, unless the
// gaps would come out under MIN_GAP. Fewer than three rects stay put.
export function spreadEvenly(rects, axis) {
const [start, size] = axis === "across" ? ["x", "w"] : ["y", "h"];
const out = rects.map((rect) => ({ x: rect.x, y: rect.y }));
if (rects.length < 3) return out;
const order = rects.map((rect, index) => index).sort((a, b) => {
const middle = (rect) => rect[start] + rect[size] / 2;
return middle(rects[a]) - middle(rects[b]) || rects[a][start] - rects[b][start];
});
const first = Math.min(...rects.map((rect) => rect[start]));
const last = Math.max(...rects.map((rect) => rect[start] + rect[size]));
const filled = rects.reduce((sum, rect) => sum + rect[size], 0);
const gap = Math.max(MIN_GAP, (last - first - filled) / (rects.length - 1));
let at = first;
for (const index of order) {
out[index][start] = at;
at += rects[index][size] + gap;
}
return out;
}
// How many rects each kind of tool needs before it can do anything.
export function enoughFor(kind, count) {
return kind === "spacing" ? count >= 3 : count >= 2;
}
// One node of a selection is being resized: every other one takes its width.
// `starts` are where the others were when the button went down, as
// { x, w, least } (least is the narrowest that node can be). A pull on the
// left edge keeps each right edge where it was; any other pull keeps each
// left edge. Returns { x, w } per start, in the order given.
export function matchWidth(width, starts, { fromLeft = false } = {}) {
return starts.map(({ x, w, least = 0 }) => {
const next = Math.max(width, least);
return { x: fromLeft ? x + w - next : x, w: next };
});
}
// Which sizes a selection shares when one of it is resized, read from how it
// is laid out: nodes stacked in a column share a width, nodes side by side in
// a row share a height, and a grid shares both. A loose scatter shares a
// width, the way a column does.
export function sharedSides(rects) {
const span = (a, b, start, size) => a[start] < b[start] + b[size] && b[start] < a[start] + a[size];
let column = false;
let row = false;
for (let i = 0; i < rects.length; i++) {
for (let j = i + 1; j < rects.length; j++) {
const sameX = span(rects[i], rects[j], "x", "w");
const sameY = span(rects[i], rects[j], "y", "h");
if (sameX && !sameY) column = true;
if (sameY && !sameX) row = true;
}
}
return { width: column || !row, height: row };
}
+272 -32
View File
@@ -12,7 +12,6 @@ export const FINE_STEP = 0.01;
// large enough that a shaky click cannot change the value.
export const SCRUB_PIXELS_PER_STEP = 4;
export const SCRUB_DEAD_ZONE = 3;
export const MAX_ROWS = 64;
let rowSerial = 0;
@@ -40,7 +39,7 @@ export function newRow() {
export function normalizeRows(parsed) {
if (!Array.isArray(parsed)) return [];
return parsed.slice(0, MAX_ROWS).map((entry) => {
return tidyGroups(parsed.map((entry) => {
const row = newRow();
if (entry && typeof entry === "object") {
row.id = typeof entry.id === "string" && entry.id ? entry.id : row.id;
@@ -49,9 +48,12 @@ export function normalizeRows(parsed) {
row.strength_clip = roundStrength(entry.strength_clip ?? row.strength);
row.enabled = entry.enabled !== false;
row.triggers = typeof entry.triggers === "string" ? entry.triggers : "";
const group = groupName(entry.group);
if (group) row.group = group;
if (group && entry.group_off === true) row.group_off = true;
}
return row;
});
}));
}
export function parseRows(raw) {
@@ -79,6 +81,232 @@ export function moveRow(rows, index, delta) {
return reorderRows(rows, index, index + delta);
}
// ---------- groups ----------
// A group is a name carried on its rows (`group`); a row without one sits at
// the top level. A group's rows always stand together, so the stack still
// runs top to bottom as shown, and a group lasts while it has a row.
// A group switched off carries `group_off` on every row: none of them runs,
// and each keeps its own on/off for when the group comes back on.
export const MAX_GROUP_NAME = 40;
export function groupName(value) {
return typeof value === "string" ? value.trim().slice(0, MAX_GROUP_NAME) : "";
}
function withoutGroup(row) {
const { group, group_off, ...rest } = row;
return rest;
}
function withoutGroupOff(row) {
const { group_off, ...rest } = row;
return rest;
}
// A row joining another group takes that group's on/off (see tidyGroups).
function withGroup(row, name) {
if (!name) return withoutGroup(row);
if (row.group === name) return row;
return { ...withoutGroupOff(row), group: name };
}
// Whether a row runs: on itself, and not in a group that is switched off.
export function rowRuns(row) {
return row?.enabled !== false && !row?.group_off;
}
// Gathers each group's rows at the place of its first row, keeping every
// other row where it is. A group is off when any of its rows says so, and
// then all of them do: a row that just joined an off group goes off with it.
export function tidyGroups(rows) {
const out = [];
const runs = new Map();
for (const row of rows) {
const name = row.group || "";
if (!name) { out.push(row.group_off ? withoutGroupOff(row) : row); continue; }
let run = runs.get(name);
if (!run) {
run = [];
runs.set(name, run);
out.push(run);
}
run.push(row);
}
return out.flatMap((item) => {
if (!Array.isArray(item)) return [item];
const off = item.some((row) => row.group_off);
return item.map((row) => off === Boolean(row.group_off) ? row
: off ? { ...row, group_off: true } : withoutGroupOff(row));
});
}
export function groupNames(rows) {
return [...new Set(rows.map((row) => row.group).filter(Boolean))];
}
export function hasGroupName(rows, name, except = "") {
const key = groupName(name).toLowerCase();
return groupNames(rows).some((group) => group !== except && group.toLowerCase() === key);
}
export function uniqueGroupName(rows, base = "Group") {
for (let n = 1; ; n += 1) {
const name = `${base} ${n}`;
if (!hasGroupName(rows, name)) return name;
}
}
// What the stack shows, in order: a header before each group's first row,
// then every row with the group it sits in.
export function stackLayout(rows) {
const items = [];
rows.forEach((row, index) => {
const group = row.group || "";
if (group && rows[index - 1]?.group !== group) {
let end = index;
while (end < rows.length && rows[end].group === group) end += 1;
const members = rows.slice(index, end);
items.push({
kind: "group", group, start: index, end,
on: members.filter((member) => member.enabled).length, total: members.length,
off: members.some((member) => member.group_off),
});
}
items.push({ kind: "row", index, group });
});
return items;
}
// A new group takes the place of the topmost chosen row. It never lands
// inside another group: from the middle of one it goes just below it.
export function groupRows(rows, ids, name) {
const chosen = new Set(ids);
const top = rows.findIndex((row) => chosen.has(row.id));
if (top < 0 || !name) return rows;
const rest = rows.filter((row) => !chosen.has(row.id));
let at = top;
while (at > 0 && at < rest.length && rest[at].group && rest[at].group === rest[at - 1].group) at += 1;
const grouped = rows.filter((row) => chosen.has(row.id)).map((row) => withGroup(row, name));
return tidyGroups([...rest.slice(0, at), ...grouped, ...rest.slice(at)]);
}
// Rows moved into a group join it at its end.
export function moveRowsToGroup(rows, ids, name) {
const chosen = new Set(ids);
const rest = rows.filter((row) => !chosen.has(row.id));
let last = -1;
rest.forEach((row, index) => { if (row.group === name) last = index; });
if (last < 0) return tidyGroups(rows.map((row) => chosen.has(row.id) ? withGroup(row, name) : row));
const moving = rows.filter((row) => chosen.has(row.id)).map((row) => withGroup(row, name));
return tidyGroups([...rest.slice(0, last + 1), ...moving, ...rest.slice(last + 1)]);
}
// Rows taken out of a group stand just outside it: the ones at its top go
// above it, the rest below it, so a row at either edge does not move.
export function ungroupRows(rows, ids) {
const chosen = new Set(ids);
const out = [];
for (let start = 0; start < rows.length;) {
const name = rows[start].group;
if (!name) { out.push(rows[start]); start += 1; continue; }
let end = start;
while (end < rows.length && rows[end].group === name) end += 1;
const run = rows.slice(start, end);
let lead = 0;
while (lead < run.length && chosen.has(run[lead].id)) lead += 1;
const stay = run.filter((row) => !chosen.has(row.id));
const below = run.slice(lead).filter((row) => chosen.has(row.id));
out.push(...run.slice(0, lead).map(withoutGroup), ...stay, ...below.map(withoutGroup));
start = end;
}
return out;
}
// A renamed group keeps its on/off.
export function renameGroup(rows, from, to) {
const name = groupName(to);
if (!name) return rows;
return tidyGroups(rows.map((row) => row.group === from ? { ...row, group: name } : row));
}
// Every row of a group on or off by itself.
export function setGroupEnabled(rows, name, enabled) {
return rows.map((row) => row.group === name ? { ...row, enabled: !!enabled } : row);
}
// The group's own switch: off stops all of its rows, and each row keeps its
// own on/off underneath.
export function setGroupOff(rows, name, off) {
return rows.map((row) => row.group !== name ? row
: off ? { ...row, group_off: true } : withoutGroupOff(row));
}
export function isGroupOff(rows, name) {
return rows.some((row) => row.group === name && row.group_off);
}
// Move up/down for one row, one step at a time: a row at the edge of its
// group steps out of it first, and a row next to a group steps into it.
export function moveRowInStack(rows, index, delta) {
const row = rows[index];
if (!row) return rows;
const next = rows[index + delta];
const group = row.group || "";
if (group && next?.group !== group) {
return rows.map((entry, i) => i === index ? withoutGroup(entry) : entry);
}
if (!next) return rows;
if (!group && next.group) {
return tidyGroups(rows.map((entry, i) => i === index ? withGroup(entry, next.group) : entry));
}
return reorderRows(rows, index, index + delta);
}
// Drop one row: before the row `beforeId` (or at the end of `group`, or at
// the very end), sitting in `group` (empty = top level).
export function placeRow(rows, id, { group = "", beforeId = null } = {}) {
const row = rows.find((entry) => entry.id === id);
if (!row) return rows;
const rest = rows.filter((entry) => entry.id !== id);
let at = beforeId ? rest.findIndex((entry) => entry.id === beforeId) : -1;
if (at < 0 && group) {
rest.forEach((entry, index) => { if (entry.group === group) at = index + 1; });
}
if (at < 0) at = rest.length;
return tidyGroups([...rest.slice(0, at), withGroup(row, group), ...rest.slice(at)]);
}
// Move a whole group before the row `beforeId` (a top-level row or the
// first row of another group), or to the end.
export function placeGroup(rows, name, beforeId = null) {
const members = rows.filter((row) => row.group === name);
if (!members.length || members.some((row) => row.id === beforeId)) return rows;
const rest = rows.filter((row) => row.group !== name);
let at = beforeId ? rest.findIndex((row) => row.id === beforeId) : -1;
if (at < 0) at = rest.length;
return [...rest.slice(0, at), ...members, ...rest.slice(at)];
}
// Move a group past the item above or below it (a row or a whole group).
export function moveGroup(rows, name, delta) {
const start = rows.findIndex((row) => row.group === name);
if (start < 0) return rows;
let end = start;
while (end < rows.length && rows[end].group === name) end += 1;
if (delta < 0) {
if (start === 0) return rows;
const above = rows[start - 1];
const beforeId = above.group ? rows.find((row) => row.group === above.group).id : above.id;
return placeGroup(rows, name, beforeId);
}
if (end >= rows.length) return rows;
const below = rows[end];
let after = end + 1;
if (below.group) while (after < rows.length && rows[after].group === below.group) after += 1;
return placeGroup(rows, name, rows[after]?.id ?? null);
}
// Drag-to-reorder model: which visual slot the pointer is over, given the
// vertical centers of the rows in their current on-screen order. Past the
// last center means the last slot; an empty stack has no slot.
@@ -229,25 +457,33 @@ export function toggleTrigger(triggers, word) {
}
export function summarizeRows(rows) {
const on = rows.filter((row) => row.enabled && row.name).length;
const on = rows.filter((row) => rowRuns(row) && row.name).length;
const total = rows.filter((row) => row.name).length;
return total === 0 ? "no LoRAs" : `${on} / ${total} on`;
}
// Master toggle model: "on" when every named row is enabled, "off" when none
// are, "mixed" otherwise. Blank rows don't vote.
// Master toggle model: "on" when every named row runs, "off" when none
// does, "mixed" otherwise. Blank rows don't vote. A row in a group that is
// switched off does not run.
export function toggleAllState(rows) {
const named = rows.filter((row) => row.name);
if (!named.length) return "off";
const on = named.filter((row) => row.enabled).length;
const on = named.filter(rowRuns).length;
return on === 0 ? "off" : on === named.length ? "on" : "mixed";
}
// Every row on (groups switched back on too) or every row off.
function setAllRows(rows, enabled) {
return rows.map((row) => {
const next = enabled ? withoutGroupOff(row) : row;
return row.name ? { ...next, enabled } : next;
});
}
// Clicking the master pill: anything not fully-on turns everything on;
// fully-on turns everything off.
export function toggleAllRows(rows) {
const enabled = toggleAllState(rows) !== "on";
return rows.map((row) => (row.name ? { ...row, enabled } : row));
return setAllRows(rows, toggleAllState(rows) !== "on");
}
// Longest common folder prefix (whole segments only) across displayed names,
@@ -363,9 +599,8 @@ export function loraPickerEntries(names, query = "", collapsedFolders = new Set(
return visit(tree);
}
// Folder add is one append, or no change when the whole selection cannot
// fit. Existing rows (even duplicates, disabled rows and empty slots) stay
// untouched. Deduplicate by full path, never basename or moved-file guess.
// Append the folder in one step, preserving duplicates, disabled rows and
// empty slots already in the stack. Deduplicate new files by full path.
export function appendFolderRows(rows, names, defaultStrength = 1) {
const seen = new Set(rows.map((row) => loraPath(row.name)));
const pending = [];
@@ -376,12 +611,10 @@ export function appendFolderRows(rows, names, defaultStrength = 1) {
seen.add(key);
pending.push(name);
}
const room = Math.max(0, MAX_ROWS - rows.length);
const needed = pending.length;
if (needed > room || !needed) return { rows, added: 0, skipped, needed, room };
if (!pending.length) return { rows, added: 0, skipped };
const strength = roundStrength(defaultStrength);
const addedRows = pending.map((name) => ({ ...newRow(), name, strength, strength_clip: strength }));
return { rows: [...rows, ...addedRows], added: needed, skipped, needed, room };
return { rows: [...rows, ...addedRows], added: addedRows.length, skipped };
}
// A suggested range is advisory: null/absent bounds never flag.
@@ -469,7 +702,7 @@ const BAR_EPSILON = 0.005;
export function strengthBarScale(rows) {
let peak = 0;
for (const row of rows ?? []) {
if (!row?.name || row.enabled === false) continue;
if (!row?.name || !rowRuns(row)) continue;
const value = Math.abs(Number(row.strength));
if (Number.isFinite(value)) peak = Math.max(peak, value);
}
@@ -619,29 +852,36 @@ export function duplicateLoraKeys(rows) {
// snapshotEnabled back in), so an accidental master click is always one more
// click from the setup it destroyed.
// A row in a group that is switched off is remembered with its group's state.
export function snapshotEnabled(rows) {
const map = {};
for (const row of rows ?? []) {
if (row?.name) map[row.id] = row.enabled !== false;
if (!row?.name) continue;
const enabled = row.enabled !== false;
map[row.id] = row.group_off ? { enabled, group_off: true } : enabled;
}
return map;
}
function restoreRow(row, saved) {
if (saved && typeof saved === "object") {
const next = { ...row, enabled: saved.enabled !== false };
return saved.group_off && next.group ? { ...next, group_off: true } : withoutGroupOff(next);
}
return { ...withoutGroupOff(row), enabled: Boolean(saved) };
}
export function cycleMasterToggle(rows, snapshot) {
const set = (enabled) =>
rows.map((row) => (row.name ? { ...row, enabled } : row));
const state = toggleAllState(rows);
if (state === "mixed") return { rows: set(true), snapshot: snapshotEnabled(rows) };
if (state === "on") return { rows: set(false), snapshot };
if (state === "mixed") return { rows: setAllRows(rows, true), snapshot: snapshotEnabled(rows) };
if (state === "on") return { rows: setAllRows(rows, false), snapshot };
// All off: restore the remembered setup when it still lights anything;
// otherwise this is a plain binary toggle and everything turns on.
const restored = rows.map((row) =>
row.name && snapshot && row.id in snapshot
? { ...row, enabled: snapshot[row.id] }
: row,
);
const remembersAnything = restored.some((row) => row.name && row.enabled);
return remembersAnything ? { rows: restored, snapshot } : { rows: set(true), snapshot };
const restored = tidyGroups(rows.map((row) =>
row.name && snapshot && row.id in snapshot ? restoreRow(row, snapshot[row.id]) : row,
));
const remembersAnything = restored.some((row) => row.name && rowRuns(row));
return remembersAnything ? { rows: restored, snapshot } : { rows: setAllRows(rows, true), snapshot };
}
// ---------- absorb the loader chain ----------
@@ -736,7 +976,7 @@ export function loaderLoraEntries(nodeLike) {
.filter((row) => row && typeof row === "object" && row.name)
.map((row) => entry(row.name, row.strength,
row.strength_clip ?? row.strength,
row.enabled, row.triggers));
row.enabled !== false && !row.group_off, row.triggers));
} catch {
return [];
}
@@ -787,9 +1027,9 @@ export function mergeImportedRows(existing, imported,
have.add(key);
added.push({ ...makeRow(), ...item });
}
const rows = (position === "after"
const rows = position === "after"
? [...(existing ?? []), ...added]
: [...added, ...(existing ?? [])]).slice(0, MAX_ROWS);
: [...added, ...(existing ?? [])];
return { rows, added: added.length, skipped };
}
+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|output)\]$/, "");
const bare = text.replace(/\s+\[input\]$/, "");
return values.includes(text) || values.includes(bare) ? "" : text;
}
+326
View File
@@ -0,0 +1,326 @@
// The timeline on H3 Prompt Writer: each moment of the script as a block on a
// bar as long as the clip, with the cuts as marks. The server reads the script with the same code a run uses,
// so the bar shows what will happen. It also colours the script box, so the
// words the node reads as instructions (@1, 3s:, a cut) stand out.
import { clipSetsDuration, linkSource } from "./h3_duration.mjs";
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { BRAND, chainCallback } from "./index.mjs";
import { dragRange, moveMoment, retime, rulerSeconds, stripCaption, stripRows, toggleCut, wiredJobs } from "./moment_strip_math.mjs";
import { attachScriptAssist } from "./script_assist.mjs";
import { attachScriptColours } from "./script_colours.mjs";
import { commitWidgetValue } from "./widget_card_math.mjs";
const CSS_ID = "ausboss-moment-strip-css";
const WAIT = 200; // Lets someone finish a word before asking.
const WATCH = 1000; // A Workflow Switches card turns a picture off without touching this node.
function ensureCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-strip{display:flex;flex-direction:column;gap:3px;height:100%;min-width:0;padding:5px 6px 3px;box-sizing:border-box;border-radius:6px;background:#0b0f10;font:600 10px/1.15 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;user-select:none}
.ausboss-strip-ruler{position:relative;flex:none;height:11px;color:#56706d;font-size:9px;line-height:11px}
.ausboss-strip-ruler span{position:absolute;transform:translateX(-50%);white-space:nowrap}
.ausboss-strip-ruler span:first-child{transform:none}
.ausboss-strip-ruler span:last-child{transform:translateX(-100%)}
.ausboss-strip-track{position:relative;flex:1 1 0;min-height:30px}
.ausboss-strip-block{position:absolute;top:0;bottom:0;display:flex;align-items:center;gap:4px;padding:0 4px;box-sizing:border-box;overflow:hidden;border:1px solid rgba(0,180,170,.5);border-radius:4px;background:rgba(0,180,170,.2);color:#cfe8e5}
.ausboss-strip-block.shot-odd{border-color:rgba(111,192,170,.42);background:rgba(111,192,170,.1)}
.ausboss-strip-block.speech{border-color:rgba(245,181,61,.55);background:rgba(245,181,61,.14);color:#f0dcae}
.ausboss-strip-faces{flex:none;display:flex;height:100%;max-height:28px}
.ausboss-strip-faces img{width:18px;height:100%;border-radius:3px;object-fit:cover;box-shadow:0 0 0 1px #0b0f10}
.ausboss-strip-faces img+img{margin-left:2px}
.ausboss-strip-words{flex:1 1 0;min-width:0;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word}
.ausboss-strip-cut{position:absolute;top:-13px;bottom:-2px;width:2px;margin-left:-1px;border-radius:1px;background:${BRAND}}
.ausboss-strip-handle{position:absolute;top:-2px;bottom:-2px;z-index:2;width:12px;margin-left:-6px;cursor:ew-resize;touch-action:none}
.ausboss-strip-handle::before{content:"";position:absolute;left:5px;top:5px;bottom:5px;width:2px;border-radius:1px;background:rgba(207,232,229,.35)}
.ausboss-strip-handle:hover::before,.ausboss-strip-handle.dragging::before{background:#cfe8e5}
.ausboss-strip-handle span{position:absolute;bottom:100%;left:50%;transform:translateX(-50%);padding:1px 4px;border-radius:3px;background:#cfe8e5;color:#0b0f10;font-size:9px;white-space:nowrap;display:none}
.ausboss-strip-handle.dragging span{display:block}
.ausboss-strip-block.movable{cursor:grab;touch-action:none}
.ausboss-strip-block.moving{z-index:3;cursor:grabbing;opacity:.85;box-shadow:0 4px 14px rgba(0,0,0,.5)}
.ausboss-strip-drop{position:absolute;top:-3px;bottom:-3px;z-index:4;width:3px;margin-left:-2px;border-radius:2px;background:#f5b53d;pointer-events:none}
.ausboss-strip-note{flex:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#6f8886;font-size:10px;font-weight:500;line-height:12px}
.ausboss-strip-note.warn{color:#d9a441}
`;
document.head.append(style);
}
function wired(node, name) {
return linkSource(node, name) !== null;
}
// The picture a loader shows, to put on the blocks that name it.
function pictureUrl(node, number) {
const source = linkSource(node, `picture_${number}`);
return source?.imgs?.[0]?.src ?? "";
}
function el(className, text) {
const node = document.createElement("div");
node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
// A card row: { panel: momentStrip, height }.
export function momentStrip(node) {
ensureCss();
const element = el("ausboss-strip");
const ruler = el("ausboss-strip-ruler");
const track = el("ausboss-strip-track");
const note = el("ausboss-strip-note");
element.append(ruler, track, note);
const colours = attachScriptColours(node);
const choices = (name) => (node.widgets?.find((widget) => widget.name === name)?.options?.values ?? []).filter((value) => value !== "as typed" && value !== "from the picture");
const assist = attachScriptAssist(node, colours.area, colours.layer, () => ({
pictures: [1, 2, 3, 4].map((number) => ({
number,
plugged: wired(node, `picture_${number}`),
job: node.widgets?.find((widget) => widget.name === `job_${number}`)?.value ?? "",
url: pictureUrl(node, number),
})),
cameras: choices("camera"),
looks: choices("look"),
seconds: clipSetsDuration(node, lastValues) ? 0 : Number(lastSeconds) || 8,
}));
let lastValues = {};
let lastSeconds = 8;
// The left edge of a moment that opens a typed line can be dragged; letting
// go types its second into that line (3s:), so the words stay the truth.
const commitScript = (change) => {
const widget = node.widgets?.find((entry) => entry.name === "script");
if (!widget) return;
const next = change(String(widget.value ?? ""));
if (next === widget.value) return;
commitWidgetValue(node, widget, next, app.canvas);
sync({ ...lastValues, script: widget.value });
};
// The left edge of a moment that opens a typed line: drag it to set when
// the moment starts (it types 3s: into that line), or click it to add or
// take away a cut there (a blank line). The words stay the truth.
const addHandle = (rows, index, seconds) => {
const range = dragRange(rows, index, seconds);
const handle = el("ausboss-strip-handle");
const label = document.createElement("span");
handle.append(label);
handle.style.left = `${rows[index].from}%`;
handle.title = range ? "Drag to set when this moment starts. Click to add or remove a cut." : "Click to add or remove a cut.";
track.append(handle);
handle.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
handle.setPointerCapture?.(event.pointerId);
const startX = event.clientX;
let moved = false;
let second = null;
const move = (moveEvent) => {
if (!moved && Math.abs(moveEvent.clientX - startX) < 4) return;
if (!range) return;
moved = true;
handle.classList.add("dragging");
const box = track.getBoundingClientRect();
const raw = ((moveEvent.clientX - box.left) / Math.max(box.width, 1)) * seconds;
second = Math.min(range[1], Math.max(range[0], Math.round(raw)));
handle.style.left = `${(second / seconds) * 100}%`;
label.textContent = `${second}s`;
};
const stop = () => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", stop);
handle.removeEventListener("pointercancel", stop);
handle.classList.remove("dragging");
if (!moved) commitScript((text) => toggleCut(text, rows[index].line));
else if (second !== null && second !== rows[index].start) commitScript((text) => retime(text, rows[index].line, second));
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", stop);
handle.addEventListener("pointercancel", stop);
});
};
// A click on a block selects its line in the words, ready to change.
const selectLine = (line) => {
const area = colours.area;
if (!area || !Number.isInteger(line)) return;
const lines = area.value.split("\n");
if (line >= lines.length) return;
const start = lines.slice(0, line).reduce((total, text) => total + text.length + 1, 0);
area.focus({ preventScroll: true });
area.setSelectionRange(start, start + lines[line].length);
const height = parseFloat(getComputedStyle(area).lineHeight) || 17;
area.scrollTop = Math.max(0, line * height - area.clientHeight / 3);
};
// A block that opens a typed line can be dragged along the bar: its words
// swap places with the others; times and cuts stay where they are.
const makeMovable = (block, rows, entries, order) => {
block.classList.add("movable");
block.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
block.setPointerCapture?.(event.pointerId);
const startX = event.clientX;
let target = order;
let moved = false;
const drop = el("ausboss-strip-drop");
const move = (moveEvent) => {
const shift = moveEvent.clientX - startX;
if (!moved && Math.abs(shift) < 4) return;
if (!moved) { moved = true; block.classList.add("moving"); track.append(drop); }
block.style.transform = `translateX(${shift}px)`;
const centre = block.getBoundingClientRect().left + block.offsetWidth / 2;
const others = entries.filter((entry) => entry.order !== order);
target = others.filter((entry) => {
const box = entry.block.getBoundingClientRect();
return centre > box.left + box.width / 2;
}).length;
const after = others[target - 1];
const before = others[target];
const at = before ? rows[before.index].from : after ? rows[after.index].to : 0;
drop.style.left = `${at}%`;
};
const stop = () => {
block.removeEventListener("pointermove", move);
block.removeEventListener("pointerup", stop);
block.removeEventListener("pointercancel", stop);
block.classList.remove("moving");
block.style.transform = "";
drop.remove();
if (!moved) selectLine(rows[entries[order].index].line);
else if (target !== order) commitScript((text) => moveMoment(text, entries.map((entry) => rows[entry.index].line), order, target));
};
block.addEventListener("pointermove", move);
block.addEventListener("pointerup", stop);
block.addEventListener("pointercancel", stop);
});
};
const show = (report) => {
const seconds = Number(report?.seconds) || Number(lastValues.seconds) || 8;
ruler.textContent = "";
for (const mark of rulerSeconds(seconds)) {
const tick = document.createElement("span");
tick.style.left = `${(mark / seconds) * 100}%`;
tick.textContent = `${mark}s`;
ruler.append(tick);
}
track.textContent = "";
let shot = 0;
const rows = stripRows(report);
const movable = [];
rows.forEach((moment, index) => {
if (moment.cut && index) shot += 1;
const block = el(`ausboss-strip-block${shot % 2 ? " shot-odd" : ""}`);
block.classList.toggle("speech", moment.speech);
block.style.left = `${moment.from}%`;
block.style.width = `calc(${moment.to - moment.from}% - 2px)`;
// Every picture the line names, in order, up to three.
const faces = el("ausboss-strip-faces");
for (const number of [...new Set([...moment.words.matchAll(/@([1-4])\b/g)].map((match) => match[1]))].slice(0, 3)) {
const url = pictureUrl(node, number);
if (!url) continue;
const picture = document.createElement("img");
picture.src = url;
picture.alt = `@${number}`;
faces.append(picture);
}
if (faces.childElementCount) block.append(faces);
block.append(el("ausboss-strip-words", moment.words));
block.title = `${moment.second}s: ${moment.words}`;
track.append(block);
if (moment.line !== null) movable.push({ block, index, order: movable.length });
const opens = rows.slice(0, index + 1).findLast((row) => row.line !== null)?.line ?? null;
block.addEventListener("click", () => { if (!block.classList.contains("movable")) selectLine(opens); });
if (index && moment.line !== null && !wired(node, "script")) addHandle(rows, index, seconds);
if (moment.cut && index) {
const cut = el("ausboss-strip-cut");
cut.style.left = `${moment.from}%`;
cut.title = `Cut at ${moment.second}s`;
track.append(cut);
}
});
if (movable.length > 1 && !wired(node, "script")) {
for (const entry of movable) {
makeMovable(entry.block, rows, movable, entry.order);
entry.block.title += " · drag to move it";
}
}
const caption = stripCaption(report);
note.textContent = caption.text;
note.title = caption.text;
note.classList.toggle("warn", caption.warn);
};
let shown = null;
let timer = null;
let asked = 0;
const ask = async (script, seconds, jobs, video) => {
const turn = ++asked;
try {
const response = await api.fetchApi("/ausboss/h3_writer/read", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ script, seconds, jobs, video }),
});
const report = response.ok ? await response.json() : null;
if (turn === asked && !clipSetsDuration(node, lastValues)) show(report);
} catch {
if (turn === asked && !clipSetsDuration(node, lastValues)) show(null);
}
};
let latest = null;
let fromClip = false;
const sync = (values) => {
latest = values;
lastValues = values;
const nextFromClip = clipSetsDuration(node, values);
if (fromClip !== nextFromClip) {
fromClip = nextFromClip;
// Also notice a source switched off without changing this node's wires.
queueMicrotask(() => node.__ausbossCard?.refresh?.());
}
colours.sync(values, [1, 2, 3, 4].filter((number) => wired(node, `picture_${number}`)));
if (fromClip) {
shown = null;
clearTimeout(timer);
asked += 1;
ruler.textContent = "";
track.textContent = "";
const message = el("ausboss-strip-words", "Length comes from the clip");
message.style.cssText = "display:flex;align-items:center;height:100%";
track.append(message);
note.textContent = "Set the clip's length upstream. Timing controls are unavailable here.";
note.title = note.textContent;
note.classList.remove("warn");
return;
}
if (wired(node, "script")) {
shown = null;
clearTimeout(timer);
asked += 1;
show(null);
note.textContent = "The words come from the wire.";
return;
}
const script = String(values.script ?? "");
// A length wired from a number box shows that box's value.
const source = linkSource(node, "seconds");
const fromWire = Number(source?.widgets?.find?.((widget) => widget.name === "value")?.value);
const seconds = Math.min(15, Math.max(5, Math.round(fromWire || Number(values.seconds) || 8)));
lastSeconds = seconds;
const jobs = wiredJobs(values, (name) => wired(node, name));
const video = wired(node, "video") ? values.video_job : "";
const key = JSON.stringify([seconds, script, jobs, video]);
if (key === shown) return;
shown = key;
clearTimeout(timer);
timer = setTimeout(() => ask(script, seconds, jobs, video), WAIT);
};
const watch = setInterval(() => latest && sync(latest), WATCH);
chainCallback(node, "onRemoved", () => { clearInterval(watch); colours.dispose(); assist.dispose(); });
return { element, sync };
}
+115
View File
@@ -0,0 +1,115 @@
// Pure decisions behind js/shared/moment_strip.mjs: what the rows and the
// line under them say. No DOM, no app import, so tests run it under node:test.
const trimmed = (seconds) => String(Math.round(seconds * 10) / 10);
const percent = (part, whole) => Math.min(Math.max((part / whole) * 100, 0), 100);
// The job of each picture that is connected, by its number, for the server to
// check the @ numbers in a script against.
export function wiredJobs(values, wired) {
const jobs = {};
for (const number of [1, 2, 3, 4]) {
if (wired(`picture_${number}`)) jobs[number] = values[`job_${number}`];
}
return jobs;
}
// One row for each moment the server read: its second, its words, and where
// its bar sits in the clip, as percentages.
export function stripRows(report) {
const moments = report?.moments ?? [];
const seconds = report?.seconds || moments.at(-1)?.end || 1;
return moments.map((moment) => ({
second: trimmed(moment.start),
words: moment.text,
from: percent(moment.start, seconds),
to: Math.max(percent(moment.end, seconds), percent(moment.start, seconds) + 1),
cut: Boolean(moment.cut),
speech: Boolean(moment.speech),
line: Number.isInteger(moment.line) ? moment.line : null,
start: Number(moment.start) || 0,
}));
}
// A time typed at the start of a line: 3s:, 3 s, at 3 seconds, [3s].
const TYPED_TIME = /^\[?(?:at\s+)?\d+(?:\.\d+)?\s*(?:seconds?|secs?|s)\b\]?\s*[:,\-]?\s*/i;
// The script with one line set to start at a second: its old time, if any,
// gives way to the new one. line counts typed lines from 0.
export function retime(text, line, second) {
const lines = String(text ?? "").split("\n");
if (!Number.isInteger(line) || line < 0 || line >= lines.length) return String(text ?? "");
const lead = lines[line].match(/^\s*/)[0];
const words = lines[line].slice(lead.length).replace(TYPED_TIME, "");
lines[line] = `${lead}${trimmed(second)}s: ${words}`;
return lines.join("\n");
}
// The whole seconds a moment's start can be dragged to: after the moment
// before it, before the one after it (or the end of the clip).
export function dragRange(rows, index, seconds) {
const before = rows[index - 1]?.start ?? 0;
const after = rows[index + 1]?.start ?? seconds;
const low = Math.floor(before) + 1;
const high = Math.ceil(after) - 1;
return low <= high ? [low, high] : null;
}
// The line under the rows: a warning first, then what the script adds up to.
export function stripCaption(report) {
if (report?.warning) return { text: report.warning, warn: true };
const moments = report?.moments ?? [];
if (!moments.length) return { text: "Each line becomes a moment.", warn: false };
const count = moments.length === 1 ? "1 moment" : `${moments.length} moments`;
const cuts = moments.filter((moment) => moment.cut).length;
if (cuts) return { text: `${count}, ${cuts === 1 ? "1 cut" : `${cuts} cuts`}.`, warn: false };
const each = moments.map((moment) => trimmed(moment.end - moment.start));
if (moments.length === 1) return { text: `${count}, ${each[0]} s, one take.`, warn: false };
const even = each.every((length) => length === each[0]);
return { text: even ? `${count}, ${each[0]} s each, one take.` : `${count}, one take.`, warn: false };
}
// The seconds the bar under the script marks: every second up to 10 s,
// every other one after, always with the end.
export function rulerSeconds(seconds) {
const total = Math.max(1, Math.round(Number(seconds) || 0));
const step = total > 10 ? 2 : 1;
const marks = [];
for (let mark = 0; mark < total; mark += step) marks.push(mark);
if (total - marks.at(-1) < step) marks.pop();
return [...marks, total];
}
// The script with one moment's words moved to another place on the bar.
// slots: the typed line of each draggable moment, in bar order. The words
// move; each place keeps its own time (3s:) and the cuts stay where they
// are, so the bar's marks don't jump.
export function moveMoment(text, slots, from, to) {
const lines = String(text ?? "").split("\n");
if (from === to || !slots.every((line) => Number.isInteger(line) && line >= 0 && line < lines.length)) return String(text ?? "");
const split = (line) => {
const lead = lines[line].match(/^\s*/)[0];
const body = lines[line].slice(lead.length);
const time = body.match(TYPED_TIME)?.[0] ?? "";
return { lead, time, words: body.slice(time.length) };
};
const parts = slots.map(split);
const words = parts.map((part) => part.words);
const [moved] = words.splice(from, 1);
words.splice(Math.max(0, Math.min(to, words.length)), 0, moved);
slots.forEach((line, index) => { lines[line] = parts[index].lead + parts[index].time + words[index]; });
return lines.join("\n");
}
// The script with a cut added or taken away just before a typed line: a cut
// is a blank line.
export function toggleCut(text, line) {
const lines = String(text ?? "").split("\n");
if (!Number.isInteger(line) || line <= 0 || line >= lines.length) return String(text ?? "");
let top = line;
while (top > 0 && lines[top - 1].trim() === "") top -= 1;
if (top < line) lines.splice(top, line - top);
else lines.splice(line, 0, "");
return lines.join("\n");
}
+13
View File
@@ -0,0 +1,13 @@
// A tile needs its renderer. Give saved tiles a real node identity so an old
// install reports a missing node instead of stretching a plain note over it.
export const CALLOUT_TYPES = new Set(["AUSBOSS_NODES_Callout", "AUSBOSS_NODES_CalloutTile"]);
export const NOTE_TYPES = new Set(["AUSBOSS_NODES_WorkflowNote", "AUSBOSS_NODES_WorkflowNoteTile"]);
export function serializeNoteIdentity(saved, tile) {
const types = CALLOUT_TYPES.has(saved.type) ? [...CALLOUT_TYPES]
: NOTE_TYPES.has(saved.type) ? [...NOTE_TYPES] : null;
if (!types) return;
saved.type = types[tile ? 1 : 0];
// LiteGraph's serialized properties can still be the live object's properties.
saved.properties = { ...saved.properties, "Node name for S&R": saved.type };
}
+45
View File
@@ -0,0 +1,45 @@
// The outline of a node or group as people see it: { x, y, w, h } in graph
// units, title bar included.
//
// The classic look draws a node at its measured bounds (boundingRect). Nodes
// 2.0 draws each node as an element on the page, which can run past those
// bounds (its frame, a footer), so there the element itself is measured.
// Groups are drawn on the canvas in both looks.
function graphRectOf(element, canvas) {
const box = element.getBoundingClientRect();
if (!(box.width > 0) || !(box.height > 0)) return null;
const view = canvas.canvas.getBoundingClientRect();
const { scale, offset } = canvas.ds;
return {
x: (box.left - view.left) / scale - offset[0],
y: (box.top - view.top) / scale - offset[1],
w: box.width / scale,
h: box.height / scale,
};
}
export function boundsOf(item) {
const box = item?.boundingRect;
if (!box || !(box[2] > 0) || !(box[3] > 0)) return null;
return { x: box[0], y: box[1], w: box[2], h: box[3] };
}
// Nodes 2.0's node elements by node id, found in one pass for measuring many.
export function nodeElements() {
const elements = new Map();
if (globalThis.LiteGraph?.vueNodesMode !== true) return elements;
for (const element of document.querySelectorAll("[data-node-id]")) elements.set(element.getAttribute("data-node-id"), element);
return elements;
}
export function outlineOf(item, canvas, { node = true, elements = null } = {}) {
if (node && globalThis.LiteGraph?.vueNodesMode === true && item?.id != null && canvas?.canvas && canvas.ds) {
const element = elements
? elements.get(String(item.id))
: document.querySelector(`[data-node-id="${CSS.escape(String(item.id))}"]`);
const rect = element && graphRectOf(element, canvas);
if (rect) return rect;
}
return boundsOf(item);
}
+54
View File
@@ -0,0 +1,54 @@
// Shared controls for compact node panels. Only elements explicitly given one
// of these classes are styled; the owning panel decides where they sit.
import { BRAND } from "./index.mjs";
export const PANEL_LABELS = Object.freeze({
load: "Load", sources: { input: "Inputs", output: "Outputs" },
embed: "Embed workflow", off: "Off", on: "On",
});
const controlFont = 'system-ui, sans-serif';
export const CAPTION_TYPE = `font:600 11px/1.25 ${controlFont};color:#b2c6c3;`;
export const HINT_TYPE = `font:400 10px/1.25 ${controlFont};color:#95aba8;`;
export function ensurePanelControls() {
if (document.getElementById("ausboss-panel-controls")) return;
const style = document.createElement("style");
style.id = "ausboss-panel-controls";
style.textContent = `
.ausboss-compact-action{box-sizing:border-box;flex:none;height:22px;min-width:24px;padding:0 7px;border:1px solid #365957;border-radius:4px;background:#111b1c;color:#bdd8d5;font:600 11px/1 ${controlFont};white-space:nowrap;cursor:pointer;pointer-events:auto}
.ausboss-compact-action:hover:enabled,.ausboss-compact-action.active{color:#dbfffb;border-color:${BRAND};background:#173332}
.ausboss-compact-action:disabled{opacity:.5;cursor:default}
.ausboss-panel-switch{display:inline-flex;align-items:center;gap:6px;align-self:flex-end;flex:none;box-sizing:border-box;min-height:20px;margin-left:auto;padding:0 2px;background:transparent;border:0;color:#b2c6c3;font:600 11px/1 ${controlFont};cursor:pointer;user-select:none;pointer-events:auto}
.ausboss-panel-switch i{position:relative;width:24px;height:13px;border-radius:7px;background:#3a4445}
.ausboss-panel-switch i::after{content:"";position:absolute;top:2px;left:2px;width:9px;height:9px;border-radius:50%;background:#b6c4c3}
.ausboss-panel-switch[aria-checked=true] i{background:${BRAND}}
.ausboss-panel-switch[aria-checked=true] i::after{left:13px;background:#fff}
.ausboss-compact-action:focus-visible,.ausboss-panel-switch:focus-visible{outline:2px solid ${BRAND};outline-offset:1px}
`;
document.head.append(style);
}
export function compactAction(label, title = "") {
ensurePanelControls();
const button = document.createElement("button");
button.type = "button";
button.className = "ausboss-compact-action";
button.textContent = label;
button.title = title;
return button;
}
export function panelSwitch(label, title) {
ensurePanelControls();
const button = document.createElement("button");
button.type = "button";
button.className = "ausboss-panel-switch";
button.title = title;
button.setAttribute("role", "switch");
button.setAttribute("aria-checked", "false");
const thumb = document.createElement("i");
thumb.setAttribute("aria-hidden", "true");
button.append(label, thumb);
return button;
}
+70
View File
@@ -0,0 +1,70 @@
// What a paste did to ids. Copy and paste give every pasted node and group a
// new id, and so do duplicate, Alt + drag and dropping a saved workflow on the
// canvas: the frontend sends all of them through
// LGraphCanvas._deserializeItems. A node that remembers other nodes by id
// (Workflow Switches, a Callout's links) would keep naming the old ones.
// Pasted on their own, its copies point at nothing; pasted into another
// workflow, they can point at an unrelated node that has the old number.
//
// The paste returns which new node each old id became. onPaste hands that
// on, so a pasted node can follow the copies of what it points at. A frontend
// without the method pastes as before and no handler runs.
//
// No app import, so tests/paste_ids.test.mjs runs it under node:test.
const handlers = new Set();
let installed = false;
// Old id (as text) -> the copy's id, for the nodes and the groups of one
// paste. The frontend adds the groups it is handed one by one, in order, so
// the groups it made are matched to the old ids by position; when the counts
// differ, no group is matched rather than a wrong one.
export function pastedIds(result, groupIds = [], isGroup = () => false) {
const nodes = new Map();
for (const [id, node] of result?.nodes ?? []) {
if (node?.id !== undefined && node.id !== null) nodes.set(String(id), node.id);
}
const created = [...(result?.created ?? [])];
const groups = new Map();
const made = created.filter(isGroup);
if (made.length === groupIds.length) {
groupIds.forEach((id, index) => {
if (id !== undefined && id !== null && made[index]?.id !== undefined) groups.set(String(id), made[index].id);
});
}
return { nodes, groups, created };
}
// Runs `handler({ nodes, groups, created })` after every paste.
export function onPaste(handler) {
if (typeof handler === "function") handlers.add(handler);
install();
}
function install() {
if (installed) return;
const lite = globalThis.LiteGraph;
const proto = lite?.LGraphCanvas?.prototype;
const original = proto?._deserializeItems;
if (typeof original !== "function") return;
installed = true;
const isGroup = (item) => Boolean(lite.LGraphGroup && item instanceof lite.LGraphGroup);
// A method whose result is needed, not a callback: chainCallback would drop
// the result, so wrap it and always hand the frontend's own result back.
function deserializeItemsWithIds(items, ...rest) {
// Read before the paste, which gives the groups it is handed new ids.
const groupIds = Array.isArray(items?.groups) ? items.groups.map((group) => group?.id) : [];
const result = original.call(this, items, ...rest);
if (!result || !handlers.size) return result;
const ids = pastedIds(result, groupIds, isGroup);
for (const handler of handlers) {
try {
handler(ids);
} catch (error) {
console.warn("[AusBoss] after a paste:", error);
}
}
return result;
}
proto._deserializeItems = deserializeItemsWithIds;
}
+59 -7
View File
@@ -129,13 +129,17 @@ export function ratioSizeNear(ratio, width, height, step = 1) {
// modifiers: snap (default true; false = 1px freedom),
// lockRatio (true = keep the grab-time ratio exactly).
export function stageScale(width, height, boxW, boxH) {
// Fit with fixed headroom for the handles, corner hints, and the label
// (the design's 90px horizontal / 60px vertical margins), never below a
// sliver on a tiny stage.
const usableW = Math.max(40, boxW - 90);
const usableH = Math.max(40, boxH - 60);
return Math.max(0.002, Math.min(usableW / width, usableH / height));
// Fit the selection inside its handles. If both corner readouts fit beside
// the rectangle (or already fit above it), the full stage height is usable.
export function fitStage(width, height, boxW, boxH, cornerRoom = 126) {
const pad = 10; // the outer half of an 18px handle plus a pixel of air
const fit = top => Math.min(Math.max(1, boxW - 2 * pad) / width,
Math.max(1, boxH - top - pad) / height);
const full = fit(pad);
const header = 26;
if ((boxW - width * full) / 2 >= cornerRoom || (boxH - height * full) / 2 >= header)
return { scale: full, shift: 0 };
return { scale: fit(header), shift: (header - pad) / 2 };
}
const HANDLE_AXES = {
@@ -406,3 +410,51 @@ export function sizeForMegapixels(width, height, targetMp, step = DEFAULT_SNAP)
}
return best;
}
// A workflow's list keeps the author's order and the exact orientation of each
// pair. A near match, including a reversed pair, is a different model input.
export function parseWorkflowSizes(text) {
const lines = text.trim().split(/\n/).map(line => line.trim()).filter(Boolean);
if (lines.length > 24) throw new Error("Choose up to 24 resolutions.");
const sizes = [];
for (const line of lines) {
const match = /^(\d{1,4})\s*[x×]\s*(\d{1,4})$/i.exec(line);
const pair = match?.slice(1).map(Number);
if (!pair || pair.some(n => n < DIM_MIN || n > DIM_MAX)) {
throw new Error(`Enter a width × height between ${DIM_MIN} and ${DIM_MAX} px on each line.`);
}
if (!sizes.some(([w, h]) => w === pair[0] && h === pair[1])) sizes.push(pair);
}
return sizes;
}
export function workflowRatios(sizes) {
const ratios = new Map();
for (const [width, height] of sizes) {
const { x, y } = reduceRatio(width, height);
const label = `${x}:${y}`;
if (!ratios.has(label)) ratios.set(label, { x, y, label, sizes: [] });
ratios.get(label).sizes.push({ width, height });
}
return [...ratios.values()];
}
export function readWorkflowPolicy(value) {
if (value === "") return { sizes: [], strict: false };
const invalid = () => { throw new Error("Invalid workflow resolutions."); };
if (typeof value !== "string" || value.length > 2048) invalid();
const policy = JSON.parse(value);
if (typeof policy?.strict !== "boolean" || !Array.isArray(policy.sizes)
|| !policy.sizes.length || policy.sizes.length > 24
|| !policy.sizes.every(pair => Array.isArray(pair) && pair.length === 2
&& pair.every(n => Number.isInteger(n) && n >= DIM_MIN && n <= DIM_MAX))) invalid();
return { sizes: policy.sizes, strict: policy.strict };
}
// The panel used to occupy the appended widget's position in older saves.
// Keep any other malformed value intact so strict settings cannot fail open.
export function savedWorkflowPolicy(value, legacy) {
if (value != null && value !== "" && value !== "image") return value;
return Array.isArray(legacy) && legacy.length ? JSON.stringify({ strict: true, sizes: legacy }) : "";
}
+4 -1
View File
@@ -20,6 +20,7 @@ const emptyWidget = (inputs, name) => !linked(inputs?.[name]) && blank(inputs?.[
const picture = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "picture" } : null);
const video = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "video" } : null);
const sound = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "sound file" } : null);
function videoSource(inputs) {
const mode = inputs?.source_mode;
@@ -38,6 +39,7 @@ export const SOURCE_LOADERS = Object.freeze({
AUSBOSS_NODES_ImageCropRotatePad: picture("image"),
// A wired source_image replaces the file, so the file may stay blank.
AUSBOSS_NODES_LoadImagePad: (inputs) => (linked(inputs?.source_image) ? null : picture("image")(inputs)),
AUSBOSS_NODES_LoadAudio: sound("audio"),
AUSBOSS_NODES_LoadVideo: video("video"),
AUSBOSS_NODES_VideoCropRotatePad: videoSource,
AUSBOSS_NODES_VideoCropRotatePadClip: videoSource,
@@ -110,7 +112,8 @@ export function emptySourceMessage(found) {
if (!first) return "";
const more = found.length - 1;
const tail = more > 0 ? ` (and ${more} more node${more === 1 ? "" : "s"})` : "";
return `Load a ${first.kind === "video" ? "video" : "picture"} first: ${first.title}${tail}`;
const noun = first.kind === "video" || first.kind === "sound file" ? first.kind : "picture";
return `Load a ${noun} first: ${first.title}${tail}`;
}
// "Save Image: exact_name may not ..." names the input it is about, after
+183
View File
@@ -0,0 +1,183 @@
// Help while typing in H3 Prompt Writer's script box:
// - a "?" beside "What happens" opens a card of everything the node reads in
// the words (@1, 3s:, a blank line, quotes, sound:, music:, camera:, look:);
// - typing @ lists the pictures plugged in, with their job; the first letters
// of a line offer sound:, music:, camera: and look:; a number offers a time;
// after camera: or look: the node's own choices are listed.
// Suggestions only open on those, so they stay out of other packs' way.
// Picking one types it into the box the browser's way, so undo still works.
import { CHEAT_SHEET, suggestAt } from "./script_assist_math.mjs";
const CSS_ID = "ausboss-script-assist-css";
function ensureCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-help-dot{margin-left:auto;pointer-events:auto;width:15px;height:15px;padding:0;border:1px solid #2f4a48;border-radius:50%;background:transparent;color:#6f8886;font:700 9.5px/13px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:0;text-align:center;cursor:help}
.ausboss-help-dot:hover,.ausboss-help-dot.open{border-color:#00b4aa;color:#a5f3ec}
.ausboss-assist-pop{position:fixed;z-index:10000;box-sizing:border-box;border:1px solid #2a3437;border-radius:8px;background:#0f1516;color:#d8ecea;box-shadow:0 8px 28px rgba(0,0,0,.55);font:500 12px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
.ausboss-assist-help{width:300px;padding:10px 12px}
.ausboss-assist-help h4{margin:0 0 6px;color:#a5f3ec;font-size:12px;font-weight:700}
.ausboss-assist-help dl{display:grid;grid-template-columns:auto 1fr;gap:5px 10px;margin:0}
.ausboss-assist-help dt{color:#f5b53d;font:600 11px/1.35 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap}
.ausboss-assist-help dd{margin:0;color:#c3d6d4}
.ausboss-assist-help p{margin:8px 0 0;color:#6f8886;font-size:11px}
.ausboss-assist-list{min-width:170px;max-width:280px;padding:4px}
.ausboss-assist-item{display:flex;align-items:center;gap:7px;padding:4px 6px;border-radius:5px;cursor:pointer;white-space:nowrap}
.ausboss-assist-item.active{background:rgba(0,180,170,.22)}
.ausboss-assist-item img{flex:none;width:20px;height:20px;border-radius:4px;object-fit:cover}
.ausboss-assist-item b{color:#a5f3ec;font:700 11.5px ui-monospace,SFMono-Regular,Menlo,monospace}
.ausboss-assist-item em{overflow:hidden;color:#6f8886;font-style:normal;text-overflow:ellipsis}
.ausboss-assist-foot{padding:3px 6px 1px;color:#56706d;font-size:10px}
`;
document.head.append(style);
}
const make = (tag, className, text) => {
const element = document.createElement(tag);
if (className) element.className = className;
if (text !== undefined) element.textContent = text;
return element;
};
// Screen position of the caret: a hidden copy of the box with a marker where
// the caret is. The copy sits in the box's own place, so zoom is included.
function caretPoint(area, layer) {
const copy = layer.cloneNode(false);
copy.className = "ausboss-colours";
copy.style.visibility = "hidden";
copy.textContent = area.value.slice(0, area.selectionEnd);
const mark = make("span", "", "​");
copy.append(mark);
area.parentElement.append(copy);
copy.scrollTop = area.scrollTop;
const box = mark.getBoundingClientRect();
copy.remove();
return { x: box.left, y: box.bottom };
}
// node: the H3 Prompt Writer; area: its script box; layer: the coloured copy
// under it; context(): { pictures, cameras, looks, seconds }.
export function attachScriptAssist(node, area, layer, context) {
const none = { dispose: () => {} };
if (!area?.parentElement) return none;
ensureCss();
// The "?" card.
const row = area.closest(".ausboss-card-row");
const header = row?.previousElementSibling?.classList?.contains("ausboss-card-section") ? row.previousElementSibling : null;
let help = null;
let dot = null;
let closeHelpTimer = null;
const closeHelp = () => { help?.remove(); help = null; dot?.classList.remove("open"); };
const openHelp = () => {
clearTimeout(closeHelpTimer);
if (help) return;
help = make("div", "ausboss-assist-pop ausboss-assist-help");
help.append(make("h4", "", "What you can type"));
const list = make("dl");
for (const entry of CHEAT_SHEET) list.append(make("dt", "", entry.code), make("dd", "", entry.text));
help.append(list, make("p", "", "Typing @ lists your pictures. Tab picks a suggestion, Esc closes it."));
help.addEventListener("mouseenter", () => clearTimeout(closeHelpTimer));
help.addEventListener("mouseleave", () => { closeHelpTimer = setTimeout(closeHelp, 250); });
document.body.append(help);
const box = dot.getBoundingClientRect();
const left = Math.max(8, Math.min(window.innerWidth - help.offsetWidth - 8, box.right - help.offsetWidth));
const below = box.bottom + 6;
help.style.left = `${left}px`;
help.style.top = `${below + help.offsetHeight > window.innerHeight - 8 ? Math.max(8, box.top - help.offsetHeight - 6) : below}px`;
dot.classList.add("open");
};
if (header) {
dot = make("button", "ausboss-help-dot", "?");
dot.type = "button";
dot.title = "";
dot.setAttribute("aria-label", "What you can type in the words");
dot.addEventListener("mouseenter", openHelp);
dot.addEventListener("mouseleave", () => { closeHelpTimer = setTimeout(closeHelp, 250); });
dot.addEventListener("pointerdown", (event) => event.stopPropagation());
dot.addEventListener("click", (event) => { event.stopPropagation(); if (help) closeHelp(); else openHelp(); });
header.append(dot);
}
// Suggestions.
let pop = null;
let offer = null;
let active = 0;
const close = () => { pop?.remove(); pop = null; offer = null; };
const take = (item) => {
if (!offer) return;
area.focus();
area.setSelectionRange(offer.from, offer.to);
// insertText keeps the browser's own undo.
if (!document.execCommand?.("insertText", false, item.insert)) {
area.setRangeText(item.insert, offer.from, offer.to, "end");
area.dispatchEvent(new Event("input", { bubbles: true }));
}
close();
};
const draw = () => {
if (!pop || !offer) return;
pop.textContent = "";
offer.items.forEach((item, index) => {
const line = make("div", `ausboss-assist-item${index === active ? " active" : ""}`);
if (item.number && item.picture) { const img = make("img"); img.src = item.picture; line.append(img); }
line.append(make("b", "", item.label));
if (item.hint) line.append(make("em", "", item.hint));
line.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); take(item); });
pop.append(line);
});
pop.append(make("div", "ausboss-assist-foot", offer.strong ? "Enter or Tab to pick · Esc to close" : "Tab to pick · Esc to close"));
};
const update = () => {
if (document.activeElement !== area || area.selectionStart !== area.selectionEnd) return close();
const ctx = context();
const next = suggestAt(area.value, area.selectionEnd, ctx);
if (!next) return close();
for (const item of next.items) if (item.number) item.picture = ctx.pictures.find((picture) => picture.number === item.number)?.url ?? "";
const same = offer && offer.from === next.from && offer.items.map((item) => item.label).join() === next.items.map((item) => item.label).join();
offer = next;
if (!same) active = 0;
if (!pop) { pop = make("div", "ausboss-assist-pop ausboss-assist-list"); document.body.append(pop); }
draw();
const point = caretPoint(area, layer);
pop.style.left = `${Math.max(8, Math.min(window.innerWidth - pop.offsetWidth - 8, point.x))}px`;
pop.style.top = `${point.y + pop.offsetHeight + 4 > window.innerHeight ? point.y - pop.offsetHeight - 22 : point.y + 4}px`;
};
const onKey = (event) => {
if (!offer) return;
const count = offer.items.length;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
active = (active + (event.key === "ArrowDown" ? 1 : count - 1)) % count;
offer.strong = true; // Choosing with the arrows makes Enter pick too.
draw();
} else if (event.key === "Tab" || (event.key === "Enter" && offer.strong && !event.shiftKey)) {
take(offer.items[active]);
} else if (event.key === "Escape") {
close();
} else {
return;
}
event.preventDefault();
event.stopPropagation();
};
const onBlur = () => setTimeout(() => { if (document.activeElement !== area) close(); }, 0);
area.addEventListener("input", update);
area.addEventListener("keydown", onKey, true);
area.addEventListener("blur", onBlur);
area.addEventListener("click", update);
return {
dispose() {
close();
closeHelp();
dot?.remove();
area.removeEventListener("input", update);
area.removeEventListener("keydown", onKey, true);
area.removeEventListener("blur", onBlur);
area.removeEventListener("click", update);
},
};
}
+92
View File
@@ -0,0 +1,92 @@
// Pure decisions behind js/shared/script_assist.mjs: what the help card says,
// and what to suggest at the caret in H3 Prompt Writer's script box. No DOM,
// so tests run it under node:test.
// The help card: each thing the node reads in the words, in plain words.
export const CHEAT_SHEET = [
{ code: "@1 to @4", text: "A picture you plugged in. @1 is Picture 1." },
{ code: "3s:", text: "At the start of a line: this moment starts 3 seconds in. Lines with no time share what is left evenly. Dragging a block's left edge on the bar types this for you." },
{ code: "blank line", text: "A cut to a new shot. No blank lines means one take." },
{ code: "\"in quotes\"", text: "Said out loud, word for word." },
{ code: "sound:", text: "On its own line: what the clip sounds like. Same as the Sound box." },
{ code: "music:", text: "On its own line: the music. Music makes the clip louder." },
{ code: "camera:", text: "On its own line: how the camera moves, like push in or handheld." },
{ code: "look:", text: "On its own line: a style, like anime or watercolour." },
{ code: "no cuts", text: "Anywhere in the words: keep it all one shot." },
];
const LABELS = [
{ word: "sound", hint: "what the clip sounds like" },
{ word: "music", hint: "the music, makes it louder" },
{ word: "camera", hint: "how the camera moves" },
{ word: "look", hint: "a style for the whole clip" },
];
const starts = (value, typed) => value.toLowerCase().startsWith(typed.toLowerCase());
// What to offer at the caret, or null.
// pictures: [{ number, job, plugged }] for @1 to @4
// cameras, looks: the choices the node knows, for camera: and look: lines
// seconds: the clip's length, so a time past the end isn't offered
// The answer replaces text[from, to) with an item's insert. `strong` means
// the person typed a trigger (@, camera:, look:), so Enter may take an item;
// a guess from the first letters of a line only takes Tab or a click, so
// Enter still starts a new line.
export function suggestAt(text, caret, { pictures = [], cameras = [], looks = [], seconds = 8 } = {}) {
const value = String(text ?? "");
const before = value.slice(0, caret);
const line = before.slice(before.lastIndexOf("\n") + 1);
const at = before.match(/(?:^|[^\w@])@(\d?)$/);
if (at) {
const typed = at[1];
const plugged = pictures.filter((picture) => picture.plugged);
const offer = (plugged.length ? plugged : pictures).filter((picture) => String(picture.number).startsWith(typed));
if (!offer.length) return null;
return {
from: caret - typed.length - 1,
to: caret,
strong: true,
items: offer.map((picture) => ({
label: `@${picture.number}`,
insert: `@${picture.number}`,
hint: picture.plugged ? picture.job || "" : "not plugged in",
number: picture.number,
})),
};
}
const choice = line.match(/^\s*(camera|look)\s*:\s*([\w ]*)$/i);
if (choice) {
const typed = choice[2];
const list = choice[1].toLowerCase() === "camera" ? cameras : looks;
const offer = list.filter((name) => starts(name, typed) && name.toLowerCase() !== typed.toLowerCase());
if (!offer.length) return null;
return { from: caret - typed.length, to: caret, strong: true, items: offer.map((name) => ({ label: name, insert: name, hint: "" })) };
}
const time = line.match(/^(\s*)(\d{1,2})$/);
if (time) {
const second = Number(time[2]);
if (second < 1 || second >= seconds) return null;
return {
from: caret - time[2].length,
to: caret,
strong: false,
items: [{ label: `${second}s:`, insert: `${second}s: `, hint: `this moment starts ${second} seconds in` }],
};
}
const word = line.match(/^(\s*)([a-z]{2,6})$/i);
if (word) {
const offer = LABELS.filter((label) => starts(label.word, word[2]) && label.word !== word[2].toLowerCase());
if (!offer.length) return null;
return {
from: caret - word[2].length,
to: caret,
strong: false,
items: offer.map((label) => ({ label: `${label.word}:`, insert: `${label.word}: `, hint: label.hint })),
};
}
return null;
}
+86
View File
@@ -0,0 +1,86 @@
// Colours in H3 Prompt Writer's script box: the words the node reads as
// instructions stand out while you type. @1 is a picture, 3s: is a time, a
// blank line is a cut, sound: and music: lines go to their own part, and
// words in quotes are spoken.
//
// The real textarea stays on top with see-through text, so typing, undo,
// copy and the caret are the browser's own. A copy of the text sits under it
// with the colours. Only colours and backgrounds change, never the font or
// the spacing, so both lay out the same lines.
import { scriptMarkup } from "./script_colours_math.mjs";
const CSS_ID = "ausboss-script-colours-css";
function ensureCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-colours-host{position:relative}
.ausboss-colours-host>:not(.ausboss-colours):not(textarea){display:none !important}
.ausboss-colours{position:absolute;inset:0;box-sizing:border-box;margin:0;overflow:hidden;white-space:pre-wrap;overflow-wrap:break-word;word-break:normal;pointer-events:none;color:#d8ecea;border:1px solid transparent;border-radius:6px;background:#0b0f10}
.ausboss-colours-host>textarea.ausboss-colours-on{position:relative;background:transparent !important;color:transparent !important;caret-color:#d8ecea;-webkit-text-fill-color:transparent}
.ausboss-colours-host>textarea.ausboss-colours-on::placeholder{color:#4f6462;-webkit-text-fill-color:#4f6462}
.ausboss-colours-host>textarea.ausboss-colours-on::selection{background:rgba(0,180,170,.35)}
.ausboss-colours .at{border-radius:3px;background:rgba(0,180,170,.3);box-shadow:0 0 0 1px rgba(0,180,170,.45);color:#a5f3ec}
.ausboss-colours .at.unknown{background:rgba(245,181,61,.18);box-shadow:0 0 0 1px rgba(245,181,61,.5);color:#f5c76b}
.ausboss-colours .time{color:#f5b53d}
.ausboss-colours .key{color:#6f8886}
.ausboss-colours .say{color:#f0dcae}
.ausboss-colours .cut{display:inline-block;width:100%;background:linear-gradient(transparent calc(50% - 1px),#2f4a48 calc(50% - 1px),#2f4a48 50%,transparent 50%);position:relative}
.ausboss-colours .cut::after{content:"cut";position:absolute;right:0;top:0;padding-left:4px;background:#0b0f10;color:#56706d;font-size:9px;letter-spacing:.1em;text-transform:uppercase}
`;
document.head.append(style);
}
// The box's own text settings, copied so the coloured copy wraps the same.
const COPIED = ["fontFamily", "fontSize", "fontWeight", "fontStyle", "lineHeight", "letterSpacing", "wordSpacing", "tabSize", "textIndent", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth"];
export function attachScriptColours(node) {
const none = { sync: () => {}, dispose: () => {} };
const area = node.__ausbossCard?.root?.querySelector?.("textarea.ausboss-card-area");
if (!area?.parentElement) return none;
ensureCss();
const host = area.parentElement;
const layer = document.createElement("div");
layer.className = "ausboss-colours";
layer.setAttribute("aria-hidden", "true");
host.classList.add("ausboss-colours-host");
host.insertBefore(layer, area);
area.classList.add("ausboss-colours-on");
// Another pack that colours every text box puts its own copy beside the
// box; the rule above hides it, so the text never shows twice.
area.dataset.ausbossColoured = "true";
let wiredNumbers = new Set([1, 2, 3, 4]);
const draw = () => {
const style = getComputedStyle(area);
for (const key of COPIED) layer.style[key] = style[key];
// A scroll bar takes width from the text; the copy gives up the same.
const bar = area.offsetWidth - area.clientWidth - (parseFloat(style.borderLeftWidth) || 0) - (parseFloat(style.borderRightWidth) || 0);
layer.style.paddingRight = `${(parseFloat(style.paddingRight) || 0) + Math.max(0, bar)}px`;
layer.innerHTML = scriptMarkup(area.value, (number) => wiredNumbers.has(number));
layer.scrollTop = area.scrollTop;
};
const onScroll = () => { layer.scrollTop = area.scrollTop; };
area.addEventListener("input", draw);
area.addEventListener("scroll", onScroll);
const observer = typeof ResizeObserver === "function" ? new ResizeObserver(draw) : null;
observer?.observe(area);
queueMicrotask(draw);
return {
area,
layer,
// values: the node's widget values; numbers: the pictures plugged in.
sync(values, numbers) {
if (numbers) wiredNumbers = new Set(numbers);
if (document.activeElement !== area && typeof values?.script === "string" && area.value !== values.script) area.value = values.script;
draw();
},
dispose() {
observer?.disconnect();
area.removeEventListener("input", draw);
area.removeEventListener("scroll", onScroll);
},
};
}

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