Compare commits

...
92 Commits
Author SHA1 Message Date
ausboss 39a7599fd9 Merge pull request #339 from ausboss/release/2.25.0
Release 2.25.0
2026-11-12 12:14:52 -06:00
ausboss c4e519565b Release 2.25.0 2026-11-12 12:07:14 -06:00
ausboss f18b8f6ca3 Merge pull request #327 from ausboss/feat/load-image
Load Image: a loader that also sets the size
2026-11-12 11:54:26 -06:00
ausboss 64ab3e9761 Load Image: crop from a side, crop without resizing, and a picture preview
- Keep (crop_from) picks the part of the picture a crop keeps: the
  center, a side or a corner. Crop to fill used to cut from the middle
  only.
- Two more Size choices that only cut and never resize: crop takes a
  width x height piece, and crop to ratio takes the largest piece of a
  shape such as 16:9. Ratio lists 13 shapes, or takes the shape from
  the width and height boxes. Round to rounds these down so the piece
  stays inside the picture.
- Two small pictures on the node show the file going in, with the kept
  part lit and the cut part dark, and the picture coming out, both on
  one scale. Between them the node names the shape and what happens
  ("3:4 -> 1:1", "77% size", "cuts 449 px of height"). A click on the
  first picture picks the part to keep.

The browser repeats the new arithmetic, and the shared table of cases
(now 469) holds it to the backend's answers.
2026-11-12 11:33:12 -06:00
ausboss 065f2cb14c Merge main: Load Image stays in Unreleased above the 2.24.0 section 2026-11-12 10:37:50 -06:00
ausboss 3d99ddd35a Merge pull request #338 from ausboss/fix/preview-slow-link
Save Image: a small picture on the node, a Save row, and no stale picture on a slow link
2026-11-12 10:37:38 -06:00
ausboss 224b4af1da Merge remote-tracking branch 'origin/main' into fix/preview-slow-link
# Conflicts:
#	CHANGELOG.md
2026-11-12 10:36:36 -06:00
ausboss 0a7177b151 Merge pull request #337 from ausboss/feat/callout-info-card
Callout: Info card style with a title, rows and small print
2026-11-12 10:34:40 -06:00
ausboss b31561d1a0 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	CHANGELOG.md
2026-11-12 10:33:54 -06:00
ausboss ba4ab486c3 Merge pull request #336 from ausboss/feat/clear-vram-menu
Clear VRAM on the canvas right-click menu
2026-11-12 10:33:34 -06:00
ausboss 4603256ebf Save Image: a row over held pictures with a solid Save button
With Auto save off the only way to save was a small SAVE label in the
corner of the preview bar, easy to miss.

A row between the bar and the picture now says "Not saved yet" and holds
a solid button: Save picture, Save all 4, Save this one, Save the rest.
After a save it says what is saved, with the file's name for one picture.
The row shows only while the node holds pictures, takes its own room so
the picture keeps its floor, and stays with the preview off.

The button is primaryAction in the shared panel controls; the wording is
keepWords, with a test.
2026-11-12 10:24:27 -06:00
ausboss 52b23691dc Save Image shows a small copy of each picture on its face
The node's face loaded the whole saved file: 31 MB for a 9232 x 4608
sheet, where the panel draws a few hundred pixels of it. On a 2.8 MB/s
link that took half a minute.

Save Image now writes a WebP copy of each picture, at most 2048 pixels on
the long side, into the temp folder (ausboss_preview) and names them in
its result. The panel shows that copy and goes back to the file itself
when the copy is gone, as after a restart. The queue still lists the
saved file, and preview off writes no copy.

Same link, same picture: the new picture shows 0.3 s after the run
(0.2 MB), where it took 32.6 s.
2026-11-12 10:13:23 -06:00
ausboss df773658da Save Image shows the new picture when it loads slowly
The frontend loads a node's result into node.imgs with a time limit: two
tries of about 8 seconds. A picture that takes longer is dropped and
node.imgs keeps the run before. The preview panel preferred node.imgs, so
a 31 MB sheet over a 2.8 MB/s link never showed; the panel stayed on the
older picture.

The panel now notes which output record node.imgs was loaded for and uses
the stored output when that is not the current one. While a new picture
loads, the one before dims after a moment.
2026-11-12 10:01:38 -06:00
ausboss d4636bb5e3 Callout: Info card style with a title, rows and small print
Right-click a Callout and pick Info card for a small card: the first line
is its title (a clock, info, warning, bulb or tick emoji at its start is
drawn as a line icon), a line with a | is a row with the label on the left
and the value on the right, a row in bold is drawn in teal, and small
print is a grey line. An empty note starts with sample text.

The card look is a node property and the text is the note's own text, so
an Info card saves as a plain Callout: an older pack shows the same text
as a note with its rows written out. A plain callout never reads | as a
row, so no note written before changes.
2026-11-12 08:21:31 -06:00
ausboss 83f59cac93 Clear VRAM on the canvas right-click menu, with a setting to hide it
Right-clicking the empty canvas offers Clear VRAM, which sends ComfyUI's
own free request (unload models and free memory) and shows a toast with
the result. The setting AusBoss.Memory.ClearVramMenu (on by default)
hides the entry, and so does ComfyUI's Comfy.Memory.AllowManualUnload.
2026-11-12 07:19:25 -06:00
ausboss 431941fc9d Merge pull request #335 from ausboss/release/2.24.0
Release 2.24.0
2026-11-12 00:59:02 -06:00
ausboss f5eab116c0 Release 2.24.0 2026-11-12 00:58:15 -06:00
ausboss 6008d31653 Merge pull request #334 from ausboss/fix/save-image-one-row
Save Image: Auto save shares the Embed workflow row
2026-11-12 00:29:19 -06:00
ausboss 434817bc76 Test: Save Image's settings card keeps its height 2026-11-12 00:28:26 -06:00
ausboss cdd98b13a3 Save Image: Auto save shares the Embed workflow row, so the node is no taller than before 2026-11-12 00:09:16 -06:00
ausboss f9683347be Merge pull request #331 from ausboss/fix/note-popup-outside-click
Workflow Note popup closes on a click outside it
2026-11-11 23:17:25 -06:00
ausboss a2853a0ae1 Merge pull request #332 from ausboss/feat/prompt-variations
New node: Prompt Variations
2026-11-11 23:17:06 -06:00
ausboss b2eb11996b Merge main: FlashVSR Upscale and Prompt Variations share the zh locale file 2026-11-11 23:11:35 -06:00
ausboss 36fcf9db4f Merge pull request #333 from ausboss/feat/flashvsr
Add FlashVSR Upscale: a video upscale node with one model file
2026-11-11 23:08:46 -06:00
ausboss cb21a14424 Add FlashVSR Upscale: a video upscale node with one model file
Makes a video larger and sharper with the FlashVSR v1.1 model, from 1x to
4x. The model, the loop and the tiling are the pack's own code in plain
PyTorch, so the node needs one model file and no other pack. A long clip
needs no more graphics memory than a short one, and a result too large for
the free memory is made in tiles.

Comes with a help page, the example workflow FlashVSR Video Upscale, and
tests for the helpers.
2026-11-11 22:49:17 -06:00
ausboss f9ca96f648 Prompt Variations node: pick one choice from each {a|b|c} set by seed
A prompt box that changes on every run. Choices in curly brackets are
picked on the server from the wired seed, so the same seed gives the same
prompt again and a randomized seed with a run count of 50 gives 50 mixes.
Brackets without a | are left alone, so JSON prompts pass through.
2026-11-11 22:17:53 -06:00
ausboss 2d7dd74fe2 Workflow Note popup closes on a click outside it
The info tile's popup is a modal dialog that only Close or Escape put
away. A press and release both on the dark area around the card now close
it; a click, text selection or drag that starts on the card keeps it open.
The Edit form already asks before an outside click drops a change.
2026-11-11 21:45:20 -06:00
ausboss 8a379940bd Merge pull request #330 from ausboss/feat/save-image-hold
Save Image: show every picture of a run, and save on a click
2026-11-11 21:03:46 -06:00
ausboss a87ffb84b7 Merge main: Text Lines and the Save Image entries share one changelog section 2026-11-11 19:56:28 -06:00
ausboss 89bda6ba11 Merge pull request #329 from ausboss/feat/text-lines-node
Text Lines: run what comes after once for each line of text
2026-11-11 19:56:16 -06:00
ausboss e191f0fc40 Save Image: SAVE ALL keeps every picture of a list run
A list run saves once for each item, so one result holds several held
runs. The button used only the first, and kept one picture of six.
2026-11-11 19:52:41 -06:00
ausboss 090dcdad62 Save Image can wait for a click before it saves
Auto save off: a run shows its pictures on the node and saves nothing.
SAVE ALL keeps every picture, SAVE keeps the one the arrows picked.
Held pictures wait in the temp folder and are copied to the output
folder under the names the node would have given them.
2026-11-11 19:44:19 -06:00
ausboss 47d1c4f739 Text Lines: run what comes after once for each line of text
A new node. Type a list of prompts, one on each line, or wire in a list
another node wrote. Empty lines are skipped, a number or bullet at the
start of a line can be taken off, and a limit stops a list that ran long.
2026-11-11 19:41:41 -06:00
ausboss a92017cff4 Save Image shows every picture of a run at once
A run that saved several pictures showed one, with arrows to step
through them. They now show side by side, as the core Save Image does.
The arrows go on to each picture by itself and round again to all of
them. The other preview nodes show a batch the same way.
2026-11-11 19:41:27 -06:00
ausboss cfbc42799a Merge main so Load Image and Edit Lock share one changelog section 2026-11-11 17:08:23 -06:00
ausboss 799d731ab5 Merge pull request #328 from ausboss/feat/edit-lock
New nodes: Edit Lock and Edit Lock Restore
2026-11-11 17:04:37 -06:00
ausboss 5fc8cffc9f Edit Lock help: rain together with a clear local change 2026-11-11 16:09:23 -06:00
ausboss 4dffaf3991 Edit Lock: rain, snow and haze come through whole
The node locked from the first step, where rain or snow over the whole
picture reads as a small local edit, so the effect was held back.

It now watches the first steps, undoing only the model's zoom and shift,
and decides once the picture has formed. When no place has changed
clearly, or the lock would free a wide part of the picture that holds no
clear change, it puts nothing back and only keeps the picture in place.
Local edits lock from that step on and end on the original as before.
2026-11-11 16:00:12 -06:00
ausboss bc474f1532 Edit Lock docs: when always is the wrong pick
An edit that also changes the light on the face (a new hair color on a
close-up) looks better plain: always keeps the old light on everything it
keeps. The README's chain numbers are from a rerun on a seed where the
model makes all five edits.
2026-11-11 14:15:08 -06:00
ausboss c206d9e9da Edit Lock: an edit leaves the rest of the picture where it was
Two new nodes. Edit Lock sits between the model and the sampler, reads
what the edit changes from the model's own prediction at every step, and
puts the original back everywhere else. Edit Lock Restore swaps the
original's pixels in for those parts after the decode.

The zoom and shift a model adds to the whole picture are measured and
discounted before anything is compared, and an even tint is taken out.
An edit of the whole picture is left alone.

On MiniMax-H3-Image at 4 megapixels, 18 local edits at two seeds each
moved by up to 14.5 px plain and by 0.12 px or less locked.
2026-11-11 00:59:25 -06:00
ausboss b4657e2b58 Load Image: a loader that also sets the size
A new node, Load Image. It loads a picture and sets its size in the
same node, so a workflow needs no resize node and no size node after
its loader.

- Size: off, megapixels, longest side, scale, fit inside, or crop to
  fill. Each choice shows only the numbers it uses. Round to rounds
  the result to a multiple. Under More: the resize method, and Enlarge,
  which when off only ever makes a picture smaller.
- Arrows under the file picker step through the input folder, with
  the picture's place in the list beside them.
- The node shows the size going in and the size coming out before a
  run. The browser repeats the backend's arithmetic, and a table of
  252 cases holds the two to the same answers.
- Outputs: image, mask, width, height, filename, original_width,
  original_height. The mask is the picture's transparency, as the
  stock loader gives it, at the new size.
- The picture on the node is ComfyUI's own preview, so the mask editor
  and clipspace work as they do on the stock loader.
- With Size off the pixels are the file's own.

Mask by Touch and the run check know the new loader as a source.
2026-11-10 17:42:00 -06:00
ausboss eba880cdee Merge pull request #326 from ausboss/feat/touch-feedback 2026-11-10 14:50:45 -06:00
ausboss b6a67a4c34 Mask by Touch: take away by name, big pictures, a quiet queue
Four things people ran into while picking a mask:

- Ctrl-click on a part of a picked thing could take the whole thing
  away. With only a sweater picked, a Ctrl-click on the face offered
  the person, which holds the sweater, and the pick was gone. From a
  spot that is not picked, a piece that holds a whole pick is no longer
  offered; the picture says nothing is picked there.
- A name could only be taken away while Erase was chosen. The name
  field now has a + / - button, and a name typed with a minus in front
  ("-face") is taken away whichever tool is chosen.
- SAM 3 answers at the size of the picture it is given, so a look at a
  3840 px picture took 53 s and an 8192 px one ran a 32 GB card out of
  memory. A picture with a long side over 2048 px is now asked about on
  a smaller copy, and the answer comes back at the picture's own size:
  31 s and 33 s. Pictures up to 2048 px are asked about as before.
- Each change on the loader in front of the node sent one more look and
  left the earlier ones waiting. The node's own waiting looks are now
  taken out of the queue before a new one is sent.

The button that pauses the look now says Pause or Look on, and an out
of memory stop says so in plain words.
2026-11-10 14:49:34 -06:00
ausboss 27525ffdd8 Merge pull request #325 from ausboss/fix/explain-known-errors
Say what to do for two run errors that come from outside the workflow
2026-11-09 16:21:15 -06:00
ausboss eb668c448c Merge pull request #324 from ausboss/fix/two-copies
Start ComfyUI when the pack is installed twice, an older copy included
2026-11-09 16:21:05 -06:00
ausboss 9b3cd7c769 Merge the two-copies fix so both changes share one changelog section 2026-11-09 16:19:53 -06:00
ausboss 13c303178b Known run errors: shorter messages 2026-11-09 16:03:33 -06:00
ausboss f945feabb6 Say what to do for two run errors that come from outside the workflow
A MiniMax H3 run that keeps a clip's sound stops with KeyError: 'latent'
when the Muse MiniMax Director pack is installed, and an older ComfyUI
stops on a w6a8 model file. Neither message tells a person what to do,
so the run check now shows one toast for each with the cause and the fix.
2026-11-09 16:00:23 -06:00
ausboss 24ce4ffb06 README: install the pack one way, not both 2026-11-09 15:51:11 -06:00
ausboss 980ed5db8b One-copy check: only look in ComfyUI's custom_nodes folders 2026-11-09 15:27:28 -06:00
ausboss bf59887643 One-copy check: look beside the folder ComfyUI loaded, not its link target 2026-11-09 15:23:12 -06:00
ausboss 60be0ee865 Start ComfyUI when the pack is installed twice
A Manager install (custom_nodes/ausboss-nodes) beside a git clone
(custom_nodes/ComfyUI-AusBoss) added every route twice and ComfyUI
stopped while starting. Each copy now looks for the others, one loads
and the rest step aside and name the folder to delete.
2026-11-09 15:22:28 -06:00
ausboss 146e3cfa8b Merge pull request #322 from ausboss/ci/publish-downloader-note
Publish Model Downloader: take the release note as an input
2026-11-09 00:35:55 -06:00
ausboss e22ddb90d5 Publish Model Downloader: take the release note as an input
The job carried the 0.1.0 note as a fixed string, so every later
version would have shown "Initial release" on the Registry. The note is
now a required input of the manual run, and the publish step refuses a
blank one.
2026-11-09 00:34:40 -06:00
ausboss db9a033f11 Merge pull request #318 from ausboss/release/2.22.1
Release 2.23.0
2026-11-09 00:16:53 -06:00
ausboss 442656c712 Release 2.23.0: date it today 2026-11-09 00:16:06 -06:00
ausboss b17f8c0355 Release 2.23.0: add the Color Match and Krea 2 Outpaint fixes 2026-11-08 23:56:09 -06:00
ausboss 92a4ae8c56 Merge remote-tracking branch 'origin/main' into release/2.22.1 2026-11-08 23:53:56 -06:00
ausboss 51a2aebf40 Merge pull request #314 from ausboss/fix/upscale-review
Color Match leaves a picture alone when matched to itself
2026-11-08 23:53:09 -06:00
ausboss 28fbd321c7 Release 2.23.0
Mask by Touch keeping picks on a new picture is a new feature, so this
release is 2.23.0 rather than 2.22.1. It also brings the stopped-run
message fix and the corrected example notes.
2026-11-08 23:48:28 -06:00
ausboss 144884fae3 Merge remote-tracking branch 'origin/main' into release/2.22.1 2026-11-08 23:45:59 -06:00
ausboss 935741b43d Merge pull request #321 from ausboss/fix/touch-stop-note
Mask by Touch: clear a stopped run's message once a run works
2026-11-08 23:45:48 -06:00
ausboss c1b620d448 Mask by Touch: clear a stopped run's message once a run works
A run that stopped at the node left its message on the face until
something else was said there. The face's own runs cleared it, but a
workflow run's result did not, so the message stayed under a good result.
The stop now marks its message, and the next result that comes in
clears it.
2026-11-08 23:44:55 -06:00
ausboss 4b1dc7ccb7 Merge pull request #320 from ausboss/fix/example-notes-text
Example notes: say what the graph does
2026-11-08 23:42:05 -06:00
ausboss c963657b6e Merge pull request #319 from ausboss/feat/touch-keep-picks
Mask by Touch: keep the picks when the picture changes
2026-11-08 23:41:52 -06:00
ausboss 5b4651df45 Mask by Touch: keep the picks when the picture changes
A different picture coming in no longer stops the run with every pick
dropped. The node moves the clicks and paint onto it, in the same places,
scaled from the size in their key to the new picture's size, and sends the
face the moved touches. The face takes them while it still holds the picks
they were moved from. New picture, under More, set to start over keeps the
old behaviour.
2026-11-08 23:29:49 -06:00
ausboss 1b1fca961c Example notes: say what the graph does
- Krea 2 Rotate + Outpaint: the note describes the tone match the
  graph runs.
- Qwen Image 2.1 Inpaint: the note says the seed is fixed.
- Qwen Image 2.1 Upscale + Restore: the Megapixels note mentions the
  roughly 2 MP minimum redraw.
2026-11-08 23:15:13 -06:00
ausboss 1568a56874 Release 2.22.1 2026-11-08 23:03:22 -06:00
ausboss 989a0e32d6 Merge pull request #317 from ausboss/fix/lifted-socket-overlap
Fix lifted inputs drawn on top of the first input in saved workflows
2026-11-08 23:01:01 -06:00
ausboss 2ce4d7c9f0 Merge pull request #313 from ausboss/fix/preview-review
Compare, preview bar and Save Image ignore late replies and stale paths
2026-11-08 22:59:56 -06:00
ausboss 25b293513e Merge remote-tracking branch 'origin/main' into fix/preview-review
# Conflicts:
#	tests/test_frontend_cache.py
2026-11-08 22:58:44 -06:00
ausboss a5190b3c6b Merge pull request #316 from ausboss/fix/switches-review
Workflow Switches: a settings switch keeps its node running
2026-11-08 22:58:12 -06:00
ausboss 9c58b5a9ed Merge pull request #315 from ausboss/fix/scripts-review
Offline checks read pyproject as TOML and catch more hidden mappings
2026-11-08 22:58:03 -06:00
ausboss 8d38e53b3a Merge pull request #312 from ausboss/fix/inpaint-review
Crop for Inpaint keeps the whole feather inside the crop
2026-11-08 22:57:54 -06:00
ausboss ef6b10a380 Merge pull request #311 from ausboss/fix/folder-review
Image Folder: fresh results on folder change, newest files kept
2026-11-08 22:57:45 -06:00
ausboss 3f7759aa79 Merge pull request #310 from ausboss/fix/canvas-review
Keep node colours, snap corner resizes and show About in Nodes 2.0
2026-11-08 22:57:41 -06:00
ausboss 95b69ce4af Fix lifted inputs drawn on top of the first input in saved workflows
Newer ComfyUI stamps a widget's input with its row position (slot.pos) and keeps using it after the widget is gone, so fixed_frames on Video Crop + Rotate + Pad -> Clip landed on force_rate. liftSocket now clears it; Resolution uses the same helper.
2026-11-08 22:21:34 -06:00
ausboss 28340ded7e Count the newer COMBO input format as a widget in the example check
ComfyUI reports newer dropdowns as "COMBO" instead of a list of choices,
so saved values for them were ignored and four examples were flagged as
missing required inputs they do have.
2026-11-08 21:18:49 -06:00
ausboss da3072a7de Workflow Switches: a settings switch keeps its node running
Review fixes for Workflow Switches.

- A switch that changes a setting on a node no longer turns that node
  off.
- LoRA choices keep every differing field and show row and group locks.
- Odd ids or titles in a saved workflow no longer break loading, and
  unsafe group colours are handled.
- The add-choice button appears as soon as a node is selected.
- Copy clones no longer leak redraw timers.

Tests: tests/workflow_switches_frontend.test.mjs and
tests/workflow_switches_regressions.test.mjs, with additions to
tests/workflow_switches.test.mjs.

Not covered: repointing switches inside copied subgraphs. The review
wrote a test for it, but nothing delivers per-subgraph id maps yet, so
that test is left out.

No inputs, outputs or mapping keys change.
2026-11-08 19:35:10 -06:00
ausboss 475d6e19ea Offline checks read pyproject as TOML and catch more hidden mappings
Review fixes for the pack's offline checks.

- The startup version reader and the checks read pyproject.toml as TOML
  instead of a regex.
- validate_nodes and registry_contract catch more ways a node mapping
  or the module list could be hidden from registry scanners.
- release_preflight flags network-client imports and spaced or
  qualified fetch calls in shipped code.
- workflow_contract verifies saved widget values against the real
  widget order, covers subgraphs and validates model download info.
- Krea 2 Inpaint Masked example: two Note nodes' named widget values
  now match their positional values, which the new check requires, and
  the file ends with a newline. The graph itself is unchanged.

Tests: tests/test_check_regressions.py, with one adjusted case in
tests/test_workflow_contract.py.

No inputs, outputs or mapping keys change. No version change.
2026-11-08 19:33:20 -06:00
ausboss b3df628a60 Color Match leaves a picture alone when matched to itself
Review fixes for the colour helpers and the Krea 2 core.

- Color Match (MKL and histogram) returns the image unchanged when the
  reference is the image itself or a same-colour reference of another
  size.
- Krea 2 outpaint stops reusing a stale reference cache: the cache is
  keyed by reference and canvas and cleared on in-place edits and at
  sampler start and cleanup.
- Other model patches (attention hooks) now apply inside the Krea 2
  outpaint path, in order, with their metadata.

Tests: tests/test_color_match.py gains the identity cases;
tests/test_krea2_core.py covers the cache and the patch order on the
CPU.

Color Match numbers change slightly for the same inputs. No inputs,
outputs or mapping keys change.
2026-11-08 19:27:31 -06:00
ausboss 2a2b31721b Compare, preview bar and Save Image ignore late replies and stale paths
Review fixes for the preview-carrying nodes.

- Compare and the input preview bar no longer rebuild their panel when
  a workflow loads, and a late Align or Save reply is ignored after the
  panel is removed or the picture changes.
- The preview bar clears its picture when the preview switch is off
  and works when two nodes watch the same source.
- Save Image forgets the old saved path when the folder changes or a
  run saves nothing, and can no longer pick the same file number as
  Compare at the same time.
- Save Image accepts bfloat16 and single-channel images.
- A blank linked caption writes no caption file, and a caption file
  that already exists follows the "on existing" setting.

Tests: tests/preview_lifecycle.test.mjs covers the panel and reply
cases; tests/test_image_save_helpers.py covers the image types, the
numbering and the caption collisions; two cache tests now call the
middleware directly instead of starting a test server.

Caption behaviour changes as described above. No inputs, outputs or
mapping keys change.
2026-11-08 19:25:24 -06:00
ausboss 68a36d99e1 Crop for Inpaint keeps the whole feather inside the crop
Review fixes for the inpaint crop and stitch helpers.

- The crop window now contains the full feathered edge, so the blend at
  the seam is complete.
- Outpaint extension bands stay fully paintable after the mask is
  shrunk or softened.
- The reported blend mask no longer extends past the area actually
  pasted.
- The Crop for Inpaint, Stitch Inpaint and Realign to Source help pages
  are corrected (Multiple default, Max zoom wording, resizing caveat).

Tests: tests/test_inpaint_crop_helpers.py covers the feather, the
bands and the partial paste window; tests/inpaint_help.test.mjs checks
the help pages against the code; the classic pin test keeps comparing
against the released output.

Crops and blend masks differ slightly from earlier releases for the
same inputs. No inputs, outputs or mapping keys change.
2026-11-08 19:23:10 -06:00
ausboss f4d42734fe Image Folder: fresh results on folder change, newest files kept
Review fixes for the Image Folder node and its routes.

- Switching folders or reloading a workflow no longer shows stale or
  mixed-up results: late replies are ignored, copy and upload locks
  hold, and removing a node closes only its own popups.
- A very large folder keeps its newest files instead of an arbitrary
  slice.
- Thumbnails refresh after a picture is rewritten in place.
- A run over more than 64 pictures marks every picture as done.
- Route errors no longer reveal server paths.

Tests: tests/image_folder_lifecycle.test.mjs and additions to
tests/test_image_folder.py cover each case; the frontend cache and
media upload tests now call the handlers directly instead of starting a
local server.

A run's completion note carries an extra context field and lists every
loaded picture. No inputs, outputs or mapping keys change.
2026-11-08 19:21:09 -06:00
ausboss 7d9e972f77 Keep node colours, snap corner resizes and show About in Nodes 2.0
Review fixes for the canvas extensions.

- A node colour picked from the right-click menu, Theme default
  included, now stays after save, reload and paste. An invalid Custom
  colour no longer overwrites the last good one.
- About this node appears in the right-click menu in Nodes 2.0, where
  the ? badge does not work.
- Resizing a node by its corner in Nodes 2.0 snaps to nearby lines;
  Shift bypasses the snap.
- A plain click on a resize handle no longer resizes the other selected
  nodes in Resize Together.
- Sideways mouse-wheel scrolling stays inside a code block that can
  scroll sideways.

Tests: tests/canvas_extensions.test.mjs runs the real extension code
against a fake page for each case above; tests/canvas_passthrough.test.mjs
gains the horizontal-wheel case.

Appearance now saves the chosen colour as a node property (ausbossColor).
No inputs, outputs or mapping keys change.
2026-11-08 19:18:28 -06:00
ausboss a6e2b65e97 README: add Hugging Face and X badges (#309) 2026-11-07 18:14:05 -06:00
ausboss d513723bb0 Release 2.22.0 (#306) 2026-11-07 18:12:07 -06:00
ausboss 9f8bf4d803 Calmer node cards and picture bar on one shared palette (#308)
* Restyle widget cards in solid gray on one shared palette

Cards, number boxes and off|on switches take their colors, font and radii
from js/shared/theme.mjs. Controls are solid gray with no outlines on a
near-black card, grays are neutral, faint text holds 4.5:1, and number boxes
in a card are as tall as the row. A test keeps colour literals out of the
three modules and checks text contrast.

* Lift the card toward the node colour and brighten the off mark

* Restyle the picture bar, its stepper and switch on the shared palette

* Darken the card a step

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

Before

Width:  |  Height:  |  Size: 425 B

After

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

@@ -0,0 +1,526 @@
{
"id": "0b455c1f-6b0c-4f69-8a7e-86f8de61df5c",
"revision": 0,
"last_node_id": 5,
"last_link_id": 4,
"nodes": [
{
"id": 1,
"type": "AUSBOSS_NODES_WorkflowNote",
"pos": [
0,
30
],
"size": [
440,
640
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "note",
"type": "STRING",
"link": null,
"widget": {
"name": "note"
}
}
],
"outputs": [],
"title": "🆎 AusBoss",
"properties": {
"cnr_id": "ausboss-nodes",
"ver": "2.24.0",
"Node name for S&R": "AUSBOSS_NODES_WorkflowNote"
},
"widgets_values": [
"{\"v\": 1, \"title\": \"FlashVSR Video Upscale\", \"subtitle\": \"Make a video larger and sharper with one node and one model file.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your video.\\n2. Set **Scale**: how many times larger each side gets. At 1 the video keeps its size and is only sharpened.\\n3. Press **Run**, then play the result in **Save Video**.\\n\\nTo try a short piece first, drag the right end of the bar under the player in Load Video.\\n\\nA long video needs no more graphics memory than a short one. A large result is made in tiles, so it fits the memory you have and takes longer.\\n\\nNeeds AusBoss nodes 2.24.0 or newer. No other pack is needed.\", \"models\": [{\"name\": \"flashvsr_v1.1_fp16.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"3.5 GB\", \"url\": \"https://huggingface.co/ausboss/FlashVSR-v1.1-ComfyUI/resolve/main/flashvsr_v1.1_fp16.safetensors\", \"note\": \"FlashVSR v1.1 by Junhao Zhuang and the OpenImagingLab team, in one file\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_FlashVSRUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}",
""
],
"widgets_values_named": {
"note": "{\"v\": 1, \"title\": \"FlashVSR Video Upscale\", \"subtitle\": \"Make a video larger and sharper with one node and one model file.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your video.\\n2. Set **Scale**: how many times larger each side gets. At 1 the video keeps its size and is only sharpened.\\n3. Press **Run**, then play the result in **Save Video**.\\n\\nTo try a short piece first, drag the right end of the bar under the player in Load Video.\\n\\nA long video needs no more graphics memory than a short one. A large result is made in tiles, so it fits the memory you have and takes longer.\\n\\nNeeds AusBoss nodes 2.24.0 or newer. No other pack is needed.\", \"models\": [{\"name\": \"flashvsr_v1.1_fp16.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"3.5 GB\", \"url\": \"https://huggingface.co/ausboss/FlashVSR-v1.1-ComfyUI/resolve/main/flashvsr_v1.1_fp16.safetensors\", \"note\": \"FlashVSR v1.1 by Junhao Zhuang and the OpenImagingLab team, in one file\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_FlashVSRUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}",
"ausboss_workflow_note_panel": ""
},
"color": "#14424d",
"bgcolor": "#161f21"
},
{
"id": 2,
"type": "AUSBOSS_NODES_LoadVideo",
"pos": [
500,
80
],
"size": [
420,
760
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "video",
"type": "COMBO",
"link": null,
"widget": {
"name": "video"
}
},
{
"name": "start_seconds",
"type": "FLOAT",
"link": null,
"widget": {
"name": "start_seconds"
}
},
{
"name": "end_seconds",
"type": "FLOAT",
"link": null,
"widget": {
"name": "end_seconds"
}
},
{
"name": "custom_width",
"type": "INT",
"link": null,
"widget": {
"name": "custom_width"
}
},
{
"name": "custom_height",
"type": "INT",
"link": null,
"widget": {
"name": "custom_height"
}
},
{
"name": "every_nth",
"type": "INT",
"link": null,
"widget": {
"name": "every_nth"
}
},
{
"name": "max_frames",
"type": "INT",
"link": null,
"widget": {
"name": "max_frames"
}
},
{
"name": "single_frame",
"type": "BOOLEAN",
"link": null,
"widget": {
"name": "single_frame"
}
},
{
"name": "upload",
"type": "IMAGEUPLOAD",
"link": null,
"widget": {
"name": "upload"
}
}
],
"outputs": [
{
"name": "frames",
"type": "IMAGE",
"links": [
1
]
},
{
"name": "audio",
"type": "AUDIO",
"links": [
3
]
},
{
"name": "frame_count",
"type": "INT",
"links": []
},
{
"name": "fps",
"type": "FLOAT",
"links": [
4
]
},
{
"name": "width",
"type": "INT",
"links": []
},
{
"name": "height",
"type": "INT",
"links": []
},
{
"name": "duration",
"type": "FLOAT",
"links": []
},
{
"name": "video",
"type": "VIDEO",
"links": []
}
],
"title": "Load Video - Your video",
"properties": {
"cnr_id": "ausboss-nodes",
"ver": "2.24.0",
"Node name for S&R": "AUSBOSS_NODES_LoadVideo",
"ausboss_load_video_loop": true
},
"widgets_values": {
"video": "",
"start_seconds": 0,
"end_seconds": 0,
"custom_width": 0,
"custom_height": 0,
"every_nth": 1,
"max_frames": 0,
"single_frame": false
},
"widgets_values_named": {
"video": "",
"start_seconds": 0,
"end_seconds": 0,
"custom_width": 0,
"custom_height": 0,
"every_nth": 1,
"max_frames": 0,
"single_frame": false,
"upload": "image",
"ausboss_widget_card": "",
"ausboss_load_video_viewer": ""
},
"color": "#14424d",
"bgcolor": "#161f21"
},
{
"id": 3,
"type": "AUSBOSS_NODES_FlashVSRUpscale",
"pos": [
980,
80
],
"size": [
360,
190
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "frames",
"type": "IMAGE",
"link": 1
},
{
"name": "vae",
"type": "VAE",
"shape": 7,
"link": null
},
{
"name": "model",
"type": "COMBO",
"link": null,
"widget": {
"name": "model"
}
},
{
"name": "scale",
"type": "FLOAT",
"link": null,
"widget": {
"name": "scale"
}
},
{
"name": "color_match",
"type": "BOOLEAN",
"link": null,
"widget": {
"name": "color_match"
}
}
],
"outputs": [
{
"name": "frames",
"type": "IMAGE",
"links": [
2
]
}
],
"title": "FlashVSR Upscale",
"properties": {
"cnr_id": "ausboss-nodes",
"ver": "2.24.0",
"Node name for S&R": "AUSBOSS_NODES_FlashVSRUpscale",
"models": [
{
"name": "flashvsr_v1.1_fp16.safetensors",
"url": "https://huggingface.co/ausboss/FlashVSR-v1.1-ComfyUI/resolve/main/flashvsr_v1.1_fp16.safetensors",
"directory": "diffusion_models"
}
]
},
"widgets_values": [
"flashvsr_v1.1_fp16.safetensors",
2,
true,
""
],
"widgets_values_named": {
"model": "flashvsr_v1.1_fp16.safetensors",
"scale": 2,
"color_match": true,
"ausboss_widget_card": ""
},
"color": "#14424d",
"bgcolor": "#161f21"
},
{
"id": 4,
"type": "AUSBOSS_NODES_SaveVideo",
"pos": [
1400,
80
],
"size": [
440,
640
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "frames",
"type": "IMAGE",
"link": 2
},
{
"name": "audio",
"type": "AUDIO",
"link": 3
},
{
"name": "video",
"type": "VIDEO",
"link": null
},
{
"name": "fps",
"type": "FLOAT",
"link": 4
},
{
"name": "filename_prefix",
"type": "STRING",
"link": null,
"widget": {
"name": "filename_prefix"
}
},
{
"name": "crf",
"type": "INT",
"link": null
},
{
"name": "format",
"type": "COMBO",
"link": null,
"widget": {
"name": "format"
}
},
{
"name": "pingpong",
"type": "BOOLEAN",
"link": null,
"widget": {
"name": "pingpong"
}
},
{
"name": "save_metadata",
"type": "BOOLEAN",
"link": null,
"widget": {
"name": "save_metadata"
}
}
],
"outputs": [
{
"name": "file_path",
"type": "STRING",
"links": []
}
],
"title": "Save Video",
"properties": {
"cnr_id": "ausboss-nodes",
"ver": "2.24.0",
"Node name for S&R": "AUSBOSS_NODES_SaveVideo",
"ausboss_save_video_loop": true
},
"widgets_values": {
"fps": 24,
"filename_prefix": "flashvsr_upscaled",
"crf": 19,
"format": "mp4 h264",
"pingpong": false,
"save_metadata": true
},
"widgets_values_named": {
"fps": 24,
"filename_prefix": "flashvsr_upscaled",
"crf": 19,
"format": "mp4 h264",
"pingpong": false,
"save_metadata": true,
"ausboss_widget_card": "",
"ausboss_save_video_viewer": ""
},
"color": "#14424d",
"bgcolor": "#161f21"
},
{
"id": 5,
"type": "AUSBOSS_NODES_RunTimer",
"pos": [
1515,
770
],
"size": [
210,
67
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"title": "Run Timer",
"properties": {
"cnr_id": "ausboss-nodes",
"ver": "2.24.0",
"Node name for S&R": "AUSBOSS_NODES_RunTimer"
},
"color": "#14424d",
"bgcolor": "#161f21"
}
],
"links": [
[
1,
2,
0,
3,
0,
"IMAGE"
],
[
2,
3,
0,
4,
0,
"IMAGE"
],
[
3,
2,
1,
4,
1,
"AUDIO"
],
[
4,
2,
3,
4,
3,
"FLOAT"
]
],
"groups": [
{
"id": 1,
"title": "1 · Your video",
"bounding": [
480,
0,
460,
880
],
"color": "#1d5560",
"flags": {}
},
{
"id": 2,
"title": "2 · Make it larger",
"bounding": [
960,
0,
400,
880
],
"color": "#1d5560",
"flags": {}
},
{
"id": 3,
"title": "3 · Result",
"bounding": [
1380,
0,
480,
880
],
"color": "#1d5560",
"flags": {}
}
],
"definitions": {
"subgraphs": []
},
"config": {},
"extra": {
"ds": {
"scale": 0.73,
"offset": [
137,
20
]
}
},
"version": 0.4
}
@@ -1913,7 +1913,7 @@
"The painted area, grown a little, is filled with flat gray, so Krea 2 knows where to paint. The plain crop goes along too."
],
"widgets_values_named": {
"text": "The painted area, grown a little, is filled with flat gray. Krea 2 never sees what you are replacing."
"text": "The painted area, grown a little, is filled with flat gray, so Krea 2 knows where to paint. The plain crop goes along too."
},
"color": "#1f2c38",
"bgcolor": "#2a3b4a"
@@ -3052,7 +3052,7 @@
"The gray-filled crop is the reference. Painting starts from the plain crop, inside the noise mask: the painted area, widened by 24 px."
],
"widgets_values_named": {
"text": "The gray-filled crop is the reference. The noise mask is the grown painted area, widened by 24 px. Krea 2 paints only inside it."
"text": "The gray-filled crop is the reference. Painting starts from the plain crop, inside the noise mask: the painted area, widened by 24 px."
},
"color": "#1f2c38",
"bgcolor": "#2a3b4a"
@@ -3482,4 +3482,4 @@
"frontendVersion": "1.53.6"
},
"version": 0.4
}
}
@@ -2139,10 +2139,10 @@
"title": "Note",
"properties": {},
"widgets_values": [
"Blend in fades the model's picture into yours just inside its edge, with no tone shift. The new area stays as painted."
"Blend in fades the model's picture into yours just inside its edge. Tone match is fully on, so the new area is adjusted to your picture's tone."
],
"widgets_values_named": {
"text": "Blend in fades the model's picture into yours just inside its edge, with no tone shift. The new area stays as painted."
"text": "Blend in fades the model's picture into yours just inside its edge. Tone match is fully on, so the new area is adjusted to your picture's tone."
},
"color": "#1f2c38",
"bgcolor": "#2a3b4a"
@@ -439,7 +439,7 @@
0,
0,
0,
true,
false,
"stop the run",
false,
false,
@@ -451,7 +451,7 @@
"grow": 0,
"soften": 0,
"smooth": 0,
"fill_holes": true,
"fill_holes": false,
"if_nothing": "stop the run",
"free_vram": false,
"invert": false,
@@ -37,10 +37,10 @@
"Node name for S&R": "AUSBOSS_NODES_WorkflowNote"
},
"widgets_values": [
"{\"v\": 1, \"title\": \"Qwen Image 2.1 Inpaint\", \"subtitle\": \"Click a thing in your picture and say what to change. Only that part is repainted.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"1. Load your image. You can crop it right there with the blue handles.\\n2. On **Mask by Touch**, click the thing you want to change. It lights up before you click.\\n3. In **What to change**, say it the way you would ask a person: \\\"Replace the cup with a glass of orange juice.\\\"\\n4. Press **Run**.\\n\\nOnly the picked part is repainted. Everything outside it stays your own picture.\\n\\n### Tips\\n- Wrong thing picked? The arrows under the picture swap it for the next thing it could mean.\\n- Name everything that should go: \\\"the iced coffee and its straw\\\". What you do not name can stay.\\n- To remove something, pick it and write \\\"Remove the cup.\\\" Pick its shadow too, or the shadow stays.\\n- To add something, switch to **Paint** and paint where it should go, with some room. Say how big: \\\"Add small black sunglasses on her eyes.\\\"\\n- Not what you wanted? Run it again. The seed changes each run.\\n\\n### Switches\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA from the list below. Off: 25 steps without it, about twice as long.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.12.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"sam3.1_multiplex_fp16.safetensors\", \"dir\": \"checkpoints\", \"size\": \"1.75 GB\", \"url\": \"https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors\", \"note\": \"by Meta, packaged by Comfy-Org\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"optional, for Fast · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MaskByTouch\"}], \"links\": [{\"label\": \"YouTube · this workflow\", \"url\": \"https://youtu.be/iMgaiXoB0v4\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
"{\"v\": 1, \"title\": \"Qwen Image 2.1 Inpaint\", \"subtitle\": \"Click a thing in your picture and say what to change. Only that part is repainted.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"1. Load your image. You can crop it right there with the blue handles.\\n2. On **Mask by Touch**, click the thing you want to change. It lights up before you click.\\n3. In **What to change**, say it the way you would ask a person: \\\"Replace the cup with a glass of orange juice.\\\"\\n4. Press **Run**.\\n\\nOnly the picked part is repainted. Everything outside it stays your own picture.\\n\\n### Tips\\n- Wrong thing picked? The arrows under the picture swap it for the next thing it could mean.\\n- Name everything that should go: \\\"the iced coffee and its straw\\\". What you do not name can stay.\\n- To remove something, pick it and write \\\"Remove the cup.\\\" Pick its shadow too, or the shadow stays.\\n- To add something, switch to **Paint** and paint where it should go, with some room. Say how big: \\\"Add small black sunglasses on her eyes.\\\"\\n- Not what you wanted? Change **Seed** and run it again. It stays fixed between runs.\\n\\n### Switches\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA from the list below. Off: 25 steps without it, about twice as long.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.12.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"sam3.1_multiplex_fp16.safetensors\", \"dir\": \"checkpoints\", \"size\": \"1.75 GB\", \"url\": \"https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors\", \"note\": \"by Meta, packaged by Comfy-Org\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"optional, for Fast · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MaskByTouch\"}], \"links\": [{\"label\": \"YouTube · this workflow\", \"url\": \"https://youtu.be/iMgaiXoB0v4\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
],
"widgets_values_named": {
"note": "{\"v\": 1, \"title\": \"Qwen Image 2.1 Inpaint\", \"subtitle\": \"Click a thing in your picture and say what to change. Only that part is repainted.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"1. Load your image. You can crop it right there with the blue handles.\\n2. On **Mask by Touch**, click the thing you want to change. It lights up before you click.\\n3. In **What to change**, say it the way you would ask a person: \\\"Replace the cup with a glass of orange juice.\\\"\\n4. Press **Run**.\\n\\nOnly the picked part is repainted. Everything outside it stays your own picture.\\n\\n### Tips\\n- Wrong thing picked? The arrows under the picture swap it for the next thing it could mean.\\n- Name everything that should go: \\\"the iced coffee and its straw\\\". What you do not name can stay.\\n- To remove something, pick it and write \\\"Remove the cup.\\\" Pick its shadow too, or the shadow stays.\\n- To add something, switch to **Paint** and paint where it should go, with some room. Say how big: \\\"Add small black sunglasses on her eyes.\\\"\\n- Not what you wanted? Run it again. The seed changes each run.\\n\\n### Switches\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA from the list below. Off: 25 steps without it, about twice as long.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.12.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"sam3.1_multiplex_fp16.safetensors\", \"dir\": \"checkpoints\", \"size\": \"1.75 GB\", \"url\": \"https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors\", \"note\": \"by Meta, packaged by Comfy-Org\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"optional, for Fast · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MaskByTouch\"}], \"links\": [{\"label\": \"YouTube · this workflow\", \"url\": \"https://youtu.be/iMgaiXoB0v4\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
"note": "{\"v\": 1, \"title\": \"Qwen Image 2.1 Inpaint\", \"subtitle\": \"Click a thing in your picture and say what to change. Only that part is repainted.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"1. Load your image. You can crop it right there with the blue handles.\\n2. On **Mask by Touch**, click the thing you want to change. It lights up before you click.\\n3. In **What to change**, say it the way you would ask a person: \\\"Replace the cup with a glass of orange juice.\\\"\\n4. Press **Run**.\\n\\nOnly the picked part is repainted. Everything outside it stays your own picture.\\n\\n### Tips\\n- Wrong thing picked? The arrows under the picture swap it for the next thing it could mean.\\n- Name everything that should go: \\\"the iced coffee and its straw\\\". What you do not name can stay.\\n- To remove something, pick it and write \\\"Remove the cup.\\\" Pick its shadow too, or the shadow stays.\\n- To add something, switch to **Paint** and paint where it should go, with some room. Say how big: \\\"Add small black sunglasses on her eyes.\\\"\\n- Not what you wanted? Change **Seed** and run it again. It stays fixed between runs.\\n\\n### Switches\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA from the list below. Off: 25 steps without it, about twice as long.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.12.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"sam3.1_multiplex_fp16.safetensors\", \"dir\": \"checkpoints\", \"size\": \"1.75 GB\", \"url\": \"https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors\", \"note\": \"by Meta, packaged by Comfy-Org\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"optional, for Fast · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MaskByTouch\"}], \"links\": [{\"label\": \"YouTube · this workflow\", \"url\": \"https://youtu.be/iMgaiXoB0v4\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
},
"color": "#14424d",
"bgcolor": "#161f21"
@@ -37,10 +37,10 @@
"ver": "2.9.0"
},
"widgets_values": [
"{\"v\": 1, \"title\": \"Qwen Image 2.1 Upscale + Restore\", \"subtitle\": \"Load a small, soft or worn picture. Qwen Image 2.1 redraws it sharp at the megapixels you set, in its own colors.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your image. You can crop it right there with the blue handles.\\n2. Set **Megapixels**: the size of the result.\\n3. Press **Run**.\\n4. Slide **Before and after** to compare. The result is saved as a PNG.\\n\\n### What you get\\n\\n- Your picture redrawn sharp, at the size you asked for.\\n- Its own colors, also on a noisy or faded photo.\\n\\n### Megapixels\\n\\n- Up to about 2: one pass.\\n- Over that: redrawn in tiles of about 2 megapixels and joined. 4 takes two tiles, 8 takes four.\\n- A small picture is redrawn at up to 8 times its own megapixels, then enlarged the plain way.\\n- A picture of 1 megapixel or more only looks better when it grows about 1.6 times per side.\\n\\n### Switches\\n\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA. Off: 25 steps without it, about three times as long.\\n\\n### Tips\\n\\n- It is a redraw. Fine detail is made up to fit, so a face stays the same person, not the same pixels.\\n- Small lettering on signs and labels can come back misspelled.\\n- Each tile is matched to your picture's colors. To let Qwen's own colors through, lower **Keep colors** inside the box.\\n- If the run stops at a red **LoRA Loader**, a LoRA that is switched on is missing. Download it from the list below.\\n- Not tested on blur from a moving camera or missed focus.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.9.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"qwen-image-2.1-restore.safetensors\", \"dir\": \"loras\", \"size\": \"159 MB\", \"url\": \"https://huggingface.co/ausboss/Qwen-Image-2.1-Restore-LoRA/resolve/main/qwen-image-2.1-restore.safetensors\", \"note\": \"keeps the result close to your picture\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"for Fast on · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_TiledUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
"{\"v\": 1, \"title\": \"Qwen Image 2.1 Upscale + Restore\", \"subtitle\": \"Load a small, soft or worn picture. Qwen Image 2.1 redraws it sharp at the megapixels you set, in its own colors.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your image. You can crop it right there with the blue handles.\\n2. Set **Megapixels**: the size of the result.\\n3. Press **Run**.\\n4. Slide **Before and after** to compare. The result is saved as a PNG.\\n\\n### What you get\\n\\n- Your picture redrawn sharp, at the size you asked for.\\n- Its own colors, also on a noisy or faded photo.\\n\\n### Megapixels\\n\\n- Up to about 2: one pass.\\n- Over that: redrawn in tiles of about 2 megapixels and joined. 4 takes two tiles, 8 takes four.\\n- The redraw limit for a small picture is 8 times its own megapixels or one roughly 2 megapixel tile, whichever is larger. The rest is enlarged the plain way.\\n- A picture of 1 megapixel or more only looks better when it grows about 1.6 times per side.\\n\\n### Switches\\n\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA. Off: 25 steps without it, about three times as long.\\n\\n### Tips\\n\\n- It is a redraw. Fine detail is made up to fit, so a face stays the same person, not the same pixels.\\n- Small lettering on signs and labels can come back misspelled.\\n- Each tile is matched to your picture's colors. To let Qwen's own colors through, lower **Keep colors** inside the box.\\n- If the run stops at a red **LoRA Loader**, a LoRA that is switched on is missing. Download it from the list below.\\n- Not tested on blur from a moving camera or missed focus.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.9.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"qwen-image-2.1-restore.safetensors\", \"dir\": \"loras\", \"size\": \"159 MB\", \"url\": \"https://huggingface.co/ausboss/Qwen-Image-2.1-Restore-LoRA/resolve/main/qwen-image-2.1-restore.safetensors\", \"note\": \"keeps the result close to your picture\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"for Fast on · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_TiledUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
],
"widgets_values_named": {
"note": "{\"v\": 1, \"title\": \"Qwen Image 2.1 Upscale + Restore\", \"subtitle\": \"Load a small, soft or worn picture. Qwen Image 2.1 redraws it sharp at the megapixels you set, in its own colors.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your image. You can crop it right there with the blue handles.\\n2. Set **Megapixels**: the size of the result.\\n3. Press **Run**.\\n4. Slide **Before and after** to compare. The result is saved as a PNG.\\n\\n### What you get\\n\\n- Your picture redrawn sharp, at the size you asked for.\\n- Its own colors, also on a noisy or faded photo.\\n\\n### Megapixels\\n\\n- Up to about 2: one pass.\\n- Over that: redrawn in tiles of about 2 megapixels and joined. 4 takes two tiles, 8 takes four.\\n- A small picture is redrawn at up to 8 times its own megapixels, then enlarged the plain way.\\n- A picture of 1 megapixel or more only looks better when it grows about 1.6 times per side.\\n\\n### Switches\\n\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA. Off: 25 steps without it, about three times as long.\\n\\n### Tips\\n\\n- It is a redraw. Fine detail is made up to fit, so a face stays the same person, not the same pixels.\\n- Small lettering on signs and labels can come back misspelled.\\n- Each tile is matched to your picture's colors. To let Qwen's own colors through, lower **Keep colors** inside the box.\\n- If the run stops at a red **LoRA Loader**, a LoRA that is switched on is missing. Download it from the list below.\\n- Not tested on blur from a moving camera or missed focus.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.9.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"qwen-image-2.1-restore.safetensors\", \"dir\": \"loras\", \"size\": \"159 MB\", \"url\": \"https://huggingface.co/ausboss/Qwen-Image-2.1-Restore-LoRA/resolve/main/qwen-image-2.1-restore.safetensors\", \"note\": \"keeps the result close to your picture\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"for Fast on · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_TiledUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
"note": "{\"v\": 1, \"title\": \"Qwen Image 2.1 Upscale + Restore\", \"subtitle\": \"Load a small, soft or worn picture. Qwen Image 2.1 redraws it sharp at the megapixels you set, in its own colors.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your image. You can crop it right there with the blue handles.\\n2. Set **Megapixels**: the size of the result.\\n3. Press **Run**.\\n4. Slide **Before and after** to compare. The result is saved as a PNG.\\n\\n### What you get\\n\\n- Your picture redrawn sharp, at the size you asked for.\\n- Its own colors, also on a noisy or faded photo.\\n\\n### Megapixels\\n\\n- Up to about 2: one pass.\\n- Over that: redrawn in tiles of about 2 megapixels and joined. 4 takes two tiles, 8 takes four.\\n- The redraw limit for a small picture is 8 times its own megapixels or one roughly 2 megapixel tile, whichever is larger. The rest is enlarged the plain way.\\n- A picture of 1 megapixel or more only looks better when it grows about 1.6 times per side.\\n\\n### Switches\\n\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA. Off: 25 steps without it, about three times as long.\\n\\n### Tips\\n\\n- It is a redraw. Fine detail is made up to fit, so a face stays the same person, not the same pixels.\\n- Small lettering on signs and labels can come back misspelled.\\n- Each tile is matched to your picture's colors. To let Qwen's own colors through, lower **Keep colors** inside the box.\\n- If the run stops at a red **LoRA Loader**, a LoRA that is switched on is missing. Download it from the list below.\\n- Not tested on blur from a moving camera or missed focus.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.9.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"qwen-image-2.1-restore.safetensors\", \"dir\": \"loras\", \"size\": \"159 MB\", \"url\": \"https://huggingface.co/ausboss/Qwen-Image-2.1-Restore-LoRA/resolve/main/qwen-image-2.1-restore.safetensors\", \"note\": \"keeps the result close to your picture\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"for Fast on · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_TiledUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}"
},
"color": "#14424d",
"bgcolor": "#161f21"
+28 -14
View File
@@ -23,6 +23,7 @@ import { openInfoCard } from "../shared/settings_menu.mjs";
const SETTING_ID = "AusBoss.Appearance.NodeColor";
const CUSTOM_SETTING_ID = "AusBoss.Appearance.CustomColor";
const COLOR_OVERRIDE = "ausbossColor";
// Stored bare (no "#") to match the color setting's storage contract.
const DEFAULT_CUSTOM_COLOR = "00b4aa";
@@ -40,6 +41,7 @@ let activeCustomColor = DEFAULT_CUSTOM_COLOR;
let activeCustomTitle = normalizeHexColor(DEFAULT_CUSTOM_COLOR);
function setActiveCustomColor(value) {
if (!normalizeHexColor(value)) return;
activeCustomColor = value;
activeCustomTitle = normalizeHexColor(value);
}
@@ -56,6 +58,7 @@ function applyScheme(node, colors) {
function repaintAll(next, previous) {
for (const node of collectGraphNodes(app.graph)) {
if (!isAusbossNode(node)) continue;
if (Object.hasOwn(node.properties ?? {}, COLOR_OVERRIDE)) continue;
if (!shouldRecolor(node, previous)) continue;
applyScheme(node, next);
}
@@ -149,8 +152,8 @@ app.registerExtension({
},
getNodeMenuItems(node) {
if (!isAusbossNode(node)) return [];
// A title-less node has no bar for the "?" badge; its card opens here.
const about = isTitleless(node)
// Vue nodes skip the canvas badge callbacks; title-less nodes have no bar.
const about = isTitleless(node) || globalThis.LiteGraph?.vueNodesMode === true
? [{ content: "About this node", callback: () => openHelpCard(node, null) }]
: [];
return [
@@ -162,12 +165,14 @@ app.registerExtension({
options: SCHEME_NAMES.map((name) => ({
content: name,
callback: () => {
// A menu pick writes the colors directly; shouldRecolor then
// treats them as the user's choice during setting sweeps.
// Keep an explicit choice, including no colors, through saves
// and setting changes. Custom stores the pair picked today.
const colors = schemeColors(name, activeCustomColor);
// Custom with an unusable stored color resolves to null, which
// would wrongly read as "Theme default" — do nothing instead.
if (name === CUSTOM_SCHEME && !colors) return;
node.properties ??= {};
node.properties[COLOR_OVERRIDE] = colors ? { ...colors } : null;
applyScheme(node, colors);
app.graph?.setDirtyCanvas?.(true, true);
},
@@ -179,16 +184,26 @@ app.registerExtension({
nodeCreated(node) {
if (!isAusbossNode(node)) return;
installHelpBadge(node);
// Colors restored from a saved workflow (and manual picks) land before
// this hook runs — an already-colored node is left alone, unless it
// wears a retired flagship pair: those colors were ours, so the node
// upgrades to the active scheme instead of keeping them forever.
if ((node.color || node.bgcolor) && !wearsLegacyScheme(node)) return;
const colors = schemeColors(activeScheme, activeCustomColor);
if (colors) applyScheme(node, colors);
restoreAppearance(node);
// Construction precedes saved fields; reload, paste and undo restore
// their colors and properties before this callback runs.
chainCallback(node, "onConfigure", function (saved) {
restoreAppearance(this, saved);
});
},
});
function restoreAppearance(node, saved) {
if (Object.hasOwn(node.properties ?? {}, COLOR_OVERRIDE)) {
// A later pick in ComfyUI's own color menu is saved in the native fields.
if (saved?.color || saved?.bgcolor) return;
applyScheme(node, node.properties[COLOR_OVERRIDE]);
return;
}
if ((node.color || node.bgcolor) && !wearsLegacyScheme(node)) return;
applyScheme(node, schemeColors(activeScheme, activeCustomColor));
}
function isTitleless(node) {
return (node.title_mode ?? node.constructor?.title_mode) === (globalThis.LiteGraph?.NO_TITLE ?? 1);
}
@@ -204,9 +219,8 @@ function openHelpCard(node, event) {
});
}
// Every AusBoss node gets a quiet "?" in the title bar; clicking it opens a
// card built from the node's own DESCRIPTION and tooltips, so the docs on
// screen are exactly the docs in the source.
// Classic nodes get a quiet "?" in the title bar. Vue nodes open the same
// card from their context menu. Its text comes from DESCRIPTION and tooltips.
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.
+115 -22
View File
@@ -13,10 +13,12 @@ import { BRAND, chainCallback, keepDomWidgetWidthAuto } from "../shared/index.mj
import {
ARROW_ANGLE,
ARROW_CHOICES,
CARD_TEMPLATE,
DOUBLE_ARROWS,
arrowText,
fitFontSize,
parseBlocks,
parseCard,
pastedLink,
repointLinks,
targetId,
@@ -45,6 +47,10 @@ const LINK_TILES = {
youtube: { label: "Watch Me on YouTube", url: "https://www.youtube.com/@ausbossdev" },
discord: { label: "Join for Help or More Workflows", url: "https://discord.gg/RwVxY9Fuaj" },
};
// A new Info card opens at this size, which shows its starter text whole.
const CARD_SIZE = [300, 230];
// The card's text grows with the node like a note's, but a card stays a card.
const CARD_FONT_MAX = 20;
const FRAME_FILL = 0.8; // the share of the view a node brought into view takes, as in Workflow Switches
const FONT = '"IBM Plex Sans", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif';
@@ -81,6 +87,20 @@ ${TILE_FACE_CSS}
.ausboss-callout-card[data-tile] .ausboss-callout-url svg{width:36px;height:28px;flex:none}
.ausboss-callout-card[data-tile] .ausboss-callout-out{margin-left:auto}
.ausboss-callout-card[data-tile] .ausboss-callout-tools{right:27px;}
.ausboss-callout-card[data-card]{background:#111818;border:1px solid #2e3c3b;}
.ausboss-callout-card[data-card] .ausboss-callout-inner{padding:.8em 1em;color:#d9e4e3;font-weight:400;line-height:1.45;}
.ausboss-callout-card[data-card] b{color:#f2f7f6;font-weight:650;}
.ausboss-card-block{margin:0 0 .85em;}
.ausboss-card-block:last-child{margin-bottom:0;}
.ausboss-card-block>*+*{margin-top:.3em;}
.ausboss-card-title{display:flex;align-items:center;gap:.4em;color:#f2f7f6;font-size:1.08em;font-weight:650;}
.ausboss-card-icon{flex:none;width:1em;height:1em;color:#c9d6d4;}
.ausboss-card-icon svg{display:block;width:100%;height:100%;}
.ausboss-card-row{display:flex;justify-content:space-between;align-items:baseline;gap:1em;}
.ausboss-card-row>span:last-child{flex:none;text-align:right;white-space:nowrap;}
.ausboss-card-row.is-mark,.ausboss-card-row.is-mark b{color:#6fe0d6;font-weight:500;}
.ausboss-card-small,.ausboss-card-small b{color:#93a6a4;}
.ausboss-card-small{font-size:.92em;}
.ausboss-callout-empty{color:#6f8987;font-size:14px;font-weight:400;}
.ausboss-callout-arrow{display:inline-block;width:1.2em;height:1.2em;margin:0 .1em;vertical-align:-.26em;filter:drop-shadow(0 0 .3em rgba(0,180,170,.55));}
.ausboss-callout-arrow svg{display:block;width:100%;height:100%;overflow:visible;}
@@ -154,6 +174,25 @@ function arrowIcon(kind) {
return root;
}
// The small line icons an Info card's title can start with.
const CARD_ICON_PATHS = {
clock: ["M12 7v5l3.2 2"],
info: ["M12 11v6", "M12 7.6v.01"],
warning: ["M12 3.5 2.8 19.5h18.4Z", "M12 10v4", "M12 16.9v.01"],
tip: ["M9.5 18h5", "M10.5 21h3", "M12 3a6 6 0 0 0-3.6 10.8c.7.6 1.1 1.3 1.1 2.2h5c0-.9.4-1.6 1.1-2.2A6 6 0 0 0 12 3Z"],
check: ["M8 12.4l2.8 2.8 5.2-5.6"],
};
const CARD_ICON_RING = new Set(["clock", "info", "check"]);
function cardIcon(kind) {
const span = el("span", "ausboss-card-icon");
const root = svg("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round" });
if (CARD_ICON_RING.has(kind)) root.append(svg("circle", { cx: 12, cy: 12, r: 9 }));
for (const d of CARD_ICON_PATHS[kind] ?? []) root.append(svg("path", { d }));
span.append(root);
return span;
}
// The node a link names, looked up in the graph the note sits in.
function linkedNode(state, id) {
const graph = state.node.graph ?? app.graph;
@@ -262,17 +301,79 @@ function bringIntoView(target) {
}
}
// One line's pieces into an element. The pieces of one link sit in one
// element; a link that leaves ComfyUI ends with a small ↗.
function appendPieces(state, into, line) {
let run = null;
let wrap = null;
const close = () => {
if (wrap?.classList.contains("ausboss-callout-url")) wrap.append(el("span", "ausboss-callout-out", "↗"));
};
for (const piece of line) {
if (piece.run !== run) {
close();
run = piece.run;
wrap = run === undefined ? null : linkRun(state, piece);
if (wrap) into.append(wrap);
}
const target = wrap ?? into;
if (piece.arrow) target.append(arrowSpan(state, piece.arrow, piece));
else target.append(piece.bold ? el("b", "", piece.text) : document.createTextNode(piece.text));
}
close();
}
// An Info card: a title, rows of label and value, and small print.
function renderCard(state, blocks) {
for (const lines of blocks) {
const block = el("div", "ausboss-card-block");
for (const line of lines) {
let row;
if (line.kind === "title") {
row = el("div", "ausboss-card-title");
if (line.icon) row.append(cardIcon(line.icon));
const words = el("span");
appendPieces(state, words, line.pieces);
row.append(words);
} else if (line.kind === "row") {
row = el("div", `ausboss-card-row${line.highlight ? " is-mark" : ""}`);
for (const cell of line.cells) {
const side = el("span");
appendPieces(state, side, cell);
row.append(side);
}
} else {
row = el("div", "ausboss-card-text");
appendPieces(state, row, line.pieces);
}
if (line.small) row.classList.add("ausboss-card-small");
block.append(row);
}
state.inner.append(block);
}
}
function renderText(state) {
const style = state.node.properties?.ausbossCalloutTile;
if (["youtube","discord","title"].includes(style)) state.card.dataset.tile = style;
else delete state.card.dataset.tile;
setTileFace(state.node, state.root, Boolean(state.card.dataset.tile), () => startEdit(state));
const blocks = parseBlocks(state.valueWidget.value);
// An Info card has no title bar, like a tile, but keeps the full editor
// and saves as a plain Callout: an older pack shows its text as a note.
if (style === "card") state.card.dataset.card = "";
else delete state.card.dataset.card;
const isCard = style === "card";
setTileFace(state.node, state.root, Boolean(state.card.dataset.tile) || isCard, () => startEdit(state));
const blocks = isCard ? parseCard(state.valueWidget.value) : parseBlocks(state.valueWidget.value);
state.inner.replaceChildren();
state.empty = blocks.length === 0;
if (state.empty) {
state.inner.append(el("div", "ausboss-callout-empty", "Double-click to write a note"));
}
if (isCard) {
renderCard(state, blocks);
fit(state);
return;
}
for (const lines of blocks) {
const point = el("div", "ausboss-callout-point");
lines.forEach((line, index) => {
@@ -280,21 +381,7 @@ function renderText(state) {
// lines are told apart by a break, as before; a block needs none.
const into = line.small ? el("div", "ausboss-callout-small") : point;
if (index && !line.small && !lines[index - 1].small) point.append(document.createElement("br"));
let run = null;
let wrap = null;
for (const piece of line) {
// The pieces of one link sit in one element.
if (piece.run !== run) {
if (wrap?.classList.contains("ausboss-callout-url")) wrap.append(el("span", "ausboss-callout-out", "↗"));
run = piece.run;
wrap = run === undefined ? null : linkRun(state, piece);
if (wrap) into.append(wrap);
}
const target = wrap ?? into;
if (piece.arrow) target.append(arrowSpan(state, piece.arrow, piece));
else target.append(piece.bold ? el("b", "", piece.text) : document.createTextNode(piece.text));
}
if (wrap?.classList.contains("ausboss-callout-url")) wrap.append(el("span", "ausboss-callout-out", "↗"));
appendPieces(state, into, line);
if (line.small) point.append(into);
});
state.inner.append(point);
@@ -364,7 +451,7 @@ function fit(state) {
inner.style.fontSize = `${px}px`;
return inner.offsetHeight <= height && inner.scrollWidth <= width + 1;
};
const size = fitFontSize(fits);
const size = fitFontSize(fits, undefined, card.dataset.card === undefined ? undefined : CARD_FONT_MAX);
const social = card.dataset.tile === "youtube" || card.dataset.tile === "discord";
inner.style.fontSize = `${social ? Math.min(size, 16) : size}px`;
}
@@ -453,7 +540,9 @@ function startEdit(state) {
const wrap = el("div", "ausboss-callout-editor");
const area = el("textarea", "ausboss-callout-edit");
area.value = state.valueWidget.value ?? "";
area.placeholder = "One idea per paragraph. **Bold** a word to stress it. The buttons below add an arrow, make a line small or make words a link.";
area.placeholder = state.card.dataset.card === undefined
? "One idea per paragraph. **Bold** a word to stress it. The buttons below add an arrow, make a line small or make words a link."
: "The first line is the title. Write label | value for a row. **Bold** a whole row to make it teal. Small makes a line small print.";
area.spellcheck = true;
const bar = el("div", "ausboss-callout-arrowbar");
bar.append(el("span", "", "Arrow"));
@@ -687,7 +776,7 @@ app.registerExtension({
const state = buildPanel(this);
if (!state) return;
renderText(state);
if (state.card.dataset.tile && Number.isFinite(saved?.size?.[1])) {
if ((state.card.dataset.tile || state.card.dataset.card !== undefined) && Number.isFinite(saved?.size?.[1])) {
this.setSize?.([this.size[0], saved.size[1]]);
}
});
@@ -699,14 +788,18 @@ 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"]]) {
for (const [style, label] of [["plain","Plain callout"],["card","Info card"],["title","Title tile"],["youtube","Set YouTube link tile"],["discord","Set Discord link tile"]]) {
options.unshift({ content: label, callback: () => {
this.properties ??= {};
this.properties.ausbossCalloutTile = style;
const preset = LINK_TILES[style];
if (preset) commitWidgetValue(this, state.valueWidget, `[${preset.label}](${preset.url})`, app.canvas);
// An empty card starts with text that shows how one is written.
const starter = style === "card" && !String(state.valueWidget.value ?? "").trim();
if (starter) commitWidgetValue(this, state.valueWidget, CARD_TEMPLATE, app.canvas);
renderText(state);
if (style !== "plain") this.setSize?.([this.size[0], style === "title" ? 86 : 78]);
if (starter) this.setSize?.([Math.max(this.size[0], CARD_SIZE[0]), CARD_SIZE[1]]);
else if (style !== "plain" && style !== "card") this.setSize?.([this.size[0], style === "title" ? 86 : 78]);
markChanged();
}});
}
+53
View File
@@ -0,0 +1,53 @@
// Clear VRAM 🆎 — a right-click entry on the empty canvas that unloads every
// model and frees memory on the ComfyUI server this page is connected to.
//
// It sends the same request as ComfyUI's own "Unload Models and Execution
// Cache" command (top menu, Edit): POST /free with unload_models and
// free_memory. That command is not on the right-click menu, so this entry
// puts it one click away. A setting hides the entry; a ComfyUI that turns
// manual unloading off (Comfy.Memory.AllowManualUnload) hides it too.
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { showToast } from "../shared/index.mjs";
const SETTING_ID = "AusBoss.Memory.ClearVramMenu";
const MENU_LABEL = "Clear VRAM 🆎";
function setting(id) {
return app.ui?.settings?.getSettingValue?.(id);
}
async function clearVram() {
try {
const response = await api.fetchApi("/free", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ unload_models: true, free_memory: true }),
});
if (!response.ok) throw new Error(`the server answered ${response.status}`);
showToast({ severity: "success", summary: MENU_LABEL, detail: "Models unloaded and memory freed.", life: 3000 });
} catch (error) {
showToast({ severity: "error", summary: MENU_LABEL, detail: `Could not clear VRAM: ${error?.message ?? error}` });
}
}
app.registerExtension({
name: "ausboss.clear.vram",
settings: [
{
id: SETTING_ID,
name: "Clear VRAM on the right-click menu",
type: "boolean",
defaultValue: true,
category: ["🆎 AusBoss", "Memory", "Clear VRAM menu"],
tooltip:
"Right-click the empty canvas for Clear VRAM: it unloads every model and frees "
+ "memory on the ComfyUI server. Off hides the entry.",
},
],
getCanvasMenuItems() {
if (setting(SETTING_ID) === false) return [];
if (setting("Comfy.Memory.AllowManualUnload") === false) return [];
return [{ content: MENU_LABEL, callback: clearVram }];
},
});
+2 -1
View File
@@ -463,8 +463,9 @@ app.registerExtension({
]);
});
chainCallback(nodeType.prototype, "onConfigure", function () {
const state = this.__ausbossCompare;
queueMicrotask(() => {
const state = buildPanel(this);
if (!state || this.__ausbossCompare !== state || state.abort.signal.aborted) return;
state.showingB = this.properties?.ausboss_compare_side === "B";
syncErase(state);
// The saved paint, if the pictures are already up.
+40 -1
View File
@@ -15,7 +15,38 @@ Right-click the Callout and choose **Title tile** to give your existing text a b
- 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
## Info card
A small card for facts people look up: run times, settings, what each choice
does. Right-click the Callout and choose **Info card**. An empty note starts
with sample text that shows each part.
Write it like this:
```text
⏱️ Too slow?
###### Redraw time on an RTX 5090. Smaller cards take longer.
2x, 18 steps | ~4 min
2x, 10 steps | ~3 min
1.5x, 18 steps | ~2.5 min
**1.5x, 10 steps (default) | ~2 min**
###### 1.5x is a 4608-wide sheet, 2x is 6144.
```
- The first line is the card's title. Start it with ⏱️ ℹ️ ⚠️ 💡 or ✅ and
that emoji is drawn as a small icon.
- A line with a `|` in it is a row: the words before the `|` on the left,
the rest on the right.
- Put the whole row in two stars, `**like this | value**`, and it is drawn
in teal. Use it for the default or the one to pick.
- Small print (the **Small** button) is a grey line, for a subtitle or a note
at the bottom.
- A blank line leaves a gap. Arrows and links work as in any callout.
- Double-click the card to edit it. Choose **Plain callout** to turn it back
into a note; the text stays.
- On a plain callout, double-click the note, or press **Edit**, to type. Click outside the box to
keep the text. **Esc** drops what you typed.
@@ -90,5 +121,13 @@ In the text a link is written `[the words](https://example.com)`, or
it to read the address before you click.
- An older version of the pack shows a link as typed, brackets and address
included, so the address can still be read and copied there.
- An Info card is saved as an ordinary Callout with the text you typed.
The card look is a setting on the node, so an older version of the pack
shows the same text as a plain note, rows written out as `label | value`.
A plain callout never reads `|` as a row, so notes written before look
the same.
- A row splits at its first `|` that is not inside a link. A row pasted
from a Markdown table loses its outer pipes, and the table's `---` line
is skipped. An Info card's text grows with the card, up to 20 px.
- The font is IBM Plex Sans when the computer has it, otherwise the system
font. The node loads no fonts and makes no network requests.
+1 -1
View File
@@ -110,7 +110,7 @@ parts back.
## Technical details
- Starting values: Context `1.2`, Blend `16` px, Multiple `8`; Grow, Blur,
- Starting values: Context `1.2`, Blend `16` px, Multiple `32`; Grow, Blur,
Extra context, the Target rows and the Extend rows start at `0`.
- With no Target size set, the crop is still rounded up to **Multiple**, so
with **Stay in picture** on it can still reach a few pixels (at most
+142
View File
@@ -0,0 +1,142 @@
# Edit Lock
Keeps every part of your picture that an edit did not change exactly where
it was, with the colors it had.
An edit model redraws the whole picture, also the parts you did not ask it
to change. Those parts often come back a little zoomed in, shifted or
tinted. Flip between the original and the edit and the whole picture
jumps. Edit Lock stops that: only the edit is new.
It needs no mask. It sees what the edit changes while the picture is being
made, and holds on to everything else.
## Quick start
1. Put **Edit Lock 🆎** between the model and the sampler: the model (after
any LoRA loader) goes in, and its `model` output goes to the sampler or
guider.
2. Wire the VAE into `vae` and the picture you are editing into `image`.
Use the same picture the edit model gets, at the size of the result.
3. After the VAE Decode, add **Edit Lock Restore 🆎**. Wire the decoded
picture into `image` and Edit Lock's `edit_lock` output into
`edit_lock`. This puts your own pixels back in the untouched parts.
4. Wire Restore's `report` into **Show Text 🆎** to read how much was kept.
Run your edit as usual. Prompt, steps and seed stay as they were.
## What it does with each kind of edit
- **A local edit** (add glasses, repaint the car, change the sign): the
edit happens, and the rest of the picture stays put.
- **An edit of the whole picture** (a new style, new light, fog):
there is nothing to keep, so Edit Lock steps aside and the edit runs as
if the node were not there. The report says so.
- **A soft effect over the whole picture** (rain, falling snow, haze):
it touches everything a little, so there is no untouched part to put
back. Edit Lock lets the effect through and only holds the picture in
place, so it does not come back zoomed or shifted. The report says so.
- **A soft effect and a clear change in one edit** (rain and an umbrella):
the node may lock on the umbrella and hold the rain back everywhere
else. If the effect is missing outside the changed part, set **Lock** to
`off`.
- **Something in between** (a new hair color on a close-up, a new
background behind a person): it may lock or step aside. The report says
which. These edits often change the light on the face too. `auto` steps
aside for most of them, so the new light comes with the new hair.
`always` keeps the old light on everything it keeps, which can look
wrong on a face. A mask settles it: paint the hair and the face, and
the rest is locked.
- **The model ignores your picture and draws a new one:** that reads as a
whole-picture edit, so `auto` steps aside. `always` holds the model to
your picture.
## Controls
- **model**: the edit model. Patch last, after any LoRAs.
- **vae**: the same VAE the workflow decodes with.
- **image**: the picture you are editing, at the size of the result. If
the sizes differ by more than a few percent the lock does nothing, and
the report says so.
- **Lock** (`lock`): `auto` (the default) locks the untouched parts and
steps aside when the whole picture changes. `always` locks even then.
`off` does nothing, which makes a quick before and after.
- **Tolerance** (`tolerance`): how big a change must be to count as part of
the edit. 1 is the default. Raise it if small unwanted changes get
through. Lower it if the lock holds back part of your edit.
- **mask** (optional): white marks where the edit may happen; everything
else is locked. With a mask the node does not look for the change
itself. A mask from **Mask by Touch 🆎** or **Mask by Name 🆎** works.
## Outputs
- **model**: the model with the lock on.
- **edit_lock**: what the lock kept, for Edit Lock Restore 🆎.
## Good to know
- The lock belongs to one picture. For an edit with two pictures, give it
the one the result should line up with.
- It works on single pictures, not video.
- The node watches the first steps of a run and locks for the rest, so
the model finishes the edit on top of your real picture. Shadows and
reflections the edit adds next to the changed part still come through.
- If your edit is missing and the report says nearly all of the picture
was kept, set **Lock** to `off`.
- Tested on MiniMax-H3-Image. The lock itself is not tied to one model, but
the line between "local edit" and "whole picture" was measured there.
## Technical details
At every step an edit model predicts the finished picture. Edit Lock
compares that prediction with your picture in latent space.
**Watching.** The first prediction of a run is a blur, and rain, snow or
the light a new hair color throws on a face only show a few steps in. So
the node watches until the noise is down to 0.8 of where it began: step 4
of a 6-step MiniMax-H3-Image run. A run that never gets that low decides
at its last step. While it watches, the model draws its own picture and
only one thing is corrected. The model often draws the whole picture about
one percent larger, or a little shifted. The node measures that zoom and
shift at every step, on the calmer half of the picture, and moves the
prediction back so it sits where your picture does.
**Deciding.** Change is measured against the quiet part of the picture:
the tenth where the prediction differs least. An even tint over the whole
picture is taken out first, so a color cast is not read as a change. A
spot counts as changed when it differs by 3 times the quiet level, or by 2
times while it touches a spot that does. So a faint change beside a clear
one belongs to the edit, and a faint change alone is redraw noise.
**Tolerance** multiplies both numbers.
**Locking.** From the decision on, wherever the picture did not change,
the original latent is put back before the sampler takes its next step. A
part once read as changed stays changed for the rest of the run. The last
step ends on the original there, so what was kept is the original itself,
not a redrawn copy.
**Stepping aside.** `auto` does not lock in three cases:
- At the first step the whole picture is already changing (a new style,
new light). The node reads the calmest fifth of a smoothed copy, where a
slightly larger redraw nearly vanishes and a new style does not. On
MiniMax-H3-Image, local edits at 1, 4 and 16 megapixels read 0.22 or
less there and whole-picture edits 0.27 or more; the line sits at 0.243.
The model then runs untouched.
- At the decision no place has changed clearly: the strongest spot of the
picture is under 4 times the quiet level. That is light rain or fog.
Local edits read 4.7 or more there, glasses and makeup included.
- At the decision the lock would hand a tenth of the picture or more to
the edit, yet the strongest hundredth of the picture is under 4 times
the quiet level. That is heavier rain or snow, and a lock would let it
through in patches. Local edits that wide read 4.4 or more; rain and
snow read 3.6 or less.
In the last two cases the node keeps undoing the zoom and shift to the end
of the run, and puts nothing back. An edit close to one of these lines (a
hair color change on a close-up, a new background, smoke) can fall on
either side. The report says which, and **Lock** overrides it.
This is the target the TailSteer paper trains a model toward (the source
where nothing changed, the model's own edit elsewhere), applied while
sampling. It needs no LoRA and no training.
+48
View File
@@ -0,0 +1,48 @@
# Edit Lock Restore
Puts your picture's own pixels back in every part the edit did not change.
**Edit Lock 🆎** keeps the untouched parts in place while the picture is
made. They still pass through the VAE, which softens fine detail a little.
This node swaps your original pixels in for those parts, so they match
your picture pixel for pixel. Only the edit is new.
## Quick start
1. Add it right after the VAE Decode, before any upscaler.
2. Wire the decoded picture into `image`.
3. Wire the `edit_lock` output of Edit Lock 🆎 into `edit_lock`.
4. Wire `report` into **Show Text 🆎** to read what was kept.
## Controls
- **image**: the edited picture, straight from the VAE Decode.
- **edit_lock**: from Edit Lock 🆎. It carries your original picture and
the map of what the edit changed, so there is nothing else to wire.
- **Feather** (`feather`): how softly your original fades into the edit,
in pixels. 16 is the default. The fade lies outside the edit, so the
edit itself is never mixed with the old picture.
## Outputs
- **image**: the edit, with your original pixels in the untouched parts.
- **edit_mask**: white where the edit changed the picture. Use it to see
what the lock decided, or for a later step that should only touch the
edit.
- **report**: one line, such as "Edit Lock kept 82% of your picture as it
was and left 18% to the edit".
## When nothing was locked
The picture passes through unchanged, the mask is all white, and the
report says why: the edit changed the whole picture, the lock was off, or
the picture and the result were different sizes.
## Technical details
The map of what changed is made while sampling, on the latent grid. Here
it is enlarged to the picture's size, widened by the feather and blurred
by the same amount, and used to blend the decoded picture with your
original. If the decoded picture is not the size of your original (after
an upscale), the original is resized to fit first, which costs it some
sharpness. That is why this node goes before an upscaler.
+69
View File
@@ -0,0 +1,69 @@
# FlashVSR Upscale
Makes a video larger and sharper. Give it the frames of a clip, set how much
larger, and it returns the same clip with more detail. Use it as the last
step before Save Video, for a generated clip or one you loaded.
It needs one model file and no other pack.
## The model file
Download `flashvsr_v1.1_fp16.safetensors` (3.5 GB) from
[ausboss/FlashVSR-v1.1-ComfyUI](https://huggingface.co/ausboss/FlashVSR-v1.1-ComfyUI)
and put it in `ComfyUI/models/diffusion_models`. Then refresh the page, or
press `R`, and pick it under **Model**.
The file holds the official FlashVSR v1.1 weights in one piece. Another
FlashVSR file will not load here: the node stops and says which part is
missing.
## Controls
- **frames**: The clip, as a batch of frames. Load Video's frames output
goes here.
- **Model** (`model`): The model file above. The list shows the files in
`diffusion_models` that have FlashVSR in their name.
- **Scale** (`scale`, starts at `2`): How many times larger each side gets,
from 1 to 4. At `1` the clip keeps its size and is only sharpened.
- **Keep colors** (`color_match`, starts on): Brings each frame's colours
back to those of your clip. Leave it on unless you want the model's own
colours.
- **vae** (optional): Connect the Wan 2.1 VAE for a slower, finer finish.
With nothing connected, the fast decoder inside the model file makes the
picture.
## Outputs
- **frames**: The larger frames, as many as went in, in the same order.
The frame rate and the sound are not touched, so wire those from Load
Video to Save Video as before.
## What to expect
FlashVSR redraws fine detail: skin, hair, cloth and edges come back
sharper than the clip had them. It keeps the scene, the motion and the
timing.
A long clip needs no more graphics memory than a short one. The node reads
the clip a few frames at a time and keeps only the last few in memory.
## Technical details
- **Size.** The result is exactly your size times **Scale**, rounded to
even numbers. Inside, the model works at a multiple of 128 pixels each
way; the node pads the picture for that and cuts the padding off again,
so nothing of your picture is cropped.
- **Graphics memory.** About 10 GB for a 576 x 1024 result. A result too
large for the free memory is made in overlapping tiles and blended, and
the tile size is chosen from the memory that is free when the node runs.
1152 x 2048 ran in two tiles at about 16 GB. On an 8 GB card (RTX 3070
Laptop) the same result ran in smaller tiles at about 6.5 GB, at about 3
seconds a frame.
- **Speed.** On an RTX 5090: about 7 frames a second for a 576 x 1024
result and about 2 frames a second for 1152 x 2048.
- **One step.** The model runs once per group of frames. There are no
steps, sampler, prompt or seed to set.
- **The model.** FlashVSR v1.1 by Junhao Zhuang and the OpenImagingLab
team, released under Apache-2.0. It is built on the Wan 2.1 1.3B video
model. This node is the pack's own code in plain PyTorch.
- The node makes no network requests and writes no files.
+108
View File
@@ -0,0 +1,108 @@
# Load Image
Loads a picture and sets its size in the same node, so a workflow does not
need a resize node and a size node after its loader.
Pick a file in **Source**, press **Load** to upload one, or use the arrows
under it to step through the pictures in ComfyUI's input folder. The line
beside the arrows shows the size of the file and, when the size changes, the
size that comes out:
`912 × 1632 → 1144 × 2048`
That is the real output size, worked out before you run.
Under that line are two small pictures: the file as it goes in, and the
picture as it comes out. On the first one the part that is kept is lit and
the part that is cut off is dark. Between them the node says the shape
(`3:4 → 1:1`) and what happens (`77% size`, `cuts 449 px of height`).
## Size
**Size** (`size`) picks how the picture is resized. Each choice shows only
the numbers it uses.
| Choice | What it does | Its number |
|---|---|---|
| **off** | The picture comes out exactly as the file has it. | none |
| **megapixels** | Brings the whole picture to a total size and keeps its shape. | **Megapixels** (`megapixels`): 1.0 is about 1024 × 1024 |
| **longest side** | Sets the longer edge; the other edge follows. | **Longest side** (`longest_side`), in pixels |
| **scale** | Multiplies both edges. | **Scale** (`scale`): 0.5 is half, 2 is double |
| **fit inside** | Makes the picture as big as it can be inside a box, with nothing cut off. | **Width** and **Height** (`width`, `height`) |
| **crop to fill** | Covers the box completely and cuts off what sticks out. | **Width** and **Height** |
| **crop** | Cuts a piece of that size out of the picture. Nothing is resized. | **Width** and **Height** |
| **crop to ratio** | Cuts the largest piece of a shape, such as 16:9, out of the picture. Nothing is resized. | **Ratio** (`ratio`) |
**Round to** (`multiple`) rounds the new width and height to a multiple of
that number. Most models want 8 or 16. Set it to 1 to keep the numbers as
they are. It does nothing while Size is off.
## Which part is kept
The three crop choices show **Keep** (`crop_from`). It picks the part of the
picture that stays: `center` cuts evenly from both sides, `top` keeps the top
and cuts from the bottom, `bottom left` keeps that corner, and so on.
You can also click the first small picture: click its top to keep the top,
a corner to keep that corner. The lit part moves so you see what you get.
In **Ratio**, `width : height` takes the shape from the Width and Height
boxes, for a shape that is not in the list (1600 and 900 give 16:9).
## More
- **Method** (`method`): how pixels are resampled. `lanczos` is the sharpest
for photos. `nearest` keeps pixel art crisp.
- **Enlarge** (`enlarge`): on, a small picture is made bigger to reach the
size you asked for. Off, a picture is only ever made smaller: one that is
already below the target comes out at its own size, and crop to fill cuts
the box's shape out of it without growing it.
## The picture on the node
The picture under the settings is ComfyUI's own preview, the same one the
stock Load Image shows. Drop a file on the node or paste one to upload it,
and right-click the node for the mask editor and for copy and paste.
## Outputs
- `image`: the picture at its new size.
- `mask`: white where the picture is see-through and black where it is
solid, as the stock loader gives it. A picture with no transparency gives
an all black mask. It is always the same size as `image`.
- `width`, `height`: the size that comes out.
- `filename`: the file's name without its folder or its ending
(`shots/hero.png` gives `hero`), ready for a save node's prefix.
- `original_width`, `original_height`: the size of the file before any
resize. Use them to bring a result back to the size you started from.
## Which loader to use
- **Load Image** (this node): load a picture and set its size.
- **Load Image + Pad**: add empty space around a picture for outpainting.
- **Image Crop + Rotate + Pad**: turn, crop and pad a picture by hand.
## Technical details
- Every frame of an animated file comes through as a batch, all at the same
new size. A phone photo is turned the way its EXIF data says.
- With Size off the pixels are the file's own, bit for bit, and Round to is
not applied.
- Rounding moves each edge to the nearest multiple, so the shape can change
by up to half a step. Fit inside rounds down instead, so the result never
leaves its box.
- Crop to fill resizes the picture until it covers the box, then cuts the
box out at the side Keep names. The mask goes through the same resize and
the same cut.
- Crop and crop to ratio only cut. A box larger than the picture is cut down
to the picture, and Round to rounds down so the piece stays inside it.
Enlarge does not apply to them.
- The small pictures are drawn on one scale, so a picture made half as large
is drawn half as large.
- With Enlarge off, a rounded size is rounded down so it never passes the
file's own size.
- The size line on the node uses the same arithmetic as the run. The pack's
tests hold the two to the same answers over a table of cases.
- Files are read only from ComfyUI's input, output and temp folders. A name
such as `photo.png [output]` loads a picture ComfyUI made.
- The node runs again when the file changes on disk or a setting changes.
+50 -11
View File
@@ -19,8 +19,8 @@ What is lit in teal is what the mask will be. When it is right, press Run
for the whole workflow.
The first time the pointer rests on a picture, the node looks at it. A
small button under the picture shows how far along it is, **❚❚ 40/104**,
filling up as it goes. The first things light up after a few seconds, and
button under the picture shows how far along it is, **❚❚ Pause · looking
40/104**, filling up as it goes. The first things light up after a few seconds, and
it has seen the whole picture after about 20.
You do not have to wait for that. Whatever is lit under the pointer can be
@@ -28,7 +28,7 @@ picked: a click takes it at once, also while the node is still looking.
Click that button to pause the looking when what you need is already lit:
what is lit stays, and you go on picking and painting. It turns into
**▶**; click it to look on from where it stopped. A click on a spot where
**▶ Look on**; click it to look on from where it stopped. A click on a spot where
nothing is lit yet looks on as well.
A click on a spot where nothing is lit yet is not lost: a dot marks it, the
@@ -50,6 +50,9 @@ at that spot.
A stroke across two things, like both shoes, takes both.
- Did the thing not light up at all? Type its name into the field under the
picture, like "screen" or "earring", and press Enter.
- Is something in the mask that should not be, like the face or the hair of
a person you picked? Click the **+** in front of that field so it shows
**−**, type "face" and press Enter. Or hold Ctrl and click the face.
- When nothing fits, switch to **Paint** and paint the area yourself.
## The whole subject, or the background
@@ -59,6 +62,10 @@ click: the person, the animal, the product. **Background** picks everything
else. They are picks like any other: click more things to add them, or
Ctrl-click a thing to take it away again.
Ctrl-click takes away the thing under the pointer, where it is in the mask.
On a spot where nothing is picked it changes nothing, and the picture says
so.
Both need ComfyUI's background-removal model, see "The model for Fine
edges" below.
@@ -115,11 +122,16 @@ or an area you painted.
it.
- **all 3** on a chip takes every thing of that name instead of the one you
clicked: all five birds, both shoes.
- The field at the end, **+ type a name**, picks every thing of the name
you type. With Erase chosen it takes them away.
- The field at the end picks every thing of the name you type. The sign in
front of it says what happens to the name: **+** adds it, **−** takes it
away. Click the sign to change it. A name typed with a minus in front,
like "-face", is taken away whatever the sign shows. With Erase chosen the
sign starts on **−**.
## The tools on the node
- **Reload** (the round arrow) shows the picture coming in now: see
**Which picture the picks belong to**, below.
- **Undo** takes back the last change, or a Clear. Ctrl+Z works as well: it
is ComfyUI's own undo, and every change counts.
- **Clear** picks nothing.
@@ -152,11 +164,15 @@ or an area you painted.
- **More → Smooth** (`smooth`): rounds off a ragged outline by this many
pixels.
- **More → Fill small holes** (`fill_holes`): closes pinholes inside the
picked area. Larger openings stay open, like a mug's handle.
picked area. Larger openings stay open, like a mug's handle. Off by
default, so a hole you pick stays a hole.
- **More → Invert** (`invert`): turns the mask round. On, the mask is
everything except what you picked.
- **More → If nothing** (`if_nothing`): **stop the run** or **empty mask**,
see below.
- **More → New picture** (`new_picture`): **keep picks** or **start over**,
for when a different picture comes in. See "Which picture the picks belong
to" below.
- **More → Free VRAM** (`free_vram`): takes SAM 3 out of the graphics card's
memory right after the search, so the next model has the room. Turn it on
in a workflow that loads a big model after this node.
@@ -206,10 +222,21 @@ nothing instead.
## Which picture the picks belong to
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.
Picks belong to the picture they were made on. When a different picture
comes in, **New picture** (under More) decides what happens to them:
- **Keep picks** (the default): your clicks and paint move onto the new
picture, in the same places. On a picture of another size they are
scaled to fit it. A click finds whatever is at that spot now; paint stays
exactly as painted. Press **Clear** to start over.
- **Start over**: the node shows the new picture with nothing picked. If the
workflow runs before you pick again, the run stops and says the picks
were made on another picture. The new picture shows on the node then,
ready to pick from.
- **The reload button** (the round arrow beside Undo) shows the picture
coming in now. Use it after you change the picture before this node, for
example with an edit. It runs only this node and what comes before it.
- With a Load Image wired straight in, or through Reroute nodes, the
picture shows as soon as you choose a file.
@@ -256,6 +283,15 @@ then.
### How SAM 3 is asked
- A picture whose long side is over 2048 pixels is asked about on a copy
with its long side at 2048. SAM 3 reads every picture at 1008 pixels
anyway, and returns each answer at the size it was given, on the graphics
card: at 8192 pixels one word with many matches needed over 27 GB. The
answer is brought back to the picture's own size, so the mask, the cut-out
and your touches stay in pixels of the picture. Paint is never scaled.
Measured on a 3840 pixel picture, five words: the new masks share 95 to
99.6 % of their area with the old ones, the edge sits a median 0 to 2
pixels differently, and a look takes 31 seconds in place of 53.
- The search is ComfyUI's own SAM 3: SAM3 Detect, asked about points, or
about a word that CLIP Text Encode has read. Both use ComfyUI's own
settings (threshold 0.5, two edge passes). The node needs ComfyUI 0.20 or
@@ -316,7 +352,10 @@ no browser:
used.
- **`paint`**: exactly the stroke's own area. SAM 3 is not asked.
- **`key`** names the picture the picks were made on: its size and a
fingerprint of every pixel. Picks made on another picture are ignored.
fingerprint of every pixel. Picks made on another picture are moved onto
the one that came in, scaled from the size in their key to its size, and
the face is sent the moved touches. With `new_picture` set to `start
over` they are ignored instead.
Leave `key` out when you write `touches` by hand.
### How an area is tidied
+54
View File
@@ -0,0 +1,54 @@
# Prompt Variations
A prompt box that changes on every run. Write choices in curly brackets and
the node picks one from each set:
```text
A woman in a {plain|striped|patterned} {tank top|sweater|button-up shirt}
and {jeans|a pleated skirt|cargo shorts}, {standing|sitting on a stool|leaning on the wall}.
```
One run might give "A woman in a striped sweater and jeans, sitting on a
stool." The next gives another mix.
Use it to make many different pictures in one go. Wire in the seed, set the
Seed node to **randomize**, and set the number of runs next to the Run
button to 50. Each run gets a new seed, so each picture gets new choices.
## Controls
- The text box (`text`): your prompt. Put each set of choices in curly
brackets and split the choices with `|`. Write as many sets as you like,
each with as many choices as you like.
- `seed` (a socket): the seed your sampler uses. A new seed picks new
choices. The same seed picks the same ones again, so a picture you like
can be made again from its seed.
## Output
- **prompt**: your text with one choice picked from each set. Wire it to
Show Text to see what was picked.
## Tips
- An empty choice lets a part come and go: `a {|striped} shirt` gives "a
shirt" or "a striped shirt".
- Sets can go inside sets: `{a {red|blue} hat|a scarf}`.
- Text without curly brackets comes out exactly as typed.
- To make one choice come up more often, write it more than once:
`{jeans|jeans|skirt}`.
## Technical details
Only brackets that hold a `|` are choices. Brackets without one are left as
they are, so a JSON prompt such as `{"light": "soft"}` passes through
unchanged. A `|` inside JSON brackets still counts as a choice.
The innermost sets are picked first, from left to right, with Python's
`random.Random(seed)`. The same text and seed always give the same prompt,
on any machine. Spaces around each choice are trimmed, and an empty pick
also drops the one space in front of it.
The picks happen when the prompt runs, on the server, not in the browser.
ComfyUI's own `{a|b}` choices in some text boxes are picked by the browser
when you queue, with no seed; this node does not use them.
+10 -4
View File
@@ -31,8 +31,8 @@ names from here on; how it measures may still improve.
moved a boat keeps the boat where it drew it, so the result lines up
closely, not pixel for pixel.
- **Leaves alone:** a picture it can't measure, or a zoom bigger than
**Max zoom**. That picture only comes out resized, and the report says
why.
**Max zoom** after allowing for padded reframing. That picture only
comes out resized, and the report says why.
## Controls
@@ -50,8 +50,10 @@ names from here on; how it measures may still improve.
with nothing in it. `edge` (the default) stretches the nearest pixels
into it, `source` fills it from the original, `gray` fills it flat gray
for inpainting. With a big enough margin there is no strip.
- **Max zoom**: the biggest zoom it will undo, in percent (20 by default).
Anything bigger is treated as a change you meant and left alone.
- **Max zoom**: the zoom limit in percent (20 by default). For a padded
picture, zooming in to fill the padding gets an extra allowance; the
setting limits zoom beyond that. Zooming out still uses the stated limit.
Anything beyond those limits is treated as a change you meant and left alone.
## Outputs
@@ -121,6 +123,10 @@ new cloud, are left out. It trusts the fit when at least 10 blocks, and at
least a quarter of them, agree. With a stitcher, the blocks tile the
picture's area only.
For padded reframing, the extra zoom-in allowance on each axis is the
canvas size divided by the source picture's size, minus one. It is added
to **Max zoom**; zooming out gets no extra allowance.
It runs on the CPU in a fraction of a second per 1 MP frame. No new
dependencies, and no model.
+17 -3
View File
@@ -50,9 +50,23 @@ file's name. A caption on the `caption_text` input writes the paired `.txt`.
shorter; on again, the node returns to its height with the last saved
image. It is display only: the file is saved either way and the queue
still lists it. When a run saves more than one image (a batch, or a list
such as a folder of images), arrows beside the switch (‹ 2 / 5 ›) step
through them.
- **Steps** (`step_preview`, off by default): the switch left of
such as a folder of images), they all show side by side. The arrows
beside the switch (‹ all 5 ›) go on to each one by itself (‹ 2 / 5 ›) and
back round to all of them. A run of more than 48 shows one at a time.
The node shows a small copy of each picture (up to 2048 pixels on the
long side), so a big file shows at once on a slow connection too. The
saved file is always full size. The copies are in ComfyUI's temp folder.
- **Auto save** (`autosave`, on by default): on, every run saves its
pictures. Off, a run only shows them here and saves nothing. A row over
the picture then says **Not saved yet**, with a **Save picture** button.
With several pictures it reads **Save all 4**, and with the arrows on one
picture, **Save this one**. After a save the row says what is saved.
Use it to make a batch, look, and keep only the good ones. The pictures
are saved under the name and folder set above, in the format picked,
with the workflow inside if **Embed workflow** is on. They wait in
ComfyUI's temp folder, which is emptied when ComfyUI restarts, so save
what you want before that.
- **Preview steps** (`step_preview`, off by default): the switch left of
**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;
+1 -1
View File
@@ -9,7 +9,7 @@ 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
**Preview 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
+5 -3
View File
@@ -67,9 +67,11 @@ are in the model's own picture.
Tone match 0 the new area is bit-identical to what the model painted, and
the whole stitch is the 2.4.0 blend in, pixel for pixel.
- With the classic seam, feeding the crop back unchanged reproduces the
original image exactly (with `fix_edge_halo` off; the fix rewrites the
feathered band on purpose). Blend in does not promise this: it keeps the
model's version of the thin strip at your picture's edge.
original image exactly only when there is no resizing in either direction,
with Tone match at 0 and `fix_edge_halo` off. Resizing can lose detail even
when the crop comes back unchanged; the halo fix rewrites the feathered
band on purpose. Blend in does not promise this: it keeps the model's
version of the thin strip at your picture's edge.
- A stitcher built from one image broadcasts over any number of inpainted
frames. A stitcher built from **more** frames than came back is trimmed to
the leading ones with a console note - video models keep 8n+1 (LTX) or
+60
View File
@@ -0,0 +1,60 @@
# Text Lines
Runs what comes after once for each line of your text. Type five prompts,
one on each line, press Run once, and you get five pictures.
Use it for a list of prompts you typed, or for a list another node wrote,
such as a text model asked for "five different poses".
## How to use it
1. Type your lines in the box, or wire text into **text**.
2. Wire **line** to where one prompt would go, such as a text encoder's
prompt.
3. Run. Every node after it runs once for each line, and a Save Image at the
end saves one picture for each.
Empty lines are skipped. Models and pictures that do not depend on the line
are loaded once, not once for each line.
## Controls
- The text box (`text`): One line for each run.
- **Split at** (`split`): **each line** makes every line its own run.
**blank line** keeps lines together and starts the next run at an empty
line. Pick it when one prompt is several lines long.
- **Numbering** (`remove_numbering`): **remove** takes a number or bullet
off the start of each line, such as `1.`, `2)`, `-` or `[prompt 3]`.
Lists a text model writes usually have them. **keep** leaves every line
as written.
- **Limit** (`limit`): The most lines to run. 0 runs them all. Set it when
a text model might write more lines than you asked for.
## Outputs
- **line**: Each line, one at a time.
- **number**: The line's place in the list, starting at 1. Add it to a seed
with Math Expression (`a + b`) to give each line its own seed.
- **count**: How many lines run.
## Notes
- With no lines at all, the run stops at this node and says so.
- Every run uses the same seed unless you add **number** to it. Different
prompts still give different pictures.
- A line such as "2 women on a beach" or "1.5x zoom" keeps its number: only
a number followed by a period, bracket or colon counts as numbering.
## Technical details
**line** and **number** are ComfyUI lists (`OUTPUT_IS_LIST`), the same way
Image Folder hands out pictures. ComfyUI runs each later node once for each
item and keeps the results in order. A node that takes a whole list at once,
such as core Rebatch Images, gets all of them together.
Numbering that is removed: a number in square, round or CJK brackets with an
optional word before it (`[prompt 1]`, `(2)`), a word and a number followed by
a colon, period, bracket or dash (`Shot 3:`), a bare number followed by a
period, bracket or colon that has no digit right after it, and the bullets
`-`, `*` and `•` followed by a space. Only one marker is removed from each
line.
+5
View File
@@ -17,6 +17,7 @@ help panel.
| Pick a picture size by shape and megapixels, in place of Empty Latent | Latent Size (`AUSBOSS_NODES_Resolution`) |
| Resize a picture, or snap its size to a multiple | Image Resize (`AUSBOSS_NODES_ImageResize`), Align Image (`AUSBOSS_NODES_AlignImage`) |
| Read a picture's width and height | Image Size (`AUSBOSS_NODES_ImageSize`) |
| Load a picture and set its size or crop it (megapixels, longest side, a box, a ratio) in one node | Load Image (`AUSBOSS_NODES_LoadImage`) |
| Outpaint: load a picture and add empty space around it | Load Image + Pad (`AUSBOSS_NODES_LoadImagePad`) |
| Turn, crop and pad a picture in one node | Image Crop + Rotate + Pad (`AUSBOSS_NODES_ImageCropRotatePad`) |
| Inpaint only the masked part, then put it back | Crop For Inpaint (`AUSBOSS_NODES_CropForInpaint`) then Stitch Inpaint (`AUSBOSS_NODES_StitchInpaint`) |
@@ -24,6 +25,7 @@ help panel.
| Get a mask by typing what to find | Mask by Name (`AUSBOSS_NODES_MaskByName`) |
| Get a mask by clicking the thing | Mask by Touch (`AUSBOSS_NODES_MaskByTouch`) |
| Grow, shrink, fill or smooth a mask | Mask Refine (`AUSBOSS_NODES_RefineMask`) |
| Keep the untouched parts of a picture exactly as they were while an edit model edits it | Edit Lock (`AUSBOSS_NODES_EditLock`) then Edit Lock Restore (`AUSBOSS_NODES_EditLockRestore`) |
| Line an edited picture back up with the original | Realign to Source (`AUSBOSS_NODES_RealignToSource`, experimental) |
| Match one picture's colors to another | Color Match (`AUSBOSS_NODES_ColorMatch`) |
| Upscale big pictures in tiles | Tiled Upscale (`AUSBOSS_NODES_TiledUpscale`) then Tiled Upscale Stitch (`AUSBOSS_NODES_TiledUpscaleStitch`) |
@@ -37,8 +39,11 @@ help panel.
| Load a sound file and keep only part of it, up to a set length | Load Audio (`AUSBOSS_NODES_LoadAudio`) |
| Turn, crop and pad a video: one frame, or the whole clip | Video Crop + Rotate + Pad → Frame (`AUSBOSS_NODES_VideoCropRotatePad`), → Clip (`AUSBOSS_NODES_VideoCropRotatePadClip`) |
| Change a clip's frame rate | Frame Interpolate (`AUSBOSS_NODES_FrameInterpolate`) |
| Make a clip larger and sharper | FlashVSR Upscale (`AUSBOSS_NODES_FlashVSRUpscale`) |
| Work with frame batches | Select Frame, Select Every Nth, Split Batch, Merge Batches (`AUSBOSS_NODES_SelectFrame`, `_SelectEveryNth`, `_SplitBatch`, `_MergeBatches`) |
| Run a workflow once for each line of text, such as a list of prompts | Text Lines (`AUSBOSS_NODES_TextLines`) |
| One shared seed, number or text | Seed, Integer, Float, Text (`AUSBOSS_NODES_Seed`, `_Integer`, `_Float`, `_Text`) |
| A prompt that picks from `{a\|b\|c}` choices on each run, by seed | Prompt Variations (`AUSBOSS_NODES_PromptVariations`) |
| Do maths on numbers | Math Expression (`AUSBOSS_NODES_MathExpression`) |
| Show text on the node, or the picture while it is made | Show Text (`AUSBOSS_NODES_ShowText`), Step Preview (`AUSBOSS_NODES_StepPreview`) |
| Free graphics memory between steps | Free Memory (`AUSBOSS_NODES_FreeMemory`) |
+57 -13
View File
@@ -26,6 +26,7 @@ import {
childOf,
countLine,
foldersAddress,
folderContext,
isPicked,
listAddress,
nameAt,
@@ -274,6 +275,7 @@ async function refresh(state) {
}
function scheduleRefresh(state, delay = 160) {
++state.token;
clearTimeout(state.timer);
state.timer = setTimeout(() => refresh(state), delay);
}
@@ -342,6 +344,7 @@ async function upload(file, subfolder, overwrite) {
// Loose pictures go into the folder the node shows.
async function addFiles(state, files) {
if (!state.alive) return false;
const now = settings(state.node);
const images = [...(files ?? [])].filter(isPicture);
if (!images.length) return false;
@@ -352,10 +355,13 @@ async function addFiles(state, files) {
const added = [];
let failed = 0;
for (const file of images) {
if (!state.alive) return true;
const name = await upload(file, now.folder, false);
if (!state.alive) return true;
if (name) added.push(name); else failed += 1;
}
await refresh(state);
if (!state.alive) return true;
// A hand-made pick takes the new pictures in: they were just put there to be used.
const picked = parsePicked(settings(state.node).pictures);
if (picked !== null && added.length) setPicked(state, [...picked, ...added.filter((name) => state.names.includes(name))]);
@@ -388,6 +394,7 @@ function closeAsk(answer = false) {
}
function askBig(state, name, pictures) {
if (!state.alive) return Promise.resolve(false);
const bytes = pictures.reduce((sum, item) => sum + (Number(item.file?.size) || 0), 0);
if (pictures.length <= BIG_COUNT && bytes <= BIG_BYTES) return Promise.resolve(true);
closeAsk(false);
@@ -404,7 +411,7 @@ function askBig(state, name, pictures) {
foot.append(cancel, go);
root.append(foot);
const abort = new AbortController();
openAsk = { root, abort, done };
openAsk = { root, abort, done, state };
cancel.addEventListener("click", () => closeAsk(false));
go.addEventListener("click", () => closeAsk(true));
document.body.append(root);
@@ -420,7 +427,17 @@ function askBig(state, name, pictures) {
// input/<its name>, folders inside it too, and the node moves there. Copying
// the same folder again writes over its copies instead of piling up doubles.
async function copyFolder(state, name, pictures) {
if (!(await askBig(state, name, pictures)) || !state.alive) return;
if (!state.alive || state.copyLocked) return;
state.copyLocked = true;
try {
if (!(await askBig(state, name, pictures)) || !state.alive) return;
await copyPictures(state, name, pictures);
} finally {
state.copyLocked = false;
}
}
async function copyPictures(state, name, pictures) {
const target = folderName(name);
const queue = pictures.slice(0, COPY_LIMIT);
state.copying = { done: 0, total: queue.length, name, target };
@@ -431,7 +448,9 @@ async function copyFolder(state, name, pictures) {
for (;;) {
const next = queue.shift();
if (!next || !state.alive) return;
if (await upload(next.file, next.folder ? `${target}/${next.folder}` : target, true)) copied += 1;
const added = await upload(next.file, next.folder ? `${target}/${next.folder}` : target, true);
if (!state.alive) return;
if (added) copied += 1;
state.copying.done += 1;
paint(state);
cover(state);
@@ -444,6 +463,7 @@ async function copyFolder(state, name, pictures) {
if (!state.alive) return;
const moved = write(state.node, "source", "input") | write(state.node, "folder", target);
if (moved) notifyAusbossChange(); else await refresh(state);
if (!state.alive) return;
paint(state);
showToast({ detail: `Copied ${copied} picture${copied === 1 ? "" : "s"} from "${name}" into your ComfyUI input folder (input/${target}).` });
if (copied < total) showToast({ severity: "warn", detail: `${total - copied} could not be copied.` });
@@ -506,7 +526,7 @@ function addMenu(state, anchor) {
closeMenu();
const root = el("div", "ausboss-if-menu");
const abort = new AbortController();
openMenu = { root, abort };
openMenu = { root, abort, state };
const row = (text, title, run) => {
const button = el("button", "ausboss-if-browse-row", text);
button.type = "button"; button.title = title;
@@ -545,19 +565,25 @@ async function browse(state, anchor) {
foot.append(cancel, use);
root.append(path, list, foot);
const abort = new AbortController();
openBrowse = { root, abort };
openBrowse = { root, abort, state };
let at = now.folder;
let source = now.source;
let navigation = 0;
const show = async (folder) => {
const token = ++navigation;
use.disabled = true;
let data = null;
try {
const response = await api.fetchApi(foldersAddress({ source: now.source, folder }));
const response = await api.fetchApi(foldersAddress({ source, folder }));
if (response.ok) data = await response.json();
} catch { /* shown as an empty list below */ }
if (openBrowse?.root !== root) return;
if (openBrowse?.root !== root || token !== navigation || !state.alive) return;
if (!data && folder) return show(""); // a folder that is gone: start at the top
at = data?.folder ?? "";
path.replaceChildren(document.createTextNode(`${now.source} / `), el("b", "", at || "(the folder itself)"));
source = data?.source ?? source;
use.disabled = false;
path.replaceChildren(document.createTextNode(`${source} / `), el("b", "", at || "(the folder itself)"));
list.replaceChildren();
if (at) {
const up = el("button", "ausboss-if-browse-row up", "↑ up");
@@ -582,7 +608,9 @@ async function browse(state, anchor) {
};
use.addEventListener("click", () => {
if (write(state.node, "folder", at)) notifyAusbossChange();
if (use.disabled || !state.alive) return;
const moved = write(state.node, "source", source) | write(state.node, "folder", at);
if (moved) notifyAusbossChange();
closeBrowse();
});
cancel.addEventListener("click", closeBrowse);
@@ -706,6 +734,12 @@ function buildPanel(node) {
const now = settings(node);
const key = `${now.source}|${now.folder}|${now.subfolders}|${now.sort}`;
if (key === state.key) { paint(state); return; }
const context = folderContext(now);
if (context !== state.context) {
state.done.clear();
state.loadedLine = "";
state.context = context;
}
// Another folder holds other names: a pick made in the old one means nothing there.
const moved = state.key && (now.source !== state.shown.source || now.folder !== state.shown.folder || now.subfolders !== state.shown.subfolders);
state.key = key;
@@ -725,7 +759,9 @@ 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, now.atTheEnd);
// The folder can change on disk or arrive through a link. Only the
// backend knows where it ends; a cached gallery must not cap "next".
const next = nextPosition(now.position, now.afterRun === "next" ? null : count, now.afterRun, now.atTheEnd);
if (next !== now.position) { position.value = next; position.callback?.(next); }
});
}
@@ -748,6 +784,7 @@ function buildPanel(node) {
});
const now = settings(node);
state.context = folderContext(now);
state.key = `${now.source}|${now.folder}|${now.subfolders}|${now.sort}`;
rebuild(state);
scheduleRefresh(state, 60);
@@ -773,12 +810,18 @@ app.registerExtension({
});
chainCallback(nodeType.prototype, "onConfigure", function (info) {
const state = buildPanel(this);
++state.token;
clearTimeout(state.timer);
state.done.clear();
state.loadedLine = "";
// A node that comes with a size keeps it, the smallest one too.
if (info?.size) state.sized = true;
// A loaded workflow brings its own folder and its own pick.
state.restoring = true;
queueMicrotask(() => {
if (!state.alive) return;
const now = settings(this);
state.context = folderContext(now);
state.key = `${now.source}|${now.folder}|${now.subfolders}|${now.sort}`;
state.restoring = false;
scheduleRefresh(state, 30);
@@ -789,6 +832,7 @@ app.registerExtension({
const state = this.__ausbossImageFolder;
const note = output?.ausboss_image_folder?.[0];
if (!state?.alive || !note) return;
if (!note.context || folderContext(note.context) !== folderContext(settings(this))) return;
for (const name of note.names ?? []) state.done.add(name);
const loaded = note.loaded ?? [];
state.loadedLine = loaded.length === 1
@@ -805,9 +849,9 @@ app.registerExtension({
state.abort.abort();
state.choosing?.abort();
clearTimeout(state.dropTimer);
if (openBrowse) closeBrowse();
if (openMenu) closeMenu();
if (openAsk?.root && state.grid.isConnected === false) closeAsk(false);
if (openBrowse?.state === state) closeBrowse();
if (openMenu?.state === state) closeMenu();
if (openAsk?.state === state) closeAsk(false);
});
},
});
+347 -46
View File
@@ -14,25 +14,28 @@
// switch lays each sampling step over the stage, with a Stop button on the
// bar (shared/step_preview.mjs).
import { panelSwitch } from "../shared/panel_controls.mjs";
import { panelSwitch, primaryAction } 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";
import { FONT_UI, RADIUS_CONTROL, RADIUS_XS, THEME as T } from "../shared/theme.mjs";
import { chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { ensureNodeMinHeight, fillNodeHeight, growToNodeFloor } from "../shared/panel_layout.mjs";
import { suppressCoreImagePreview } from "../shared/core_preview.mjs";
import { autoMaskValues } from "../shared/mask_auto.mjs";
import { hideInputsInDef, hideWidget, setWidgetVisible } from "../shared/widget_visibility.mjs";
import { makePictureStepper } from "../shared/picture_stepper.mjs";
import { keepIndex, stepIndex } from "../shared/picture_steps.mjs";
import { ALL, allLabel, gridShape, heldPlaces, keepIndex, keepWords, stepIndex, stepWithAll } from "../shared/picture_steps.mjs";
import { addStepConsumer, mountStepOverlay, showsStepsOn, stepRun, stopStepRun, watchSteps } from "../shared/step_preview.mjs";
import {
describeNodePreview,
describeSourcePreview,
lightPreviewQuery,
outputLocatorId,
outputRecordCount,
outputRecordQuery,
ownResultIsCurrent,
placeholderText,
staleText,
sourceFileWidget,
@@ -53,7 +56,15 @@ const BAR_GAP = 6;
const BAR_PADDING = 2;
const PANEL_HEIGHT = STAGE_HEIGHT + BAR_HEIGHT + 4 + 16;
const OFF_HEIGHT = BAR_HEIGHT + 16;
// The row over pictures that wait for a Save, and the gap under it.
const KEEP_HEIGHT = 30;
const KEEP_SPACE = KEEP_HEIGHT + 4;
const INPUT_SIDE = 1; // LiteGraph.INPUT
// A run that left this many pictures or fewer opens with all of them side
// by side. More than that and each would be a speck, so the arrows go
// through them one at a time, as before.
const GRID_MAX = 48;
const GRID_GAP = 4;
// The node's height from before its preview was switched off, so switching
// it back on returns the node to it (saved with the workflow).
const HEIGHT_PROPERTY = "ausboss_preview_height";
@@ -113,7 +124,17 @@ 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.", steps: true },
// SAVE shows only for a run made with Auto save off, which holds its
// pictures until this button keeps them.
AUSBOSS_NODES_SaveImage: {
inputName: "images",
noun: "an image",
switchTitle: "Show the saved image here. The file is saved either way.",
steps: true,
// Auto save off: a row over the picture says it is not saved yet and
// holds the button that saves it.
keep: "Save what is showing: every picture, or the one the arrows picked.",
},
};
// The picture sits out of flow, centred in the stage, so it can never set
@@ -130,28 +151,40 @@ function ensureCss() {
style.textContent = `
.ausboss-input-preview{box-sizing:border-box;display:flex;flex-direction:column;gap:4px;width:100%;height:100%;padding:0 6px 6px;pointer-events:none;overflow:hidden;}
.ausboss-input-preview-bar{box-sizing:border-box;flex:none;display:flex;align-items:center;gap:${BAR_GAP}px;height:${BAR_HEIGHT}px;padding:0 ${BAR_PADDING}px;pointer-events:none;}
.ausboss-input-preview-stage{position:relative;flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;width:100%;overflow:hidden;border:1px solid rgba(0,180,170,.27);border-radius:6px;background:rgba(0,0,0,.28);}
.ausboss-input-preview-stage{position:relative;flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;width:100%;overflow:hidden;border:1px solid ${T.rule};border-radius:${RADIUS_CONTROL}px;background:${T.well};}
[data-node-id] .ausboss-input-preview-stage{box-sizing:border-box;min-height:${STAGE_HEIGHT}px;}
.ausboss-input-preview.preview-off .ausboss-input-preview-stage{display:none;}
.ausboss-input-preview.preview-off .ausboss-stepper{display:none;}
.ausboss-input-preview-stage img,.ausboss-input-preview-stage video{display:none;position:absolute;inset:0;margin:auto;max-width:100%;max-height:100%;object-fit:contain;}
.ausboss-input-preview-stage.show-image img{display:block;}
.ausboss-input-preview-stage.loading-image img{opacity:.4;transition:opacity .3s ease 1.2s;}
.ausboss-input-preview-stage.show-video video{display:block;}
.ausboss-input-preview-hint{display:none;max-width:86%;color:#78908e;font:11px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:center;}
.ausboss-input-preview-stage:not(.show-image):not(.show-video) .ausboss-input-preview-hint{display:block;}
.ausboss-input-preview-grid{display:none;position:absolute;inset:0;box-sizing:border-box;gap:${GRID_GAP}px;padding:${GRID_GAP}px;justify-content:center;align-content:center;}
.ausboss-input-preview-stage.show-grid .ausboss-input-preview-grid{display:grid;}
.ausboss-input-preview-stage .ausboss-input-preview-grid img{display:block;position:static;margin:0;width:100%;height:100%;min-width:0;min-height:0;max-width:none;max-height:none;object-fit:contain;}
.ausboss-input-preview-hint{display:none;max-width:86%;color:${T.faint};font:11px/1.35 ${FONT_UI};text-align:center;}
.ausboss-input-preview-stage:not(.show-image):not(.show-video):not(.show-grid) .ausboss-input-preview-hint{display:block;}
.ausboss-input-preview-keep{box-sizing:border-box;flex:none;display:flex;align-items:center;justify-content:space-between;gap:10px;height:${KEEP_HEIGHT}px;padding:0 4px 0 10px;border-radius:${RADIUS_CONTROL}px;background:${T.brandSoft};pointer-events:none;}
.ausboss-input-preview-keep[hidden]{display:none;}
.ausboss-input-preview-keep span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${T.brandInk};font:600 11px/1.2 ${FONT_UI};}
.ausboss-input-preview-keep.done{background:${T.fill};}
.ausboss-input-preview-keep.done span{color:${T.label};}
.ausboss-input-preview-tools{display:flex;gap:4px;pointer-events:auto;}
.ausboss-input-preview-tool{box-sizing:border-box;height:${BAR_HEIGHT}px;min-width:24px;padding:0 6px;border:1px solid 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-tool{box-sizing:border-box;height:${BAR_HEIGHT}px;min-width:24px;padding:0 6px;border:1px solid transparent;border-radius:${RADIUS_XS}px;background:${T.fill};color:${T.label};font:600 9.5px/18px ${FONT_UI};letter-spacing:.02em;cursor:pointer;}
.ausboss-input-preview-tool[hidden]{display:none;}
.ausboss-input-preview-stage .ausboss-input-preview-grid img.kept{outline:2px solid ${T.brand};outline-offset:-2px;}
.ausboss-input-preview-tool:hover{color:${T.ink};background:${T.fillHover};}
.ausboss-input-preview-tool.active{color:${T.brandInk};background:${T.brandSoft};}
.ausboss-input-preview-tool:focus-visible{outline:2px solid ${T.brand};outline-offset:1px;}
.ausboss-input-preview:not(.preview-off) .ausboss-panel-switch.steps + .ausboss-panel-switch{margin-left:0;}
.ausboss-input-preview.preview-off .ausboss-panel-switch.steps{display:none;}
.ausboss-input-preview-tool.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{box-sizing:border-box;height:${BAR_HEIGHT}px;padding:0 7px;border:1px solid transparent;border-radius:999px;background:${T.brandSoft};color:${T.brandInk};font:600 9.5px/18px ${FONT_UI};font-variant-numeric:tabular-nums;white-space:nowrap;pointer-events:none;}
.ausboss-input-preview-readout:empty{display:none;}
.ausboss-input-preview-readout.warn{border-color:#c98a2b;background:rgba(201,138,43,.16);color:#ffd9a0;}
.ausboss-input-preview-note{display:none;position:absolute;left:6px;right:6px;bottom:6px;z-index:6;padding:6px 8px;border:1px solid #c98a2b;border-radius:6px;background:rgba(20,10,0,.86);color:#ffd9a0;font:600 11px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:center;pointer-events:none;}
.ausboss-input-preview-toast{position:absolute;left:50%;bottom:6px;z-index:5;max-width:88%;padding:3px 7px;border-radius:4px;background:rgba(0,0,0,.78);color:#b8d3d1;font:10px/1.3 "Segoe UI",sans-serif;text-align:center;transform:translateX(-50%);pointer-events:none;}
.ausboss-input-preview-readout.warn{background:${T.warnSoft};color:${T.warnInk};}
.ausboss-input-preview-note{display:none;position:absolute;left:6px;right:6px;bottom:6px;z-index:6;padding:6px 8px;border:1px solid ${T.warn};border-radius:${RADIUS_CONTROL}px;background:${T.warnSoft};color:${T.warnInk};font:600 11px/1.35 ${FONT_UI};text-align:center;pointer-events:none;}
.ausboss-input-preview-toast{position:absolute;left:50%;bottom:6px;z-index:5;max-width:88%;padding:3px 7px;border-radius:${RADIUS_XS}px;background:${T.fill};color:${T.inkSoft};font:10px/1.3 ${FONT_UI};text-align:center;transform:translateX(-50%);pointer-events:none;}
`;
document.head.appendChild(style);
}
@@ -334,7 +367,7 @@ function installStepsSwitch(state, signal) {
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.");
const button = panelSwitch("Preview steps", "Show the picture here while it is being made, one step at a time, with a Stop button. Off by default.");
button.classList.add("steps");
button.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
button.addEventListener("click", (event) => {
@@ -444,12 +477,12 @@ function syncPreviewMode(state, resize) {
}
if (!enabled) {
// Nothing to show and nothing to fetch while it is off.
state.img.removeAttribute("src");
clearMedia(state);
}
if (resize) {
const node = state.node;
node.properties ??= {};
const floor = node.computeSize?.()[1] ?? (enabled ? PANEL_HEIGHT : OFF_HEIGHT);
const floor = node.computeSize?.()[1] ?? panelFloor(state);
if (!enabled) {
// Off: remember the height, then shrink to what is left.
const height = Number(node.size?.[1]);
@@ -492,7 +525,7 @@ function buildTools(state, tools, signal) {
}
function showHint(state, text) {
state.stage.classList.remove("show-image", "show-video");
state.stage.classList.remove("show-image", "show-video", "show-grid");
state.hint.textContent = text;
state.placeholder = null;
releaseSteps(state);
@@ -507,24 +540,76 @@ function showPlaceholder(state, connected) {
function showMedia(state, kind) {
state.stage.classList.toggle("show-image", kind === "image");
state.stage.classList.toggle("show-video", kind === "video");
state.stage.classList.toggle("show-grid", kind === "grid");
state.placeholder = null;
releaseSteps(state);
}
function clearMedia(state) {
state.img.removeAttribute("src");
state.stage.classList.remove("loading-image");
state.video.pause?.();
state.video.removeAttribute("src");
clearGrid(state);
}
// Restore the watched widget's callback if our hook is still the tail of the
// chain; if someone chained after us the hook simply goes inert via
// state.alive. We never create object URLs, so there is nothing to revoke.
function clearGrid(state) {
if (!state.gridKey) return;
state.gridKey = "";
state.grid?.replaceChildren();
}
// Every picture of the run at once, each as big as the stage allows.
function layoutGrid(state) {
const cells = [...(state.grid?.children ?? [])];
if (!cells.length) return;
const loaded = cells.find((cell) => cell.naturalWidth > 0 && cell.naturalHeight > 0);
const aspect = loaded ? loaded.naturalWidth / loaded.naturalHeight : 1;
const { columns, rows } = gridShape(cells.length, state.stage.clientWidth, state.stage.clientHeight, aspect);
// Tracks the size of one picture, so the pictures sit together in the
// middle of the stage with no bands between the rows.
const wide = (state.stage.clientWidth - GRID_GAP * (columns + 1)) / columns;
const tall = (state.stage.clientHeight - GRID_GAP * (rows + 1)) / rows;
if (!(wide > 0) || !(tall > 0)) return;
const width = Math.floor(Math.min(wide, tall * aspect));
const height = Math.floor(width / aspect);
state.grid.style.gridTemplateColumns = `repeat(${columns}, ${width}px)`;
state.grid.style.gridTemplateRows = `repeat(${rows}, ${height}px)`;
}
function showGrid(state, count) {
const urls = Array.from({ length: count }, (_, index) => storedResultUrl(state.node, index) ?? "");
const key = urls.join("\n");
if (state.gridKey !== key) {
state.gridKey = key;
state.grid.replaceChildren(...urls.map((url, index) => {
const cell = document.createElement("img");
cell.alt = `Picture ${index + 1} of ${count}`;
cell.decoding = "async";
// The first picture to load tells the grid the shape to lay out for.
cell.addEventListener("load", () => layoutGrid(state), { once: true });
cell.addEventListener("error", () => dropLightPreview(state), { once: true });
cell.src = url;
return cell;
}));
}
state.img.removeAttribute("src");
state.video.pause?.();
state.video.removeAttribute("src");
layoutGrid(state);
showMedia(state, "grid");
}
// Each source has one hook, with subscribers that can leave in any order.
const sourceWatchers = new WeakMap();
function unwatchSource(state) {
const watched = state.watched;
state.watched = null;
if (watched && watched.widget.callback === watched.hook) {
if (!watched) return;
watched.subscribers.delete(state);
if (!watched.subscribers.size && watched.widget.callback === watched.hook) {
watched.widget.callback = watched.prior;
sourceWatchers.delete(watched.widget);
}
}
@@ -533,14 +618,21 @@ function rewatchSource(state, source) {
if (state.watched?.widget === widget) return;
unwatchSource(state);
if (!widget) return;
const prior = widget.callback;
const hook = function (...args) {
const result = prior?.apply(this, args);
if (state.alive) scheduleRefresh(state);
return result;
};
widget.callback = hook;
state.watched = { widget, prior, hook };
let watched = sourceWatchers.get(widget);
if (!watched) {
const prior = widget.callback;
const subscribers = new Set();
const hook = function (...args) {
const result = prior?.apply(this, args);
for (const subscriber of subscribers) if (subscriber.alive) scheduleRefresh(subscriber);
return result;
};
watched = { widget, prior, hook, subscribers };
sourceWatchers.set(widget, watched);
widget.callback = hook;
}
watched.subscribers.add(state);
state.watched = watched;
}
// The node's stored output as a /view URL, for Nodes 2.0, where node.imgs
@@ -549,17 +641,36 @@ function rewatchSource(state, source) {
// record: an overwritten file reloads after the next run, and a plain refresh
// never reloads the picture.
const storedRand = new WeakMap();
const outputRecord = (node) => app.nodeOutputs?.[outputLocatorId(node)];
// Save Image also writes a small copy of each picture for its face, and the
// panel shows that one: a few hundred kilobytes where the file can be tens
// of megabytes. The copies are in the temp folder, which ComfyUI empties
// when it starts; a record whose copy is gone goes back to the file itself.
const lightGone = new WeakSet();
function lightQuery(record, index = null) {
return record && !lightGone.has(record) ? lightPreviewQuery(record, index) : null;
}
function storedResultUrl(node, index = null) {
const record = app.nodeOutputs?.[outputLocatorId(node)];
const query = outputRecordQuery(record, index);
const record = outputRecord(node);
const light = lightQuery(record, index);
const query = light ?? outputRecordQuery(record, index);
if (!query) return null;
if (!storedRand.has(record)) storedRand.set(record, app.getRandParam?.() ?? "");
return api.apiURL(`/view?${query}${app.getPreviewFormatParam?.() ?? ""}${storedRand.get(record)}`);
return api.apiURL(`/view?${query}${light ? "" : app.getPreviewFormatParam?.() ?? ""}${storedRand.get(record)}`);
}
// A small copy that would not load: show the file itself from now on.
function dropLightPreview(state) {
const record = outputRecord(state.node);
if (!lightQuery(record)) return false;
lightGone.add(record);
scheduleRefresh(state);
return true;
}
// Several pictures from one run (a list, or a batch Save Image saved one by
// one): the arrows in the bar pick which one shows. One picture keeps the
// panel as it was, with no arrows.
// one): they all show side by side, and the arrows in the bar go on to each
// one by itself and back round to all of them. One picture keeps the panel
// as it was, with no arrows.
function resultCount(state) {
if (state.stale || state.resultGone) return 0;
return outputRecordCount(app.nodeOutputs?.[outputLocatorId(state.node)]);
@@ -575,21 +686,174 @@ function previewMinWidth(state) {
return Math.max(PANEL_MIN_WIDTH, Math.ceil(state.node.size[0] - state.bar.clientWidth + needed));
}
// --- pictures held until Save (Save Image with Auto save off) ---------------
// The pictures this node is holding, in the order it shows them. Empty when
// the last run saved its pictures itself.
function heldPictures(state) {
if (state.stale || state.resultGone) return [];
return heldPlaces(app.nodeOutputs?.[outputLocatorId(state.node)]);
}
// Which of the shown pictures are kept, by their place on the node.
function keptOf(state, held) {
const key = held.map((picture) => `${picture.token}_${picture.index}`).join(" ");
if (state.kept?.key !== key) state.kept = { key, indexes: new Set() };
return state.kept.indexes;
}
// A click keeps every picture, or only the one the arrows picked. With the
// preview off there is no picture to pick, so it keeps them all.
function keepsAll(state, count) {
return count < 2 || showsAll(state, count) || !previewEnabled(state);
}
// The row shows while the node holds pictures. It says what is saved, and
// its button what a click will do (keepWords).
function syncHeld(state, count = resultCount(state)) {
const row = state.keepRow;
if (!row) return;
const held = heldPictures(state);
const hidden = !held.length || count < 1;
if (row.hidden !== hidden) {
row.hidden = hidden;
fitKeepRow(state);
}
if (hidden) return;
const kept = keptOf(state, held);
const all = keepsAll(state, count);
const done = all ? kept.size >= held.length : kept.has(state.index);
const words = keepWords({
held: held.length, kept: kept.size, all, done, saving: Boolean(state.keeping), name: state.kept?.name ?? "",
});
state.keepNote.textContent = words.note;
state.keepNote.title = state.kept?.path ? `ComfyUI/output/${state.kept.path}` : "";
state.keepButton.textContent = words.button;
state.keepButton.classList.toggle("quiet", words.quiet);
state.keepButton.disabled = Boolean(state.keeping);
row.classList.toggle("done", words.quiet && kept.size >= held.length);
[...(state.grid?.children ?? [])].forEach((cell, index) => cell.classList.toggle("kept", kept.has(index)));
}
// The row takes room of its own, so the picture keeps its floor: the node
// grows to hold it, and a node with the preview off gives the room back.
function fitKeepRow(state) {
const node = state.node;
if (previewEnabled(state)) {
ensureNodeMinHeight(node);
} else {
const floor = Number(node.computeSize?.()?.[1]);
if (Number.isFinite(floor)) node.setSize?.([node.size?.[0] ?? PANEL_MIN_WIDTH, floor]);
}
node.setDirtyCanvas?.(true, true);
}
function buildKeepRow(state, title, signal) {
const row = document.createElement("div");
row.className = "ausboss-input-preview-keep";
row.hidden = true;
const note = document.createElement("span");
const button = primaryAction("Save picture", title);
// Stop the graph from seeing the click as a canvas drag on the node.
button.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
keepHeld(state);
}, { signal });
row.append(note, button);
state.keepRow = row;
state.keepNote = note;
state.keepButton = button;
return row;
}
async function keepHeld(state) {
const held = heldPictures(state);
if (!held.length || state.keeping) return;
const count = resultCount(state);
const wanted = keepsAll(state, count) ? held.map((_, place) => place) : [state.index];
// One request for each run, in the order the pictures show.
const runs = new Map();
for (const place of wanted) {
const picture = held[place];
if (!picture) continue;
if (!runs.has(picture.token)) runs.set(picture.token, []);
runs.get(picture.token).push(picture.index);
}
state.keeping = true;
syncHeld(state);
const kept = keptOf(state, held);
const saved = [];
let skipped = 0;
try {
for (const [token, picks] of runs) {
const response = await api.fetchApi("/ausboss/save_image/keep", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, picks }),
});
const answer = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(answer.error || "Could not save.");
if (!state.alive) return;
const done = new Set(answer.kept ?? []);
held.forEach((picture, place) => {
if (picture.token === token && done.has(picture.index)) kept.add(place);
});
saved.push(...(answer.saved ?? []));
// One picture: the row says the name it was saved under.
const path = held.length === 1 ? answer.saved?.[0]?.path : null;
if (typeof path === "string" && path && state.kept) {
state.kept.path = path;
state.kept.name = path.slice(path.lastIndexOf("/") + 1);
}
skipped += answer.skipped?.length ?? 0;
}
toast(state, saved.length === 1
? `Saved ${saved[0].path}`
: saved.length
? `Saved ${saved.length} pictures`
: skipped ? "Not saved: the file already exists" : "Already saved");
} catch (error) {
if (state.alive) toast(state, error?.message || "Could not save.");
} finally {
state.keeping = false;
if (state.alive) syncHeld(state);
}
}
function showsAll(state, count) {
return count > 1 && count <= GRID_MAX && state.all !== false;
}
function stepPicture(state, step) {
const count = resultCount(state);
if (count < 2) return;
state.index = stepIndex(state.index, count, step);
if (count > GRID_MAX) {
state.index = stepIndex(state.index, count, step);
} else {
const at = stepWithAll(showsAll(state, count) ? ALL : state.index, count, step);
state.all = at === ALL;
if (!state.all) state.index = at;
}
refresh(state);
}
function refresh(state) {
if (!state.alive) return;
if (!previewEnabled(state)) {
clearMedia(state);
syncHeld(state);
return;
}
const source = upstreamNode(state.node, state.inputName);
rewatchSource(state, source);
const count = resultCount(state);
const hidden = state.stepper.root.hidden;
state.index = keepIndex(state.index, count);
state.stepper.set(state.index, count);
const all = showsAll(state, count);
state.stepper.set(state.index, count, all ? allLabel(count) : undefined);
syncHeld(state, count);
if (state.stepper.root.hidden !== hidden) growToNodeFloor(state.widget);
if (state.stale) {
// The node ran while its preview was off, so the picture it still has
@@ -601,11 +865,20 @@ function refresh(state) {
// A node that just stopped the run has no result for this picture: the
// one it still holds is from an earlier run (Mask by Name's last find,
// tinted), so show what feeds it instead.
if (all) {
showGrid(state, count);
// The grid's cells are new: mark the ones already kept.
syncHeld(state, count);
return;
}
clearGrid(state);
const described = state.resultGone
? describeSourcePreview(source)
: count > 1
? { kind: "url", url: storedResultUrl(state.node, state.index) }
: describeNodePreview(state.node, state.inputName, storedResultUrl(state.node));
: describeNodePreview(state.node, state.inputName, storedResultUrl(state.node),
// The small copy goes first: node.imgs is the full file.
!lightQuery(outputRecord(state.node)) && ownResultIsCurrent(state.node, outputRecord(state.node), state.imgsFor));
if (!described) {
clearMedia(state);
showPlaceholder(state, !!source);
@@ -627,6 +900,9 @@ function refresh(state) {
state.video.removeAttribute("src");
if (state.img.getAttribute("src") !== url) {
state.img.src = url;
// The picture before stays up while this one loads. A slow load
// dims it, so an old picture is never taken for the new one.
state.stage.classList.toggle("loading-image", true);
} else if (state.img.complete && state.img.naturalWidth > 0) {
showMedia(state, "image");
}
@@ -649,8 +925,19 @@ function scheduleRefresh(state) {
// Watching the property rather than an api event catches both routes that set
// it - the progress frames streamed during a run and the ui payload delivered
// when the node finishes - without this file having to know either event name.
// The panel's floor: the bar and the picture, the bar alone with the
// preview off, and the Save row on top while it shows.
function panelFloor(state, enabled = state ? previewEnabled(state) : true) {
const keep = state?.keepRow && !state.keepRow.hidden ? KEEP_SPACE : 0;
return (enabled ? PANEL_HEIGHT : OFF_HEIGHT) + keep;
}
function watchOwnResult(node, state) {
let images = node.imgs;
// Which output the pictures in node.imgs were loaded for. The frontend
// gives up on a picture that loads slowly and keeps the run before, so
// the panel checks this before it trusts node.imgs (ownResultIsCurrent).
state.imgsFor = outputRecord(node);
try {
Object.defineProperty(node, "imgs", {
configurable: true,
@@ -658,6 +945,7 @@ function watchOwnResult(node, state) {
get: () => images,
set: (value) => {
images = value;
state.imgsFor = outputRecord(node);
if (state.alive) scheduleRefresh(state);
},
});
@@ -686,7 +974,9 @@ function buildPanel(node, config) {
const toastEl = document.createElement("div");
toastEl.className = "ausboss-input-preview-toast";
toastEl.style.display = "none";
stage.append(img, video, hint, toastEl);
const grid = document.createElement("div");
grid.className = "ausboss-input-preview-grid";
stage.append(img, video, grid, hint, toastEl);
const bar = document.createElement("div");
bar.className = "ausboss-input-preview-bar";
root.append(bar, stage);
@@ -696,7 +986,7 @@ function buildPanel(node, config) {
const widget = node.addDOMWidget(WIDGET_NAME, "ausboss_input_preview", root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => (enabled() ? PANEL_HEIGHT : OFF_HEIGHT),
getMinHeight: () => panelFloor(node.__ausbossInputPreview, enabled()),
});
keepDomWidgetWidthAuto(widget);
// Not saved with the workflow either: options.serialize only keeps it out
@@ -708,7 +998,7 @@ function buildPanel(node, config) {
// dragged taller. With the preview off the floor is the bar alone.
fillNodeHeight(widget, {
minWidth: () => previewMinWidth(node.__ausbossInputPreview),
minHeight: () => (enabled() ? PANEL_HEIGHT : OFF_HEIGHT),
minHeight: () => panelFloor(node.__ausbossInputPreview, enabled()),
minNodeSize: [PANEL_MIN_WIDTH, 90],
exactMinWidth: true,
});
@@ -720,7 +1010,10 @@ function buildPanel(node, config) {
noun: config.noun,
switchTitle: config.switchTitle,
advanced: config.advanced,
root, bar, stage, img, video, hint, widget, abort,
root, bar, stage, img, video, grid, hint, widget, abort,
// Several pictures open side by side; the arrows pick one (false).
all: true,
gridKey: "",
toast: toastEl,
toastTimer: 0,
toolButtons: null,
@@ -736,6 +1029,7 @@ function buildPanel(node, config) {
};
livePanels.add(state);
if (config.tools?.length) bar.append(buildTools(state, config.tools, abort.signal));
if (config.keep) bar.after(buildKeepRow(state, config.keep, abort.signal));
if (config.readout) {
state.readout = document.createElement("span");
state.readout.className = "ausboss-input-preview-readout";
@@ -754,9 +1048,16 @@ function buildPanel(node, config) {
installPreviewSwitch(state, abort.signal);
if (config.steps) installStepsSwitch(state, abort.signal);
img.addEventListener("load", () => showMedia(state, "image"));
// The grid is laid out for the stage's shape, so a resized node lays it out again.
if (typeof ResizeObserver === "function") {
const watcher = new ResizeObserver(() => { if (state.alive && state.gridKey) layoutGrid(state); });
watcher.observe(stage);
abort.signal.addEventListener("abort", () => watcher.disconnect(), { once: true });
}
img.addEventListener("load", () => { stage.classList.remove("loading-image"); showMedia(state, "image"); });
img.addEventListener("error", () => {
if (img.getAttribute("src")) showPlaceholder(state, true);
stage.classList.remove("loading-image");
if (img.getAttribute("src") && !dropLightPreview(state)) showPlaceholder(state, true);
});
video.addEventListener("loadeddata", () => showMedia(state, "video"));
video.addEventListener("error", () => {
@@ -825,9 +1126,9 @@ app.registerExtension({
if (state) scheduleRefresh(state);
});
chainCallback(nodeType.prototype, "onConfigure", function () {
const state = this.__ausbossInputPreview;
queueMicrotask(() => {
const state = buildPanel(this, config);
if (!state) return;
if (!state?.alive || this.__ausbossInputPreview !== state) return;
// onNodeCreated already built the panel before this node's saved
// properties existed, so the advanced toggle has to be re-applied
// here or a workflow always reopens in the simple state.
+3
View File
@@ -288,6 +288,9 @@ function syncSizes() {
const grabbed = together.grabbed;
if (!grabbed) return;
const was = together.before.get(grabbed);
// A handle click is not a resize. Once a drag starts, keep syncing even
// when it returns to its starting size so the other nodes follow it back.
if (!together.sides && grabbed.size[0] === was.size[0] && grabbed.size[1] === was.size[1]) return;
together.sides ??= sharedSides(
[...together.before.values()].map(({ pos, size }) => ({ x: pos[0], y: pos[1], w: size[0], h: size[1] })),
);
+25 -1
View File
@@ -47,6 +47,24 @@ async function stopLook(node) {
return Boolean(running || waiting.length);
}
// Take this node's own runs that still wait out of the queue: the looks and
// touches its picture asked for (they carry an `ask`), never a run of the
// workflow. Says how many went. The one that is running is left alone.
async function dropWaiting(node) {
const id = executionIdOf(app.rootGraph ?? app.graph, node);
if (!id) return 0;
const queue = await (await api.fetchApi("/queue")).json();
const ours = (entry) => {
const step = entry?.[2]?.[id];
return step?.class_type === NODE_CLASS && String(step.inputs?.ask ?? "") !== "";
};
const waiting = (queue?.queue_pending ?? []).filter(ours).map((entry) => entry[1]);
if (waiting.length) {
await api.fetchApi("/queue", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: waiting }) });
}
return waiting.length;
}
// The picture goes above the settings: the stage widget sits ahead of the
// node's card, whichever of the two was built first.
function stageFirst(node) {
@@ -92,6 +110,7 @@ function build(node) {
const face = mountTouchStage(node, {
lookOnButton: () => lookOnButton,
stopLook: () => stopLook(node).catch(() => false),
dropWaiting: () => dropWaiting(node).catch(() => 0),
// A workflow is being opened: nothing is run on the person's behalf.
loading: () => Boolean(app.configuringGraph),
// Bypassed or muted, or inside a box that is: nothing is run for it.
@@ -168,13 +187,18 @@ api.addEventListener("execution_error", ({ detail }) => {
api.addEventListener("execution_interrupted", () => settleAll());
// A look is one long, quiet run. The node sends what it has found every
// second or two, so things light up while it is still looking.
// A run that stops at the node (nothing picked on this picture) sends the
// picture that came in first, so a picture that changed upstream shows at
// once. It is shown like a kept note: it starts no run of its own.
api.addEventListener(LOOK_EVENT, ({ detail }) => {
try {
if (!faces.size || !detail?.note) return;
const id = String(detail.node ?? "");
const root = app.rootGraph ?? app.graph;
for (const face of faces) {
if (face.alive && face.inFlight && executionIdOf(root, face.node) === id) face.takeNote(detail.note, { interim: true });
if (!face.alive || executionIdOf(root, face.node) !== id) continue;
if (detail.stopped) face.takeNote(detail.note, { restored: true });
else if (face.inFlight) face.takeNote(detail.note, { interim: true });
}
} catch {
// Never in the way of a run.
+2 -3
View File
@@ -30,7 +30,7 @@ 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 { liftSlot, markSocketless } from "../shared/widget_card_math.mjs";
import { makeScrubInput } from "../shared/scrub_input.mjs";
import {
closeSettingsMenu,
@@ -1014,8 +1014,7 @@ function installResolutionNode(node) {
const liftSockets = () => {
for (const input of node.inputs ?? []) {
if (["width", "height", "latent", "batch_size"].includes(input.widget?.name)) {
delete input.widget;
input._widget = undefined;
liftSlot(input);
}
}
};
+11
View File
@@ -26,6 +26,7 @@ import { nodeByExecutionId } from "../shared/graph_ids.mjs";
import {
collapseRepeatedErrors,
emptySourceMessage,
explainRunError,
findEmptySources,
nodeLacksSource,
} from "../shared/run_check.mjs";
@@ -174,10 +175,20 @@ function collapseAnswers() {
api.queuePrompt = collapsing;
}
// A run that stops on an error the pack knows the cause of gets one toast
// that says what to do. ComfyUI's own error report is left as it is.
function explainKnownErrors() {
api.addEventListener("execution_error", ({ detail }) => {
const known = explainRunError(detail);
if (known) showToast({ severity: "warn", summary: known.summary, detail: known.detail, life: 30000 });
});
}
app.registerExtension({
name: "ausboss.run_check",
setup() {
guardRuns();
collapseAnswers();
explainKnownErrors();
},
});
+34 -5
View File
@@ -33,7 +33,7 @@ const CARD_HEIGHT = 288;
const WRAPPER_INSET = 16;
const HIDDEN = [
"filename_prefix", "format", "save_metadata", "exact_name", "on_existing",
"output_dir", "caption", "name_counter", "name_date", "name_time", "name_size", "name_batch",
"output_dir", "caption", "name_counter", "name_date", "name_time", "name_size", "name_batch", "autosave",
];
const FONT = `-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif`;
const MONO = `ui-monospace,SFMono-Regular,Menlo,Consolas,monospace`;
@@ -60,7 +60,10 @@ function ensureCss() {
.ausboss-save-chip:hover{color:#fff;border-color:${BRAND}}
.ausboss-save-chip.on{background:${BRAND};border-color:${BRAND};color:#04201d}
.ausboss-save-chips.off .ausboss-save-chip{opacity:.4;pointer-events:none}
.ausboss-save-embed-label{flex:0 0 30%;color:#8ba3a1;font-size:11px;user-select:none}
.ausboss-save-embed-label{flex:none;color:#8ba3a1;font-size:11px;white-space:nowrap;user-select:none}
.ausboss-save-half{display:flex;align-items:center;gap:6px;flex:1 1 auto;min-width:0}
.ausboss-save-half .ausboss-card-seg{flex:1 1 0;min-width:0}
.ausboss-save-switches{gap:12px}
.ausboss-save-browse{position:fixed;z-index:10000;width:260px;max-height:320px;overflow:auto;padding:4px;border:1px solid #3a4047;border-radius:7px;background:#1c1f23;box-shadow:0 8px 28px rgba(0,0,0,.5);font:12px/1.3 ${FONT};color:#d8ecea}
.ausboss-save-browse-head{padding:5px 8px 6px;border-bottom:1px solid #2c3238;color:#78908e;font:10px/1 ${FONT};letter-spacing:.08em;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ausboss-save-browse button{display:block;width:100%;padding:6px 8px;border:none;border-radius:5px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}
@@ -146,7 +149,7 @@ async function openBrowse(state, anchor) {
}
path = listing.path;
const use = el("button", "use", path ? `Use “${path}”` : "Use the output folder");
use.addEventListener("click", () => { setValue(state.node, "output_dir", path); state.refresh(); notifyAusbossChange(); closeBrowse(); });
use.addEventListener("click", () => { state.savedPath = null; setValue(state.node, "output_dir", path); state.refresh(); notifyAusbossChange(); closeBrowse(); });
popup.append(use);
if (path) {
const up = el("button", "", "↑ up one level");
@@ -282,9 +285,29 @@ function buildCard(node) {
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", PANEL_LABELS.embed), embedSeg);
const embedHalf = el("div", "ausboss-save-half");
embedHalf.append(el("span", "ausboss-save-embed-label", PANEL_LABELS.embed), embedSeg);
embedRow.classList.add("ausboss-save-switches");
embedRow.append(embedHalf);
root.append(embedRow);
// Auto save: off shows the pictures without saving them; the Save button
// over the picture (js/input_preview) then keeps the ones that are wanted.
// It shares the Embed workflow row, so the card is no taller than before.
const autoHalf = el("div", "ausboss-save-half");
const autoSeg = el("div", "ausboss-card-seg ausboss-card-bool");
autoSeg.setAttribute("role", "radiogroup");
const autoButtons = new Map();
const autoTitle = "On: every run saves its pictures. Off: the pictures only show here, and the Save button over them keeps the ones you want.";
for (const [on, text] of [[false, PANEL_LABELS.off], [true, PANEL_LABELS.on]]) {
const button = el("button", "", text);
button.type = "button"; button.title = autoTitle;
button.addEventListener("click", () => { if (setValue(node, "autosave", on)) { state.savedPath = null; notifyAusbossChange(); } state.refresh(); });
autoSeg.append(button); autoButtons.set(on, button);
}
autoHalf.append(el("span", "ausboss-save-embed-label", "Auto save"), autoSeg);
embedRow.append(autoHalf);
root.addEventListener("pointerdown", (event) => {
if (event.target.closest("button,input,select")) event.stopPropagation();
});
@@ -330,6 +353,11 @@ function buildCard(node) {
button.classList.toggle("on", on === embed);
button.setAttribute("aria-checked", String(on === embed));
}
const auto = values.autosave !== false;
for (const [on, button] of autoButtons) {
button.classList.toggle("on", on === auto);
button.setAttribute("aria-checked", String(on === auto));
}
};
const domWidget = node.addDOMWidget(WIDGET_NAME, WIDGET_NAME, root, {
@@ -370,7 +398,8 @@ function buildCard(node) {
chainCallback(node, "onExecuted", (output) => {
if (isForeignRun()) return;
const saved = output?.ausboss_saved_path?.[0];
if (typeof saved === "string" && saved) { state.savedPath = saved; state.refresh(); }
state.savedPath = typeof saved === "string" && saved ? saved : null;
state.refresh();
});
chainCallback(node, "onRemoved", () => { state.alive = false; closeBrowse(); });
state.refresh();
+2 -2
View File
@@ -39,7 +39,7 @@ function getLoopEnabled(node) {
return !!node.properties.ausboss_save_video_loop;
}
// The STEPS button: the hidden `step_preview` widget (the card hides it).
// The Preview steps switch: 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) {
@@ -131,7 +131,7 @@ function buildPreview(node) {
loopButton.classList.add("ausboss-steps-loop");
const stopButton = makeToolButton("STOP", "Stop this run now. The next run in the queue still starts.");
stopButton.classList.add("ausboss-steps-stop");
const stepsButton = panelSwitch("Steps", "Show the clip's first frame here while it is being made, one step at a time, with a Stop button. Off by default.");
const stepsButton = panelSwitch("Preview steps", "Show the clip's first frame here while it is being made, one step at a time, with a Stop button. Off by default.");
tools.classList.add("ausboss-steps-tools");
tools.append(stopButton, loopButton);
stage.append(video, image, status, tools);
+83
View File
@@ -289,3 +289,86 @@ export function repointLinks(text, nodes, groups) {
.replace(ARROW_RE, (all, _arrow, id) => (id === undefined ? all : `${all.slice(0, -id.length - 1)}${follow(`#${id}`)}`));
return changed ? next : null;
}
// Info card: a small card with a title, rows and small print, chosen from the
// note's menu. Its text is the note's ordinary text with one addition: a line
// with a | in it is a row, the words before it on the left and the rest on
// the right. A row in **bold** is the one to notice and is drawn in teal. The
// first line is the card's title; a clock, info, warning, bulb or tick emoji
// at its start is drawn as a small icon. A plain callout reads none of this,
// so every note written before looks as it did, and a release before this
// one shows the card as a plain note with its rows written out.
const CARD_ICONS = new Map([
["⏱", "clock"], ["⏲", "clock"], ["⏰", "clock"], ["\u{1F552}", "clock"], ["⌛", "clock"], ["⏳", "clock"],
["ℹ", "info"], ["⚠", "warning"], ["\u{1F4A1}", "tip"], ["✅", "check"], ["✔", "check"],
]);
const CARD_ICON_RE = new RegExp(`^(${[...CARD_ICONS.keys()].join("|")})${VS16}?\\s*`, "u");
export const CARD_TEMPLATE = [
"ℹ️ Title",
`${SMALL_MARK}A line of small print under the title.`,
"",
"Label | value",
"**The one to notice | value**",
"",
`${SMALL_MARK}A note at the bottom.`,
].join("\n");
// Where a row splits: the first | that is not inside a link, or -1.
function cellSplit(line) {
const masked = line.replace(WORDS_LINK_RE, (all) => " ".repeat(all.length));
return masked.indexOf("|");
}
// A row's two sides, or null when the line is not a row. The outer pipes of
// a pasted Markdown table row are dropped.
export function cardCells(line) {
const bare = String(line ?? "").trim().replace(/^\|/, "").replace(/\|$/, "");
const at = cellSplit(bare);
if (at < 0) return null;
let label = bare.slice(0, at).trim();
let value = bare.slice(at + 1).trim();
if (!label || !value) return null;
// **label | value** marks the whole row.
if (label.startsWith("**") && !label.slice(2).includes("**") && value.endsWith("**") && !value.slice(0, -2).includes("**")) {
label = `${label}**`;
value = `**${value}`;
}
return [label, value];
}
// Text -> blocks of lines. A line is { kind: "title", icon, pieces },
// { kind: "row", cells: [pieces, pieces], highlight } or { kind: "text",
// pieces }; small print carries `small: true`. A Markdown table's ---|---
// line is dropped.
export function parseCard(text) {
const blocks = [];
let lines = [];
let first = true;
const flush = () => {
if (lines.length) blocks.push(lines);
lines = [];
};
for (const raw of String(text ?? "").replace(/\r\n?/g, "\n").split("\n")) {
const line = raw.replace(HEADING_RE, "").trim();
if (!line) { flush(); continue; }
if (/^\|?[\s:|-]*-[\s:|-]*\|?$/.test(line) && line.includes("|")) continue;
const small = SMALL_RE.test(raw);
const cells = cardCells(line);
let item;
if (first && !small && !cells) {
const icon = CARD_ICON_RE.exec(line);
item = { kind: "title", icon: icon ? CARD_ICONS.get(icon[1]) : null, pieces: pieces(icon ? line.slice(icon[0].length) : line) };
} else if (cells) {
const sides = cells.map((cell) => pieces(cell));
const shown = sides.flat().filter((piece) => piece.arrow || piece.text?.trim());
item = { kind: "row", cells: sides, highlight: shown.length > 0 && shown.every((piece) => piece.bold) };
} else {
item = { kind: "text", pieces: pieces(line) };
}
first = false;
if (small) item.small = true;
lines.push(item);
}
flush();
return blocks;
}
+12 -5
View File
@@ -10,11 +10,15 @@
// The wheel belongs to the panel while it can still scroll that way (a long
// text box, a list of rows), or while a text field has the keyboard.
export function canScrollFurther(element, deltaY) {
if (!element || !deltaY) return false;
const room = element.scrollHeight - element.clientHeight;
export function canScrollFurther(element, delta, axis = "y") {
if (!element || !delta) return false;
const horizontal = axis === "x";
const size = horizontal ? element.scrollWidth : element.scrollHeight;
const visible = horizontal ? element.clientWidth : element.clientHeight;
const start = horizontal ? element.scrollLeft : element.scrollTop;
const room = size - visible;
if (room <= 1) return false;
return deltaY > 0 ? element.scrollTop + element.clientHeight < element.scrollHeight - 1 : element.scrollTop > 0;
return delta > 0 ? start + visible < size - 1 : start > 0;
}
// Does the wheel belong to something inside the panel, between the target
@@ -25,8 +29,11 @@ export function panelKeepsWheel(target, event, { root, host, styleOf, activeElem
for (let element = target; element && element !== root && element !== host; element = element.parentElement) {
const tag = String(element.tagName ?? "").toUpperCase();
if ((tag === "TEXTAREA" || tag === "INPUT") && element === activeElement) return true;
const overflow = styleOf(element)?.overflowY;
const style = styleOf(element);
const overflow = style?.overflowY;
if ((overflow === "auto" || overflow === "scroll") && canScrollFurther(element, event.deltaY)) return true;
const overflowX = style?.overflowX;
if ((overflowX === "auto" || overflowX === "scroll") && canScrollFurther(element, event.deltaX, "x")) return true;
}
return false;
}
+16 -8
View File
@@ -628,7 +628,7 @@ function installPointer(er, signal) {
}
async function save(er) {
if (er.saving || !er.refs) return;
if (er.signal.aborted || er.saving || !er.refs) return;
er.saving = true;
syncTools(er);
try {
@@ -644,6 +644,7 @@ async function save(er) {
})),
});
const answer = await response.json().catch(() => null);
if (er.signal.aborted) return;
if (!response.ok) {
// No words from the route: this ComfyUI started before the update.
const missing = (response.status === 404 || response.status === 405) && !answer?.error;
@@ -652,10 +653,11 @@ async function save(er) {
showToast({ severity: "success", summary: "Image Compare", detail: savedMessage(answer?.files) });
er.onSaved?.(answer?.files ?? []);
} catch (error) {
if (er.signal.aborted) return;
showToast({ severity: "error", summary: "Image Compare", detail: String(error?.message ?? error), life: 8000 });
} finally {
er.saving = false;
syncTools(er);
if (!er.signal.aborted) syncTools(er);
}
}
@@ -663,20 +665,24 @@ async function save(er) {
// to B as it came. The panel swaps in the pictures; Save and Copy then use
// the lined-up B, and the paint stays where it is.
async function align(er) {
if (er.aligning || !er.refs) return;
if (er.signal.aborted || er.aligning || !er.refs) return;
if (er.refs.aligned) {
er.onAligned?.(er.refs.original);
return;
}
er.aligning = true;
const refs = er.refs;
const picturesVersion = er.picturesVersion;
const current = () => !er.signal.aborted && er.refs === refs && er.picturesVersion === picturesVersion;
syncTools(er);
try {
const response = await api.fetchApi(ALIGN_ROUTE, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pair: er.refs.pair }),
body: JSON.stringify({ pair: refs.pair }),
});
const answer = await response.json().catch(() => null);
if (!current()) return;
if (!response.ok) {
// No words from the route: this ComfyUI started before the update.
const missing = (response.status === 404 || response.status === 405) && !answer?.error;
@@ -684,15 +690,16 @@ async function align(er) {
}
if (answer?.aligned && answer.b?.filename && typeof answer.pair === "string") {
showToast({ severity: "success", summary: "Image Compare", detail: answer.message, life: 4000 });
er.onAligned?.({ ...er.refs, b: answer.b, pair: answer.pair, aligned: true, original: er.refs });
er.onAligned?.({ ...refs, b: answer.b, pair: answer.pair, aligned: true, original: refs });
} else {
showToast({ severity: "info", summary: "Image Compare", detail: answer?.message || "B was left as it is.", life: 6000 });
}
} catch (error) {
if (!current()) return;
showToast({ severity: "error", summary: "Image Compare", detail: String(error?.message ?? error), life: 8000 });
} finally {
er.aligning = false;
syncTools(er);
if (!er.signal.aborted) syncTools(er);
}
}
@@ -764,7 +771,7 @@ function copy(er) {
// pictures change.
export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved, onAligned }) {
const er = {
node, root, stage, imageA, imageB, onSaved, onAligned,
node, root, stage, imageA, imageB, signal, onSaved, onAligned,
canvas: document.createElement("canvas"),
mask: document.createElement("canvas"),
live: document.createElement("canvas"),
@@ -772,7 +779,7 @@ export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved,
dab: document.createElement("canvas"),
dabKey: "",
maskScale: 1,
refs: null, width: 0, height: 0, fit: null,
refs: null, picturesVersion: 0, width: 0, height: 0, fit: null,
strokes: [], history: [],
active: false, stroke: null, adjust: null, pointer: null, back: false, flipKey: false,
saving: false, copying: false, frame: 0, keys: null,
@@ -985,6 +992,7 @@ export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved,
// Both pictures are loaded (or, with null, on their way). The paint is
// read from the node, and kept only while the picture keeps its size.
setPictures(refs) {
er.picturesVersion += 1;
const ready = Boolean(refs && imageA.naturalWidth > 0 && imageB.naturalWidth > 0);
er.refs = ready ? refs : null;
finishStroke(er, null, true);
+24
View File
@@ -0,0 +1,24 @@
// A click on the dark area around a modal <dialog> closes it, the way people
// expect any popup to work. The browser reports a click on a dialog's
// backdrop as a click on the dialog element itself, so a click counts as
// outside when it lands on the dialog element beyond its own box. Both the
// press and the release must be outside: a text selection or a drag that
// starts on the card and ends on the backdrop keeps the popup open.
export function pointOutside(rect, x, y) {
return x < rect.left || x > rect.right || y < rect.top || y > rect.bottom;
}
export function closeOnBackdropClick(dialog, close = () => dialog.close()) {
const outside = (event) => event.target === dialog
&& pointOutside(dialog.getBoundingClientRect(), event.clientX, event.clientY);
let pressedOutside = false;
dialog.addEventListener("pointerdown", (event) => {
pressedOutside = outside(event);
});
dialog.addEventListener("click", (event) => {
const started = pressedOutside;
pressedOutside = false;
if (started && outside(event)) close();
});
}
+8
View File
@@ -93,8 +93,10 @@ export function nameAt(picked, names, position, atTheEnd) {
// the first, so Picture always names a real picture. A Picture already past
// the end (saved by an older version) steps on from the picture it loaded.
// "random" jumps to any picked picture (`roll` is 0..1, for tests).
// With an unknown count, "next" keeps advancing and the backend handles the end.
export function nextPosition(position, count, afterRun, atTheEnd = "stop the run", roll = Math.random()) {
const place = Math.max(1, Math.round(Number(position) || 1));
if (count == null) return afterRun === "next" ? place + 1 : place;
if (count < 1) return place;
if (afterRun === "random") return Math.min(count, Math.floor(roll * count) + 1);
if (afterRun !== "next") return place;
@@ -124,6 +126,12 @@ export function childOf(folder, name) {
return [...String(folder ?? "").split("/").filter(Boolean), name].join("/");
}
// A relative name belongs to one folder, even when another has the same name.
export function folderContext({ source, folder, subfolders }) {
const relative = String(folder ?? "").trim().replaceAll("\\", "/").split("/").filter(part => part && part !== ".").join("/");
return JSON.stringify([source, relative, Boolean(subfolders)]);
}
// A query string. Every value is typed by a person or saved in a workflow.
export function query(params) {
return Object.entries(params)
+1 -1
View File
@@ -9,7 +9,7 @@ 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.21.2";
export const AUSBOSS_JS_VERSION = "2.25.0";
export const BRAND = "#00b4aa"; // AusBoss teal — keep node accents consistent.
export const BRAND_DARK = "#007f78";
+46 -3
View File
@@ -107,16 +107,59 @@ export function outputRecordQuery(record, index = null) {
}).toString();
}
// The /view query of the small copy Save Image writes for its face
// ({ ausboss_preview: ["ausboss_preview/<name>.webp"] }, one for each entry
// of images, in the temp folder). Null when the record has none: an older
// run, another node, or the preview switched off.
export function lightPreviewQuery(record, index = null) {
const places = record?.ausboss_preview;
const count = outputRecordCount(record);
if (!count || !Array.isArray(places) || places.length !== count) return null;
const at = Number.isInteger(index) && index >= 0 && index < count ? index : count - 1;
const place = places[at];
if (typeof place !== "string" || !place) return null;
const slash = place.lastIndexOf("/");
return new URLSearchParams({
filename: place.slice(slash + 1),
subfolder: slash >= 0 ? place.slice(0, slash) : "",
type: "temp",
}).toString();
}
// How many pictures the node's last run left in its output record.
export function outputRecordCount(record) {
return Array.isArray(record?.images) ? record.images.length : 0;
}
// Is node.imgs the picture of this output record?
//
// The frontend loads a result into node.imgs with a time limit (two tries of
// about 8 seconds). A picture that takes longer, a big one over a slow link,
// is dropped without a word and node.imgs keeps the run before. `loadedFor`
// is the record that was current when node.imgs was last set; the file name
// is compared as well, for a newer run that arrived while a picture loaded.
export function ownResultIsCurrent(node, record, loadedFor) {
const images = node?.imgs;
const src = Array.isArray(images) && images.length ? images[images.length - 1]?.src : null;
if (typeof src !== "string" || !src) return false;
const wanted = outputRecordQuery(record);
// No stored picture to compare with, or a frame streamed during the run.
if (!wanted || !/[?&]filename=/.test(src)) return true;
if (loadedFor !== record) return false;
const have = new URLSearchParams(src.slice(src.indexOf("?") + 1));
const want = new URLSearchParams(wanted);
return ["filename", "subfolder", "type"].every((key) => (have.get(key) ?? "") === (want.get(key) ?? ""));
}
// What the panel shows, best first: this node's result (node.imgs, then the
// URL of its stored output), then whatever feeds it, then nothing.
export function describeNodePreview(node, inputName, storedUrl = null) {
return describeOwnResult(node)
?? (storedUrl ? { kind: "url", url: storedUrl } : null)
// `ownIsCurrent` false: node.imgs is an older run's (ownResultIsCurrent), so
// the stored output goes first.
export function describeNodePreview(node, inputName, storedUrl = null, ownIsCurrent = true) {
const stored = storedUrl ? { kind: "url", url: storedUrl } : null;
return (ownIsCurrent ? null : stored)
?? describeOwnResult(node)
?? stored
?? describeSourcePreview(upstreamNode(node, inputName));
}
+211
View File
@@ -0,0 +1,211 @@
// The row under the file picker on Load Image: arrows that step through the
// input folder, where the file is in the list ("14 / 212"), and the size the
// picture goes in at and comes out at ("912 × 1632 → 1144 × 2048"). The
// widgets underneath stay the source of truth: an arrow only changes the
// image widget's value, the way picking from the list does.
//
// Under it, the preview: the picture as it goes in with the part that is
// kept lit, and the picture as it comes out, both on one scale, with what
// the size choice does in words. While the choice cuts something, a click
// on the first picture picks the part to keep.
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { CROP_MODES, changeText, cropSideAt, keptPart, placeText, planLoadSize, shapeText, sizeText, stepFile, thumbSizes } from "./load_size.mjs";
import { mediaViewQuery } from "./media_list.mjs";
import { FONT_UI, RADIUS_CONTROL, THEME as T } from "./theme.mjs";
import { commitWidgetValue, comboValues } from "./widget_card_math.mjs";
const CSS_ID = "ausboss-load-strip-css";
// Sizes already read, by file: stepping back to a picture shows its size at once.
const SIZES_KEPT = 200;
const sizes = new Map();
function ensureCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-load-strip{display:flex;align-items:center;gap:6px;height:100%;min-width:0;font:500 11px/1 ${FONT_UI};color:${T.label}}
.ausboss-load-step{flex:none;width:26px;height:100%;padding:0;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};color:${T.inkSoft};font:600 11px/1 ${FONT_UI};cursor:pointer}
.ausboss-load-step:hover{background:${T.fillHover};color:${T.inkHover}}
.ausboss-load-step:focus-visible{outline:2px solid ${T.brand};outline-offset:1px}
.ausboss-load-step:disabled{opacity:.4;cursor:default}
.ausboss-load-place{flex:none;color:${T.faint};font-variant-numeric:tabular-nums;white-space:nowrap;user-select:none}
.ausboss-load-size{flex:1 1 0;min-width:0;overflow:hidden;text-align:right;white-space:nowrap;text-overflow:ellipsis;color:${T.inkSoft};font-weight:600;font-variant-numeric:tabular-nums}
.ausboss-load-size b{color:${T.brandInk};font-weight:700}
.ausboss-load-size i{color:${T.faint};font-style:normal;margin:0 5px}
.ausboss-load-view{display:flex;align-items:center;justify-content:center;gap:10px;height:100%;min-width:0;font:500 10px/1.25 ${FONT_UI};color:${T.faint}}
.ausboss-load-thumb{position:relative;flex:none;overflow:hidden;border-radius:2px;background-color:${T.fill};background-repeat:no-repeat}
.ausboss-load-thumb.in{background-size:100% 100%}
.ausboss-load-thumb.in.pick{cursor:pointer}
.ausboss-load-thumb.out{outline:1px solid ${T.brand};outline-offset:0}
.ausboss-load-kept{position:absolute;box-sizing:border-box;border:1px solid ${T.brand};box-shadow:0 0 0 400px rgba(8,10,11,.68);pointer-events:none}
.ausboss-load-note{flex:0 1 auto;min-width:0;display:flex;flex-direction:column;align-items:center;gap:2px;text-align:center;user-select:none}
.ausboss-load-note b{color:${T.inkSoft};font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}
.ausboss-load-note span{white-space:nowrap}
`;
document.head.append(style);
}
const viewUrl = (value) => api.apiURL(`/view?${mediaViewQuery(value)}`);
// The picture's own size, read from the file the way the browser shows it.
function readSize(file, done) {
const known = sizes.get(file);
if (known) return known;
const picture = new Image();
picture.onload = () => {
if (sizes.size >= SIZES_KEPT) sizes.delete(sizes.keys().next().value);
sizes.set(file, { width: picture.naturalWidth, height: picture.naturalHeight });
done();
};
picture.src = viewUrl(file);
return null;
}
// The widest and tallest a preview picture is drawn.
const THUMB_WIDTH = 76;
const THUMB_HEIGHT = 68;
export function loadImagePreview(node) {
ensureCss();
const root = document.createElement("div");
root.className = "ausboss-load-view";
const input = document.createElement("div");
input.className = "ausboss-load-thumb in";
const kept = document.createElement("div");
kept.className = "ausboss-load-kept";
input.append(kept);
const note = document.createElement("div");
note.className = "ausboss-load-note";
const output = document.createElement("div");
output.className = "ausboss-load-thumb out";
root.append(input, note, output);
let last = {};
const side = () => node.widgets?.find((item) => item.name === "crop_from") ?? null;
const sideLinked = () => node.inputs?.find((slot) => slot?.widget?.name === "crop_from")?.link != null;
input.addEventListener("click", (event) => {
const widget = side();
if (!widget || !input.classList.contains("pick")) return;
const box = input.getBoundingClientRect();
if (!(box.width > 0 && box.height > 0)) return;
const next = cropSideAt((event.clientX - box.left) / box.width, (event.clientY - box.top) / box.height);
if (next === widget.value) return;
commitWidgetValue(node, widget, next, app.canvas);
node.setDirtyCanvas?.(true, true);
});
const line = (tag, text) => {
const item = document.createElement(tag);
item.textContent = text;
return item;
};
const sync = (values = last) => {
last = values;
const file = String(values.image ?? "");
const source = file ? readSize(file, () => { if (String(last.image ?? "") === file) sync(); }) : null;
const plan = source ? planLoadSize(source.width, source.height, values) : null;
root.style.visibility = plan ? "" : "hidden";
if (!plan) return;
const picture = `url(${JSON.stringify(viewUrl(file))})`;
const sizes = thumbSizes(source.width, source.height, plan, THUMB_WIDTH, THUMB_HEIGHT);
const part = keptPart(plan);
Object.assign(input.style, { width: `${sizes.input[0]}px`, height: `${sizes.input[1]}px`, backgroundImage: picture });
Object.assign(kept.style, {
left: `${part.x * 100}%`, top: `${part.y * 100}%`,
width: `${part.width * 100}%`, height: `${part.height * 100}%`,
});
const spareWide = plan.resizeWidth - plan.width;
const spareHigh = plan.resizeHeight - plan.height;
Object.assign(output.style, {
width: `${sizes.output[0]}px`, height: `${sizes.output[1]}px`, backgroundImage: picture,
backgroundSize: `${(plan.resizeWidth / plan.width) * 100}% ${(plan.resizeHeight / plan.height) * 100}%`,
backgroundPosition: `${spareWide > 0 ? (plan.cropX / spareWide) * 100 : 0}% ${spareHigh > 0 ? (plan.cropY / spareHigh) * 100 : 0}%`,
});
const picks = CROP_MODES.includes(values.size) && !sideLinked();
input.classList.toggle("pick", picks);
input.title = picks
? "The picture going in. The lit part is kept. Click the part you want to keep."
: "The picture going in. The lit part is kept.";
output.title = "The picture coming out.";
const from = shapeText(source.width, source.height);
const to = shapeText(plan.width, plan.height);
const change = changeText(source.width, source.height, plan);
note.replaceChildren(
line("b", from === to ? from : `${from} → ${to}`),
...(change ? change.split(" · ").map((text) => line("span", text)) : [line("span", "no change")]),
);
};
return { element: root, sync };
}
export function loadImageStrip(node) {
ensureCss();
const root = document.createElement("div");
root.className = "ausboss-load-strip";
const widget = () => node.widgets?.find((item) => item.name === "image") ?? null;
const linked = () => {
const slot = node.inputs?.find((input) => input?.widget?.name === "image" || input?.name === "image");
return slot?.link != null;
};
let last = {};
const step = (delta, label, title) => {
const button = document.createElement("button");
button.type = "button";
button.className = "ausboss-load-step";
button.textContent = label;
button.title = title;
button.addEventListener("click", () => {
const image = widget();
if (!image) return;
const next = stepFile(comboValues(image), image.value, delta);
if (next === null || next === image.value) return;
commitWidgetValue(node, image, next, app.canvas);
node.setDirtyCanvas?.(true, true);
});
root.append(button);
return button;
};
const back = step(-1, "◀", "The picture before this one in the input folder");
const on = step(1, "▶", "The next picture in the input folder");
const place = document.createElement("span");
place.className = "ausboss-load-place";
place.title = "Where this picture is in the input folder's list";
const size = document.createElement("span");
size.className = "ausboss-load-size";
root.append(place, size);
const sync = (values = last) => {
last = values;
const image = widget();
const files = comboValues(image);
const file = String(values.image ?? "");
place.textContent = placeText(files, file);
const stuck = linked() || stepFile(files, file, 1) === null;
back.disabled = stuck;
on.disabled = stuck;
const source = file ? readSize(file, () => { if (String(last.image ?? "") === file) sync(); }) : null;
size.replaceChildren();
if (!source) {
size.title = "";
return;
}
const plan = planLoadSize(source.width, source.height, values);
const text = sizeText(source.width, source.height, plan);
const [from, to] = text.split(" → ");
size.append(from);
if (to) {
const arrow = document.createElement("i");
arrow.textContent = "→";
const out = document.createElement("b");
out.textContent = to;
size.append(arrow, out);
}
size.title = to
? `The file is ${from}. The picture comes out at ${to}.`
: `The picture comes out at its own size, ${from}.`;
};
return { element: root, sync };
}
+206
View File
@@ -0,0 +1,206 @@
// Load Image: the size a picture comes out at, worked out in the browser so
// the node can show it before a run. This repeats plan_load_size in
// nodes/_load_image_helpers.py; tests/fixtures/load_size_cases.json holds
// both to the same answers. No DOM in here.
export const SIZE_MODES = Object.freeze(["off", "megapixels", "longest side", "scale", "fit inside", "crop to fill", "crop", "crop to ratio"]);
// Which part of the picture a crop keeps, as a 3 x 3 grid read row by row.
export const CROP_GRID = Object.freeze(["top left", "top", "top right", "left", "center", "right", "bottom left", "bottom", "bottom right"]);
// The choices that cut something off, so the kept part matters.
export const CROP_MODES = Object.freeze(["crop to fill", "crop", "crop to ratio"]);
const roundHalfUp = (value) => Math.floor(value + 0.5);
const whole = (value, fallback) => (Number.isFinite(Number(value)) ? Math.trunc(Number(value)) : fallback);
// Nearest multiple, never below one step.
const nearest = (value, step) => Math.max(step, roundHalfUp(value / step) * step);
// The largest multiple that is not above the value, never below one step.
const below = (value, step) => Math.max(step, Math.floor(value / step) * step);
// Rounded down to a multiple, or left alone when smaller than one step.
const inside = (value, step) => (value >= step ? Math.floor(value / step) * step : value);
// Where a cut starts along one edge with `spare` pixels to lose.
const cropStart = (spare, side, low, high) => (side.includes(low) ? 0 : side.includes(high) ? spare : Math.floor(spare / 2));
function ratioTerms(ratio, width, height) {
if (ratio === "width : height") return [Math.max(1, width), Math.max(1, height)];
const [across, down] = String(ratio ?? "1:1").split(":").map((term) => Math.trunc(Number(term)));
return [Math.max(1, across || 1), Math.max(1, down || 1)];
}
function target(sourceW, sourceH, size, { megapixels, longest_side: longestSide, scale, width, height }) {
const scaled = (factor) => [Math.max(1, roundHalfUp(sourceW * factor)), Math.max(1, roundHalfUp(sourceH * factor))];
if (size === "megapixels") {
const amount = Number(megapixels);
return amount > 0 ? scaled(Math.sqrt((amount * 1e6) / (sourceW * sourceH))) : [sourceW, sourceH];
}
if (size === "longest side") {
const edge = whole(longestSide, 0);
if (edge <= 0) return [sourceW, sourceH];
const [w, h] = scaled(edge / Math.max(sourceW, sourceH));
return sourceW >= sourceH ? [edge, h] : [w, edge];
}
if (size === "scale") {
const factor = Number(scale);
return factor > 0 ? scaled(factor) : [sourceW, sourceH];
}
const fit = Math.min(Math.max(1, whole(width, 1)) / sourceW, Math.max(1, whole(height, 1)) / sourceH);
return scaled(fit);
}
// { resizeWidth, resizeHeight, cropX, cropY, width, height } for a picture of
// sourceWidth x sourceHeight and the node's values, or null when the picture
// has no size yet or the choice is not one the node knows.
export function planLoadSize(sourceWidth, sourceHeight, values = {}) {
const sourceW = whole(sourceWidth, 0);
const sourceH = whole(sourceHeight, 0);
const size = values.size ?? "off";
if (sourceW <= 0 || sourceH <= 0 || !SIZE_MODES.includes(size)) return null;
const same = { resizeWidth: sourceW, resizeHeight: sourceH, cropX: 0, cropY: 0, width: sourceW, height: sourceH };
if (size === "off") return same;
const step = Math.max(1, whole(values.multiple, 1));
const enlarge = values.enlarge !== false;
const boxW = Math.max(1, whole(values.width, 1024));
const boxH = Math.max(1, whole(values.height, 1024));
const side = CROP_GRID.includes(values.crop_from) ? values.crop_from : "center";
const cut = (resizeWidth, resizeHeight, width, height) => ({
resizeWidth, resizeHeight,
cropX: cropStart(resizeWidth - width, side, "left", "right"),
cropY: cropStart(resizeHeight - height, side, "top", "bottom"),
width, height,
});
if (size === "crop") {
return cut(sourceW, sourceH, inside(Math.min(boxW, sourceW), step), inside(Math.min(boxH, sourceH), step));
}
if (size === "crop to ratio") {
const [across, down] = ratioTerms(values.ratio, boxW, boxH);
let width = sourceW;
let height = sourceH;
if (sourceW * down >= sourceH * across) width = Math.min(sourceW, Math.max(1, roundHalfUp((sourceH * across) / down)));
else height = Math.min(sourceH, Math.max(1, roundHalfUp((sourceW * down) / across)));
return cut(sourceW, sourceH, inside(width, step), inside(height, step));
}
if (size === "crop to fill") {
let width = boxW;
let height = boxH;
if (enlarge) {
width = nearest(width, step);
height = nearest(height, step);
} else {
const grow = Math.max(width / sourceW, height / sourceH);
if (grow > 1) {
width = Math.max(1, Math.floor(width / grow + 1e-6));
height = Math.max(1, Math.floor(height / grow + 1e-6));
}
width = Math.min(below(width, step), Math.max(step, sourceW));
height = Math.min(below(height, step), Math.max(step, sourceH));
}
const cover = Math.max(width / sourceW, height / sourceH);
const resizeWidth = Math.max(width, Math.ceil(sourceW * cover - 1e-6));
const resizeHeight = Math.max(height, Math.ceil(sourceH * cover - 1e-6));
return cut(resizeWidth, resizeHeight, width, height);
}
let [targetW, targetH] = target(sourceW, sourceH, size, { ...values, width: boxW, height: boxH });
if (!enlarge && (targetW > sourceW || targetH > sourceH)) [targetW, targetH] = [sourceW, sourceH];
const rounded = (wanted, source, box) => {
let value = nearest(wanted, step);
if (size === "fit inside" && value > box) value = below(box, step);
if (!enlarge && value > source) value = below(source, step);
return value;
};
const width = rounded(targetW, sourceW, boxW);
const height = rounded(targetH, sourceH, boxH);
return { resizeWidth: width, resizeHeight: height, cropX: 0, cropY: 0, width, height };
}
// "912 × 1632" for a picture that comes out as it went in, "912 × 1632 →
// 1144 × 2048" when its size changes, "" while the picture's size is unknown.
export function sizeText(sourceWidth, sourceHeight, plan) {
if (!(sourceWidth > 0 && sourceHeight > 0)) return "";
const from = `${sourceWidth} × ${sourceHeight}`;
if (!plan || (plan.width === sourceWidth && plan.height === sourceHeight)) return from;
return `${from} → ${plan.width} × ${plan.height}`;
}
// What a plan does to the picture, in a few words for the node: how much it
// is resized and how much is cut off, in the file's own pixels. "" when the
// picture comes out as it went in.
export function changeText(sourceWidth, sourceHeight, plan) {
if (!plan || !(sourceWidth > 0 && sourceHeight > 0)) return "";
const parts = [];
const percent = Math.round((plan.resizeWidth / sourceWidth) * 100);
if (plan.resizeWidth !== sourceWidth || plan.resizeHeight !== sourceHeight) parts.push(`${percent}% size`);
const cutWide = Math.round(((plan.resizeWidth - plan.width) * sourceWidth) / plan.resizeWidth);
const cutHigh = Math.round(((plan.resizeHeight - plan.height) * sourceHeight) / plan.resizeHeight);
if (cutWide > 0 && cutHigh > 0) parts.push(`cuts ${cutWide} × ${cutHigh} px`);
else if (cutWide > 0) parts.push(`cuts ${cutWide} px of width`);
else if (cutHigh > 0) parts.push(`cuts ${cutHigh} px of height`);
return parts.join(" · ");
}
const SHAPES = [[1, 1], [5, 4], [4, 3], [3, 2], [16, 10], [16, 9], [2, 1], [21, 9]];
// A picture's shape in words people use: "3:4", "16:9". A size within one
// percent of a common shape gets that name (rounding to a multiple moves a
// 16:9 picture a few pixels off it); any other gets "~1.34:1" or "~1:1.34".
export function shapeText(width, height) {
if (!(width > 0 && height > 0)) return "";
const wide = width >= height;
const value = wide ? width / height : height / width;
const near = SHAPES.find(([across, down]) => Math.abs(value - across / down) <= (across / down) * 0.01);
if (near) return wide ? `${near[0]}:${near[1]}` : `${near[1]}:${near[0]}`;
const rounded = Math.round(value * 100) / 100;
return wide ? `~${rounded}:1` : `~1:${rounded}`;
}
// The part of the file a plan keeps, as fractions of the file's width and
// height: { x, y, width, height }, all 0 to 1.
export function keptPart(plan) {
if (!plan) return { x: 0, y: 0, width: 1, height: 1 };
return {
x: plan.cropX / plan.resizeWidth,
y: plan.cropY / plan.resizeHeight,
width: Math.min(1, plan.width / plan.resizeWidth),
height: Math.min(1, plan.height / plan.resizeHeight),
};
}
// The sizes two small pictures are drawn at so their difference shows: both
// on one scale, the larger filling a box of maxWidth x maxHeight, and neither
// edge under `least` pixels.
export function thumbSizes(sourceWidth, sourceHeight, plan, maxWidth, maxHeight, least = 10) {
const outW = plan?.width ?? sourceWidth;
const outH = plan?.height ?? sourceHeight;
const scale = Math.min(maxWidth / Math.max(sourceWidth, outW), maxHeight / Math.max(sourceHeight, outH));
const at = (value) => Math.max(least, Math.round(value * scale));
return { input: [at(sourceWidth), at(sourceHeight)], output: [at(outW), at(outH)] };
}
// The crop side under a click at (x, y), each 0 to 1 across the picture.
export function cropSideAt(x, y) {
const third = (value) => (value < 1 / 3 ? 0 : value < 2 / 3 ? 1 : 2);
return CROP_GRID[third(y) * 3 + third(x)];
}
// The file one step before or after the current one in the list, going
// round at the ends. A file that is not in the list steps to the first or
// the last. null when the list has nothing to step to.
export function stepFile(files, current, delta) {
const list = (Array.isArray(files) ? files : []).filter((name) => name !== "" && name != null);
if (!list.length) return null;
const at = list.indexOf(current);
if (at < 0) return delta < 0 ? list[list.length - 1] : list[0];
if (list.length === 1) return null;
return list[(at + (delta < 0 ? -1 : 1) + list.length) % list.length];
}
// "14 / 212": where the current file is in the list. "" for an empty list,
// "– / 212" for a file that is not in it.
export function placeText(files, current) {
const list = (Array.isArray(files) ? files : []).filter((name) => name !== "" && name != null);
if (!list.length) return "";
const at = list.indexOf(current);
return `${at < 0 ? "–" : at + 1} / ${list.length}`;
}
+30 -12
View File
@@ -1,30 +1,35 @@
// 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";
import { FONT_UI, RADIUS_XS, THEME as T } from "./theme.mjs";
export const PANEL_LABELS = Object.freeze({
load: "Load", sources: { input: "Inputs", output: "Outputs" },
embed: "Embed workflow", off: "Off", on: "On",
});
const controlFont = '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;`;
const controlFont = FONT_UI;
export const CAPTION_TYPE = `font:600 11px/1.25 ${controlFont};color:${T.label};`;
export const HINT_TYPE = `font:400 10px/1.25 ${controlFont};color:${T.faint};`;
export function ensurePanelControls() {
if (document.getElementById("ausboss-panel-controls")) return;
const style = document.createElement("style");
style.id = "ausboss-panel-controls";
style.textContent = `
.ausboss-compact-action{box-sizing:border-box;flex:none;height:22px;min-width:24px;padding:0 7px;border:1px solid #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{box-sizing:border-box;flex:none;height:22px;min-width:24px;padding:0 7px;border:1px solid transparent;border-radius:${RADIUS_XS}px;background:${T.fill};color:${T.inkSoft};font:600 11px/1 ${controlFont};white-space:nowrap;cursor:pointer;pointer-events:auto}
.ausboss-compact-action:hover:enabled{color:${T.ink};background:${T.fillHover}}
.ausboss-compact-action.active{color:${T.brandInk};background:${T.brandSoft}}
.ausboss-compact-action:disabled{opacity:.5;cursor:default}
.ausboss-panel-switch{display:inline-flex;align-items:center;gap:6px;align-self:flex-end;flex:none;box-sizing:border-box;min-height:20px;margin-left:auto;padding:0 2px;background:transparent;border:0;color:#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}
.ausboss-panel-switch{display:inline-flex;align-items:center;gap:6px;align-self:flex-end;flex:none;box-sizing:border-box;min-height:20px;margin-left:auto;padding:0 2px;background:transparent;border:0;color:${T.label};font:600 11px/1 ${controlFont};cursor:pointer;user-select:none;pointer-events:auto}
.ausboss-panel-switch i{position:relative;width:24px;height:13px;border-radius:7px;background:${T.switchOff}}
.ausboss-panel-switch i::after{content:"";position:absolute;top:2px;left:2px;width:9px;height:9px;border-radius:50%;background:${T.inkSoft}}
.ausboss-panel-switch[aria-checked=true] i{background:${T.brand}}
.ausboss-panel-switch[aria-checked=true] i::after{left:13px;background:${T.inkHover}}
.ausboss-primary-action{box-sizing:border-box;flex:none;height:22px;min-width:24px;padding:0 14px;border:0;border-radius:${RADIUS_XS}px;background:${T.brand};color:${T.onBrand};font:700 11px/1 ${controlFont};white-space:nowrap;cursor:pointer;pointer-events:auto}
.ausboss-primary-action:hover:enabled{filter:brightness(1.12)}
.ausboss-primary-action.quiet{background:${T.fill};color:${T.inkSoft}}
.ausboss-primary-action:disabled{opacity:.6;cursor:default}
.ausboss-compact-action:focus-visible,.ausboss-primary-action:focus-visible,.ausboss-panel-switch:focus-visible{outline:2px solid ${T.brand};outline-offset:1px}
`;
document.head.append(style);
}
@@ -39,6 +44,19 @@ export function compactAction(label, title = "") {
return button;
}
// The one thing a panel wants done next, as a solid button: Save Image's
// "Save picture" over a picture that is not saved yet. `quiet` (a class the
// panel sets) is the same button once it has nothing left to do.
export function primaryAction(label, title = "") {
ensurePanelControls();
const button = document.createElement("button");
button.type = "button";
button.className = "ausboss-primary-action";
button.textContent = label;
button.title = title;
return button;
}
export function panelSwitch(label, title) {
ensurePanelControls();
const button = document.createElement("button");
+9 -8
View File
@@ -3,7 +3,7 @@
// steps through its pairs with it, and the preview panel of Save Image and
// the other preview nodes through their results. It hides itself while
// there is one picture or none, so a single picture looks as it always did.
import { BRAND } from "./index.mjs";
import { FONT_UI, RADIUS_CONTROL, RADIUS_XS, THEME as T } from "./theme.mjs";
import { stepperLabel } from "./picture_steps.mjs";
const CSS_ID = "ausboss-picture-stepper-v1";
@@ -13,12 +13,12 @@ function ensureCss() {
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-stepper{box-sizing:border-box;flex:none;display:flex;align-items:stretch;height:var(--ausboss-stepper-height,24px);padding:1px;border:1px solid #2a3437;border-radius:6px;background:#0f1516;pointer-events:auto;user-select:none;}
.ausboss-stepper{box-sizing:border-box;flex:none;display:flex;align-items:stretch;height:var(--ausboss-stepper-height,24px);padding:1px;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};pointer-events:auto;user-select:none;}
.ausboss-stepper[hidden]{display:none;}
.ausboss-stepper button{box-sizing:border-box;width:20px;margin:0;padding:0;border:none;border-radius:4px;background:transparent;color:#8ba3a1;font:700 13px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;}
.ausboss-stepper button:hover{color:#fff;background:rgba(0,79,75,.78);}
.ausboss-stepper button:focus-visible{outline:1px solid ${BRAND};outline-offset:-1px;}
.ausboss-stepper span{min-width:34px;padding:0 3px;color:#d8eeee;font:700 10px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-variant-numeric:tabular-nums;text-align:center;white-space:nowrap;align-self:center;}
.ausboss-stepper button{box-sizing:border-box;width:20px;margin:0;padding:0;border:none;border-radius:${RADIUS_XS}px;background:transparent;color:${T.label};font:600 13px/1 ${FONT_UI};cursor:pointer;}
.ausboss-stepper button:hover{color:${T.ink};background:${T.fillHover};}
.ausboss-stepper button:focus-visible{outline:1px solid ${T.brand};outline-offset:-1px;}
.ausboss-stepper span{min-width:34px;padding:0 3px;color:${T.ink};font:600 10px/1 ${FONT_UI};font-variant-numeric:tabular-nums;text-align:center;white-space:nowrap;align-self:center;}
`;
document.head.appendChild(style);
}
@@ -52,9 +52,10 @@ export function makePictureStepper({ noun = "picture", height = 24, onStep, sign
root.append(arrow("‹", -1, `Previous ${noun}`), label, arrow("›", 1, `Next ${noun}`));
return {
root,
set(index, count) {
// `text` replaces the "2 / 5" for a stop that is not one picture.
set(index, count, text) {
root.hidden = !(count > 1);
label.textContent = stepperLabel(index, count);
label.textContent = text ?? stepperLabel(index, count);
},
};
}
+72
View File
@@ -19,3 +19,75 @@ export function stepIndex(index, count, step) {
export function stepperLabel(index, count) {
return count > 1 ? `${keepIndex(index, count) + 1} / ${count}` : "";
}
// The preview panel has one more stop, before picture 1: every picture at
// once. ALL stands for it where an index would be.
export const ALL = -1;
// One step back or on through "all, 1, 2 ... count", round at both ends.
export function stepWithAll(index, count, step) {
if (!(count > 0)) return ALL;
const stops = count + 1;
const at = index === ALL ? 0 : keepIndex(index, count) + 1;
const next = (((at + Math.sign(Number(step) || 0)) % stops) + stops) % stops;
return next === 0 ? ALL : next - 1;
}
export function allLabel(count) {
return count > 1 ? `all ${count}` : "";
}
// How to lay `count` pictures out in a box so each one draws as big as it
// can: the columns and rows that leave a picture of this shape (`aspect`,
// width over height) the most room.
export function gridShape(count, width, height, aspect = 1) {
const total = Math.max(1, Math.trunc(Number(count) || 0));
const shape = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
if (!(width > 0) || !(height > 0)) {
const columns = Math.ceil(Math.sqrt(total));
return { columns, rows: Math.ceil(total / columns) };
}
let best = { columns: 1, rows: total, size: 0 };
for (let columns = 1; columns <= total; columns += 1) {
const rows = Math.ceil(total / columns);
const size = Math.min(width / columns, (height / rows) * shape);
if (size > best.size) best = { columns, rows, size };
}
return { columns: best.columns, rows: best.rows };
}
// The held pictures in a Save Image result, in the order they show: each
// one's run token and its place in that run. A list run saves once for each
// item, so one result can hold several tokens. Empty when the result holds
// none, or when a picture in it is not one of the held ones.
export function heldPlaces(record) {
const tokens = new Set(Array.isArray(record?.ausboss_held) ? record.ausboss_held : []);
if (!tokens.size) return [];
const held = (Array.isArray(record.images) ? record.images : []).map((image) => {
const match = /^([0-9a-f]{16})_(\d{3})\./.exec(String(image?.filename ?? ""));
return match && tokens.has(match[1]) ? { token: match[1], index: Number(match[2]) } : null;
});
return held.length && held.every(Boolean) ? held : [];
}
// What the row over held pictures says, and what its button does.
// held how many pictures the node holds
// kept how many of them are saved already
// all a click keeps every picture (one picture, the grid, preview off)
// done nothing is left to keep where the click would act
// saving a save is on its way
// name the file a single picture was saved as, when known
// `quiet` is the button once it has nothing left to do.
export function keepWords({ held = 0, kept = 0, all = true, done = false, saving = false, name = "" } = {}) {
const note = !kept
? "Not saved yet"
: held < 2
? (name ? `Saved as ${name}` : "Saved")
: kept >= held ? `All ${held} saved` : `${kept} of ${held} saved`;
if (saving) return { note, button: "Saving...", quiet: false };
if (done) return { note, button: "Saved", quiet: true };
const button = held < 2
? "Save picture"
: !all ? "Save this one" : kept ? "Save the rest" : `Save all ${held}`;
return { note, button, quiet: false };
}
+49
View File
@@ -37,6 +37,7 @@ export const SOURCE_LOADERS = Object.freeze({
LoadImageOutput: picture("image"),
LoadVideo: video("file"),
AUSBOSS_NODES_ImageCropRotatePad: picture("image"),
AUSBOSS_NODES_LoadImage: picture("image"),
// A wired source_image replaces the file, so the file may stay blank.
AUSBOSS_NODES_LoadImagePad: (inputs) => (linked(inputs?.source_image) ? null : picture("image")(inputs)),
AUSBOSS_NODES_LoadAudio: sound("audio"),
@@ -160,3 +161,51 @@ export function collapseRepeatedErrors(nodeErrors) {
}
return nodeErrors;
}
// ------------------------------------------------------------ known run errors
//
// Some errors that stop a run come from outside the workflow and say nothing
// a person can act on ("KeyError: 'latent'"). Each entry here names one such
// error by what is in ComfyUI's own report, and says in plain words what to
// do. An entry is added only after the cause was found for real.
const KNOWN_RUN_ERRORS = Object.freeze([
{
// Muse MiniMax Director replaces part of H3 for every workflow, and that
// part reads a picture from every guide. ComfyUI's own H3 nodes also make
// guides that hold sound alone, which is how a clip keeps its sound.
matches: ({ type, message, traceback }) =>
type === "KeyError" && message.includes("'latent'") && traceback.includes("muse_minimax_hybrid_patch"),
summary: "Another node pack stopped this run",
detail:
"This error comes from the Muse MiniMax Director pack, not from this workflow. Turn that pack off in "
+ "Manager, restart ComfyUI and run again.",
},
{
// The w6a8 H3 files load from ComfyUI 0.38.0 on; older versions stop in
// the model loader on the name of the format.
matches: ({ message }) => message.includes("w6a8_int8"),
summary: "This model needs a newer ComfyUI",
detail:
"Your ComfyUI is too old to read this model file. Update ComfyUI to 0.38 or newer and run again.",
},
]);
/**
* Plain advice for a run error the pack knows the cause of, from the
* `execution_error` event's detail. `null` for every other error.
*/
export function explainRunError(detail) {
const report = {
type: String(detail?.exception_type ?? "").split(".").pop(),
message: String(detail?.exception_message ?? ""),
traceback: [].concat(detail?.traceback ?? []).join("\n"),
};
for (const known of KNOWN_RUN_ERRORS) {
try {
if (known.matches(report)) return { summary: known.summary, detail: known.detail };
} catch (_error) {
// A report in a shape this entry did not expect is simply not a match.
}
}
return null;
}
+9 -11
View File
@@ -25,10 +25,8 @@
// Shift's fine step and typed values are never snapped.
import { keepKeyInField } from "./canvas_passthrough.mjs";
// Keep in sync with BRAND in shared/index.mjs - importing it would pull
// /scripts/app.js into node:test, and this module's math must stay testable.
const BRAND = "#00b4aa";
// theme.mjs has no imports, so this module's math stays testable in node:test.
import { FONT_UI, RADIUS_CONTROL, THEME as T } from "./theme.mjs";
// Horizontal pixels of drag per step; small enough to feel light, large
// enough that a shaky click cannot change the value. The dead zone keeps a
@@ -181,19 +179,19 @@ function ensureScrubCss() {
// for one) carry blanket `section input` rules, and these must outrank
// them wherever the control is mounted.
style.textContent = `
.ausboss-scrub{display:flex;width:72px;height:24px;border:1px solid #3a4047;border-radius:5px;
background:#23272c;overflow:hidden;flex:none;color:#d7dde2;font:12px system-ui}
.ausboss-scrub:focus-within{border-color:${BRAND}}
.ausboss-scrub{display:flex;width:72px;height:24px;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;
background:${T.fill};overflow:hidden;flex:none;color:${T.ink};font:500 12px ${FONT_UI};font-variant-numeric:tabular-nums}
.ausboss-scrub:focus-within{border-color:${T.brand}}
.ausboss-scrub>.ausboss-scrub-input{flex:1 1 auto;min-width:0;width:100%;height:100%;border:none;
padding:0;margin:0;background:transparent;color:inherit;text-align:center;cursor:ew-resize;
user-select:none;font:inherit;outline:none;border-radius:0}
.ausboss-scrub>.ausboss-scrub-input:focus{cursor:text;user-select:text}
.ausboss-scrub>.ausboss-scrub-unit{flex:none;align-self:center;padding:0 3px 0 1px;color:#5f7674;font-size:9.5px;letter-spacing:.06em;text-transform:uppercase;text-align:center;user-select:none;pointer-events:none}
.ausboss-scrub>.ausboss-scrub-unit{flex:none;align-self:center;padding:0 3px 0 1px;color:${T.faint};font:600 10px ${FONT_UI};letter-spacing:.04em;text-transform:uppercase;text-align:center;user-select:none;pointer-events:none}
.ausboss-scrub>.ausboss-scrub-step{flex:none;width:14px;display:flex;flex-direction:column;
border-left:1px solid #3a4047}
border-left:1px solid transparent;background:${T.fillHover}}
.ausboss-scrub>.ausboss-scrub-step>button{flex:1 1 0;min-height:0;border:none;background:transparent;
color:#9ba2aa;cursor:pointer;padding:0;margin:0;display:grid;place-items:center;border-radius:0}
.ausboss-scrub>.ausboss-scrub-step>button:hover{color:${BRAND};background:rgba(255,255,255,.05)}
color:${T.label};cursor:pointer;padding:0;margin:0;display:grid;place-items:center;border-radius:0}
.ausboss-scrub>.ausboss-scrub-step>button:hover{color:${T.brand};background:rgba(255,255,255,.05)}
`;
document.head.append(style);
}
+9 -9
View File
@@ -2,7 +2,7 @@
// chosen one: the mode row of Image Compare A/B, and its off | on choices.
// The choices share one width, so the marker only has to move. With `fit`
// each choice is as wide as its own name, and the marker is measured.
import { BRAND } from "./index.mjs";
import { FONT_UI, RADIUS_CONTROL, RADIUS_XS, THEME as T } from "./theme.mjs";
const CSS_ID = "ausboss-slide-switch-v1";
@@ -11,17 +11,17 @@ function ensureCss() {
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-slide{position:relative;box-sizing:border-box;flex:none;display:grid;grid-auto-flow:column;grid-auto-columns:1fr;height:24px;padding:2px;border:1px solid #2a3437;border-radius:6px;background:#0f1516;}
.ausboss-slide{position:relative;box-sizing:border-box;flex:none;display:grid;grid-auto-flow:column;grid-auto-columns:1fr;height:24px;padding:2px;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};}
.ausboss-slide.fit{grid-auto-columns:max-content;}
.ausboss-slide.fit button{padding:0 7px;}
.ausboss-slide-mark{position:absolute;top:2px;bottom:2px;left:2px;border-radius:4px;background:${BRAND};box-shadow:0 0 7px rgba(0,180,170,.3);transition:transform .16s ease,width .16s ease;pointer-events:none;}
.ausboss-slide-mark{position:absolute;top:2px;bottom:2px;left:2px;border-radius:${RADIUS_XS}px;background:${T.brand};transition:transform .16s ease,width .16s ease;pointer-events:none;}
.ausboss-slide.none .ausboss-slide-mark{display:none;}
.ausboss-slide button{position:relative;box-sizing:border-box;min-width:0;height:100%;margin:0;padding:0 9px;border:none;border-radius:4px;background:transparent;color:#8ba3a1;font:700 10px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.02em;white-space:nowrap;cursor:pointer;transition:color .16s ease;}
.ausboss-slide button:hover{color:#fff;}
.ausboss-slide button.on,.ausboss-slide button.on:hover{color:#04201d;}
.ausboss-slide.off-left button.on:first-of-type,.ausboss-slide.off-left button.on:first-of-type:hover{color:#d8eeee;}
.ausboss-slide.off-left.is-off .ausboss-slide-mark{background:#33423f;box-shadow:none;}
.ausboss-slide button:focus-visible{outline:1px solid ${BRAND};outline-offset:1px;}
.ausboss-slide button{position:relative;box-sizing:border-box;min-width:0;height:100%;margin:0;padding:0 9px;border:none;border-radius:${RADIUS_XS}px;background:transparent;color:${T.label};font:600 11px/1 ${FONT_UI};white-space:nowrap;cursor:pointer;transition:color .16s ease;}
.ausboss-slide button:hover{color:${T.inkHover};}
.ausboss-slide button.on,.ausboss-slide button.on:hover{color:${T.onBrand};}
.ausboss-slide.off-left button.on:first-of-type,.ausboss-slide.off-left button.on:first-of-type:hover{color:${T.ink};}
.ausboss-slide.off-left.is-off .ausboss-slide-mark{background:${T.switchOff};}
.ausboss-slide button:focus-visible{outline:1px solid ${T.brand};outline-offset:1px;}
@media (prefers-reduced-motion: reduce){.ausboss-slide-mark,.ausboss-slide button{transition:none;}}
`;
document.head.appendChild(style);
+8 -8
View File
@@ -12,7 +12,7 @@
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { nodeByExecutionId } from "./graph_ids.mjs";
import { BRAND } from "./index.mjs";
import { FONT_UI, RADIUS_CONTROL, THEME as T } from "./theme.mjs";
import { outputRecordQuery } from "./input_preview.mjs";
import { isForeignRun } from "./prompt_scope.mjs";
import {
@@ -213,15 +213,15 @@ export function ensureStepCss() {
.ausboss-steps.on{display:block;}
.ausboss-steps-showing>img,.ausboss-steps-showing>video,.ausboss-steps-showing>.ausboss-input-preview-hint,.ausboss-steps-showing>.ausboss-video-status{visibility:hidden;}
.ausboss-steps img,.ausboss-steps.on img{display:block;position:absolute;inset:0;width:100%;height:100%;max-width:none;max-height:none;margin:0;object-fit:contain;visibility:visible;}
.ausboss-steps-line{position:absolute;left:0;top:0;height:3px;width:0;background:${BRAND};box-shadow:0 0 6px rgba(0,180,170,.5);transition:width .15s linear;}
.ausboss-steps-chip{position:absolute;left:6px;right:6px;bottom:6px;box-sizing:border-box;padding:4px 7px;border:1px solid rgba(255,255,255,.08);border-radius:5px;background:rgba(8,12,13,.84);color:#d8ecea;font:600 10.5px/1.35 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.ausboss-steps-line{position:absolute;left:0;top:0;height:3px;width:0;background:${T.brand};transition:width .15s linear;}
.ausboss-steps-chip{position:absolute;left:6px;right:6px;bottom:6px;box-sizing:border-box;padding:4px 7px;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:rgba(21,24,26,.88);color:${T.inkSoft};font:600 10.5px/1.35 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.ausboss-steps.stopped img{filter:brightness(.55) saturate(.7);}
.ausboss-steps.stopped .ausboss-steps-line{display:none;}
.ausboss-steps.stopped .ausboss-steps-chip{border-color:rgba(224,96,90,.55);color:#ffd0cc;}
.ausboss-steps-hint{position:absolute;left:50%;top:50%;max-width:86%;transform:translate(-50%,-50%);color:#78908e;font:11px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:center;}
.ausboss-steps-stop{border-color:rgba(224,96,90,.8)!important;background:rgba(58,28,27,.9)!important;color:#ffd9d6!important;}
.ausboss-steps-stop:hover{background:rgba(120,38,33,.95)!important;color:#fff!important;}
.ausboss-steps-stop::before{content:"";display:inline-block;width:6px;height:6px;margin-right:5px;border-radius:1px;background:#e0605a;vertical-align:1px;}
.ausboss-steps.stopped .ausboss-steps-chip{border-color:${T.danger};color:${T.dangerInk};}
.ausboss-steps-hint{position:absolute;left:50%;top:50%;max-width:86%;transform:translate(-50%,-50%);color:${T.faint};font:11px/1.35 ${FONT_UI};text-align:center;}
.ausboss-steps-stop{border-color:transparent!important;background:${T.dangerSoft}!important;color:${T.dangerInk}!important;}
.ausboss-steps-stop:hover{background:rgba(120,38,33,.95)!important;color:${T.inkHover}!important;}
.ausboss-steps-stop::before{content:"";display:inline-block;width:6px;height:6px;margin-right:5px;border-radius:1px;background:${T.danger};vertical-align:1px;}
.ausboss-video-tool.ausboss-steps-stop{display:none;}
.ausboss-video-tool.ausboss-steps-stop.live{display:inline-block;}
.ausboss-video-stage.is-still .ausboss-video-tools.ausboss-steps-tools{display:flex;}
+50
View File
@@ -0,0 +1,50 @@
// The pack's node-face palette, in one place. Cards, number boxes and
// switches read their colours, font and sizes from here instead of
// carrying their own literals.
//
// The look: solid gray controls with no outlines on a near-black card,
// neutral grays, and one teal accent for whatever is on or picked.
//
// No imports on purpose: scrub_input.mjs and the node:test suites load
// this file without the ComfyUI app.
export const THEME = Object.freeze({
brand: "#00b4aa", // the one accent: picked segment, switch on, focus ring
onBrand: "#04201d", // text on a brand fill
brandSoft: "rgba(0,180,170,.16)", // chip fill
brandSoftHover: "rgba(0,180,170,.26)",
brandInk: "#45d6cb", // chip text on brandSoft
brandGlow: "#6fe0d6", // the big text look
card: "#101214", // widget card ground
fill: "#2d3134", // every control: solid, no border
fillHover: "#383c40", // control hover, the chevron column
well: "#0f1112", // picture stage ground
rule: "#2b2f32", // hairline after a section label
switchOff: "#62676d", // the mark of a switch sitting on off
ink: "#f6f7f8", // values: numbers, picked options, typed text
inkSoft: "#e0e3e5", // default card text
inkHover: "#ffffff",
label: "#b4b9bd", // row labels, unselected segments, chevrons
faint: "#979da2", // units, section labels, placeholders, muted rows
danger: "#e0605a", // stop: the dot on the Stop button
dangerInk: "#ffd9d6",
dangerSoft: "#3a1c1b",
warn: "#e0a040",
warnInk: "#ffdca8",
warnSoft: "#3a2a10",
});
export const FONT_UI = `system-ui,-apple-system,"Segoe UI",Roboto,sans-serif`;
// Every control in a card row is as tall as the row.
export const CONTROL_HEIGHT = 26;
export const RADIUS_XS = 4;
export const RADIUS_CONTROL = 6;
export const RADIUS_CARD = 10;
// The select's chevron, drawn in `label`.
export const SELECT_CHEVRON = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23b4b9bd' stroke-width='1.5'/%3E%3C/svg%3E")`;
+59 -4
View File
@@ -57,9 +57,9 @@ export function goesBehind(flying, ask, epoch) {
// The pack's own picture loaders. One of them wired straight into Mask by
// Touch is run by the node itself, the way a press on Show picture runs it:
// it may crop, turn or pad its file, so the file alone is not the picture,
// it may crop, turn, pad or resize its file, so the file alone is not the picture,
// but running it costs next to nothing.
export const OWN_LOADERS = new Set(["AUSBOSS_NODES_ImageCropRotatePad", "AUSBOSS_NODES_LoadImagePad"]);
export const OWN_LOADERS = new Set(["AUSBOSS_NODES_ImageCropRotatePad", "AUSBOSS_NODES_LoadImage", "AUSBOSS_NODES_LoadImagePad"]);
// What a loader will hand on, as far as the page can tell: its file and its
// settings, as one text. Another text means another picture. Widgets that
@@ -74,6 +74,25 @@ export function loaderSignature(widgets) {
return JSON.stringify(settings);
}
// What the picture says when a run stopped at the node. ComfyUI's own text
// for a full graphics card is ten lines of numbers; here it is one plain
// line. Anything else is said as it came.
export const OUT_OF_MEMORY = "The graphics card ran out of memory, so the node could not look. Free some memory on it, then click again.";
export function stopMessage(message) {
const text = String(message ?? "");
return /out of memory|OutOfMemory/i.test(text) ? OUT_OF_MEMORY : text;
}
// A name as it is typed under the picture: "-face" (or the minus sign a
// phone types) takes the name away, "+face" adds it; without a sign it does
// what `takes` says. Returns { word, add }, the word cleaned.
export function typedName(text, takes = false) {
const typed = String(text ?? "").trim();
const sign = /^[-\u2212\u2013+]/.exec(typed)?.[0] ?? "";
const word = cleanWord(sign ? typed.slice(1) : typed);
return { word, add: sign ? sign === "+" : !takes };
}
// A word as the node sends it to SAM 3: commas, colons and brackets are the
// text reader's own syntax and go; spaces are collapsed.
export function cleanWord(word) {
@@ -268,6 +287,15 @@ export function encodeTouches(key, items) {
return JSON.stringify({ v: 1, key: String(key ?? ""), items: clean });
}
// The picks the node moved onto a new picture (New picture: keep picks), as
// the text to write: only while the widget still holds the picks they were
// moved from (`current`). Anything picked since is kept, and then this is null.
export function movedTouches(note, current) {
const moved = note?.moved;
if (!moved || typeof moved.touches !== "string" || typeof moved.from !== "string") return null;
return stampOf(String(current ?? "")) === moved.from ? moved.touches : null;
}
// Whether two runs of touches pick the same things. How they are written
// decides, so a touch fresh from a click and the same one read back from the
// widget compare equal.
@@ -1035,19 +1063,46 @@ export function strokePieces(planes, list, sizes, pixels, stroke, ranks, wholes
// picked area, and as untouched while almost none of it is.
const NEARLY = 0.03;
// Which pieces hold a whole pick: nearly all of one picked area (`areas`,
// on/off masks, one per chip) lies inside the piece. A person holds the
// sweater picked on them; hair that a picked jacket only laps over does not.
export const HOLDS = 0.9;
export function piecesHolding(planes, count, areas) {
const holds = new Uint8Array(count);
for (const area of areas ?? []) {
if (!area) continue;
let size = 0;
for (let index = 0; index < area.length; index += 1) if (area[index]) size += 1;
if (!size) continue;
const inside = piecesInside(planes, count, area);
for (let piece = 0; piece < count; piece += 1) if (inside[piece] >= HOLDS * size) holds[piece] = 1;
}
return holds;
}
// Which piece a pointer at pixel `index` lights up: the first piece there,
// in the order they are offered (`ranks`; without it, smallest first), that
// would still change something. Adding, that is a piece not yet (nearly)
// all picked, so clicking the same spot again goes on from the glasses to
// the face to the person. Taking away, it is a piece that has some of the
// picked area in it. -1 when there is none.
export function pieceAt(planes, index, { sizes, inside, adding = true, ranks = null } = {}) {
//
// Taking away from a spot that is not picked itself (`here` false) never
// means a thing that holds a whole pick somewhere else (`holds`, from
// piecesHolding): with a sweater picked, pointing at the face does not
// offer the person, which would take the sweater along.
export function pieceAt(planes, index, { sizes, inside, adding = true, ranks = null, here = true, holds = null } = {}) {
for (const piece of candidatesAt(planes, index, ranks)) {
const size = sizes?.[piece] ?? 0;
if (!size) continue;
const picked = inside?.[piece] ?? 0;
const slack = Math.max(4, size * NEARLY);
if (adding ? size - picked > slack : picked > slack) return piece;
if (adding) {
if (size - picked > slack) return piece;
continue;
}
if (picked <= slack || (!here && holds?.[piece])) continue;
return piece;
}
return -1;
}
+142 -30
View File
@@ -63,6 +63,7 @@ import {
loaderSignature,
maskOn,
maskShare,
movedTouches,
nameShown,
nextGroup,
numberChip,
@@ -76,6 +77,7 @@ import {
pieceRanks,
pieceWholes,
pieceSizes,
piecesHolding,
piecesInside,
prefixStamps,
previewPixels,
@@ -84,9 +86,11 @@ import {
sharedPixels,
shrinkMask,
stampOf,
stopMessage,
strokePieces,
thinStroke,
touchFits,
typedName,
} from "./touch_math.mjs";
const CSS_ID = "ausboss-touch-css";
@@ -95,8 +99,9 @@ export const NOTE_KEY = "ausboss_mask_by_touch";
const TOOLS_HEIGHT = 24;
const BAR_HEIGHT = 20;
const STAGE_MIN = 150;
// Wide enough for the bar under the picture: the arrows, a pick's name, Subject and Background.
export const PANEL_MIN_WIDTH = 400;
// Wide enough for the tools over the picture (Reload, Undo, Clear and the eye
// included) and the bar under it: the arrows, a pick's name, Subject and Background.
export const PANEL_MIN_WIDTH = 428;
// What the panel's element needs at its smallest: tools, stage and bar, two
// gaps and the padding under them.
const CHIPS_HEIGHT = 22;
@@ -104,9 +109,9 @@ const CHIPS_HEIGHT = 22;
const CHIP_ROWS = 3;
// A node this wide has room for the names in front of the brush's two
// numbers. A narrower one shows the numbers alone.
const LABELS_FROM = 440;
const LABELS_FROM = 470;
// The width a new node opens with: the names fit.
export const OPENING_WIDTH = 460;
export const OPENING_WIDTH = 490;
const PANEL_NEEDS = TOOLS_HEIGHT + STAGE_MIN + BAR_HEIGHT + CHIPS_HEIGHT + 12 + 6;
// Saved with the workflow: the brush size once it was set by hand, the snap
// switch, and how the picked area is shown.
@@ -134,6 +139,8 @@ const EYE_TITLES = {
// The eye beside Undo and Clear, drawn here: an almond and its pupil.
const EYE_ICON = '<svg width="16" height="16" viewBox="-8 -8 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true">'
+ '<path d="M-6.6 0Q0 -6 6.6 0Q0 6 -6.6 0Z"/><circle r="2" fill="currentColor" stroke="none"/></svg>';
const RELOAD_ICON = '<svg width="14" height="14" viewBox="-8 -8 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
+ '<path d="M5.6 -2.4A6 6 0 1 0 5.2 3"/><path d="M6 -6.2V-2.2H2"/></svg>';
// The brush size, across the picture, in picture pixels.
const BRUSH_SIZE = [4, 1000];
// A brush mark counts as touching an area within this many pixels of the
@@ -186,9 +193,17 @@ function ensureCss() {
.ausboss-touch-chip>button:hover{opacity:1;background:rgba(255,255,255,.14);}
.ausboss-touch-chip>button.more{padding:0 6px;background:rgba(0,0,0,.35);opacity:.95;}
.ausboss-touch-chip.open>button.more{background:rgba(0,0,0,.2);}
.ausboss-touch-name{box-sizing:border-box;flex:none;width:104px;height:20px;padding:0 9px;border:1px dashed rgba(0,180,170,.5);border-radius:10px;background:transparent;color:#dffcf9;font:600 10px/18px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;outline:none;}
.ausboss-touch-namebox{box-sizing:border-box;flex:none;display:inline-flex;align-items:stretch;height:20px;border:1px dashed rgba(0,180,170,.5);border-radius:10px;overflow:hidden;}
.ausboss-touch-namebox:focus-within{border-style:solid;border-color:${BRAND};background:rgba(0,0,0,.4);}
.ausboss-touch-namebox.away{border-color:rgba(${LIT_AWAY.join(",")},.6);}
.ausboss-touch-namebox.away:focus-within{border-color:rgb(${LIT_AWAY.join(",")});}
.ausboss-touch-sign{box-sizing:border-box;flex:none;width:22px;padding:0;border:0;border-right:1px solid rgba(0,180,170,.35);background:rgba(0,180,170,.16);color:#dffcf9;font:700 13px/18px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;}
.ausboss-touch-sign:hover{background:rgba(0,180,170,.34);color:#fff;}
.ausboss-touch-sign:disabled{opacity:.4;cursor:default;}
.ausboss-touch-namebox.away .ausboss-touch-sign{border-right-color:rgba(${LIT_AWAY.join(",")},.4);background:rgba(${LIT_AWAY.join(",")},.24);color:#ffe9e2;}
.ausboss-touch-namebox.away .ausboss-touch-sign:hover{background:rgba(${LIT_AWAY.join(",")},.42);}
.ausboss-touch-name{box-sizing:border-box;flex:none;width:118px;height:18px;padding:0 8px 0 6px;border:0;background:transparent;color:#dffcf9;font:600 10px/18px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;outline:none;}
.ausboss-touch-name::placeholder{color:#78908e;font-weight:500;}
.ausboss-touch-name:focus{border-style:solid;border-color:${BRAND};background:rgba(0,0,0,.4);}
.ausboss-touch-gap{flex:1 1 auto;min-width:0;}
.ausboss-touch-stage{position:relative;flex:1 1 auto;min-height:0;width:100%;overflow:hidden;border:1px solid rgba(0,180,170,.27);border-radius:6px;background:rgba(0,0,0,.28);}
[data-node-id] .ausboss-touch-stage{box-sizing:border-box;min-height:${STAGE_MIN}px;}
@@ -220,9 +235,12 @@ function ensureCss() {
.ausboss-touch.fining .ausboss-touch-fining{display:flex;}
.ausboss-touch-fining::before{content:"";flex:none;width:7px;height:7px;border-radius:50%;background:${BRAND};animation:ausboss-touch-pulse 1s ease-in-out infinite;}
@keyframes ausboss-touch-pulse{0%,100%{opacity:.25;}50%{opacity:1;}}
.ausboss-touch-btn.lookpill{display:inline-flex;align-items:center;gap:5px;height:${BAR_HEIGHT}px;padding:0 9px 0 8px;border:1px solid rgba(0,180,170,.6);border-radius:10px;letter-spacing:0;text-transform:none;font:600 10px/18px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#dffcf9;background:linear-gradient(90deg,rgba(0,180,170,.38) var(--done,0%),rgba(0,180,170,.08) var(--done,0%));}
.ausboss-touch-btn.lookpill{display:inline-flex;align-items:center;gap:5px;height:${BAR_HEIGHT}px;padding:0 9px 0 0;overflow:hidden;border:1px solid rgba(0,180,170,.8);border-radius:10px;letter-spacing:0;text-transform:none;font:600 10px/18px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#dffcf9;background:linear-gradient(90deg,rgba(0,180,170,.38) var(--done,0%),rgba(0,180,170,.08) var(--done,0%));}
.ausboss-touch-btn.lookpill:hover{border-color:${BRAND};color:#fff;background:linear-gradient(90deg,rgba(0,180,170,.5) var(--done,0%),rgba(0,180,170,.16) var(--done,0%));}
.ausboss-touch-btn.lookpill .icon{flex:none;font-size:8px;letter-spacing:-1px;opacity:.9;}
.ausboss-touch-btn.lookpill .key{flex:none;display:inline-flex;align-items:center;gap:4px;align-self:stretch;padding:0 7px 0 8px;background:${BRAND};color:#04201e;font-weight:700;}
.ausboss-touch-btn.lookpill:hover .key{background:#35d6cb;}
.ausboss-touch-btn.lookpill.paused .key{background:#9fb4b2;}
.ausboss-touch-btn.lookpill .icon{flex:none;font-size:8px;letter-spacing:-1px;}
.ausboss-touch-btn.lookpill.paused{border-color:rgba(120,144,142,.6);color:#c8dddd;background:linear-gradient(90deg,rgba(120,144,142,.3) var(--done,0%),rgba(120,144,142,.06) var(--done,0%));}
.ausboss-touch-btn.lookpill.paused .icon{letter-spacing:0;}
.ausboss-touch-btn.lookpill .count{opacity:.8;}
@@ -241,7 +259,7 @@ function ensureCss() {
.ausboss-touch.narrow .ausboss-touch-tools{gap:3px;}
.ausboss-touch.narrow .ausboss-touch-field.size .ausboss-scrub{width:56px !important;}
.ausboss-touch.narrow .ausboss-touch-field.soft .ausboss-scrub{width:48px !important;}
.ausboss-touch-btn.eye{display:inline-flex;align-items:center;justify-content:center;width:26px;min-width:26px;padding:0;}
.ausboss-touch-btn.eye,.ausboss-touch-btn.reload{display:inline-flex;align-items:center;justify-content:center;width:26px;min-width:26px;padding:0;}
.ausboss-touch-btn.eye.on{border-color:${BRAND};background:${BRAND};color:#04201f;}
.ausboss-touch-btn.arrow{min-width:20px;width:20px;padding:0;font-size:14px;line-height:16px;}
.ausboss-touch-swatch{box-sizing:border-box;flex:none;width:${BAR_HEIGHT}px;height:${BAR_HEIGHT}px;border-radius:4px;background:#0b1113;border:1px solid rgba(0,180,170,.3);pointer-events:auto;}
@@ -523,6 +541,7 @@ function resolve(face) {
face.raw = raw;
face.exact = exact;
face.chipAreas = null;
face.holds = null;
face.inside = pic.pieces ? piecesInside(pic.pieces.planes, pic.pieces.list.length, raw) : null;
face.hoverMask = null;
}
@@ -693,6 +712,11 @@ function requestRun(face, ask) {
return false;
}
const run = { ask, stamp: face.stamp, epoch: face.sourceEpoch, queued: false };
// Runs of this node that are at a picture it no longer has. The one that
// is running ends by itself once the next job waits; the ones that still
// wait in the queue would each run for nothing, one after the other, so
// they are taken out before this one is sent.
const stale = face.flying.filter((other) => other.queued && other.epoch !== run.epoch);
face.flying.push(run);
face.inFlight = true;
face.askedAt = performance.now();
@@ -703,7 +727,15 @@ function requestRun(face, ask) {
face.flying = face.flying.filter((other) => other !== run);
if (!face.flying.length) settle(face);
};
Promise.resolve(face.host.run(ask === "scope" ? scopeAsk(face) : ask)).then((queued) => {
const cleared = stale.length && face.host.dropWaiting
? Promise.resolve(face.host.dropWaiting()).then((gone) => {
// What was taken out never answers: the newest of the stale ones,
// because a run of this node goes to the front of the queue.
const out = new Set(stale.slice(Math.max(0, stale.length - (Number(gone) || 0))));
if (out.size) face.flying = face.flying.filter((other) => !out.has(other));
}, () => {})
: Promise.resolve();
cleared.then(() => face.host.run(ask === "scope" ? scopeAsk(face) : ask)).then((queued) => {
if (queued === false) {
failed();
return;
@@ -742,7 +774,7 @@ function settings(face) {
grow: Math.round(number("grow")),
soften: Math.max(0, number("soften")),
smooth: Math.max(0, Math.round(number("smooth"))),
fill: widgetByName(face.node, "fill_holes")?.value !== false,
fill: widgetByName(face.node, "fill_holes")?.value === true,
invert: widgetByName(face.node, "invert")?.value === true,
fine: fineChoice(widgetByName(face.node, "fine_edges")?.value),
};
@@ -1254,11 +1286,23 @@ function takeAll(face, index) {
refreshHover(face);
}
// Whether a typed name is taken away: what the sign in front of the field
// was set to, or else what the tool does right now (Erase, or Ctrl held).
function nameTakes(face) {
return face.nameAway ?? face.away;
}
// A typed name, as every thing of that name. "-face" takes it away and
// "+face" adds it, whatever the sign says.
function addName(face, text) {
const word = cleanWord(text);
if (!word || !face.pic || face.held) return false;
const typed = typedName(text, nameTakes(face));
if (!typed.word || !face.pic || face.held) return false;
flushPending(face);
addItem(face, { t: "all", op: face.away ? 0 : 1, w: word });
if (!typed.add && !face.items.some((item) => item.op === 1)) {
setNote(face, "Nothing is picked yet. Pick something first, then take a name away from it.", { life: 3200 });
return false;
}
addItem(face, { t: "all", op: typed.add ? 1 : 0, w: typed.word });
return true;
}
@@ -1403,14 +1447,25 @@ function syncTools(face) {
const waiting = live && face.pending.length > 0;
face.undo.disabled = !live || (!some && !face.past.length && !waiting);
face.clear.disabled = !some && !waiting;
face.reload.disabled = !live || face.inFlight;
if (face.lookButton) {
const offer = live && face.snap && !face.held && !face.inFlight && !face.pic.scoped && Boolean(face.host.lookOnButton?.());
face.lookButton.style.display = offer ? "" : "none";
}
if (face.nameField) {
face.nameField.disabled = !live || face.held > 0;
// A typed name is added, or taken away while the tool takes away.
face.nameField.placeholder = face.away ? "\u2212 type a name" : "+ type a name";
const off = !live || face.held > 0;
const takes = nameTakes(face);
face.nameField.disabled = off;
face.nameSign.disabled = off;
face.nameBox.classList.toggle("away", takes);
face.nameSign.textContent = takes ? "\u2212" : "+";
face.nameSign.title = takes
? "A name you type is taken away from the mask. Click to add by name instead."
: "A name you type is added to the mask. Click to take away by name instead.";
face.nameField.placeholder = takes ? "take away by name" : "add by name";
face.nameField.title = takes
? "Type what to take out of the mask, like \"face\" or \"hair\", and press Enter. Every thing of that name is taken away."
: "Type what to pick, like \"screen\" or \"earring\", and press Enter. Every thing of that name is taken. Start with a minus, like \"-face\", to take it away instead.";
}
if (face.newButton) face.newButton.disabled = !live || face.held > 0 || Boolean(face.fresh);
// The switch shows what a press does right now: with Ctrl held, Paint
@@ -1529,6 +1584,9 @@ function followOwnLoader(face, now = false) {
}
const OTHER_PICTURE = "This is another picture. The picks made on the last one are not used.";
// With New picture on keep picks, the node moves them onto it instead.
const PICKS_CARRY = "This is another picture. Your picks carry over once the node looks at it or the workflow runs.";
const otherPictureNote = (face) => (widgetByName(face.node, "new_picture")?.value === "start over" ? OTHER_PICTURE : PICKS_CARRY);
function dropPicture(face) {
dropPending(face);
@@ -1596,7 +1654,7 @@ function syncSource(face) {
});
readItems(face);
face.itemNotes = null;
if (face.other) setNote(face, OTHER_PICTURE, { life: 6000 });
if (face.other) setNote(face, otherPictureNote(face), { life: 6000 });
resolve(face);
syncState(face);
draw(face);
@@ -1642,6 +1700,7 @@ function syncState(face) {
function setNote(face, text, { life = 0 } = {}) {
clearTimeout(face.noteTimer);
face.stopNote = false;
face.noteEl.textContent = text;
face.noteEl.classList.toggle("on", Boolean(text));
if (text && life) face.noteTimer = setTimeout(() => setNote(face, ""), life);
@@ -1700,7 +1759,15 @@ function adopt(face, entry) {
function show(face, entry, note, answered, { restored = false, own = false, interim = false, areas = null } = {}) {
const before = face.pic;
// A run that stopped left its message up. A result since says it is over.
if (face.stopNote && !interim && !restored) setNote(face, "");
adopt(face, entry);
// Picks made on another picture that the node moved onto this one (New
// picture: keep picks) come back moved. They are taken only while the
// widget still holds the picks they were moved from: anything picked
// since stays.
const moved = movedTouches(note, face.touches?.value);
if (moved !== null) writeTouches(face, moved);
readItems(face);
let items = face.items;
let mine = Boolean(answered) && note.stamp === face.stamp;
@@ -1781,7 +1848,7 @@ function show(face, entry, note, answered, { restored = false, own = false, inte
readItems(face);
face.itemNotes = notes;
}
if (face.other) setNote(face, OTHER_PICTURE, { life: 6000 });
if (face.other) setNote(face, otherPictureNote(face), { life: 6000 });
if (face.shownPieces !== entry.piecesTag) {
face.shownPieces = entry.piecesTag;
face.hover = -1;
@@ -1932,12 +1999,31 @@ function pointOf(face, event) {
return [clamp(x, 0, face.pic.width - 1), clamp(y, 0, face.pic.height - 1)];
}
// The pieces that hold a whole pick, worked out once per change to the
// picks and only when something is being taken away.
function holdsOf(face) {
const pic = face.pic;
if (!pic?.pieces || !face.raw) return null;
if (!face.holds || face.holds.length !== pic.pieces.list.length) {
const areas = chipGroups(face.items).filter((chip) => !chip.away).map((chip) => chipArea(face, chip));
face.holds = piecesHolding(pic.pieces.planes, pic.pieces.list.length, areas);
}
return face.holds;
}
function pieceUnder(face, point, adding) {
const pic = face.pic;
if (!pic?.pieces || !point) return -1;
const x = clamp(Math.floor(point[0] * pic.scale), 0, pic.pw - 1);
const y = clamp(Math.floor(point[1] * pic.scale), 0, pic.ph - 1);
return pieceAt(pic.pieces.planes, y * pic.pw + x, { sizes: pic.pieces.sizes, inside: face.inside, adding, ranks: pic.pieces.ranks });
const at = y * pic.pw + x;
if (adding) return pieceAt(pic.pieces.planes, at, { sizes: pic.pieces.sizes, inside: face.inside, ranks: pic.pieces.ranks });
// Taking away from a spot that is not picked: never a thing that holds a
// whole pick somewhere else (the person, for the sweater picked on them).
return pieceAt(pic.pieces.planes, at, {
sizes: pic.pieces.sizes, inside: face.inside, adding, ranks: pic.pieces.ranks,
here: face.raw?.[at] >= 0.5, holds: holdsOf(face),
});
}
// The pixel of the small copy under a point of the picture.
@@ -2307,7 +2393,8 @@ function syncLooking(face) {
pill.classList.toggle("paused", paused);
pill.style.setProperty("--done", `${total ? Math.round((done / total) * 100) : 0}%`);
face.lookIcon.textContent = paused ? "\u25b6" : "\u275a\u275a";
face.lookWord.textContent = paused ? "Paused" : "Looking";
face.lookVerb.textContent = paused ? "Look on" : "Pause";
face.lookWord.textContent = paused ? "paused" : "looking";
face.lookCount.textContent = total ? `${done}/${total}` : "\u2026";
const far = total ? ` ${done} of ${total}` : "";
pill.title = paused
@@ -2700,6 +2787,8 @@ function setSoft(face, soft) {
// Pick takes whole things; Paint and Erase are the brush.
function setTool(face, tool) {
// The sign in front of the name field follows the tool again.
face.nameAway = null;
if (!TOOLS.includes(tool)) return;
face.tool = tool;
face.snap = tool === "pick";
@@ -2756,16 +2845,21 @@ export function mountTouchStage(node, host) {
const bar = el("div", "ausboss-touch-bar");
const chips = el("div", "ausboss-touch-chips");
const chipList = el("span", "ausboss-touch-chiplist");
// A name typed here picks every thing of that name, or takes it away:
// the sign in front says which, and a press on it changes it.
const nameBox = el("span", "ausboss-touch-namebox");
const nameSign = el("button", "ausboss-touch-sign", "+");
nameSign.type = "button";
const nameField = el("input", "ausboss-touch-name");
nameField.type = "text";
nameField.placeholder = "+ type a name";
nameField.placeholder = "add by name";
nameField.maxLength = 60;
nameField.spellcheck = false;
nameField.title = "Type what to pick, like \"screen\" or \"earring\", and press Enter. Every thing of that name is taken.";
nameBox.append(nameSign, nameField);
const newButton = el("button", "ausboss-touch-new", "+ new");
newButton.type = "button";
newButton.title = "Start a new area: what you paint next gets a chip of its own, also where it touches another area.";
chips.append(chipList, newButton, nameField);
chips.append(chipList, newButton, nameBox);
root.append(tools, stage, bar, chips);
const frame = el("div", "ausboss-touch-frame");
@@ -2810,7 +2904,7 @@ export function mountTouchStage(node, host) {
const face = node.__ausbossTouch = {
node, host, root, stage, frame, img, tint, lit, ink, path, dots, ringBack, ring, coreRing, dab, brushLabel, tag, hint, noteEl, abort,
chips, chipList, newButton, nameField, chipsShown: null,
chips, chipList, newButton, nameField, nameBox, nameSign, nameAway: null, chipsShown: null,
flying: [],
pendingTimer: 0,
alive: true,
@@ -2877,9 +2971,9 @@ export function mountTouchStage(node, host) {
};
readProperties(face);
const showPicture = () => { if (!face.inFlight) { face.retries = 0; if (face.pic) face.pic.paused = false; requestRun(face, "scope"); } };
face.start = button("ausboss-touch-start", "Show picture",
"Runs only this node, so the picture shows here and its pieces light up under the pointer.",
() => { if (!face.inFlight) { face.retries = 0; if (face.pic) face.pic.paused = false; requestRun(face, "scope"); } }, signal);
"Runs only this node, so the picture shows here and its pieces light up under the pointer.", showPicture, signal);
wake.append(face.start, hint);
// Tools: what a press does, the brush, undo and clear; on the right, how
@@ -2976,7 +3070,13 @@ export function mountTouchStage(node, host) {
if (event.shiftKey) face.setView(face.view === "cut" ? "mask" : face.view === "mask" ? "cut" : face.result === "cut" ? "mask" : "cut");
else face.setView(face.view === "tint" ? face.result : "tint");
}, { signal });
tools.append(face.toolSwitch.root, face.sizeField, face.softField, el("div", "ausboss-touch-gap"), face.undo, face.clear, face.eye);
// The picture coming in changed (an edit made upstream): this shows the
// new one, the same way Show picture does.
face.reload = button("ausboss-touch-btn reload", "",
"Reload the picture: runs only this node, so a picture that changed upstream shows here.", showPicture, signal);
face.reload.innerHTML = RELOAD_ICON;
face.reload.setAttribute("aria-label", "Reload the picture");
tools.append(face.toolSwitch.root, face.sizeField, face.softField, el("div", "ausboss-touch-gap"), face.reload, face.undo, face.clear, face.eye);
newButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
@@ -3008,10 +3108,15 @@ export function mountTouchStage(node, host) {
if (face.pic?.paused && !face.inFlight) resumeLook(face);
else pauseLook(face);
}, signal);
// The press it takes comes first, as a button of its own look: "Pause"
// while the node looks, "Look on" once it is paused.
face.lookIcon = el("span", "icon");
face.lookVerb = el("span", "verb");
face.lookWord = el("span", "word");
face.lookCount = el("span", "count");
face.lookPill.append(face.lookIcon, face.lookWord, face.lookCount);
const lookKey = el("span", "key");
lookKey.append(face.lookIcon, face.lookVerb);
face.lookPill.append(lookKey, face.lookWord, face.lookCount);
face.lookPill.style.display = "none";
// First in the line under the picture: what the node is doing with the
// picture, then what is picked; the buttons that pick come after.
@@ -3021,6 +3126,11 @@ export function mountTouchStage(node, host) {
for (const type of ["keydown", "keyup", "keypress", "pointerdown", "mousedown", "dblclick"]) {
chips.addEventListener(type, (event) => event.stopPropagation(), { signal });
}
nameSign.addEventListener("click", () => {
face.nameAway = !nameTakes(face);
syncTools(face);
nameField.focus();
}, { signal });
nameField.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
@@ -3073,7 +3183,9 @@ export function mountTouchStage(node, host) {
// The run stopped at this node with a message for the person.
face.stopped = (message) => {
settle(face);
setNote(face, String(message ?? ""));
setNote(face, stopMessage(message));
// It stays up until something else is said or the next result comes in.
face.stopNote = true;
};
// A setting on the node changed: the edge follows it.
face.settingsChanged = () => {
+46 -52
View File
@@ -30,17 +30,18 @@
// Pure decisions (which rows show, how tall the card is, how a number
// scrubs, where a socket sits) live in widget_card_math.mjs for node:test;
// this file is the DOM.
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "./index.mjs";
import { chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "./index.mjs";
import { keepKeyInField } from "./canvas_passthrough.mjs";
import { createMediaPicker } from "./media_picker.mjs";
import { ensureNodeMinHeight, fillNodeHeight, holdNodeMinWidth, nodeHeightAfterCardChange, pinVuePanelHeight } from "./panel_layout.mjs";
import { makeScrubInput, quantizeScrubValue, stepDecimals } from "./scrub_input.mjs";
import { CONTROL_HEIGHT, FONT_UI, RADIUS_CARD, RADIUS_CONTROL, RADIUS_XS, SELECT_CHEVRON, THEME as T } from "./theme.mjs";
import { limitsLabel, narrowedRange } from "./wired_limits.mjs";
import { hideWidget } from "./widget_visibility.mjs";
import {
CARD_PADDING, GROUP_HEIGHT, ROW_GAP, ROW_HEIGHT, SECTION_HEIGHT, SLOT_OFFSET, UNIT_SLOT_WIDTH,
cardChangeRestore, cardHeight, choiceLabel, commitWidgetValue, comboValues, rowHeight, rowInLook, rowKind, rowMuted, rowTops, scrubSteps, socketWidgetY, visibleRows,
widgetTooltip,
liftSocket, widgetTooltip,
} from "./widget_card_math.mjs";
export * from "./widget_card_math.mjs";
@@ -55,7 +56,7 @@ const WRAPPER_INSET = 16;
// widget's layout y (DomWidgets: margin 10 per side).
const WRAPPER_TOP = 10;
const FONT = `-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif`;
const FONT = FONT_UI;
// A boolean pill's state labels must fit half a control; longer text is a
// tooltip, and the pill says on / off.
const SWITCH_LABEL_MAX = 16;
@@ -78,76 +79,80 @@ export function ensureCardCss() {
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-card{position:relative;box-sizing:border-box;width:100%;height:100%;display:flex;flex-direction:column;gap:${ROW_GAP}px;padding:${CARD_PADDING}px 8px;overflow:hidden;border:1px solid rgba(0,180,170,.22);border-radius:8px;background:rgba(0,0,0,.28);font:12px/1.3 ${FONT};color:#c8dddd;pointer-events:none}
.ausboss-card{position:relative;box-sizing:border-box;width:100%;height:100%;display:flex;flex-direction:column;gap:${ROW_GAP}px;padding:${CARD_PADDING}px 8px;overflow:hidden;border:1px solid transparent;border-radius:${RADIUS_CARD}px;background:${T.card};font:12px/1.3 ${FONT};color:${T.inkSoft};pointer-events:none}
.ausboss-card-row{display:grid;grid-template-columns:minmax(56px,30%) 1fr;align-items:center;gap:8px;height:${ROW_HEIGHT}px;flex:none;pointer-events:auto}
.ausboss-card-row.hidden{display:none}
.ausboss-card-row.linked{opacity:.5}
.ausboss-card-row.linked .ausboss-card-control{pointer-events:none}
.ausboss-card-row.muted .ausboss-card-control{opacity:.35;pointer-events:none}
.ausboss-card-row.muted .ausboss-card-label{color:#566b69}
.ausboss-card-range{margin-left:6px;color:#5f7674;font-size:10px;font-weight:600;letter-spacing:.02em;white-space:nowrap}
.ausboss-card-row.muted .ausboss-card-label{color:${T.faint}}
.ausboss-card-range{margin-left:6px;color:${T.faint};font-size:10px;font-weight:600;letter-spacing:.02em;white-space:nowrap}
.ausboss-card.has-corner .ausboss-card-control{margin-right:var(--ausboss-card-corner,0px)}
.ausboss-card-corner{position:absolute;top:${CARD_PADDING}px;right:8px;height:${ROW_HEIGHT}px;display:flex;align-items:center;justify-content:flex-end;gap:5px;pointer-events:auto}
.ausboss-card-gear{width:24px;height:24px;flex:none;display:grid;place-items:center;padding:0;border:1px solid #2a3437;border-radius:6px;background:#0f1516;color:#8ba3a1;cursor:pointer}
.ausboss-card-gear:hover{border-color:${BRAND};color:${BRAND}}
.ausboss-card-chip{height:20px;flex:none;padding:0 8px;border:1px solid rgba(0,180,170,.5);border-radius:10px;background:rgba(0,180,170,.14);color:${BRAND};font:600 10px/1 ${FONT};letter-spacing:.02em;white-space:nowrap;cursor:pointer}
.ausboss-card-chip:hover{background:rgba(0,180,170,.24);color:#fff}
.ausboss-card-gear{width:24px;height:24px;flex:none;display:grid;place-items:center;padding:0;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};color:${T.label};cursor:pointer}
.ausboss-card-gear:hover{background:${T.fillHover};color:${T.inkHover}}
.ausboss-card-chip{height:20px;flex:none;padding:0 9px;border:none;border-radius:999px;background:${T.brandSoft};color:${T.brandInk};font:600 10px/1 ${FONT};letter-spacing:.02em;font-variant-numeric:tabular-nums;white-space:nowrap;cursor:pointer}
.ausboss-card-chip:hover{background:${T.brandSoftHover};color:${T.inkHover}}
.ausboss-card-chip[hidden]{display:none}
.ausboss-card-row.area{display:block}
.ausboss-card-row.panel{display:block}
.ausboss-card-row.area.grow{flex:1 1 auto;height:auto}
.ausboss-card-label{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#8ba3a1;font-size:11px;user-select:none}
.ausboss-card-label{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:${T.label};font-size:11px;user-select:none}
.ausboss-card-control{display:flex;align-items:center;gap:6px;min-width:0;height:100%}
.ausboss-card-field{display:flex;align-items:center;gap:6px;flex:1 1 0;min-width:0;height:100%}
.ausboss-card-field.linked{opacity:.5;pointer-events:none}
.ausboss-card .ausboss-scrub{height:${CONTROL_HEIGHT}px}
.ausboss-card-field>.ausboss-scrub{flex:1 1 0;width:auto;min-width:0}
.ausboss-card-field>.ausboss-scrub .ausboss-scrub-input{width:100%}
.ausboss-card-sep{flex:none;color:#5f7674;font-size:11px}
.ausboss-card-suffix{flex:none;color:#5f7674;font-size:10px;letter-spacing:.06em;text-transform:uppercase;user-select:none}
.ausboss-card-section{height:${SECTION_HEIGHT}px;flex:none;display:flex;align-items:flex-end;color:#5f7674;font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;user-select:none}
.ausboss-card-sep{flex:none;color:${T.faint};font-size:11px}
.ausboss-card-suffix{flex:none;color:${T.faint};font:600 10px ${FONT};letter-spacing:.1em;text-transform:uppercase;user-select:none}
.ausboss-card-section{height:${SECTION_HEIGHT}px;flex:none;display:flex;align-items:flex-end;gap:8px;color:${T.faint};font:600 10px/1 ${FONT};letter-spacing:.1em;text-transform:uppercase;user-select:none}
.ausboss-card-section::after{content:"";flex:1;height:1px;margin-bottom:4px;background:${T.rule}}
.ausboss-card-section.hidden{display:none}
.ausboss-card-group{height:${GROUP_HEIGHT}px;flex:none;display:flex;align-items:center;gap:6px;padding:0 4px;border:1px solid transparent;border-radius:6px;background:transparent;color:#78908e;font:600 10.5px/1 ${FONT};letter-spacing:.06em;text-transform:uppercase;cursor:pointer;text-align:left;pointer-events:auto}
.ausboss-card-group:hover{color:#fff;border-color:#2a3437}
.ausboss-card-group{height:${GROUP_HEIGHT}px;flex:none;display:flex;align-items:center;gap:6px;padding:0 4px;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:transparent;color:${T.faint};font:600 10px/1 ${FONT};letter-spacing:.1em;text-transform:uppercase;cursor:pointer;text-align:left;pointer-events:auto}
.ausboss-card-group:hover{color:${T.inkHover}}
.ausboss-card-group .glyph{display:inline-block;width:10px;transition:transform .12s}
.ausboss-card-group.open .glyph{transform:rotate(90deg)}
.ausboss-card-seg{display:grid;grid-auto-flow:column;grid-auto-columns:1fr;width:100%;height:${ROW_HEIGHT}px;padding:2px;box-sizing:border-box;border:1px solid #2a3437;border-radius:6px;background:#0f1516}
.ausboss-card-seg button{min-width:0;border:none;border-radius:4px;padding:0 2px;background:transparent;color:#8ba3a1;font:600 10.5px/1 ${FONT};cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ausboss-card-seg button:hover{color:#fff}
.ausboss-card-seg button.on{background:${BRAND};color:#04201d}
.ausboss-card-select{width:100%;height:${ROW_HEIGHT}px;padding:0 24px 0 8px;box-sizing:border-box;border:1px solid #2a3437;border-radius:6px;background:#0b0f10 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%238ba3a1' stroke-width='1.5'/%3E%3C/svg%3E") no-repeat right 8px center;color:#d8ecea;font:600 11.5px ${FONT};appearance:none;-webkit-appearance:none;cursor:pointer;overflow:hidden;text-overflow:ellipsis}
.ausboss-card-select:hover{border-color:${BRAND}}
.ausboss-card-select:focus-visible{outline:2px solid ${BRAND};outline-offset:1px}
.ausboss-card-text{width:100%;height:${ROW_HEIGHT}px;padding:0 8px;box-sizing:border-box;border:1px solid #2a3437;border-radius:6px;background:#0b0f10;color:#d8ecea;font:12px ${FONT};min-width:0}
.ausboss-card-text:focus{outline:none;border-color:${BRAND}}
.ausboss-card-text::placeholder{color:#4f6462}
.ausboss-card-area{display:block;width:100%;height:100%;padding:5px 8px;box-sizing:border-box;border:1px solid #2a3437;border-radius:6px;background:#0b0f10;color:#d8ecea;font:12px/1.4 ${FONT};resize:none;overflow:auto;min-width:0}
.ausboss-card-area:focus{outline:none;border-color:${BRAND}}
.ausboss-card-area::placeholder{color:#4f6462}
.ausboss-card.look-big .ausboss-card-row.area .ausboss-card-field{position:relative;display:block;border:1px solid #2a3437;border-radius:6px;background:#0b0f10;cursor:text}
.ausboss-card.look-big .ausboss-card-row.area .ausboss-card-field:focus-within{border-color:${BRAND}}
.ausboss-card.look-big .ausboss-card-area{position:absolute;left:0;right:0;top:0;bottom:0;margin:auto 0;max-height:100%;padding:${BIG_PAD_Y}px ${BIG_PAD_X}px;border:none;border-radius:5px;background:transparent;color:#6fe0d6;text-shadow:0 0 .35em rgba(0,180,170,.55);font:600 ${BIG_FONT}px/${BIG_LINE}px ${FONT};text-align:center;scrollbar-width:thin;scrollbar-color:#2f3d3c transparent}
.ausboss-card.look-big .ausboss-card-area::placeholder{color:#4f6462;text-shadow:none}
.ausboss-card-seg{display:grid;grid-auto-flow:column;grid-auto-columns:1fr;width:100%;height:${ROW_HEIGHT}px;padding:2px;box-sizing:border-box;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill}}
.ausboss-card-seg button{min-width:0;border:none;border-radius:${RADIUS_XS}px;padding:0 2px;background:transparent;color:${T.label};font:600 11px/1 ${FONT};cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ausboss-card-seg button:hover{color:${T.inkHover}}
.ausboss-card-seg button.on{background:${T.brand};color:${T.onBrand}}
.ausboss-card-bool button.on:first-child{background:${T.switchOff};color:${T.ink}}
.ausboss-card-select{width:100%;height:${ROW_HEIGHT}px;padding:0 24px 0 8px;box-sizing:border-box;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill} ${SELECT_CHEVRON} no-repeat right 9px center;color:${T.ink};font:500 12px ${FONT};appearance:none;-webkit-appearance:none;cursor:pointer;overflow:hidden;text-overflow:ellipsis}
.ausboss-card-select:hover{background-color:${T.fillHover}}
.ausboss-card-select:focus-visible{outline:2px solid ${T.brand};outline-offset:1px}
.ausboss-card-text{width:100%;height:${ROW_HEIGHT}px;padding:0 8px;box-sizing:border-box;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};color:${T.ink};font:12px ${FONT};min-width:0}
.ausboss-card-text:focus{outline:none;border-color:${T.brand}}
.ausboss-card-text::placeholder{color:${T.faint}}
.ausboss-card-area{display:block;width:100%;height:100%;padding:5px 8px;box-sizing:border-box;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};color:${T.ink};font:12px/1.4 ${FONT};resize:none;overflow:auto;min-width:0}
.ausboss-card-area:focus{outline:none;border-color:${T.brand}}
.ausboss-card-area::placeholder{color:${T.faint}}
.ausboss-card.look-big .ausboss-card-row.area .ausboss-card-field{position:relative;display:block;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};cursor:text}
.ausboss-card.look-big .ausboss-card-row.area .ausboss-card-field:focus-within{border-color:${T.brand}}
.ausboss-card.look-big .ausboss-card-area{position:absolute;left:0;right:0;top:0;bottom:0;margin:auto 0;max-height:100%;padding:${BIG_PAD_Y}px ${BIG_PAD_X}px;border:none;border-radius:5px;background:transparent;color:${T.brandGlow};text-shadow:0 0 .35em rgba(0,180,170,.55);font:600 ${BIG_FONT}px/${BIG_LINE}px ${FONT};text-align:center;scrollbar-width:thin;scrollbar-color:${T.fillHover} transparent}
.ausboss-card.look-big .ausboss-card-area::placeholder{color:${T.faint};text-shadow:none}
.ausboss-card.look-big .ausboss-card-row:not(.area){display:block}
.ausboss-card.look-big .ausboss-card-row:not(.area) .ausboss-card-label{display:none}
.ausboss-card.look-big .ausboss-card-row:not(.area) .ausboss-card-control{margin:0}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub{height:100%;justify-content:center;align-items:center;gap:4px;border-color:#2a3437;border-radius:6px;background:#0b0f10}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub:focus-within{border-color:${BRAND}}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-input{order:1;flex:0 0 auto;width:auto;min-width:2ch;max-width:60%;field-sizing:content;color:#6fe0d6;text-shadow:0 0 .35em rgba(0,180,170,.55);font:600 ${BIG_NUMBER_FONT}px ${FONT}}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub{height:100%;justify-content:center;align-items:center;gap:4px;border-color:transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill}}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub:focus-within{border-color:${T.brand}}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-input{order:1;flex:0 0 auto;width:auto;min-width:2ch;max-width:60%;field-sizing:content;color:${T.brandGlow};text-shadow:0 0 .35em rgba(0,180,170,.55);font:600 ${BIG_NUMBER_FONT}px ${FONT}}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-unit{order:2}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-unit:empty{display:none}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-step{display:contents}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-step>button{flex:none;width:30px;height:30px;border-radius:15px;color:#6f8886}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-step>button{flex:none;width:30px;height:30px;border-radius:15px;color:${T.label}}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-step>button:first-child{order:3}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-step>button:last-child{order:0}
.ausboss-card.look-big .ausboss-card-field>.ausboss-scrub>.ausboss-scrub-step>button>svg{transform:rotate(90deg) scale(1.6)}
.ausboss-card.look-big .ausboss-card-corner{top:${CARD_PADDING + 4}px;right:12px;height:22px}
.ausboss-card.look-big .ausboss-card-gear{width:22px;height:22px;border-color:transparent;background:transparent}
.ausboss-card.look-big .ausboss-card-gear:hover{border-color:${BRAND}}
.ausboss-card-swatch{flex:none;width:${ROW_HEIGHT}px;height:${ROW_HEIGHT}px;padding:0;border:1px solid #2a3437;border-radius:6px;background:#0b0f10;cursor:pointer}
.ausboss-card.look-big .ausboss-card-gear:hover{background:${T.fillHover}}
.ausboss-card-swatch{flex:none;width:${ROW_HEIGHT}px;height:${ROW_HEIGHT}px;padding:0;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};cursor:pointer}
.ausboss-card-swatch::-webkit-color-swatch-wrapper{padding:3px}
.ausboss-card-swatch::-webkit-color-swatch{border:none;border-radius:3px}
.ausboss-card-btn{flex:none;height:${ROW_HEIGHT}px;padding:0 10px;border:1px solid #2a3437;border-radius:6px;background:#16201f;color:#d8ecea;font:600 11px/1 ${FONT};cursor:pointer;white-space:nowrap}
.ausboss-card-btn:hover{border-color:${BRAND};color:#fff}
.ausboss-card-btn{flex:none;height:${ROW_HEIGHT}px;padding:0 10px;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};color:${T.inkSoft};font:600 11px/1 ${FONT};cursor:pointer;white-space:nowrap}
.ausboss-card-btn:hover{background:${T.fillHover};color:${T.inkHover}}
.ausboss-card-btn:focus-visible{outline:2px solid ${T.brand};outline-offset:1px}
`;
document.head.append(style);
}
@@ -169,17 +174,6 @@ function widgetSlot(node, name) {
return node.inputs?.find((input) => input?.widget?.name === name || input?.name === name) ?? null;
}
// A `top: true` row's widgets keep their input slots but the slots stop
// being widget inputs: the frontend then measures and draws them in the
// node's slot column, and a link into one is resolved by input name.
export function liftSocket(node, name) {
const slot = node.inputs?.find((input) => input?.widget?.name === name);
if (!slot) return false;
delete slot.widget;
slot._widget = undefined;
return true;
}
function widgetLinked(node, name) {
const slot = widgetSlot(node, name);
return Boolean(slot && slot.link !== null && slot.link !== undefined);
+20
View File
@@ -218,3 +218,23 @@ export function widgetTooltip(node, name, widget = null) {
const text = Array.isArray(spec) ? spec[1]?.tooltip : undefined;
return typeof text === "string" ? text : "";
}
// A `top: true` row's widgets keep their input slots but the slots stop
// being widget inputs: the frontend then measures and draws them in the
// node's slot column, and a link into one is resolved by input name.
// Newer frontends also stamp a widget's socket with the position of its
// row (`slot.pos`) and keep honouring it once the widget is gone, so every
// lifted socket would sit on the first slot's row. The stamp goes too.
export function liftSocket(node, name) {
const slot = node.inputs?.find((input) => input?.widget?.name === name);
if (!slot) return false;
liftSlot(slot);
return true;
}
// The same lift for a slot already in hand (a loop over `node.inputs`).
export function liftSlot(slot) {
delete slot.widget;
slot._widget = undefined;
delete slot.pos;
}
+22 -11
View File
@@ -62,7 +62,8 @@ const CHOICES = {
};
export function cleanTitle(title) {
return String(title ?? "").replace(/\s+/g, " ").trim().slice(0, TITLE_MAX);
const text = ["string", "number", "boolean"].includes(typeof title) ? String(title) : "";
return text.replace(/\s+/g, " ").trim().slice(0, TITLE_MAX);
}
// Node and group ids as a workflow file stores them: numbers, or text for a
@@ -135,7 +136,7 @@ export function normalizeSwitches(raw) {
const used = new Set();
for (const item of Array.isArray(raw) ? raw : []) {
if (!item || typeof item !== "object") continue;
let id = Number(item.id);
let id = typeof item.id === "number" || typeof item.id === "string" ? Number(item.id) : NaN;
if (!Number.isInteger(id) || id < 1 || used.has(id)) {
id = 1;
while (used.has(id)) id += 1;
@@ -357,7 +358,7 @@ export function stackRows(value) {
} catch {
return null;
}
if (!Array.isArray(rows) || !rows.length) return null;
if (!Array.isArray(rows)) return null;
const isRow = (row) => row && typeof row === "object" && typeof row.name === "string" && typeof row.enabled === "boolean";
return rows.every(isRow) ? rows : null;
}
@@ -484,7 +485,12 @@ export function choicePairs(entry, switches) {
for (const other of switches ?? []) {
if (other === entry || !other.choice) continue;
for (const pair of valuePairs({ on: entry.on, off: other.on })) {
if (!found.has(pairKey(pair))) found.set(pairKey(pair), { ...pair, choice: true });
const previous = found.get(pairKey(pair));
if (!previous) found.set(pairKey(pair), { ...pair, choice: true });
else if (pair.row !== undefined) {
previous.on = { ...previous.on, ...pair.on };
previous.off = { ...pair.off, ...previous.off };
}
}
}
return [...found.values()];
@@ -539,8 +545,9 @@ export function stackRowOwners(boards, nodeId, widget, rowName) {
const owners = [];
if (!rowName) return owners;
for (const { node, settings } of boards ?? []) {
for (const entry of normalizeSettings(settings).switches) {
const moves = valuePairs(entry).some(
const { switches } = normalizeSettings(settings);
for (const entry of switches) {
const moves = (entry.choice ? choicePairs(entry, switches) : valuePairs(entry)).some(
(pair) => pair.row !== undefined && String(pair.node) === String(nodeId) && pair.widget === widget && sameFile(pair.row, rowName),
);
if (moves) owners.push({ node, id: entry.id, title: entry.title });
@@ -555,8 +562,9 @@ export function stackGroupOwners(boards, nodeId, widget, group) {
const owners = [];
if (!group) return owners;
for (const { node, settings } of boards ?? []) {
for (const entry of normalizeSettings(settings).switches) {
const moves = valuePairs(entry).some(
const { switches } = normalizeSettings(settings);
for (const entry of switches) {
const moves = (entry.choice ? choicePairs(entry, switches) : valuePairs(entry)).some(
(pair) => pair.group === group && String(pair.node) === String(nodeId) && pair.widget === widget,
);
if (moves) owners.push({ node, id: entry.id, title: entry.title });
@@ -642,7 +650,9 @@ export function readGroups(groups, placed, { counts = () => true } = {}) {
group,
rect,
title: String(group.title ?? ""),
color: typeof group.color === "string" && group.color ? group.color : null,
// Group colors come from a hex picker; a CSS background must never
// receive URL functions or references from a saved workflow.
color: typeof group.color === "string" && /^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.test(group.color.trim()) ? group.color.trim() : null,
members,
...partState(members, counts),
};
@@ -668,6 +678,7 @@ export function readSwitches(switches, placed, groupRows, { counts = () => true
if (id !== undefined && id !== null && !groupById.has(String(id))) groupById.set(String(id), row);
}
return (switches ?? []).map((entry) => {
const sides = new Set([...Object.keys(entry.on ?? {}), ...Object.keys(entry.off ?? {})]);
const members = [];
const offMembers = [];
const seen = new Set();
@@ -675,6 +686,7 @@ export function readSwitches(switches, placed, groupRows, { counts = () => true
let gone = 0;
const takeNodes = (ids, into) => {
for (const id of ids ?? []) {
if (sides.has(String(id))) continue;
const hit = nodeById.get(String(id));
if (!hit) {
gone += 1;
@@ -695,7 +707,7 @@ export function readSwitches(switches, placed, groupRows, { counts = () => true
}
rects.push(row.rect);
for (const node of row.members) {
if (seen.has(node)) continue;
if (seen.has(node) || sides.has(String(node.id))) continue;
seen.add(node);
into.push(node);
}
@@ -708,7 +720,6 @@ export function readSwitches(switches, placed, groupRows, { counts = () => true
// The nodes whose settings it changes: listed, framed and selected with
// the rest, but never turned off by it.
const valueNodes = [];
const sides = new Set([...Object.keys(entry.on ?? {}), ...Object.keys(entry.off ?? {})]);
for (const id of sides) {
const hit = nodeById.get(id);
if (!hit) {
+21 -4
View File
@@ -37,7 +37,8 @@ const OVERHANG_PX = 6;
const state = {
enabled: true,
before: null, // Map item -> { pos, size, rect } taken when the button went down
corner: null, // the corner a classic resize pulls, such as "SE"
corner: null, // the corner a resize pulls, such as "SE"
resizingNode: null, // the node whose Vue corner handle took the press
drag: null, // the running move, or { idle: true } for a gesture left alone
guides: [],
frame: 0,
@@ -199,8 +200,8 @@ function stepMove(canvas, drag, event) {
canvas.setDirty?.(true, true);
}
// Classic look: LiteGraph has just sized the node from its corner, so pull
// the moving edges onto any line within reach.
// The frontend has just sized the node from its corner, so pull the moving
// edges onto any line within reach.
function stepNodeResize(canvas, node, event) {
// LiteGraph forgets the corner once the pointer leaves the node, so use the
// one it had when the button went down.
@@ -238,6 +239,7 @@ function step(event) {
const canvas = app.canvas;
if (!state.before || !canvas?.graph || !canvas.canvas || !canvas.ds) return;
if (canvas.resizing_node) return stepNodeResize(canvas, canvas.resizing_node, event);
if (state.resizingNode) return stepNodeResize(canvas, state.resizingNode, event);
if (canvas.resizingGroup) return stepGroupResize(canvas, canvas.resizingGroup, event);
if (!state.drag) state.drag = startMove(canvas, event);
if (state.drag && !state.drag.idle) stepMove(canvas, state.drag, event);
@@ -250,6 +252,20 @@ function onPointerDown(event) {
if (OTHER_SNAPPERS.some(settingOn)) return;
if (app.canvas?.graph && app.canvas.canvas && app.canvas.ds) state.before = takeBefore(app.canvas);
state.corner = cornerOf(app.canvas);
if (vueNodes() && state.before) {
const handle = event.target?.closest?.("[data-corner]");
const id = handle?.closest?.("[data-node-id]")?.dataset?.nodeId;
const corner = handle?.dataset?.corner;
if (id != null && ["SE", "SW", "NE", "NW"].includes(corner)) {
for (const [item, was] of state.before) {
if (was.kind === "node" && String(item.id) === id) {
state.resizingNode = item;
state.corner = corner;
break;
}
}
}
}
}
function cornerOf(canvas) {
@@ -259,7 +275,7 @@ function cornerOf(canvas) {
// Behind LiteGraph's own pointerdown, which names the corner a resize pulls.
function afterPointerDown() {
if (state.before) state.corner = cornerOf(app.canvas) ?? state.corner;
if (state.before && !state.resizingNode) state.corner = cornerOf(app.canvas) ?? state.corner;
}
function onPointerMove(event) {
@@ -281,6 +297,7 @@ function onPointerMove(event) {
function end() {
state.before = null;
state.corner = null;
state.resizingNode = null;
state.drag = null;
state.pending = null;
setGuides([]);
+68 -2
View File
@@ -12,8 +12,10 @@ import { formatWidgetVisibility } from "../shared/save_video_formats.mjs";
import { hideInputsInDef } from "../shared/widget_visibility.mjs";
import { castChips } from "../shared/h3_cast_chips.mjs";
import { clipSetsDuration } from "../shared/h3_duration.mjs";
import { loadImagePreview, loadImageStrip } from "../shared/load_image_strip.mjs";
import { momentStrip } from "../shared/moment_strip.mjs";
import { mountWidgetCard } from "../shared/widget_card.mjs";
import { FONT_UI, THEME } from "../shared/theme.mjs";
import { SOCKETLESS_CHOICE, SOCKETLESS_TEXT, commitWidgetValue, markSocketless, resetUnknownValues } from "../shared/widget_card_math.mjs";
import { mediaViewQuery } from "../shared/media_list.mjs";
import { gearIconSvg, openSettingsMenu } from "../shared/settings_menu.mjs";
@@ -26,6 +28,9 @@ import { valueToTake, wiredLimits } from "../shared/wired_limits.mjs";
const viewUrl = (value) => api.apiURL(`/view?${mediaViewQuery(value)}`);
const isMode = (name, ...modes) => (values) => modes.includes(values[name]);
// Load Image: the choices that read the width and height boxes.
const loadImageUsesBox = (values) => ["fit inside", "crop to fill", "crop"].includes(values.size)
|| (values.size === "crop to ratio" && values.ratio === "width : height");
// Stitch Inpaint: Seam lives in the card's gear menu (shared/stitch_seam.mjs
// holds the menu and the rules). The gear in the card's corner opens the
// menu; a "blend in" chip beside it shows the mode at a glance and opens the
@@ -59,7 +64,7 @@ function seamCorner(node, card) {
const note = document.createElement("div");
note.append("not used", document.createElement("br"), "by blend in");
note.style.cssText = "position:absolute;top:33px;right:2px;text-align:right;white-space:nowrap;"
+ "color:#6f8886;font:9px/11px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;"
+ `color:${THEME.faint};font:9px/11px ${FONT_UI};`
+ "pointer-events:none";
tools.append(chip, gear, note);
return {
@@ -120,6 +125,15 @@ const CARDS = {
{ widget: "interpolation", label: "Filter", kind: "select" },
],
},
AUSBOSS_NODES_EditLock: {
minWidth: 300,
rows: [
{ widget: "lock", label: "Lock", kind: "segment",
titles: { auto: "Lock the untouched parts; step aside when the whole picture changes", always: "Lock even when the whole picture changes", off: "Do nothing" } },
{ widget: "tolerance", label: "Tolerance", decimals: 2, suffix: "×" },
],
},
AUSBOSS_NODES_EditLockRestore: { minWidth: 300, rows: [{ widget: "feather", label: "Feather", suffix: "px" }] },
AUSBOSS_NODES_ColorMatch: {
minWidth: 300,
rows: [
@@ -242,6 +256,8 @@ const CARDS = {
{ widget: "fill_holes", label: "Fill small holes", group: "advanced" },
{ widget: "invert", label: "Invert", group: "advanced" },
{ widget: "if_nothing", label: "If nothing", kind: "segment", group: "advanced" },
// What a different picture coming in does to the picks.
{ widget: "new_picture", label: "New picture", kind: "segment", group: "advanced" },
{ widget: "free_vram", label: "Free VRAM", group: "advanced" },
],
},
@@ -278,6 +294,14 @@ const CARDS = {
{ widget: "batch_size", label: "Batch" },
],
},
AUSBOSS_NODES_FlashVSRUpscale: {
minWidth: 340,
rows: [
{ widget: "model", label: "Model", kind: "select" },
{ widget: "scale", label: "Scale", suffix: "×", decimals: 2 },
{ widget: "color_match", label: "Keep colors" },
],
},
AUSBOSS_NODES_AlignImage: {
minWidth: 300,
rows: [
@@ -328,6 +352,23 @@ const CARDS = {
rows: [{ widget: "text", kind: "textarea", placeholder: "Prompt, caption, or shared text", height: 100, grow: true,
looks: { big: { height: 62, placeholder: "type here" } } }],
},
// One line for each run. The three settings under the box say where a run
// ends, whether a leading "1." comes off, and how many lines run at most.
AUSBOSS_NODES_TextLines: {
minWidth: 320,
rows: [
{ widget: "text", kind: "textarea", placeholder: "One line for each run", height: 100, grow: true },
{ widget: "split", label: "Split at", kind: "segment" },
{ widget: "remove_numbering", label: "Numbering", onText: "remove", offText: "keep" },
{ widget: "limit", label: "Limit" },
],
},
// The seed arrives on a socket (forceInput), so the card is the prompt
// alone, with its {a|b} choice sets.
AUSBOSS_NODES_PromptVariations: {
minWidth: 300,
rows: [{ widget: "text", kind: "textarea", placeholder: "A woman in a {red|blue|green} {sweater|jacket}. One choice is picked from each set.", height: 100, grow: true }],
},
// All the words go in the box, and the strip under it shows when each
// moment plays. The pictures, the video, the voice and the song arrive on
// sockets, and each picture's job sits under the words.
@@ -397,6 +438,31 @@ const CARDS = {
title: "The longest the sound may be. Anything past it is cut off and shows in red. 0 means no limit." },
],
},
// Load Image: the file, then where it is in the folder and the size it
// goes in and comes out at, then the size settings. ComfyUI's own picture
// preview sits under the card, so its mask editor and copy and paste work
// as they do on the stock loader.
AUSBOSS_NODES_LoadImage: {
minWidth: 320, first: true, hide: ["upload"],
rows: [
{ widget: "image", label: "Source", kind: "select", preview: { kind: "image", url: viewUrl },
button: { text: PANEL_LABELS.load, title: "Upload a picture into ComfyUI's input folder", onClick: (node) => node.widgets?.find((w) => w.name === "upload")?.callback?.() } },
{ panel: loadImageStrip, height: 26 },
{ panel: loadImagePreview, height: 72 },
{ widget: "size", label: "Size", kind: "select" },
{ widget: "megapixels", label: "Megapixels", suffix: "MP", when: isMode("size", "megapixels") },
{ widget: "longest_side", label: "Longest side", suffix: "px", when: isMode("size", "longest side") },
{ widget: "scale", label: "Scale", suffix: "\u00d7", when: isMode("size", "scale") },
{ widget: "ratio", label: "Ratio", kind: "select", when: isMode("size", "crop to ratio") },
{ widget: "width", label: "Width", suffix: "px", when: loadImageUsesBox },
{ widget: "height", label: "Height", suffix: "px", when: loadImageUsesBox },
{ widget: "crop_from", label: "Keep", kind: "select", when: isMode("size", "crop to fill", "crop", "crop to ratio") },
{ widget: "multiple", label: "Round to", snap: true, step: 8, when: (values) => values.size !== "off" },
{ group: "more", label: "More" },
{ widget: "method", label: "Method", kind: "select", group: "more" },
{ widget: "enlarge", label: "Enlarge", group: "more" },
],
},
AUSBOSS_NODES_LoadImagePad: {
minWidth: 340, first: true, hide: ["upload"],
rows: [
@@ -416,7 +482,7 @@ const CARDS = {
],
},
AUSBOSS_NODES_SaveVideo: {
// step_preview is the STEPS button on the player (js/save_video).
// step_preview is the Preview steps switch on the player (js/save_video).
minWidth: 320, first: true, hide: ["step_preview"],
rows: [
{ widget: "filename_prefix", label: "Prefix", placeholder: "video" },
+2
View File
@@ -19,6 +19,7 @@ import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } fro
import { collectGraphNodes } from "../shared/appearance.mjs";
import { copyToClipboard } from "../shared/clipboard.mjs";
import { confirmDiscard } from "../shared/discard_prompt.mjs";
import { closeOnBackdropClick } from "../shared/dialog_backdrop.mjs";
import { WIDGET_FRAME, fillNodeHeight, holdNodeMinHeight } from "../shared/panel_layout.mjs";
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
import { suppressCoreImagePreview } from "../shared/core_preview.mjs";
@@ -551,6 +552,7 @@ function render(state) {
if (note.author) header.append(el("p", "", `by ${note.author}`));
dialog.append(header);
renderSections({ note, rows: [] }, dialog);
closeOnBackdropClick(dialog);
document.body.append(dialog);
state.reader = dialog;
dialog.addEventListener("close", () => {
+3 -4
View File
@@ -604,7 +604,7 @@ function newSwitch(state) {
// has choices the nodes they hold, so the next one takes no selecting.
function choiceSnapshots(state, nodes) {
const graph = state.node.graph;
const held = choiceNodeIds(settingsOf(state.node)).map((id) => graph?.getNodeById?.(Number(id))).filter(Boolean);
const held = choiceNodeIds(settingsOf(state.node)).map((id) => graph?.getNodeById?.(id)).filter(Boolean);
return [...new Set([...nodes, ...held])]
.map((node) => ({ id: node.id, values: snapshotNode(node) }))
.filter((item) => Object.keys(item.values).length);
@@ -1156,7 +1156,7 @@ function refresh(state) {
// The selection only shows in the edit view (the + Switch bar).
const pick = edit ? picked(state) : { nodes: [], groups: [], count: 0 };
const signature = [
rowsSignature(rows, settings, rowKey), groups.length, edit, settingsOpen(node), pick.count,
rowsSignature(rows, settings, rowKey), groups.length, edit, settingsOpen(node), pick.count, pick.nodes.length,
].join("|");
if (signature === state.signature) return;
state.signature = signature;
@@ -1166,7 +1166,7 @@ function refresh(state) {
// Called on every canvas redraw: read the graph again at most every
// RECHECK_MS, and always once after the last redraw.
function scheduleCheck(state) {
if (state.disposed || state.timer !== null) return;
if (state.disposed || !state.node.graph || state.timer !== null) return;
const shown = app.canvas?.getCurrentGraph?.() ?? app.canvas?.graph;
if (state.node.graph && shown && state.node.graph !== shown) return;
const wait = Math.max(0, state.checkedAt + RECHECK_MS - performance.now());
@@ -1254,7 +1254,6 @@ function buildPanel(node) {
trimNodeBottom(node);
state.widget = widget;
live.add(state);
ensureRedrawHook();
return state;
}
+198
View File
@@ -172,6 +172,64 @@
}
}
},
"AUSBOSS_NODES_EditLock": {
"display_name": "编辑锁定 🆎",
"description": "让图片里编辑没有改动的部分保持在原位,颜色也不变。编辑模型会把整张图重画一遍,没改的部分常常略微放大、偏移或偏色。把这个节点放在模型和采样器之间,并接上你要编辑的图片。不需要遮罩:它在出图过程中看出编辑改了哪里。如果编辑改变的是整张图(新风格、新光线、新天气),它会自动让开。解码后再接上编辑锁定还原 🆎,未改动的部分就能换回你原图的像素。",
"inputs": {
"model": {
"name": "模型"
},
"vae": {
"name": "vae"
},
"image": {
"name": "图像"
},
"lock": {
"name": "锁定"
},
"tolerance": {
"name": "容差"
},
"mask": {
"name": "遮罩"
}
},
"outputs": {
"0": {
"name": "模型"
},
"1": {
"name": "编辑锁"
}
}
},
"AUSBOSS_NODES_EditLockRestore": {
"display_name": "编辑锁定还原 🆎",
"description": "把编辑没有改动的部分换回你原图自己的像素。放在 VAE 解码之后,并接上编辑锁定 🆎 的 edit_lock 输出。未改动的部分与原图逐像素一致,只有编辑的部分是新的。如果锁定让开了,图片原样通过。",
"inputs": {
"image": {
"name": "图像"
},
"edit_lock": {
"name": "编辑锁"
},
"feather": {
"name": "羽化"
}
},
"outputs": {
"0": {
"name": "图像"
},
"1": {
"name": "编辑遮罩"
},
"2": {
"name": "报告"
}
}
},
"AUSBOSS_NODES_Float": {
"display_name": "浮点数 🆎",
"description": "一个独立的 FLOAT 值。在多个节点之间共用同一个强度、缩放或 CFG,只需在一处修改。",
@@ -593,6 +651,71 @@
}
}
},
"AUSBOSS_NODES_LoadImage": {
"display_name": "加载图像 🆎",
"description": "在同一个节点里加载图片并设定尺寸。可以选择文件、上传,或用箭头逐张浏览输入文件夹。尺寸可以关闭,也可以按百万像素、最长边、缩放倍数、放入一个框内,或裁剪填满一个框来调整。也可以不缩放,直接从图片中裁出一块或裁成某个比例,并选择保留哪一边。运行之前,节点就会显示输入和输出的图片及两者的尺寸。",
"inputs": {
"image": {
"name": "图像"
},
"size": {
"name": "尺寸"
},
"megapixels": {
"name": "百万像素"
},
"longest_side": {
"name": "最长边"
},
"scale": {
"name": "缩放倍数"
},
"width": {
"name": "宽度"
},
"height": {
"name": "高度"
},
"multiple": {
"name": "取整倍数"
},
"method": {
"name": "插值"
},
"enlarge": {
"name": "放大"
},
"crop_from": {
"name": "保留部分"
},
"ratio": {
"name": "比例"
}
},
"outputs": {
"0": {
"name": "图像"
},
"1": {
"name": "遮罩"
},
"2": {
"name": "宽度"
},
"3": {
"name": "高度"
},
"4": {
"name": "文件名"
},
"5": {
"name": "原始宽度"
},
"6": {
"name": "原始高度"
}
}
},
"AUSBOSS_NODES_LoadImagePad": {
"display_name": "加载图像 + 扩边 🆎",
"description": "在一个节点里加载图像并扩边成扩图画布。拖动节点上画布的任意一边,直观地设置每边的扩边量;遮罩正好覆盖新扩出的部分,可选择向内羽化跨过接缝。画布会取整到整数倍,但不会给你没扩的边加条带;设置百万像素目标时会先缩放原图,让遮罩接缝保持清晰。",
@@ -1052,6 +1175,9 @@
},
"step_preview": {
"name": "步骤预览"
},
"autosave": {
"name": "自动保存"
}
},
"outputs": {
@@ -1240,6 +1366,35 @@
}
}
},
"AUSBOSS_NODES_TextLines": {
"display_name": "逐行文本 🆎",
"description": "文本的每一行都会让后面的节点运行一次。输入五条提示词,每行一条,运行一次就得到五张图。空行会跳过,行首的序号或项目符号可以去掉。",
"inputs": {
"text": {
"name": "文本"
},
"split": {
"name": "分隔方式"
},
"remove_numbering": {
"name": "去掉序号"
},
"limit": {
"name": "上限"
}
},
"outputs": {
"0": {
"name": "行"
},
"1": {
"name": "序号"
},
"2": {
"name": "数量"
}
}
},
"AUSBOSS_NODES_TiledUpscale": {
"display_name": "分块放大 🆎",
"description": "以百万像素设置成品尺寸,并把图片切成模型可以逐块重画的图块。一块就能装下的图片保持完整。把图块送进采样器,再和 stitcher 一起送进分块放大拼合 🆎。小图不会被重画到远超其细节的程度:最多重画到原百万像素的 max_growth 倍,再用普通方式放大。report 会说明对你的图片做了什么。",
@@ -1591,5 +1746,48 @@
"AUSBOSS_NODES_WorkflowNoteTile": {
"display_name": "工作流说明卡片 🆎",
"description": "点击打开完整说明的小按钮。点击铅笔编辑,拖动角落调整大小。旧版节点包会将它显示为缺失节点,请更新 ComfyUI-AusBoss。"
},
"AUSBOSS_NODES_FlashVSRUpscale": {
"display_name": "FlashVSR 视频放大 🆎",
"description": "用 FlashVSR 模型把视频放大并变清晰。选好模型文件,设定放大倍数,然后运行。节点每次只读取几帧,所以长视频和短视频占用的显存一样多,每一帧都会返回。接上 Wan 2.1 VAE 可以得到更慢但更细致的结果。",
"inputs": {
"frames": {
"name": "帧"
},
"model": {
"name": "模型"
},
"scale": {
"name": "放大倍数"
},
"color_match": {
"name": "保留颜色"
},
"vae": {
"name": "vae"
}
},
"outputs": {
"0": {
"name": "帧"
}
}
},
"AUSBOSS_NODES_PromptVariations": {
"display_name": "提示词变体 🆎",
"description": "每次运行都会变化的提示词框。把选项写在花括号里,例如 {红色|蓝色|绿色} 的 {毛衣|夹克},节点会从每组里选一个。种子决定选哪个:同一个种子得到同一个提示词,每次换新种子就得到新的组合。没有花括号的文字原样输出。",
"inputs": {
"text": {
"name": "文本"
},
"seed": {
"name": "种子"
}
},
"outputs": {
"0": {
"name": "提示词"
}
}
}
}
+31 -17
View File
@@ -208,7 +208,7 @@ def _weighted_mean_cov(
def _matrix_sqrt(matrix: torch.Tensor, inverse: bool = False) -> torch.Tensor:
"""Symmetric PSD square root (or inverse square root) via eigh."""
values, vectors = torch.linalg.eigh(matrix.double())
values = values.clamp_min(1e-10)
values = values.clamp_min(1e-10 if inverse else 0.0)
roots = values.rsqrt() if inverse else values.sqrt()
return (vectors @ torch.diag(roots) @ vectors.T).to(matrix.dtype)
@@ -229,18 +229,36 @@ def _mkl_matched(
weights = None
if mask is not None:
weights = mask[index if mask.shape[0] > 1 else 0].reshape(-1)
img_mean, img_cov = _weighted_mean_cov(pixels, weights)
img_mean, img_cov = _weighted_mean_cov(
pixels.double(), weights.double() if weights is not None else None
)
ref_pixels = reference[index if reference.shape[0] > 1 else 0].reshape(-1, 3)
ref_mean, ref_cov = _weighted_mean_cov(ref_pixels, None)
ref_mean, ref_cov = _weighted_mean_cov(ref_pixels.double(), None)
# Regularize both covariances in the same units. Their product has
# squared units, so its square root must not use that absolute floor.
regularizer = torch.eye(3, dtype=img_cov.dtype, device=img_cov.device) * 1e-10
img_cov = img_cov + regularizer
ref_cov = ref_cov + regularizer
img_root = _matrix_sqrt(img_cov)
img_root_inv = _matrix_sqrt(img_cov, inverse=True)
middle = _matrix_sqrt(img_root @ ref_cov @ img_root)
# SVD gives the middle square root without squaring the condition
# number of nearly singular covariances in an eigendecomposition.
_, singular, right = torch.linalg.svd(_matrix_sqrt(ref_cov) @ img_root)
middle = right.T @ torch.diag(singular) @ right
mapping = img_root_inv @ middle @ img_root_inv
matched = (pixels - img_mean) @ mapping.T + ref_mean
frames.append(matched.reshape(rgb.shape[1], rgb.shape[2], 3))
frames.append(matched.to(rgb.dtype).reshape(rgb.shape[1], rgb.shape[2], 3))
return torch.stack(frames, dim=0).clamp(0.0, 1.0)
def _interpolate(values: torch.Tensor, positions: torch.Tensor, samples: torch.Tensor) -> torch.Tensor:
"""Linear interpolation with constant endpoints, including a singleton."""
high = torch.searchsorted(positions, values.contiguous()).clamp_max(positions.numel() - 1)
low = (high - 1).clamp_min(0)
fraction = ((values - positions[low]) / (positions[high] - positions[low]).clamp_min(1e-20)).clamp(0, 1)
return samples[low] + fraction * (samples[high] - samples[low])
def _histogram_matched(
rgb: torch.Tensor, reference: torch.Tensor, mask: torch.Tensor | None
) -> torch.Tensor:
@@ -263,18 +281,14 @@ def _histogram_matched(
for channel in range(3):
values = frame[..., channel]
pool = values[selected] if selected is not None else values.reshape(-1)
sorted_pool, _ = pool.reshape(-1).sort()
sorted_ref, _ = ref[..., channel].reshape(-1).sort()
# Rank every pixel against the measured pool, then read the same
# quantile off the reference.
ranks = torch.searchsorted(sorted_pool, values.reshape(-1).contiguous())
ranks = ranks.clamp_max(sorted_pool.numel() - 1)
quantiles = ranks.float() / max(1, sorted_pool.numel() - 1)
positions = quantiles * (sorted_ref.numel() - 1)
low = positions.floor().long()
high = positions.ceil().long()
fraction = positions - low.float()
mapped = sorted_ref[low] * (1.0 - fraction) + sorted_ref[high] * fraction
source_values, source_counts = torch.unique(pool, sorted=True, return_counts=True)
ref_values, ref_counts = torch.unique(ref[..., channel], sorted=True, return_counts=True)
source_quantiles = source_counts.cumsum(0).float() / pool.numel()
ref_quantiles = ref_counts.cumsum(0).float() / ref[..., channel].numel()
# Equal values share their cumulative probability, independent
# of how many pixels were used to represent the distribution.
mapping = _interpolate(source_quantiles, ref_quantiles, ref_values)
mapped = _interpolate(values.reshape(-1), source_values, mapping)
channels.append(mapped.reshape(values.shape))
frames.append(torch.stack(channels, dim=-1))
return torch.stack(frames, dim=0).clamp(0.0, 1.0)
+2 -3
View File
@@ -36,7 +36,7 @@ import torch
from PIL import Image
from ._brush_helpers import brush_core, brush_sigma, stroke_cover # noqa: F401 - the brush's numbers are read from here too
from ._image_save_helpers import next_free_counter, require_output_path, strip_image_extension
from ._image_save_helpers import IMAGE_SAVE_LOCK, next_free_counter, require_output_path, strip_image_extension
from ._psd_helpers import PSD_MAX_SIDE, write_psd
from ._realign_helpers import crop_if_shift, measure, warp_to_source
@@ -72,7 +72,6 @@ ALIGN_FAILED = (
"or have too little detail to measure."
)
_save_lock = threading.Lock()
_pairs: OrderedDict[str, tuple[Path, Path, str]] = OrderedDict()
_pairs_lock = threading.Lock()
@@ -361,7 +360,7 @@ def save_mix(*, picture_a, picture_b, output_dir, top="A", strokes=None, layers=
mixed = mix_pictures(upper, under, gone)
folder = Path(output_dir)
with _save_lock:
with IMAGE_SAVE_LOCK:
folder.mkdir(parents=True, exist_ok=True)
counter = next_free_counter((entry.name for entry in folder.iterdir()), name)
while True:
+455
View File
@@ -0,0 +1,455 @@
"""Edit Lock: keep what an edit did not change. Pure torch, no ComfyUI imports.
At every sampling step an edit model predicts the finished picture. Where
that prediction sits close to the original, the edit is not happening
there, so the original is put back and the model carries on from the real
picture. This is the target TailSteer trains a model toward
(original where nothing changed, the model's edit elsewhere), applied while
sampling instead of through training.
What counts as changed is measured against the quiet part of the picture:
the tenth of it where the prediction differs least, which a local edit
leaves near the level of the model's own redraw noise. The node watches the
first steps, where it only undoes the model's zoom and shift, and decides
once the picture has formed. An edit of the whole picture (a style, the
light) has no quiet part: the first look checks for that and the lock steps
aside. A soft change over the whole picture (rain, snow, haze) has no clear
peak: the decision checks for that, and the picture is only held in place.
"""
from __future__ import annotations
import torch
import torch.nn.functional as F
LOCK_MODES = ("auto", "always", "off")
# A spot counts as changed when it differs from the original by STRONG times
# the quiet level, or by WEAK times while it touches a spot that does. So a
# faint change next to a clear one belongs to the edit, and a faint change
# alone is redraw noise. Set against what 14 plain MiniMax-H3-Image edits
# changed in the end: this frees about 80% of it at the first look and 3%
# of what stayed.
WEAK = 2.0
STRONG = 3.0
# The model often draws the whole picture a little larger or shifted. That
# is measured and discounted before anything is compared, up to these
# limits; a bigger move is treated as part of the edit.
# The node watches the start of a run and decides once the noise is down to
# this share of where it began. The first prediction is a blur: rain, snow,
# smoke and the light a new hair color throws on a face only show a few
# steps in. While it watches it only undoes the zoom and shift.
DECIDE_AT = 0.8
# A local edit has a peak: somewhere the picture clearly changes. Rain, snow
# and haze change the whole picture a little and no part clearly. A lock
# would hold them back, or let them through in patches where it hands a wide
# part of the picture to the edit. So two things are read at the decision,
# in units of the quiet level: the strongest spot, and the strongest
# hundredth when the lock would free SOFT_WIDE of the picture or more. On
# recorded MiniMax-H3-Image runs the strongest spot read 4.7 or more for
# every local edit, glasses and makeup included, and 3.7 or less for light
# rain, fog and snow; with a wide part freed, the strongest hundredth read
# 4.4 or more for local edits and 3.6 or less for rain and snow.
SOFT_WIDE = 0.10
SOFT_PEAK = 4.0
# Kept this much or more: the report adds what to do if the edit is missing.
# A soft effect over the whole picture (rain, snow, smoke) looks like a small
# edit at the first look, and under the lock the model then drops it.
NEARLY_ALL = 0.95
MAX_ZOOM = 0.05
MAX_SHIFT = 4.0
# The whole picture is changing when even its calmest fifth sits this far
# from the original at the first look, in units of the original's own
# spread. Measured on MiniMax-H3-Image: 28 local edits at 1, 4 and 16 MP came
# in at 0.22 or less (zoomed ones included), 12 whole-picture edits (styles,
# light, weather) at 0.27 or more.
WHOLE_PICTURE = 0.243
# Blur and growth of the change map, as a share of the short side.
REACH = 1.0 / 54.0
# A result this much larger or smaller than the picture is another picture.
SIZE_SLACK = 0.04
def box_blur(x: torch.Tensor, radius: int) -> torch.Tensor:
if radius <= 0:
return x
size = 2 * radius + 1
return F.avg_pool2d(F.pad(x, (radius,) * 4, mode="replicate"), size, stride=1)
def reach(height: int, width: int) -> int:
return max(1, round(min(height, width) * REACH))
def _moved(source: torch.Tensor, move: torch.Tensor) -> torch.Tensor:
"""The original sampled at (zoom_x * x + shift_x, zoom_y * y + shift_y), x and y in cells from the centre.
move = [zoom_x - 1, zoom_y - 1, shift_x, shift_y].
"""
height, width = source.shape[-2:]
theta = torch.zeros(source.shape[0], 2, 3, device=source.device, dtype=source.dtype)
theta[:, 0, 0] = 1.0 + move[0]
theta[:, 1, 1] = 1.0 + move[1]
theta[:, 0, 2] = 2.0 * move[2] / width
theta[:, 1, 2] = 2.0 * move[3] / height
grid = F.affine_grid(theta, list(source.shape), align_corners=False)
return F.grid_sample(source, grid, mode="bilinear", padding_mode="border", align_corners=False)
def measure_redraw(pred: torch.Tensor, source: torch.Tensor) -> torch.Tensor:
"""How much larger and how far shifted the prediction is drawn, as the move that lays the original over it.
pred and source are [1, C, H, W]. Fitted on the calmer half of the
picture, so the edit itself does not pull the answer, from a coarse grid
down to the full one. Returns [zoom_x - 1, zoom_y - 1, shift_x, shift_y];
zeros when nothing can be measured.
"""
pred, source = pred.float(), source.float()
move = torch.zeros(4, device=pred.device, dtype=torch.float32)
limit = torch.tensor([MAX_ZOOM, MAX_ZOOM, MAX_SHIFT, MAX_SHIFT], device=pred.device)
for level in (4, 2, 1):
if min(pred.shape[-2:]) // level < 16:
continue
a = box_blur(F.avg_pool2d(pred, level) if level > 1 else pred, 1)
b = box_blur(F.avg_pool2d(source, level) if level > 1 else source, 1)
height, width = a.shape[-2:]
ys = (torch.arange(height, device=a.device, dtype=torch.float32) + 0.5 - height / 2)[:, None].expand(height, width)
xs = (torch.arange(width, device=a.device, dtype=torch.float32) + 0.5 - width / 2)[None, :].expand(height, width)
here = move.clone()
here[2:] = here[2:] / level
for _ in range(6):
laid = _moved(b, here)
rest = a - laid
rest = rest - rest.flatten(2).median(dim=2).values[:, :, None, None]
wide = F.pad(laid, (1, 1, 1, 1), mode="replicate")
gx = (wide[..., 1:-1, 2:] - wide[..., 1:-1, :-2]) / 2.0
gy = (wide[..., 2:, 1:-1] - wide[..., :-2, 1:-1]) / 2.0
size = rest.abs().mean(dim=1, keepdim=True)
calm = (size <= size.flatten().median()).float()
calm[..., :2, :] = 0.0
calm[..., -2:, :] = 0.0
calm[..., :, :2] = 0.0
calm[..., :, -2:] = 0.0
jac = (torch.stack([gx * xs, gy * ys, gx, gy], dim=-1) * calm[..., None]).reshape(-1, 4)
normal = jac.T @ jac + 1e-6 * torch.eye(4, device=a.device)
try:
change = torch.linalg.solve(normal, jac.T @ (rest * calm).reshape(-1))
except RuntimeError:
return torch.zeros(4, device=pred.device, dtype=torch.float32)
if not bool(torch.isfinite(change).all()):
return torch.zeros(4, device=pred.device, dtype=torch.float32)
here = here + change
if float(change.abs().max()) < 1e-4:
break
move = here.clone()
move[2:] = move[2:] * level
move = torch.maximum(torch.minimum(move, limit), -limit)
return move
def redraws(pred: torch.Tensor, source: torch.Tensor) -> list[torch.Tensor]:
"""measure_redraw for each picture of a batch."""
return [measure_redraw(pred[index:index + 1], source[:1]) for index in range(pred.shape[0])]
def difference(pred: torch.Tensor, source: torch.Tensor, moves: list[torch.Tensor] | None = None) -> torch.Tensor:
"""How far each spot of the prediction is from the original, [B, 1, H, W].
Two things are not a change. A redraw of the whole picture slightly
larger or shifted: the original is laid over the prediction first. And
an even tint: each channel's middle difference is taken out. The result
is in units of the original's own spread, which keeps the numbers alike
between models.
"""
pred = pred.float()
source = source.float()
moves = moves if moves is not None else redraws(pred, source)
out = []
for index in range(pred.shape[0]):
delta = pred[index:index + 1] - _moved(source[:1], moves[index])
delta = delta - delta.flatten(2).median(dim=2).values[:, :, None, None]
out.append(delta.abs().mean(dim=1, keepdim=True))
magnitude = torch.cat(out) / source.std().clamp_min(1e-6)
return box_blur(magnitude, reach(*magnitude.shape[-2:]))
def moved_back(pred: torch.Tensor, moves: list[torch.Tensor]) -> torch.Tensor:
"""The prediction with its zoom and shift undone, so it sits where the original does.
Resampling softens a latent, so a move under a tenth of a cell at the
corner is left alone, and the rest is resampled bicubic: on recorded
runs that kept 85% of the fine detail where bilinear kept 65%.
"""
out = []
for index in range(pred.shape[0]):
one = pred[index:index + 1]
move = moves[index]
height, width = one.shape[-2:]
corner = (move[0].abs() * width / 2 + move[2].abs()).maximum(move[1].abs() * height / 2 + move[3].abs())
if float(corner) < 0.1:
out.append(one)
continue
theta = torch.zeros(1, 2, 3, device=one.device, dtype=torch.float32)
theta[:, 0, 0] = 1.0 / (1.0 + move[0])
theta[:, 1, 1] = 1.0 / (1.0 + move[1])
theta[:, 0, 2] = -2.0 * move[2] / (1.0 + move[0]) / width
theta[:, 1, 2] = -2.0 * move[3] / (1.0 + move[1]) / height
grid = F.affine_grid(theta, list(one.shape), align_corners=False)
back = F.grid_sample(one.float(), grid, mode="bicubic", padding_mode="border", align_corners=False)
out.append(back.to(one.dtype))
return torch.cat(out)
def broad_change(pred: torch.Tensor, source: torch.Tensor) -> torch.Tensor:
"""How far the calmest fifth of each picture sits from the original, [B].
Read on a smoothed copy, where a redraw one percent larger nearly
vanishes and a new style, new light or new weather does not. An even
tint is taken out first: the model adds one by itself, a stronger one
on large pictures, and that is no edit.
"""
delta = (pred - source).float()
delta = delta - delta.flatten(2).median(dim=2).values[:, :, None, None]
delta = box_blur(delta, max(1, round(min(delta.shape[-2:]) / 30.0)))
level = (delta * delta).mean(dim=1).sqrt().flatten(1)
stride = max(1, level.shape[1] // 200000)
spread = source.float().std().clamp_min(1e-6)
return torch.quantile(level[:, ::stride], 0.2, dim=1) / spread
def quiet_level(magnitude: torch.Tensor) -> torch.Tensor:
"""The tenth of each picture that differs least, [B]."""
flat = magnitude.flatten(1)
stride = max(1, flat.shape[1] // 200000)
return torch.quantile(flat[:, ::stride], 0.1, dim=1)
def peaks(magnitude: torch.Tensor, quiet: torch.Tensor) -> tuple[float, float]:
"""How clear the clearest change is, in units of the quiet level: (strongest spot, strongest hundredth)."""
ratio = (magnitude / quiet.clamp_min(1e-6)[:, None, None, None]).flatten(1)
stride = max(1, ratio.shape[1] // 200000)
return float(ratio.max(dim=1).values.min()), float(torch.quantile(ratio[:, ::stride], 0.99, dim=1).min())
def change_core(magnitude: torch.Tensor, quiet: torch.Tensor, tolerance: float = 1.0,
held: torch.Tensor | None = None) -> torch.Tensor:
"""1 where the edit changed the picture, 0 where it did not, hard-edged, [B, 1, H, W].
`held` is what earlier steps called changed: it stays changed, and
faint change that touches it joins it.
"""
ratio = magnitude / quiet.clamp_min(1e-6)[:, None, None, None]
weak = (ratio > WEAK * tolerance).float()
core = (ratio > STRONG * tolerance).float()
if held is not None:
weak = torch.maximum(weak, held)
core = torch.maximum(core, held)
for _ in range(sum(core.shape[-2:])):
grown = F.max_pool2d(core, 3, stride=1, padding=1) * weak
if torch.equal(grown, core):
break
core = grown
return core
def soften(core: torch.Tensor) -> torch.Tensor:
"""The hard change map with a margin and a soft edge, so the edit has room to blend in."""
grow = reach(*core.shape[-2:])
return box_blur(F.max_pool2d(core, 2 * grow + 1, stride=1, padding=grow), grow)
def fit_latent(latent: torch.Tensor, height: int, width: int) -> torch.Tensor | None:
"""The original's latent on the result's grid, or None when they are different pictures."""
h, w = latent.shape[-2:]
if (h, w) == (height, width):
return latent
if abs(h / height - 1.0) > SIZE_SLACK or abs(w / width - 1.0) > SIZE_SLACK:
return None
return F.interpolate(latent, size=(height, width), mode="bilinear", align_corners=False)
class EditLock:
"""One lock: the original, the settings, and what the last run kept."""
def __init__(self, mode: str = "auto", tolerance: float = 1.0):
self.mode = mode
self.tolerance = tolerance
self.source: torch.Tensor | None = None # IMAGE, [1, H, W, 3]
self.latent: torch.Tensor | None = None # [1, C, H, W] or [1, C, 1, H, W], model space
self.user_mask: torch.Tensor | None = None # MASK, white = the edit may happen
self.status = "waiting"
self.mask: torch.Tensor | None = None # [B, 1, h, w], 1 = changed, soft-edged
self.core: torch.Tensor | None = None # the same before its margin, hard-edged
self.quiet: torch.Tensor | None = None # [B], the quiet level at the decision
self.broad: float | None = None # the first look's whole-picture reading
self.wide: float | None = None # share of the picture the lock would free, at the decision
self.top: float | None = None # the strongest spot then, in units of the quiet level
self.peak: float | None = None # and the strongest hundredth
self._sigma: float | None = None
self._first = True
self._first_sigma = 1.0
self._decided = False
def _begin(self) -> None:
self.status = "waiting"
self.mask = None
self.core = None
self.quiet = None
self.broad = None
self.wide = None
self.top = None
self.peak = None
self._first = True
self._decided = False
def _decides_now(self, args: dict, sigma: float) -> bool:
"""True once the noise is down far enough, or this is the run's last step."""
steps = (args.get("model_options") or {}).get("transformer_options", {}).get("sample_sigmas")
if steps is None or len(steps) < 2:
return True # the sampler does not say where the run ends: decide at once
if sigma <= float(steps[-2]) * 1.0001:
return True
return sigma <= DECIDE_AT * self._first_sigma
def step(self, args: dict) -> torch.Tensor:
"""ComfyUI's post-CFG hook: the prediction comes in, the locked prediction goes out."""
pred = args["denoised"]
sigma = float(args["sigma"].flatten()[0])
if self._sigma is None or sigma >= self._sigma:
self._begin() # the noise level went up: a new run
self._sigma = sigma
if self.status in ("whole picture", "size", "video", "vae") or self.latent is None:
return pred
video = pred.ndim == 5
if video and pred.shape[2] != 1:
self.status = "video"
return pred
flat = pred[:, :, 0] if video else pred
latent = self.latent[:, :, 0] if self.latent.ndim == 5 else self.latent
if latent.shape[1] != flat.shape[1]:
self.status = "vae"
return pred
source = fit_latent(latent.to(device=flat.device, dtype=torch.float32), *flat.shape[-2:])
if source is None:
self.status = "size"
return pred
if self.status == "soft":
# nothing is put back, the picture is only held where the original sits
back = moved_back(flat, redraws(flat, source))
return back[:, :, None] if video else back
if self.user_mask is not None:
mask = self._given_mask(flat)
else:
if self._first:
self._first_sigma = sigma
self.broad = float(broad_change(flat, source).max())
if self.mode == "auto" and self.broad > WHOLE_PICTURE:
self.status = "whole picture"
return pred
moves = redraws(flat, source)
magnitude = difference(flat, source, moves)
if not self._decided and not self._decides_now(args, sigma):
# still watching: the model draws its own picture, only its zoom and shift are undone
self._first = False
self.status = "watching"
if self.broad > WHOLE_PICTURE:
return pred # too much is changing to trust the measured move
back = moved_back(flat, moves)
return back[:, :, None] if video else back
if not self._decided:
self._decided = True
# the yardstick is set once, here: later steps are read against it
self.quiet = quiet_level(magnitude)
self.top, self.peak = peaks(magnitude, self.quiet)
self.wide = float(change_core(magnitude, self.quiet, self.tolerance).flatten(1).mean(dim=1).max())
thin = self.top < SOFT_PEAK or (self.wide >= SOFT_WIDE and self.peak < SOFT_PEAK)
if self.mode == "auto" and not self._first and thin:
# a soft change over the whole picture: a lock would hold it back or let it through in patches
self.status = "soft"
back = moved_back(flat, moves)
return back[:, :, None] if video else back
held = self.core if self.core is not None and self.core.shape[0] == flat.shape[0] else None
self.core = change_core(magnitude, self.quiet.to(magnitude.device), self.tolerance,
held.to(magnitude.device) if held is not None else None).detach()
mask = soften(self.core)
self._first = False
self.mask = mask.detach()
self.status = "locked"
keep = (1.0 - mask).to(flat.dtype)
locked = flat * (1.0 - keep) + source.to(flat.dtype) * keep
return locked[:, :, None] if video else locked
def _given_mask(self, flat: torch.Tensor) -> torch.Tensor:
mask = self.user_mask
if mask.ndim == 2:
mask = mask[None]
mask = mask[:1, None].to(device=flat.device, dtype=torch.float32)
mask = F.interpolate(mask, size=flat.shape[-2:], mode="bilinear", align_corners=False).clamp(0.0, 1.0)
return soften(mask).expand(flat.shape[0], -1, -1, -1)
def kept_share(self) -> float | None:
if self.status != "locked" or self.mask is None:
return None
return float((self.mask < 0.5).float().mean())
def pixel_mask(self, height: int, width: int) -> torch.Tensor | None:
"""The change mask at the picture's size, [B, H, W], or None when nothing was locked."""
if self.status != "locked" or self.mask is None:
return None
mask = F.interpolate(self.mask.float().cpu(), size=(height, width), mode="bilinear", align_corners=False)
return mask[:, 0].clamp(0.0, 1.0)
def restore_pixels(result: torch.Tensor, source: torch.Tensor, mask: torch.Tensor,
feather: int) -> tuple[torch.Tensor, torch.Tensor]:
"""The original's own pixels where the mask is black. result [B, H, W, C], source [1, h, w, 3], mask [b, H, W].
The fade lies outside the edit: the mask is widened by the feather first.
"""
frames, height, width = int(result.shape[0]), int(result.shape[1]), int(result.shape[2])
edit = mask[:, None].float()
if edit.shape[0] not in (1, frames):
edit = edit[:1]
if feather > 0:
size = 2 * feather + 1
edit = F.max_pool2d(edit, size, stride=1, padding=feather)
edit = box_blur(edit, feather)
original = source[..., :3].float()
if tuple(original.shape[1:3]) != (height, width):
original = F.interpolate(original.movedim(-1, 1), size=(height, width), mode="bicubic",
align_corners=False, antialias=True).movedim(1, -1).clamp(0.0, 1.0)
weight = edit.movedim(1, -1).to(result.device, result.dtype)
out = result.clone()
out[..., :3] = result[..., :3] * weight + original.to(result.device, result.dtype) * (1.0 - weight)
edit_mask = edit[:, 0].expand(frames, -1, -1).clone()
return out, edit_mask
def describe(lock: EditLock) -> str:
if lock.status == "locked":
kept = lock.kept_share() or 0.0
how = "your mask" if lock.user_mask is not None else "what the edit changed"
text = (f"Edit Lock kept {kept * 100:.0f}% of your picture as it was and left "
f"{(1.0 - kept) * 100:.0f}% to the edit ({how}).")
if kept >= NEARLY_ALL and lock.user_mask is None:
# rain, snow or smoke over the whole picture reads as a small edit and is held back
text += (" If your edit is missing, it is one that spreads thinly over the whole picture "
"(rain, snow, smoke): set lock to off.")
return text
if lock.status == "whole picture":
return ("Edit Lock stepped aside: the edit changes the whole picture, so there was nothing to keep. "
"Set lock to always to lock anyway.")
if lock.status == "soft":
return ("Edit Lock stepped aside: the edit spreads thinly over the whole picture (rain, snow, haze), "
"so no part of it stayed as it was. It only held the picture in place. "
"Set lock to always to lock anyway.")
if lock.status == "off":
return "Edit Lock is off."
if lock.status == "size":
return ("Edit Lock did nothing: the picture and the result are different sizes. "
"Give it the picture at the size of the result.")
if lock.status == "video":
return "Edit Lock did nothing: it works on single pictures, not video."
if lock.status == "vae":
return "Edit Lock did nothing: this VAE does not belong to this model."
return "Edit Lock has not run yet: plug its model output into the sampler."
+946
View File
@@ -0,0 +1,946 @@
"""FlashVSR video upscaling: the model's parts and the loop that runs a clip.
FlashVSR (OpenImagingLab, Apache-2.0) sharpens a clip in one model step.
It is a Wan 2.1 1.3B transformer that is also shown the low-quality frames,
a small projector that turns those frames into tokens, and a small decoder
that reads the frames again while it draws the result.
Everything here is plain PyTorch. The model looks at a clip a few frames at
a time and keeps a short memory of the frames before, so a long clip costs
no more graphics memory than a short one. Module and parameter names follow
the names in the published weights, which is what lets them load.
Shapes used below: a token is 16x16 output pixels of one latent frame, a
latent frame is 4 video frames, and an attention block is 2 latent frames
by 8x8 tokens (128 tokens, 128x128 pixels over 8 frames).
"""
from __future__ import annotations
import math
import os
import torch
import torch.nn as nn
import torch.nn.functional as F
PIXEL_STEP = 128 # the working size is a multiple of this in both directions
BLOCK_TOKENS = 128
KEPT_GROUPS = 3 # earlier 2-frame groups each layer remembers
LOCAL_REACH = 11 # a block looks at blocks inside an 11x11 neighbourhood
SPARSE_RATIO = 2.0
SPARSE_REFERENCE_AREA = 768 * 1280
FIRST_PASS_LATENTS = 6
PASS_LATENTS = 2
MIN_GROUPS = 3 # the first pass alone needs 25 frames
TIMESTEP = 1000.0
DEFAULT_SLOT_BUDGET = 12000
def _cast(parameter: torch.Tensor, like: torch.Tensor) -> torch.Tensor:
return parameter.to(device=like.device, dtype=like.dtype)
# --------------------------------------------------------------------------
# Sizes and frame counts
# --------------------------------------------------------------------------
def _even(value: float) -> int:
return max(2, int(round(value / 2.0)) * 2)
def plan_sizes(height: int, width: int, scale: float) -> tuple[int, int, int, int]:
"""Output size (even numbers) and the padded size the model works at."""
out_h, out_w = _even(height * scale), _even(width * scale)
work_h = math.ceil(out_h / PIXEL_STEP) * PIXEL_STEP
work_w = math.ceil(out_w / PIXEL_STEP) * PIXEL_STEP
return out_h, out_w, work_h, work_w
def plan_groups(frame_count: int) -> int:
"""How many 8-frame groups the padded clip has.
The model reads 8n+1 frames and returns 8n-3, so the clip is padded by
repeating its last frame until 8n-3 covers every frame it has.
"""
return max(MIN_GROUPS, math.ceil((int(frame_count) + 3) / 8))
# --------------------------------------------------------------------------
# Position encoding and attention blocks
# --------------------------------------------------------------------------
def _axis_angles(dim: int, positions: torch.Tensor) -> torch.Tensor:
steps = torch.arange(0, dim, 2, dtype=torch.float64)[: dim // 2] / dim
return torch.outer(positions.double(), 1.0 / (10000.0**steps))
def rope_table(head_dim: int, first_frame: int, frames: int, rows: int, cols: int, device):
"""Rotation angles for every token, as (cos, sin) of shape [tokens, 1, head_dim / 2]."""
side = head_dim // 3
t = _axis_angles(head_dim - 2 * side, torch.arange(first_frame, first_frame + frames))
r = _axis_angles(side, torch.arange(rows))
c = _axis_angles(side, torch.arange(cols))
angles = torch.cat(
[
t[:, None, None, :].expand(frames, rows, cols, -1),
r[None, :, None, :].expand(frames, rows, cols, -1),
c[None, None, :, :].expand(frames, rows, cols, -1),
],
dim=-1,
).reshape(frames * rows * cols, 1, head_dim // 2)
return angles.cos().float().to(device), angles.sin().float().to(device)
def apply_rope(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor, heads: int) -> torch.Tensor:
"""Rotate each pair of channels of x [tokens, dim] by its token's angle."""
tokens, dim = x.shape
a, b = x.float().view(tokens, heads, dim // heads // 2, 2).unbind(-1)
return torch.stack((a * cos - b * sin, a * sin + b * cos), dim=-1).view(tokens, dim).to(x.dtype)
def to_blocks(x: torch.Tensor, frames: int, rows: int, cols: int) -> torch.Tensor:
"""[tokens, dim] in (frame, row, column) order -> [blocks, 128, dim]."""
dim = x.shape[-1]
x = x.view(frames // 2, 2, rows // 8, 8, cols // 8, 8, dim).permute(0, 2, 4, 1, 3, 5, 6)
return x.reshape(-1, BLOCK_TOKENS, dim)
def from_blocks(blocks: torch.Tensor, frames: int, rows: int, cols: int) -> torch.Tensor:
dim = blocks.shape[-1]
x = blocks.view(frames // 2, rows // 8, cols // 8, 2, 8, 8, dim).permute(0, 3, 1, 4, 2, 5, 6)
return x.reshape(frames * rows * cols, dim)
def local_block_mask(block_rows: int, block_cols: int, reach: int, device) -> torch.Tensor:
"""[blocks, blocks] True where a key block is inside a query block's neighbourhood."""
r = torch.arange(block_rows, device=device).repeat_interleave(block_cols)
c = torch.arange(block_cols, device=device).repeat(block_rows)
dr, dc = r[None, :] - r[:, None], c[None, :] - c[:, None]
low, high = -(reach // 2), reach - 1 - reach // 2
return (dr >= low) & (dr <= high) & (dc >= low) & (dc <= high)
def choose_key_blocks(
q_blocks: torch.Tensor,
k_blocks: torch.Tensor,
heads: int,
local: torch.Tensor,
query_groups: int,
keep: int,
) -> torch.Tensor:
"""Pick, for every head, which key blocks each query block reads.
A block's mean query is scored against every block's mean key. Blocks
outside the neighbourhood are ruled out, the scores of a query block are
turned into shares that sum to one, and for each head and each 2-frame
group of queries the `keep` largest shares win. Returns two
[heads, query blocks, key blocks] tensors: True where a pair is kept,
and the shares themselves (0 outside the neighbourhood).
"""
nq, _, dim = q_blocks.shape
nk = k_blocks.shape[0]
head_dim = dim // heads
q_mean = q_blocks.float().mean(1).view(nq, heads, head_dim).transpose(0, 1)
k_mean = k_blocks.float().mean(1).view(nk, heads, head_dim).transpose(0, 1)
scores = q_mean @ k_mean.transpose(1, 2) / math.sqrt(head_dim)
per_group = local.shape[0]
allowed = local.repeat(nq // per_group, nk // per_group)
shares = scores.masked_fill(~allowed, float("-inf")).softmax(dim=-1)
flat = shares.view(heads, query_groups, per_group * nk)
keep = min(flat.shape[-1] - 1, int(keep))
threshold = flat.topk(keep + 1, dim=-1).values[..., -1:]
return (flat > threshold).view(heads, nq, nk), shares
def sparse_block_attention(
q: torch.Tensor,
k: torch.Tensor,
v: torch.Tensor,
chosen: torch.Tensor,
shares: torch.Tensor | None = None,
slot_budget: int = DEFAULT_SLOT_BUDGET,
) -> torch.Tensor:
"""Attention where each query block only reads its chosen key blocks.
q is [heads, query blocks, 128, head_dim], k and v are
[heads, key blocks, 128, head_dim] and chosen is
[heads, query blocks, key blocks]. Each (head, query block) is a row
that gathers the keys it chose. Rows with a similar number of chosen
blocks run together, all at the width of the row that chose most. A
row that chose nothing returns zeros. `slot_budget` caps
(rows x chosen blocks) per call, which caps memory.
With `shares` (from choose_key_blocks) a narrower row is filled up
with its next-best blocks from its neighbourhood, so a call needs no
padding mask, which is what makes it fast: a row then reads a few
blocks more than it chose, never fewer. Without `shares` the padding
is masked and every row reads exactly what it chose.
"""
heads, nq, tokens, head_dim = q.shape
nk = k.shape[1]
rows = chosen.reshape(heads * nq, nk)
counts = rows.sum(dim=1)
if shares is None:
rank, usable = rows.float(), counts
else:
flat_shares = shares.reshape(heads * nq, nk).float()
rank, usable = rows.float() * 2 + flat_shares, (flat_shares > 0).sum(dim=1)
order = torch.argsort(counts)
picks = torch.argsort(rank, dim=1, descending=True, stable=True)
picks = picks + (torch.arange(heads, device=q.device).repeat_interleave(nq) * nk)[:, None]
q_rows = q.reshape(heads * nq, tokens, head_dim)
k_rows = k.reshape(heads * nk, tokens, head_dim)
v_rows = v.reshape(heads * nk, tokens, head_dim)
out = torch.zeros_like(q_rows)
sorted_counts = counts[order].tolist()
total = len(sorted_counts)
start = 0
while start < total and sorted_counts[start] == 0:
start += 1
while start < total:
end = start + 1
while end < total and (end - start + 1) * sorted_counts[end] <= slot_budget:
end += 1
rows_here = order[start:end]
widest = sorted_counts[end - 1]
index = picks[rows_here, :widest]
keys = k_rows[index].view(end - start, widest * tokens, head_dim)
values = v_rows[index].view(end - start, widest * tokens, head_dim)
mask = None
real = torch.arange(widest, device=q.device)[None, :] < usable[rows_here][:, None]
if not bool(real.all()):
mask = real.repeat_interleave(tokens, dim=1)[:, None, :]
out[rows_here] = F.scaled_dot_product_attention(q_rows[rows_here], keys, values, attn_mask=mask)
start = end
return out.view(heads, nq, tokens, head_dim)
class AttentionPlan:
"""What stays the same for every layer and pass at one working size."""
def __init__(self, rows: int, cols: int, device, slot_budget: int = DEFAULT_SLOT_BUDGET):
self.rows, self.cols = rows, cols
self.per_group = (rows // 8) * (cols // 8)
self.local = local_block_mask(rows // 8, cols // 8, LOCAL_REACH, device)
ratio = SPARSE_RATIO * SPARSE_REFERENCE_AREA / (rows * 16 * cols * 16)
self.keep = int(self.per_group * self.per_group * ratio) - 1
self.slot_budget = slot_budget
# --------------------------------------------------------------------------
# The transformer
# --------------------------------------------------------------------------
class _RMSNorm(nn.Module):
def __init__(self, dim: int, eps: float, device=None, dtype=None):
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(dim, device=device, dtype=dtype))
def forward(self, x: torch.Tensor) -> torch.Tensor:
y = x.float()
y = y * torch.rsqrt(y.pow(2).mean(dim=-1, keepdim=True) + self.eps)
return y.to(x.dtype) * _cast(self.weight, x)
def _heads_first(blocks: torch.Tensor, heads: int) -> torch.Tensor:
count, tokens, dim = blocks.shape
return blocks.view(count, tokens, heads, dim // heads).permute(2, 0, 1, 3).contiguous()
class _SelfAttention(nn.Module):
def __init__(self, dim, heads, eps, operations, device=None, dtype=None):
super().__init__()
self.heads = heads
kw = {"device": device, "dtype": dtype}
self.q = operations.Linear(dim, dim, **kw)
self.k = operations.Linear(dim, dim, **kw)
self.v = operations.Linear(dim, dim, **kw)
self.o = operations.Linear(dim, dim, **kw)
self.norm_q = _RMSNorm(dim, eps, **kw)
self.norm_k = _RMSNorm(dim, eps, **kw)
def forward(self, x, rope, grid, memory: dict, plan: AttentionPlan):
frames, rows, cols = grid
cos, sin = rope
q = to_blocks(apply_rope(self.norm_q(self.q(x))[0], cos, sin, self.heads), *grid)
k = to_blocks(apply_rope(self.norm_k(self.k(x))[0], cos, sin, self.heads), *grid)
v = to_blocks(self.v(x)[0], *grid)
if memory.get("k") is not None:
k = torch.cat([memory["k"], k], dim=0)
v = torch.cat([memory["v"], v], dim=0)
chosen, shares = choose_key_blocks(q, k, self.heads, plan.local, frames // 2, plan.keep)
out = sparse_block_attention(
_heads_first(q, self.heads),
_heads_first(k, self.heads),
_heads_first(v, self.heads),
chosen,
shares,
plan.slot_budget,
)
if k.shape[0] // plan.per_group > KEPT_GROUPS:
k, v = k[plan.per_group :], v[plan.per_group :]
memory["k"], memory["v"] = k, v
out = out.permute(1, 2, 0, 3).reshape(-1, BLOCK_TOKENS, x.shape[-1])
return self.o(from_blocks(out, *grid)[None])
class _CrossAttention(nn.Module):
def __init__(self, dim, heads, eps, operations, device=None, dtype=None):
super().__init__()
self.heads = heads
kw = {"device": device, "dtype": dtype}
self.q = operations.Linear(dim, dim, **kw)
self.k = operations.Linear(dim, dim, **kw)
self.v = operations.Linear(dim, dim, **kw)
self.o = operations.Linear(dim, dim, **kw)
self.norm_q = _RMSNorm(dim, eps, **kw)
self.norm_k = _RMSNorm(dim, eps, **kw)
def _split(self, x: torch.Tensor) -> torch.Tensor:
batch, tokens, dim = x.shape
return x.view(batch, tokens, self.heads, dim // self.heads).transpose(1, 2)
def forward(self, x, context, memory: dict):
if memory.get("k") is None: # the prompt never changes during a run
memory["k"] = self._split(self.norm_k(self.k(context)))
memory["v"] = self._split(self.v(context))
out = F.scaled_dot_product_attention(self._split(self.norm_q(self.q(x))), memory["k"], memory["v"])
return self.o(out.transpose(1, 2).reshape(x.shape))
class _Block(nn.Module):
def __init__(self, dim, heads, ffn_dim, eps, operations, device=None, dtype=None):
super().__init__()
self.dim, self.eps = dim, eps
kw = {"device": device, "dtype": dtype}
self.self_attn = _SelfAttention(dim, heads, eps, operations, **kw)
self.cross_attn = _CrossAttention(dim, heads, eps, operations, **kw)
self.norm3 = operations.LayerNorm(dim, eps=eps, elementwise_affine=True, **kw)
self.ffn = nn.Sequential(
operations.Linear(dim, ffn_dim, **kw), nn.GELU(approximate="tanh"), operations.Linear(ffn_dim, dim, **kw)
)
self.modulation = nn.Parameter(torch.zeros(1, 6, dim, **kw))
def _plain_norm(self, x: torch.Tensor) -> torch.Tensor:
return F.layer_norm(x, (self.dim,), eps=self.eps)
def forward(self, x, context, time_mod, rope, grid, memory: dict, plan: AttentionPlan):
shift_a, scale_a, gate_a, shift_m, scale_m, gate_m = (_cast(self.modulation, x) + time_mod).chunk(6, dim=1)
x = x + gate_a * self.self_attn(self._plain_norm(x) * (1 + scale_a) + shift_a, rope, grid, memory["self"], plan)
x = x + self.cross_attn(self.norm3(x), context, memory["cross"])
return x + gate_m * self.ffn(self._plain_norm(x) * (1 + scale_m) + shift_m)
class _Head(nn.Module):
def __init__(self, dim, out_dim, eps, operations, device=None, dtype=None):
super().__init__()
self.dim, self.eps = dim, eps
self.head = operations.Linear(dim, out_dim * 4, device=device, dtype=dtype)
self.modulation = nn.Parameter(torch.zeros(1, 2, dim, device=device, dtype=dtype))
def forward(self, x, time_embed):
shift, scale = (_cast(self.modulation, x) + time_embed).chunk(2, dim=1)
return self.head(F.layer_norm(x, (self.dim,), eps=self.eps) * (1 + scale) + shift)
# --------------------------------------------------------------------------
# The low-quality projector
# --------------------------------------------------------------------------
class _ChannelNorm(nn.Module):
def __init__(self, dim: int, device=None, dtype=None):
super().__init__()
self.scale = dim**0.5
self.gamma = nn.Parameter(torch.ones(dim, 1, 1, 1, device=device, dtype=dtype))
def forward(self, x: torch.Tensor) -> torch.Tensor:
return F.normalize(x.float(), dim=1).to(x.dtype) * self.scale * _cast(self.gamma, x)
class LowQualityProjector(nn.Module):
"""Turns enlarged low-quality frames into one set of tokens per 4 frames.
Two convolutions that only look backwards in time, each halving the
frame rate, then one linear layer. `push` is fed the first frame alone,
then 4 frames at a time, and remembers what the next call needs.
"""
def __init__(self, out_dim=1536, hidden=(2048, 3072), patch=16, operations=nn, device=None, dtype=None):
super().__init__()
self.patch = patch
kw = {"device": device, "dtype": dtype}
self.conv1 = operations.Conv3d(3 * patch * patch, hidden[0], (4, 3, 3), stride=(2, 1, 1), **kw)
self.norm1 = _ChannelNorm(hidden[0], **kw)
self.conv2 = operations.Conv3d(hidden[0], hidden[1], (4, 3, 3), stride=(2, 1, 1), **kw)
self.norm2 = _ChannelNorm(hidden[1], **kw)
self.linear_layers = nn.ModuleList([operations.Linear(hidden[1], out_dim, **kw)])
def _fold(self, clip: torch.Tensor) -> torch.Tensor:
batch, channels, frames, height, width = clip.shape
p = self.patch
x = clip.view(batch, channels, frames, height // p, p, width // p, p).permute(0, 1, 4, 6, 2, 3, 5)
return x.reshape(batch, channels * p * p, frames, height // p, width // p)
@staticmethod
def _causal(conv, x: torch.Tensor, earlier) -> torch.Tensor:
front = 2
if earlier is not None:
x = torch.cat([earlier, x], dim=2)
front -= earlier.shape[2]
return conv(F.pad(x, (1, 1, 1, 1, front, 0), mode="replicate"))
def push(self, memory: dict, clip: torch.Tensor):
"""clip is [1, 3, frames, height, width] in -1..1. Returns tokens, or None for the first frame."""
first = not memory
if first:
clip = torch.cat([clip[:, :, :1].repeat(1, 1, 3, 1, 1), clip], dim=2)
x = self._fold(clip)
tail = x[:, :, -2:].clone()
x = F.silu(self.norm1(self._causal(self.conv1, x, memory.get("conv1"))))
memory["conv1"] = tail
tail = x[:, :, -2:].clone()
if first:
memory["conv2"] = tail
return None
x = F.silu(self.norm2(self._causal(self.conv2, x, memory["conv2"])))
memory["conv2"] = tail
return self.linear_layers[0](x.flatten(2).transpose(1, 2))
# --------------------------------------------------------------------------
# The fast decoder
# --------------------------------------------------------------------------
class _SoftClamp(nn.Module):
def forward(self, x):
return torch.tanh(x / 3) * 3
class _MemoryBlock(nn.Module):
"""A residual block that also sees the frame before."""
def __init__(self, width: int, operations, device=None, dtype=None):
super().__init__()
kw = {"device": device, "dtype": dtype}
self.conv = nn.Sequential(
operations.Conv2d(width * 2, width, 3, padding=1, **kw),
nn.ReLU(),
operations.Conv2d(width, width, 3, padding=1, **kw),
nn.ReLU(),
operations.Conv2d(width, width, 3, padding=1, **kw),
)
def forward(self, x, past):
return F.relu(self.conv(torch.cat([x, past], dim=1)) + x)
class _TimeGrow(nn.Module):
"""Makes `stride` frames out of each frame."""
def __init__(self, width: int, stride: int, operations, device=None, dtype=None):
super().__init__()
self.conv = operations.Conv2d(width, width * stride, 1, bias=False, device=device, dtype=dtype)
def forward(self, x):
_, channels, height, width = x.shape
return self.conv(x).reshape(-1, channels, height, width)
class _Upsample(nn.Module):
def forward(self, x):
return F.interpolate(x, scale_factor=2, mode="nearest")
class ConditionedDecoder(nn.Module):
"""Latent frames plus the low-quality frames in, 4 video frames per latent frame out."""
def __init__(self, latent_channels=16, widths=(512, 256, 128, 128), operations=nn, device=None, dtype=None):
super().__init__()
kw = {"device": device, "dtype": dtype}
a, b, c, d = widths
def conv(n_in, n_out, bias=True):
return operations.Conv2d(n_in, n_out, 3, padding=1, bias=bias, **kw)
def memory_blocks(width):
return [_MemoryBlock(width, operations, **kw) for _ in range(3)]
self.latent_channels = latent_channels
self.decoder = nn.Sequential(
_SoftClamp(),
conv(latent_channels + 3 * 4 * 8 * 8, a),
nn.ReLU(),
conv(a, a, bias=False),
nn.ReLU(),
*memory_blocks(a),
_Upsample(),
_TimeGrow(a, 1, operations, **kw),
conv(a, b, bias=False),
*memory_blocks(b),
_Upsample(),
_TimeGrow(b, 2, operations, **kw),
conv(b, c, bias=False),
*memory_blocks(c),
_Upsample(),
_TimeGrow(c, 2, operations, **kw),
conv(c, d, bias=False),
nn.ReLU(),
conv(d, d, bias=False),
nn.ReLU(),
conv(d, 3),
)
@staticmethod
def fold_frames(clip: torch.Tensor) -> torch.Tensor:
"""[3, 4, height, width] -> [768, height / 8, width / 8]."""
channels, frames, height, width = clip.shape
x = clip.view(channels, frames, height // 8, 8, width // 8, 8).permute(0, 1, 3, 5, 2, 4)
return x.reshape(channels * frames * 64, height // 8, width // 8)
def decode(self, memory: dict, x: torch.Tensor) -> torch.Tensor:
"""x is [latent frames, 768 + latent channels, h, w]. Returns [4 x latent frames, 3, 8h, 8w] in 0..1.
The first 3 frames of the first call are lead-in and are dropped.
"""
first = not memory
memory["started"] = True
for index, layer in enumerate(self.decoder):
if isinstance(layer, _MemoryBlock):
before = memory.get(index)
if before is None:
before = torch.zeros_like(x[:1])
memory[index] = x[-1:].clone()
x = layer(x, torch.cat([before, x[:-1]], dim=0))
else:
x = layer(x)
return x[3:] if first else x
# --------------------------------------------------------------------------
# The whole model
# --------------------------------------------------------------------------
class FlashVSRModel(nn.Module):
def __init__(
self,
dim=1536,
heads=12,
ffn_dim=8960,
layers=30,
latent_channels=16,
text_dim=4096,
text_len=512,
freq_dim=256,
eps=1e-6,
projector_hidden=(2048, 3072),
decoder_widths=(512, 256, 128, 128),
operations=nn,
device=None,
dtype=None,
):
super().__init__()
self.dim, self.heads, self.freq_dim, self.latent_channels = dim, heads, freq_dim, latent_channels
kw = {"device": device, "dtype": dtype}
self.patch_embedding = operations.Conv3d(latent_channels, dim, (1, 2, 2), stride=(1, 2, 2), **kw)
self.text_embedding = nn.Sequential(
operations.Linear(text_dim, dim, **kw), nn.GELU(approximate="tanh"), operations.Linear(dim, dim, **kw)
)
self.time_embedding = nn.Sequential(
operations.Linear(freq_dim, dim, **kw), nn.SiLU(), operations.Linear(dim, dim, **kw)
)
self.time_projection = nn.Sequential(nn.SiLU(), operations.Linear(dim, dim * 6, **kw))
self.blocks = nn.ModuleList([_Block(dim, heads, ffn_dim, eps, operations, **kw) for _ in range(layers)])
self.head = _Head(dim, latent_channels, eps, operations, **kw)
self.lq_proj_in = LowQualityProjector(dim, projector_hidden, operations=operations, **kw)
self.tc_decoder = ConditionedDecoder(latent_channels, decoder_widths, operations=operations, **kw)
self.register_buffer("positive_conditioning", torch.zeros(text_len, text_dim, **kw))
def start(self, like: torch.Tensor) -> dict:
"""The values that never change during one clip."""
half = self.freq_dim // 2
steps = torch.pow(10000.0, -torch.arange(half, dtype=torch.float64) / half) * TIMESTEP
wave = torch.cat([torch.cos(steps), torch.sin(steps)])[None].to(device=like.device, dtype=like.dtype)
time_embed = self.time_embedding(wave)
return {
"time_embed": time_embed[:, None],
"time_mod": self.time_projection(time_embed).unflatten(1, (6, self.dim)),
"context": self.text_embedding(_cast(self.positive_conditioning, like)[None]),
"layers": [{"self": {}, "cross": {}} for _ in self.blocks],
"projector": {},
"decoder": {},
}
def denoise(self, state: dict, noise: torch.Tensor, lq_tokens: torch.Tensor, first_frame: int, plan: AttentionPlan):
"""One pass over a few latent frames. Returns the clean latent frames."""
x = self.patch_embedding(noise)
frames, rows, cols = x.shape[2:]
grid = (frames, rows, cols)
x = x.flatten(2).transpose(1, 2) + lq_tokens
rope = rope_table(self.dim // self.heads, first_frame, frames, rows, cols, x.device)
for block, memory in zip(self.blocks, state["layers"]):
x = block(x, state["context"], state["time_mod"], rope, grid, memory, plan)
x = self.head(x, state["time_embed"])
x = x.view(1, frames, rows, cols, 2, 2, self.latent_channels).permute(0, 6, 1, 2, 4, 3, 5)
return noise - x.reshape(1, self.latent_channels, frames, rows * 2, cols * 2)
def pass_layout(groups: int):
"""(first latent frame, latent frames) of each pass over a clip of `groups` 8-frame groups."""
yield 0, FIRST_PASS_LATENTS
for index in range(1, groups - 2):
yield 4 + 2 * index, PASS_LATENTS
def match_colors(frames: torch.Tensor, source: torch.Tensor) -> torch.Tensor:
"""Give each frame its source frame's mean and spread, per colour channel. Both [n, 3, h, w]."""
f, s = frames.float(), source.float()
f_mean, s_mean = f.mean(dim=(2, 3), keepdim=True), s.mean(dim=(2, 3), keepdim=True)
f_std, s_std = f.std(dim=(2, 3), keepdim=True) + 1e-5, s.std(dim=(2, 3), keepdim=True) + 1e-5
return (f - f_mean) / f_std * s_std + s_mean
TILE_OVERLAP = 128
def plan_tiles(work_h: int, work_w: int, max_pixels: int | None) -> list[tuple[int, int, int, int]]:
"""Cut the working size into as few overlapping tiles as fit `max_pixels` each.
Returns (top, left, height, width) boxes. Tile sides are multiples of
128 and corners sit on the 16-pixel token grid. One box, the whole
picture, when it fits or when no limit is given.
"""
if not max_pixels or work_h * work_w <= max_pixels:
return [(0, 0, work_h, work_w)]
def side(total: int, parts: int) -> int:
if parts == 1:
return total
return min(total, math.ceil((total + (parts - 1) * TILE_OVERLAP) / parts / PIXEL_STEP) * PIXEL_STEP)
best = None
for rows in range(1, work_h // PIXEL_STEP + 1):
for cols in range(1, work_w // PIXEL_STEP + 1):
tile_h, tile_w = side(work_h, rows), side(work_w, cols)
if tile_h * tile_w <= max_pixels and (best is None or rows * cols < best[0] * best[1]):
best = (rows, cols, tile_h, tile_w)
if best is None: # even one 128x128 tile is over the limit: use the smallest tiles there are
best = (work_h // PIXEL_STEP, work_w // PIXEL_STEP, PIXEL_STEP, PIXEL_STEP)
rows, cols, tile_h, tile_w = best
def starts(total: int, tile: int, parts: int) -> list[int]:
if parts == 1:
return [0]
return [round(i * (total - tile) / (parts - 1) / 16) * 16 for i in range(parts)]
return [(top, left, tile_h, tile_w) for top in starts(work_h, tile_h, rows) for left in starts(work_w, tile_w, cols)]
def tile_weight(box: tuple[int, int, int, int], work_h: int, work_w: int) -> torch.Tensor:
"""[height, width] blend weights for a tile: 1 inside, fading over the overlap on edges that meet another tile."""
top, left, height, width = box
def ramp(length: int, fade_start: bool, fade_end: bool) -> torch.Tensor:
w = torch.ones(length)
fade = (torch.arange(TILE_OVERLAP) + 0.5) / TILE_OVERLAP
if fade_start:
w[:TILE_OVERLAP] = fade
if fade_end:
w[-TILE_OVERLAP:] = torch.minimum(w[-TILE_OVERLAP:], fade.flip(0))
return w
rows = ramp(height, top > 0, top + height < work_h)
cols = ramp(width, left > 0, left + width < work_w)
return rows[:, None] * cols[None, :]
@torch.no_grad()
def upscale_frames(
model: FlashVSRModel,
frames: torch.Tensor,
scale: float,
*,
device,
dtype,
color_match: bool = True,
seed: int = 0,
slot_budget: int = DEFAULT_SLOT_BUDGET,
max_tile_pixels: int | None = None,
decode_latents=None,
on_pass=None,
) -> torch.Tensor:
"""Upscale a clip. `frames` is [count, height, width, 3] in 0..1; so is the result, at the new size.
A picture larger than `max_tile_pixels` is done in overlapping tiles,
one after the other, and blended; that is what bounds graphics memory.
`decode_latents`, when given, takes the clean latent frames of a tile
([1, 16, frames, h, w]) and returns [frames, height, width, 3]; it is how
another decoder (the Wan 2.1 VAE) is used in place of the fast one.
`on_pass(done, total)` is called after each pass.
"""
count, height, width, _ = frames.shape
out_h, out_w, work_h, work_w = plan_sizes(height, width, scale)
groups = plan_groups(count)
layout = list(pass_layout(groups))
tiles = plan_tiles(work_h, work_w, max_tile_pixels)
def enlarged(start: int, stop: int) -> torch.Tensor:
"""Frames start..stop-1 of the padded clip at the working size: [n, 3, work_h, work_w] in 0..1."""
index = torch.arange(start, stop).clamp(max=count - 1)
x = frames[index][..., :3].to(device).movedim(-1, 1).float()
if (height, width) != (out_h, out_w):
x = F.interpolate(x, size=(out_h, out_w), mode="bicubic", align_corners=False).clamp(0, 1)
return F.pad(x, (0, work_w - out_w, 0, work_h - out_h), mode="replicate")
noise = torch.randn(
(1, model.latent_channels, 2 * groups, work_h // 8, work_w // 8), generator=torch.Generator().manual_seed(seed)
)
result = torch.zeros((count, out_h, out_w, 3), dtype=torch.float32)
total_weight = torch.zeros((work_h, work_w), dtype=torch.float32)
like = torch.zeros(1, device=device, dtype=dtype)
done = 0
for box in tiles:
top, left, tile_h, tile_w = box
weight = tile_weight(box, work_h, work_w)
total_weight[top : top + tile_h, left : left + tile_w] += weight
weight = weight.to(device)
def crop(start: int, stop: int) -> torch.Tensor:
return enlarged(start, stop)[..., top : top + tile_h, left : left + tile_w]
def signed_clip(start: int, stop: int) -> torch.Tensor:
return (crop(start, stop) * 2 - 1).movedim(0, 1)[None].to(dtype)
def store(decoded: torch.Tensor, written: int) -> int:
"""Add decoded frames [n, 3, tile_h, tile_w] to the result; returns how many frames the tile has given."""
decoded = decoded[: count - written].float()
if decoded.shape[0] == 0:
return written
stop = written + decoded.shape[0]
if color_match:
decoded = match_colors(decoded, crop(written, stop))
bottom, right = min(top + tile_h, out_h), min(left + tile_w, out_w)
if bottom > top and right > left:
piece = (decoded.clamp(0, 1) * weight)[..., : bottom - top, : right - left]
result[written:stop, top:bottom, left:right] += piece.movedim(1, -1).cpu()
return stop
state = model.start(like)
plan = AttentionPlan(tile_h // 16, tile_w // 16, device, slot_budget)
tile_noise = noise[..., top // 8 : (top + tile_h) // 8, left // 8 : (left + tile_w) // 8]
kept_latents = []
written = 0
model.lq_proj_in.push(state["projector"], signed_clip(0, 1))
for first, latent_count in layout:
tokens = torch.cat(
[
model.lq_proj_in.push(state["projector"], signed_clip(4 * i + 1, 4 * i + 5))
for i in range(first, first + latent_count)
],
dim=1,
)
chunk = tile_noise[:, :, first : first + latent_count].to(device=device, dtype=dtype)
latents = model.denoise(state, chunk, tokens, first, plan)
if decode_latents is not None:
kept_latents.append(latents.float().cpu())
else:
for i in range(first, first + latent_count):
if written >= count:
break
source = signed_clip(0, 1)[0].repeat(1, 4, 1, 1) if i == 0 else signed_clip(4 * i - 3, 4 * i + 1)[0]
both = torch.cat([ConditionedDecoder.fold_frames(source), latents[0, :, i - first]], dim=0)
written = store(model.tc_decoder.decode(state["decoder"], both[None]), written)
done += 1
if on_pass is not None:
on_pass(done, len(layout) * len(tiles))
state.clear() # the layers' memory is no longer needed; the next tile or another decoder wants the room
if decode_latents is not None:
decoded = decode_latents(torch.cat(kept_latents, dim=2)).movedim(-1, 1)
for start in range(0, min(count, decoded.shape[0]), 8):
written = store(decoded[start : start + 8].to(device), written)
if len(tiles) > 1:
result /= total_weight[:out_h, :out_w, None]
return result
# --------------------------------------------------------------------------
# ComfyUI side: loading the file and running a clip. ComfyUI is imported
# inside the functions so the code above runs without it.
# --------------------------------------------------------------------------
MODEL_PARTS = {
"blocks.0.self_attn.q.weight": "the transformer",
"lq_proj_in.conv1.weight": "the low-quality projector",
"tc_decoder.decoder.1.weight": "the fast decoder",
"positive_conditioning": "the stored prompt",
}
NOT_A_FLASHVSR_FILE = (
"This is not a FlashVSR model file for this node: it has no {missing}. "
"Pick the FlashVSR file named in the node's help page."
)
_loaded: dict = {}
# Measured on one clip (640x1024, bf16): about 9.6 KB of graphics memory per working pixel on top of
# the weights. Most of it is the keys and values every layer keeps of the last few frames.
WORKING_BYTES_PER_PIXEL = 12_000
MEMORY_TO_ASK_FOR = 16 * 1024**3
MEMORY_HEADROOM = 1024**3
def working_memory(pixels: int, bytes_per_value: int = 2) -> int:
"""A rough count of the graphics memory a picture of `pixels` needs besides the weights, in bytes."""
return int(pixels * WORKING_BYTES_PER_PIXEL * bytes_per_value / 2)
def tile_pixels_that_fit(free_bytes: int, bytes_per_value: int = 2) -> int:
"""The largest tile, in pixels, that `free_bytes` of graphics memory can hold."""
return max(PIXEL_STEP * PIXEL_STEP, int((free_bytes - MEMORY_HEADROOM) / working_memory(1_000_000, bytes_per_value) * 1_000_000))
def load_flashvsr(path: str, dtype: torch.dtype | None = None):
"""The model behind ComfyUI's memory manager, and the number type it runs in. Kept between runs."""
import comfy.model_management as mm
import comfy.model_patcher
import comfy.ops
import comfy.utils
stamp = (path, os.path.getmtime(path), dtype)
if _loaded.get("stamp") == stamp:
return _loaded["patcher"], _loaded["dtype"]
_loaded.clear()
weights = comfy.utils.load_torch_file(path, safe_load=True)
missing = [what for key, what in MODEL_PARTS.items() if key not in weights]
if missing:
raise ValueError(NOT_A_FLASHVSR_FILE.format(missing=" or ".join(missing)))
device, offload = mm.get_torch_device(), mm.unet_offload_device()
if dtype is None:
# The file is 16-bit, so that is the first choice: no weight has to be converted.
if mm.should_use_fp16(device):
dtype = torch.float16
elif mm.should_use_bf16(device):
dtype = torch.bfloat16
else:
dtype = torch.float32
# manual_cast: every layer brings its weights to the device and number type of what it is given,
# so a model ComfyUI keeps partly, or wholly, outside graphics memory still runs.
model = FlashVSRModel(operations=comfy.ops.manual_cast, device=offload, dtype=dtype)
model.eval()
# ComfyUI's own wrapper for a model: the one that loads weights as they are needed, where it has one.
wrapper = getattr(comfy.model_patcher, "CoreModelPatcher", comfy.model_patcher.ModelPatcher)
patcher = wrapper(model, load_device=device, offload_device=offload)
assign = bool(getattr(patcher, "is_dynamic", lambda: False)())
model.load_state_dict(weights, strict=True, assign=assign)
model.requires_grad_(False)
_loaded.update(stamp=stamp, patcher=patcher, dtype=dtype)
return patcher, dtype
def _memory_for_work(patcher, device) -> int:
"""Graphics memory left for the picture once all of the model's weights are in it."""
import comfy.model_management as mm
free = mm.get_free_memory(device)
try:
still_to_load = max(0, int(patcher.model_size()) - int(patcher.loaded_size()))
except Exception:
still_to_load = 0
return free - still_to_load
def run_flashvsr(
path: str,
frames: torch.Tensor,
scale: float,
color_match: bool = True,
vae=None,
seed: int = 0,
max_tile_pixels: int | None = None,
):
"""Upscale `frames` with the model file at `path`. `vae`, when given, decodes in place of the fast decoder."""
import comfy.latent_formats
import comfy.model_management as mm
from ._execution_helpers import progress_bar, raise_if_interrupted
patcher, dtype = load_flashvsr(path)
device = patcher.load_device
_, _, work_h, work_w = plan_sizes(frames.shape[1], frames.shape[2], scale)
wanted = working_memory(work_h * work_w, dtype.itemsize)
mm.load_models_gpu([patcher], memory_required=min(wanted, MEMORY_TO_ASK_FOR))
if max_tile_pixels is None and device.type != "cpu":
max_tile_pixels = tile_pixels_that_fit(_memory_for_work(patcher, device), dtype.itemsize)
passes = len(list(pass_layout(plan_groups(frames.shape[0]))))
decode = None
if vae is not None:
latent_format = comfy.latent_formats.Wan21()
def decode(latents: torch.Tensor) -> torch.Tensor:
images = vae.decode(latent_format.process_out(latents))
return images.reshape(-1, *images.shape[-3:])
while True:
progress = progress_bar(passes * len(plan_tiles(work_h, work_w, max_tile_pixels)))
def on_pass(done: int, total: int) -> None:
raise_if_interrupted()
if progress is not None:
progress.update_absolute(done, total)
try:
return upscale_frames(
patcher.model,
frames,
scale,
device=device,
dtype=dtype,
color_match=color_match,
seed=seed,
max_tile_pixels=max_tile_pixels,
decode_latents=decode,
on_pass=on_pass,
)
except torch.cuda.OutOfMemoryError:
# Out of graphics memory: start again in tiles half the size, down to the smallest tile.
tried = max_tile_pixels if max_tile_pixels is not None else work_h * work_w
if tried <= PIXEL_STEP * PIXEL_STEP:
raise
max_tile_pixels = max(PIXEL_STEP * PIXEL_STEP, min(tried, work_h * work_w) // 2)
mm.soft_empty_cache()
MODEL_FOLDER = "diffusion_models"
EXPECTED_MODEL_NAME = "flashvsr_v1.1_fp16.safetensors"
MODEL_NOT_FOUND = (
"FlashVSR Upscale could not find {name} in ComfyUI/models/diffusion_models. "
"Download the FlashVSR model file named in the node's help page and put it there."
)
def list_flashvsr_models() -> list[str]:
"""FlashVSR files in the diffusion models folder; every file there when none is named FlashVSR."""
try:
import folder_paths
except ImportError: # Offline tests import this module without ComfyUI.
return [EXPECTED_MODEL_NAME]
names = list(folder_paths.get_filename_list(MODEL_FOLDER))
ours = [name for name in names if "flashvsr" in name.lower().replace("_", "").replace("-", "")]
return ours or names or [EXPECTED_MODEL_NAME]
def resolve_flashvsr_model(name: str) -> str:
import folder_paths
path = folder_paths.get_full_path(MODEL_FOLDER, name)
if not path:
raise FileNotFoundError(MODEL_NOT_FOUND.format(name=name))
return path
+11 -1
View File
@@ -340,7 +340,9 @@ def write_prompt(script, seconds=15.0, camera="", look="", sound="", language="E
cast.clip_sound = True
# With no words typed, the clip's own speech is still heard, so the new person says it as the video does.
cast.typed_lines = any(moment.speech for moment in scene.moments)
cast.speaks_as_video = bool(speech) and not cast.typed_lines
# Only a voice makes them speak: a train or a beat is loud in the voice band too, but has no clear pitch,
# and the line made the new person mouth along to it (subway clip, 2026-11-07: 0.0 s of clear voice).
cast.speaks_as_video = heard_a_voice(speech) and not cast.typed_lines
time_from_sound(scene.moments, speech)
scene.moments = [moment for moment in scene.moments if not (moment.voice or moment.other)]
if not scene.moments:
@@ -438,6 +440,14 @@ LONGEST_LINE = 12 # Stretches of speech one line can take.
GAP_WEIGHT, INNER_GAP_WEIGHT, NOISE_WEIGHT, PITCH_WEIGHT = 1.0, 1.0, 1.5, 6.0
SPEAKING_VOICE = 0.5 # Seconds of clear voice before the clip counts as someone talking.
def heard_a_voice(speech):
"""Whether find_speech heard someone talk: enough clear voice, not just loud sound in the voice band."""
return sum(stretch[3] for stretch in speech or ()) >= SPEAKING_VOICE
def find_speech(samples, rate):
"""[(start, end, pitch, voiced), ...]: the stretches of a mono track loud
enough in the voice band to be words, in seconds, with each stretch's
+30 -30
View File
@@ -10,6 +10,7 @@ as a path below that root, and anything that leaves it is refused.
from __future__ import annotations
import hashlib
import heapq
import json
import os
import re
@@ -125,45 +126,44 @@ def list_folders(root, folder, source: str = "input") -> list[str]:
def list_pictures(root, folder, subfolders: bool = False, sort: str = "name", source: str = "input",
limit: int = MAX_PICTURES) -> list[dict]:
"""Every picture in the folder: {"name": path below the folder, "path",
"mtime", "size"}, in the chosen order. Hidden files, links that lead out
of the root and anything that is not a picture are left out."""
"mtime", "mtime_ns", "size"}, in the chosen order. Hidden files, links
that lead out of the root and anything that is not a picture are left out."""
base = safe_folder(root, folder, source)
top = Path(root).resolve()
found: list[dict] = []
if subfolders:
walker = os.walk(base, followlinks=False)
else:
walker = [(str(base), [], sorted(os.listdir(base)))]
for directory, folders, files in walker:
folders[:] = sorted(name for name in folders if not name.startswith("."))
for name in files:
if name.startswith(".") or not name.lower().endswith(IMAGE_EXTENSIONS):
continue
path = Path(directory) / name
try:
real = path.resolve()
if top not in real.parents or not real.is_file():
def candidates():
for directory, folders, files in walker:
folders[:] = sorted(name for name in folders if not name.startswith("."))
for name in files:
if name.startswith(".") or not name.lower().endswith(IMAGE_EXTENSIONS):
continue
stat = real.stat()
except OSError:
continue
found.append({
"name": path.relative_to(base).as_posix(),
"path": real,
"mtime": float(stat.st_mtime),
"size": int(stat.st_size),
})
if len(found) >= limit:
break
if len(found) >= limit:
break
path = Path(directory) / name
try:
real = path.resolve()
if top not in real.parents or not real.is_file():
continue
stat = real.stat()
except (OSError, RuntimeError):
continue
yield {
"name": path.relative_to(base).as_posix(),
"path": real,
"mtime": float(stat.st_mtime),
"mtime_ns": int(stat.st_mtime_ns),
"size": int(stat.st_size),
}
if sort == "newest first":
found.sort(key=lambda item: (-item["mtime"], natural_key(item["name"])))
order = lambda item: (-item["mtime_ns"], natural_key(item["name"]))
elif sort == "oldest first":
found.sort(key=lambda item: (item["mtime"], natural_key(item["name"])))
order = lambda item: (item["mtime_ns"], natural_key(item["name"]))
else:
found.sort(key=lambda item: natural_key(item["name"]))
return found
order = lambda item: natural_key(item["name"])
return heapq.nsmallest(limit, candidates(), key=order)
def parse_picked(text) -> list[str] | None:
@@ -216,7 +216,7 @@ def output_name(name: str) -> str:
def _has_see_through(frame: Image.Image) -> bool:
return frame.mode in ("RGBA", "LA", "PA") or (frame.mode == "P" and "transparency" in frame.info)
return frame.mode in ("RGBA", "LA", "PA") or "transparency" in frame.info
def load_picture(path) -> tuple[torch.Tensor, torch.Tensor]:
+170
View File
@@ -0,0 +1,170 @@
"""Pictures Save Image holds until they are kept.
With Auto save off, a run writes its pictures to ComfyUI's temp folder and
shows them on the node. A click on the node's Save button keeps the ones
that are wanted: they are copied into the output folder under the names the
node would have given them. Everything here works from names this module
made itself; the only thing a caller sends is the run's token and which
pictures to keep.
"""
from __future__ import annotations
from datetime import datetime
import json
from pathlib import Path
import re
import secrets
import shutil
from ._image_save_helpers import (
IMAGE_SAVE_LOCK,
existing_action,
name_stem,
next_free_counter,
plan_exact_names,
plan_local_names,
require_output_path,
resolve_output_root,
sidecar_path,
split_prefix,
strip_image_extension,
)
HELD_FOLDER = "ausboss_held"
_TOKEN = re.compile(r"^[0-9a-f]{16}$")
GONE = "These pictures are no longer held. Run the workflow again."
def plan_destinations(settings: dict, count: int, now: datetime, root: Path, output_root: Path, picks=None):
"""Where each picture goes, as (path, what to do if it exists).
Call it with IMAGE_SAVE_LOCK held: the counter it reads stays free only
until the lock is let go. `picks` keeps some of a run's pictures; an
exact name then keeps the number it would have had in the whole run.
"""
wanted = list(range(count)) if picks is None else list(picks)
extension = settings["extension"]
exact = settings.get("exact") or ""
if exact:
names = plan_exact_names(strip_image_extension(exact), extension, count)
return [(root / names[index], settings["on_existing"]) for index in wanted]
subfolder, base = split_prefix(settings["filename_prefix"])
folder = root / subfolder if subfolder else root
require_output_path(folder, output_root)
stem = name_stem(
base,
{"date": bool(settings["name_date"]), "time": bool(settings["name_time"]), "size": bool(settings["name_size"])},
now,
int(settings["width"]),
int(settings["height"]),
)
counter = bool(settings["name_counter"])
existing = [entry.name for entry in folder.iterdir()] if folder.is_dir() else []
names = plan_local_names(
stem, extension, len(wanted), counter=counter, batch=bool(settings["name_batch"]),
next_counter=next_free_counter(existing, stem),
)
# With a counter the name is free by construction. Without one, the
# legacy policy decides whether to overwrite.
policy = "overwrite" if counter else settings["on_existing"]
return [(folder / name, policy) for name in names]
def settle_destinations(planned, has_caption: bool, output_root: Path) -> list[str]:
"""Check every destination, then say 'write' or 'skip' for each.
The whole batch is settled before any write: an "error" on a later
picture stops the save before the first one lands.
"""
for path, _policy in planned:
require_output_path(path, output_root)
if has_caption:
require_output_path(sidecar_path(path), output_root)
return [
existing_action(path.exists() or (has_caption and sidecar_path(path).exists()), policy)
for path, policy in planned
]
def _held_name(token: str, index: int, extension: str) -> str:
return f"{token}_{index:03d}.{extension}"
def hold_pictures(temp_root, frames, settings: dict, encode) -> tuple[str, list[dict]]:
"""Write a run's pictures to the temp folder and remember how to name them.
`encode(path, frame)` writes one picture in the run's format. Returns
the run's token and the entries the node shows them from.
"""
folder = Path(temp_root) / HELD_FOLDER
folder.mkdir(parents=True, exist_ok=True)
token = secrets.token_hex(8)
previews = []
for index, frame in enumerate(frames):
name = _held_name(token, index, settings["extension"])
encode(folder / name, frame)
previews.append({"filename": name, "subfolder": HELD_FOLDER, "type": "temp"})
manifest = dict(settings, count=len(previews), kept={})
(folder / f"{token}.json").write_text(json.dumps(manifest), encoding="utf-8")
return token, previews
def keep_pictures(temp_root, output_root, token, picks=None, now: datetime | None = None) -> dict:
"""Copy held pictures into the output folder. Returns what was saved.
`picks` lists which pictures of the run to keep, counted from 0; None
keeps them all. A picture kept before is not saved a second time.
"""
if not isinstance(token, str) or not _TOKEN.match(token):
raise ValueError("Save Image: that is not a run it is holding.")
folder = Path(temp_root) / HELD_FOLDER
manifest_path = folder / f"{token}.json"
if not manifest_path.is_file():
raise LookupError(GONE)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
count = int(manifest["count"])
kept = {int(index): str(path) for index, path in dict(manifest.get("kept") or {}).items()}
if picks is None:
wanted = list(range(count))
else:
wanted = []
for pick in picks:
if isinstance(pick, bool) or not isinstance(pick, int) or not 0 <= pick < count:
raise ValueError("Save Image: that picture is not in this run.")
if pick not in wanted:
wanted.append(pick)
todo = [index for index in wanted if index not in kept]
output_root = Path(output_root)
caption = str(manifest.get("caption") or "")
has_caption = bool(caption.strip())
skipped: list[int] = []
saved: list[int] = []
with IMAGE_SAVE_LOCK:
root = resolve_output_root(manifest.get("output_dir", ""), output_root)
planned = plan_destinations(manifest, count, now or datetime.now(), root, output_root, picks=todo)
actions = settle_destinations(planned, has_caption, output_root)
resolved_root = output_root.resolve()
for (path, _policy), action, index in zip(planned, actions, todo):
if action == "skip":
skipped.append(index)
continue
source = folder / _held_name(token, index, manifest["extension"])
if not source.is_file():
raise LookupError(GONE)
path.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, path)
if has_caption:
sidecar_path(path).write_text(caption, encoding="utf-8")
kept[index] = path.resolve().relative_to(resolved_root).as_posix()
saved.append(index)
manifest["kept"] = {str(index): path for index, path in kept.items()}
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
return {
"count": count,
"saved": [{"index": index, "path": kept[index]} for index in saved],
"kept": sorted(kept),
"skipped": skipped,
}
+41 -1
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
import json
import re
import secrets
import threading
from datetime import datetime
from pathlib import Path, PurePosixPath, PureWindowsPath
@@ -32,6 +34,8 @@ FORMAT_EXTENSIONS = {"png": "png", "webp lossless": "webp", "jxl lossless": "jxl
COUNTER_WIDTH = 5
BATCH_WIDTH = 3
# Save Image and Compare choose counters and write under this same lock.
IMAGE_SAVE_LOCK = threading.Lock()
def metadata_disabled() -> bool:
@@ -262,10 +266,42 @@ def sidecar_path(image_path: Path) -> Path:
def frame_to_pil(frame: torch.Tensor) -> Image.Image:
array = np.clip(frame.detach().cpu().numpy() * 255.0, 0, 255).astype(np.uint8)
array = np.clip(frame.detach().cpu().float().numpy() * 255.0, 0, 255).astype(np.uint8)
if array.ndim == 3 and array.shape[-1] == 1:
array = array[..., 0]
return Image.fromarray(array)
# A small copy of each picture, for the node's face. The saved file can be
# tens of megabytes; this one is a few hundred kilobytes, so the picture
# shows at once on a slow connection too. It lives in the temp folder, which
# ComfyUI empties when it starts.
PREVIEW_FOLDER = "ausboss_preview"
PREVIEW_EDGE = 2048
def write_light_previews(temp_root, frames) -> list[str]:
"""Write one small WebP per frame; returns each place below the temp folder.
Never in the way of a save: if anything goes wrong the list is empty
and the node shows the saved file itself, as it always did.
"""
try:
folder = Path(temp_root) / PREVIEW_FOLDER
folder.mkdir(parents=True, exist_ok=True)
token = secrets.token_hex(8)
places = []
for index, frame in enumerate(frames):
image = frame_to_pil(frame)
image.thumbnail((PREVIEW_EDGE, PREVIEW_EDGE), Image.Resampling.LANCZOS)
name = f"{token}_{index:03d}.webp"
image.save(folder / name, format="WEBP", quality=88, method=2)
places.append(f"{PREVIEW_FOLDER}/{name}")
return places
except Exception:
return []
def _png_info(metadata: dict | None):
if not metadata:
return None
@@ -333,7 +369,10 @@ __all__ = [
"FORMAT_EXTENSIONS",
"IMAGE_EXTENSIONS",
"IMAGE_FORMATS",
"IMAGE_SAVE_LOCK",
"OUTPUT_DIR_RULE",
"PREVIEW_EDGE",
"PREVIEW_FOLDER",
"counter_pattern",
"encode_image",
"existing_action",
@@ -351,4 +390,5 @@ __all__ = [
"sidecar_path",
"split_prefix",
"strip_image_extension",
"write_light_previews",
]
+27 -2
View File
@@ -434,6 +434,15 @@ def build_crop(
blur_sigma = max(0.0, float(mask_blur))
if blur_sigma > 0.0:
mask = blur_mask(mask, blur_sigma).clamp(0.0, 1.0)
# New space must stay fully paintable, even after shrinking or softening.
if ext_l:
mask[:, :, :ext_l] = 1.0
if ext_r:
mask[:, :, -ext_r:] = 1.0
if ext_u:
mask[:, :ext_u, :] = 1.0
if ext_d:
mask[:, -ext_d:, :] = 1.0
height, width = image.shape[1], image.shape[2]
multiple = max(1, int(output_multiple))
blend_px = max(0, int(blend_pixels))
@@ -449,6 +458,7 @@ def build_crop(
)
bbox = mask_bbox(mask)
keep_box = bbox
if bbox is None:
rect = (0, 0, width, height)
else:
@@ -460,6 +470,17 @@ def build_crop(
rect[2] + 2 * context_px,
rect[3] + 2 * context_px,
)
if blend_px:
# Feathering dilates by blend_px, then blurs over another
# ceil(3 * sigma) pixels. Keep all of that support in the crop.
reach = blend_px + math.ceil(3.0 * (blend_px / 3.0))
bx, by, bw, bh = bbox
x0, y0 = max(0, bx - reach), max(0, by - reach)
x1, y1 = min(width, bx + bw + reach), min(height, by + bh + reach)
keep_box = (x0, y0, x1 - x0, y1 - y0)
x0, y0 = min(rect[0], x0), min(rect[1], y0)
x1, y1 = max(rect[0] + rect[2], x1), max(rect[1] + rect[3], y1)
rect = (x0, y0, x1 - x0, y1 - y0)
if keep_inside:
rect = clamp_rect_to_bounds(rect, width, height)
rect = fit_rect(rect, width, height)
@@ -480,7 +501,7 @@ def build_crop(
target_h = max(1, round(rect[3] * target_w / rect[2]))
target_w = round_up_to_multiple(target_w, multiple)
target_h = round_up_to_multiple(target_h, multiple)
keep_box = bbox if bbox is not None else (0, 0, width, height)
keep_box = keep_box if keep_box is not None else (0, 0, width, height)
rect = match_rect_shape(rect, target_w, target_h, width, height, keep_inside, keep_box)
else:
# Native sizing: the crop itself must satisfy the sampler multiple.
@@ -1056,7 +1077,11 @@ def stitch_blend_mask(stitcher: dict, frames: int = 1, seam: str = SEAM_CLASSIC)
# One depth map serves every frame, so the weight broadcasts.
weight = blend_in_weight(plan["depth"], plan["tone"])[:, oy : oy + oh, ox : ox + ow]
return weight.expand(frames, -1, -1).contiguous()
mask = blend[:, oy : oy + oh, ox : ox + ow]
# Older stitchers may have a feather wider than the actual paste window.
cx, cy, cw, ch = stitcher["crop_to_canvas"]
mask = torch.zeros_like(blend)
mask[:, cy : cy + ch, cx : cx + cw] = blend[:, cy : cy + ch, cx : cx + cw]
mask = mask[:, oy : oy + oh, ox : ox + ow]
if mask.shape[0] == 1 and frames > 1:
mask = mask.expand(frames, -1, -1)
return mask.contiguous()
+28 -5
View File
@@ -14,6 +14,8 @@ tested against.
from __future__ import annotations
import hashlib
import torch
import torch.nn.functional as F
from einops import rearrange
@@ -91,6 +93,13 @@ def _attn_kv(
k = rearrange(k, "B L (H D) -> B H L D", H=attn.kvheads)
v = rearrange(v, "B L (H D) -> B H L D", H=attn.kvheads)
q, k = attn.qknorm(q, k)
patches = transformer_options.get("patches", {})
extra_options = transformer_options.copy()
if "block_index" in transformer_options:
for callback in patches.get("attn1_patch", ()):
changes = callback(q, k, v, pe=freqs, attn_mask=mask, extra_options=extra_options)
q, k, v = changes.get("q", q), changes.get("k", k), changes.get("v", v)
freqs, mask = changes.get("pe", freqs), changes.get("attn_mask", mask)
if freqs is not None:
q, k = apply_rope(q, k, freqs)
if kv_capture is not None:
@@ -111,6 +120,9 @@ def _attn_kv(
skip_reshape=True,
transformer_options=transformer_options,
)
if "block_index" in transformer_options:
for callback in patches.get("attn1_output_patch", ()):
out = callback(out, extra_options)
return attn.wo(out * F.sigmoid(gate))
@@ -153,7 +165,10 @@ def _precompute_ref_kv(
freqs = dit.pe_embedder(refpos)
ref_kv = []
for block in dit.blocks:
transformer_options = {**transformer_options, "total_blocks": len(dit.blocks),
"block_type": "single", "img_slice": [0, h.shape[1]]}
for index, block in enumerate(dit.blocks):
transformer_options["block_index"] = index
cap = []
h = _block_kv_forward(
block,
@@ -204,7 +219,10 @@ def _forward_with_cached_refs(
freqs = dit.pe_embedder(pos)
for block, kv in zip(dit.blocks, ref_kv):
transformer_options = {**transformer_options, "total_blocks": len(dit.blocks),
"block_type": "single", "img_slice": [txtlen, combined.shape[1]]}
for index, (block, kv) in enumerate(zip(dit.blocks, ref_kv)):
transformer_options["block_index"] = index
combined = _block_kv_forward(
block,
combined,
@@ -232,8 +250,13 @@ def _forward_with_cached_refs(
def _ref_fingerprint(ref_latents, bs: int, bbox_norm: list[float] | None):
key = [bs, tuple(round(v, 6) for v in bbox_norm) if bbox_norm is not None else None]
key = [bs, tuple(bbox_norm) if bbox_norm is not None else None]
for r in ref_latents:
rf = r.float()
key.append((tuple(r.shape), float(rf.sum()), float(rf.square().sum())))
try:
version = r._version
except RuntimeError:
# Inference tensors have no mutation counter. Hash their bytes
# rather than treating equal summary statistics as equal pixels.
version = hashlib.sha256(r.detach().contiguous().view(torch.uint8).cpu().numpy().tobytes()).digest()
key.append((id(r), version, tuple(r.shape), tuple(r.stride()), r.dtype, r.device))
return tuple(key)
+247
View File
@@ -0,0 +1,247 @@
"""Load Image: the size a picture comes out at, as plain numbers.
``plan_load_size`` turns the node's size choice into what the picture is
resized to and what is cut from it. It holds no tensors, so it is tested as
numbers, and ``js/shared/load_size.mjs`` repeats it for the size the node
shows before a run (``tests/fixtures/load_size_cases.json`` holds both to the
same answers). ``apply_load_size`` runs a plan on a picture and its mask.
"""
from __future__ import annotations
import math
import numpy as np
import torch
from ._resize_helpers import plan_resize, resample_batch, resolve_target, snap_to_multiple
SIZE_MODES = (
"off",
"megapixels",
"longest side",
"scale",
"fit inside",
"crop to fill",
"crop",
"crop to ratio",
)
# Which part of the picture a crop keeps.
CROP_SIDES = (
"center",
"top",
"bottom",
"left",
"right",
"top left",
"top right",
"bottom left",
"bottom right",
)
# The last one takes its shape from the width and height boxes.
RATIOS = (
"1:1",
"4:3",
"3:4",
"3:2",
"2:3",
"16:9",
"9:16",
"5:4",
"4:5",
"21:9",
"9:21",
"2:1",
"1:2",
"width : height",
)
def _round_half_up(value: float) -> int:
return int(math.floor(value + 0.5))
def _multiple_below(value: int, multiple: int) -> int:
"""The largest multiple that is not above ``value``, never below one step."""
step = max(1, int(multiple))
return max(step, (int(value) // step) * step)
def _multiple_inside(value: int, multiple: int) -> int:
"""``value`` rounded down to a multiple, or left alone when it is smaller
than one step: a cut never asks for more than the picture holds."""
step = max(1, int(multiple))
return (int(value) // step) * step if int(value) >= step else int(value)
def _crop_start(spare: int, side: str, low: str, high: str) -> int:
"""Where a cut starts along one edge with ``spare`` pixels to lose."""
if low in side:
return 0
if high in side:
return int(spare)
return int(spare) // 2
def _ratio_terms(ratio: str, width: int, height: int) -> tuple[int, int]:
if ratio == "width : height":
return max(1, int(width)), max(1, int(height))
across, _, down = str(ratio).partition(":")
return max(1, int(across)), max(1, int(down))
def plan_load_size(
source_width: int,
source_height: int,
size: str = "off",
megapixels: float = 1.0,
longest_side: int = 1024,
scale: float = 1.0,
width: int = 1024,
height: int = 1024,
multiple: int = 1,
enlarge: bool = True,
crop_from: str = "center",
ratio: str = "1:1",
) -> dict[str, int]:
"""What one picture is resized to (``resize_width``/``resize_height``),
where a crop starts in the resized picture (``crop_x``/``crop_y``) and
the size that comes out (``width``/``height``).
``off`` gives the picture back as it is; ``multiple`` rounds only a size
that is being changed. With ``enlarge`` off nothing comes out larger than
the picture: a target above it leaves the picture at its own size, and
``crop to fill`` cuts the box's shape out of the picture without growing
it. ``fit inside`` rounds down, so the result never leaves its box.
``crop`` cuts a width x height piece out of the picture and ``crop to
ratio`` cuts the largest piece of a shape; neither resizes. ``crop_from``
names the part every cut keeps.
"""
source_w, source_h = int(source_width), int(source_height)
if source_w <= 0 or source_h <= 0:
raise ValueError("Load Image needs a picture larger than 0x0.")
if size not in SIZE_MODES:
raise ValueError(f"size must be one of {SIZE_MODES}, got '{size}'.")
if crop_from not in CROP_SIDES:
raise ValueError(f"crop_from must be one of {CROP_SIDES}, got '{crop_from}'.")
if ratio not in RATIOS:
raise ValueError(f"ratio must be one of {RATIOS}, got '{ratio}'.")
if size == "off":
return {
"resize_width": source_w,
"resize_height": source_h,
"crop_x": 0,
"crop_y": 0,
"width": source_w,
"height": source_h,
}
step = max(1, int(multiple))
def cut(resize_w: int, resize_h: int, out_w: int, out_h: int) -> dict[str, int]:
return {
"resize_width": resize_w,
"resize_height": resize_h,
"crop_x": _crop_start(resize_w - out_w, crop_from, "left", "right"),
"crop_y": _crop_start(resize_h - out_h, crop_from, "top", "bottom"),
"width": out_w,
"height": out_h,
}
if size == "crop":
out_w = _multiple_inside(min(max(1, int(width)), source_w), step)
out_h = _multiple_inside(min(max(1, int(height)), source_h), step)
return cut(source_w, source_h, out_w, out_h)
if size == "crop to ratio":
across, down = _ratio_terms(ratio, width, height)
if source_w * down >= source_h * across:
out_h = source_h
out_w = min(source_w, max(1, _round_half_up(source_h * across / down)))
else:
out_w = source_w
out_h = min(source_h, max(1, _round_half_up(source_w * down / across)))
return cut(source_w, source_h, _multiple_inside(out_w, step), _multiple_inside(out_h, step))
if size == "crop to fill":
box_w, box_h = max(1, int(width)), max(1, int(height))
if not enlarge:
grow = max(box_w / source_w, box_h / source_h)
if grow > 1.0:
# The box keeps its shape and shrinks to what the picture holds.
box_w = max(1, int(math.floor(box_w / grow + 1e-6)))
box_h = max(1, int(math.floor(box_h / grow + 1e-6)))
canvas_w = min(_multiple_below(box_w, step), max(step, source_w))
canvas_h = min(_multiple_below(box_h, step), max(step, source_h))
else:
canvas_w = snap_to_multiple(box_w, step)
canvas_h = snap_to_multiple(box_h, step)
plan = plan_resize(source_w, source_h, canvas_w, canvas_h, "cover_crop", 1)
return cut(plan["resize_width"], plan["resize_height"], canvas_w, canvas_h)
if size == "megapixels":
target_w, target_h = resolve_target(source_w, source_h, "megapixels", megapixels=megapixels)
elif size == "longest side":
target_w, target_h = resolve_target(source_w, source_h, "longest_edge", edge_length=longest_side)
elif size == "scale":
target_w, target_h = resolve_target(source_w, source_h, "scale_factor", scale_factor=scale)
else: # fit inside
fit = min(max(1, int(width)) / source_w, max(1, int(height)) / source_h)
target_w = max(1, _round_half_up(source_w * fit))
target_h = max(1, _round_half_up(source_h * fit))
if not enlarge and (target_w > source_w or target_h > source_h):
target_w, target_h = source_w, source_h
def rounded(target: int, source: int, box: int) -> int:
value = snap_to_multiple(target, step)
if size == "fit inside" and value > box:
value = _multiple_below(box, step)
if not enlarge and value > source:
value = _multiple_below(source, step)
return value
out_w = rounded(target_w, source_w, max(1, int(width)))
out_h = rounded(target_h, source_h, max(1, int(height)))
return {
"resize_width": out_w,
"resize_height": out_h,
"crop_x": 0,
"crop_y": 0,
"width": out_w,
"height": out_h,
}
def frames_to_image_and_mask(frames) -> tuple[torch.Tensor, torch.Tensor]:
"""RGBA PIL frames to a BHWC RGB batch and a BHW mask.
The mask is what ComfyUI's own Load Image gives: white where the picture
is see-through, black where it is solid, and all black for a picture
with no transparency (here at the picture's own size).
"""
images, masks = [], []
for frame in frames:
array = np.asarray(frame.convert("RGBA"), dtype=np.float32) / 255.0
images.append(torch.from_numpy(array[..., :3].copy()))
masks.append(torch.from_numpy(1.0 - array[..., 3]))
return torch.stack(images, dim=0), torch.stack(masks, dim=0)
def apply_load_size(
image: torch.Tensor, mask: torch.Tensor, plan: dict[str, int], method: str
) -> tuple[torch.Tensor, torch.Tensor]:
"""Run a plan on a BHWC picture and its BHW mask. A plan that changes
nothing hands both back untouched."""
resize_w, resize_h = int(plan["resize_width"]), int(plan["resize_height"])
out_w, out_h = int(plan["width"]), int(plan["height"])
if (int(image.shape[2]), int(image.shape[1])) != (resize_w, resize_h):
image = resample_batch(image, resize_w, resize_h, method)
mask = resample_batch(mask.unsqueeze(-1), resize_w, resize_h, method).squeeze(-1).clamp(0.0, 1.0)
if (resize_w, resize_h) != (out_w, out_h):
x0, y0 = int(plan["crop_x"]), int(plan["crop_y"])
image = image[:, y0 : y0 + out_h, x0 : x0 + out_w, :].contiguous()
mask = mask[:, y0 : y0 + out_h, x0 : x0 + out_w].contiguous()
return image, mask
__all__ = ["CROP_SIDES", "RATIOS", "SIZE_MODES", "apply_load_size", "frames_to_image_and_mask", "plan_load_size"]
+138
View File
@@ -41,6 +41,16 @@ RADIUS_MAX = 4096.0
# The face's own picture: the long side is at most this.
SMALL_SIDE = 1024
# SAM 3 is asked about a copy of a big picture: its long side is at most
# this. ComfyUI's SAM 3 reads every picture at 1008 pixels and returns each
# answer at the size of the picture it was given, on the graphics card. At
# 8192 pixels one word with sixteen matches needed over 27 GB there, and a
# look at 3840 took twice as long as at 2048, all for outlines the model
# never saw that finely. Twice what the model reads keeps its second pass
# (a close-up of each thing it found) as sharp as before on pictures up to
# this size, which come out exactly as they did.
WORK_SIDE = 2048
# How a find stroke is asked (find_points, choose_found). Measured on 27
# strokes over three pictures, each drawn 13 slightly different ways: four
# points alone returned the stroked thing in 87 % of 351 tries, two points
@@ -123,6 +133,15 @@ OTHER_PICTURE = (
"coming into this node has changed since, so none of them are used. "
"Click a thing on the node's picture, or paint over it, then run again."
)
MOVED_NOTHING = (
"Mask by Touch: the picks made on the last picture found nothing on "
"this one. Click a thing on the node's picture, or paint over it, then "
"run again."
)
# What happens to the picks when another picture comes in: they are used on
# it, in the same places, or the run stops until something is picked anew.
NEW_PICTURE = ("keep picks", "start over")
KEEP_PICKS, START_OVER = NEW_PICTURE
NOT_TOUCHES = (
"Mask by Touch could not read what was picked. Press Clear on the node "
"and pick again."
@@ -183,6 +202,53 @@ def touch_stamp(text) -> str:
return f"{value:08x}"
# A picture key starts with the picture's size: "<width>x<height>-".
_KEY_SIZE = re.compile(r"(\d{1,9})x(\d{1,9})-")
def _number(value) -> bool:
return not isinstance(value, bool) and isinstance(value, (int, float)) and math.isfinite(value)
def move_touches(text, key: str) -> str:
"""Touches made on another picture, moved onto the picture `key` names.
Every touch keeps its place: the same pixels on a picture of the same
size, the same spot scaled on one of another size, a brush's width
scaled with it. Everything else in a touch (its word, its chip, how soft
it was) stays as written. The face writes this text back as it is, so
the node and the face go on from the same touches. Whole pixels round
half up, as the face rounds.
"""
data = json.loads(str(text))
old = _KEY_SIZE.match(str(data.get("key") or ""))
new = _KEY_SIZE.match(str(key))
across, down = 1.0, 1.0
if old and new and int(old.group(1)) > 0 and int(old.group(2)) > 0:
across = int(new.group(1)) / int(old.group(1))
down = int(new.group(2)) / int(old.group(2))
if (across, down) != (1.0, 1.0):
def at(x, y):
return math.floor(x * across + 0.5), math.floor(y * down + 0.5)
for entry in data.get("items", []):
if not isinstance(entry, dict):
continue
if _number(entry.get("x")) and _number(entry.get("y")):
entry["x"], entry["y"] = at(entry["x"], entry["y"])
if _number(entry.get("r")):
entry["r"] = max(1, math.floor(entry["r"] * math.sqrt(across * down) + 0.5))
if isinstance(entry.get("pts"), list):
entry["pts"] = [
[*at(point[0], point[1]), *point[2:]]
if isinstance(point, list) and len(point) >= 2 and _number(point[0]) and _number(point[1])
else point
for point in entry["pts"]
]
data["key"] = str(key)
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
def _whole(value) -> int:
"""A coordinate as a whole pixel. Text, a missing value or a number that
is not finite is an error."""
@@ -330,6 +396,73 @@ def read_ask(ask, height: int, width: int) -> tuple:
return "look", None
# ---------------------------------------------------------------------------
# The copy of a big picture that SAM 3 is asked about
# ---------------------------------------------------------------------------
class WorkView:
"""One picture as SAM 3 is asked about it.
A picture whose long side is over WORK_SIDE is asked about on a smaller
copy (`frame`, averaged down, so nothing shimmers), and what SAM 3
answers there is brought back to the picture's own size with `full`.
A smaller picture is its own copy: `same` is true and nothing changes.
Touches and pieces keep their places in pixels of the picture. `point`
gives a place on the copy, `full_point` a place on the picture that
`point` leads back to the same pixel of the copy.
"""
def __init__(self, frame: torch.Tensor):
self.height, self.width = int(frame.shape[1]), int(frame.shape[2])
long_side = max(self.height, self.width)
self.same = long_side <= WORK_SIDE
if self.same:
self.frame, self.work_height, self.work_width = frame, self.height, self.width
return
scale = WORK_SIDE / long_side
self.work_height = max(1, int(math.floor(self.height * scale + 0.5)))
self.work_width = max(1, int(math.floor(self.width * scale + 0.5)))
small = F.interpolate(
frame.detach().float().cpu().movedim(-1, 1), size=(self.work_height, self.work_width), mode="area",
)
self.frame = small.movedim(1, -1).clamp(0.0, 1.0).contiguous()
def point(self, point) -> tuple:
x = min(self.work_width - 1, max(0, int(point[0]) * self.work_width // self.width))
y = min(self.work_height - 1, max(0, int(point[1]) * self.work_height // self.height))
return (x, y)
def full_point(self, point) -> tuple:
x = min(self.width - 1, -(-int(point[0]) * self.width // self.work_width))
y = min(self.height - 1, -(-int(point[1]) * self.height // self.work_height))
return (x, y)
def points(self, points) -> tuple:
return tuple(self.point(point) for point in points)
def length(self, value: float) -> float:
"""A length of the picture (a brush's radius) on the copy."""
return float(value) * self.work_width / self.width
def full_box(self, box) -> list:
if self.same:
return list(box)
x0, y0, x1, y1 = (int(value) for value in box)
return [
x0 * self.width // self.work_width, y0 * self.height // self.work_height,
min(self.width, -(-x1 * self.width // self.work_width)), min(self.height, -(-y1 * self.height // self.work_height)),
]
def full(self, mask: torch.Tensor) -> torch.Tensor:
"""An answer from the copy at the picture's own size (bool)."""
if self.same or tuple(mask.shape) == (self.height, self.width):
return mask
soft = F.interpolate(mask[None, None].float(), size=(self.height, self.width), mode="bilinear", align_corners=False)
return soft[0, 0] >= 0.5
# ---------------------------------------------------------------------------
# Strokes
# ---------------------------------------------------------------------------
@@ -1134,10 +1267,13 @@ __all__ = [
"HOLE_SHARE",
"ITEMS_MAX",
"Item",
"KEEP_PICKS",
"MATCHES_MAX",
"Memory",
"MOVED_NOTHING",
"NEEDS_SAM3",
"NEWER_TOUCHES",
"NEW_PICTURE",
"NOTHING_PICKED",
"NOT_A_PICTURE",
"NOT_SAM3",
@@ -1149,6 +1285,7 @@ __all__ = [
"POINTS_MAX",
"PieceSieve",
"SMALL_SIDE",
"START_OVER",
"TEXT_MAX",
"TOO_MUCH",
"Touches",
@@ -1168,6 +1305,7 @@ __all__ = [
"keep_touched",
"match_points",
"match_under",
"move_touches",
"pack_planes",
"parse_touches",
"picture_key",
+35
View File
@@ -0,0 +1,35 @@
"""Pick one choice from each {a|b|c} set in a prompt, decided by a seed.
Only a set that holds a ``|`` is a choice. Braces without one are left as
they are, so a JSON prompt passes through unchanged. Sets can sit inside
other sets; the innermost ones are picked first.
"""
from __future__ import annotations
import random
import re
# An innermost {...} that holds at least one "|", with the one space in
# front of it, so an empty pick does not leave two spaces behind.
_CHOICE = re.compile(r"( ?)\{([^{}]*\|[^{}]*)\}")
def pick_variations(text: str, seed: int) -> tuple[str, list[str]]:
"""Return the prompt with every choice set replaced, and the picks in order."""
rng = random.Random(int(seed))
picks: list[str] = []
def choose(match: re.Match) -> str:
options = [option.strip() for option in match.group(2).split("|")]
pick = rng.choice(options)
picks.append(pick)
return match.group(1) + pick if pick else ""
while True:
text, count = _CHOICE.subn(choose, text)
if not count:
return text, picks
__all__ = ["pick_variations"]
+97
View File
@@ -0,0 +1,97 @@
"""Only one copy of the pack loads.
A Manager install lands in ``custom_nodes/ausboss-nodes`` and a git clone in
``custom_nodes/ComfyUI-AusBoss``. With both in place every route is added
twice and ComfyUI stops while starting. So each copy looks for the others
and all of them agree on the one that loads; the rest step aside and say
which folder to delete.
A copy from before this file existed cannot step aside, so it always loads
and a newer copy yields to it. Among copies that can, the newest version
loads.
"""
from __future__ import annotations
import os
import tomllib
PACK_NAME = "ausboss-nodes"
# A copy that has this file knows how to step aside.
_GUARD_FILE = os.path.join("nodes", "_single_copy.py")
def _read_pack(folder: str) -> dict | None:
"""Name the pack copy in ``folder``, or None when it is another pack."""
try:
with open(os.path.join(folder, "pyproject.toml"), "rb") as f:
project = tomllib.load(f).get("project", {})
except Exception:
return None
if project.get("name") != PACK_NAME:
return None
if not os.path.isfile(os.path.join(folder, "__init__.py")):
return None
version = project.get("version")
return {
"folder": folder,
"version": version if isinstance(version, str) else "unknown",
"guarded": os.path.isfile(os.path.join(folder, _GUARD_FILE)),
}
def _version_key(version: str) -> tuple:
parts = []
for part in version.split("."):
digits = "".join(ch for ch in part if ch.isdigit())
parts.append(int(digits) if digits else -1)
return tuple(parts)
def find_copies(own_folder: str, roots: list[str]) -> list[dict]:
"""Every copy of the pack below ``roots``, this one included."""
own = os.path.realpath(own_folder)
seen = {own}
copies = [_read_pack(own) or {"folder": own, "version": "unknown", "guarded": True}]
for root in roots:
try:
names = sorted(os.listdir(root))
except OSError:
continue
for name in names:
# ComfyUI skips these too.
if name.endswith(".disabled") or name.startswith("."):
continue
folder = os.path.realpath(os.path.join(root, name))
if folder in seen or not os.path.isdir(folder):
continue
seen.add(folder)
pack = _read_pack(folder)
if pack:
copies.append(pack)
return copies
def pick_copy(copies: list[dict]) -> dict:
"""The one copy that loads. Every copy reaches the same answer."""
unguarded = [c for c in copies if not c["guarded"]]
pool = unguarded or copies
return max(pool, key=lambda c: (_version_key(c["version"]), c["folder"]))
def other_copy_that_loads(own_folder: str, roots: list[str] | None = None) -> dict | None:
"""The copy this one steps aside for, or None when this one loads."""
if roots is None:
# Outside ComfyUI nothing is served, so there is nothing to clash.
try:
import folder_paths
roots = list(folder_paths.get_folder_paths("custom_nodes"))
except Exception:
return None
roots = list(dict.fromkeys(os.path.realpath(r) for r in roots))
copies = find_copies(own_folder, roots)
if len(copies) < 2:
return None
chosen = pick_copy(copies)
return None if chosen["folder"] == os.path.realpath(own_folder) else chosen
+67
View File
@@ -0,0 +1,67 @@
"""Cut a block of text into the items Text Lines runs one at a time.
Pure text handling with no ComfyUI imports, so it runs under the
standard-library tests.
"""
from __future__ import annotations
import re
SPLIT_EACH_LINE = "each line"
SPLIT_BLANK_LINE = "blank line"
SPLIT_MODES = (SPLIT_EACH_LINE, SPLIT_BLANK_LINE)
# The words a list writer puts before an item's number: "Prompt 3:", "Shot 2 -".
# Not "image" or "picture": an edit prompt may open with "Image 1: ...".
_LABEL = r"(?:prompt|shot|scene|line|step|item)"
# A marker at the start of an item that only counts or bullets it. A bare
# number needs punctuation after it, and that punctuation must not be followed
# by a digit, so "2 women on a beach", "1.5x zoom" and "10:30 at night" stay
# whole. A bullet needs a space after it, so "*bold* text" stays whole.
_MARKER = re.compile(
r"""^\s*(?:
[\[【(]\s*(?:%(label)s[\s_#-]*)?\d+\s*[\]】)]\s*[:.\-–—]? # [prompt 1] (2)
| %(label)s[\s_#-]*\d+\s*[:.)\-–—] # Prompt 1: Shot 2 -
| \#?\d+\s*[.):](?!\d) # 1. 2) #3:
| [-*•·–—](?=\s|$) # - bullet
)\s*"""
% {"label": _LABEL},
re.IGNORECASE | re.VERBOSE,
)
_BLANK_LINE = re.compile(r"\n[ \t]*(?:\n[ \t]*)+")
def strip_marker(item: str) -> str:
"""Take one leading number or bullet off an item."""
return _MARKER.sub("", item, count=1).strip()
def split_items(text, split=SPLIT_EACH_LINE, remove_numbering=True, limit=0) -> list[str]:
"""The items of `text`, in order, with empty ones left out.
`split` picks where one item ends: at every line break, or only at an
empty line (so an item can hold several lines). `remove_numbering` takes
a leading "1." or "-" off each item. `limit` keeps the first so many;
0 keeps them all.
"""
text = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
if split == SPLIT_BLANK_LINE:
parts = _BLANK_LINE.split(text)
else:
parts = text.split("\n")
items = []
for part in parts:
item = part.strip()
if remove_numbering:
item = strip_marker(item)
if item:
items.append(item)
limit = int(limit or 0)
if limit > 0:
items = items[:limit]
return items
+213
View File
@@ -0,0 +1,213 @@
"""Edit Lock 🆎 and Edit Lock Restore 🆎: an edit leaves the rest of the picture alone.
An edit model redraws the whole picture, so the parts nobody asked to change
come back a little moved and a little tinted. Edit Lock watches the model's
own prediction at every step, reads where it differs from the original, and
puts the original back everywhere else, so the model finishes the edit on
top of the real picture. Edit Lock Restore then swaps the original's own
pixels in for those parts after the decode. The deciding and blending live
in _edit_lock_helpers.py.
"""
from __future__ import annotations
import torch
from ._edit_lock_helpers import (
LOCK_MODES,
EditLock,
describe,
restore_pixels,
)
class AusBossEditLock:
CATEGORY = "🆎 AusBoss/Image"
DESCRIPTION = (
"Keeps every part of your picture that an edit did not change "
"exactly where it was, with the colors it had. Edit models redraw "
"the whole picture, so untouched parts often come back slightly "
"zoomed, shifted or tinted. Put this between the model and the "
"sampler and give it the picture you are editing. It needs no "
"mask: it sees what the edit changes while the picture is made. "
"When the edit changes the whole picture (a new style, new light, "
"new weather) it steps aside. Add Edit Lock Restore 🆎 after the "
"decode to get your own pixels back in the untouched parts."
)
SEARCH_ALIASES = [
"edit lock",
"pixel drift",
"pixel shift",
"color shift",
"consistency",
"keep unchanged",
"preserve original",
"tail steer",
"no drift edit",
"ausboss",
]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": (
"MODEL",
{"tooltip": "The edit model. Patch last, after any LoRAs."},
),
"vae": (
"VAE",
{"tooltip": "The same VAE the workflow decodes with."},
),
"image": (
"IMAGE",
{
"tooltip": (
"The picture you are editing, at the size of the "
"result. Use the same picture the edit model gets."
),
},
),
"lock": (
list(LOCK_MODES),
{
"default": LOCK_MODES[0],
"tooltip": (
"auto locks the untouched parts and steps aside "
"when the whole picture changes. always locks "
"even then. off does nothing."
),
},
),
"tolerance": (
"FLOAT",
{
"default": 1.0,
"min": 0.25,
"max": 4.0,
"step": 0.05,
"tooltip": (
"How big a change must be to count as part of the "
"edit. Raise it if the lock lets small unwanted "
"changes through; lower it if it holds back part "
"of your edit."
),
},
),
},
"optional": {
"mask": (
"MASK",
{
"tooltip": (
"Optional. White marks where the edit may happen; "
"everything else is locked. With a mask the node "
"does not look for the change itself."
),
},
),
},
}
RETURN_TYPES = ("MODEL", "AUSBOSS_EDIT_LOCK")
RETURN_NAMES = ("model", "edit_lock")
OUTPUT_TOOLTIPS = (
"The model with the lock on. Plug it into the sampler.",
"What the lock kept. Plug it into Edit Lock Restore 🆎 after the decode.",
)
FUNCTION = "patch"
def patch(self, model, vae, image, lock, tolerance, mask=None):
if image.ndim != 4:
raise ValueError("Edit Lock: image must be an IMAGE batch.")
state = EditLock(mode=str(lock), tolerance=float(tolerance))
source = image[:1, ..., :3]
state.source = source.detach().float().cpu()
if mask is not None:
state.user_mask = mask.detach().float().cpu()
if state.mode == "off":
state.status = "off"
return (model, state)
latent = vae.encode(source)
patched = model.clone()
state.latent = patched.model.process_latent_in(latent).detach().float().cpu()
patched.set_model_sampler_post_cfg_function(state.step)
return (patched, state)
class AusBossEditLockRestore:
CATEGORY = "🆎 AusBoss/Image"
DESCRIPTION = (
"Puts your picture's own pixels back in every part the edit did not "
"change. Use it right after the VAE decode, with the edit_lock "
"output of Edit Lock 🆎. The untouched parts then match your "
"original pixel for pixel; only the edit is new. When the lock "
"stepped aside the picture passes through unchanged."
)
SEARCH_ALIASES = [
"edit lock restore",
"restore original pixels",
"paste back",
"keep unchanged",
"ausboss",
]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": (
"IMAGE",
{"tooltip": "The edited picture, straight from the VAE decode."},
),
"edit_lock": (
"AUSBOSS_EDIT_LOCK",
{"tooltip": "From Edit Lock 🆎."},
),
"feather": (
"INT",
{
"default": 16,
"min": 0,
"max": 256,
"step": 1,
"tooltip": (
"How softly your original fades into the edit, in "
"pixels. The fade stays outside the edit."
),
},
),
},
}
RETURN_TYPES = ("IMAGE", "MASK", "STRING")
RETURN_NAMES = ("image", "edit_mask", "report")
OUTPUT_TOOLTIPS = (
"The edit with your original pixels in the untouched parts.",
"White where the edit changed the picture.",
"How much of the picture was kept. Plug it into Show Text 🆎 to read it.",
)
FUNCTION = "restore"
def restore(self, image, edit_lock, feather):
if image.ndim != 4:
raise ValueError("Edit Lock Restore: image must be an IMAGE batch.")
if not isinstance(edit_lock, EditLock):
raise ValueError("Edit Lock Restore: edit_lock needs the output of Edit Lock.")
height, width = int(image.shape[1]), int(image.shape[2])
mask = edit_lock.pixel_mask(height, width)
if mask is None or edit_lock.source is None:
return (image, torch.ones((int(image.shape[0]), height, width)), describe(edit_lock))
result, edit_mask = restore_pixels(image, edit_lock.source, mask, int(feather))
return (result, edit_mask, describe(edit_lock))
NODE_CLASS_MAPPINGS = {
"AUSBOSS_NODES_EditLock": AusBossEditLock,
"AUSBOSS_NODES_EditLockRestore": AusBossEditLockRestore,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AUSBOSS_NODES_EditLock": "Edit Lock 🆎",
"AUSBOSS_NODES_EditLockRestore": "Edit Lock Restore 🆎",
}
+87
View File
@@ -0,0 +1,87 @@
"""FlashVSR Upscale 🆎."""
from __future__ import annotations
from ._flashvsr_helpers import EXPECTED_MODEL_NAME, list_flashvsr_models, resolve_flashvsr_model, run_flashvsr
class AusBossFlashVSRUpscale:
CATEGORY = "🆎 AusBoss/Video"
DESCRIPTION = (
"Makes a video larger and sharper with the FlashVSR model. Pick the "
"model file, set how much larger, and run. It reads the clip a few "
"frames at a time, so a long clip needs no more graphics memory than "
"a short one, and every frame comes back. Connect the Wan 2.1 VAE "
"for a slower, finer finish."
)
SEARCH_ALIASES = [
"flashvsr",
"video upscale",
"upscale video",
"video super resolution",
"vsr",
"sharpen video",
"ausboss",
]
@classmethod
def INPUT_TYPES(cls):
models = list_flashvsr_models()
return {
"required": {
"frames": (
"IMAGE",
{"tooltip": "The video frames to make larger."},
),
"model": (
models,
{
"default": EXPECTED_MODEL_NAME if EXPECTED_MODEL_NAME in models else models[0],
"tooltip": "The FlashVSR model file, from ComfyUI/models/diffusion_models.",
},
),
"scale": (
"FLOAT",
{
"default": 2.0,
"min": 1.0,
"max": 4.0,
"step": 0.25,
"tooltip": "How many times larger each side gets. 1 keeps the size and only sharpens.",
},
),
"color_match": (
"BOOLEAN",
{
"default": True,
"tooltip": "Keeps each frame's colours the same as your video. Leave it on.",
},
),
},
"optional": {
"vae": (
"VAE",
{
"tooltip": (
"Optional. Connect the Wan 2.1 VAE for a slower, finer finish. "
"Left empty, the fast decoder inside the model file is used."
)
},
),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("frames",)
OUTPUT_TOOLTIPS = ("The larger, sharper frames: as many as went in.",)
FUNCTION = "upscale"
def upscale(self, frames, model, scale, color_match, vae=None):
output = run_flashvsr(resolve_flashvsr_model(model), frames, float(scale), bool(color_match), vae)
return (output,)
NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_FlashVSRUpscale": AusBossFlashVSRUpscale}
NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_FlashVSRUpscale": "FlashVSR Upscale 🆎"}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+10 -5
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from pathlib import Path
from ._execution_helpers import ignore_extra_inputs
from ._route_errors import safe_route_error
from ._image_folder_helpers import (
AFTER_RUN,
AT_THE_END,
@@ -86,7 +87,7 @@ def _register_routes() -> None:
return str(request.rel_url.query.get(name, "")).strip()
def refused(error: Exception):
return web.json_response({"error": str(error)}, status=400)
return web.json_response({"error": safe_route_error(error, "This folder cannot be read.")}, status=400)
@routes.get("/ausboss/image_folder/folders")
async def folders(request):
@@ -95,7 +96,7 @@ def _register_routes() -> None:
source, folder = _settle(source, query(request, "folder"))
shown = clean_folder(folder)
names = list_folders(_root(source), shown, source)
except (ValueError, RuntimeError) as error:
except (ValueError, RuntimeError, OSError) as error:
return refused(error)
return web.json_response({"source": source, "folder": shown, "folders": names})
@@ -110,13 +111,13 @@ def _register_routes() -> None:
listed = list_pictures(
_root(source), shown, query(request, "subfolders") == "1", query(request, "sort") or "name", source
)
except (ValueError, RuntimeError) as error:
except (ValueError, RuntimeError, OSError) as error:
return refused(error)
return web.json_response({
"source": source,
"folder": shown,
"full": len(listed) >= MAX_PICTURES,
"pictures": [{"name": item["name"], "v": int(item["mtime"])} for item in listed],
"pictures": [{"name": item["name"], "v": f"{item['mtime_ns']}-{item['size']}"} for item in listed],
})
@routes.get("/ausboss/image_folder/thumb")
@@ -273,6 +274,7 @@ class AusBossImageFolder:
def load(self, source, folder, subfolders, sort, pictures, run, position, after_run, at_the_end):
# after_run is the editor's: it moves Picture on after a run is queued. A run loads what Picture says.
source, folder = _settle(source, folder)
chosen = _picked(source, folder, subfolders, sort, pictures)
count = len(chosen)
images, masks, names, numbers = [], [], [], []
@@ -283,7 +285,10 @@ class AusBossImageFolder:
masks.append(mask)
names.append(output_name(item["name"]))
numbers.append(place + 1)
note = [{"count": count, "loaded": numbers[:64], "names": [chosen[n - 1]["name"] for n in numbers[:64]]}]
note = [{
"count": count, "loaded": numbers, "names": [chosen[n - 1]["name"] for n in numbers],
"context": {"source": source, "folder": clean_folder(folder), "subfolders": bool(subfolders)},
}]
return {"ui": {UI_KEY: note}, "result": (images, masks, names, numbers, count)}
+16 -3
View File
@@ -93,6 +93,7 @@ class AusBossKrea2OutpaintModelPatch:
def patch(self, model, stitcher, kv_cache=True, placement="source rectangle"):
import comfy.conds
from comfy.patcher_extension import CallbacksMP
from . import _krea2_core as core
@@ -132,6 +133,13 @@ class AusBossKrea2OutpaintModelPatch:
state = {"last_sigma": None, "caches": {}}
def clear_cache(*_):
state["caches"].clear()
state["last_sigma"] = None
patched.add_callback(CallbacksMP.ON_PRE_RUN, clear_cache)
patched.add_callback(CallbacksMP.ON_CLEANUP, clear_cache)
def forward(
x,
timesteps,
@@ -171,8 +179,11 @@ class AusBossKrea2OutpaintModelPatch:
ref_kv = None
key = None
if kv_cache:
key = core._ref_fingerprint(ref_latents, batch, bbox_norm)
ref_kv = state["caches"].get(key)
key = (tuple(x.shape), x.dtype, x.device,
core._ref_fingerprint(ref_latents, batch, bbox_norm))
cached = state["caches"].get(key)
if cached is not None:
ref_kv = cached[1]
if ref_kv is None:
ref_kv = core._precompute_ref_kv(
dit,
@@ -183,7 +194,9 @@ class AusBossKrea2OutpaintModelPatch:
bbox_norm=bbox_norm,
)
if kv_cache:
state["caches"][key] = ref_kv
# Keep the references alive while their identities are
# keys, so a later tensor cannot reuse a released id.
state["caches"][key] = (tuple(ref_latents), ref_kv)
return core._forward_with_cached_refs(
dit, x, timesteps, context, ref_kv, transformer_options
)
+263
View File
@@ -0,0 +1,263 @@
"""Load Image 🆎."""
from __future__ import annotations
from ._execution_helpers import ignore_extra_inputs
from ._load_image_helpers import (
CROP_SIDES,
RATIOS,
SIZE_MODES,
apply_load_size,
frames_to_image_and_mask,
plan_load_size,
)
from ._media_helpers import list_input_images, load_image_frames, resolve_input_path
from ._resize_helpers import INTERPOLATION_MODES
from ._transform_engine import stable_file_fingerprint
class AusBossLoadImage:
CATEGORY = "🆎 AusBoss/Image"
DESCRIPTION = (
"Loads a picture and sets its size in the same node. Pick a file, "
"upload one, or step through the input folder with the arrows. "
"Size can stay off, or bring the picture to a megapixel count, a "
"longest side, a scale, a box it must fit inside, or a box it fills "
"by cropping. It can also cut a piece or a shape out of the picture "
"without resizing, keeping the side you choose. The node shows the "
"picture going in and coming out, with both sizes, before you run."
)
SEARCH_ALIASES = [
"load image",
"load picture",
"image loader",
"resize on load",
"megapixels",
"longest side",
"crop",
"aspect ratio",
"ausboss",
]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": (
list_input_images(),
{
"image_upload": True,
"tooltip": "The picture to load. Pick one from ComfyUI's input folder or upload a new one.",
},
),
"size": (
list(SIZE_MODES),
{
"default": "off",
"tooltip": (
"How the picture is resized. off keeps it as it "
"is. megapixels, longest side and scale keep its "
"shape. fit inside shrinks or grows it to sit in "
"a box. crop to fill covers the box and cuts off "
"what sticks out. crop cuts a piece out without "
"resizing. crop to ratio cuts the largest piece "
"of a shape."
),
},
),
"megapixels": (
"FLOAT",
{
"default": 1.0,
"min": 0.01,
"max": 64.0,
"step": 0.01,
"tooltip": "For megapixels: the picture's total size. 1.0 is about 1024 x 1024.",
},
),
"longest_side": (
"INT",
{
"default": 1024,
"min": 16,
"max": 16384,
"step": 1,
"tooltip": "For longest side: the length of the longer edge, in pixels. The other edge follows.",
},
),
"scale": (
"FLOAT",
{
"default": 1.0,
"min": 0.05,
"max": 8.0,
"step": 0.01,
"tooltip": "For scale: 0.5 is half the size, 2 is double.",
},
),
"width": (
"INT",
{
"default": 1024,
"min": 16,
"max": 16384,
"step": 1,
"tooltip": "For fit inside, crop to fill and crop: the box's width in pixels.",
},
),
"height": (
"INT",
{
"default": 1024,
"min": 16,
"max": 16384,
"step": 1,
"tooltip": "For fit inside, crop to fill and crop: the box's height in pixels.",
},
),
"multiple": (
"INT",
{
"default": 8,
"min": 1,
"max": 256,
"step": 1,
"tooltip": (
"Rounds the new width and height to a multiple of "
"this. Most models want 8 or 16. 1 leaves the "
"numbers as they are. Not used while size is off."
),
},
),
"method": (
list(INTERPOLATION_MODES),
{
"default": "lanczos",
"tooltip": "How pixels are resampled. lanczos is the sharpest for photos; nearest keeps pixel art crisp.",
},
),
"enlarge": (
"BOOLEAN",
{
"default": True,
"tooltip": (
"On: a small picture is made bigger to reach the "
"size you asked for. Off: a picture is only ever "
"made smaller, and a small one comes out at its "
"own size."
),
},
),
"crop_from": (
list(CROP_SIDES),
{
"default": "center",
"tooltip": (
"For the three crop choices: the part of the "
"picture that is kept. top keeps the top and cuts "
"from the bottom."
),
},
),
"ratio": (
list(RATIOS),
{
"default": "1:1",
"tooltip": (
"For crop to ratio: the shape to cut, width to "
"height. width : height uses the numbers in the "
"width and height boxes."
),
},
),
},
}
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING", "INT", "INT")
RETURN_NAMES = (
"image",
"mask",
"width",
"height",
"filename",
"original_width",
"original_height",
)
OUTPUT_TOOLTIPS = (
"The picture, at its new size.",
"White where the picture is see-through, black where it is solid. "
"All black for a picture with no transparency. Same size as the picture.",
"Width of the picture that comes out, in pixels.",
"Height of the picture that comes out, in pixels.",
"The file's name without its folder or its ending, ready for a save "
"node's prefix.",
"Width of the file before any resize.",
"Height of the file before any resize.",
)
FUNCTION = "load"
def load(
self,
image,
size="off",
megapixels=1.0,
longest_side=1024,
scale=1.0,
width=1024,
height=1024,
multiple=8,
method="lanczos",
enlarge=True,
crop_from="center",
ratio="1:1",
):
path = resolve_input_path(image)
picture, mask = frames_to_image_and_mask(load_image_frames(path))
source_w, source_h = int(picture.shape[2]), int(picture.shape[1])
plan = plan_load_size(
source_w,
source_h,
str(size),
float(megapixels),
int(longest_side),
float(scale),
int(width),
int(height),
int(multiple),
bool(enlarge),
str(crop_from),
str(ratio),
)
picture, mask = apply_load_size(picture, mask, plan, str(method))
return (
picture,
mask,
int(plan["width"]),
int(plan["height"]),
path.stem,
source_w,
source_h,
)
@classmethod
@ignore_extra_inputs
def VALIDATE_INPUTS(cls, image):
try:
resolve_input_path(image)
except Exception as exc:
return f"Load Image: {exc}"
return True
@classmethod
def IS_CHANGED(cls, image, **values):
try:
path = resolve_input_path(image)
except Exception:
path = image or ""
return stable_file_fingerprint(path, {"image": image, **values})
NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_LoadImage": AusBossLoadImage}
NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_LoadImage": "Load Image 🆎"}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+137 -44
View File
@@ -28,16 +28,21 @@ from ._fine_edge_helpers import (
)
from ._mask_by_name_helpers import IF_NOTHING, cut_out
from ._mask_by_touch_helpers import (
KEEP_PICKS,
MOVED_NOTHING,
NEEDS_SAM3,
NEW_PICTURE,
NOTHING_PICKED,
NOT_A_PICTURE,
NOT_SAM3,
NO_TEXT_READER,
OTHER_PICTURE,
START_OVER,
WORDS_FIRST,
WORDS_MORE,
Memory,
PieceSieve,
WorkView,
apply_item,
box_of,
choose_found,
@@ -47,6 +52,7 @@ from ._mask_by_touch_helpers import (
finish_mask,
match_points,
match_under,
move_touches,
pack_planes,
parse_touches,
picture_key,
@@ -121,6 +127,8 @@ QUICK = True
# Shared by every Mask by Touch node: what SAM 3 answered, per picture.
_MEMORY = Memory()
# How many of those smaller copies a node keeps: this picture's and the last one's.
VIEWS_KEPT = 2
# What the text reader made of each word, per text reader. A word reads the
# same for every picture, so it is read once.
_WORDS = OrderedDict()
@@ -212,19 +220,19 @@ def _server():
return None
def _tell(node_id, note: dict, end: bool) -> bool:
def _tell(node_id, note: dict, end: bool, stopped: bool = False) -> bool:
"""Tell the node's face what a look has found, over ComfyUI's own channel
to its page. It goes to the page that asked for the run. True when it
was sent; a failure never stops the look."""
was sent; a failure never stops the look. `stopped`: the run stops at
this node, and the face is only shown the picture that came in."""
server = _server()
if server is None or node_id is None:
return False
event = {"node": str(node_id), "note": note, "end": bool(end)}
if stopped:
event["stopped"] = True
try:
server.send_sync(
LOOK_EVENT,
{"node": str(node_id), "note": note, "end": bool(end)},
getattr(server, "client_id", None),
)
server.send_sync(LOOK_EVENT, event, getattr(server, "client_id", None))
except Exception: # noqa: BLE001
return False
return True
@@ -720,10 +728,10 @@ class AusBossMaskByTouch:
"fill_holes": (
"BOOLEAN",
{
"default": True,
"default": False,
"tooltip": (
"Closes pinholes inside the picked area. Larger openings "
"stay open, like a mug's handle."
"stay open, like a mug's handle. Off keeps every hole you pick."
),
},
),
@@ -786,6 +794,18 @@ class AusBossMaskByTouch:
),
},
),
"new_picture": (
list(NEW_PICTURE),
{
"default": KEEP_PICKS,
"tooltip": (
"When a different picture comes in. Keep picks uses your "
"clicks and paint on it, in the same places (scaled if the "
"size changed). Start over drops them, and the run stops "
"until you pick again."
),
},
),
},
# ComfyUI hands the node its own id here, so a look can tell this
# node's face what it has found. Not a widget and not a socket.
@@ -819,25 +839,44 @@ class AusBossMaskByTouch:
# What Fine edges is set to in the run at hand: the subject is asked
# of that model.
self._edge_choice = FINE_OFF
# The smaller copies of big pictures SAM 3 is asked about, newest last.
self._views = OrderedDict()
# -- what SAM 3 finds ---------------------------------------------------
def _tap(self, session: _Session, frame, frame_key: str, point):
"""The thing at one point, without its stray specks. None for nothing.
def _view(self, frame, frame_key: str) -> WorkView:
"""The picture as SAM 3 is asked about it: itself, or a smaller copy
of a big one, kept for the next run on the same picture."""
held = self._views.get(frame_key)
if held is not None and (held.height, held.width) == (int(frame.shape[1]), int(frame.shape[2])):
self._views.move_to_end(frame_key)
return held
view = WorkView(frame)
if not view.same:
self._views[frame_key] = view
while len(self._views) > VIEWS_KEPT:
self._views.popitem(last=False)
return view
def _tap(self, session: _Session, view: WorkView, frame_key: str, point):
"""The thing at one point, without its stray specks, at the size SAM 3
was asked at (`view`). None for nothing. `point` is a pixel of the
picture.
Hovering's pieces and taps share this, and what it returned is
remembered, so a tap on a piece that was shown gives that piece."""
key = (session.token, frame_key, ("tap", int(point[0]), int(point[1])))
found, answer = _MEMORY.get(key)
if not found:
answer = drop_specks(session.ask(frame, [point]), point)
spot = view.point(point)
answer = drop_specks(session.ask(view.frame, [spot]), spot)
answer = answer if bool(answer.any()) else None
_MEMORY.put(key, answer)
return answer
def _named(self, session: _Session, frame, frame_key: str, word: str) -> list:
def _named(self, session: _Session, view: WorkView, frame_key: str, word: str) -> list:
"""Everything in one picture that SAM 3 calls by a word, tidied:
[(H x W bool, how sure), ...].
[(bool at the size SAM 3 was asked at, how sure), ...].
Hovering's named pieces and `name` touches share this, and what it
returned is remembered, so a touch on a named piece that was shown
@@ -845,7 +884,7 @@ class AusBossMaskByTouch:
key = (session.token, session.reader, frame_key, ("word", word))
found, matches = _MEMORY.get_matches(key)
if not found:
matches = tidy_matches(*session.ask_word(frame, word))
matches = tidy_matches(*session.ask_word(view.frame, word))
_MEMORY.put_matches(key, matches)
return matches
@@ -856,6 +895,9 @@ class AusBossMaskByTouch:
The area of a `paint` touch made with a soft brush is float, 0..1.
"""
height, width = int(frame.shape[1]), int(frame.shape[2])
# What SAM 3 is asked comes back at the size it was asked at, and is
# brought to the picture's own size here.
view = self._view(frame, frame_key)
if item.kind in ("subject", "background"):
# The picture's main subject, as ComfyUI's background-removal
# model sees it, or everything but it.
@@ -864,25 +906,25 @@ class AusBossMaskByTouch:
return (area, "thing", ()) if bool(area.any()) else (None, "none", ())
if item.kind == "all":
# Every thing called by that word, joined.
matches = self._named(session, frame, frame_key, item.word) if item.word else []
matches = self._named(session, view, frame_key, item.word) if item.word else []
if not matches:
return None, "none", ()
area = torch.zeros(height, width, dtype=torch.bool)
area = torch.zeros(view.work_height, view.work_width, dtype=torch.bool)
for mask, _ in matches:
area |= mask
return area, "thing", tuple(range(len(matches)))
return view.full(area), "thing", tuple(range(len(matches)))
if item.kind in ("tap", "name"):
if not (0 <= item.x < width and 0 <= item.y < height):
return None, "none", ()
if item.kind == "tap":
answer = self._tap(session, frame, frame_key, (item.x, item.y))
return (answer, "thing", ()) if answer is not None else (None, "none", ())
answer = self._tap(session, view, frame_key, (item.x, item.y))
return (view.full(answer), "thing", ()) if answer is not None else (None, "none", ())
if not item.word:
return None, "none", ()
# Of the things called by that word, the smallest at the point.
matches = self._named(session, frame, frame_key, item.word)
index = match_under(matches, (item.x, item.y))
return (None, "none", ()) if index is None else (matches[index][0], "thing", (index,))
matches = self._named(session, view, frame_key, item.word)
index = match_under(matches, view.point((item.x, item.y)))
return (None, "none", ()) if index is None else (view.full(matches[index][0]), "thing", (index,))
if item.kind not in ("find", "paint"):
return None, "none", ()
if item.kind == "paint" and item.soft > 0:
@@ -901,14 +943,18 @@ class AusBossMaskByTouch:
found, answer = _MEMORY.get(key)
if not found:
many, pair = find_points(item.pts, height, width)
# The stroke as it lies on the copy SAM 3 is asked about.
under = stroke if view.same else stroke_area(
view.points(item.pts), max(1.0, view.length(item.r)), view.work_height, view.work_width,
)
answer = choose_found(
session.ask(frame, many) if many else None,
session.ask(frame, pair) if pair else None,
stroke,
session.ask(view.frame, view.points(many)) if many else None,
session.ask(view.frame, view.points(pair)) if pair else None,
under,
)
_MEMORY.put(key, answer)
# Over empty space the paint is kept as it is.
return (answer, "thing", ()) if answer is not None else (stroke, "paint", ())
return (view.full(answer), "thing", ()) if answer is not None else (stroke, "paint", ())
def _look(self, session: _Session, frame, key: str, pieces: dict, pointer=None, tell=None) -> dict:
"""Find a picture's pieces for hovering.
@@ -926,6 +972,7 @@ class AusBossMaskByTouch:
piece.
"""
height, width = int(frame.shape[1]), int(frame.shape[2])
view = self._view(frame, key)
queue = _scope_queue(width, height)
sieve = pieces["sieve"]
fresh = {} # (name or None, point) -> (area, is it tidy and remembered)
@@ -950,7 +997,7 @@ class AusBossMaskByTouch:
if pointer is not None:
since = time.perf_counter()
known = _MEMORY.has((session.token, key, ("tap", pointer[0], pointer[1])))
answer = self._tap(session, frame, key, pointer)
answer = self._tap(session, view, key, pointer)
answered(known, since)
if answer is not None and sieve.offer(pointer, answer, pointed=True):
fresh[(None, pointer)] = (answer, True)
@@ -976,8 +1023,10 @@ class AusBossMaskByTouch:
break
ahead.append(later)
session.read_ahead(ahead, stop=_jobs_waiting)
matches = self._named(session, frame, key, what)
for (area, sure), point in zip(matches, match_points(matches)):
matches = self._named(session, view, key, what)
for (area, sure), spot in zip(matches, match_points(matches)):
# The match's own point, as a pixel of the picture.
point = None if spot is None else view.full_point(spot)
if point is not None and sieve.offer(point, area, name=what, sure=sure):
fresh[(what, point)] = (area, True)
pieces["words"] += 1
@@ -985,7 +1034,7 @@ class AusBossMaskByTouch:
memory_key = (session.token, key, ("tap", what[0], what[1]))
known, answer = _MEMORY.get(memory_key)
if not known:
answer = session.ask(frame, [what])
answer = session.ask(view.frame, [view.point(what)])
if not bool(answer.any()):
_MEMORY.put(memory_key, None)
answer = None
@@ -1008,6 +1057,7 @@ class AusBossMaskByTouch:
first, and the small copies packed into planes. `fresh` holds the
areas found since the last time, and is emptied."""
height, width = int(frame.shape[1]), int(frame.shape[2])
view = self._view(frame, key)
small_width, small_height, _ = small_size(width, height)
sieve = pieces["sieve"]
before = {ident: slot for slot, ident in enumerate(pieces["ids"])}
@@ -1019,18 +1069,19 @@ class AusBossMaskByTouch:
else:
# A piece kept by a look that was stopped half way is asked
# for again: its answer was not held on to.
answer, tidy = fresh.get(ident) or (self._piece(session, frame, key, name, point), True)
answer, tidy = fresh.get(ident) or (self._piece(session, view, key, name, point), True)
if answer is None:
continue
if not tidy:
# What a tap at this point gives: the same answer, tidied
# the same way, remembered so the tap asks nothing.
answer = drop_specks(answer, point)
answer = drop_specks(answer, view.point(point))
_MEMORY.put((session.token, key, ("tap", point[0], point[1])), answer)
sure = sieve.sure_of(name, point)
entry = {
"x": point[0], "y": point[1],
"share": round(float(answer.sum()) / (height * width), 5), "box": box_of(answer),
"share": round(float(answer.sum()) / (view.work_height * view.work_width), 5),
"box": view.full_box(box_of(answer)),
"kind": "point" if name is None else "name", "name": name,
"sure": None if sure is None else round(sure, 3),
}
@@ -1047,13 +1098,13 @@ class AusBossMaskByTouch:
pieces["list"] = [row[4] for row in rows]
pieces["planes"] = pack_planes(masks)
def _piece(self, session: _Session, frame, key: str, name, point):
def _piece(self, session: _Session, view: WorkView, key: str, name, point):
"""The area of a piece from what is remembered: a point's answer, or
the match of a word that the piece's own point stands for."""
if name is None:
return self._tap(session, frame, key, point)
matches = self._named(session, frame, key, name)
index = match_under(matches, point)
return self._tap(session, view, key, point)
matches = self._named(session, view, key, name)
index = match_under(matches, view.point(point))
return None if index is None else matches[index][0]
# -- files for the node's face -------------------------------------------
@@ -1105,6 +1156,30 @@ class AusBossMaskByTouch:
entry = self._keep(("picture", key), self._write(picture, "picture", "jpg", format="JPEG", quality=90))
return entry
def _show_picture(self, session, image, key: str, unique_id, moved=None) -> bool:
"""Send the face the picture that came in, with nothing picked on it,
for a run that stops at this node, and the picks moved onto it if
they were. A failure never changes why the run stops."""
if unique_id is None or _server() is None:
return False
try:
height, width = int(image.shape[1]), int(image.shape[2])
small_width, small_height, scale = small_size(width, height)
picture = self._picture_file(image, key, small_width, small_height)
if picture is None:
return False
note = {
"key": key, "width": width, "height": height, "scale": scale, "stamp": "",
"picture": picture, "selection": None, "items": [], "share": 0.0,
"pieces": self._pieces_note(_MEMORY.get_pieces((session.token, session.reader, key))),
"fine": None, "areas": None,
}
if moved:
note["moved"] = moved
except Exception: # noqa: BLE001
return False
return _tell(unique_id, note, True, stopped=True)
def _pieces_note(self, pieces):
if pieces is None:
return None
@@ -1144,12 +1219,13 @@ class AusBossMaskByTouch:
def pick(
self, image, model, clip, touches, grow, soften, smooth, fill_holes, if_nothing,
free_vram=False, invert=False, ask="", fine_edges=FINE_OFF, unique_id=None,
free_vram=False, invert=False, ask="", fine_edges=FINE_OFF, new_picture=KEEP_PICKS, unique_id=None,
):
session = _Session(model, clip)
try:
return self._run(
session, image, touches, grow, soften, smooth, fill_holes, if_nothing, invert, ask, unique_id, fine_edges
session, image, touches, grow, soften, smooth, fill_holes, if_nothing, invert, ask, unique_id, fine_edges,
new_picture,
)
finally:
session.close()
@@ -1163,7 +1239,7 @@ class AusBossMaskByTouch:
def _run(
self, session, image, touches, grow, soften, smooth, fill_holes, if_nothing, invert, ask, unique_id,
fine_edges=FINE_OFF,
fine_edges=FINE_OFF, new_picture=KEEP_PICKS,
):
if not isinstance(image, torch.Tensor) or image.ndim != 4 or 0 in image.shape:
raise ValueError(NOT_A_PICTURE)
@@ -1173,6 +1249,15 @@ class AusBossMaskByTouch:
picked = parse_touches(touches)
# Touches made on another picture are not this picture's.
elsewhere = picked.key not in (None, key) and bool(picked.items)
moved = None
if elsewhere and str(new_picture) != START_OVER:
# Unless the node starts over, they are moved onto this one: the
# same places, scaled to its size. The face is sent the moved
# touches, and the stamp of the ones they were moved from.
moved = {"from": touch_stamp(touches), "touches": move_touches(touches, key)}
touches = moved["touches"]
picked = parse_touches(touches)
elsewhere = False
items = () if elsewhere else picked.items
strokes, selections, painted, item_notes = {}, [], [], []
# Each picked thing's own area on the first picture, for the face.
@@ -1239,9 +1324,13 @@ class AusBossMaskByTouch:
raw = torch.stack(selections)
soft = raw.dtype != torch.bool
if not bool(raw.any()) and not asked and str(if_nothing) != IF_NOTHING[1]:
# The run stops here, but the face is shown the picture that
# came in first: a picture that changed upstream is there to
# pick from at once, without unplugging it.
self._show_picture(session, image, key, unique_id, moved)
# Picks the face may still show, made before the picture
# changed: say so, not that nothing is picked.
raise RuntimeError(OTHER_PICTURE if elsewhere else NOTHING_PICKED)
raise RuntimeError(OTHER_PICTURE if elsewhere else MOVED_NOTHING if moved else NOTHING_PICKED)
# A look for pieces (scope) returns no mask anyone uses: no fine edge then.
fine = wants_fine_edges(fine_edges) and asked != "scope" and bool(raw.any())
some = raw.flatten(1).any(dim=1)[:, None, None]
@@ -1307,7 +1396,7 @@ class AusBossMaskByTouch:
areas_note = self._areas_note(key, stamp, things, small_width, small_height)
def note(pieces) -> dict:
return {
told = {
"key": key,
"width": width,
"height": height,
@@ -1321,14 +1410,18 @@ class AusBossMaskByTouch:
"fine": fine_note,
"areas": areas_note,
}
if moved:
told["moved"] = moved
return told
pieces_key = (session.token, session.reader, key)
pieces = _MEMORY.get_pieces(pieces_key)
if asked == "scope" and (pieces is None or pointer is not None or pieces["next"] < pieces["total"]):
if pieces is None:
words = len(WORDS_FIRST) + len(WORDS_MORE)
view = self._view(image[:1], key)
pieces = {
"sieve": PieceSieve(height, width), "next": 0, "total": len(_scope_queue(width, height)),
"sieve": PieceSieve(view.work_height, view.work_width), "next": 0, "total": len(_scope_queue(width, height)),
"words": 0, "words_total": words, "number": 0, "ids": [], "list": [], "planes": [],
}
# Remembered from the start: a look that is stopped half way
+73
View File
@@ -0,0 +1,73 @@
"""Prompt Variations 🆎 — one prompt, a different mix of choices on every run."""
from __future__ import annotations
from ._prompt_variations_helpers import pick_variations
class AusBossPromptVariations:
CATEGORY = "🆎 AusBoss/Utility"
DESCRIPTION = (
"A prompt box that changes on every run. Write choices in curly "
"brackets, such as a {red|blue|green} {sweater|jacket}, and the node "
"picks one from each set. The seed decides the picks: the same seed "
"gives the same prompt again, and a new seed on each run gives a new "
"mix. Text without brackets comes out exactly as typed."
)
SEARCH_ALIASES = [
"random prompt", "wildcard", "dynamic prompt", "variations",
"random choice", "prompt mixer", "ausboss",
]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": (
"STRING",
{
"default": "",
"multiline": True,
"dynamicPrompts": False,
"tooltip": (
"Your prompt. Put choices in curly brackets, split "
"by |, such as {tank top|sweater|shirt}. One is "
"picked from each set."
),
},
),
"seed": (
"INT",
{
"default": 0,
"min": 0,
"max": 0xFFFFFFFFFFFFFFFF,
"forceInput": True,
"tooltip": (
"Wire in the seed your sampler uses. A new seed "
"picks new choices; the same seed picks the same "
"ones again."
),
},
),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("prompt",)
OUTPUT_TOOLTIPS = ("The prompt with one choice picked from each set.",)
FUNCTION = "pick"
def pick(self, text, seed):
prompt, _picks = pick_variations(text, seed)
return (prompt,)
NODE_CLASS_MAPPINGS = {
"AUSBOSS_NODES_PromptVariations": AusBossPromptVariations,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AUSBOSS_NODES_PromptVariations": "Prompt Variations 🆎",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+116 -57
View File
@@ -6,25 +6,21 @@ from datetime import datetime
from pathlib import Path
from ._execution_helpers import ignore_extra_inputs
from ._image_hold_helpers import hold_pictures, keep_pictures, plan_destinations, settle_destinations
from ._image_save_helpers import (
EXISTING_POLICIES,
FORMAT_EXTENSIONS,
IMAGE_FORMATS,
IMAGE_SAVE_LOCK,
encode_image,
existing_action,
fill_name_tokens,
metadata_disabled,
name_stem,
next_free_counter,
plan_exact_names,
plan_local_names,
resolve_output_root,
require_output_path,
sanitize_exact_name,
sidecar_path,
split_prefix,
strip_image_extension,
write_light_previews,
)
from ._route_errors import safe_route_error
from ._step_preview_helpers import STEP_PREVIEW_DEFAULT, register_step_preview_handler
from ._video_save_helpers import workflow_metadata
@@ -67,6 +63,33 @@ def _register_folder_route() -> None:
shown = "" if target == root else target.relative_to(root).as_posix()
return web.json_response({"path": shown, "folders": folders})
@PromptServer.instance.routes.post("/ausboss/save_image/keep")
async def keep_held(request):
"""Save pictures a run is holding (Auto save off). The body names the
run by the token the node was given and, optionally, which of its
pictures to keep. Names and folders come from what the run stored,
never from the request."""
try:
body = await request.json()
except Exception:
return web.json_response({"error": "bad request"}, status=400)
if not isinstance(body, dict):
return web.json_response({"error": "bad request"}, status=400)
picks = body.get("picks")
if picks is not None and not isinstance(picks, list):
return web.json_response({"error": "bad request"}, status=400)
try:
answer = keep_pictures(
folder_paths.get_temp_directory(), folder_paths.get_output_directory(), body.get("token"), picks,
)
except LookupError as exc:
return web.json_response({"error": safe_route_error(exc, "Those pictures are gone.")}, status=404)
except ValueError as exc:
return web.json_response({"error": safe_route_error(exc, "Could not save.")}, status=400)
except Exception as exc:
return web.json_response({"error": safe_route_error(exc, "Could not save.")}, status=500)
return web.json_response(answer)
class AusBossSaveImage:
CATEGORY = "🆎 AusBoss/Image"
@@ -276,6 +299,17 @@ class AusBossSaveImage:
),
},
),
"autosave": (
"BOOLEAN",
{
"default": True,
"tooltip": (
"On: every run saves its pictures. Off: the "
"pictures only show on the node, and its Save "
"button keeps the ones you want."
),
},
),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
@@ -309,6 +343,7 @@ class AusBossSaveImage:
name_batch=False,
preview=True,
step_preview=STEP_PREVIEW_DEFAULT,
autosave=True,
prompt=None,
extra_pnginfo=None,
):
@@ -319,8 +354,7 @@ class AusBossSaveImage:
embed = save_metadata and not metadata_disabled()
metadata = workflow_metadata(prompt, extra_pnginfo) if embed else None
# A linked caption wins over the legacy text box; blank means no sidecar.
linked_caption = str(caption_text or "") if caption_text is not None else ""
caption_value = linked_caption if linked_caption.strip() else str(caption or "")
caption_value = str(caption_text) if caption_text is not None else str(caption or "")
output_root = Path(folder_paths.get_output_directory())
root = resolve_output_root(output_dir, output_root)
count = int(images.shape[0])
@@ -343,56 +377,67 @@ class AusBossSaveImage:
else:
exact = sanitize_exact_name(exact_name)
planned: list[tuple[Path, str]] = [] # (path, collision policy)
if exact:
base = strip_image_extension(exact)
for name in plan_exact_names(base, extension, count):
planned.append((root / name, on_existing))
else:
subfolder, base = split_prefix(filename_prefix)
folder = root / subfolder if subfolder else root
require_output_path(folder, output_root)
stem = name_stem(
base,
{"date": bool(name_date), "time": bool(name_time), "size": bool(name_size)},
now,
width,
height,
)
counter = bool(name_counter)
existing = [entry.name for entry in folder.iterdir()] if folder.is_dir() else []
names = plan_local_names(
stem, extension, count, counter=counter, batch=bool(name_batch),
next_counter=next_free_counter(existing, stem),
)
# The counter is collision-safe by construction; without it the
# legacy policy decides, and its default deliberately overwrites.
policy = "overwrite" if counter else on_existing
planned.extend((folder / name, policy) for name in names)
settings = {
"format": format,
"extension": extension,
"width": width,
"height": height,
"exact": exact,
"filename_prefix": filename_prefix,
"on_existing": on_existing,
"output_dir": str(output_dir or ""),
"name_counter": bool(name_counter),
"name_date": bool(name_date),
"name_time": bool(name_time),
"name_size": bool(name_size),
"name_batch": bool(name_batch),
"caption": caption_value,
}
has_caption = bool(caption_value.strip())
# Validate the whole batch before any write, including caption targets,
# then settle every collision: an "error" on a later frame must stop
# the run before the first frame lands, not after.
for path, _policy in planned:
require_output_path(path, output_root)
if caption_value.strip():
require_output_path(sidecar_path(path), output_root)
actions = [existing_action(path.exists(), policy) for path, policy in planned]
if not autosave:
# Nothing is saved yet. The pictures wait in the temp folder, in
# their final format, and the node's Save button keeps the ones
# that are wanted (the keep route above). A bad name or folder
# still stops the run here, not at the click.
with IMAGE_SAVE_LOCK:
settle_destinations(plan_destinations(settings, count, now, root, output_root), has_caption, output_root)
token, previews = hold_pictures(
folder_paths.get_temp_directory(), images, settings,
lambda path, frame: encode_image(path, frame, format, metadata),
)
return {
"ui": {
"images": previews, "ausboss_saved_path": [], "ausboss_held": [token],
"ausboss_preview": self._light_previews(images, preview),
},
"result": ("", images),
}
saved: list[Path] = []
for (path, _policy), action, frame in zip(planned, actions, images):
if action == "skip":
continue
path.parent.mkdir(parents=True, exist_ok=True)
encode_image(path, frame, format, metadata)
if caption_value.strip():
sidecar_path(path).write_text(caption_value, encoding="utf-8")
saved.append(path)
with IMAGE_SAVE_LOCK:
# Validate the whole batch before any write, including caption targets,
# then settle every collision: an "error" on a later frame must stop
# the run before the first frame lands, not after.
planned = plan_destinations(settings, count, now, root, output_root)
actions = settle_destinations(planned, has_caption, output_root)
saved: list[Path] = []
saved_frames = []
for (path, _policy), action, frame in zip(planned, actions, images):
if action == "skip":
continue
path.parent.mkdir(parents=True, exist_ok=True)
encode_image(path, frame, format, metadata)
if has_caption:
sidecar_path(path).write_text(caption_value, encoding="utf-8")
saved.append(path)
saved_frames.append(frame)
# Every destination was confined to the output folder above, so each
# saved file previews from there and is shown relative to it. The
# preview switch is display only: these entries are also how the
# queue lists the save, and there is no temp file to skip.
# saved file previews from there and is shown relative to it. These
# entries are also how the queue lists the save, so the preview
# switch leaves them alone; it only skips the small copies the node
# shows on its face (ausboss_preview).
previews: list[dict] = []
resolved_root = output_root.resolve()
shown_paths: list[str] = []
@@ -406,10 +451,24 @@ class AusBossSaveImage:
})
shown_paths.append(resolved.relative_to(resolved_root).as_posix())
return {
"ui": {"images": previews, "ausboss_saved_path": shown_paths[:1]},
"ui": {
"images": previews, "ausboss_saved_path": shown_paths[:1],
"ausboss_preview": self._light_previews(saved_frames, preview),
},
"result": (str(saved[0]) if saved else "", images),
}
@staticmethod
def _light_previews(frames, preview) -> list[str]:
# The node's face shows these instead of the full files. Preview
# off: nothing is shown, so nothing is written.
if not preview:
return []
try:
return write_light_previews(folder_paths.get_temp_directory(), frames)
except Exception:
return []
@classmethod
def IS_CHANGED(cls, **_values):
# A saver's job is the side effect: deleting the file and queueing
+100
View File
@@ -0,0 +1,100 @@
"""Text Lines 🆎 — run what comes after once for each line of text."""
from __future__ import annotations
from ._text_lines_helpers import SPLIT_EACH_LINE, SPLIT_MODES, split_items
MAX_LIMIT = 1000
class AusBossTextLines:
CATEGORY = "🆎 AusBoss/Utility"
DESCRIPTION = (
"Runs what comes after once for each line of your text. Type five "
"prompts, one on each line, and one run makes five pictures. Empty "
"lines are skipped, and a number or bullet at the start of a line "
"can be taken off."
)
SEARCH_ALIASES = [
"prompt list",
"one prompt per line",
"each line",
"split text",
"text to list",
"batch prompts",
"queue prompts",
"ausboss",
]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "One line for each run. Type it here, or wire in text another node wrote.",
},
),
"split": (
list(SPLIT_MODES),
{
"default": SPLIT_EACH_LINE,
"tooltip": (
"each line: every line is its own run. blank line: lines stay "
"together, and an empty line starts the next run."
),
},
),
"remove_numbering": (
"BOOLEAN",
{
"default": True,
"tooltip": (
'On: a number or bullet at the start is taken off, like "1.", "-" '
'or "[prompt 2]". Off: every line stays as written.'
),
},
),
"limit": (
"INT",
{
"default": 0,
"min": 0,
"max": MAX_LIMIT,
"step": 1,
"tooltip": "The most lines to run. 0 runs them all.",
},
),
},
}
RETURN_TYPES = ("STRING", "INT", "INT")
RETURN_NAMES = ("line", "number", "count")
OUTPUT_IS_LIST = (True, True, False)
OUTPUT_TOOLTIPS = (
"Each line, one at a time. What comes after runs once for each.",
"The line's place in the list, starting at 1. Add it to a seed to give each run its own.",
"How many lines run.",
)
FUNCTION = "split"
def split(self, text, split=SPLIT_EACH_LINE, remove_numbering=True, limit=0):
items = split_items(text, split, remove_numbering, limit)
if not items:
raise ValueError(
"Text Lines has no lines to run. Type at least one line, or check the node that writes its text."
)
return (items, list(range(1, len(items) + 1)), len(items))
NODE_CLASS_MAPPINGS = {
"AUSBOSS_NODES_TextLines": AusBossTextLines,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AUSBOSS_NODES_TextLines": "Text Lines 🆎",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+3 -2
View File
@@ -2,8 +2,8 @@
name = "ausboss-nodes"
# Manager/registry search matches against this text, so it names the actual
# nodes instead of describing the collection abstractly.
description = "ComfyUI-AusBoss (AusBoss nodes): image and video nodes with compact controls: Image and Video Crop + Rotate + Pad (frame or clip), Load Image + Pad, Image Folder, Image Resize, Align Image, Image Size, Color Match, Image Compare A/B, Realign to Source (experimental), Save Image (PNG, WebP/JXL, captions), Load Video, Load Audio, Save Video, Select Frame, Frame Interpolate, Mask Refine, Mask by Name and Mask by Touch (type a name or click a thing, get its mask), LaMa Inpaint, Crop For Inpaint, Stitch Inpaint, Tiled Upscale and Tiled Upscale Stitch, LoRA Loader, Krea 2 Encode and Outpaint Model Patch, Latent Size, Layout Boxes, Seed, Select Every Nth, Split Batch, Merge Batches, Text, H3 Prompt Writer, Integer, Float, Math Expression, Show Text, Free Memory, Workflow Note, Workflow Note Tile, Callout, Callout Tile, Workflow Switches, Run Timer, and Step Preview (stop bad runs). Example workflows for Qwen Image 2.1, Krea 2, Klein 9B and LTX 2.3."
version = "2.21.2"
description = "ComfyUI-AusBoss (AusBoss nodes): image and video nodes with compact controls: Image and Video Crop + Rotate + Pad (frame or clip), Load Image, Load Image + Pad, Image Folder, Image Resize, Align Image, Image Size, Color Match, Image Compare A/B, Realign to Source (experimental), Save Image (PNG, WebP/JXL, captions), Load Video, Load Audio, Save Video, Select Frame, Frame Interpolate, Mask Refine, Mask by Name and Mask by Touch, LaMa Inpaint, Crop For Inpaint, Stitch Inpaint, Tiled Upscale and Tiled Upscale Stitch, FlashVSR Upscale, Edit Lock and Edit Lock Restore, LoRA Loader, Krea 2 Encode and Outpaint Model Patch, Latent Size, Layout Boxes, Seed, Select Every Nth, Split Batch, Merge Batches, Text, Text Lines, Prompt Variations, H3 Prompt Writer, Integer, Float, Math Expression, Show Text, Free Memory, Workflow Note, Workflow Note Tile, Callout, Callout Tile, Workflow Switches, Run Timer, and Step Preview. Example workflows for Qwen Image 2.1, Krea 2, Klein 9B and LTX 2.3."
version = "2.25.0"
# comfy-cli serializes this to JSON verbatim, so `file` publishes the literal
# string {"file": "LICENSE"} to the registry listing. `text` puts a readable
# name there instead. Both forms are supported; the LICENSE file is unchanged.
@@ -21,6 +21,7 @@ keywords = [
"image folder", "batch folder", "callout", "sticky note", "arrows",
"tiled upscale", "upscale", "tiles", "restore", "layout boxes", "bounding boxes", "moss image", "replace text", "rotate box",
"step preview", "live preview", "stop run",
"text lines", "prompt list", "prompt variations", "wildcards", "flashvsr", "video upscale", "edit lock", "load image",
]
# Read by comfy-cli into the registry's supported_os field. Nothing in the pack
+5 -4
View File
@@ -28,6 +28,7 @@ import os
from pathlib import Path
import re
import sys
import tomllib
ROOT = Path(__file__).resolve().parent.parent
# The Registry shows the notes in a small box: a long release is cut to its
@@ -101,10 +102,10 @@ def notes_for(version: str, text: str) -> str:
def pyproject_version() -> str:
raw = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
found = re.search(r'^version\s*=\s*"([^"]+)"', raw, re.MULTILINE)
if not found:
raise ValueError('pyproject.toml has no version = "..." line')
return found.group(1)
version = tomllib.loads(raw).get("project", {}).get("version")
if not isinstance(version, str) or not version.strip():
raise ValueError("pyproject.toml needs a nonempty project.version string")
return version
def main(argv: list[str] | None = None) -> int:
+26 -13
View File
@@ -53,12 +53,21 @@ def example_nodes(data: dict):
yield f" in subgraph '{graph.get('name') or graph.get('id')}'", node
def given_names(node: dict) -> set[str]:
"""The inputs a saved node has a socket or a named value for."""
names = {entry.get("name") for entry in node.get("inputs") or [] if isinstance(entry, dict)}
named = node.get("widgets_values_named")
if isinstance(named, dict):
names.update(named)
def _widget(spec) -> bool:
if not isinstance(spec, (list, tuple)) or not spec:
return False
options = spec[1] if len(spec) > 1 and isinstance(spec[1], dict) else {}
return not options.get("forceInput") and (isinstance(spec[0], list) or spec[0] in ("INT", "FLOAT", "BOOLEAN", "STRING", "COMBO"))
def given_names(node: dict, info: dict) -> set[str]:
"""Connected sockets and usable serialized widget values."""
names = {entry.get("name") for entry in node.get("inputs") or []
if isinstance(entry, dict) and entry.get("link") is not None}
for saved in (node.get("widgets_values"), node.get("widgets_values_named")):
if isinstance(saved, dict):
names.update(name for name, value in saved.items()
if value is not None and _widget(required_of(info, name)))
return {name for name in names if name}
@@ -82,25 +91,29 @@ def example_problems(directory: Path, offered: dict) -> list[str]:
continue
if not isinstance(data, dict) or not isinstance(data.get("nodes"), list):
continue
subgraphs = {graph.get("id") for graph in (data.get("definitions") or {}).get("subgraphs") or []}
for where, node in example_nodes(data):
kind = str(node.get("type"))
if kind in FRONTEND_ONLY or SUBGRAPH.match(kind):
if kind in FRONTEND_ONLY or kind in subgraphs:
continue
if SUBGRAPH.fullmatch(kind):
problems.append(f"{path.name}: unresolved subgraph '{kind}' (#{node.get('id')}{where})")
continue
info = offered.get(kind)
if info is None:
problems.append(f"{path.name}: this ComfyUI has no node '{kind}' (#{node.get('id')}{where})")
continue
required = set((info.get("input") or {}).get("required") or {})
# A saved node without named values (an older save) cannot be
# checked by name; its sockets alone would raise false alarms.
if not isinstance(node.get("widgets_values_named"), dict) and node.get("widgets_values"):
continue
given = given_names(node)
# Older positional saves cannot identify widgets by name, but
# required sockets still need a connection.
positional = not isinstance(node.get("widgets_values_named"), dict) and isinstance(node.get("widgets_values"), list) and bool(node["widgets_values"])
given = given_names(node, info)
# An input that grows (values.a, values.b, ...) is saved under
# its parts' names, and may have none of them filled.
roots = given | {name.split(".", 1)[0] for name in given}
for name in sorted(required - roots):
if _grows(required_of(info, name)):
spec = required_of(info, name)
if _grows(spec) or (positional and _widget(spec)):
continue
problems.append(f"{path.name}: '{kind}' (#{node.get('id')}{where}) now requires '{name}', and the example has no value for it")
return problems
+58 -9
View File
@@ -63,6 +63,23 @@ def _parent_map(tree: ast.AST) -> dict[int, ast.AST]:
return parents
def _bound_names(node: ast.AST) -> set[str]:
"""Bindings stored as strings rather than ast.Name nodes."""
if isinstance(node, ast.Import):
return {alias.asname or alias.name.split(".")[0] for alias in node.names}
if isinstance(node, ast.ImportFrom):
return {alias.asname or alias.name for alias in node.names}
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
return {node.name}
if isinstance(node, ast.arg):
return {node.arg}
if isinstance(node, (ast.ExceptHandler, ast.MatchAs, ast.MatchStar)):
return {node.name} if node.name else set()
if isinstance(node, ast.MatchMapping):
return {node.rest} if node.rest else set()
return set()
def _reference_problem(name: str, ref: ast.Name, parents: dict[int, ast.AST]) -> str:
"""Why this mention of a mapping outside its own assignment is refused.
@@ -188,6 +205,13 @@ def mapping_problems(source: str, label: str) -> list[str]:
f"{label}:{ref.lineno}: {_reference_problem(name, ref, parents)}"
" - a scanner reads only the literal"
)
for node in ast.walk(tree):
bindings = _bound_names(node)
if name in bindings or "*" in bindings:
problems.append(
f"{label}:{node.lineno}: {name} may be rebound here "
"- a scanner reads only the literal"
)
# The two mappings describe the same nodes, so they carry the same keys.
if len(literals) == 2:
@@ -248,20 +272,45 @@ def module_list(source: str, name: str = "NODE_MODULES") -> set[str] | None:
tree = ast.parse(source)
except SyntaxError:
return None
for node in tree.body:
if (
declarations = [node for node in tree.body if (
isinstance(node, ast.Assign)
and len(node.targets) == 1
and isinstance(node.targets[0], ast.Name)
and node.targets[0].id == name
):
try:
value = ast.literal_eval(node.value)
except (ValueError, SyntaxError):
)]
if len(declarations) != 1:
return None
declaration = declarations[0]
try:
value = ast.literal_eval(declaration.value)
except (ValueError, SyntaxError):
return None
if not isinstance(value, (list, tuple, set)) or not all(isinstance(item, str) for item in value):
return None
parents = _parent_map(tree)
for node in ast.walk(tree):
bindings = _bound_names(node)
if name in bindings or "*" in bindings:
return None
if isinstance(node, ast.Name) and node.id == name and node is not declaration.targets[0]:
parent = parents.get(id(node))
# The loader only iterates the list. Passing or aliasing it could
# change it somewhere this check cannot follow.
if not isinstance(parent, ast.For) or parent.iter is not node:
return None
if not isinstance(value, (list, tuple, set)):
return None
return {item for item in value if isinstance(item, str)}
return set(value)
def registered_class(tree: ast.Module, key: str) -> ast.ClassDef | None:
"""Resolve a literal registration to its module-level class declaration."""
for node in tree.body:
if not (isinstance(node, ast.Assign) and isinstance(node.value, ast.Dict)
and any(isinstance(t, ast.Name) and t.id == "NODE_CLASS_MAPPINGS" for t in node.targets)):
continue
for ident, value in zip(node.value.keys, node.value.values):
if isinstance(ident, ast.Constant) and ident.value == key and isinstance(value, ast.Name):
classes = [item for item in tree.body if isinstance(item, ast.ClassDef) and item.name == value.id]
return classes[0] if len(classes) == 1 else None
return None
+33 -8
View File
@@ -33,9 +33,11 @@ Checks:
Exit code 0 = ready to release, 1 = problems printed below.
"""
import ast
import pathlib
import re
import sys
import tomllib
from changelog_notes import notes_for
from registry_contract import git_paths
@@ -56,20 +58,27 @@ except OSError as exc:
if raw.startswith(b"\xef\xbb\xbf"):
errors.append("pyproject.toml starts with a UTF-8 BOM; save it without one")
match = re.search(rb'^version\s*=\s*"([^"]+)"', raw, re.MULTILINE)
if match:
pyproject_version = match.group(1).decode("ascii", "replace")
try:
project = tomllib.loads(raw.decode("utf-8")).get("project", {})
except (ValueError, UnicodeError) as exc:
errors.append(f"could not parse pyproject.toml: {exc}")
project = {}
version = project.get("version")
if isinstance(version, str) and version.strip():
pyproject_version = version
else:
errors.append('pyproject.toml has no version = "..." line')
errors.append("pyproject.toml needs a nonempty project.version string")
# The Registry refuses an upload whose description is over 1000 bytes
# ("description is too long"), and it says so only after the version is
# on main.
DESCRIPTION_LIMIT = 1000
match = re.search(rb'^description\s*=\s*"([^"]*)"', raw, re.MULTILINE)
if match and len(match.group(1)) > DESCRIPTION_LIMIT:
description = project.get("description")
if not isinstance(description, str) or not description.strip():
errors.append("pyproject.toml needs a nonempty project.description string")
elif len(description.encode("utf-8")) > DESCRIPTION_LIMIT:
errors.append(
f"the pyproject description is {len(match.group(1))} bytes; "
f"the pyproject description is {len(description.encode('utf-8'))} bytes; "
f"the Registry takes {DESCRIPTION_LIMIT} at most"
)
@@ -286,6 +295,7 @@ NETWORK_PATTERNS = {
".js": (".connect(",),
".mjs": (".connect(",),
}
NETWORK_IMPORTS = {"requests", "httpx", "socket", "urllib.request", "http.client", "aiohttp.ClientSession"}
shipped_source = [ROOT / "__init__.py", *sorted((ROOT / "nodes").rglob("*.py"))]
shipped_source += sorted(p for p in (ROOT / "js").rglob("*") if p.suffix in (".js", ".mjs"))
for path in shipped_source:
@@ -296,6 +306,21 @@ for path in shipped_source:
except OSError as exc:
errors.append(f"could not read {path.relative_to(ROOT)}: {exc}")
continue
if path.suffix == ".py":
try:
tree = ast.parse(text)
except SyntaxError as exc:
errors.append(f"{path.relative_to(ROOT)} cannot be parsed: {exc}")
continue
imports = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imports.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and not node.level:
imports.extend(f"{node.module}.{alias.name}" for alias in node.names)
for imported in imports:
if any(imported == name or imported.startswith(name + ".") for name in NETWORK_IMPORTS):
errors.append(f"{path.relative_to(ROOT)} imports {imported}; shipped code makes no network requests")
for pattern in NETWORK_PATTERNS[path.suffix]:
if pattern in text:
errors.append(
@@ -315,7 +340,7 @@ for path in shipped_source:
relative = path.relative_to(ROOT).as_posix()
if relative in FETCH_ALLOWED:
continue
if re.search(r"(?<![\w.])fetch\(", path.read_text(encoding="utf-8")):
if re.search(r"(?<![\w$])fetch\s*(?:\?\.\s*)?\(", path.read_text(encoding="utf-8")):
errors.append(
f"{relative} calls fetch(); only {sorted(FETCH_ALLOWED)} may. "
"Use api.fetchApi for the pack's own routes."
+13 -3
View File
@@ -43,6 +43,7 @@ from registry_contract import (
duplicate_key_problems,
mapping_problems,
module_list,
registered_class,
scanned_python_files,
)
@@ -55,6 +56,9 @@ PUBLIC_NODE_IDS = {
"AUSBOSS_NODES_ColorMatch",
"AUSBOSS_NODES_Compare",
"AUSBOSS_NODES_CropForInpaint",
"AUSBOSS_NODES_EditLock",
"AUSBOSS_NODES_EditLockRestore",
"AUSBOSS_NODES_FlashVSRUpscale",
"AUSBOSS_NODES_Float",
"AUSBOSS_NODES_FrameInterpolate",
"AUSBOSS_NODES_FreeMemory",
@@ -69,6 +73,7 @@ PUBLIC_NODE_IDS = {
"AUSBOSS_NODES_LaMaInpaint",
"AUSBOSS_NODES_LayoutBoxes",
"AUSBOSS_NODES_LoadAudio",
"AUSBOSS_NODES_LoadImage",
"AUSBOSS_NODES_LoadImagePad",
"AUSBOSS_NODES_LoadVideo",
"AUSBOSS_NODES_LoraLoader",
@@ -76,6 +81,7 @@ PUBLIC_NODE_IDS = {
"AUSBOSS_NODES_MaskByTouch",
"AUSBOSS_NODES_MathExpression",
"AUSBOSS_NODES_MergeBatches",
"AUSBOSS_NODES_PromptVariations",
"AUSBOSS_NODES_RealignToSource",
"AUSBOSS_NODES_RefineMask",
"AUSBOSS_NODES_Resolution",
@@ -91,6 +97,7 @@ PUBLIC_NODE_IDS = {
"AUSBOSS_NODES_StitchInpaint",
"AUSBOSS_NODES_TiledUpscaleStitch",
"AUSBOSS_NODES_Text",
"AUSBOSS_NODES_TextLines",
"AUSBOSS_NODES_TiledUpscale",
"AUSBOSS_NODES_VideoCropRotatePad",
"AUSBOSS_NODES_VideoCropRotatePadClip",
@@ -210,9 +217,12 @@ for path in node_files:
text = path.read_text(encoding="utf-8")
if path.stem in {"node_image_crop_rotate_pad", "node_video_crop_rotate_pad"}:
tree = ast.parse(text)
key = {"node_image_crop_rotate_pad": "AUSBOSS_NODES_ImageCropRotatePad",
"node_video_crop_rotate_pad": "AUSBOSS_NODES_VideoCropRotatePad"}[path.stem]
cls = registered_class(tree, key)
return_types = None
return_names = None
for node in ast.walk(tree):
for node in cls.body if cls is not None else []:
if not isinstance(node, ast.Assign):
continue
for target in node.targets:
@@ -257,13 +267,13 @@ for unlisted_id in sorted(mapping_keys - RELEASED_NODE_IDS):
# import ever registers, and Manager offered the pack for workflows using
# them. Fixtures live as .py.txt for this reason - real source, not a module.
for path in python_files:
if path.parent == ROOT / "nodes":
if path in node_files:
continue
stray = class_mapping_keys(path.read_text(encoding="utf-8"))
for key in sorted(stray):
errors.append(
f"{path.relative_to(ROOT)}: declares mapping key {key} outside "
"nodes/, where registry scanners will advertise it as a node"
"a registered node module, where registry scanners will advertise it as a node"
)
# --- 6. public display names carry the pack suffix ---------------------------
+153 -14
View File
@@ -2,9 +2,14 @@
from __future__ import annotations
import ast
from functools import lru_cache
import json
import re
from pathlib import Path
from urllib.parse import unquote, urlsplit
from registry_contract import class_mapping_keys, registered_class
UUID = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
# Below this saved zoom the frontend stops drawing widget text (about 0.57 on
@@ -18,6 +23,93 @@ PREFIX_SLOT = {"AUSBOSS_NODES_SaveImage": 0, "AUSBOSS_NODES_SaveVideo": 1}
# loaders (its CLIP toggle covers the model-only case).
CORE_LORA_LOADERS = {"LoraLoader", "LoraLoaderModelOnly"}
# Serialized widget slots for the core/frontend nodes used by the examples.
# Keep this independent of each saved object's named-copy insertion order.
CORE_WIDGETS = {
"UNETLoader": "unet_name weight_dtype",
"CLIPLoader": "clip_name type device",
"DualCLIPLoader": "clip_name1 clip_name2 type device",
"VAELoader": "vae_name",
"CheckpointLoaderSimple": "ckpt_name",
"MarkdownNote": "text",
"Note": "text",
"ImageScaleToMaxDimension": "upscale_method largest_size",
"TextGenerate": "prompt max_length sampling_mode sampling_mode.temperature sampling_mode.top_k sampling_mode.top_p sampling_mode.min_p sampling_mode.repetition_penalty sampling_mode.seed sampling_mode.presence_penalty thinking use_default_template mtp",
"StringConcatenate": "string_a string_b delimiter",
"StringFormat": "f_string",
"CLIPTextEncode": "text",
"KSampler": "seed control_after_generate steps cfg sampler_name scheduler denoise",
"LoadImage": "image upload",
"EmptyImage": "width height batch_size color",
"ImageCompositeMasked": "x y resize_source",
"GrowMask": "expand tapered_corners",
"LTXVPreprocess": "img_compression",
"LTXVConditioning": "frame_rate",
"EmptyLTXVLatentVideo": "width height length batch_size",
"LTXVAddGuide": "frame_idx strength",
"VAEDecodeTiled": "tile_size overlap temporal_size temporal_overlap",
"TextEncodeQwenImage21": "prompt negative_prompt resolution",
"QwenImage21Cache": "device dtype",
"SAM3_Detect": "threshold refine_iterations individual_masks",
}
@lru_cache(maxsize=1)
def pack_widget_names() -> dict[str, list[str]]:
"""Read the pack's widget order without importing any node or library."""
result = {}
for path in sorted((Path(__file__).resolve().parent.parent / "nodes").glob("node_*.py")):
source = path.read_text(encoding="utf-8")
tree = ast.parse(source)
for key in class_mapping_keys(source):
cls = registered_class(tree, key)
if cls is None:
continue
names = []
upload = False
for fn in cls.body:
if not isinstance(fn, ast.FunctionDef) or fn.name != "INPUT_TYPES":
continue
returned = [n.value for n in ast.walk(fn) if isinstance(n, ast.Return)]
if len(returned) != 1 or not isinstance(returned[0], ast.Dict):
continue
groups = {k.value: v for k, v in zip(returned[0].keys, returned[0].values) if isinstance(k, ast.Constant)}
for group in ("required", "optional"):
inputs = groups.get(group)
if not isinstance(inputs, ast.Dict):
continue
for name, spec in zip(inputs.keys, inputs.values):
if not isinstance(name, ast.Constant) or not isinstance(spec, ast.Tuple) or not spec.elts:
continue
kind = spec.elts[0]
options = spec.elts[1] if len(spec.elts) > 1 else None
flags = {k.value: v.value for k, v in zip(options.keys, options.values)
if isinstance(k, ast.Constant) and isinstance(v, ast.Constant)} if isinstance(options, ast.Dict) else {}
primitive = isinstance(kind, ast.Constant) and kind.value in ("INT", "FLOAT", "BOOLEAN", "STRING")
combo = isinstance(kind, (ast.List, ast.Call)) or isinstance(kind, ast.Name) and kind.id != "WILDCARD"
if flags.get("forceInput") or not (primitive or combo):
continue
names.append(name.value)
if flags.get("control_after_generate"):
names.append("control_after_generate")
if flags.get("image_upload") or flags.get("video_upload") or flags.get("audio_upload"):
upload = True
if upload:
names.append("upload")
result[key] = names
return result
def serialized_widget_names(node: dict) -> list[str] | None:
kind = node.get("type")
if kind in CORE_WIDGETS:
return CORE_WIDGETS[kind].split()
if kind in pack_widget_names():
return pack_widget_names()[kind]
if isinstance(kind, str) and UUID.fullmatch(kind):
return [entry["widget"]["name"] for entry in node.get("inputs", []) if isinstance(entry.get("widget"), dict)]
return None
def widget_value_list(node: dict) -> list:
values = node.get("widgets_values")
@@ -51,19 +143,37 @@ def named_copy_problems(node: dict) -> list[str]:
values = node.get("widgets_values")
if not isinstance(named, dict):
return []
placeholders = {key for key, value in named.items() if key.startswith("ausboss_") and value in ("", None)}
keys = set(named) - placeholders
if isinstance(values, dict):
# Upload buttons and old panels can have an empty named value even
# though the frontend does not serialize them into the primary copy.
if "upload" not in values:
keys.discard("upload")
pairs = [(key, named[key], values[key]) for key in named if key in values]
unmatched = keys ^ set(values)
elif isinstance(values, list):
# Panel widgets the frontend saves as "" have no positional twin.
keys = [key for key in named if not (key.startswith("ausboss_") and named[key] in ("", None))]
pairs = [(key, named[key], value) for key, value in zip(keys, values)]
slots = serialized_widget_names(node)
if slots is None:
return [f"node {node['id']} has no known serialized widget slot mapping"]
last = max((slots.index(key) for key in keys if key in slots), default=-1) + 1
unmatched = keys - set(slots)
unmatched.update(key for index, key in enumerate(slots[:last]) if key not in keys or index >= len(values))
pairs = [(key, named[key], values[index]) for index, key in enumerate(slots)
if key in keys and index < len(values)]
trailing = values[last:]
if len(trailing) > len(placeholders) or any(value not in ("", None) for value in trailing):
unmatched.add("extra positional values")
else:
return []
return [f"node {node['id']} has named widgets without serialized values"]
stale = [key for key, saved, loaded in pairs if saved != loaded]
return [f"node {node['id']} has stale named widget values: {', '.join(stale)}"] if stale else []
problems = [f"node {node['id']} has stale named widget values: {', '.join(stale)}"] if stale else []
if unmatched:
problems.append(f"node {node['id']} has unmatched widget values: {', '.join(sorted(unmatched))}")
return problems
def workflow_problems(data: dict) -> list[str]:
def workflow_problems(data: dict, *, _subgraph: bool = False) -> list[str]:
problems = []
nodes = data.get("nodes", [])
if not isinstance(nodes, list) or not nodes:
@@ -71,7 +181,21 @@ def workflow_problems(data: dict) -> list[str]:
by_id = {n["id"]: n for n in nodes}
if len(by_id) != len(nodes):
problems.append("duplicate node ids")
if _subgraph:
# Boundary nodes are stored separately, with their sockets on the
# graph itself. Treat them like nodes for the same link checks.
by_id[data["inputNode"]["id"]] = {
"id": data["inputNode"]["id"],
"outputs": [{"links": entry.get("linkIds", [])} for entry in data.get("inputs", [])],
}
by_id[data["outputNode"]["id"]] = {
"id": data["outputNode"]["id"],
"inputs": [{"boundary_links": entry.get("linkIds", [])} for entry in data.get("outputs", [])],
}
links = data.get("links", [])
if _subgraph:
fields = ("id", "origin_id", "origin_slot", "target_id", "target_slot", "type")
links = [[link[key] for key in fields] if isinstance(link, dict) and all(key in link for key in fields) else link for link in links]
by_link = {link[0]: link for link in links if isinstance(link, list) and len(link) == 6}
if len(by_link) != len(links):
problems.append("duplicate or malformed links (expected six fields)")
@@ -88,20 +212,21 @@ def workflow_problems(data: dict) -> list[str]:
problems.append(f"link {ident} is missing from its source output")
if not isinstance(target_slot, int) or not 0 <= target_slot < len(inputs):
problems.append(f"link {ident} has an invalid target slot")
elif inputs[target_slot].get("link") != ident:
elif ident not in inputs[target_slot].get("boundary_links", [inputs[target_slot].get("link")]):
problems.append(f"link {ident} is missing from its target input")
for n in nodes:
for n in by_id.values():
for slot, inp in enumerate(n.get("inputs", [])):
ident = inp.get("link")
if ident is not None and (ident not in by_link or by_link[ident][3:5] != [n["id"], slot]):
problems.append(f"node {n['id']} input {slot} has a stale link")
for ident in inp.get("boundary_links", [inp.get("link")]):
if ident is not None and (ident not in by_link or by_link[ident][3:5] != [n["id"], slot]):
problems.append(f"node {n['id']} input {slot} has a stale link")
for slot, output in enumerate(n.get("outputs", [])):
for ident in output.get("links") or []:
if ident not in by_link or by_link[ident][1:3] != [n["id"], slot]:
problems.append(f"node {n['id']} output {slot} has a stale link")
if data.get("last_node_id", -1) < max(by_id):
counters = data.get("state", {}) if _subgraph else data
if counters.get("lastNodeId" if _subgraph else "last_node_id", -1) < max(by_id):
problems.append("last_node_id is behind the graph")
if by_link and data.get("last_link_id", -1) < max(by_link):
if by_link and counters.get("lastLinkId" if _subgraph else "last_link_id", -1) < max(by_link):
problems.append("last_link_id is behind the graph")
ident = data.get("id")
@@ -117,8 +242,19 @@ def workflow_problems(data: dict) -> list[str]:
selected = selected_files(n)
for model in (n.get("properties") or {}).get("models") or []:
name = model.get("name") if isinstance(model, dict) else None
if not isinstance(model, dict) or any(not isinstance(model.get(key), str) or not model[key].strip() for key in ("name", "url", "directory")):
problems.append(f"node {n['id']} download info needs nonempty name, url and directory strings")
continue
if name and name not in selected:
problems.append(f"node {n['id']} carries download info for {name}, which it does not select")
try:
url = urlsplit(model["url"])
valid_url = url.scheme in ("http", "https") and bool(url.netloc)
filename = unquote(url.path.rsplit("/", 1)[-1])
except ValueError:
valid_url, filename = False, ""
if not valid_url or filename != name:
problems.append(f"node {n['id']} download URL must end with the selected filename {name}")
if n["type"] in PREFIX_SLOT:
saved = n.get("widgets_values")
prefix = saved.get("filename_prefix") if isinstance(saved, dict) else (
@@ -130,7 +266,7 @@ def workflow_problems(data: dict) -> list[str]:
if not groups:
problems.append("no stage groups")
notes = [n for n in nodes if n["type"] in {"AUSBOSS_NODES_WorkflowNote", "AUSBOSS_NODES_WorkflowNoteTile"}]
if not notes:
if not notes and not _subgraph:
problems.append("no Workflow Note with setup instructions")
for n in notes:
try:
@@ -172,6 +308,9 @@ def workflow_problems(data: dict) -> list[str]:
for other, ox0, oy0, ox1, oy1 in rectangles[i + 1:]:
if min(x1, ox1) > max(x0, ox0) + 1 and min(y1, oy1) > max(y0, oy0) + 1:
problems.append(f"nodes {ident} and {other} overlap (including title bars)")
for graph in (data.get("definitions") or {}).get("subgraphs") or []:
label = graph.get("name") or graph.get("id")
problems.extend(f"subgraph {label}: {issue}" for issue in workflow_problems(graph, _subgraph=True))
return problems
+89 -1
View File
@@ -5,7 +5,7 @@ import test from "node:test";
import { fileURLToPath } from "node:url";
import {
ARROW_ANGLE, ARROW_CHOICES, SMALL_MARK, arrowText, fitFontSize, linkTarget, parseBlocks, pastedLink, repointLinks, targetId,
ARROW_ANGLE, ARROW_CHOICES, CARD_TEMPLATE, SMALL_MARK, arrowText, cardCells, parseCard, fitFontSize, linkTarget, parseBlocks, pastedLink, repointLinks, targetId,
toggleSmall, urlLabel, wrapLink, tileLinkFields, tileLinkText,
} from "../js/shared/callout.mjs";
@@ -322,3 +322,91 @@ test("a note pasted with what its links name follows the copies", () => {
assert.equal(repointLinks("[elsewhere](#40) ⬆️ plain", nodes, groups), null);
assert.equal(repointLinks(undefined, nodes, groups), null);
});
// Info card. The words of a piece list, so a test reads like the card.
const words = (pieces) => pieces.map((piece) => piece.text ?? "").join("");
test("an Info card reads a title, rows and small print from plain text", () => {
const blocks = parseCard([
"⏱ Too slow?",
"###### Redraw time on an RTX 5090. Smaller cards take longer.",
"",
"2x, 18 steps | ~4 min",
"2x, 10 steps | ~3 min",
"1.5x, 18 steps | ~2.5 min",
"**1.5x, 10 steps (default) | ~2 min**",
"",
"###### 1.5x is a 4608-wide sheet, 2x is 6144.",
].join("\n"));
assert.equal(blocks.length, 3);
const [head, rows, foot] = blocks;
assert.deepEqual(head.map((line) => line.kind), ["title", "text"]);
assert.equal(head[0].icon, "clock");
assert.equal(words(head[0].pieces), "Too slow?");
assert.equal(head[1].small, true);
assert.deepEqual(
rows.map((row) => [row.kind, words(row.cells[0]), words(row.cells[1]), row.highlight]),
[
["row", "2x, 18 steps", "~4 min", false],
["row", "2x, 10 steps", "~3 min", false],
["row", "1.5x, 18 steps", "~2.5 min", false],
["row", "1.5x, 10 steps (default)", "~2 min", true],
],
);
assert.equal(foot[0].kind, "text");
assert.equal(foot[0].small, true);
});
test("a row is marked by bold on the whole row, written either way", () => {
assert.equal(parseCard("T\n**a | b**")[0][1].highlight, true);
assert.equal(parseCard("T\n**a** | **b**")[0][1].highlight, true);
// Bold on one word stresses the word; the row is not marked.
assert.equal(parseCard("T\n**a** b | c")[0][1].highlight, false);
assert.equal(parseCard("T\na | **b**")[0][1].highlight, false);
});
test("a row splits at the first | outside a link", () => {
assert.deepEqual(cardCells("Size (1.5x | 2x)"), ["Size (1.5x", "2x)"]);
assert.deepEqual(cardCells("[a|b](https://example.com/a|b) | v"), ["[a|b](https://example.com/a|b)", "v"]);
assert.deepEqual(cardCells("a | b | c"), ["a", "b | c"]);
// A pasted Markdown table row loses its outer pipes; its --- line is dropped.
assert.deepEqual(cardCells("| 2x | ~4 min |"), ["2x", "~4 min"]);
assert.deepEqual(parseCard("T\n| a | b |\n|---|:--:|\n| c | d |")[0].map((line) => line.kind), ["title", "row", "row"]);
// One side empty is not a row.
for (const line of ["a |", "| b", "|", "no pipe"]) assert.equal(cardCells(line), null, line);
});
test("only the card's first line is its title, and only a known emoji is an icon", () => {
assert.equal(parseCard("a | b\nT")[0][0].kind, "row");
assert.equal(parseCard("###### small\nT")[0][0].kind, "text");
assert.deepEqual(parseCard("T\nU")[0].map((line) => line.kind), ["title", "text"]);
for (const [title, icon] of [["ℹ️ Note", "info"], ["⚠️ Careful", "warning"], ["💡 Tip", "tip"], ["✅ Done", "check"], ["⏱️Fast", "clock"], ["🙂 Hi", null], ["Plain", null]]) {
const line = parseCard(title)[0][0];
assert.equal(line.icon, icon, title);
if (icon) assert.ok(!/^\s/.test(words(line.pieces)), title);
}
assert.equal(words(parseCard("🙂 Hi")[0][0].pieces), "🙂 Hi");
});
test("the card's starter text shows every part of a card", () => {
const [head, rows, foot] = parseCard(CARD_TEMPLATE);
assert.equal(head[0].icon, "info");
assert.equal(head[1].small, true);
assert.deepEqual(rows.map((row) => row.highlight), [false, true]);
assert.equal(foot[0].small, true);
});
test("a plain callout reads a | as text, so no note written before changes", () => {
const [[line]] = parseBlocks("2x, 18 steps | ~4 min");
assert.deepEqual(line, [{ text: "2x, 18 steps | ~4 min", bold: false }]);
});
test("an Info card saves as a plain Callout, so an older pack shows its text", () => {
// Tiles save as Callout Tile and an older pack reports that node missing.
// A card is readable as a plain note there, so it keeps the plain type.
const source = readFileSync(join(ROOT, "js", "callout", "index.js"), "utf-8");
const saved = /serializeNoteIdentity\(saved, (\[[^\]]*\])\.includes/.exec(source);
assert.ok(saved, "the tile list in onSerialize moved");
assert.ok(!saved[1].includes("card"), "an Info card would save as a Callout Tile");
assert.match(source, /\["card","Info card"\]/, "the Info card is gone from the note's menu");
});
+243
View File
@@ -0,0 +1,243 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import vm from "node:vm";
import * as appearance from "../js/shared/appearance.mjs";
import * as help from "../js/shared/help_badge.mjs";
import * as line from "../js/shared/line_up.mjs";
import * as snap from "../js/shared/snap_lines.mjs";
import { boundsOf } from "../js/shared/outline.mjs";
// Run the real extension entry with a small page and graph in place of ComfyUI.
function page(name, nodes, { vue = false, hooks = [] } = {}) {
const graph = { _nodes: nodes, _groups: [], setDirtyCanvas() {} };
const canvas = {
graph, selectedItems: new Set(nodes), setDirty() {},
canvas: { getBoundingClientRect: () => ({ left: 0, top: 0 }) },
ds: { scale: 1, offset: [0, 0] },
};
const app = { graph, canvas, registerExtension(extension) { this.extension = extension; } };
const frames = new Map();
let frameId = 0;
const cards = [];
const context = vm.createContext({
...appearance, ...help, ...line, ...snap, boundsOf,
outlineOf: boundsOf, nodeElements: () => new Map(),
app, LiteGraph: { vueNodesMode: vue },
window: { addEventListener() {}, removeEventListener() {}, innerWidth: 1000 },
document: { querySelector: () => null },
requestAnimationFrame(fn) { frames.set(++frameId, fn); return frameId; },
cancelAnimationFrame(id) { frames.delete(id); },
chainCallback(object, key, fn) {
const prior = object[key];
object[key] = function (...args) {
const result = prior?.apply(this, args);
fn.apply(this, args);
return result;
};
},
chainHandler(object, key, fn) {
const prior = object[key];
object[key] = function (...args) {
const result = prior?.apply(this, args);
const handled = fn.apply(this, args);
return result || handled;
};
},
isAusbossNode: (node) => node.comfyClass?.startsWith("AUSBOSS_NODES_"),
openInfoCard: (card) => cards.push(card),
BRAND: "#00b4aa", notifyAusbossChange() {}, showToast() {},
});
const source = readFileSync(new URL(`../js/${name}/index.js`, import.meta.url), "utf8")
.replace(/^import[\s\S]*?;\s*/gm, "");
vm.runInContext(`${source}\nglobalThis.hooks = {${hooks.join(",")}};`, context);
return {
app, extension: app.extension, hooks: context.hooks, cards,
flush() { for (const [id, fn] of frames) { frames.delete(id); fn(); } },
};
}
function node(id, x = 0, y = 0, w = 200, h = 100) {
return {
id, pos: [x, y], size: [w, h], properties: {}, flags: {},
comfyClass: "AUSBOSS_NODES_Seed", title: "Seed", constructor: {},
get boundingRect() { return [this.pos[0], this.pos[1] - 30, this.size[0], this.size[1] + 30]; },
computeSize: () => [100, 50],
setSize(size) { this.size = size; },
};
}
function pointer(node, corner = "SE", extra = {}) {
const face = { dataset: { nodeId: String(node.id) } };
const handle = { dataset: { corner }, closest: () => face };
return {
button: 0, buttons: 1, clientX: 0, clientY: 0,
target: { closest: () => handle }, ...extra,
};
}
function pick(page, node, name) {
page.extension.getNodeMenuItems(node).find((item) => item.content === "AusBoss color")
.submenu.options.find((item) => item.content === name).callback();
}
function setting(page, suffix, value) {
page.extension.settings.find((item) => item.id.endsWith(suffix)).onChange(value);
}
function restore(page, saved) {
const copy = node(99);
page.extension.nodeCreated(copy); // construction precedes saved field restoration
Object.assign(copy, JSON.parse(JSON.stringify(saved)));
copy.onConfigure?.(saved);
return copy;
}
test("pressing and releasing a resize handle leaves the other selected nodes alone", () => {
for (const vue of [false, true]) {
const a = node(1), b = node(2, 0, 300, 400);
const p = page("line_up", [a, b], { vue, hooks: ["onTogetherDown", "onTogetherMove", "onTogetherUp"] });
p.hooks.onTogetherDown(pointer(a));
if (!vue) p.app.canvas.resizing_node = a;
p.hooks.onTogetherMove(pointer(a));
p.flush();
p.hooks.onTogetherUp();
assert.equal(b.size[0], 400);
}
});
test("resize together still propagates a drag and its return to the starting size", () => {
const a = node(1), b = node(2, 0, 300, 400);
const p = page("line_up", [a, b], { hooks: ["onTogetherDown", "onTogetherMove", "onTogetherUp"] });
p.hooks.onTogetherDown(pointer(a));
a.size[0] = 240;
p.hooks.onTogetherMove(pointer(a));
p.flush();
assert.equal(b.size[0], 240);
a.size[0] = 200;
p.hooks.onTogetherUp();
assert.equal(b.size[0], 200);
});
test("per-node Theme default survives serialization, reload and paste", () => {
const a = node(1);
const p = page("appearance", [a]);
p.extension.nodeCreated(a);
pick(p, a, "Theme default");
const saved = JSON.parse(JSON.stringify({ properties: a.properties, color: a.color, bgcolor: a.bgcolor }));
for (let i = 0; i < 2; i++) {
const copy = restore(p, saved);
assert.equal(copy.color, undefined);
assert.equal(copy.bgcolor, undefined);
}
});
test("explicit color choices keep following the node instead of the global scheme", () => {
for (const name of ["AusBoss", "Theme default", "Custom"]) {
const a = node(1);
const p = page("appearance", [a]);
p.extension.nodeCreated(a);
pick(p, a, name);
const chosen = { color: a.color, bgcolor: a.bgcolor };
setting(p, "NodeColor", "Slate");
assert.deepEqual({ color: a.color, bgcolor: a.bgcolor }, chosen, name);
const copy = restore(p, { properties: a.properties, ...chosen });
assert.deepEqual({ color: copy.color, bgcolor: copy.bgcolor }, chosen, name);
}
});
for (const corner of ["SE", "NE", "SW", "NW"]) {
test(`Nodes 2.0 ${corner} resize snaps only the moving edges`, () => {
const a = node(1, 0, 0, 280, 100);
const west = corner.includes("W"), north = corner.includes("N");
const b = node(2, west ? -20 : 300, north ? -200 : 400, 100, 100);
const p = page("snap_lines", [a, b], { vue: true, hooks: ["onPointerDown", "onPointerMove", "end"] });
p.hooks.onPointerDown(pointer(a, corner));
if (west) { a.pos[0] = -16; a.size[0] = 296; }
else a.size[0] = 296;
if (north) { a.pos[1] = -66; a.size[1] = 166; }
p.hooks.onPointerMove(pointer(a, corner));
p.flush();
assert.equal(a.size[0], 300);
assert.equal(a.pos[0], west ? -20 : 0);
assert.equal(a.pos[0] + a.size[0], west ? 280 : 300);
if (north) {
assert.equal(a.pos[1], -70);
assert.equal(a.pos[1] + a.size[1], 100);
}
p.hooks.end();
a.size[0] = 296;
p.hooks.onPointerMove(pointer(a, corner));
p.flush();
assert.equal(a.size[0], 296, "release clears the captured handle");
});
}
test("Nodes 2.0 resize lets Shift bypass snapping", () => {
const a = node(1, 0, 0, 280), b = node(2, 300, 400, 100);
const p = page("snap_lines", [a, b], { vue: true, hooks: ["onPointerDown", "onPointerMove"] });
p.hooks.onPointerDown(pointer(a));
a.size[0] = 296;
p.hooks.onPointerMove(pointer(a, "SE", { shiftKey: true }));
p.flush();
assert.equal(a.size[0], 296);
});
test("titled Nodes 2.0 nodes offer an About card in the context menu", () => {
const a = node(1);
const p = page("appearance", [a], { vue: true });
p.extension.nodeCreated(a);
const about = p.extension.getNodeMenuItems(a).find((item) => item.content === "About this node");
assert.ok(about);
about.callback();
assert.equal(p.cards[0].title, "Seed");
});
test("an invalid Custom edit preserves the last valid color for later sweeps", () => {
const a = node(1);
const p = page("appearance", [a]);
p.extension.nodeCreated(a);
setting(p, "NodeColor", "Custom");
const before = a.color;
setting(p, "CustomColor", "bad");
assert.equal(a.color, before);
setting(p, "CustomColor", "ff0000");
assert.equal(a.color, "#ff0000");
setting(p, "NodeColor", "Slate");
assert.equal(a.color, appearance.schemeColors("Slate").title);
});
test("saved legacy colors migrate after configuration, including Theme default", () => {
for (const name of ["AusBoss", "Slate", "Theme default"]) {
const p = page("appearance", []);
setting(p, "NodeColor", name);
const old = appearance.LEGACY_SCHEME_PAIRS[0];
const copy = restore(p, { color: old.title, bgcolor: old.body, properties: {} });
assert.equal(copy.color, appearance.schemeColors(name)?.title);
assert.equal(copy.bgcolor, appearance.schemeColors(name)?.body);
}
});
test("restoration preserves manual colors and chains the existing configure callback", () => {
const a = node(1);
const p = page("appearance", [a]);
let calls = 0;
a.onConfigure = function () { assert.equal(this, a); calls++; return 42; };
p.extension.nodeCreated(a);
Object.assign(a, { color: "#123456", bgcolor: "#654321" });
assert.equal(a.onConfigure({}), 42);
assert.equal(calls, 1);
assert.equal(a.color, "#123456");
assert.equal(a.bgcolor, "#654321");
});
test("a later manual color edit wins over an earlier menu choice on reload", () => {
const a = node(1);
const p = page("appearance", [a]);
p.extension.nodeCreated(a);
pick(p, a, "Theme default");
const copy = restore(p, { properties: a.properties, color: "#123456", bgcolor: "#654321" });
assert.equal(copy.color, "#123456");
assert.equal(copy.bgcolor, "#654321");
});
+24
View File
@@ -45,6 +45,30 @@ test("middle button and Ctrl+Shift drag go to the graph; ordinary clicks do not"
assert.equal(graphDragStarts({ button: 0, buttons: 1, ctrlKey: true }, on), false);
});
test("horizontal wheel stays in a scrollable code block until its edge", () => {
const root = { parentElement: null };
const code = {
tagName: "PRE", parentElement: root,
scrollWidth: 1000, clientWidth: 200, scrollLeft: 0,
scrollHeight: 50, clientHeight: 50, scrollTop: 0,
};
const context = { root, styleOf: () => ({ overflowX: "auto", overflowY: "auto" }) };
const keeps = (deltaX, deltaY = 0) => panelKeepsWheel(code, { deltaX, deltaY }, context);
assert.equal(keeps(80), true);
assert.equal(keeps(-80), false);
code.scrollLeft = 400;
assert.equal(keeps(-80), true);
assert.equal(keeps(80, 80), true, "diagonal scroll with horizontal room");
code.scrollLeft = 800;
assert.equal(keeps(80), false);
assert.equal(keeps(-80), true);
code.scrollWidth = 200;
assert.equal(keeps(80), false);
code.scrollWidth = 1000;
context.styleOf = () => ({ overflowX: "hidden" });
assert.equal(keeps(-80), false, "hidden overflow cannot keep the wheel");
});
test("only the pack's own panels count, in both node renderers", () => {
const chain = (host, ...classes) => {
let parent = host;
+71
View File
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import vm from "node:vm";
// Run the real extension entry with a fake page in place of ComfyUI.
function page({ settings = {}, answer = { ok: true, status: 200 } } = {}) {
const requests = [];
const toasts = [];
const app = {
ui: { settings: { getSettingValue: (id) => settings[id] } },
registerExtension(extension) { this.extension = extension; },
};
const api = {
async fetchApi(path, options) {
requests.push({ path, ...options });
if (answer instanceof Error) throw answer;
return answer;
},
};
const context = vm.createContext({ app, api, showToast: (toast) => toasts.push(toast), JSON });
const source = readFileSync(new URL("../js/clear_vram/index.js", import.meta.url), "utf8")
.replace(/^import[\s\S]*?;\s*/gm, "");
vm.runInContext(source, context);
return { extension: app.extension, requests, toasts };
}
async function click(p) {
const [item] = p.extension.getCanvasMenuItems();
await item.callback();
}
test("the setting follows the pack's id and category rule and is on by default", () => {
const [setting] = page().extension.settings;
assert.equal(setting.id, "AusBoss.Memory.ClearVramMenu");
assert.equal(setting.type, "boolean");
assert.equal(setting.defaultValue, true);
assert.deepEqual([...setting.category], ["🆎 AusBoss", "Memory", "Clear VRAM menu"]);
});
test("the canvas menu shows Clear VRAM unless the setting is off", () => {
assert.deepEqual([...page().extension.getCanvasMenuItems().map((item) => item.content)], ["Clear VRAM 🆎"]);
const off = page({ settings: { "AusBoss.Memory.ClearVramMenu": false } });
assert.equal(off.extension.getCanvasMenuItems().length, 0);
});
test("a ComfyUI that forbids manual unloading gets no entry", () => {
const p = page({ settings: { "Comfy.Memory.AllowManualUnload": false } });
assert.equal(p.extension.getCanvasMenuItems().length, 0);
});
test("a click sends ComfyUI's own free request and confirms it", async () => {
const p = page();
await click(p);
assert.equal(p.requests.length, 1);
assert.equal(p.requests[0].path, "/free");
assert.equal(p.requests[0].method, "POST");
assert.deepEqual(JSON.parse(p.requests[0].body), { unload_models: true, free_memory: true });
assert.equal(p.toasts.length, 1);
assert.equal(p.toasts[0].severity, "success");
});
test("a refused or failed request shows the error", async () => {
for (const answer of [{ ok: false, status: 500 }, new Error("Failed to fetch")]) {
const p = page({ answer });
await click(p);
assert.equal(p.toasts.length, 1);
assert.equal(p.toasts[0].severity, "error");
assert.match(p.toasts[0].detail, answer instanceof Error ? /Failed to fetch/ : /500/);
}
});

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