Compare commits

...
Author SHA1 Message Date
ausboss d5e60ce7a6 Pika H3 Flex example: note steps that work, blank audio loaders
- The Settings note now includes setting the video job to swap the
  person, so its steps work as written.
- Voice and Song loaders are saved blank, like the other media loaders.
- The version sentence with unfilled placeholders is removed.
2026-11-04 00:37:41 -06:00
ausboss 0ffb6a576b H3 Prompt Writer: a swap puts each person picture in one person's place 2026-11-03 20:57:51 -06:00
ausboss 5656144a9f Add the Pika H3 Flex example workflow 2026-11-03 20:29:04 -06:00
ausboss 3b16d1e254 Load Audio: shorter wave, length moves off the wave next to OUT 2026-11-03 23:09:43 +00:00
ausboss dff58e6b88 Load Audio: open smaller and go down to 280 wide 2026-11-03 22:57:38 +00:00
ausboss ef06093d56 Merge remote-tracking branch 'origin/main' into feat/load-audio-trim
# Conflicts:
#	CHANGELOG.md
2026-11-03 22:40:25 +00:00
ausboss 8582a0219d Add Load Audio node: wave, trim, preview and max length
Load a sound file and keep only the part you want. The node shows the
whole file as a wave. Drag the IN and OUT handles (or type the times),
press play to hear just that part, and set Max length to the longest
sound the next step takes: anything past it turns red and is cut off,
so you see what comes out before you run. Max length can be wired.

Only the picked part is decoded. Outputs the audio and its seconds.
Help page, README, changelog, Chinese names, tests and the API snapshot
are included.
2026-11-03 20:51:32 +00:00
ausboss 939d320b8b Merge pull request #271 from ausboss/fix/resize-selected-together
Line up: resize selected nodes together
2026-11-03 14:38:57 -06:00
ausboss 9bb8a201fe Merge remote-tracking branch 'origin/main' into fix/resize-selected-together 2026-11-03 14:33:13 -06:00
ausboss 52c3353c69 Resize together: rows share height, grids share both
How the selection is laid out decides what it shares: nodes stacked in a
column get the grabbed node's width, nodes side by side in a row get its
height, and a grid gets both. A pull on the top edge keeps each bottom
edge in place.
2026-11-03 14:22:46 -06:00
ausboss ded731859c Integer and Float: a big look, like Text (#270)
Integer and Float get the same gear as Text, with a big look: one large centred number with its arrows, in a taller row. The Pika H3 Flex workflow uses it for Seconds.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+2 -2
View File
@@ -21,7 +21,7 @@ Every visible field and output must earn its place. Published IDs, input order,
1. Put independently testable processing in an underscore-prefixed helper under `nodes/`.
2. Add one V1 wrapper in `nodes/node_<purpose>.py` with `INPUT_TYPES`, `RETURN_TYPES`, `FUNCTION`, `CATEGORY`, and both mapping dictionaries — keys as string literals, never a `NODE_ID` variable, or registry scanners cannot see the node.
3. Add the module to `NODE_MODULES` in `__init__.py` and the key to `PUBLIC_NODE_IDS` in `scripts/validate_nodes.py`; the validator fails on a registered key it does not know. A key kept only so old workflows load goes in `LEGACY_NODE_IDS` instead.
4. Give the node its face. Any INT, FLOAT, BOOLEAN, COMBO or STRING widget on a public node is shown through a widget card entry in `js/widget_cards/index.js` (see AGENTS.md §Conventions: numbers scrub, booleans are off | on pills, one linkable widget per row, `top: true` or `forceInput` for values people wire). Write more frontend JavaScript only when the card grammar cannot provide the interaction (a stage, a player, an editor).
4. Give the node its face. Any INT, FLOAT, BOOLEAN, COMBO or STRING widget on a public node is shown through a widget card entry in `js/widget_cards/index.js` (see CONTRIBUTING.md, Conventions: numbers scrub, booleans are off | on pills, one linkable widget per row, `top: true` or `forceInput` for values people wire). Write more frontend JavaScript only when the card grammar cannot provide the interaction (a stage, a player, an editor).
5. Namespace every route, extension, event, DOM marker, CSS class, cache, and browser state with `ausboss`.
6. Chain lifecycle hooks through `chainCallback`; never replace core or third-party prototypes directly.
7. Keep normal workflow and API execution independent from the custom frontend.
@@ -31,7 +31,7 @@ Every visible field and output must earn its place. Published IDs, input order,
## Document and prove
- Add rich help at `js/docs/<exact-mapping-key>.md` (the validator checks it exists) and a section in `README.md`. Name card controls the way the node face shows them, with the widget name alongside: **Tone match** (`color_match`).
- Show the node in an example under `example_workflows/`, a new one or an existing one it fits: a Workflow Note, numbered groups holding every node, no overlaps, and a matching `.jpg` of a real output. AGENTS.md § Example workflows lists the rest.
- Show the node in an example under `example_workflows/`, a new one or an existing one it fits: a Workflow Note, numbered groups holding every node, no overlaps, and a matching `.jpg` of a real output. CONTRIBUTING.md, Example workflows, lists the rest.
- A DOM panel goes into `tests/panel_guards.test.mjs` as `mustGrow` or `fixedByDesign`.
- At the next release, name the node in the pyproject `description`: Registry and Manager search read it.
- Extend `scripts/validate_nodes.py` when the new contract needs a permanent assertion.
File diff suppressed because it is too large Load Diff
+16
View File
@@ -16,11 +16,13 @@ import {
fitFontSize,
parseBlocks,
pastedLink,
repointLinks,
targetId,
toggleSmall,
urlLabel,
wrapLink,
} from "../shared/callout.mjs";
import { onPaste } from "../shared/paste_ids.mjs";
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
@@ -529,6 +531,20 @@ function buildPanel(node) {
app.registerExtension({
name: "AusBoss.Callout",
setup() {
// A note copied with what its links name follows the copies.
onPaste(({ nodes, groups, created }) => {
for (const node of created) {
if (node?.comfyClass !== NODE_CLASS && node?.type !== NODE_CLASS) continue;
const state = buildPanel(node);
if (!state) continue;
const text = repointLinks(state.valueWidget.value, nodes, groups);
if (text === null) continue;
state.valueWidget.value = text;
renderText(state);
}
});
},
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_CLASS) return;
hideInputsInDef(nodeData, ["text"]);
+5 -4
View File
@@ -7,10 +7,11 @@
// ComfyUI does for its own text boxes. A panel that wants the wheel for itself
// (a scrolling list, a focused text field, an editor) still keeps it.
//
// A file dragged in from the desktop has the same problem in the classic
// renderer: dropped on a node's title it loads into the node, dropped on the
// node's panel (the picture, the controls) it missed the node. The drop
// listeners here hand it to the node under the pointer.
// A file dragged in from the desktop, or a picture's card from the Assets
// panel, has the same problem in the classic renderer: dropped on a node's
// title it loads into the node, dropped on the node's panel (the picture, the
// controls) it missed the node. The drop listeners here hand it to the node
// under the pointer.
import { app } from "/scripts/app.js";
import {
dragCarriesFiles, graphDragStarts, panelHost, panelKeepsWheel, panelNeedsDropHelp, panelRoot,
+20 -2
View File
@@ -20,7 +20,7 @@ import {
normalizeCompareMode,
sizeAfterErase,
} from "../shared/compare.mjs";
import { savedLinks } from "../shared/compare_erase.mjs";
import { CHECKER, backdropPadding, savedLinks } from "../shared/compare_erase.mjs";
import { ERASE_CSS, makeEraser } from "../shared/compare_erase_stage.mjs";
const NODE_NAME = "AUSBOSS_NODES_Compare";
@@ -54,6 +54,7 @@ function ensureCompareCss() {
.ausboss-compare-stage{position:relative;flex:1 1 auto;min-height:112px;overflow:hidden;border:1px solid rgba(0,180,170,.34);border-radius:6px;background:#000;box-shadow:inset 0 0 0 1px rgba(255,255,255,.025);touch-action:none;}
.ausboss-compare-stage img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none;}
.ausboss-compare-stage.is-empty img{visibility:hidden;}
.ausboss-compare-stage img.has-alpha{box-sizing:border-box;background-image:repeating-conic-gradient(${CHECKER.light} 0 25%,${CHECKER.dark} 0 50%);background-size:${CHECKER.side * 2}px ${CHECKER.side * 2}px;background-origin:content-box;background-clip:content-box;}
.ausboss-compare-seam{position:absolute;top:0;bottom:0;z-index:2;width:1px;margin-left:-0.5px;background:${BRAND};box-shadow:0 0 4px rgba(0,180,170,.55);opacity:0;pointer-events:none;}
.ausboss-compare-status{position:absolute;left:7px;top:7px;z-index:3;max-width:calc(100% - 112px);padding:3px 6px;border-radius:4px;background:rgba(0,0,0,.7);color:#b8d3d1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none;backdrop-filter:blur(4px);}
/* Once the previews are up the status has nothing to say, and an empty chip
@@ -104,6 +105,19 @@ function applyClip(state) {
state.identity.setAttribute("aria-label", state.identity.title);
}
// A picture with see-through parts lies on a checkerboard. It is the
// picture's own background, held to the picture by padding the size of the
// bars beside it, so in Slide the cut that shows A hides B behind A's clear
// parts too.
function placeBackdrops(state) {
const { clientWidth, clientHeight } = state.stage;
for (const [image, ref] of [[state.imageA, state.refs?.a], [state.imageB, state.refs?.b]]) {
const clear = Boolean(ref?.alpha) && image.naturalWidth > 0;
image.classList.toggle("has-alpha", clear);
image.style.padding = clear ? backdropPadding(clientWidth, clientHeight, image.naturalWidth, image.naturalHeight) : "";
}
}
function setEmpty(state, text) {
state.stage.classList.add("is-empty");
state.status.textContent = text;
@@ -355,7 +369,10 @@ function buildPanel(node) {
updateModeButtons(state);
// The labels are placed in pixels, so they follow the stage's width.
const resized = new ResizeObserver(() => applyClip(state));
const resized = new ResizeObserver(() => {
applyClip(state);
placeBackdrops(state);
});
resized.observe(stage);
signal.addEventListener("abort", () => resized.disconnect());
// Movement belongs to the preview only; graph dragging and panning remain
@@ -378,6 +395,7 @@ function buildPanel(node) {
}, { signal });
const onImageLoad = () => {
placeBackdrops(state);
state.loaded += 1;
if (state.loaded >= 2) {
setReady(state);
+2
View File
@@ -64,6 +64,8 @@ In the text a link is written `[the words](https://example.com)`, or
- The text is stored in the node's one hidden `text` input, so saving,
undo and copy work like any other node. Nothing else is saved with it.
- A note copied together with the nodes and groups its links name links to
the copies. A link to anything left behind keeps its number.
- In the text, small print is a line that starts with six `#` and a space:
`###### Off, your words go in as typed.` Small print is 62% of the note's
text size and never under 11 px.
+24 -6
View File
@@ -1,15 +1,16 @@
# Image Compare A/B
Shows two images on one panel so differences pop out instead of hiding in a
side-by-side. The first frame of each batch is previewed; `image_a` passes
through the output unchanged, so the node can sit in the middle of a graph
without breaking anything downstream.
side-by-side. The first frame of each batch is previewed; `image_a` and
`image_b` pass through their outputs unchanged, so the node can sit in the
middle of a graph without breaking anything downstream.
## Inputs
- **image_a**: The baseline batch. Its first frame is preview A, and the
whole batch is what the output carries.
- **image_b**: The comparison batch. Its first frame is preview B.
whole batch is what the `image_a` output carries.
- **image_b**: The comparison batch. Its first frame is preview B, and the
whole batch is what the `image_b` output carries.
- **filename** (optional): a name for the files that **Erase** saves, such
as the source picture's file name. Leave it unwired to type the name on
the node.
@@ -28,6 +29,9 @@ picks what the panel does, and its marker slides to your choice:
it and it rubs away, so the other one shows through. Then save the mix.
See **Erase** below.
A picture with see-through parts shows them over a checkerboard, as an
editor does.
The labels ride with the split line: **A** just left of it and **B** just right of
it, on the picture each one names. With one picture showing, its label sits alone
at the top centre. The chosen mode and locked side are
@@ -49,6 +53,11 @@ Use it when you want part of one picture and part of the other: the edit
is good but it changed the hands, so you paint the old hands back. Or you
only want the new sky.
It also brings back a clear background. When the picture underneath has
see-through parts, such as a cut-out the model filled in behind, rub the
top one away there and they come back clear. The saved PNG and PSD keep
them.
Pick **Erase** on the switch. A row of tools opens above the picture:
- **Erase | Restore**: what the brush does. Erase rubs the top picture
@@ -128,9 +137,12 @@ B, the "after" picture. If your graph makes A out of B's picture (B is the
picture you loaded and A is the edited one), the result is A. To keep one
picture up whatever happens, use the **A** or **B** button.
## Output
## Outputs
- **image_a**: The `image_a` batch, untouched.
- **image_b**: The `image_b` batch, untouched: the picture you compared
against, often the edited one. Wire it on when the picture you want to
keep is B.
Previews are written to ComfyUI's temp folder and vanish with it. Nothing
is saved to the output folder until you press **SAVE** in Erase, and no
@@ -150,6 +162,12 @@ network requests are made.
left, and a new run does not change it.
- **Erase works on the first picture of each batch**, at its full size.
The mix is always A's size: a B of another size is resized to fit A.
- **See-through parts** are kept in a preview only when a picture has some,
so solid pictures save exactly as before. The mix lays the top picture
over the other as an editor does. In the PSD each layer keeps its own
see-through parts, and the flat picture carries the mix's, its colours
stored over white the way Photoshop stores them. **Align** measures the
two pictures laid over gray and moves B's see-through parts with it.
- **What is kept is the strokes**, not a painted picture. The panel draws
them for you, and Save draws them again on the server at full size, so
the saved edge is as sharp as the picture however small the node is.
+16 -3
View File
@@ -32,13 +32,20 @@ side of the picture, which is how the pair outpaints.
the paste mask is widened by this many pixels and blurred. The sampling
mask sent to the inpainter stays hard-edged, so feathering never weakens
what the model sees. `0` pastes with the raw mask.
- **Multiple** (`output_multiple`): Crop and target dimensions are rounded
up to a multiple of this so samplers accept them.
- **Multiple** (`output_multiple`): The crop's size is rounded up to a
multiple of this. Keep it at `32` (the default) for Qwen Image 2.1, Flux
and most newer models. Their VAE trims a picture that is not a multiple
of 16 or 32, and the trimmed result, stretched back over the crop, looks
slightly bigger than the original. Stitch Inpaint says so in the console
when that happens.
- **Target width / Target height** (`target_width` / `target_height`, in
the **Target size, extend** fold): Rescale the crop to a fixed size for
the sampler. `0` keeps the native crop size; setting only one dimension
derives the other from the crop's aspect ratio. Explicit values here
override `target_megapixels`.
override `target_megapixels`. When the crop is rescaled, the crop area
takes in a few more pixels of context on one side so it has the exact
shape of the target size, and the picture is scaled the same amount
both ways, never stretched.
### Shaping the mask
@@ -95,6 +102,12 @@ Any inpainting sampler fits between the two nodes the same way — connect
the crop's `image` and `mask` to it, then its output to Stitch Inpaint's
`inpainted` input.
A picture with see-through parts keeps them through the pair. ComfyUI's
Load Image splits them off into its `mask`; wire its `IMAGE` and `MASK`
into **Join Image with Alpha** to put them back on before the crop. The
sampler gets the colours, and Stitch Inpaint gives the result its clear
parts back.
## Technical details
- Starting values: Context `1.2`, Blend `16` px, Multiple `8`; Grow, Blur,
+9
View File
@@ -8,6 +8,11 @@ every node that needs it, and change it in one place.
- **Value** (`value`): The number to send out. It starts at `0`. The box
shows the decimals its steps use (`0.00`) and holds up to three, so a
typed `0.125` stays `0.125`.
- The gear, right: **Look**. **normal** is the usual box with its label.
**big** shows the number large in the middle of the node, with an arrow
on each side, for a setting people change on every run, such as the
length of a clip. A drag and typing work the same. Each node keeps its own look, and it is
saved with the workflow.
## Limits
@@ -44,3 +49,7 @@ into **Math Expression 🆎** instead of retyping derived numbers.
API is passed on as it is.
- Wired only to inputs that share no value (one stops at 1, the other starts
at 2), the box keeps no limits.
- The look is stored in the node's properties as `ausboss_text_look: "big"`, the
same as Text 🆎. It is not an input, so the saved value and what the node
sends are the same in both looks, and an older version of the pack shows
the usual box.
+231
View File
@@ -0,0 +1,231 @@
# H3 Prompt Writer
Type what happens, one line for each moment. Leave a blank line to cut to a new
place. The node writes H3's full prompt for you, and you never see its format.
Use it with MiniMax H3 or Pika H3 Flex. Connect up to four pictures and give each
one a job. Copy the moves from a video, keep a clip going, or add a voice or a
song.
## How to write it
Every line is a moment, and the lines play in order in one shot. The bar under
the box shows the second each one starts, before you spend a run.
```
@1 walks through a crowded night market toward us, looking over her shoulder.
@1 stops and looks up at the lanterns.
@1 stands at the edge of a rooftop in the rain, her back to us.
@1 slowly turns her head toward us.
```
That is two shots in two places. `@1` is Picture 1. With one person you can
write `She` or `He` instead.
The node also fills in two things H3 misses by itself. A moment gets its own
sound when it names something that makes one, like rain, footsteps or thunder.
And what changes stays changed: when the lights go out, the next moments in that
place say they are still out.
## The words box and the bar
The words the node acts on light up as you type: `@1` in teal (amber when
nothing is plugged in there), a time like `3s:` in amber, words in quotes in
yellow, and a blank line shows as a line marked CUT. Hover the `?` beside
"What happens" for a short list of everything you can type.
Typing `@` lists the pictures you plugged in, with their job. The first letters
of a line offer `sound:`, `music:`, `camera:` or `look:`, and after `camera:` or
`look:` you get the node's own choices. Tab picks one, Esc closes the list.
The bar under the box is the clip from start to end, one block for each moment
with the pictures it names:
- **Drag a block** along the bar to move its line. The times and cuts stay
where they are.
- **Drag the left edge** of a block to set when that moment starts. The node
types the time (`3s:`) into its line for you.
- **Click the left edge** to add or take out a cut there.
- **Click a block** to pick out its line in the words, ready to change.
Everything on the bar changes the words, so Ctrl+Z undoes it.
## Give each picture a job
Pick a job for each picture under the words, then name it in your lines with
`@1`, `@2`, `@3` or `@4`.
- **person**: this person goes wherever your lines say. Name the place in the
line, or name none and they stay where the picture shows. Use a clear photo.
- **character sheet**: one character drawn from several sides. Only the
character is used, not the panels.
- **place**: where it happens. Use a picture with nobody in it.
- **outfit**: clothes for your person. Works best with no head in the picture.
- **thing**: an object or a product. Works best with nobody in the picture.
- **style**: only the look is used, for the whole clip.
- **start on it**: the clip starts from this picture and stays in its place.
- **end on it**: the clip lands on this picture. It can't be named in a line.
Some scripts, with the jobs they need:
```
@1 sits at the counter in @2. Picture 2 is a place
@1 and @2 sit side by side on a night bus. Picture 2 is a person
@1 walks down a runway wearing @2. Picture 2 is an outfit
@1 spins slowly on a black glass table. Picture 1 is a thing
```
A picture that is off simply isn't counted. If a line still names it, the node
stops and tells you which one. It needs at least one picture or a video to start
from.
## Use a video
Connect a video, then pick what it is for:
- **copy its moves**: your person does the moves in the video. It needs a
picture of your person. Say where in your lines:
`@1 dances on a rooftop at sunset.` The clothes can come from words or from an
outfit picture.
- **swap the person**: your person takes over from the one in the video. The
place, the moves and the camera stay. You can leave the box empty.
- **keep going**: more of the same clip, from where it ends. Say what happens
next, or leave the box empty and it just carries on. Connect the whole clip:
the node sends H3 only the last 2 seconds.
- **new voice**: the same clip with a new voice. Connect the voice, and type
what they say in quotes.
Swap and new voice ignore **Length** and keep the video's own length.
## What you can type
- **A time**: start a line with `3s:` and it happens at 3 seconds.
- **A cut in the same place**: start the line with a kind of shot, like
`a close-up of her hand`. Any other line after a blank line goes to a new
place.
- **One long take**: type `no cuts` and blank lines stop being cuts.
- **An ending**: start a line with `ends on`, wherever it sits:
`ends on her sitting by the window`.
- **Speech**: put the words in quotes: `@1 says "we are late."` Each person
keeps their own voice.
- **Words on a sign**: name the sign: `a neon sign reads "OPEN"`.
- **Sound and music**: use the **Sound** and **Music** boxes, or type
`sound: rain on a tin roof` and `music: slow sad piano`. With no music you get
none, and the clip comes out quieter.
- **Camera and look**: pick them on the node, or type your own:
`camera: slow push in`, `look: a 1970s film`.
A `camera:` or `sound:` line inside a group of lines belongs to that shot. On its
own, with blank lines around it, it is for the whole clip.
Already wrote H3's six parts yourself? Paste them in and they go through as
they are.
## What you can plug in
- **picture_1** to **picture_4**: your pictures.
- **video** and **video_sound**: the frames and the sound of a video. Only keep
going uses the sound.
- **voice**: a clip of someone talking, about 10 seconds, plus words in quotes
in your script. It goes to whoever speaks first. It works with your own voice,
or one you have permission to use. Put the spoken line last: with time left
after it, H3 tends to keep talking.
- **song**: your music. It is cut to the length of the clip and plays as it is,
and each moment moves onto its beat.
- **clip**: a small text model such as Qwen3-VL 8B. Type one line and it adds
what happens next, and it names the sound of the place. Your line always stays
first.
A voice and a song work together. H3 can't keep a song while someone speaks, so
the node keeps the song out of H3's way and hands it on as **song_under**, turned
down, to mix under the finished clip.
Then wire the node's outputs into H3: **prompt** into its prompt, **frames** into
its length, each **picture** into its next `ref_image`, **video** into `ref_video`,
**video_sound** into `ref_video_audio`, **voice** into its first `ref_audio`,
**song** into the next one. The pictures come out in the order the prompt counts
them, so a picture that is off leaves no gap. **width** and **height** go into
H3's width and height: the clip takes the shape of your video, or else of your
first picture. **song_under** goes into `audio2` of Audio Merge, with the clip's
sound in `audio1`.
Nothing plugged in means nothing is added. So a page can turn each one on and off
with [Workflow Switches](#AUSBOSS_NODES_WorkflowSwitches): a bypassed loader reads
as not plugged in.
## The controls
- **Sound** (`sound`): what the clip sounds like, for the whole clip. Same as a
`sound:` line.
- **Music** (`music`): the music for the whole clip. Same as a `music:` line.
- **Length** (`seconds`): how long the clip is, 5 to 15.
- **Camera** (`camera`): how the camera moves the whole way through.
- **Look** (`look`): from the picture keeps your photo's look. The others redraw
the clip. Clay and 3D don't work from words, so change your picture first with
an edit model.
- **Picture 1** to **Picture 4** (`job_1` to `job_4`): what each picture is
for, on the picture chips under the bar. An empty slot shows as a faint `+ @3`.
- **Video** (`video_job`): what the video is for, on the video chip.
- **Song starts at** (`song_start`, under **More**): the second of the song to
start from, so the clip gets the good part.
- **Moments on the beat** (`cut_on_beat`, under **More**): moves each moment onto
the nearest beat of the song. Off keeps your own seconds.
- **Language** (`language`, under **More**): the language of the words in quotes.
## Tips
- About one moment a second is the most H3 can play. The line under the rows
warns you, and it warns you when a spoken line won't fit its moment.
- Say which way they face, and think about where they end up. If someone walks
away from us, we see their back until a line turns them.
- H3 draws each shot on its own. A cut to a new place hides that. A cut inside one
place shows every gap, so keep a place in one shot and move the camera instead.
- Nothing moves or exists unless a line says so. If the birds should fly, say so
in that shot.
- Say what you would see, not what someone feels. `She smiles` works.
`She feels happy` doesn't.
- A clip came out wrong? Run again with a new seed.
## Slots
| Slot | Direction | Type | Purpose |
| --- | --- | --- | --- |
| `script` | input | STRING | What happens, one line for each moment. |
| `seconds` | input | INT | How long the clip is. |
| `clip` | input, optional | CLIP | A small text model that fills in a one-line idea. |
| `picture_1` to `picture_4` | input, optional | IMAGE | Your pictures. |
| `video` | input, optional | IMAGE | The frames of a video. |
| `video_sound` | input, optional | AUDIO | The video's own sound. |
| `voice` | input, optional | AUDIO | A clip of the voice to use. |
| `song` | input, optional | AUDIO | The music to use. |
| `prompt` | output | STRING | The finished prompt. |
| `picture_1` to `picture_4` | output | IMAGE | Your pictures, in the order the prompt counts them. |
| `video` | output | IMAGE | The video, or only its end to keep going from. |
| `video_sound` | output | AUDIO | The video's sound, when it should reach H3. |
| `voice` | output | AUDIO | The voice, 15 seconds at most. |
| `song` | output | AUDIO | The song, cut to the clip. Empty when a voice is connected too. |
| `song_under` | output | AUDIO | With a voice and a song, the song to mix under the finished clip. |
| `frames` | output | INT | The clip's length in frames. |
| `width`, `height` | output | INT | The shape of your video, or else of your first picture. |
## Technical details
Code does the reading, so nothing typed can be lost or moved. It follows Pika's
and MiniMax's published prompt guides: every picture is defined once, each one
states what is kept and how, and a person who changes clothes or moves to a new
place is marked that way.
A timed shot mark makes H3 jump, whatever the words around it say: in tests 31 of
36 one-take clips jumped within half a second of a mark. So a mark is written only
for a real cut, and the moments of one take are sentences inside one shot. Clips
written that way had no jumps in 4 of 4 tries.
The prompt always tells H3 not to keep the background of a person's picture. With
a place in your lines the clip stayed there to the end on 2 of 2 tries; without
that sentence the picture's street came back halfway through on 2 of 2. With no
place in your lines H3 has nothing else to use, so it stayed in the picture's
place on 2 of 2.
H3's own rewriter runs only on paid services, and the open model reads a prompt as
it is. This node does that job on your machine, with no model needed.
+4 -3
View File
@@ -29,8 +29,9 @@ uses what the node shows.
From top to bottom:
- **Image source**: the pictures in ComfyUI's input folder. The list shows the
picture under the pointer and filters as you type. **Upload** adds one. The
gear holds the node's display options.
picture under the pointer and filters as you type. **Upload** adds one. A
picture ComfyUI made, named like `photo.png [output]`, loads too, as it does
in core Load Image. The gear holds the node's display options.
- **The picture**, with its handles.
- **The size line**: each step that sets the output size, ending with the size
the run gives you.
@@ -175,7 +176,7 @@ to every Image Crop + Rotate + Pad in this browser.
- **image**: the turned, cropped and padded picture. Every frame of an animated
image gets the same framing.
- **mask**: White where the model paints: the padding, the empty corners a turn leaves, and the see-through parts of your picture.
- **stitcher**: Wire to Stitch Inpaint to restore the kept canvas around an outpaint result, blended into the source by **Blend** (32 px unless changed). It follows the final resized canvas.
- **stitcher**: Wire to Stitch Inpaint to restore the kept canvas around an outpaint result, blended into the source by **Blend** (32 px unless changed). It follows the final resized canvas. After a hand crop, set **Paste onto** to original in Stitch Inpaint's gear menu to get your whole picture back with the edit in place.
- **original**: your picture before rotation, crop, padding or resize, with
see-through parts shown as white (your picture there when **Painted area**
is on keep).
+10 -3
View File
@@ -58,7 +58,8 @@ folders. A folder from anywhere else is copied in:
a tile makes it the next.
After the last picture, **At the end** either stops the run with a message
or starts over at the first.
or starts over at the first. With **start over**, Picture goes back to 1 and
the pictures repeat in order for as many runs as you queue.
## The card
@@ -73,7 +74,9 @@ or starts over at the first.
run loads, counted from 1.
- **After a run** (`after_run`): **next** moves Picture on, **stay** keeps
loading the same one, **random** jumps to any of the picked ones.
- **At the end** (`at_the_end`): **stop the run** or **start over**.
- **At the end** (`at_the_end`): after the last picture, **stop the run**
stops with a message, and **start over** goes back to the first and keeps
going.
## The tiles
@@ -104,7 +107,8 @@ or starts over at the first.
There is no setting that changes this.
- The picked pictures are saved in the workflow as a list of names
(`pictures`). Empty means every picture, so a picture added to the folder
later is in too. A name that is no longer in the folder is skipped.
later is in too. A name that is no longer in the folder is skipped. It
takes no wire: the ticks on the node write it.
- Pictures are PNG, JPG, WebP, BMP, GIF (first frame) and TIFF. A photo that
was taken turned comes out upright. Files whose names start with a dot are
skipped.
@@ -116,6 +120,9 @@ or starts over at the first.
their own sizes. They are not resized or stacked into one batch.
- Moving Picture on is done by the editor when a run is queued. A workflow
sent through the API loads exactly the picture `position` names.
- With **start over**, a Picture number past the last picture counts round
from the first. A workflow saved with such a number loads that picture and
moves on from there.
- The node runs again when a picked file is added, removed or rewritten.
- A folder lists up to 5,000 pictures, and a folder that is brought in is
copied up to 3,000.
+9
View File
@@ -8,6 +8,11 @@ wire it to every node that needs it.
- **Value** (`value`): The whole number to send out. It starts at `0`. The
arrows and a drag move it by 1, or click and type. It holds numbers as
big as a seed.
- The gear, right: **Look**. **normal** is the usual box with its label.
**big** shows the number large in the middle of the node, with an arrow
on each side, for a setting people change on every run, such as the
length of a clip. A drag and typing work the same. Each node keeps its own look, and it is
saved with the workflow.
## Limits
@@ -40,3 +45,7 @@ For arithmetic on the way — halving a size, adding a margin — feed this into
- On its own the box goes up to 9,007,199,254,740,991, the largest whole
number a browser holds exactly. It stopped at 2,147,483,647 before, which
most seeds are above.
- The look is stored in the node's properties as `ausboss_text_look: "big"`, the
same as Text 🆎. It is not an input, so the saved value and what the node
sends are the same in both looks, and an older version of the pack shows
the usual box.
+9 -2
View File
@@ -9,7 +9,8 @@ done one at a time, so a long clip does not need more graphics memory.
## Model setup
Place `big-lama.pt` in:
Download [`big-lama.pt`](https://github.com/Sanster/models/releases/download/add_big_lama/big-lama.pt)
and place it in:
```text
ComfyUI/models/lama/big-lama.pt
@@ -20,6 +21,12 @@ refresh the browser or restart ComfyUI so the model list is updated. Until
the file is there, the list still shows `big-lama.pt`, and a run stops with a
message that says where to put it.
The node only opens known copies of this file, whatever you name them: the
download above, or the one from
[fashn-ai/LaMa](https://huggingface.co/fashn-ai/LaMa). A LaMa model file can
run code when it opens, so any other file stops the run with a message and is
never loaded.
## Controls
- **image**: BHWC images or video frames.
@@ -28,7 +35,7 @@ message that says where to put it.
another size is stretched to fit the picture. A picture with no mask
painted on it stops the run with "No mask painted" instead of coming back
unchanged.
- **Model** (`model`): A `.pt` or `.pth` TorchScript checkpoint from `models/lama`.
- **Model** (`model`): A known copy of `big-lama.pt` from `models/lama`.
## Output
+52
View File
@@ -0,0 +1,52 @@
# Load Audio
Loads a sound file and hands on only the part you pick. Use it for a voice
clip, a song or any sound a video model or a save node takes, when you want
one part of the file and not the whole thing.
The node shows the whole file as a wave, so you can see where the loud parts
are. The part that comes out is bright. Drag the white **IN** and **OUT**
handles to set where it starts and stops, drag the bright part to slide it,
or click somewhere else on the wave to move it there. You can also type a time
under the wave, in seconds (`12.5`) or minutes and seconds (`1:02.5`).
Press **▶** to hear only the part that comes out. **LOOP** plays it again and
again.
## Max length
Set **Max length** to the longest sound the next step can take, such as the
length of the video you are making. The start stays where it is, and anything
past the limit turns red with a dashed line where it is cut. Next to OUT,
the node shows how long the sound that comes out is, such as `15.0 s`. It
turns red when part of your pick is cut; hold the mouse over it to see how
much, and click it to move OUT to the cut. Play stops at the cut, so what you
hear is what comes out. `0` means no limit.
Max length can be wired. When the wire comes from a number node such as
Float 🆎, the wave shows the cut for that number; otherwise the length's tooltip says
`max wired` and the run still cuts the sound to the wired length.
## Controls
- **Source** (`audio`): a sound file in ComfyUI's input folder. **Upload**
adds one. Videos are listed as well, for their sound.
- **Max length** (`max_seconds`): the longest the sound may be, in seconds.
`0` means no limit.
- **IN** (`start_seconds`): where the sound starts.
- **OUT** (`end_seconds`): where the sound stops. `0` means the end of the
file.
## Outputs
- `audio`: the part you picked, for any node that takes `AUDIO`.
- `seconds`: how long that sound is.
## Technical details
- Only the picked part is decoded: decoding stops once the window is full,
so a short pick from a long recording stays quick.
- The sound keeps its own sample rate and channels.
- A start past the end of the file stops the run with a message that says so.
- The wave is drawn in the browser from the file itself; nothing extra is
stored with the workflow besides the four values above.
+1 -1
View File
@@ -41,7 +41,7 @@ padding value unchanged.
## Controls
- **Source** (`image`): Choose or upload from ComfyUI's input folder; the list previews the image under the pointer and filters as you type.
- **Source** (`image`): Choose or upload from ComfyUI's input folder; the list previews the image under the pointer and filters as you type. A picture ComfyUI made, named like `photo.png [output]`, loads too, as it does in core Load Image.
- **source_image** (optional socket): Wire an image from another node here
and it is padded instead of the file; the Source row dims while the wire
is connected. The canvas can only show a wired picture after a run, so it
+1
View File
@@ -47,6 +47,7 @@ embedded workflow replaces the whole graph.
## Controls
- **Source** (`video`): A file in the input folder; **Upload** adds new ones.
A video ComfyUI made, named like `clip.mp4 [output]`, loads too.
- **IN** (`start_seconds`): Skip everything before this time.
- **OUT** (`end_seconds`): Stop at this time; `0` means the source end until a
handle is moved.
+83 -4
View File
@@ -29,7 +29,9 @@ come out of one socket, ready to wire into a prompt.
modified date, and trigger words for that LoRA.
Right-click a row to insert an empty LoRA above or below it, move, duplicate,
or remove it. **Remove this LoRA** is red. **Delete all LoRAs…** is a separate red, trash-marked action
or remove it, or to put it in a group (see Groups below). With several rows
picked (Ctrl/Cmd-click or Shift-click), **Remove these LoRAs** removes them
all. **Remove this LoRA** is red. **Delete all LoRAs…** is a separate red, trash-marked action
with confirmation; it clears this node's rows and keeps all files on disk.
Cancel or Escape keeps the stack. Inserting keeps the existing rows and their settings in order;
the new slot uses the gear menu's default strength (initially 1).
@@ -42,6 +44,60 @@ model/CLIP strengths flips it on automatically so the difference stays
visible. Turning it back off sets every row's CLIP strength to its model
strength.
## Groups
Groups keep a long stack tidy, like layer groups in an image editor. A
group has a header with its own on/off switch, so you can turn a whole set
of LoRAs (all your style LoRAs, say) off and on with one click. Each LoRA
in the group keeps its own switch too: turn the group off and back on, and
the same LoRAs are on as before.
- **Make a group:** right-click a row and choose **Group this LoRA**. To
group several, hold **Ctrl** (**Cmd** on a Mac) and click each row first,
or click one and **Shift**-click another to pick every row between them.
Then right-click one of them and choose **Group these LoRAs**. The new
group appears where the topmost row was, and you type its name.
- **Add to a group:** drag a row by its grip onto the group's name, or
between any two of its rows. Right-click → **Move to group** does the
same.
- **Take out of a group:** drag the row above or below the group, or
right-click → **Take out of** the group.
- **Fold:** the arrow next to the group's switch hides its rows and makes
the node shorter. Folded rows still run.
- **Rename:** double-click the group's name.
- Right-click a group's header to move the whole group, fold it, turn all
its LoRAs on or off, ungroup it (the LoRAs stay), or remove it together
with its LoRAs. Drag the header's grip to move the whole group.
- **Turn a group on and off from a Workflow Switch:** save the switch once
with the group on and once with it off. The group's header then shows a
lock, and the switch turns the whole group on and off. The LoRAs inside
stay yours to turn on and off. Click the lock to take the group back.
Right-click the empty space around the rows (or under **+ LoRA**) to add
LoRAs, group the rows you picked, fold or open every group at once, or
delete all LoRAs.
A plain click anywhere clears a Ctrl/Shift selection, and so does Escape.
The count on a header (`2/3 on`) shows how many of its LoRAs are on. While
the group is off it reads `group off`, and its LoRAs are dimmed.
### Technical details
LoRAs still apply in the order you see, top to bottom; a group changes
nothing about how the stack runs. Groups are one level deep, and a group
lasts while it has a LoRA in it. Each row keeps its group's name in the
saved stack, so groups travel with templates, undo, and rows shown on a
subgraph box. Which groups are folded is saved with the node. A group that
is off marks each of its rows with `group_off` in the saved stack: none of
them runs, and each keeps its own on/off. A row dragged into an off group
goes off with it, and a row taken out runs by its own switch again. A
renamed group keeps its on/off and its Workflow Switch. The switch at the
top of the node turns every row and every group on. Move up and Move down step a row out of the edge of
its group, or into a group next to it, one step at a time. While you drag,
the drop line starts indented when the row would join a group. Just below
a group's last row is outside the group; to add a row at a group's end,
drop it on the group's name.
## Rows on a subgraph box
When the loader sits inside a subgraph, its rows can show on the box
@@ -75,6 +131,10 @@ over: the switch forgets that row and leaves it alone from then on, and
stops reading "changed" because of it. Everything else the switch does
stays. A locked row still drags to a new place.
A group that a switch turns on and off shows the lock on its header
instead. The LoRAs in it keep plain grips, so each one is still yours to
turn on or off. Click the header's lock to take the group over.
## Strength bars
Every named row paints a center-zero bar behind its name: teal grows right
@@ -110,7 +170,14 @@ its height on rows, not chrome. Left to right:
**+ LoRA** opens one list of files grouped by folder. Click a file to add
one LoRA, or click a folder name to add all its LoRAs, including nested
folders. Use the arrow beside a folder to collapse or expand it without
folders. To add several files from different folders, tick the box beside
each one (or **Ctrl**-click its name), then click **Add N LoRAs** at the
bottom or press **Enter**. **Shift**-click ticks every file between the
last one you ticked and the one you click. Ticks stay while you search, so you can
gather files from several searches; **Clear** unticks them all. Files that
are already in the stack say **in stack** and have no box. With the
keyboard, **Ctrl+Enter** ticks the highlighted file and **Shift+Enter**
ticks the run up to it. Use the arrow beside a folder to collapse or expand it without
adding anything. Folded folders are remembered in this browser. Search
reveals matching files; clearing it brings back your folded view. You can
also fold folders while searching.
@@ -236,6 +303,14 @@ or the bare `.png` / `.jpg` / `.jpeg` / `.webp`). No sidecar, no thumbnail —
nothing appears and nothing shifts. The gear menu's **Preview thumbnails**
switch turns the behavior off.
**Get the pictures from Civitai:** click **Fetch Civitai info** (or
**Refresh Civitai info**) in a LoRA's info card. It also saves the LoRA's
first picture from Civitai next to the file, and the hover and the info card
show it right away. Gear menu → **Civitai picture** picks which pictures:
**PG-13** (the default) skips pictures Civitai rates R or above, **any**
takes the first picture whatever its rating, and **off** saves none. It never
replaces a picture you already have.
## Trigger words
The info card gathers words from three places: the LoRA file's own metadata,
@@ -252,10 +327,14 @@ switched on for the old one.
- **Fetch Civitai info** looks this one LoRA up on civitai.com. Nothing is
sent until you click. Your browser then sends only the file's SHA256
fingerprint, and the answer is saved next to the LoRA as
`<model>.civitai.info`. After that the button reads **Refresh Civitai
`<model>.civitai.info`, with the LoRA's picture as `<model>.preview.jpg`
(see "Get the pictures from Civitai" above). After that the button reads **Refresh Civitai
info** and the card gets a **View on Civitai** link. A LoRA Civitai does
not know (unpublished or hidden) reads **Not found on Civitai**. Gear
menu → **Civitai lookup button** removes the button.
menu → **Civitai lookup button** removes the button. When a lookup fails,
a message says why. If it always says it could not reach civitai.com,
check whether ComfyUI was started with `--disable-api-nodes`: that setting
stops the browser from reaching any other site, Civitai included.
- **Suggested strength**: a lowest and a highest value at the bottom of the
card (leave one empty for "any"). It is only a reminder: a row set outside
it shows its number in orange, and the tooltip gives the range. Saved per
+4
View File
@@ -208,6 +208,8 @@ nothing instead.
Picks belong to the picture they were made on. Load another picture and the
node shows it with nothing picked, and says the old picks are not used.
If the workflow runs before the node has shown the new picture, the run
stops and says the picks were made on another picture.
- With a Load Image wired straight in, or through Reroute nodes, the
picture shows as soon as you choose a file.
@@ -277,6 +279,8 @@ no browser:
]}
```
- `touches` takes no wire, and neither does `ask` (below): the node's
picture writes them. From the API, send them as values.
- A `paint` touch may carry **`"s"`**, how soft the brush's edge was (1 to
100); without it the brush is hard. A soft brush gives a mask with values
between 0 and 1 along its edge.
+10
View File
@@ -52,6 +52,16 @@ file's name. A caption on the `caption_text` input writes the paired `.txt`.
still lists it. When a run saves more than one image (a batch, or a list
such as a folder of images), arrows beside the switch (‹ 2 / 5 ›) step
through them.
- **Steps** (`step_preview`, off by default): the switch left of
**preview** shows the picture while it is being made, one step at a time,
where the saved image goes, with a **STOP** button on the bar. Stop a run
that is going wrong after the first steps instead of waiting for the end;
a stopped run saves nothing and says so on the picture. Once the image is
saved it replaces the steps. With several samplers, the node shows the one
that feeds it. During an upscale in tiles it keeps the whole picture and
says "upscaling in tiles" instead of showing each tile. Display only: the
saved file is the same with it on or off.
The [Step Preview](AUSBOSS_NODES_StepPreview.md) page has the details.
## Inputs
+7
View File
@@ -9,6 +9,13 @@ duration, shows a tall video whole, and has a **LOOP** control. `gif` and
`webp` are shown as pictures with just the file name, since a video player
cannot play either.
**STEPS** (`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 beside it. Stop a clip that is going wrong after the first steps
instead of waiting for the end; a stopped run saves nothing. The saved
video replaces the steps. The [Step Preview](AUSBOSS_NODES_StepPreview.md)
page has the details.
## Controls
- **frames**: BHWC frame batch to encode. For the subsampled formats, odd
+68
View File
@@ -0,0 +1,68 @@
# Step Preview
Shows the picture while it is being made, one step at a time, with a
**Stop** button. If the picture is going wrong (the pose, the framing, an
extra arm) you can see it after the first few steps and stop the run,
instead of waiting for the last step.
Put it anywhere on the canvas. It needs no wires: it shows whichever sampler
in the workflow is making a picture right now, and the caption under the
picture says how far it is ("step 3 of 8").
## The buttons
- **STOP**: stops this run now. Nothing after the sampler runs, so nothing
is saved. The next run in the queue still starts.
- **STOP ALL · N**: shows when more runs are waiting in the queue. It
empties the queue and stops this run.
As soon as the run saves the picture, the finished picture replaces the
blurry last step, and it stays on the node until the next run starts. The
word at the right of the bar says how the run ended: done, stopped or
failed. A stopped run keeps the last step it reached.
An upscale in tiles works on one small piece of the picture at a time.
Those pieces are not shown as the picture: the node keeps the last whole
picture and says "upscaling in tiles", with the steps of the piece being
worked on.
## Save Image and Save Video can do it too
Save Image 🆎 and Save Video 🆎 have a **steps** switch of their own (off by
default). On, the picture area of the save node shows the steps of the
picture it is about to save, with the same Stop button. Use the save node's
switch when you look at the result anyway, and this node when you want a
bigger view, or one view for a workflow with several samplers.
## Sharper step pictures
The step pictures are small and blurry. That is enough to judge the pose
and the layout. Some models have a small preview decoder (a TAESD file)
that makes them sharper: put it in ComfyUI's `models/vae_approx` folder and
ComfyUI uses it.
## Technical details
- ComfyUI only makes step pictures when its preview method is something
other than `none`, and a fresh install starts with `none`. When a queued
workflow holds this node, or a Save Image or Save Video with its steps
switch on, the pack asks for the `auto` method for that one run.
- It never overrides a choice you made: if you picked a method in
ComfyUI's **Live preview method** setting, or started ComfyUI with
`--preview-method`, that one is used. `--preview-method none` on the
command line is treated as "not chosen", since it is the default.
- `auto` uses the model's TAESD decoder from `models/vae_approx` when it is
installed, and a quick color estimate (latent2rgb) otherwise. Some models
have neither (SeedVR2, for one), and then no step pictures come.
- The pictures are at most 512 px on their long side (`--preview-size`).
They do not change the result: the same seed makes the same picture with
the steps shown or not.
- They cost next to nothing. Measured with Qwen Image 2.1 on one desktop:
8.50 s with the steps off and 8.40 s on for 8 steps, 17.62 s and 17.64 s
for 25 steps, the same picture every time.
- While previews are on, a ComfyUI sampler that sits on the top level of
the workflow (not inside a box) shows the steps on its own face too, as
it does whenever previews are turned on in ComfyUI. The node grows to
hold the picture and keeps the last step after the run.
- Runs queued from another workflow tab are not shown here.
- With video models the step picture is the clip's first frame.
+41
View File
@@ -7,6 +7,22 @@ from **Crop For Inpaint 🆎**, **Load Image + Pad 🆎** or any
result, it is resized to fit first, then joined to your picture the way
**Seam** says.
## Paste onto: crop or original
**Paste onto** picks what you get back. It lives in the gear menu, next to
Seam.
- **crop** (the default) gives back the picture you framed.
- **original** puts the edit on your whole picture, at its full size. Use it
after you crop by hand in **Image Crop + Rotate + Pad 🆎**: the model works
on the crop, and everything you did not paint stays exactly as it was. It
also works when you turned or resized the crop.
Padding has no place on your picture, so anything painted in added space is
left out. With **Crop For Inpaint** and **Load Image + Pad** the result is
your whole picture either way. The video nodes don't have this yet: their
result stays on the crop, and the console says so once.
## Seam: classic or blend in
**Seam** picks how the new area joins your picture. It lives in the gear
@@ -60,6 +76,17 @@ are in the model's own picture.
4n+1 (Wan) frames and drop the tail - so a long run is never thrown away
over the count. More inpainted frames than source frames is still an error.
## Pictures with see-through parts
A picture with a clear background, such as a cut-out, keeps it. Most
models paint colour only, so the new area takes your picture's own
see-through parts where it lands: what was clear stays clear, and the edit
shows where your picture is solid. A model that paints see-through parts
of its own (Qwen Image 2.1 can) has them blended in like the colours.
So with a colour-only model, new things painted into a clear area stay
hidden. Fill that area in first if the edit needs it.
## Controls
- **stitcher**: The stitcher from one of the nodes above. It carries the
@@ -70,6 +97,8 @@ are in the model's own picture.
- **Seam** (`seam`, in the gear menu): `classic` (the default) or
`blend in`. Read "Seam: classic or blend in" above. It has no socket,
so a link cannot drive it.
- **Paste onto** (`paste_onto`, in the gear menu): `crop` (the default) or
`original`. Read "Paste onto: crop or original" above. It has no socket.
- **Fix edge halo** (`fix_edge_halo`, classic seam only): Off by default. Recovers the true
color under the feathered seam before pasting, so half-transparent edge
pixels stop blending their background in a second time. It costs real
@@ -207,6 +236,8 @@ you brighten it first.
## Outputs
- **image**: The original-size image with the inpainted region blended in.
After a crop in a Crop + Rotate + Pad node, that means the crop's size,
unless **Paste onto** is on original.
- **blend_mask**: The feathered paste mask in the stitched image's own
coordinates — white where the inpaint blended in, zero where the original
survived untouched. Wire it into a color-match or compositing node to
@@ -222,6 +253,16 @@ you brighten it first.
The rest of this page is for people who want to know how blend in works
and how it was tested.
### How Paste onto original works
Image Crop + Rotate + Pad's stitcher carries your picture and the crop's
position. A straight crop at its own size is a plain paste, so every pixel
the mask does not touch is bit-identical. A turned or resized crop is sampled
back (bicubic) once per picture pixel, so the edit in those cases is as soft
as the canvas it was painted on. Untouched pixels stay bit-identical there
too, and in the feathered band your full-size picture replaces the canvas's
softer copy of it.
### How the empty check works
The new area is cut into 16 px blocks, and only blocks fully inside it are
@@ -162,6 +162,7 @@ Five optional sockets sit on the left, above the editor:
| `end_frame` | Exclusive source end frame. 0 means the end of the source. Overrides timeline OUT. |
| `fixed_frames` | Exact output frames from IN; the source must be long enough. Connected, it sets the length: OUT, Length and Frames for step aside. |
| `frame_load_cap` | Maximum frames after rate conversion and Every nth, before Frames for. 0 means unlimited. Connected, it sets the length in place of Length. |
| `last_seconds` | Load only this many seconds from the end of the clip, or of the part before OUT. 0 loads all of it. Use it to carry on from a clip: a long clip then costs no more memory than a short one. |
Frame bounds use the source's frame-rate grid, independent of `force_rate`.
The footer labels **Source fps** separately from **Output fps**. A direct
+70 -5
View File
@@ -21,6 +21,36 @@ Groups work too. Every group on the canvas gets a switch of its own, unless
you change that under **Settings**. A group can also be part of a switch you
make: select it together with the nodes.
## Pick between saved settings
Use this when the same node should be set one of a few ways and you want to
pick by name: 8 steps or 20 steps, or which branch to run.
1. Set the node the way the first choice should be, and select it.
2. Press **+ Choice**, type a name and press Enter.
3. Set the node the second way and press **+ Choice** again. You don't need
to select it this time.
The card is now one pill with your choices. Click one and the node goes back
to how you saved it. Only the settings that differ between your choices are
changed, so the rest stay yours. Add a third the same way.
To run one of two branches, wire both into ComfyUI's **If/Else Switch**, where
they meet, and make one choice with its switch at true and one at false. Only
the branch you pick runs. Both branches must end in the same type.
A missing model or picture stops the run even in the branch you didn't pick,
because ComfyUI checks every node that is on. To avoid that, select the nodes
only one choice needs, open that choice's three dots and pick **The selected
run only with this choice**. They switch off while another choice is picked.
Do this for each branch, and leave out any node both branches use.
A card holds either choices or switches, so **+ Choice** shows on a card with
no switches yet, once a node is selected. To have both, use two cards.
To change a choice later, set the node, open the choice's three dots and pick
**Save how it is set now**.
## A switch that changes settings
A switch can also change values instead of turning nodes off. One click can
@@ -45,8 +75,13 @@ does not change keep whatever you give them. The same works for the LoRA
rows on a subgraph box (LoRA Loader's gear, **Rows on the box**): select the
box itself and save its on and off.
A LoRA row a switch sets shows a lock in the LoRA Loader. Click the lock to
take that row over: this switch then leaves the row alone.
A LoRA Loader group works the same way. Save the switch once with the group
on and once with it off, and the switch turns the whole group on and off.
Each LoRA in the group keeps its own on/off, so you can still leave one out
by hand, and a LoRA added to the group later goes with it.
A LoRA row or group a switch sets shows a lock in the LoRA Loader. Click the
lock to take it over: this switch then leaves it alone.
## A switch with two sides
@@ -161,11 +196,19 @@ Open **Settings** at the bottom of the edit view.
from the left and top to bottom inside a column. **title** sorts by title,
with numbers in numeric order (*2 · …* before *10 · …*).
- **One at a time**: switching a row on switches the other rows off. Use it
for alternatives, such as two upscalers.
for alternatives, such as two upscalers. Turn it on while several rows are
on and the top one stays on. A new switch starts off while another row is
on.
- **Show as** (there while One at a time is on): **rows** gives every switch
a row with its own off | on. **one pill** shows them as one pill to pick
from, see above.
## Have an AI agent set it up
An AI agent can add switches for you by writing them into the workflow file.
Point it at `js/docs/agents/workflow_switches.md` in the pack's folder. That
page tells it what to write.
## Technical details
- **What bypass passes on.** A bypassed node, or box, hands on its first
@@ -174,8 +217,14 @@ Open **Settings** at the bottom of the edit view.
no input of that kind (a decode: latent in, picture out) passes nothing,
so the nodes that read it belong in the same switch.
- **A switch finds its nodes by id.** A deleted node drops out of its
switch. A copy of a node is not in the switch, and a pasted copy of the
card switches the same nodes as the original.
switch. A copy of a node is not in the switch. A card copied together with
its nodes and groups switches the copies, so a whole workflow pasted into
a new tab keeps working switches. A card copied on its own switches the
same nodes as the original.
- **"empty" next to a switch** means none of its nodes are in this workflow,
so there is nothing to turn on. A workflow pasted and saved on a version
before 2.20.3 stays this way. Open the original file again, or add nodes
from the switch's menu in the edit view.
- **A node in two switches** is changed by either. The other row then reads
mixed. A node that two parts share needs no switch: ComfyUI only runs what
a result needs.
@@ -212,6 +261,22 @@ Open **Settings** at the bottom of the edit view.
- **One at a time** switches the other rows off, which runs their off sides.
A node that one row runs and another switches off ends up running, and the
row you switch on has the last word.
- **What a choice stores.** One saved value set, with no off side. A choice
changes the settings it holds differently from another choice on its card,
so a third value on one control needs nothing extra. Picking one writes only
its own values, and a node it does not hold stays as it was. A choice can
also hold nodes, the way a switch does: they run while it is picked and are
off otherwise. The node a choice sets can't be one of them, because switching
it off would undo the choice. A value set by
hand that matches no choice leaves none picked. A card with choices keeps
One at a time on, shows one pill and lists no groups, so its Settings are
hidden.
- **When a card settles by itself.** Turning One at a time on, or making a
switch while it is on, leaves one row on when more than one is. A row that
is fully on goes before one that is partly on, then the top row. A new
switch gives way to the rows that were there. Rows that a change to
**Groups** or **Match** adds are left as they are, because the words typed
into Match should not stop nodes. A click on a row settles those.
- **A pill or a list.** Two to four rows are a pill when their names fit on
a card no wider than 400 px. More rows, or longer names, are a list. With
one row the card stays in the row view. Groups listed on the card are
+78
View File
@@ -0,0 +1,78 @@
# AusBoss nodes: a guide for AI agents
Give this page to an AI agent and it can pick the right AusBoss node for a job,
wire it into a workflow, and find the help for every input. It ships with the
pack, so it is also in your ComfyUI install, under
`custom_nodes/<this pack>/js/docs/agents/`.
**If you are the agent:** find the job in the table below, then read that
node's help page before you wire it. Each help page is
`js/docs/<node id>.md` in this pack. ComfyUI shows the same page in its node
help panel.
## Which node for which job
| The job | Node (id) |
|---|---|
| Pick a picture size by shape and megapixels, in place of Empty Latent | Latent Size (`AUSBOSS_NODES_Resolution`) |
| Resize a picture, or snap its size to a multiple | Image Resize (`AUSBOSS_NODES_ImageResize`), Align Image (`AUSBOSS_NODES_AlignImage`) |
| Read a picture's width and height | Image Size (`AUSBOSS_NODES_ImageSize`) |
| Outpaint: load a picture and add empty space around it | Load Image + Pad (`AUSBOSS_NODES_LoadImagePad`) |
| Turn, crop and pad a picture in one node | Image Crop + Rotate + Pad (`AUSBOSS_NODES_ImageCropRotatePad`) |
| Inpaint only the masked part, then put it back | Crop For Inpaint (`AUSBOSS_NODES_CropForInpaint`) then Stitch Inpaint (`AUSBOSS_NODES_StitchInpaint`) |
| Remove a watermark or an object with no prompt | LaMa Inpaint (`AUSBOSS_NODES_LaMaInpaint`) |
| Get a mask by typing what to find | Mask by Name (`AUSBOSS_NODES_MaskByName`) |
| Get a mask by clicking the thing | Mask by Touch (`AUSBOSS_NODES_MaskByTouch`) |
| Grow, shrink, fill or smooth a mask | Mask Refine (`AUSBOSS_NODES_RefineMask`) |
| Line an edited picture back up with the original | Realign to Source (`AUSBOSS_NODES_RealignToSource`, experimental) |
| Match one picture's colors to another | Color Match (`AUSBOSS_NODES_ColorMatch`) |
| Upscale big pictures in tiles | Tiled Upscale (`AUSBOSS_NODES_TiledUpscale`) then Tiled Upscale Stitch (`AUSBOSS_NODES_TiledUpscaleStitch`) |
| Compare two pictures | Image Compare A/B (`AUSBOSS_NODES_Compare`) |
| Save pictures (PNG, lossless WebP or JPEG XL) | Save Image (`AUSBOSS_NODES_SaveImage`) |
| Run a workflow on each picture in a folder | Image Folder (`AUSBOSS_NODES_ImageFolder`) |
| Apply a stack of LoRAs | LoRA Loader (`AUSBOSS_NODES_LoraLoader`) |
| Krea 2 prompt, and Krea 2 outpainting | Krea 2 Encode (`AUSBOSS_NODES_Krea2Encode`), Krea 2 Outpaint Model Patch (`AUSBOSS_NODES_Krea2OutpaintModelPatch`) |
| Load or save a video | Load Video (`AUSBOSS_NODES_LoadVideo`), Save Video (`AUSBOSS_NODES_SaveVideo`) |
| Load a sound file and keep only part of it, up to a set length | Load Audio (`AUSBOSS_NODES_LoadAudio`) |
| Turn, crop and pad a video: one frame, or the whole clip | Video Crop + Rotate + Pad → Frame (`AUSBOSS_NODES_VideoCropRotatePad`), → Clip (`AUSBOSS_NODES_VideoCropRotatePadClip`) |
| Change a clip's frame rate | Frame Interpolate (`AUSBOSS_NODES_FrameInterpolate`) |
| Work with frame batches | Select Frame, Select Every Nth, Split Batch, Merge Batches (`AUSBOSS_NODES_SelectFrame`, `_SelectEveryNth`, `_SplitBatch`, `_MergeBatches`) |
| One shared seed, number or text | Seed, Integer, Float, Text (`AUSBOSS_NODES_Seed`, `_Integer`, `_Float`, `_Text`) |
| Do maths on numbers | Math Expression (`AUSBOSS_NODES_MathExpression`) |
| Show text on the node, or the picture while it is made | Show Text (`AUSBOSS_NODES_ShowText`), Step Preview (`AUSBOSS_NODES_StepPreview`) |
| Free graphics memory between steps | Free Memory (`AUSBOSS_NODES_FreeMemory`) |
| Time the whole run | Run Timer (`AUSBOSS_NODES_RunTimer`) |
| Turn parts of a workflow off and on | Workflow Switches (`AUSBOSS_NODES_WorkflowSwitches`): [its own agent guide](workflow_switches.md) |
| Explain a shared workflow on the canvas | Workflow Note (`AUSBOSS_NODES_WorkflowNote`), Callout (`AUSBOSS_NODES_Callout`) |
| Lay out boxes of things and words on a canvas | Layout Boxes (`AUSBOSS_NODES_LayoutBoxes`) |
Every node's display name ends in 🆎 and sits under `🆎 AusBoss/<group>` in
the node menu. In a workflow file and in the API, a node is named by its id,
the `AUSBOSS_NODES_...` key above.
## Before you edit a workflow
- **Read settings by name.** A saved workflow keeps each node's settings
twice: by position in `widgets_values`, and by name in
`widgets_values_named`. Read and write by name, and keep both copies in
step.
- **Look at a node's inputs and outputs** in the running ComfyUI:
`GET /object_info/<node id>` lists them with their types and defaults.
- **Inputs never get renamed or reordered** after a release, so a workflow
that worked keeps working.
- **Media loaders start blank** in the example workflows. Put in the file the
person wants to use before you queue.
## Example workflows
`example_workflows/` in this pack holds complete workflows, each with a
picture of what it makes. Each one starts with a Workflow Note that lists the
model files it needs and where they go. Start from the closest one rather
than building from nothing.
## When something goes wrong
- Read the node's help page again, including its "Technical details" or "Notes" part when it has one.
- Search the pack's GitHub issues: https://github.com/ausboss/ComfyUI-AusBoss/issues
- A new bug report should name the ComfyUI version, the pack version from
the console banner, the node, and the smallest workflow that shows it.
+191
View File
@@ -0,0 +1,191 @@
# Workflow Switches: a guide for AI agents
Give this page to an AI agent and it can add switches to a ComfyUI workflow
for you. Nobody has to click through menus: the agent writes the card straight
into the workflow file.
**If you are the agent:** the card is one node in the workflow file. It has no
wires. It lists which nodes each switch controls, in one property. Pick the
recipe below that fits, add the card, and set the nodes' starting state.
## Before you start
- **Edit the file ComfyUI saves with Save**, the one with a `nodes` list. The
card never reaches a run, so it has no place in the API format. For an API
prompt, leave out what should not run.
- **Use the `id` of each node** in `nodes`, and of each box in `groups`.
- **Stay on one level.** The card controls nodes on its own level of the
graph. From the main graph it can switch a whole subgraph node, or change a
control that subgraph node shows, but not reach a node inside it.
- **Read a control's name** from the node's `widgets_values_named` in the file,
or from `/object_info/<node type>` on the ComfyUI server.
## Add the card
Add this to `nodes`. Give it an `id` no other node has, and raise
`last_node_id` if it is lower than that `id`. Pick a free `pos` so it covers
nothing, and the next free number for `order`.
```json
{
"id": 50,
"type": "AUSBOSS_NODES_WorkflowSwitches",
"pos": [0, 0],
"size": [320, 100],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"title": "Workflow Switches",
"properties": {
"cnr_id": "ausboss-nodes",
"ausboss_show_edit": false,
"ausboss_workflow_switches": {
"off": "bypass",
"groups": "none",
"exclusive": false,
"show": "rows",
"switches": []
}
},
"widgets_values": []
}
```
Then fill `switches` from a recipe. Keep `"groups": "none"`: the default,
`"all"`, gives every group on the canvas a row of its own.
`show` is `"rows"`, one off | on row per switch, or `"pill"`, one pill to pick
from. A row's `id` is a whole number from 1, new on its card only. A workflow
can have several cards.
## Set the starting state
The card remembers nothing about what is on. It reads each node's own `mode`,
so the file decides how the workflow opens.
| `mode` | What the node does |
| --- | --- |
| `0` | Runs |
| `4` | Bypassed: passes its input on, and ComfyUI doesn't check it |
| `2` | Muted: passes nothing on, and ComfyUI doesn't check it |
For a part that opens switched off, set `"mode": 4` on its nodes, Save Image
nodes included. With `"off": "mute"` on the card, use `2`.
To change a value in the file, change it in both `widgets_values` and
`widgets_values_named`. The two have to agree.
## Recipes
### An optional part
One row that turns some nodes off and on: an upscale at the end, say.
```json
"switches": [
{ "id": 1, "title": "Upscale", "nodes": [12, 13], "groups": [] }
]
```
To switch a whole group of nodes, put the group's `id` in `groups`.
### Pick one part
One pill: picking a name turns its nodes on and the other rows' nodes off. Open
it with one row's nodes at `0` and the rest at `4`. Up to 4 names sit in a
pill, more become a list.
```json
"exclusive": true,
"show": "pill",
"switches": [
{ "id": 1, "title": "Photo", "nodes": [20, 21], "groups": [] },
{ "id": 2, "title": "Drawing", "nodes": [22], "groups": [] }
]
```
A node both parts use goes in neither row.
### One switch that changes settings
The row sets values instead of turning nodes off. `on` and `off` are keyed by
node id, as text, then by control name. Give every node both sides.
```json
"switches": [
{
"id": 1,
"title": "Fast",
"nodes": [],
"groups": [],
"on": { "5": { "steps": 8 } },
"off": { "5": { "steps": 20 } }
}
]
```
Only values that differ between the two sides change. Values are text, numbers
or true/false.
A LoRA Loader 🆎 keeps its rows in one text value, `loras`. Save the whole text
on both sides with only the row's `enabled` different, and the switch moves
that row alone.
### Named choices
The simplest way to pick between settings, and to run one of two branches. Each
choice is one way of setting a node, so it has `on` and no `off`. A card of
choices is always one pill, so give other switches a card of their own.
To run one of two branches, wire both into ComfyUI's If/Else Switch (type
`ComfySwitchNode`, control `switch`, inputs `on_true` and `on_false`):
```json
"exclusive": true,
"show": "pill",
"switches": [
{ "id": 1, "title": "Soft", "choice": true, "nodes": [3], "groups": [], "on": { "6": { "switch": true } } },
{ "id": 2, "title": "Big", "choice": true, "nodes": [4, 5], "groups": [], "on": { "6": { "switch": false } } }
]
```
Here node 6 is the If/Else, and each choice's `nodes` are the nodes only that
branch needs. Open the file with the If/Else set to the picked choice, that
choice's nodes at `0` and the others' at `4`.
List a branch's nodes every time. ComfyUI checks every node that is on before
it runs anything, so a missing model in the branch nobody picked stops the run
unless its nodes are off.
Keep these out of a choice's `nodes`: the node the choice sets, because
switching it off would undo the choice, and any node both branches use. Both
branches must end in the same type, such as a picture.
### A row with two sides
`nodes_off` holds nodes that run only while the row is off. Use it when turning
one thing on should turn another off.
```json
{ "id": 1, "title": "Edit a picture", "nodes": [7], "groups": [], "nodes_off": [8] }
```
Open it with one side at `0` and the other at `4`.
## Check your work
Open the workflow in ComfyUI, then:
- **A row says "empty":** none of its ids match a node on the card's level.
- **A row says "mixed", or a pill shows no pick:** the saved `mode`s or values
don't match any one row. Fix the starting state.
- **A row does nothing:** a value's control name is wrong, or both sides hold
the same value.
- **A choice says "needs one more":** no other choice differs from it.
## Limits
A card holds up to 40 rows. A title is up to 60 characters, and a row up to
400 nodes. A row can set values on up to 40 nodes.
+1 -1
View File
@@ -725,7 +725,7 @@ function buildPanel(node) {
const now = settings(node);
if (now.run !== "one per run" || isLinked(node, "position")) return;
const count = pickedNames(parsePicked(now.pictures), state.names).length;
const next = nextPosition(now.position, count, now.afterRun);
const next = nextPosition(now.position, count, now.afterRun, now.atTheEnd);
if (next !== now.position) { position.value = next; position.callback?.(next); }
});
}
+162 -6
View File
@@ -9,6 +9,10 @@
//
// The panel is fully inert (pointer-events: none) so clicks fall through, the
// node stays draggable from its body, and wheel zoom belongs to the graph.
//
// Save Image can also show the picture while it is being made: its `steps`
// switch lays each sampling step over the stage, with a Stop button on the
// bar (shared/step_preview.mjs).
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
@@ -21,6 +25,7 @@ import { autoMaskValues } from "../shared/mask_auto.mjs";
import { hideInputsInDef, hideWidget, setWidgetVisible } from "../shared/widget_visibility.mjs";
import { makePictureStepper } from "../shared/picture_stepper.mjs";
import { keepIndex, stepIndex } from "../shared/picture_steps.mjs";
import { addStepConsumer, mountStepOverlay, showsStepsOn, stepRun, stopStepRun, watchSteps } from "../shared/step_preview.mjs";
import {
describeNodePreview,
describeSourcePreview,
@@ -107,7 +112,7 @@ const NODE_CONFIG = {
},
AUSBOSS_NODES_SelectFrame: { inputName: "frames", noun: "frames" },
// Its result is the file it just saved; the card sits above the panel.
AUSBOSS_NODES_SaveImage: { inputName: "images", noun: "an image", switchTitle: "Show the saved image here. The file is saved either way." },
AUSBOSS_NODES_SaveImage: { inputName: "images", noun: "an image", switchTitle: "Show the saved image here. The file is saved either way.", steps: true },
};
// The picture sits out of flow, centred in the stage, so it can never set
@@ -138,6 +143,10 @@ function ensureCss() {
.ausboss-input-preview-tool:hover{border-color:${BRAND};color:#fff;background:rgba(0,79,75,.78);}
.ausboss-input-preview-tool.active{border-color:${BRAND};color:${BRAND};}
.ausboss-input-preview-toggle{display:flex;align-items:center;gap:6px;margin-left:auto;pointer-events:auto;cursor:pointer;user-select:none;}
.ausboss-input-preview:not(.preview-off) .ausboss-input-preview-toggle.steps + .ausboss-input-preview-toggle{margin-left:0;}
.ausboss-input-preview.preview-off .ausboss-input-preview-toggle.steps{display:none;}
.ausboss-input-preview-tool.ausboss-steps-stop{visibility:hidden;}
.ausboss-input-preview-tool.ausboss-steps-stop.live{visibility:visible;pointer-events:auto;}
.ausboss-input-preview-toggle span{color:#5f7674;font:10px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.08em;text-transform:uppercase;}
.ausboss-input-preview-switch{box-sizing:border-box;width:24px;height:13px;border-radius:7px;border:none;background:#3a4047;position:relative;cursor:pointer;padding:0;flex:none;pointer-events:auto}
.ausboss-input-preview-switch::after{content:"";position:absolute;top:2px;left:2px;width:9px;height:9px;border-radius:50%;background:#9ba2aa;transition:left .12s}
@@ -325,6 +334,131 @@ function installPreviewSwitch(state, signal) {
syncPreviewMode(state, false);
}
// The steps switch, left of the preview switch: the hidden `step_preview`
// widget. On, the stage shows each step of the picture while it is being
// made and the bar gets a Stop button for the run. Off (the default), the
// node is exactly as it was. It goes away with the preview, which is where
// the steps would show.
function stepsEnabled(state) {
return state.stepsWidget?.value === true && previewEnabled(state);
}
function installStepsSwitch(state, signal) {
const widget = widgetByName(state.node, "step_preview");
if (!widget) return;
state.stepsWidget = widget;
hideWidget(widget);
const toggle = document.createElement("div");
toggle.className = "ausboss-input-preview-toggle steps";
toggle.title = "Show the picture here while it is being made, one step at a time, with a Stop button. Off by default.";
const caption = document.createElement("span");
caption.textContent = "steps";
const button = document.createElement("button");
button.type = "button";
button.className = "ausboss-input-preview-switch";
button.setAttribute("role", "switch");
toggle.append(caption, button);
toggle.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
toggle.addEventListener("click", (event) => {
event.preventDefault(); event.stopPropagation();
const next = widget.value !== true;
widget.value = next;
widget.callback?.(next);
syncSteps(state);
notifyAusbossChange();
}, { signal });
state.stepsButton = button;
const previewToggle = state.switchButton?.parentElement;
if (previewToggle?.parentElement === state.bar) state.bar.insertBefore(toggle, previewToggle);
else state.bar.append(toggle);
// Stop sits where the node's tools go; it keeps its place while hidden so
// the bar never shifts when a run starts.
const stop = document.createElement("button");
stop.type = "button";
stop.className = "ausboss-input-preview-tool ausboss-steps-stop";
stop.textContent = "STOP";
stop.title = "Stop this run now. The next run in the queue still starts.";
stop.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
stop.addEventListener("click", (event) => {
event.preventDefault(); event.stopPropagation();
Promise.resolve(stopStepRun()).catch(() => toast(state, "Could not stop the run."));
}, { signal });
state.stopButton = stop;
state.bar.prepend(stop);
state.stepOverlay = mountStepOverlay(state.stage);
state.forgetSteps = [
addStepConsumer(state.node, () => stepsEnabled(state)),
watchSteps((run) => syncSteps(state, run)),
];
chainCallback(state.node, "onConfigure", () => queueMicrotask(() => {
if (!state.alive) return;
hideWidget(widget);
syncSteps(state);
}));
}
// Draw the run on this node: its steps while they feed it, where it stopped
// if it never got to save, and the saved picture again once it has.
function syncSteps(state, run = stepRun()) {
const overlay = state.stepOverlay;
if (!overlay) return;
// An empty stage says when the run is on its way, not to start one.
if (typeof state.placeholder === "boolean") state.hint.textContent = placeholderText(state.placeholder, state.noun, runningFor(state, run));
const on = stepsEnabled(state);
if (state.stepsButton) {
state.stepsButton.classList.toggle("on", state.stepsWidget?.value === true);
state.stepsButton.setAttribute("aria-checked", String(state.stepsWidget?.value === true));
}
state.stopButton?.classList.toggle("live", on && run.status === "running");
if (!on) {
overlay.hide();
return;
}
const thisRun = (id) => id !== null && id !== undefined && String(id) === String(run.promptId);
if (run.status === "running") {
// Saved already in this run: its own picture stays.
if (thisRun(state.stepsSaved)) return;
if (run.url && showsStepsOn(state.node, run)) {
state.stepsShown = run;
overlay.show(run, { saves: true });
} else if (!thisRun(state.stepsShown?.promptId)) {
overlay.hide();
}
return;
}
if (run.status === "stopped" && thisRun(state.stepsShown?.promptId) && !thisRun(state.stepsSaved)) {
overlay.show({ ...state.stepsShown, status: "stopped" }, { saves: true });
return;
}
// The last step stays up until the saved picture has loaded under it.
if (thisRun(state.stepsHeld)) return;
overlay.hide();
}
// What a run on its way says on an empty stage: false when none is.
function runningFor(state, run = stepRun()) {
if (!state.stepOverlay || run.status !== "running") return false;
return stepsEnabled(state) ? "steps" : "result";
}
// Saved while the steps were up: hold them until the stage has its picture,
// or a few seconds at most.
const HOLD_MS = 4000;
function holdSteps(state) {
clearTimeout(state.holdTimer);
state.stepsHeld = stepRun().promptId;
state.holdTimer = setTimeout(() => releaseSteps(state), HOLD_MS);
}
function releaseSteps(state) {
if (state.stepsHeld === null || state.stepsHeld === undefined) return;
clearTimeout(state.holdTimer);
state.stepsHeld = null;
syncSteps(state);
}
function syncPreviewMode(state, resize) {
const enabled = previewEnabled(state);
state.root.classList.toggle("preview-off", !enabled);
@@ -355,6 +489,7 @@ function syncPreviewMode(state, resize) {
node.setDirtyCanvas?.(true, true);
}
if (enabled && state.alive) scheduleRefresh(state);
syncSteps(state);
}
function buildTools(state, tools, signal) {
@@ -383,11 +518,21 @@ function buildTools(state, tools, signal) {
function showHint(state, text) {
state.stage.classList.remove("show-image", "show-video");
state.hint.textContent = text;
state.placeholder = null;
releaseSteps(state);
}
// The placeholder, which changes with the run (syncSteps).
function showPlaceholder(state, connected) {
showHint(state, placeholderText(connected, state.noun, runningFor(state)));
state.placeholder = connected;
}
function showMedia(state, kind) {
state.stage.classList.toggle("show-image", kind === "image");
state.stage.classList.toggle("show-video", kind === "video");
state.placeholder = null;
releaseSteps(state);
}
function clearMedia(state) {
@@ -487,7 +632,7 @@ function refresh(state) {
: describeNodePreview(state.node, state.inputName, storedResultUrl(state.node));
if (!described) {
clearMedia(state);
showHint(state, placeholderText(!!source, state.noun));
showPlaceholder(state, !!source);
return;
}
const url = described.kind === "url"
@@ -610,6 +755,8 @@ function buildPanel(node, config) {
previewWidget,
index: 0,
stepper: null,
// Whether the stage shows the placeholder (true: something is connected).
placeholder: null,
};
livePanels.add(state);
if (config.tools?.length) bar.append(buildTools(state, config.tools, abort.signal));
@@ -629,18 +776,19 @@ function buildPanel(node, config) {
bar.append(state.stepper.root);
syncAdvanced(state);
installPreviewSwitch(state, abort.signal);
if (config.steps) installStepsSwitch(state, abort.signal);
img.addEventListener("load", () => showMedia(state, "image"));
img.addEventListener("error", () => {
if (img.getAttribute("src")) showHint(state, placeholderText(true, state.noun));
if (img.getAttribute("src")) showPlaceholder(state, true);
});
video.addEventListener("loadeddata", () => showMedia(state, "video"));
video.addEventListener("error", () => {
if (video.getAttribute("src")) showHint(state, placeholderText(true, state.noun));
if (video.getAttribute("src")) showPlaceholder(state, true);
});
watchOwnResult(node, state);
showHint(state, placeholderText(false, state.noun));
showPlaceholder(state, false);
scheduleRefresh(state);
return state;
}
@@ -691,7 +839,7 @@ app.registerExtension({
beforeRegisterNodeDef(nodeType, nodeData) {
const config = NODE_CONFIG[nodeData?.name];
if (!config) return;
hideInputsInDef(nodeData, ["preview"]);
hideInputsInDef(nodeData, ["preview", "step_preview"]);
chainCallback(nodeType.prototype, "onNodeCreated", function () {
buildPanel(this, config);
});
@@ -716,6 +864,12 @@ app.registerExtension({
const state = buildPanel(this, config);
// A result arrived (Save Image sends one even with its preview off).
if (state) { state.stale = false; state.resultGone = false; scheduleRefresh(state); }
// Saved: the file's picture replaces the steps.
if (state?.stepOverlay) {
state.stepsSaved = stepRun().promptId;
if (state.stepOverlay.shown) holdSteps(state);
syncSteps(state);
}
if (state && config.readout) {
setNote(state, "");
const readout = config.readout(output);
@@ -733,6 +887,8 @@ app.registerExtension({
state.abort?.abort();
unwatchSource(state);
clearMedia(state);
for (const forget of state.forgetSteps ?? []) forget();
clearTimeout(state.holdTimer);
this.__ausbossInputPreview = null;
});
},
+528
View File
@@ -0,0 +1,528 @@
import { app } from "/scripts/app.js";
import { BRAND, notifyAusbossChange, showToast } from "../shared/index.mjs";
import { enoughFor, lineUp, matchWidth, sameSize, sharedSides, spreadEvenly } from "../shared/line_up.mjs";
import { outlineOf } from "../shared/outline.mjs";
// Line up: with two or more nodes or groups selected, ComfyUI's selection
// toolbar gets one button that pops out a small panel. The panel lines the
// selection up, gives it one width or height, or spaces it evenly. Every tool
// is also a command, so it can be found in the command palette and given a key.
//
// Resize together: with two or more nodes selected, pulling the edge or corner
// of one resizes them all, in both looks. A column shares the width, a row
// shares the height, and a grid shares both.
//
// A node inside a selected group moves with its group, the way a drag moves
// it. Pinned items count when the selection is measured but never move.
const PANEL = "AusBoss.LineUp.Panel";
const SETTING = "AusBoss.Canvas.LineUpButton";
const TOGETHER_SETTING = "AusBoss.Canvas.ResizeTogether";
const ICON_CLASS = "ausboss-line-up-icon";
const TOOLS = [
{ kind: "lineUp", how: "left", id: "AusBoss.LineUp.Left", label: "Line up left edges" },
{ kind: "lineUp", how: "middleX", id: "AusBoss.LineUp.MiddleX", label: "Line up middles in a column" },
{ kind: "lineUp", how: "right", id: "AusBoss.LineUp.Right", label: "Line up right edges" },
{ kind: "lineUp", how: "top", id: "AusBoss.LineUp.Top", label: "Line up top edges" },
{ kind: "lineUp", how: "middleY", id: "AusBoss.LineUp.MiddleY", label: "Line up middles in a row" },
{ kind: "lineUp", how: "bottom", id: "AusBoss.LineUp.Bottom", label: "Line up bottom edges" },
{ kind: "size", how: "widest", id: "AusBoss.LineUp.Widest", label: "Same width as the widest" },
{ kind: "size", how: "narrowest", id: "AusBoss.LineUp.Narrowest", label: "Same width as the narrowest" },
{ kind: "size", how: "tallest", id: "AusBoss.LineUp.Tallest", label: "Same height as the tallest" },
{ kind: "size", how: "shortest", id: "AusBoss.LineUp.Shortest", label: "Same height as the shortest" },
{ kind: "spacing", how: "across", id: "AusBoss.LineUp.SpaceAcross", label: "Even spacing across" },
{ kind: "spacing", how: "down", id: "AusBoss.LineUp.SpaceDown", label: "Even spacing down" },
];
const SECTIONS = [
{ title: "Line up", kind: "lineUp" },
{ title: "Same size", kind: "size" },
{ title: "Even spacing", kind: "spacing" },
];
// ---------------------------------------------------------------------------
// Icons: grey boxes in the text color, and the line or arrows they line up on
// in the pack's teal, the color of the snap lines.
// ---------------------------------------------------------------------------
const box = (x, y, w, h) => ({ x, y, w, h });
const turn = (parts) => ({
boxes: parts.boxes.map(({ x, y, w, h }) => box(y, x, h, w)),
lines: parts.lines.map(([x1, y1, x2, y2]) => [y1, x1, y2, x2]),
heads: parts.heads.map(([x, y, dir]) => [y, x, { l: "u", r: "d", u: "l", d: "r" }[dir]]),
});
const WIDEST = {
boxes: [box(3, 4, 18, 5), box(8, 15, 8, 5)],
lines: [[7, 12, 3.5, 12], [17, 12, 20.5, 12]],
heads: [[3.5, 12, "l"], [20.5, 12, "r"]],
};
const NARROWEST = {
boxes: [box(8, 4, 8, 5), box(3, 15, 18, 5)],
lines: [[3, 12, 7, 12], [21, 12, 17, 12]],
heads: [[7, 12, "r"], [17, 12, "l"]],
};
const ACROSS = {
boxes: [box(2, 7, 4.5, 10), box(9.75, 7, 4.5, 10), box(17.5, 7, 4.5, 10)],
lines: [[7.5, 12, 8.75, 12], [15.25, 12, 16.5, 12], [7.5, 9.5, 7.5, 14.5], [8.75, 9.5, 8.75, 14.5], [15.25, 9.5, 15.25, 14.5], [16.5, 9.5, 16.5, 14.5]],
heads: [],
};
const ICONS = {
left: { boxes: [box(7, 5, 13, 5), box(7, 14, 8, 5)], lines: [[4, 3, 4, 21]], heads: [] },
middleX: { boxes: [box(5, 5, 14, 5), box(8, 14, 8, 5)], lines: [[12, 3, 12, 21]], heads: [] },
right: { boxes: [box(4, 5, 13, 5), box(9, 14, 8, 5)], lines: [[20, 3, 20, 21]], heads: [] },
top: { boxes: [box(5, 7, 5, 13), box(14, 7, 5, 8)], lines: [[3, 4, 21, 4]], heads: [] },
middleY: { boxes: [box(5, 5, 5, 14), box(14, 8, 5, 8)], lines: [[3, 12, 21, 12]], heads: [] },
bottom: { boxes: [box(5, 4, 5, 13), box(14, 9, 5, 8)], lines: [[3, 20, 21, 20]], heads: [] },
widest: WIDEST,
narrowest: NARROWEST,
tallest: turn(WIDEST),
shortest: turn(NARROWEST),
across: ACROSS,
down: turn(ACROSS),
};
function headPath([x, y, dir]) {
const s = 2.6;
const points = {
l: [[x + s, y - s], [x, y], [x + s, y + s]],
r: [[x - s, y - s], [x, y], [x - s, y + s]],
u: [[x - s, y + s], [x, y], [x + s, y + s]],
d: [[x - s, y - s], [x, y], [x + s, y - s]],
}[dir];
return `M${points.map((p) => p.join(" ")).join(" L")}`;
}
function boxesSvg(parts, color, fillOpacity) {
return parts.boxes
.map(({ x, y, w, h }) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="1.4" fill="${color}" fill-opacity="${fillOpacity}" stroke="${color}" stroke-width="1.5"/>`)
.join("");
}
function marksSvg(parts, color) {
const lines = parts.lines.map(([x1, y1, x2, y2]) => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"/>`).join("");
const heads = parts.heads.map((head) => `<path d="${headPath(head)}" fill="none"/>`).join("");
return `<g stroke="${color}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${lines}${heads}</g>`;
}
function iconSvg(how) {
const parts = ICONS[how];
return `<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">${boxesSvg(parts, "currentColor", 0.2)}${marksSvg(parts, BRAND)}</svg>`;
}
// The toolbar button is an <i> with a class, so its icon is drawn in CSS:
// the boxes take the button's text color, the line stays teal.
function maskUrl(body) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">${body}</svg>`;
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
}
function styleSheet() {
const parts = ICONS.left;
const boxes = maskUrl(boxesSvg(parts, "black", 0.25));
const marks = maskUrl(marksSvg(parts, "black"));
return `
.${ICON_CLASS} { position: relative; display: inline-block; width: 1rem; height: 1rem; }
.${ICON_CLASS}::before, .${ICON_CLASS}::after {
content: ""; position: absolute; inset: 0;
-webkit-mask: var(--ausboss-mask) center / contain no-repeat; mask: var(--ausboss-mask) center / contain no-repeat;
}
.${ICON_CLASS}::before { --ausboss-mask: ${boxes}; background: currentColor; }
.${ICON_CLASS}::after { --ausboss-mask: ${marks}; background: ${BRAND}; }
.ausboss-line-up {
position: fixed; z-index: 1000; padding: 6px 6px 4px; border-radius: 10px;
background: var(--interface-panel-surface, #1f2225); color: var(--base-foreground, #e6edf3);
border: 1px solid var(--interface-stroke, #3a3f44); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
font-size: 11px; line-height: 1.2; user-select: none;
}
.ausboss-line-up-title { margin: 2px 6px 3px; color: var(--muted-foreground, #8b949e); }
.ausboss-line-up-row { display: flex; gap: 2px; }
.ausboss-line-up-rule { height: 1px; margin: 5px 2px; background: var(--interface-stroke, #30363d); }
.ausboss-line-up button {
display: grid; place-items: center; width: 30px; height: 30px; padding: 0;
border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer;
}
.ausboss-line-up button:hover:not(:disabled) { background: color-mix(in srgb, ${BRAND} 18%, transparent); }
.ausboss-line-up button:focus-visible { outline: 1px solid ${BRAND}; }
.ausboss-line-up button:disabled { opacity: 0.35; cursor: default; }
`;
}
// ---------------------------------------------------------------------------
// The selection
// ---------------------------------------------------------------------------
function graphSets(graph) {
return {
nodes: new Set(graph?._nodes ?? graph?.nodes ?? []),
groups: new Set(graph?._groups ?? graph?.groups ?? []),
};
}
function pickedCount(canvas) {
const { nodes, groups } = graphSets(canvas?.graph);
let count = 0;
for (const item of canvas?.selectedItems ?? []) if (nodes.has(item) || groups.has(item)) count++;
return count;
}
// The selected nodes and groups that move on their own, each with the
// outline people see. A node or group inside a selected group is left out:
// it rides along with that group.
function selectionUnits(canvas) {
const { nodes, groups } = graphSets(canvas?.graph);
const picked = [...(canvas?.selectedItems ?? [])].filter((item) => nodes.has(item) || groups.has(item));
const carried = new Set();
for (const item of picked) {
if (!groups.has(item)) continue;
item.recomputeInsideNodes?.();
for (const child of item.children ?? item._children ?? []) carried.add(child);
}
const units = [];
for (const item of picked) {
if (carried.has(item)) continue;
const group = groups.has(item);
if (!group) item.updateArea?.(canvas.ctx); // fresh after a size change this frame
const rect = outlineOf(item, canvas, { node: !group });
if (rect) units.push({ item, group, rect });
}
return units;
}
function isPinned(item) {
return item.pinned === true || item.flags?.pinned === true;
}
function moveUnit({ item, group, rect }, { x, y }) {
const dx = x - rect.x;
const dy = y - rect.y;
if ((!dx && !dy) || isPinned(item)) return;
if (group) item.move(dx, dy);
else item.pos = [item.pos[0] + dx, item.pos[1] + dy];
}
function resizeUnit({ item, group, rect }, { w, h }) {
if (isPinned(item) || item.flags?.collapsed) return;
if (w === rect.w && h === rect.h) return;
if (group) {
item.resize(w, h);
return;
}
// A node's outline includes its title bar and any frame drawn around it;
// its size does not, so set the size that gives the outline asked for.
const extraW = rect.w - item.size[0];
const extraH = rect.h - item.size[1];
const least = item.computeSize?.() ?? [0, 0];
item.setSize([Math.max(w - extraW, least[0]), Math.max(h - extraH, least[1])]);
}
function run(tool) {
const canvas = app.canvas;
if (!canvas?.graph) return;
const units = selectionUnits(canvas);
if (!enoughFor(tool.kind, units.length)) {
showToast({ detail: `Select ${tool.kind === "spacing" ? "three" : "two"} or more nodes or groups first.`, life: 3000 });
return;
}
const rects = units.map((unit) => unit.rect);
if (tool.kind === "size") {
const sizes = sameSize(rects, tool.how);
units.forEach((unit, index) => resizeUnit(unit, sizes[index]));
} else {
const places = tool.kind === "lineUp" ? lineUp(rects, tool.how) : spreadEvenly(rects, tool.how);
units.forEach((unit, index) => moveUnit(unit, places[index]));
}
canvas.setDirty?.(true, true);
notifyAusbossChange();
refreshPanel(true);
}
// ---------------------------------------------------------------------------
// Resize together
// ---------------------------------------------------------------------------
//
// The frontend resizes only the node under the pointer: the canvas's own
// pointermove in the classic look, a pointermove on the window in Nodes 2.0.
// This listens on the window and catches up a frame later, so it always reads
// the size the frontend (and Snap into line) settled on. Which sizes are
// shared comes from how the selection was laid out when the button went down
// (sharedSides in js/shared/line_up.mjs). The grabbed node is
// the canvas's resizing node, or in Nodes 2.0 the node whose corner handle
// took the press. Every other selected node takes its size from where it
// stood when the button went down, so nothing builds up across steps.
const together = { enabled: true, before: null, grabbed: null, handleOf: null, sides: null, frame: 0 };
function resizableSelection(canvas) {
const { nodes } = graphSets(canvas?.graph);
return [...(canvas?.selectedItems ?? [])].filter(
(item) => nodes.has(item) && item.size && item.pos && !isPinned(item) && !item.flags?.collapsed,
);
}
function onTogetherDown(event) {
endTogether();
if (event.button !== 0 || !together.enabled) return;
const picked = resizableSelection(app.canvas);
if (picked.length < 2) return;
together.before = new Map(picked.map((node) => [node, { pos: [node.pos[0], node.pos[1]], size: [node.size[0], node.size[1]] }]));
// Nodes 2.0 draws a corner handle on each node (a div with data-corner).
const handle = event.target?.closest?.("[data-corner]");
together.handleOf = handle?.closest?.("[data-node-id]")?.dataset?.nodeId ?? null;
}
function grabbedNode(canvas) {
const classic = canvas?.resizing_node;
if (classic) return together.before.has(classic) ? classic : null;
if (together.handleOf == null) return null;
for (const node of together.before.keys()) if (String(node.id) === together.handleOf) return node;
return null;
}
function syncSizes() {
const canvas = app.canvas;
if (!together.before || !canvas?.graph) return;
together.grabbed ??= grabbedNode(canvas);
const grabbed = together.grabbed;
if (!grabbed) return;
const was = together.before.get(grabbed);
together.sides ??= sharedSides(
[...together.before.values()].map(({ pos, size }) => ({ x: pos[0], y: pos[1], w: size[0], h: size[1] })),
);
const others = [...together.before].filter(([node]) => node !== grabbed);
const least = others.map(([node]) => node.computeSize?.() ?? [0, 0]);
// Heights go through the same rule as widths, with y for x.
const widths = together.sides.width
? matchWidth(
grabbed.size[0],
others.map(([, start], i) => ({ x: start.pos[0], w: start.size[0], least: least[i][0] })),
{ fromLeft: grabbed.pos[0] !== was.pos[0] },
)
: null;
const heights = together.sides.height
? matchWidth(
grabbed.size[1],
others.map(([, start], i) => ({ x: start.pos[1], w: start.size[1], least: least[i][1] })),
{ fromLeft: grabbed.pos[1] !== was.pos[1] },
)
: null;
let moved = false;
others.forEach(([node], index) => {
const x = widths ? widths[index].x : node.pos[0];
const y = heights ? heights[index].x : node.pos[1];
const w = widths ? widths[index].w : node.size[0];
const h = heights ? heights[index].w : node.size[1];
if (node.pos[0] !== x || node.pos[1] !== y) {
node.pos = [x, y];
moved = true;
}
if (node.size[0] !== w || node.size[1] !== h) {
node.setSize([w, h]);
moved = true;
}
});
if (moved) canvas.setDirty?.(true, true);
}
function onTogetherMove(event) {
if (!together.before) return;
if (!(event.buttons & 1)) return endTogether();
if (together.frame) return;
together.frame = requestAnimationFrame(() => {
together.frame = 0;
syncSizes();
});
}
function onTogetherUp() {
if (together.before) syncSizes(); // the last step, before the frontend records the change
endTogether();
}
function endTogether() {
if (together.frame) cancelAnimationFrame(together.frame);
together.frame = 0;
together.before = null;
together.grabbed = null;
together.handleOf = null;
together.sides = null;
}
function installTogether() {
window.addEventListener("pointerdown", onTogetherDown, true);
window.addEventListener("pointermove", onTogetherMove);
// Capture phase: the classic canvas stops its pointerup from bubbling.
window.addEventListener("pointerup", onTogetherUp, true);
window.addEventListener("pointercancel", endTogether, true);
window.addEventListener("blur", endTogether);
}
// ---------------------------------------------------------------------------
// The panel
// ---------------------------------------------------------------------------
const panel = { el: null, frame: 0, buttons: [], count: -1 };
function anchorButton() {
return document.querySelector(`.selection-toolbox .${ICON_CLASS}`)?.closest("button") ?? null;
}
function labelAnchor() {
const button = anchorButton();
if (!button || button.title) return;
button.title = "Line up, same size, even spacing";
button.setAttribute("aria-label", button.title);
}
function buildPanel() {
const el = document.createElement("div");
el.className = "ausboss-line-up";
el.setAttribute("role", "toolbar");
el.setAttribute("aria-label", "Line up the selection");
panel.buttons = [];
SECTIONS.forEach((section, index) => {
if (index) el.append(Object.assign(document.createElement("div"), { className: "ausboss-line-up-rule" }));
el.append(Object.assign(document.createElement("div"), { className: "ausboss-line-up-title", textContent: section.title }));
const row = Object.assign(document.createElement("div"), { className: "ausboss-line-up-row" });
for (const tool of TOOLS.filter((t) => t.kind === section.kind)) {
const button = document.createElement("button");
button.type = "button";
button.title = tool.label;
button.setAttribute("aria-label", tool.label);
button.innerHTML = iconSvg(tool.how);
button.addEventListener("click", () => run(tool));
panel.buttons.push({ button, tool });
row.append(button);
}
el.append(row);
});
return el;
}
function refreshPanel(force = false) {
if (!panel.el) return;
const count = pickedCount(app.canvas);
if (!force && count === panel.count) return;
panel.count = count;
const units = selectionUnits(app.canvas).length;
for (const { button, tool } of panel.buttons) button.disabled = !enoughFor(tool.kind, units);
}
// Above the selection toolbar, or below it when there is no room above. The
// toolbar follows the selection, so the panel follows the toolbar.
function placePanel() {
const anchor = anchorButton();
if (!panel.el || !anchor) return closePanel();
const bar = (anchor.closest(".selection-toolbox") ?? anchor).getBoundingClientRect();
const at = anchor.getBoundingClientRect();
const size = panel.el.getBoundingClientRect();
const left = Math.min(Math.max(8, at.left + at.width / 2 - size.width / 2), window.innerWidth - size.width - 8);
let top = bar.top - size.height - 6;
if (top < 8) top = bar.bottom + 6;
panel.el.style.left = `${Math.round(left)}px`;
panel.el.style.top = `${Math.round(top)}px`;
refreshPanel();
}
function follow() {
panel.frame = 0;
if (!panel.el) return;
placePanel();
if (panel.el) panel.frame = requestAnimationFrame(follow);
}
function openPanel() {
if (panel.el || !anchorButton()) return;
panel.el = buildPanel();
panel.count = -1;
document.body.append(panel.el);
window.addEventListener("pointerdown", onOutside, true);
window.addEventListener("keydown", onKey, true);
follow();
}
function closePanel() {
if (!panel.el) return;
panel.el.remove();
panel.el = null;
panel.buttons = [];
if (panel.frame) cancelAnimationFrame(panel.frame);
panel.frame = 0;
window.removeEventListener("pointerdown", onOutside, true);
window.removeEventListener("keydown", onKey, true);
}
function onOutside(event) {
if (!panel.el || panel.el.contains(event.target)) return;
if (anchorButton()?.contains(event.target)) return; // its own click closes the panel
closePanel();
}
function onKey(event) {
if (event.key === "Escape") closePanel();
}
function togglePanel() {
if (panel.el) closePanel();
else openPanel();
}
function buttonOn() {
return app.ui?.settings?.getSettingValue?.(SETTING) !== false;
}
app.registerExtension({
name: "AusBoss.LineUp",
commands: [
{ id: PANEL, label: "AusBoss: Line up, same size, even spacing", icon: ICON_CLASS, function: togglePanel },
...TOOLS.map((tool) => ({ id: tool.id, label: `AusBoss: ${tool.label}`, function: () => run(tool) })),
],
getSelectionToolboxCommands() {
if (!buttonOn() || pickedCount(app.canvas) < 2) return [];
// The toolbar names extension buttons from its own translations, which
// have no entry for this one; name it once it is on the page.
requestAnimationFrame(labelAnchor);
return [PANEL];
},
settings: [
{
id: SETTING,
name: "Line-up button on the selection toolbar",
type: "boolean",
defaultValue: true,
category: ["🆎 AusBoss", "Canvas", "Line-up button"],
tooltip:
"With two or more nodes or groups selected, the toolbar over them gets a button " +
"that lines them up, gives them one size or spaces them evenly.",
onChange(value) {
if (value === false) closePanel();
},
},
{
id: TOGETHER_SETTING,
name: "Resize selected nodes together",
type: "boolean",
defaultValue: true,
category: ["🆎 AusBoss", "Canvas", "Resize together"],
tooltip:
"With two or more nodes selected, pulling the edge or corner of one resizes them all: " +
"a column gets one width, a row one height, a grid both.",
onChange(value) {
together.enabled = value !== false;
if (!together.enabled) endTogether();
},
},
],
setup() {
const style = document.createElement("style");
style.id = "ausboss-line-up-style";
style.textContent = styleSheet();
document.head.append(style);
// onChange only fires on later edits on some frontends, so read the
// stored value here.
together.enabled = app.ui?.settings?.getSettingValue?.(TOGETHER_SETTING) !== false;
installTogether();
},
});
+84
View File
@@ -0,0 +1,84 @@
// Load Audio's window rules for the face, mirroring audio_window in
// nodes/_audio_trim_helpers.py so the wave shows exactly what one Run hands
// on. Pure: tests/load_audio_window.test.mjs runs it without a browser.
function finite(value, fallback = 0) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
// The pick, and what comes out of it. `end` 0 means "to the end of the
// file". A max above 0 keeps the start and cuts off anything past
// start + max. All times in seconds; duration is the file's length.
export function outputWindow(durationValue, startValue, endValue, maxValue = 0) {
const duration = Math.max(0, finite(durationValue));
const start = Math.min(Math.max(0, finite(startValue)), duration);
const pickedEnd = finite(endValue) > 0 ? Math.min(finite(endValue), duration) : duration;
const end = Math.max(start, pickedEnd);
const max = Math.max(0, finite(maxValue));
const keepEnd = max > 0 && end - start > max ? Math.min(duration, start + max) : end;
return { start, end, keepEnd, length: keepEnd - start, cut: Math.max(0, end - keepEnd) };
}
// "0:12.0" style, the way the IN and OUT fields write a time.
export function secondsLabel(value) {
const seconds = Math.max(0, finite(value));
if (seconds < 60) return `${seconds.toFixed(1)} s`;
const minutes = Math.floor(seconds / 60);
return `${minutes}:${(seconds - minutes * 60).toFixed(1).padStart(4, "0")}`;
}
// The line between IN and OUT: what comes out, and what the max cuts off.
export function outputSummary(window, { maxFromLink = false } = {}) {
if (!window || window.length <= 0) return "";
const parts = [secondsLabel(window.length)];
if (window.cut > 0.005) parts.push(`cut ${secondsLabel(window.cut)}`);
if (maxFromLink) parts.push("max wired");
return parts.join(" · ");
}
// Loud and quiet across the file: for each of `buckets` slices, the lowest
// and highest sample of the channels mixed down. Channel data is a list of
// Float32Arrays (AudioBuffer.getChannelData). Returns a Float32Array of
// [min0, max0, min1, max1, ...].
export function wavePeaks(channels, bucketsValue = 1200) {
const list = Array.isArray(channels) ? channels.filter(Boolean) : [];
const buckets = Math.max(1, Math.floor(finite(bucketsValue, 1200)));
const peaks = new Float32Array(buckets * 2);
const length = list.length ? list[0].length : 0;
if (!length) return peaks;
const count = list.length;
for (let bucket = 0; bucket < buckets; bucket += 1) {
const from = Math.floor((bucket * length) / buckets);
const to = Math.max(from + 1, Math.floor(((bucket + 1) * length) / buckets));
let low = 0;
let high = 0;
for (let index = from; index < to && index < length; index += 1) {
let sample = 0;
for (let channel = 0; channel < count; channel += 1) sample += list[channel][index] || 0;
sample /= count;
if (sample < low) low = sample;
if (sample > high) high = sample;
}
peaks[bucket * 2] = low;
peaks[bucket * 2 + 1] = high;
}
return peaks;
}
// Which part of the wave a press at `fraction` (0..1 across it) grabs: a
// handle within `hitPixels` of it, the inside of the pick to slide it, or
// "jump" to move the pick there.
export function grabZone(window, durationValue, fraction, widthValue, hitPixels = 10) {
const duration = Math.max(0, finite(durationValue));
const width = Math.max(0, finite(widthValue));
if (duration <= 0 || width <= 0) return "none";
const x = Math.min(1, Math.max(0, finite(fraction))) * width;
const startX = (window.start / duration) * width;
const endX = (window.end / duration) * width;
const toStart = Math.abs(x - startX);
const toEnd = Math.abs(x - endX);
if (Math.min(toStart, toEnd) <= hitPixels) return toStart <= toEnd ? "start" : "end";
if (x > startX && x < endX) return "move";
return "jump";
}
+563
View File
@@ -0,0 +1,563 @@
// Load Audio 🆎 - the wave, the trim and the player.
//
// The card above (js/widget_cards) holds Source and Max length. This panel
// draws the whole file as a wave: the part that comes out is bright, the
// part a Max length cuts off is red, and the rest is dim. Drag a handle to
// move IN or OUT, drag the middle to slide the pick, or click elsewhere to
// move it there. Play plays only what comes out. The widgets underneath
// (start_seconds, end_seconds, max_seconds) stay the source of truth.
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
import { formatTimecode, parseTimecode } from "../shared/timecode.mjs";
import { parseImageReference } from "../shared/pad_canvas.mjs";
import { mediaViewQuery, splitMediaName } from "../shared/video_preview.mjs";
import { ensureVideoCss, hideCanvasWidget, makeToolButton } from "../shared/video_ui.mjs";
import { MIN_TRIM_SECONDS, dragTrimHandle, slideTrimWindow } from "../load_video/trim_preview.mjs";
import { grabZone, outputSummary, outputWindow, secondsLabel, wavePeaks } from "./audio_window.mjs";
const NODE_NAME = "AUSBOSS_NODES_LoadAudio";
const PANEL_WIDGET = "ausboss_load_audio_wave";
// The frontend's own player for an audio_upload input. The wave replaces it.
const CORE_AUDIO_WIDGET = "audioUI";
const MIN_WIDTH = 280;
// 8px root padding + the wave's 28px floor + 6px gap + the 22px play, IN/OUT and loop row.
const PANEL_MIN_HEIGHT = 64;
// Opens small and neat: 300 px wide, with a wave about as tall as a field
// (32 px). Drag the corner down for a taller wave.
const DEFAULT_WIDTH = 300;
const DEFAULT_WAVE_EXTRA = 4;
const CUT_RED = "#e0525a";
const PEAK_BUCKETS = 1600;
const END_EPSILON = 0.03;
// Decoded once per file and kept small: only the peaks and the length.
const peakCache = new Map();
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function roundHundredth(value) {
return Math.round(Number(value || 0) * 100) / 100;
}
function hideCoreAudio(node) {
for (const widget of node.widgets ?? []) {
if (widget?.name === CORE_AUDIO_WIDGET) hideCanvasWidget(widget, widget.element);
}
}
// A wired Max length: the number the wire carries, when the far end is a
// node that shows it (Float, Integer, a Primitive). null when it can't be known.
function wiredMax(node) {
const input = node.inputs?.find((slot) => slot?.widget?.name === "max_seconds");
if (!input || input.link == null) return { wired: false, value: null };
const graph = node.graph;
const link = graph?.getLink?.(input.link) ?? graph?.links?.get?.(input.link) ?? graph?.links?.[input.link];
const origin = link ? graph?.getNodeById?.(link.origin_id) : null;
const shown = origin?.widgets?.find((widget) => typeof widget?.value === "number");
return { wired: true, value: shown ? Number(shown.value) : null };
}
function maxSeconds(state) {
const wired = wiredMax(state.node);
if (wired.wired) return { value: wired.value ?? 0, fromLink: true, known: wired.value != null };
return { value: Number(state.maxWidget?.value) || 0, fromLink: false, known: true };
}
function currentWindow(state) {
return outputWindow(state.duration, state.startWidget.value, state.endWidget.value, maxSeconds(state).value);
}
async function loadPeaks(name, route) {
const key = name;
if (peakCache.has(key)) return peakCache.get(key);
const job = (async () => {
const response = await api.fetchApi(route, { cache: "no-store" });
if (!response.ok) throw new Error(`the file could not be read (${response.status})`);
const bytes = await response.arrayBuffer();
const Context = window.AudioContext || window.webkitAudioContext;
const context = new Context();
try {
const buffer = await context.decodeAudioData(bytes);
const channels = [];
for (let index = 0; index < buffer.numberOfChannels; index += 1) channels.push(buffer.getChannelData(index));
return { duration: buffer.duration, peaks: wavePeaks(channels, PEAK_BUCKETS) };
} finally {
context.close?.();
}
})();
peakCache.set(key, job);
job.catch(() => peakCache.delete(key));
return job;
}
function ensureAudioCss() {
ensureVideoCss();
if (document.getElementById("ausboss-load-audio-css")) return;
const style = document.createElement("style");
style.id = "ausboss-load-audio-css";
style.textContent = `
.ausboss-audio-root .ausboss-video-stage{min-height:28px;background:#050b0b;cursor:pointer;touch-action:none;}
.ausboss-audio-root canvas.ausboss-audio-wave{position:absolute;inset:0;width:100%;height:100%;display:block;}
.ausboss-audio-root .ausboss-video-stage:not(.is-empty) .ausboss-video-status{display:none;}
.ausboss-audio-root .ausboss-audio-row{display:flex;align-items:center;gap:6px;flex:none;padding:0 2px;}
.ausboss-audio-root .ausboss-audio-row .ausboss-video-values{flex:1 1 auto;min-width:0;}
.ausboss-audio-root .ausboss-audio-play{min-width:30px;font-size:10px;}
.ausboss-audio-root .ausboss-audio-length{flex:none;padding:0 2px;color:#7fd9d2;font-size:10px;font-variant-numeric:tabular-nums;white-space:nowrap;cursor:default;}
.ausboss-audio-root .ausboss-audio-length:empty{display:none;}
.ausboss-audio-root .ausboss-audio-length.has-cut{color:#ff9aa0;cursor:pointer;text-decoration:underline dotted rgba(224,82,90,.8);text-underline-offset:2px;}
.ausboss-audio-root .ausboss-audio-row .ausboss-video-tool{flex:none;}
.ausboss-audio-root .ausboss-video-values{display:flex;gap:8px;}
.ausboss-audio-root .ausboss-video-value input{width:48px;}
.ausboss-audio-root .ausboss-audio-length{margin-left:auto;}
`;
document.head.appendChild(style);
}
function draw(state) {
const { canvas } = state;
const rect = canvas.getBoundingClientRect();
const scale = window.devicePixelRatio || 1;
const width = Math.max(1, Math.round(rect.width * scale));
const height = Math.max(1, Math.round(rect.height * scale));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, width, height);
if (!state.peaks || state.duration <= 0) return;
const win = currentWindow(state);
const xOf = (seconds) => (seconds / state.duration) * width;
const startX = xOf(win.start);
const keepX = xOf(win.keepEnd);
const endX = xOf(win.end);
// Backdrops: the part that comes out, then the part cut off.
ctx.fillStyle = "rgba(0,180,170,0.13)";
ctx.fillRect(startX, 0, keepX - startX, height);
if (endX > keepX) {
ctx.fillStyle = "rgba(224,82,90,0.16)";
ctx.fillRect(keepX, 0, endX - keepX, height);
}
// The wave, one bar per pixel column.
const mid = height / 2;
const reach = mid * 0.92;
const buckets = state.peaks.length / 2;
for (let x = 0; x < width; x += 1) {
const from = Math.floor((x / width) * buckets);
const to = Math.max(from + 1, Math.floor(((x + 1) / width) * buckets));
let low = 0;
let high = 0;
for (let bucket = from; bucket < to && bucket < buckets; bucket += 1) {
low = Math.min(low, state.peaks[bucket * 2]);
high = Math.max(high, state.peaks[bucket * 2 + 1]);
}
if (x >= startX && x < keepX) ctx.fillStyle = BRAND;
else if (x >= keepX && x < endX) ctx.fillStyle = CUT_RED;
else ctx.fillStyle = "#3a4948";
const top = mid - high * reach;
ctx.fillRect(x, top, 1, Math.max(1, (high - low) * reach));
}
// The handles: IN and OUT, and a dashed line where the max cuts off.
// The grips stay small on a short wave so they don't hide it.
const grip = Math.round(Math.max(3, Math.min(7, rect.height * 0.12)) * scale);
const handle = (x, color) => {
ctx.fillStyle = color;
ctx.fillRect(Math.round(x) - scale, 0, 2 * scale, height);
ctx.fillRect(Math.round(x) - 4 * scale, 0, 8 * scale, grip);
ctx.fillRect(Math.round(x) - 4 * scale, height - grip, 8 * scale, grip);
};
handle(startX, "#f4ffff");
handle(endX, "#f4ffff");
if (endX > keepX + 1) {
ctx.strokeStyle = CUT_RED;
ctx.lineWidth = 2 * scale;
ctx.setLineDash([4 * scale, 3 * scale]);
ctx.beginPath();
ctx.moveTo(keepX, 0);
ctx.lineTo(keepX, height);
ctx.stroke();
ctx.setLineDash([]);
}
// The playhead while it plays, or where it paused inside the pick.
if (state.audio && !state.audio.paused) {
const x = xOf(state.audio.currentTime);
ctx.fillStyle = "#ffffff";
ctx.fillRect(x - scale / 2, 0, scale, height);
}
}
function updateFace(state) {
const win = currentWindow(state);
const max = maxSeconds(state);
if (document.activeElement !== state.startInput) state.startInput.value = formatTimecode(win.start);
if (document.activeElement !== state.endInput) state.endInput.value = formatTimecode(win.end);
if (state.duration > 0) {
const summary = outputSummary(win, { maxFromLink: max.fromLink && !max.known });
state.summary.textContent = win.length > 0 ? secondsLabel(win.length) : "";
state.summary.classList.toggle("has-cut", win.cut > 0.005);
state.summary.title = win.cut > 0.005
? `${summary}. Max length is ${max.value} s, so ${formatTimecode(win.keepEnd)} to ${formatTimecode(win.end)} is cut off (red). Click to move OUT to ${formatTimecode(win.keepEnd)}.`
: `${summary}: ${formatTimecode(win.start)} to ${formatTimecode(win.keepEnd)} comes out.`;
} else {
state.summary.textContent = "";
}
draw(state);
}
function writeWidget(widget, value) {
const next = roundHundredth(value);
if (Math.abs(Number(widget.value || 0) - next) < 0.0001) return;
widget.value = next;
widget.callback?.(next);
}
function writeTrim(state, bounds) {
state.writing = true;
try {
writeWidget(state.startWidget, bounds.start);
// OUT at the very end stays 0, the widget's own "to the end".
writeWidget(state.endWidget, bounds.end >= state.duration - 0.005 ? 0 : bounds.end);
} finally {
state.writing = false;
}
updateFace(state);
state.node.setDirtyCanvas?.(true, true);
}
function installDrag(state) {
const surface = state.stage;
const infoAt = (clientX) => {
const rect = state.canvas.getBoundingClientRect();
const fraction = rect.width ? Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)) : 0;
const win = currentWindow(state);
return { fraction, win, zone: grabZone(win, state.duration, fraction, rect.width) };
};
surface.addEventListener("pointermove", (event) => {
if (state.dragging || state.duration <= 0) return;
const { zone } = infoAt(event.clientX);
surface.style.cursor = zone === "start" || zone === "end" ? "ew-resize" : zone === "move" ? "grab" : "pointer";
});
surface.addEventListener("pointerdown", (event) => {
if (event.button !== 0 || state.dragging || state.duration <= 0) return;
event.preventDefault();
event.stopPropagation();
const initial = infoAt(event.clientX);
const bounds = { start: initial.win.start, end: initial.win.end };
const anchor = initial.fraction * state.duration;
const downX = event.clientX;
let moved = false;
state.dragging = true;
try { surface.setPointerCapture(event.pointerId); } catch (_error) { /* mouse fallback */ }
const centred = (fraction) => {
const span = bounds.end - bounds.start;
return slideTrimWindow(state.duration, bounds, fraction * state.duration - span / 2 - bounds.start);
};
const apply = (clientX) => {
const next = infoAt(clientX);
if (initial.zone === "start" || initial.zone === "end") {
const now = currentWindow(state);
writeTrim(state, dragTrimHandle(state.duration, { start: now.start, end: now.end }, initial.zone, next.fraction, MIN_TRIM_SECONDS));
} else if (initial.zone === "move") {
writeTrim(state, slideTrimWindow(state.duration, bounds, next.fraction * state.duration - anchor));
} else {
writeTrim(state, centred(next.fraction));
}
};
const move = (moveEvent) => {
if (!(moveEvent.buttons & 1)) { finish(moveEvent); return; }
if (!moved && Math.abs(moveEvent.clientX - downX) < 3) return;
moved = true;
apply(moveEvent.clientX);
};
const finish = (finishEvent) => {
if (!state.dragging) return;
state.dragging = false;
surface.removeEventListener("pointermove", move);
try { surface.releasePointerCapture(event.pointerId); } catch (_error) { /* already released */ }
if (!moved && initial.zone === "jump") writeTrim(state, centred(infoAt(finishEvent?.clientX ?? downX).fraction));
if (moved || initial.zone === "jump") {
if (state.audio && !state.audio.paused) state.audio.currentTime = currentWindow(state).start;
notifyAusbossChange();
}
};
surface.addEventListener("pointermove", move);
surface.addEventListener("pointerup", finish, { once: true });
surface.addEventListener("pointercancel", finish, { once: true });
surface.addEventListener("lostpointercapture", finish, { once: true });
});
}
function loopEnabled(node) {
node.properties ??= {};
return !!node.properties.ausboss_load_audio_loop;
}
function installPlayer(state) {
const audio = state.audio;
const stop = () => {
if (state.raf) cancelAnimationFrame(state.raf);
state.raf = 0;
state.playButton.textContent = "▶";
state.playButton.classList.remove("active");
draw(state);
};
const tick = () => {
if (audio.paused) { stop(); return; }
const win = currentWindow(state);
if (audio.currentTime >= win.keepEnd - END_EPSILON || audio.currentTime < win.start - 0.25) {
if (loopEnabled(state.node)) audio.currentTime = win.start;
else { audio.pause(); audio.currentTime = win.start; stop(); return; }
}
draw(state);
state.raf = requestAnimationFrame(tick);
};
audio.addEventListener("play", () => {
state.playButton.textContent = "❚❚";
state.playButton.classList.add("active");
if (state.raf) cancelAnimationFrame(state.raf);
state.raf = requestAnimationFrame(tick);
}, { signal: state.abort.signal });
audio.addEventListener("pause", stop, { signal: state.abort.signal });
state.playButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (state.duration <= 0) return;
if (!audio.paused) { audio.pause(); return; }
const win = currentWindow(state);
if (audio.currentTime < win.start || audio.currentTime >= win.keepEnd - END_EPSILON) audio.currentTime = win.start;
audio.play().catch(() => stop());
});
state.stopPlayer = () => { audio.pause(); stop(); };
}
function commitTimeInput(state, edge, input) {
if (state.duration <= 0) return;
const seconds = parseTimecode(input.value);
const win = currentWindow(state);
if (seconds === null) {
input.value = formatTimecode(edge === "start" ? win.start : win.end);
return;
}
writeTrim(state, dragTrimHandle(state.duration, { start: win.start, end: win.end }, edge, seconds / state.duration, MIN_TRIM_SECONDS));
notifyAusbossChange();
input.blur();
}
function watchWidget(state, widget) {
if (!widget || widget.__ausbossAudioWatch) return;
widget.__ausbossAudioWatch = true;
const prior = widget.callback;
widget.callback = function (...args) {
const result = prior?.apply(this, args);
if (!state.writing) updateFace(state);
return result;
};
}
function buildPanel(node) {
if (node.__ausbossLoadAudio) return node.__ausbossLoadAudio;
const audioWidget = findWidget(node, "audio");
const startWidget = findWidget(node, "start_seconds");
const endWidget = findWidget(node, "end_seconds");
if (!audioWidget || !startWidget || !endWidget) return null;
ensureAudioCss();
hideCoreAudio(node);
hideCanvasWidget(startWidget);
hideCanvasWidget(endWidget);
const root = document.createElement("div");
root.className = "ausboss-video-root ausboss-audio-root";
const stage = document.createElement("div");
stage.className = "ausboss-video-stage is-empty";
stage.title = "Drag a handle to set where the sound starts or stops. Drag the middle to slide it.";
const canvas = document.createElement("canvas");
canvas.className = "ausboss-audio-wave";
const status = document.createElement("div");
status.className = "ausboss-video-status";
status.textContent = "Choose or upload a sound file";
const playButton = makeToolButton("▶", "Play the part that comes out");
playButton.classList.add("ausboss-audio-play");
const loopButton = makeToolButton("LOOP", "Play it again and again");
stage.append(canvas, status);
const values = document.createElement("div");
values.className = "ausboss-video-values";
const makeValue = (labelText, title) => {
const wrap = document.createElement("div");
wrap.className = "ausboss-video-value";
const label = document.createElement("label");
label.textContent = labelText;
const input = document.createElement("input");
input.type = "text";
input.inputMode = "decimal";
input.placeholder = "m:ss.s";
input.title = title;
input.addEventListener("keydown", (event) => {
keepKeyInField(event);
if (event.key === "Enter") input.dispatchEvent(new Event("change"));
});
wrap.append(label, input);
return { wrap, input };
};
const startValue = makeValue("IN", "Where the sound starts. Type seconds (12.5) or a time (0:12.5).");
const endValue = makeValue("OUT", "Where the sound stops. Type seconds (24) or a time (0:24.0).");
// How long the output is, beside OUT and off the wave. Red when Max
// length cuts the pick short; the tooltip says how much.
const summary = document.createElement("div");
summary.className = "ausboss-audio-length";
values.append(startValue.wrap, endValue.wrap);
const row = document.createElement("div");
row.className = "ausboss-audio-row";
row.append(playButton, values, summary, loopButton);
root.append(stage, row);
const widget = node.addDOMWidget(PANEL_WIDGET, "ausboss_audio", root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => PANEL_MIN_HEIGHT + WIDGET_FRAME,
});
keepDomWidgetWidthAuto(widget);
widget.serialize = false;
fillNodeHeight(widget, {
minWidth: MIN_WIDTH,
minHeight: PANEL_MIN_HEIGHT + WIDGET_FRAME,
minNodeSize: [MIN_WIDTH, 200],
exactMinWidth: true,
});
const audio = new Audio();
audio.preload = "auto";
const abort = new AbortController();
const state = node.__ausbossLoadAudio = {
node, root, stage, canvas, status, playButton, loopButton, summary, audio,
startInput: startValue.input, endInput: endValue.input,
audioWidget, startWidget, endWidget, maxWidget: findWidget(node, "max_seconds"),
duration: 0, peaks: null, abort, writing: false, dragging: false, raf: 0, loading: 0,
};
// Clicking the red length moves OUT to where the sound really ends.
summary.addEventListener("click", (event) => {
const win = currentWindow(state);
if (win.cut <= 0.005) return;
event.preventDefault();
event.stopPropagation();
writeTrim(state, { start: win.start, end: win.keepEnd });
notifyAusbossChange();
});
const updateLoop = () => loopButton.classList.toggle("active", loopEnabled(node));
updateLoop();
loopButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
node.properties.ausboss_load_audio_loop = !loopEnabled(node);
updateLoop();
notifyAusbossChange();
});
const refresh = async () => {
state.stopPlayer?.();
const name = audioWidget.value;
const ticket = ++state.loading;
state.duration = 0;
state.peaks = null;
if (!name || typeof name !== "string") {
audio.removeAttribute("src");
stage.classList.add("is-empty");
status.textContent = "Choose or upload a sound file";
updateFace(state);
return;
}
const parts = splitMediaName(name);
stage.classList.add("is-empty");
status.textContent = `Loading ${parts.filename}…`;
const type = parseImageReference(name)?.type ?? "input";
const route = `/view?${mediaViewQuery({ ...parts, type }, "input")}`;
audio.src = api.apiURL(route);
try {
const result = await loadPeaks(name, route);
if (ticket !== state.loading) return;
state.duration = result.duration;
state.peaks = result.peaks;
stage.classList.remove("is-empty");
updateFace(state);
} catch (error) {
if (ticket !== state.loading) return;
status.textContent = `This sound could not be shown: ${error?.message || error}`;
}
};
state.refresh = refresh;
installDrag(state);
installPlayer(state);
watchWidget(state, startWidget);
watchWidget(state, endWidget);
watchWidget(state, state.maxWidget);
startValue.input.addEventListener("change", () => commitTimeInput(state, "start", startValue.input));
endValue.input.addEventListener("change", () => commitTimeInput(state, "end", endValue.input));
const observer = new ResizeObserver(() => draw(state));
observer.observe(stage);
state.observer = observer;
const priorAudioCallback = audioWidget.callback;
audioWidget.callback = function (...args) {
const result = priorAudioCallback?.apply(this, args);
peakCache.delete(audioWidget.value);
refresh();
return result;
};
setTimeout(refresh, 0);
return state;
}
app.registerExtension({
name: "ausboss.load_audio",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_NAME) return;
// The frontend's Upload button for an audio_upload input looks for its
// player widget and fails without one, and it adds that player only to
// its own audio nodes. Ask for it here, ahead of the button; the panel
// hides it, since the wave plays the sound instead.
const required = nodeData.input?.required;
if (required && !required.audioUI) {
const entries = Object.entries(required);
const at = entries.findIndex(([name]) => name === "audio") + 1;
entries.splice(at, 0, ["audioUI", ["AUDIO_UI", {}]]);
nodeData.input.required = Object.fromEntries(entries);
}
chainCallback(nodeType.prototype, "onNodeCreated", function () {
buildPanel(this);
// Only for a genuinely new node: onConfigure restores a saved size
// after this runs, so a workflow's own size still wins.
const floor = Number(this.computeSize?.()?.[1]) || 0;
if (floor > 0) this.setSize?.([DEFAULT_WIDTH, floor + DEFAULT_WAVE_EXTRA]);
});
chainCallback(nodeType.prototype, "onConfigure", function () {
queueMicrotask(() => {
const state = buildPanel(this);
if (state) {
hideCoreAudio(this);
state.refresh?.();
}
});
});
// A wire into Max length changes what comes out; redraw when one comes or goes.
chainCallback(nodeType.prototype, "onConnectionsChange", function () {
const state = this.__ausbossLoadAudio;
if (state) queueMicrotask(() => updateFace(state));
});
chainCallback(nodeType.prototype, "onRemoved", function () {
const state = this.__ausbossLoadAudio;
state?.stopPlayer?.();
state?.abort?.abort();
state?.observer?.disconnect();
if (state) state.audio.removeAttribute("src");
this.__ausbossLoadAudio = null;
});
},
});
+4 -1
View File
@@ -4,6 +4,7 @@ import { chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../s
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
import { formatTimecode, parseTimecode } from "../shared/timecode.mjs";
import { parseImageReference } from "../shared/pad_canvas.mjs";
import {
mediaInfo,
mediaViewQuery,
@@ -590,7 +591,9 @@ function buildPreview(node) {
const parts = splitMediaName(name);
setEmpty(state, `Loading ${parts.filename}…`);
video.pause();
video.src = api.apiURL(`/view?${mediaViewQuery({ ...parts, type: "input" }, "input")}`);
// A video ComfyUI made ("clip.mp4 [output]") plays from its own folder.
const type = parseImageReference(name)?.type ?? "input";
video.src = api.apiURL(`/view?${mediaViewQuery({ ...parts, type }, "input")}`);
video.load();
probeSourceFps(state, name);
};
+920 -100
View File
File diff suppressed because it is too large Load Diff
+75 -2
View File
@@ -4,6 +4,7 @@ import { isForeignRun, outputKey } from "../shared/prompt_scope.mjs";
import { chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { installNameTokens } from "../shared/name_tokens.mjs";
import { fillNodeHeight } from "../shared/panel_layout.mjs";
import { addStepConsumer, mountStepOverlay, showsStepsOn, stepRun, stopStepRun, watchSteps } from "../shared/step_preview.mjs";
import {
findVideoMetadata,
isStillImage,
@@ -37,6 +38,40 @@ function getLoopEnabled(node) {
return !!node.properties.ausboss_save_video_loop;
}
// The STEPS button: the hidden `step_preview` widget (the card hides it).
// On, the player shows each step of the clip's first frame while it is
// being made, and a STOP button joins the tools for the run.
function stepsOn(state) {
return state.stepsWidget?.value === true;
}
function syncSteps(state, run = stepRun()) {
const on = stepsOn(state);
state.stepsButton?.classList.toggle("active", on);
state.stopButton?.classList.toggle("live", on && run.status === "running");
const overlay = state.stepOverlay;
if (!on) {
overlay.hide();
return;
}
const thisRun = (id) => id !== null && id !== undefined && String(id) === String(run.promptId);
if (run.status === "running") {
if (thisRun(state.stepsSaved)) return;
if (run.url && showsStepsOn(state.node, run)) {
state.stepsShown = run;
overlay.show(run, { saves: true });
} else if (!thisRun(state.stepsShown?.promptId)) {
overlay.hide();
}
return;
}
if (run.status === "stopped" && thisRun(state.stepsShown?.promptId) && !thisRun(state.stepsSaved)) {
overlay.show({ ...state.stepsShown, status: "stopped" }, { saves: true });
return;
}
overlay.hide();
}
function setEmpty(state, text) {
state.stage.classList.add("is-empty");
state.status.textContent = text;
@@ -92,7 +127,12 @@ function buildPreview(node) {
// button only ever re-fetched what was on screen, and after a page reload
// there is no saved file in memory for it to fetch at all.
const loopButton = makeToolButton("LOOP", "Toggle looping for this saved preview");
tools.append(loopButton);
loopButton.classList.add("ausboss-steps-loop");
const stopButton = makeToolButton("STOP", "Stop this run now. The next run in the queue still starts.");
stopButton.classList.add("ausboss-steps-stop");
const stepsButton = makeToolButton("STEPS", "Show the clip's first frame here while it is being made, one step at a time, with a Stop button. Off by default.");
tools.classList.add("ausboss-steps-tools");
tools.append(stopButton, stepsButton, loopButton);
stage.append(video, image, status, tools);
root.append(stage);
@@ -116,7 +156,36 @@ function buildPreview(node) {
const state = node.__ausbossSaveVideo = {
node, root, stage, video, image, status, tools, loopButton,
widget, abort, meta: null,
stepsWidget: node.widgets?.find((item) => item?.name === "step_preview") ?? null,
stepsButton, stopButton,
stepOverlay: mountStepOverlay(stage),
};
// Under the tools, so they stay on top of the picture.
stage.insertBefore(state.stepOverlay.root, tools);
if (!state.stepsWidget) stepsButton.style.display = "none";
for (const button of [stopButton, stepsButton]) {
button.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal: abort.signal });
}
stepsButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (!state.stepsWidget) return;
const next = !stepsOn(state);
state.stepsWidget.value = next;
state.stepsWidget.callback?.(next);
syncSteps(state);
node.setDirtyCanvas?.(true, true);
notifyAusbossChange();
}, { signal: abort.signal });
stopButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
Promise.resolve(stopStepRun()).catch(() => {});
}, { signal: abort.signal });
state.forgetSteps = [
addStepConsumer(node, () => stepsOn(state)),
watchSteps((run) => syncSteps(state, run)),
];
const updateLoopButton = () => {
const enabled = getLoopEnabled(node);
video.loop = enabled;
@@ -183,14 +252,17 @@ app.registerExtension({
});
chainCallback(nodeType.prototype, "onConfigure", function () {
queueMicrotask(() => {
buildPreview(this);
const state = buildPreview(this);
suppressCoreVideoPreview(this);
if (state) syncSteps(state);
});
});
chainCallback(nodeType.prototype, "onExecuted", function (message) {
if (isForeignRun()) return;
const state = buildPreview(this);
const meta = findVideoMetadata(message);
// Saved: the file replaces the steps.
if (state) { state.stepsSaved = stepRun().promptId; syncSteps(state); }
if (state && meta) loadMetadata(state, meta);
else if (state) setEmpty(state, "Execution finished without video metadata");
app.canvas?.setDirty?.(true, true);
@@ -199,6 +271,7 @@ app.registerExtension({
const state = this.__ausbossSaveVideo;
state?.video?.pause();
state?.abort?.abort();
for (const forget of state?.forgetSteps ?? []) forget();
this.__ausbossSaveVideo = null;
});
},
+21
View File
@@ -250,3 +250,24 @@ export function pastedLink(selected, pasted) {
if (linkTarget(words.trim())?.url) return null; // an address over an address is just a paste
return `[${words}](${target.url})`;
}
// A pasted note's text, its links pointed at the pasted copies of the nodes
// and groups they name. `nodes` and `groups` map an old id (as text) to its
// copy's id (js/shared/paste_ids.mjs). A link to anything the paste did not
// copy is kept as typed. Returns null when no link changes.
export function repointLinks(text, nodes, groups) {
let changed = false;
const follow = (raw) => {
const target = linkTarget(raw);
const isGroup = target?.group !== undefined;
const old = isGroup ? target.group : target?.link;
const copy = old === undefined ? undefined : (isGroup ? groups : nodes)?.get(String(old));
if (copy === undefined || copy === null) return raw;
changed = true;
return `#${isGroup ? "g" : ""}${copy}`;
};
const next = String(text ?? "")
.replace(WORDS_LINK_RE, (_all, words, address) => `[${words}](${follow(address)})`)
.replace(ARROW_RE, (all, _arrow, id) => (id === undefined ? all : `${all.slice(0, -id.length - 1)}${follow(`#${id}`)}`));
return changed ? next : null;
}
+5 -3
View File
@@ -72,12 +72,14 @@ export function panelRoot(target) {
return root;
}
// A drag that carries files, from the file manager or the desktop. A row
// being dragged inside a panel carries none and stays the panel's own.
// A drag that carries files, from the file manager or the desktop, or a card
// from ComfyUI's Assets panel, which names a picture it made ("... [output]")
// instead of carrying the file. A row being dragged inside a panel carries
// neither and stays the panel's own.
export function dragCarriesFiles(event) {
const types = event?.dataTransfer?.types;
if (!types) return false;
return Array.from(types).includes("Files");
return Array.from(types).some((type) => type === "Files" || type === "application/x-comfy-asset-info");
}
// Does a file dropped here have to be handed to its node by the pack? The
+75
View File
@@ -26,6 +26,81 @@ export function parseCivitaiBody(text) {
return payload;
}
// What to tell the person when a Civitai call fails. A browser that cannot
// reach the site at all only says "Failed to fetch", which explains nothing.
export function civitaiFailureText(error) {
if (error instanceof TypeError) {
return "Could not reach civitai.com. A firewall, a VPN or starting ComfyUI "
+ "with --disable-api-nodes can block it.";
}
return String(error?.message || error || "The Civitai lookup failed.");
}
export function civitaiButtonLabel(hasCivitai) {
return hasCivitai ? "Refresh Civitai info" : "Fetch Civitai info";
}
// ---------- the LoRA's preview picture ----------
// After the lookup, the browser also downloads the LoRA's first picture from
// Civitai's picture server, and the pack's server saves it beside the LoRA.
export const PICTURE_HOST = "image.civitai.com";
export const PICTURE_WIDTH = 450;
export const MAX_PICTURE_BYTES = 8 * 1024 * 1024;
// Civitai rates each picture. The number is a set of flags: 1 = PG,
// 2 = PG-13, 4 = R, 8 = X, 16 = XXX. Older answers give a word instead.
const MILD_LEVEL = 2;
const MILD_WORDS = new Set(["none", "soft"]);
export function isMildPicture(image) {
const level = Number(image?.nsfwLevel);
if (Number.isFinite(level) && level > 0) return level <= MILD_LEVEL;
if (typeof image?.nsfw === "string") return MILD_WORDS.has(image.nsfw.toLowerCase());
if (image?.nsfw === false) return true;
return false;
}
// The first still picture in Civitai's answer that may be saved: PG and
// PG-13 only, unless any rating is allowed.
export function pickCivitaiPicture(info, anyRating = false) {
const images = Array.isArray(info?.images) ? info.images : [];
return images.find((image) =>
image && typeof image.url === "string"
&& (image.type ?? "image") === "image"
&& (anyRating || isMildPicture(image))
&& smallPictureUrl(image.url) !== null) ?? null;
}
// The address of a 450-pixel copy of the picture, or null for any address
// that is not Civitai's picture server over https.
export function smallPictureUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return null;
}
if (parsed.protocol !== "https:" || parsed.hostname !== PICTURE_HOST) return null;
const parts = parsed.pathname.split("/");
const sizing = parts.findIndex((part, index) => index > 1 && index < parts.length - 1 && part.includes("="));
if (sizing >= 0) parts[sizing] = `width=${PICTURE_WIDTH}`;
else if (parts.length >= 3) parts.splice(parts.length - 1, 0, `width=${PICTURE_WIDTH}`);
else return null;
parsed.pathname = parts.join("/");
parsed.search = "";
parsed.hash = "";
return parsed.href;
}
// Where the picture really came from, after Civitai's redirect to its file
// store: Civitai's own servers, over https.
export function isCivitaiAddress(url) {
try {
const parsed = new URL(url);
return parsed.protocol === "https:"
&& (parsed.hostname === "civitai.com" || parsed.hostname.endsWith(".civitai.com"));
} catch {
return false;
}
}
+12
View File
@@ -120,6 +120,18 @@ export function fitRect(boxWidth, boxHeight, width, height) {
return { left: (boxWidth - fitWidth) / 2, top: (boxHeight - fitHeight) / 2, width: fitWidth, height: fitHeight, scale };
}
// A see-through picture shows over a checkerboard, as in an editor: its
// squares' side in CSS pixels and their two colours.
export const CHECKER = { side: 8, light: "#4d4d4d", dark: "#383838" };
// The padding that holds a contained picture's own background to the
// picture: the bars beside it, as CSS. Empty when there is nothing to fit.
export function backdropPadding(boxWidth, boxHeight, width, height) {
const fit = fitRect(boxWidth, boxHeight, width, height);
if (!(fit.scale > 0)) return "";
return `${fit.top}px ${fit.left}px`;
}
// A place in the box as a place on the picture, in picture pixels.
export function toPicture(fit, x, y) {
if (!(fit?.scale > 0)) return null;
+21
View File
@@ -14,6 +14,7 @@ import { makeSlideSwitch } from "./slide_switch.mjs";
import { extendStroke } from "./touch_math.mjs";
import { makeToolButton } from "./video_ui.mjs";
import {
CHECKER,
ERASE_OUTSIDE,
ERASE_SIZE,
ERASE_SOFT_DEFAULT,
@@ -227,6 +228,25 @@ function sizeView(er) {
}
}
// See-through parts show over a checkerboard, as on the Slide view, its
// squares as big on screen.
function drawCheckerboard(er, ctx, fit) {
const side = Math.max(2, Math.round(CHECKER.side * er.canvas.width / Math.max(1, er.canvas.clientWidth)));
if (er.checker?.side !== side) {
const tile = document.createElement("canvas");
const tileCtx = sized(tile, side * 2, side * 2);
tileCtx.fillStyle = CHECKER.dark;
tileCtx.fillRect(0, 0, side * 2, side * 2);
tileCtx.fillStyle = CHECKER.light;
tileCtx.fillRect(0, 0, side, side);
tileCtx.fillRect(side, side, side, side);
er.checker = { side, pattern: ctx.createPattern(tile, "repeat") };
}
er.checker.pattern.setTransform?.(new DOMMatrix([1, 0, 0, 1, fit.left, fit.top]));
ctx.fillStyle = er.checker.pattern;
ctx.fillRect(fit.left, fit.top, fit.width, fit.height);
}
function draw(er) {
er.frame = 0;
if (!er.active || !er.refs) return;
@@ -256,6 +276,7 @@ function draw(er) {
cut.drawImage(top, 0, 0, width, height);
cut.globalCompositeOperation = "destination-out";
cut.drawImage(mask, 0, 0, width, height);
if (er.refs.a?.alpha || er.refs.b?.alpha) drawCheckerboard(er, ctx, fit);
ctx.drawImage(under, fit.left, fit.top, width, height);
ctx.drawImage(er.cut, fit.left, fit.top);
+142
View File
@@ -0,0 +1,142 @@
// The row of chips on H3 Prompt Writer: each picture plugged in, with its @
// name, a small copy of it and its job, then the video and its job. A slot with nothing plugged in is a faint
// dashed chip, so it reads as optional. The widgets underneath stay the
// source of truth: a chip only changes their value.
import { app } from "/scripts/app.js";
import { BRAND, chainCallback } from "./index.mjs";
import { liftSocket } from "./widget_card.mjs";
import { commitWidgetValue, comboValues } from "./widget_card_math.mjs";
const CSS_ID = "ausboss-cast-chips-css";
const WATCH = 1000; // Workflow Switches can turn a loader off without touching this node.
function ensureCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-chips{display:flex;flex-wrap:wrap;align-content:flex-start;gap:5px;height:100%;min-width:0;font:600 10.5px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
.ausboss-chip{position:relative;display:flex;align-items:center;gap:4px;height:24px;max-width:100%;padding:0 8px;box-sizing:border-box;border:1px solid #2a3437;border-radius:12px;background:#0b0f10;color:#d8ecea;white-space:nowrap}
.ausboss-chip:hover{border-color:${BRAND}}
.ausboss-chip img{flex:none;width:18px;height:18px;margin-left:-5px;border-radius:9px;object-fit:cover}
.ausboss-chip b{flex:none;color:${BRAND};font:700 10px ui-monospace,SFMono-Regular,Menlo,monospace}
.ausboss-chip em{flex:none;color:#6f8886;font-style:normal;font-weight:500}
.ausboss-chip select{field-sizing:content;min-width:0;max-width:120px;height:22px;padding:0 11px 0 0;border:none;background:transparent url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' fill='none' stroke='%236f8886' stroke-width='1.4'/%3E%3C/svg%3E") no-repeat right 1px center;color:#d8ecea;font:inherit;appearance:none;-webkit-appearance:none;cursor:pointer;text-overflow:ellipsis}
.ausboss-chip select:focus-visible{outline:2px solid ${BRAND};outline-offset:2px}
.ausboss-chip select option{background:#0f1516;color:#d8ecea}
.ausboss-chip.off{border-style:dashed;background:transparent;color:#56706d}
.ausboss-chip.off:hover{border-color:#2a3437}
.ausboss-chip.wired{color:#8ba3a1}
`;
document.head.append(style);
}
function linkSource(node, name) {
const slot = node.inputs?.find((input) => input?.widget?.name === name || input?.name === name);
if (slot?.link === null || slot?.link === undefined) return null;
const graph = node.graph;
const link = graph?.links?.[slot.link] ?? graph?._links?.get?.(slot.link);
const source = graph?.getNodeById?.(link?.origin_id);
return (source?.mode ?? 0) === 0 ? source ?? {} : null;
}
const widgetOf = (node, name) => node.widgets?.find((widget) => widget.name === name) ?? null;
function chip(className = "") {
const element = document.createElement("label");
element.className = `ausboss-chip ${className}`.trim();
return element;
}
function add(parent, tag, text, className) {
const child = document.createElement(tag);
if (className) child.className = className;
if (text !== undefined) child.textContent = text;
parent.append(child);
return child;
}
function choice(node, name, into) {
const widget = widgetOf(node, name);
const select = add(into, "select");
for (const value of comboValues(widget)) add(select, "option", value).value = value;
select.value = widget?.value ?? "";
select.title = widget?.options?.tooltip ?? "";
select.addEventListener("change", () => {
if (widget) commitWidgetValue(node, widget, select.value, app.canvas);
node.setDirtyCanvas?.(true, true);
});
return (values) => { if (document.activeElement !== select) select.value = values[name] ?? ""; };
}
// The jobs have no socket of their own, but a page saved by an earlier
// release may have one wired: that link stays, in the slot column.
const JOBS = ["job_1", "job_2", "job_3", "job_4", "video_job"];
const keepOldJobLinks = (node) => {
for (const name of JOBS) {
const slot = node.inputs?.find((input) => input?.widget?.name === name);
if (slot && slot.link !== null && slot.link !== undefined) liftSocket(node, name);
}
};
// A card row: { panel: castChips, height }.
export function castChips(node) {
ensureCss();
const element = document.createElement("div");
element.className = "ausboss-chips";
queueMicrotask(() => keepOldJobLinks(node));
let shape = "";
let syncs = [];
let latest = {};
const build = (values) => {
element.textContent = "";
syncs = [];
for (const number of [1, 2, 3, 4]) {
const source = linkSource(node, `picture_${number}`);
if (!source) {
const empty = chip("off");
empty.textContent = `+ @${number}`;
empty.title = `Plug a picture into picture_${number} to name it @${number} in your lines.`;
element.append(empty);
continue;
}
const item = chip();
const url = source.imgs?.[0]?.src;
if (url) add(item, "img").src = url;
add(item, "b", `@${number}`);
if (linkSource(node, `job_${number}`)) add(item, "span", "job from its wire");
else syncs.push(choice(node, `job_${number}`, item));
element.append(item);
}
if (linkSource(node, "video")) {
const item = chip();
add(item, "b", "video");
if (linkSource(node, "video_job")) add(item, "span", "job from its wire");
else syncs.push(choice(node, "video_job", item));
element.append(item);
} else {
const empty = chip("off");
empty.textContent = "+ video";
empty.title = "Plug a video into video to copy its moves, swap the person, keep it going or give it a new voice.";
element.append(empty);
}
for (const run of syncs) run(values);
};
const shapeOf = () => JSON.stringify(["picture_1", "picture_2", "picture_3", "picture_4", "video", ...JOBS].map((name) => {
const source = linkSource(node, name);
return source ? [source.id ?? 1, source.imgs?.[0]?.src ?? ""] : 0;
}));
const sync = (values) => {
latest = values;
const now = shapeOf();
if (now !== shape) { shape = now; build(values); return; }
for (const run of syncs) run(values);
};
const watch = setInterval(() => sync(latest), WATCH);
chainCallback(node, "onRemoved", () => clearInterval(watch));
chainCallback(node, "onConfigure", () => queueMicrotask(() => keepOldJobLinks(node)));
return { element, sync };
}
+8 -4
View File
@@ -87,14 +87,18 @@ export function nameAt(picked, names, position, atTheEnd) {
return atTheEnd === "start over" ? list[(place - 1) % list.length] : null;
}
// Picture after a run is queued. "next" may reach one past the last picture,
// where the node stops the run; it never runs further than that. "random"
// jumps to any picked picture (`roll` is 0..1, for tests).
export function nextPosition(position, count, afterRun, roll = Math.random()) {
// Picture after a run is queued. With "stop the run", "next" may reach one
// past the last picture, where the node stops the run; it never runs further
// than that. With "start over", "next" wraps from the last picture back to
// the first, so Picture always names a real picture. A Picture already past
// the end (saved by an older version) steps on from the picture it loaded.
// "random" jumps to any picked picture (`roll` is 0..1, for tests).
export function nextPosition(position, count, afterRun, atTheEnd = "stop the run", roll = Math.random()) {
const place = Math.max(1, Math.round(Number(position) || 1));
if (count < 1) return place;
if (afterRun === "random") return Math.min(count, Math.floor(roll * count) + 1);
if (afterRun !== "next") return place;
if (atTheEnd === "start over") return (place % count) + 1;
return Math.min(place + 1, count + 1);
}
+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.17.0";
export const AUSBOSS_JS_VERSION = "2.20.5";
export const BRAND = "#00b4aa"; // AusBoss teal — keep node accents consistent.
export const BRAND_DARK = "#007f78";
+13 -6
View File
@@ -7,6 +7,7 @@
// 2. an uploadable image/video file combo widget -> deterministic /view query
// 3. nothing -> the caller shows a quiet placeholder
import { parseImageReference } from "./pad_canvas.mjs";
import { splitMediaName } from "./video_preview.mjs";
// Core loader nodes keep the uploaded file name in a combo widget with one of
@@ -37,11 +38,13 @@ export function sourceFileWidget(sourceNode) {
return sourceNode?.widgets?.find((widget) => FILE_WIDGET_NAMES.has(widget?.name)) ?? null;
}
// Deterministic /view query (no cache buster) for an input-folder file name,
// tolerating Windows separators and " [input]" annotations.
// Deterministic /view query (no cache buster) for a file widget's name,
// tolerating Windows separators. A name without a tag is in the input
// folder; "photo.png [output]" or "[temp]" is read from that folder.
export function viewQueryForFile(name) {
const { filename, subfolder } = splitMediaName(name);
return new URLSearchParams({ filename, subfolder, type: "input" }).toString();
const type = parseImageReference(name)?.type ?? "input";
return new URLSearchParams({ filename, subfolder, type }).toString();
}
// Describe how to preview a source node:
@@ -117,9 +120,13 @@ export function describeNodePreview(node, inputName, storedUrl = null) {
?? describeSourcePreview(upstreamNode(node, inputName));
}
// Quiet ASCII placeholder copy; never an error.
export function placeholderText(connected, noun = "an image") {
return connected ? "Run the workflow to see it here" : `Connect ${noun} to see it here`;
// Quiet ASCII placeholder copy; never an error. `running`: a run is on its
// way to this node, which shows its steps ("steps") or only the result.
export function placeholderText(connected, noun = "an image", running = false) {
if (!connected) return `Connect ${noun} to see it here`;
if (running === "steps") return "Running. The steps show here once the picture starts";
if (running) return "Running. The picture shows here when it is saved";
return "Run the workflow to see it here";
}
// The node ran while its preview was off, so its last picture is older
+110
View File
@@ -0,0 +1,110 @@
// Line up, same size and even spacing for a selection of nodes and groups.
// No LiteGraph here; js/line_up/index.js reads the selection and moves it.
//
// A rect is { x, y, w, h } in graph units: the outline people see, title bar
// included. Each function returns one result per rect, in the order given.
import { unionOf } from "./snap_lines.mjs";
export const LINE_UP = ["left", "middleX", "right", "top", "middleY", "bottom"];
export const SAME_SIZE = ["widest", "narrowest", "tallest", "shortest"];
export const SPACING = ["across", "down"];
// Where each rect goes, as { x, y }, so they all share one edge or middle.
// Edges line up on the outermost edge of the selection, middles on its middle.
export function lineUp(rects, how) {
const bounds = unionOf(rects);
if (!bounds) return [];
return rects.map((rect) => {
switch (how) {
case "left":
return { x: bounds.x, y: rect.y };
case "middleX":
return { x: bounds.x + bounds.w / 2 - rect.w / 2, y: rect.y };
case "right":
return { x: bounds.x + bounds.w - rect.w, y: rect.y };
case "top":
return { x: rect.x, y: bounds.y };
case "middleY":
return { x: rect.x, y: bounds.y + bounds.h / 2 - rect.h / 2 };
case "bottom":
return { x: rect.x, y: bounds.y + bounds.h - rect.h };
default:
return { x: rect.x, y: rect.y };
}
});
}
// The size each rect takes, as { w, h }, so they share the width or height of
// the widest, narrowest, tallest or shortest one.
export function sameSize(rects, how) {
if (!rects.length) return [];
const widths = rects.map((rect) => rect.w);
const heights = rects.map((rect) => rect.h);
const w = how === "widest" ? Math.max(...widths) : how === "narrowest" ? Math.min(...widths) : null;
const h = how === "tallest" ? Math.max(...heights) : how === "shortest" ? Math.min(...heights) : null;
return rects.map((rect) => ({ w: w ?? rect.w, h: h ?? rect.h }));
}
// Nodes never end up on top of each other: a gap under this, overlaps
// included, becomes this, and the selection grows from its first edge.
export const MIN_GAP = 20;
// Equal gaps between the rects along one axis, as { x, y } per rect. They keep
// their order (by middle) and the selection keeps its outer edges, unless the
// gaps would come out under MIN_GAP. Fewer than three rects stay put.
export function spreadEvenly(rects, axis) {
const [start, size] = axis === "across" ? ["x", "w"] : ["y", "h"];
const out = rects.map((rect) => ({ x: rect.x, y: rect.y }));
if (rects.length < 3) return out;
const order = rects.map((rect, index) => index).sort((a, b) => {
const middle = (rect) => rect[start] + rect[size] / 2;
return middle(rects[a]) - middle(rects[b]) || rects[a][start] - rects[b][start];
});
const first = Math.min(...rects.map((rect) => rect[start]));
const last = Math.max(...rects.map((rect) => rect[start] + rect[size]));
const filled = rects.reduce((sum, rect) => sum + rect[size], 0);
const gap = Math.max(MIN_GAP, (last - first - filled) / (rects.length - 1));
let at = first;
for (const index of order) {
out[index][start] = at;
at += rects[index][size] + gap;
}
return out;
}
// How many rects each kind of tool needs before it can do anything.
export function enoughFor(kind, count) {
return kind === "spacing" ? count >= 3 : count >= 2;
}
// One node of a selection is being resized: every other one takes its width.
// `starts` are where the others were when the button went down, as
// { x, w, least } (least is the narrowest that node can be). A pull on the
// left edge keeps each right edge where it was; any other pull keeps each
// left edge. Returns { x, w } per start, in the order given.
export function matchWidth(width, starts, { fromLeft = false } = {}) {
return starts.map(({ x, w, least = 0 }) => {
const next = Math.max(width, least);
return { x: fromLeft ? x + w - next : x, w: next };
});
}
// Which sizes a selection shares when one of it is resized, read from how it
// is laid out: nodes stacked in a column share a width, nodes side by side in
// a row share a height, and a grid shares both. A loose scatter shares a
// width, the way a column does.
export function sharedSides(rects) {
const span = (a, b, start, size) => a[start] < b[start] + b[size] && b[start] < a[start] + a[size];
let column = false;
let row = false;
for (let i = 0; i < rects.length; i++) {
for (let j = i + 1; j < rects.length; j++) {
const sameX = span(rects[i], rects[j], "x", "w");
const sameY = span(rects[i], rects[j], "y", "h");
if (sameX && !sameY) column = true;
if (sameY && !sameX) row = true;
}
}
return { width: column || !row, height: row };
}
+266 -22
View File
@@ -40,7 +40,7 @@ export function newRow() {
export function normalizeRows(parsed) {
if (!Array.isArray(parsed)) return [];
return parsed.slice(0, MAX_ROWS).map((entry) => {
return tidyGroups(parsed.slice(0, MAX_ROWS).map((entry) => {
const row = newRow();
if (entry && typeof entry === "object") {
row.id = typeof entry.id === "string" && entry.id ? entry.id : row.id;
@@ -49,9 +49,12 @@ export function normalizeRows(parsed) {
row.strength_clip = roundStrength(entry.strength_clip ?? row.strength);
row.enabled = entry.enabled !== false;
row.triggers = typeof entry.triggers === "string" ? entry.triggers : "";
const group = groupName(entry.group);
if (group) row.group = group;
if (group && entry.group_off === true) row.group_off = true;
}
return row;
});
}));
}
export function parseRows(raw) {
@@ -79,6 +82,232 @@ export function moveRow(rows, index, delta) {
return reorderRows(rows, index, index + delta);
}
// ---------- groups ----------
// A group is a name carried on its rows (`group`); a row without one sits at
// the top level. A group's rows always stand together, so the stack still
// runs top to bottom as shown, and a group lasts while it has a row.
// A group switched off carries `group_off` on every row: none of them runs,
// and each keeps its own on/off for when the group comes back on.
export const MAX_GROUP_NAME = 40;
export function groupName(value) {
return typeof value === "string" ? value.trim().slice(0, MAX_GROUP_NAME) : "";
}
function withoutGroup(row) {
const { group, group_off, ...rest } = row;
return rest;
}
function withoutGroupOff(row) {
const { group_off, ...rest } = row;
return rest;
}
// A row joining another group takes that group's on/off (see tidyGroups).
function withGroup(row, name) {
if (!name) return withoutGroup(row);
if (row.group === name) return row;
return { ...withoutGroupOff(row), group: name };
}
// Whether a row runs: on itself, and not in a group that is switched off.
export function rowRuns(row) {
return row?.enabled !== false && !row?.group_off;
}
// Gathers each group's rows at the place of its first row, keeping every
// other row where it is. A group is off when any of its rows says so, and
// then all of them do: a row that just joined an off group goes off with it.
export function tidyGroups(rows) {
const out = [];
const runs = new Map();
for (const row of rows) {
const name = row.group || "";
if (!name) { out.push(row.group_off ? withoutGroupOff(row) : row); continue; }
let run = runs.get(name);
if (!run) {
run = [];
runs.set(name, run);
out.push(run);
}
run.push(row);
}
return out.flatMap((item) => {
if (!Array.isArray(item)) return [item];
const off = item.some((row) => row.group_off);
return item.map((row) => off === Boolean(row.group_off) ? row
: off ? { ...row, group_off: true } : withoutGroupOff(row));
});
}
export function groupNames(rows) {
return [...new Set(rows.map((row) => row.group).filter(Boolean))];
}
export function hasGroupName(rows, name, except = "") {
const key = groupName(name).toLowerCase();
return groupNames(rows).some((group) => group !== except && group.toLowerCase() === key);
}
export function uniqueGroupName(rows, base = "Group") {
for (let n = 1; ; n += 1) {
const name = `${base} ${n}`;
if (!hasGroupName(rows, name)) return name;
}
}
// What the stack shows, in order: a header before each group's first row,
// then every row with the group it sits in.
export function stackLayout(rows) {
const items = [];
rows.forEach((row, index) => {
const group = row.group || "";
if (group && rows[index - 1]?.group !== group) {
let end = index;
while (end < rows.length && rows[end].group === group) end += 1;
const members = rows.slice(index, end);
items.push({
kind: "group", group, start: index, end,
on: members.filter((member) => member.enabled).length, total: members.length,
off: members.some((member) => member.group_off),
});
}
items.push({ kind: "row", index, group });
});
return items;
}
// A new group takes the place of the topmost chosen row. It never lands
// inside another group: from the middle of one it goes just below it.
export function groupRows(rows, ids, name) {
const chosen = new Set(ids);
const top = rows.findIndex((row) => chosen.has(row.id));
if (top < 0 || !name) return rows;
const rest = rows.filter((row) => !chosen.has(row.id));
let at = top;
while (at > 0 && at < rest.length && rest[at].group && rest[at].group === rest[at - 1].group) at += 1;
const grouped = rows.filter((row) => chosen.has(row.id)).map((row) => withGroup(row, name));
return tidyGroups([...rest.slice(0, at), ...grouped, ...rest.slice(at)]);
}
// Rows moved into a group join it at its end.
export function moveRowsToGroup(rows, ids, name) {
const chosen = new Set(ids);
const rest = rows.filter((row) => !chosen.has(row.id));
let last = -1;
rest.forEach((row, index) => { if (row.group === name) last = index; });
if (last < 0) return tidyGroups(rows.map((row) => chosen.has(row.id) ? withGroup(row, name) : row));
const moving = rows.filter((row) => chosen.has(row.id)).map((row) => withGroup(row, name));
return tidyGroups([...rest.slice(0, last + 1), ...moving, ...rest.slice(last + 1)]);
}
// Rows taken out of a group stand just outside it: the ones at its top go
// above it, the rest below it, so a row at either edge does not move.
export function ungroupRows(rows, ids) {
const chosen = new Set(ids);
const out = [];
for (let start = 0; start < rows.length;) {
const name = rows[start].group;
if (!name) { out.push(rows[start]); start += 1; continue; }
let end = start;
while (end < rows.length && rows[end].group === name) end += 1;
const run = rows.slice(start, end);
let lead = 0;
while (lead < run.length && chosen.has(run[lead].id)) lead += 1;
const stay = run.filter((row) => !chosen.has(row.id));
const below = run.slice(lead).filter((row) => chosen.has(row.id));
out.push(...run.slice(0, lead).map(withoutGroup), ...stay, ...below.map(withoutGroup));
start = end;
}
return out;
}
// A renamed group keeps its on/off.
export function renameGroup(rows, from, to) {
const name = groupName(to);
if (!name) return rows;
return tidyGroups(rows.map((row) => row.group === from ? { ...row, group: name } : row));
}
// Every row of a group on or off by itself.
export function setGroupEnabled(rows, name, enabled) {
return rows.map((row) => row.group === name ? { ...row, enabled: !!enabled } : row);
}
// The group's own switch: off stops all of its rows, and each row keeps its
// own on/off underneath.
export function setGroupOff(rows, name, off) {
return rows.map((row) => row.group !== name ? row
: off ? { ...row, group_off: true } : withoutGroupOff(row));
}
export function isGroupOff(rows, name) {
return rows.some((row) => row.group === name && row.group_off);
}
// Move up/down for one row, one step at a time: a row at the edge of its
// group steps out of it first, and a row next to a group steps into it.
export function moveRowInStack(rows, index, delta) {
const row = rows[index];
if (!row) return rows;
const next = rows[index + delta];
const group = row.group || "";
if (group && next?.group !== group) {
return rows.map((entry, i) => i === index ? withoutGroup(entry) : entry);
}
if (!next) return rows;
if (!group && next.group) {
return tidyGroups(rows.map((entry, i) => i === index ? withGroup(entry, next.group) : entry));
}
return reorderRows(rows, index, index + delta);
}
// Drop one row: before the row `beforeId` (or at the end of `group`, or at
// the very end), sitting in `group` (empty = top level).
export function placeRow(rows, id, { group = "", beforeId = null } = {}) {
const row = rows.find((entry) => entry.id === id);
if (!row) return rows;
const rest = rows.filter((entry) => entry.id !== id);
let at = beforeId ? rest.findIndex((entry) => entry.id === beforeId) : -1;
if (at < 0 && group) {
rest.forEach((entry, index) => { if (entry.group === group) at = index + 1; });
}
if (at < 0) at = rest.length;
return tidyGroups([...rest.slice(0, at), withGroup(row, group), ...rest.slice(at)]);
}
// Move a whole group before the row `beforeId` (a top-level row or the
// first row of another group), or to the end.
export function placeGroup(rows, name, beforeId = null) {
const members = rows.filter((row) => row.group === name);
if (!members.length || members.some((row) => row.id === beforeId)) return rows;
const rest = rows.filter((row) => row.group !== name);
let at = beforeId ? rest.findIndex((row) => row.id === beforeId) : -1;
if (at < 0) at = rest.length;
return [...rest.slice(0, at), ...members, ...rest.slice(at)];
}
// Move a group past the item above or below it (a row or a whole group).
export function moveGroup(rows, name, delta) {
const start = rows.findIndex((row) => row.group === name);
if (start < 0) return rows;
let end = start;
while (end < rows.length && rows[end].group === name) end += 1;
if (delta < 0) {
if (start === 0) return rows;
const above = rows[start - 1];
const beforeId = above.group ? rows.find((row) => row.group === above.group).id : above.id;
return placeGroup(rows, name, beforeId);
}
if (end >= rows.length) return rows;
const below = rows[end];
let after = end + 1;
if (below.group) while (after < rows.length && rows[after].group === below.group) after += 1;
return placeGroup(rows, name, rows[after]?.id ?? null);
}
// Drag-to-reorder model: which visual slot the pointer is over, given the
// vertical centers of the rows in their current on-screen order. Past the
// last center means the last slot; an empty stack has no slot.
@@ -229,25 +458,33 @@ export function toggleTrigger(triggers, word) {
}
export function summarizeRows(rows) {
const on = rows.filter((row) => row.enabled && row.name).length;
const on = rows.filter((row) => rowRuns(row) && row.name).length;
const total = rows.filter((row) => row.name).length;
return total === 0 ? "no LoRAs" : `${on} / ${total} on`;
}
// Master toggle model: "on" when every named row is enabled, "off" when none
// are, "mixed" otherwise. Blank rows don't vote.
// Master toggle model: "on" when every named row runs, "off" when none
// does, "mixed" otherwise. Blank rows don't vote. A row in a group that is
// switched off does not run.
export function toggleAllState(rows) {
const named = rows.filter((row) => row.name);
if (!named.length) return "off";
const on = named.filter((row) => row.enabled).length;
const on = named.filter(rowRuns).length;
return on === 0 ? "off" : on === named.length ? "on" : "mixed";
}
// Every row on (groups switched back on too) or every row off.
function setAllRows(rows, enabled) {
return rows.map((row) => {
const next = enabled ? withoutGroupOff(row) : row;
return row.name ? { ...next, enabled } : next;
});
}
// Clicking the master pill: anything not fully-on turns everything on;
// fully-on turns everything off.
export function toggleAllRows(rows) {
const enabled = toggleAllState(rows) !== "on";
return rows.map((row) => (row.name ? { ...row, enabled } : row));
return setAllRows(rows, toggleAllState(rows) !== "on");
}
// Longest common folder prefix (whole segments only) across displayed names,
@@ -469,7 +706,7 @@ const BAR_EPSILON = 0.005;
export function strengthBarScale(rows) {
let peak = 0;
for (const row of rows ?? []) {
if (!row?.name || row.enabled === false) continue;
if (!row?.name || !rowRuns(row)) continue;
const value = Math.abs(Number(row.strength));
if (Number.isFinite(value)) peak = Math.max(peak, value);
}
@@ -619,29 +856,36 @@ export function duplicateLoraKeys(rows) {
// snapshotEnabled back in), so an accidental master click is always one more
// click from the setup it destroyed.
// A row in a group that is switched off is remembered with its group's state.
export function snapshotEnabled(rows) {
const map = {};
for (const row of rows ?? []) {
if (row?.name) map[row.id] = row.enabled !== false;
if (!row?.name) continue;
const enabled = row.enabled !== false;
map[row.id] = row.group_off ? { enabled, group_off: true } : enabled;
}
return map;
}
function restoreRow(row, saved) {
if (saved && typeof saved === "object") {
const next = { ...row, enabled: saved.enabled !== false };
return saved.group_off && next.group ? { ...next, group_off: true } : withoutGroupOff(next);
}
return { ...withoutGroupOff(row), enabled: Boolean(saved) };
}
export function cycleMasterToggle(rows, snapshot) {
const set = (enabled) =>
rows.map((row) => (row.name ? { ...row, enabled } : row));
const state = toggleAllState(rows);
if (state === "mixed") return { rows: set(true), snapshot: snapshotEnabled(rows) };
if (state === "on") return { rows: set(false), snapshot };
if (state === "mixed") return { rows: setAllRows(rows, true), snapshot: snapshotEnabled(rows) };
if (state === "on") return { rows: setAllRows(rows, false), snapshot };
// All off: restore the remembered setup when it still lights anything;
// otherwise this is a plain binary toggle and everything turns on.
const restored = rows.map((row) =>
row.name && snapshot && row.id in snapshot
? { ...row, enabled: snapshot[row.id] }
: row,
);
const remembersAnything = restored.some((row) => row.name && row.enabled);
return remembersAnything ? { rows: restored, snapshot } : { rows: set(true), snapshot };
const restored = tidyGroups(rows.map((row) =>
row.name && snapshot && row.id in snapshot ? restoreRow(row, snapshot[row.id]) : row,
));
const remembersAnything = restored.some((row) => row.name && rowRuns(row));
return remembersAnything ? { rows: restored, snapshot } : { rows: setAllRows(rows, true), snapshot };
}
// ---------- absorb the loader chain ----------
@@ -736,7 +980,7 @@ export function loaderLoraEntries(nodeLike) {
.filter((row) => row && typeof row === "object" && row.name)
.map((row) => entry(row.name, row.strength,
row.strength_clip ?? row.strength,
row.enabled, row.triggers));
row.enabled !== false && !row.group_off, row.triggers));
} catch {
return [];
}
+314
View File
@@ -0,0 +1,314 @@
// The timeline on H3 Prompt Writer: each moment of the script as a block on a
// bar as long as the clip, with the cuts as marks. The server reads the script with the same code a run uses,
// so the bar shows what will happen. It also colours the script box, so the
// words the node reads as instructions (@1, 3s:, a cut) stand out.
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { BRAND, chainCallback } from "./index.mjs";
import { dragRange, moveMoment, retime, rulerSeconds, stripCaption, stripRows, toggleCut, wiredJobs } from "./moment_strip_math.mjs";
import { attachScriptAssist } from "./script_assist.mjs";
import { attachScriptColours } from "./script_colours.mjs";
import { commitWidgetValue } from "./widget_card_math.mjs";
const CSS_ID = "ausboss-moment-strip-css";
const WAIT = 200; // Lets someone finish a word before asking.
const WATCH = 1000; // A Workflow Switches card turns a picture off without touching this node.
function ensureCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-strip{display:flex;flex-direction:column;gap:3px;height:100%;min-width:0;padding:5px 6px 3px;box-sizing:border-box;border-radius:6px;background:#0b0f10;font:600 10px/1.15 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;user-select:none}
.ausboss-strip-ruler{position:relative;flex:none;height:11px;color:#56706d;font-size:9px;line-height:11px}
.ausboss-strip-ruler span{position:absolute;transform:translateX(-50%);white-space:nowrap}
.ausboss-strip-ruler span:first-child{transform:none}
.ausboss-strip-ruler span:last-child{transform:translateX(-100%)}
.ausboss-strip-track{position:relative;flex:1 1 0;min-height:30px}
.ausboss-strip-block{position:absolute;top:0;bottom:0;display:flex;align-items:center;gap:4px;padding:0 4px;box-sizing:border-box;overflow:hidden;border:1px solid rgba(0,180,170,.5);border-radius:4px;background:rgba(0,180,170,.2);color:#cfe8e5}
.ausboss-strip-block.shot-odd{border-color:rgba(111,192,170,.42);background:rgba(111,192,170,.1)}
.ausboss-strip-block.speech{border-color:rgba(245,181,61,.55);background:rgba(245,181,61,.14);color:#f0dcae}
.ausboss-strip-faces{flex:none;display:flex;height:100%;max-height:28px}
.ausboss-strip-faces img{width:18px;height:100%;border-radius:3px;object-fit:cover;box-shadow:0 0 0 1px #0b0f10}
.ausboss-strip-faces img+img{margin-left:2px}
.ausboss-strip-words{flex:1 1 0;min-width:0;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word}
.ausboss-strip-cut{position:absolute;top:-13px;bottom:-2px;width:2px;margin-left:-1px;border-radius:1px;background:${BRAND}}
.ausboss-strip-handle{position:absolute;top:-2px;bottom:-2px;z-index:2;width:12px;margin-left:-6px;cursor:ew-resize;touch-action:none}
.ausboss-strip-handle::before{content:"";position:absolute;left:5px;top:5px;bottom:5px;width:2px;border-radius:1px;background:rgba(207,232,229,.35)}
.ausboss-strip-handle:hover::before,.ausboss-strip-handle.dragging::before{background:#cfe8e5}
.ausboss-strip-handle span{position:absolute;bottom:100%;left:50%;transform:translateX(-50%);padding:1px 4px;border-radius:3px;background:#cfe8e5;color:#0b0f10;font-size:9px;white-space:nowrap;display:none}
.ausboss-strip-handle.dragging span{display:block}
.ausboss-strip-block.movable{cursor:grab;touch-action:none}
.ausboss-strip-block.moving{z-index:3;cursor:grabbing;opacity:.85;box-shadow:0 4px 14px rgba(0,0,0,.5)}
.ausboss-strip-drop{position:absolute;top:-3px;bottom:-3px;z-index:4;width:3px;margin-left:-2px;border-radius:2px;background:#f5b53d;pointer-events:none}
.ausboss-strip-note{flex:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#6f8886;font-size:10px;font-weight:500;line-height:12px}
.ausboss-strip-note.warn{color:#d9a441}
`;
document.head.append(style);
}
// A bypassed or muted source doesn't reach the node when it runs, so it doesn't count.
function linkSource(node, name) {
const slot = node.inputs?.find((input) => input?.widget?.name === name || input?.name === name);
if (slot?.link === null || slot?.link === undefined) return null;
const graph = node.graph;
const link = graph?.links?.[slot.link] ?? graph?._links?.get?.(slot.link);
const source = graph?.getNodeById?.(link?.origin_id);
return (source?.mode ?? 0) === 0 ? source ?? {} : null;
}
function wired(node, name) {
return linkSource(node, name) !== null;
}
// The picture a loader shows, to put on the blocks that name it.
function pictureUrl(node, number) {
const source = linkSource(node, `picture_${number}`);
return source?.imgs?.[0]?.src ?? "";
}
function el(className, text) {
const node = document.createElement("div");
node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
// A card row: { panel: momentStrip, height }.
export function momentStrip(node) {
ensureCss();
const element = el("ausboss-strip");
const ruler = el("ausboss-strip-ruler");
const track = el("ausboss-strip-track");
const note = el("ausboss-strip-note");
element.append(ruler, track, note);
const colours = attachScriptColours(node);
const choices = (name) => (node.widgets?.find((widget) => widget.name === name)?.options?.values ?? []).filter((value) => value !== "as typed" && value !== "from the picture");
const assist = attachScriptAssist(node, colours.area, colours.layer, () => ({
pictures: [1, 2, 3, 4].map((number) => ({
number,
plugged: wired(node, `picture_${number}`),
job: node.widgets?.find((widget) => widget.name === `job_${number}`)?.value ?? "",
url: pictureUrl(node, number),
})),
cameras: choices("camera"),
looks: choices("look"),
seconds: Number(lastSeconds) || 8,
}));
let lastValues = {};
let lastSeconds = 8;
// The left edge of a moment that opens a typed line can be dragged; letting
// go types its second into that line (3s:), so the words stay the truth.
const commitScript = (change) => {
const widget = node.widgets?.find((entry) => entry.name === "script");
if (!widget) return;
const next = change(String(widget.value ?? ""));
if (next === widget.value) return;
commitWidgetValue(node, widget, next, app.canvas);
sync({ ...lastValues, script: widget.value });
};
// The left edge of a moment that opens a typed line: drag it to set when
// the moment starts (it types 3s: into that line), or click it to add or
// take away a cut there (a blank line). The words stay the truth.
const addHandle = (rows, index, seconds) => {
const range = dragRange(rows, index, seconds);
const handle = el("ausboss-strip-handle");
const label = document.createElement("span");
handle.append(label);
handle.style.left = `${rows[index].from}%`;
handle.title = range ? "Drag to set when this moment starts. Click to add or remove a cut." : "Click to add or remove a cut.";
track.append(handle);
handle.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
handle.setPointerCapture?.(event.pointerId);
const startX = event.clientX;
let moved = false;
let second = null;
const move = (moveEvent) => {
if (!moved && Math.abs(moveEvent.clientX - startX) < 4) return;
if (!range) return;
moved = true;
handle.classList.add("dragging");
const box = track.getBoundingClientRect();
const raw = ((moveEvent.clientX - box.left) / Math.max(box.width, 1)) * seconds;
second = Math.min(range[1], Math.max(range[0], Math.round(raw)));
handle.style.left = `${(second / seconds) * 100}%`;
label.textContent = `${second}s`;
};
const stop = () => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", stop);
handle.removeEventListener("pointercancel", stop);
handle.classList.remove("dragging");
if (!moved) commitScript((text) => toggleCut(text, rows[index].line));
else if (second !== null && second !== rows[index].start) commitScript((text) => retime(text, rows[index].line, second));
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", stop);
handle.addEventListener("pointercancel", stop);
});
};
// A click on a block selects its line in the words, ready to change.
const selectLine = (line) => {
const area = colours.area;
if (!area || !Number.isInteger(line)) return;
const lines = area.value.split("\n");
if (line >= lines.length) return;
const start = lines.slice(0, line).reduce((total, text) => total + text.length + 1, 0);
area.focus({ preventScroll: true });
area.setSelectionRange(start, start + lines[line].length);
const height = parseFloat(getComputedStyle(area).lineHeight) || 17;
area.scrollTop = Math.max(0, line * height - area.clientHeight / 3);
};
// A block that opens a typed line can be dragged along the bar: its words
// swap places with the others; times and cuts stay where they are.
const makeMovable = (block, rows, entries, order) => {
block.classList.add("movable");
block.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
block.setPointerCapture?.(event.pointerId);
const startX = event.clientX;
let target = order;
let moved = false;
const drop = el("ausboss-strip-drop");
const move = (moveEvent) => {
const shift = moveEvent.clientX - startX;
if (!moved && Math.abs(shift) < 4) return;
if (!moved) { moved = true; block.classList.add("moving"); track.append(drop); }
block.style.transform = `translateX(${shift}px)`;
const centre = block.getBoundingClientRect().left + block.offsetWidth / 2;
const others = entries.filter((entry) => entry.order !== order);
target = others.filter((entry) => {
const box = entry.block.getBoundingClientRect();
return centre > box.left + box.width / 2;
}).length;
const after = others[target - 1];
const before = others[target];
const at = before ? rows[before.index].from : after ? rows[after.index].to : 0;
drop.style.left = `${at}%`;
};
const stop = () => {
block.removeEventListener("pointermove", move);
block.removeEventListener("pointerup", stop);
block.removeEventListener("pointercancel", stop);
block.classList.remove("moving");
block.style.transform = "";
drop.remove();
if (!moved) selectLine(rows[entries[order].index].line);
else if (target !== order) commitScript((text) => moveMoment(text, entries.map((entry) => rows[entry.index].line), order, target));
};
block.addEventListener("pointermove", move);
block.addEventListener("pointerup", stop);
block.addEventListener("pointercancel", stop);
});
};
const show = (report) => {
const seconds = Number(report?.seconds) || Number(lastValues.seconds) || 8;
ruler.textContent = "";
for (const mark of rulerSeconds(seconds)) {
const tick = document.createElement("span");
tick.style.left = `${(mark / seconds) * 100}%`;
tick.textContent = `${mark}s`;
ruler.append(tick);
}
track.textContent = "";
let shot = 0;
const rows = stripRows(report);
const movable = [];
rows.forEach((moment, index) => {
if (moment.cut && index) shot += 1;
const block = el(`ausboss-strip-block${shot % 2 ? " shot-odd" : ""}`);
block.classList.toggle("speech", moment.speech);
block.style.left = `${moment.from}%`;
block.style.width = `calc(${moment.to - moment.from}% - 2px)`;
// Every picture the line names, in order, up to three.
const faces = el("ausboss-strip-faces");
for (const number of [...new Set([...moment.words.matchAll(/@([1-4])\b/g)].map((match) => match[1]))].slice(0, 3)) {
const url = pictureUrl(node, number);
if (!url) continue;
const picture = document.createElement("img");
picture.src = url;
picture.alt = `@${number}`;
faces.append(picture);
}
if (faces.childElementCount) block.append(faces);
block.append(el("ausboss-strip-words", moment.words));
block.title = `${moment.second}s: ${moment.words}`;
track.append(block);
if (moment.line !== null) movable.push({ block, index, order: movable.length });
const opens = rows.slice(0, index + 1).findLast((row) => row.line !== null)?.line ?? null;
block.addEventListener("click", () => { if (!block.classList.contains("movable")) selectLine(opens); });
if (index && moment.line !== null && !wired(node, "script")) addHandle(rows, index, seconds);
if (moment.cut && index) {
const cut = el("ausboss-strip-cut");
cut.style.left = `${moment.from}%`;
cut.title = `Cut at ${moment.second}s`;
track.append(cut);
}
});
if (movable.length > 1 && !wired(node, "script")) {
for (const entry of movable) {
makeMovable(entry.block, rows, movable, entry.order);
entry.block.title += " · drag to move it";
}
}
const caption = stripCaption(report);
note.textContent = caption.text;
note.title = caption.text;
note.classList.toggle("warn", caption.warn);
};
let shown = null;
let timer = null;
let asked = 0;
const ask = async (script, seconds, jobs, video) => {
const turn = ++asked;
try {
const response = await api.fetchApi("/ausboss/h3_writer/read", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ script, seconds, jobs, video }),
});
const report = response.ok ? await response.json() : null;
if (turn === asked) show(report);
} catch {
if (turn === asked) show(null);
}
};
let latest = null;
const sync = (values) => {
latest = values;
lastValues = values;
colours.sync(values, [1, 2, 3, 4].filter((number) => wired(node, `picture_${number}`)));
if (wired(node, "script")) {
shown = null;
clearTimeout(timer);
asked += 1;
show(null);
note.textContent = "The words come from the wire.";
return;
}
const script = String(values.script ?? "");
// A length wired from a number box shows that box's value.
const source = linkSource(node, "seconds");
const fromWire = Number(source?.widgets?.find?.((widget) => widget.name === "value")?.value);
const seconds = Math.min(15, Math.max(5, Math.round(fromWire || Number(values.seconds) || 8)));
lastSeconds = seconds;
const jobs = wiredJobs(values, (name) => wired(node, name));
const video = wired(node, "video") ? values.video_job : "";
const key = JSON.stringify([seconds, script, jobs, video]);
if (key === shown) return;
shown = key;
clearTimeout(timer);
timer = setTimeout(() => ask(script, seconds, jobs, video), WAIT);
};
const watch = setInterval(() => latest && sync(latest), WATCH);
chainCallback(node, "onRemoved", () => { clearInterval(watch); colours.dispose(); assist.dispose(); });
return { element, sync };
}
+115
View File
@@ -0,0 +1,115 @@
// Pure decisions behind js/shared/moment_strip.mjs: what the rows and the
// line under them say. No DOM, no app import, so tests run it under node:test.
const trimmed = (seconds) => String(Math.round(seconds * 10) / 10);
const percent = (part, whole) => Math.min(Math.max((part / whole) * 100, 0), 100);
// The job of each picture that is connected, by its number, for the server to
// check the @ numbers in a script against.
export function wiredJobs(values, wired) {
const jobs = {};
for (const number of [1, 2, 3, 4]) {
if (wired(`picture_${number}`)) jobs[number] = values[`job_${number}`];
}
return jobs;
}
// One row for each moment the server read: its second, its words, and where
// its bar sits in the clip, as percentages.
export function stripRows(report) {
const moments = report?.moments ?? [];
const seconds = report?.seconds || moments.at(-1)?.end || 1;
return moments.map((moment) => ({
second: trimmed(moment.start),
words: moment.text,
from: percent(moment.start, seconds),
to: Math.max(percent(moment.end, seconds), percent(moment.start, seconds) + 1),
cut: Boolean(moment.cut),
speech: Boolean(moment.speech),
line: Number.isInteger(moment.line) ? moment.line : null,
start: Number(moment.start) || 0,
}));
}
// A time typed at the start of a line: 3s:, 3 s, at 3 seconds, [3s].
const TYPED_TIME = /^\[?(?:at\s+)?\d+(?:\.\d+)?\s*(?:seconds?|secs?|s)\b\]?\s*[:,\-]?\s*/i;
// The script with one line set to start at a second: its old time, if any,
// gives way to the new one. line counts typed lines from 0.
export function retime(text, line, second) {
const lines = String(text ?? "").split("\n");
if (!Number.isInteger(line) || line < 0 || line >= lines.length) return String(text ?? "");
const lead = lines[line].match(/^\s*/)[0];
const words = lines[line].slice(lead.length).replace(TYPED_TIME, "");
lines[line] = `${lead}${trimmed(second)}s: ${words}`;
return lines.join("\n");
}
// The whole seconds a moment's start can be dragged to: after the moment
// before it, before the one after it (or the end of the clip).
export function dragRange(rows, index, seconds) {
const before = rows[index - 1]?.start ?? 0;
const after = rows[index + 1]?.start ?? seconds;
const low = Math.floor(before) + 1;
const high = Math.ceil(after) - 1;
return low <= high ? [low, high] : null;
}
// The line under the rows: a warning first, then what the script adds up to.
export function stripCaption(report) {
if (report?.warning) return { text: report.warning, warn: true };
const moments = report?.moments ?? [];
if (!moments.length) return { text: "Each line becomes a moment.", warn: false };
const count = moments.length === 1 ? "1 moment" : `${moments.length} moments`;
const cuts = moments.filter((moment) => moment.cut).length;
if (cuts) return { text: `${count}, ${cuts === 1 ? "1 cut" : `${cuts} cuts`}.`, warn: false };
const each = moments.map((moment) => trimmed(moment.end - moment.start));
if (moments.length === 1) return { text: `${count}, ${each[0]} s, one take.`, warn: false };
const even = each.every((length) => length === each[0]);
return { text: even ? `${count}, ${each[0]} s each, one take.` : `${count}, one take.`, warn: false };
}
// The seconds the bar under the script marks: every second up to 10 s,
// every other one after, always with the end.
export function rulerSeconds(seconds) {
const total = Math.max(1, Math.round(Number(seconds) || 0));
const step = total > 10 ? 2 : 1;
const marks = [];
for (let mark = 0; mark < total; mark += step) marks.push(mark);
if (total - marks.at(-1) < step) marks.pop();
return [...marks, total];
}
// The script with one moment's words moved to another place on the bar.
// slots: the typed line of each draggable moment, in bar order. The words
// move; each place keeps its own time (3s:) and the cuts stay where they
// are, so the bar's marks don't jump.
export function moveMoment(text, slots, from, to) {
const lines = String(text ?? "").split("\n");
if (from === to || !slots.every((line) => Number.isInteger(line) && line >= 0 && line < lines.length)) return String(text ?? "");
const split = (line) => {
const lead = lines[line].match(/^\s*/)[0];
const body = lines[line].slice(lead.length);
const time = body.match(TYPED_TIME)?.[0] ?? "";
return { lead, time, words: body.slice(time.length) };
};
const parts = slots.map(split);
const words = parts.map((part) => part.words);
const [moved] = words.splice(from, 1);
words.splice(Math.max(0, Math.min(to, words.length)), 0, moved);
slots.forEach((line, index) => { lines[line] = parts[index].lead + parts[index].time + words[index]; });
return lines.join("\n");
}
// The script with a cut added or taken away just before a typed line: a cut
// is a blank line.
export function toggleCut(text, line) {
const lines = String(text ?? "").split("\n");
if (!Number.isInteger(line) || line <= 0 || line >= lines.length) return String(text ?? "");
let top = line;
while (top > 0 && lines[top - 1].trim() === "") top -= 1;
if (top < line) lines.splice(top, line - top);
else lines.splice(line, 0, "");
return lines.join("\n");
}
+45
View File
@@ -0,0 +1,45 @@
// The outline of a node or group as people see it: { x, y, w, h } in graph
// units, title bar included.
//
// The classic look draws a node at its measured bounds (boundingRect). Nodes
// 2.0 draws each node as an element on the page, which can run past those
// bounds (its frame, a footer), so there the element itself is measured.
// Groups are drawn on the canvas in both looks.
function graphRectOf(element, canvas) {
const box = element.getBoundingClientRect();
if (!(box.width > 0) || !(box.height > 0)) return null;
const view = canvas.canvas.getBoundingClientRect();
const { scale, offset } = canvas.ds;
return {
x: (box.left - view.left) / scale - offset[0],
y: (box.top - view.top) / scale - offset[1],
w: box.width / scale,
h: box.height / scale,
};
}
export function boundsOf(item) {
const box = item?.boundingRect;
if (!box || !(box[2] > 0) || !(box[3] > 0)) return null;
return { x: box[0], y: box[1], w: box[2], h: box[3] };
}
// Nodes 2.0's node elements by node id, found in one pass for measuring many.
export function nodeElements() {
const elements = new Map();
if (globalThis.LiteGraph?.vueNodesMode !== true) return elements;
for (const element of document.querySelectorAll("[data-node-id]")) elements.set(element.getAttribute("data-node-id"), element);
return elements;
}
export function outlineOf(item, canvas, { node = true, elements = null } = {}) {
if (node && globalThis.LiteGraph?.vueNodesMode === true && item?.id != null && canvas?.canvas && canvas.ds) {
const element = elements
? elements.get(String(item.id))
: document.querySelector(`[data-node-id="${CSS.escape(String(item.id))}"]`);
const rect = element && graphRectOf(element, canvas);
if (rect) return rect;
}
return boundsOf(item);
}
+70
View File
@@ -0,0 +1,70 @@
// What a paste did to ids. Copy and paste give every pasted node and group a
// new id, and so do duplicate, Alt + drag and dropping a saved workflow on the
// canvas: the frontend sends all of them through
// LGraphCanvas._deserializeItems. A node that remembers other nodes by id
// (Workflow Switches, a Callout's links) would keep naming the old ones.
// Pasted on their own, its copies point at nothing; pasted into another
// workflow, they can point at an unrelated node that has the old number.
//
// The paste returns which new node each old id became. onPaste hands that
// on, so a pasted node can follow the copies of what it points at. A frontend
// without the method pastes as before and no handler runs.
//
// No app import, so tests/paste_ids.test.mjs runs it under node:test.
const handlers = new Set();
let installed = false;
// Old id (as text) -> the copy's id, for the nodes and the groups of one
// paste. The frontend adds the groups it is handed one by one, in order, so
// the groups it made are matched to the old ids by position; when the counts
// differ, no group is matched rather than a wrong one.
export function pastedIds(result, groupIds = [], isGroup = () => false) {
const nodes = new Map();
for (const [id, node] of result?.nodes ?? []) {
if (node?.id !== undefined && node.id !== null) nodes.set(String(id), node.id);
}
const created = [...(result?.created ?? [])];
const groups = new Map();
const made = created.filter(isGroup);
if (made.length === groupIds.length) {
groupIds.forEach((id, index) => {
if (id !== undefined && id !== null && made[index]?.id !== undefined) groups.set(String(id), made[index].id);
});
}
return { nodes, groups, created };
}
// Runs `handler({ nodes, groups, created })` after every paste.
export function onPaste(handler) {
if (typeof handler === "function") handlers.add(handler);
install();
}
function install() {
if (installed) return;
const lite = globalThis.LiteGraph;
const proto = lite?.LGraphCanvas?.prototype;
const original = proto?._deserializeItems;
if (typeof original !== "function") return;
installed = true;
const isGroup = (item) => Boolean(lite.LGraphGroup && item instanceof lite.LGraphGroup);
// A method whose result is needed, not a callback: chainCallback would drop
// the result, so wrap it and always hand the frontend's own result back.
function deserializeItemsWithIds(items, ...rest) {
// Read before the paste, which gives the groups it is handed new ids.
const groupIds = Array.isArray(items?.groups) ? items.groups.map((group) => group?.id) : [];
const result = original.call(this, items, ...rest);
if (!result || !handlers.size) return result;
const ids = pastedIds(result, groupIds, isGroup);
for (const handler of handlers) {
try {
handler(ids);
} catch (error) {
console.warn("[AusBoss] after a paste:", error);
}
}
return result;
}
proto._deserializeItems = deserializeItemsWithIds;
}
+4 -1
View File
@@ -20,6 +20,7 @@ const emptyWidget = (inputs, name) => !linked(inputs?.[name]) && blank(inputs?.[
const picture = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "picture" } : null);
const video = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "video" } : null);
const sound = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "sound file" } : null);
function videoSource(inputs) {
const mode = inputs?.source_mode;
@@ -38,6 +39,7 @@ export const SOURCE_LOADERS = Object.freeze({
AUSBOSS_NODES_ImageCropRotatePad: picture("image"),
// A wired source_image replaces the file, so the file may stay blank.
AUSBOSS_NODES_LoadImagePad: (inputs) => (linked(inputs?.source_image) ? null : picture("image")(inputs)),
AUSBOSS_NODES_LoadAudio: sound("audio"),
AUSBOSS_NODES_LoadVideo: video("video"),
AUSBOSS_NODES_VideoCropRotatePad: videoSource,
AUSBOSS_NODES_VideoCropRotatePadClip: videoSource,
@@ -110,7 +112,8 @@ export function emptySourceMessage(found) {
if (!first) return "";
const more = found.length - 1;
const tail = more > 0 ? ` (and ${more} more node${more === 1 ? "" : "s"})` : "";
return `Load a ${first.kind === "video" ? "video" : "picture"} first: ${first.title}${tail}`;
const noun = first.kind === "video" || first.kind === "sound file" ? first.kind : "picture";
return `Load a ${noun} first: ${first.title}${tail}`;
}
// "Save Image: exact_name may not ..." names the input it is about, after
+183
View File
@@ -0,0 +1,183 @@
// Help while typing in H3 Prompt Writer's script box:
// - a "?" beside "What happens" opens a card of everything the node reads in
// the words (@1, 3s:, a blank line, quotes, sound:, music:, camera:, look:);
// - typing @ lists the pictures plugged in, with their job; the first letters
// of a line offer sound:, music:, camera: and look:; a number offers a time;
// after camera: or look: the node's own choices are listed.
// Suggestions only open on those, so they stay out of other packs' way.
// Picking one types it into the box the browser's way, so undo still works.
import { CHEAT_SHEET, suggestAt } from "./script_assist_math.mjs";
const CSS_ID = "ausboss-script-assist-css";
function ensureCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-help-dot{margin-left:auto;pointer-events:auto;width:15px;height:15px;padding:0;border:1px solid #2f4a48;border-radius:50%;background:transparent;color:#6f8886;font:700 9.5px/13px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:0;text-align:center;cursor:help}
.ausboss-help-dot:hover,.ausboss-help-dot.open{border-color:#00b4aa;color:#a5f3ec}
.ausboss-assist-pop{position:fixed;z-index:10000;box-sizing:border-box;border:1px solid #2a3437;border-radius:8px;background:#0f1516;color:#d8ecea;box-shadow:0 8px 28px rgba(0,0,0,.55);font:500 12px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
.ausboss-assist-help{width:300px;padding:10px 12px}
.ausboss-assist-help h4{margin:0 0 6px;color:#a5f3ec;font-size:12px;font-weight:700}
.ausboss-assist-help dl{display:grid;grid-template-columns:auto 1fr;gap:5px 10px;margin:0}
.ausboss-assist-help dt{color:#f5b53d;font:600 11px/1.35 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap}
.ausboss-assist-help dd{margin:0;color:#c3d6d4}
.ausboss-assist-help p{margin:8px 0 0;color:#6f8886;font-size:11px}
.ausboss-assist-list{min-width:170px;max-width:280px;padding:4px}
.ausboss-assist-item{display:flex;align-items:center;gap:7px;padding:4px 6px;border-radius:5px;cursor:pointer;white-space:nowrap}
.ausboss-assist-item.active{background:rgba(0,180,170,.22)}
.ausboss-assist-item img{flex:none;width:20px;height:20px;border-radius:4px;object-fit:cover}
.ausboss-assist-item b{color:#a5f3ec;font:700 11.5px ui-monospace,SFMono-Regular,Menlo,monospace}
.ausboss-assist-item em{overflow:hidden;color:#6f8886;font-style:normal;text-overflow:ellipsis}
.ausboss-assist-foot{padding:3px 6px 1px;color:#56706d;font-size:10px}
`;
document.head.append(style);
}
const make = (tag, className, text) => {
const element = document.createElement(tag);
if (className) element.className = className;
if (text !== undefined) element.textContent = text;
return element;
};
// Screen position of the caret: a hidden copy of the box with a marker where
// the caret is. The copy sits in the box's own place, so zoom is included.
function caretPoint(area, layer) {
const copy = layer.cloneNode(false);
copy.className = "ausboss-colours";
copy.style.visibility = "hidden";
copy.textContent = area.value.slice(0, area.selectionEnd);
const mark = make("span", "", "​");
copy.append(mark);
area.parentElement.append(copy);
copy.scrollTop = area.scrollTop;
const box = mark.getBoundingClientRect();
copy.remove();
return { x: box.left, y: box.bottom };
}
// node: the H3 Prompt Writer; area: its script box; layer: the coloured copy
// under it; context(): { pictures, cameras, looks, seconds }.
export function attachScriptAssist(node, area, layer, context) {
const none = { dispose: () => {} };
if (!area?.parentElement) return none;
ensureCss();
// The "?" card.
const row = area.closest(".ausboss-card-row");
const header = row?.previousElementSibling?.classList?.contains("ausboss-card-section") ? row.previousElementSibling : null;
let help = null;
let dot = null;
let closeHelpTimer = null;
const closeHelp = () => { help?.remove(); help = null; dot?.classList.remove("open"); };
const openHelp = () => {
clearTimeout(closeHelpTimer);
if (help) return;
help = make("div", "ausboss-assist-pop ausboss-assist-help");
help.append(make("h4", "", "What you can type"));
const list = make("dl");
for (const entry of CHEAT_SHEET) list.append(make("dt", "", entry.code), make("dd", "", entry.text));
help.append(list, make("p", "", "Typing @ lists your pictures. Tab picks a suggestion, Esc closes it."));
help.addEventListener("mouseenter", () => clearTimeout(closeHelpTimer));
help.addEventListener("mouseleave", () => { closeHelpTimer = setTimeout(closeHelp, 250); });
document.body.append(help);
const box = dot.getBoundingClientRect();
const left = Math.max(8, Math.min(window.innerWidth - help.offsetWidth - 8, box.right - help.offsetWidth));
const below = box.bottom + 6;
help.style.left = `${left}px`;
help.style.top = `${below + help.offsetHeight > window.innerHeight - 8 ? Math.max(8, box.top - help.offsetHeight - 6) : below}px`;
dot.classList.add("open");
};
if (header) {
dot = make("button", "ausboss-help-dot", "?");
dot.type = "button";
dot.title = "";
dot.setAttribute("aria-label", "What you can type in the words");
dot.addEventListener("mouseenter", openHelp);
dot.addEventListener("mouseleave", () => { closeHelpTimer = setTimeout(closeHelp, 250); });
dot.addEventListener("pointerdown", (event) => event.stopPropagation());
dot.addEventListener("click", (event) => { event.stopPropagation(); if (help) closeHelp(); else openHelp(); });
header.append(dot);
}
// Suggestions.
let pop = null;
let offer = null;
let active = 0;
const close = () => { pop?.remove(); pop = null; offer = null; };
const take = (item) => {
if (!offer) return;
area.focus();
area.setSelectionRange(offer.from, offer.to);
// insertText keeps the browser's own undo.
if (!document.execCommand?.("insertText", false, item.insert)) {
area.setRangeText(item.insert, offer.from, offer.to, "end");
area.dispatchEvent(new Event("input", { bubbles: true }));
}
close();
};
const draw = () => {
if (!pop || !offer) return;
pop.textContent = "";
offer.items.forEach((item, index) => {
const line = make("div", `ausboss-assist-item${index === active ? " active" : ""}`);
if (item.number && item.picture) { const img = make("img"); img.src = item.picture; line.append(img); }
line.append(make("b", "", item.label));
if (item.hint) line.append(make("em", "", item.hint));
line.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); take(item); });
pop.append(line);
});
pop.append(make("div", "ausboss-assist-foot", offer.strong ? "Enter or Tab to pick · Esc to close" : "Tab to pick · Esc to close"));
};
const update = () => {
if (document.activeElement !== area || area.selectionStart !== area.selectionEnd) return close();
const ctx = context();
const next = suggestAt(area.value, area.selectionEnd, ctx);
if (!next) return close();
for (const item of next.items) if (item.number) item.picture = ctx.pictures.find((picture) => picture.number === item.number)?.url ?? "";
const same = offer && offer.from === next.from && offer.items.map((item) => item.label).join() === next.items.map((item) => item.label).join();
offer = next;
if (!same) active = 0;
if (!pop) { pop = make("div", "ausboss-assist-pop ausboss-assist-list"); document.body.append(pop); }
draw();
const point = caretPoint(area, layer);
pop.style.left = `${Math.max(8, Math.min(window.innerWidth - pop.offsetWidth - 8, point.x))}px`;
pop.style.top = `${point.y + pop.offsetHeight + 4 > window.innerHeight ? point.y - pop.offsetHeight - 22 : point.y + 4}px`;
};
const onKey = (event) => {
if (!offer) return;
const count = offer.items.length;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
active = (active + (event.key === "ArrowDown" ? 1 : count - 1)) % count;
offer.strong = true; // Choosing with the arrows makes Enter pick too.
draw();
} else if (event.key === "Tab" || (event.key === "Enter" && offer.strong && !event.shiftKey)) {
take(offer.items[active]);
} else if (event.key === "Escape") {
close();
} else {
return;
}
event.preventDefault();
event.stopPropagation();
};
const onBlur = () => setTimeout(() => { if (document.activeElement !== area) close(); }, 0);
area.addEventListener("input", update);
area.addEventListener("keydown", onKey, true);
area.addEventListener("blur", onBlur);
area.addEventListener("click", update);
return {
dispose() {
close();
closeHelp();
dot?.remove();
area.removeEventListener("input", update);
area.removeEventListener("keydown", onKey, true);
area.removeEventListener("blur", onBlur);
area.removeEventListener("click", update);
},
};
}
+92
View File
@@ -0,0 +1,92 @@
// Pure decisions behind js/shared/script_assist.mjs: what the help card says,
// and what to suggest at the caret in H3 Prompt Writer's script box. No DOM,
// so tests run it under node:test.
// The help card: each thing the node reads in the words, in plain words.
export const CHEAT_SHEET = [
{ code: "@1 to @4", text: "A picture you plugged in. @1 is Picture 1." },
{ code: "3s:", text: "At the start of a line: this moment starts 3 seconds in. Lines with no time share what is left evenly. Dragging a block's left edge on the bar types this for you." },
{ code: "blank line", text: "A cut to a new shot. No blank lines means one take." },
{ code: "\"in quotes\"", text: "Said out loud, word for word." },
{ code: "sound:", text: "On its own line: what the clip sounds like. Same as the Sound box." },
{ code: "music:", text: "On its own line: the music. Music makes the clip louder." },
{ code: "camera:", text: "On its own line: how the camera moves, like push in or handheld." },
{ code: "look:", text: "On its own line: a style, like anime or watercolour." },
{ code: "no cuts", text: "Anywhere in the words: keep it all one shot." },
];
const LABELS = [
{ word: "sound", hint: "what the clip sounds like" },
{ word: "music", hint: "the music, makes it louder" },
{ word: "camera", hint: "how the camera moves" },
{ word: "look", hint: "a style for the whole clip" },
];
const starts = (value, typed) => value.toLowerCase().startsWith(typed.toLowerCase());
// What to offer at the caret, or null.
// pictures: [{ number, job, plugged }] for @1 to @4
// cameras, looks: the choices the node knows, for camera: and look: lines
// seconds: the clip's length, so a time past the end isn't offered
// The answer replaces text[from, to) with an item's insert. `strong` means
// the person typed a trigger (@, camera:, look:), so Enter may take an item;
// a guess from the first letters of a line only takes Tab or a click, so
// Enter still starts a new line.
export function suggestAt(text, caret, { pictures = [], cameras = [], looks = [], seconds = 8 } = {}) {
const value = String(text ?? "");
const before = value.slice(0, caret);
const line = before.slice(before.lastIndexOf("\n") + 1);
const at = before.match(/(?:^|[^\w@])@(\d?)$/);
if (at) {
const typed = at[1];
const plugged = pictures.filter((picture) => picture.plugged);
const offer = (plugged.length ? plugged : pictures).filter((picture) => String(picture.number).startsWith(typed));
if (!offer.length) return null;
return {
from: caret - typed.length - 1,
to: caret,
strong: true,
items: offer.map((picture) => ({
label: `@${picture.number}`,
insert: `@${picture.number}`,
hint: picture.plugged ? picture.job || "" : "not plugged in",
number: picture.number,
})),
};
}
const choice = line.match(/^\s*(camera|look)\s*:\s*([\w ]*)$/i);
if (choice) {
const typed = choice[2];
const list = choice[1].toLowerCase() === "camera" ? cameras : looks;
const offer = list.filter((name) => starts(name, typed) && name.toLowerCase() !== typed.toLowerCase());
if (!offer.length) return null;
return { from: caret - typed.length, to: caret, strong: true, items: offer.map((name) => ({ label: name, insert: name, hint: "" })) };
}
const time = line.match(/^(\s*)(\d{1,2})$/);
if (time) {
const second = Number(time[2]);
if (second < 1 || second >= seconds) return null;
return {
from: caret - time[2].length,
to: caret,
strong: false,
items: [{ label: `${second}s:`, insert: `${second}s: `, hint: `this moment starts ${second} seconds in` }],
};
}
const word = line.match(/^(\s*)([a-z]{2,6})$/i);
if (word) {
const offer = LABELS.filter((label) => starts(label.word, word[2]) && label.word !== word[2].toLowerCase());
if (!offer.length) return null;
return {
from: caret - word[2].length,
to: caret,
strong: false,
items: offer.map((label) => ({ label: `${label.word}:`, insert: `${label.word}: `, hint: label.hint })),
};
}
return null;
}
+86
View File
@@ -0,0 +1,86 @@
// Colours in H3 Prompt Writer's script box: the words the node reads as
// instructions stand out while you type. @1 is a picture, 3s: is a time, a
// blank line is a cut, sound: and music: lines go to their own part, and
// words in quotes are spoken.
//
// The real textarea stays on top with see-through text, so typing, undo,
// copy and the caret are the browser's own. A copy of the text sits under it
// with the colours. Only colours and backgrounds change, never the font or
// the spacing, so both lay out the same lines.
import { scriptMarkup } from "./script_colours_math.mjs";
const CSS_ID = "ausboss-script-colours-css";
function ensureCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-colours-host{position:relative}
.ausboss-colours-host>:not(.ausboss-colours):not(textarea){display:none !important}
.ausboss-colours{position:absolute;inset:0;box-sizing:border-box;margin:0;overflow:hidden;white-space:pre-wrap;overflow-wrap:break-word;word-break:normal;pointer-events:none;color:#d8ecea;border:1px solid transparent;border-radius:6px;background:#0b0f10}
.ausboss-colours-host>textarea.ausboss-colours-on{position:relative;background:transparent !important;color:transparent !important;caret-color:#d8ecea;-webkit-text-fill-color:transparent}
.ausboss-colours-host>textarea.ausboss-colours-on::placeholder{color:#4f6462;-webkit-text-fill-color:#4f6462}
.ausboss-colours-host>textarea.ausboss-colours-on::selection{background:rgba(0,180,170,.35)}
.ausboss-colours .at{border-radius:3px;background:rgba(0,180,170,.3);box-shadow:0 0 0 1px rgba(0,180,170,.45);color:#a5f3ec}
.ausboss-colours .at.unknown{background:rgba(245,181,61,.18);box-shadow:0 0 0 1px rgba(245,181,61,.5);color:#f5c76b}
.ausboss-colours .time{color:#f5b53d}
.ausboss-colours .key{color:#6f8886}
.ausboss-colours .say{color:#f0dcae}
.ausboss-colours .cut{display:inline-block;width:100%;background:linear-gradient(transparent calc(50% - 1px),#2f4a48 calc(50% - 1px),#2f4a48 50%,transparent 50%);position:relative}
.ausboss-colours .cut::after{content:"cut";position:absolute;right:0;top:0;padding-left:4px;background:#0b0f10;color:#56706d;font-size:9px;letter-spacing:.1em;text-transform:uppercase}
`;
document.head.append(style);
}
// The box's own text settings, copied so the coloured copy wraps the same.
const COPIED = ["fontFamily", "fontSize", "fontWeight", "fontStyle", "lineHeight", "letterSpacing", "wordSpacing", "tabSize", "textIndent", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth"];
export function attachScriptColours(node) {
const none = { sync: () => {}, dispose: () => {} };
const area = node.__ausbossCard?.root?.querySelector?.("textarea.ausboss-card-area");
if (!area?.parentElement) return none;
ensureCss();
const host = area.parentElement;
const layer = document.createElement("div");
layer.className = "ausboss-colours";
layer.setAttribute("aria-hidden", "true");
host.classList.add("ausboss-colours-host");
host.insertBefore(layer, area);
area.classList.add("ausboss-colours-on");
// Another pack that colours every text box puts its own copy beside the
// box; the rule above hides it, so the text never shows twice.
area.dataset.ausbossColoured = "true";
let wiredNumbers = new Set([1, 2, 3, 4]);
const draw = () => {
const style = getComputedStyle(area);
for (const key of COPIED) layer.style[key] = style[key];
// A scroll bar takes width from the text; the copy gives up the same.
const bar = area.offsetWidth - area.clientWidth - (parseFloat(style.borderLeftWidth) || 0) - (parseFloat(style.borderRightWidth) || 0);
layer.style.paddingRight = `${(parseFloat(style.paddingRight) || 0) + Math.max(0, bar)}px`;
layer.innerHTML = scriptMarkup(area.value, (number) => wiredNumbers.has(number));
layer.scrollTop = area.scrollTop;
};
const onScroll = () => { layer.scrollTop = area.scrollTop; };
area.addEventListener("input", draw);
area.addEventListener("scroll", onScroll);
const observer = typeof ResizeObserver === "function" ? new ResizeObserver(draw) : null;
observer?.observe(area);
queueMicrotask(draw);
return {
area,
layer,
// values: the node's widget values; numbers: the pictures plugged in.
sync(values, numbers) {
if (numbers) wiredNumbers = new Set(numbers);
if (document.activeElement !== area && typeof values?.script === "string" && area.value !== values.script) area.value = values.script;
draw();
},
dispose() {
observer?.disconnect();
area.removeEventListener("input", draw);
area.removeEventListener("scroll", onScroll);
},
};
}
+51
View File
@@ -0,0 +1,51 @@
// Pure decisions behind js/shared/script_colours.mjs: which parts of a script
// get which colour. No DOM, so tests run it under node:test.
const escape = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const span = (kind, text) => `<span class="${kind}">${escape(text)}</span>`;
const TOKENS = /(@\d)|("[^"\n]*"?)/g;
const LABEL = /^\s*(sound|music|camera|look):/i;
function lineMarkup(line, wired) {
let head = "";
let rest = line;
const time = rest.match(/^(\s*)(\d+(?:\.\d+)?s:)/);
const key = rest.match(/^(\s*)(sound|music|camera|look):/i);
if (time) { head = escape(time[1]) + span("time", time[2]); rest = rest.slice(time[0].length); }
else if (key) { head = escape(key[1]) + span("key", `${key[2]}:`); rest = rest.slice(key[0].length); }
let out = head;
let at = 0;
for (const match of rest.matchAll(TOKENS)) {
out += escape(rest.slice(at, match.index));
if (match[1]) {
const number = Number(match[1].slice(1));
out += span(number >= 1 && number <= 4 && wired(number) ? "at" : "at unknown", match[1]);
} else {
out += span("say", match[2]);
}
at = match.index + match[0].length;
}
return out + escape(rest.slice(at));
}
// The coloured copy of a script, as HTML. wired(number) says whether a
// picture is plugged in under that @ number; one that isn't shows amber.
// A blank line between two lines is a cut, unless the script says no cuts or
// what follows is a label line.
export function scriptMarkup(text, wired = () => true) {
const lines = String(text ?? "").split("\n");
const cuts = !/\bno cuts\b/i.test(String(text ?? ""));
const filled = lines.map((line) => line.trim() !== "");
// A sound:, music:, camera: or look: line on its own is for the whole clip, not a new shot.
const label = lines.map((line) => LABEL.test(line));
const html = lines.map((line, index) => {
if (filled[index]) return lineMarkup(line, wired);
const before = filled.slice(0, index).some(Boolean);
const next = filled.indexOf(true, index + 1);
const after = next >= 0 && !label[next];
const firstBlank = index > 0 && filled[index - 1];
return cuts && before && after && firstBlank ? '<span class="cut">​</span>' : escape(line);
});
// A box that ends on a new line shows one more empty line.
return html.join("\n") + (String(text ?? "").endsWith("\n") ? " " : "");
}
+136
View File
@@ -0,0 +1,136 @@
// Snap into line: the geometry behind lining a dragged node or group up with
// the ones around it. No LiteGraph here; js/snap_lines/index.js measures the
// canvas, moves the nodes and draws the lines.
//
// A rect is { x, y, w, h } in graph units. Along each axis it offers three
// lines: its start edge, its middle and its end edge. A dragged rect catches
// the nearest target line within reach on each axis, and every line it then
// shares with a target is drawn as a guide.
export const SNAP_SCREEN_PX = 8; // how near, on screen, a line must come to catch
const SAME_LINE = 0.5; // graph units: two lines this close are one line
const TIE = 1e-6;
const SPAN = { x: ["x", "w"], y: ["y", "h"] };
const ACROSS = { x: "y", y: "x" };
export function linesOf(rect, axis) {
const [start, size] = SPAN[axis];
return [rect[start], rect[start] + rect[size] / 2, rect[start] + rect[size]];
}
// How far apart two rects are across `axis` (0 where they overlap there).
// A target in the same row or column as the dragged rect wins a tie.
function gapAcross(a, b, axis) {
const [start, size] = SPAN[ACROSS[axis]];
return Math.max(0, b[start] - (a[start] + a[size]), a[start] - (b[start] + b[size]));
}
// The shortest shift along `axis` that lands one of `lines` on a line of a
// target, or null when no target line is within `reach`.
export function nearestCatch(lines, rect, targets, axis, reach) {
let best = null;
for (const target of targets) {
const gap = gapAcross(rect, target, axis);
for (const to of linesOf(target, axis)) {
for (const from of lines) {
const shift = to - from;
const size = Math.abs(shift);
if (size > reach) continue;
const better = !best || size < best.size - TIE || (size <= best.size + TIE && gap < best.gap);
if (better) best = { shift, size, gap };
}
}
}
return best ? best.shift : null;
}
// Where a dragged rect lands: the shift to add on each axis (0 when that axis
// caught nothing).
export function snapMove(rect, targets, reach) {
const dx = nearestCatch(linesOf(rect, "x"), rect, targets, "x", reach);
const dy = nearestCatch(linesOf(rect, "y"), rect, targets, "y", reach);
return { dx: dx ?? 0, dy: dy ?? 0 };
}
// A resize moves some edges and holds the others. Each moving edge catches
// on its own; a catch that would take the rect under `min` is let go.
export function snapEdges(rect, edges, targets, reach, min = {}) {
let { x, y, w, h } = rect;
const minW = min.w ?? 0;
const minH = min.h ?? 0;
const near = (value, axis) => nearestCatch([value], rect, targets, axis, reach);
if (edges.right) {
const shift = near(x + w, "x");
if (shift != null && w + shift >= minW) w += shift;
} else if (edges.left) {
const shift = near(x, "x");
if (shift != null && w - shift >= minW) {
x += shift;
w -= shift;
}
}
if (edges.bottom) {
const shift = near(y + h, "y");
if (shift != null && h + shift >= minH) h += shift;
} else if (edges.top) {
const shift = near(y, "y");
if (shift != null && h - shift >= minH) {
y += shift;
h -= shift;
}
}
return { x, y, w, h };
}
// The lines of a moved rect, as { axis, at, middle } for guidesFor. A resize
// passes only the edges it moved, so the edges it held draw nothing.
export function rectLines(rect, edges = null) {
const lines = [];
for (const axis of ["x", "y"]) {
const [start, middle, end] = linesOf(rect, axis);
const first = axis === "x" ? "left" : "top";
const last = axis === "x" ? "right" : "bottom";
if (!edges || edges[first]) lines.push({ axis, at: start, middle: false });
if (!edges) lines.push({ axis, at: middle, middle: true });
if (!edges || edges[last]) lines.push({ axis, at: end, middle: false });
}
return lines;
}
// One guide for each of `lines` that a target shares. It runs across the rect
// and every target on that line, and `marks` are where each of them meets it.
export function guidesFor(rect, targets, lines) {
const guides = [];
for (const { axis, at, middle } of lines) {
if (guides.some((guide) => guide.axis === axis && Math.abs(guide.at - at) <= SAME_LINE)) continue;
const on = targets.filter((target) => linesOf(target, axis).some((line) => Math.abs(line - at) <= SAME_LINE));
if (!on.length) continue;
const [start, size] = SPAN[ACROSS[axis]];
const marks = [];
for (const box of [rect, ...on]) marks.push(box[start], box[start] + box[size]);
guides.push({ axis, at, middle, from: Math.min(...marks), to: Math.max(...marks), marks });
}
return guides;
}
export function overlaps(a, b) {
return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
}
// A node counts as inside a group when its middle is.
export function insideAny(rect, groups) {
const cx = rect.x + rect.w / 2;
const cy = rect.y + rect.h / 2;
return groups.some((group) => cx >= group.x && cx <= group.x + group.w && cy >= group.y && cy <= group.y + group.h);
}
export function unionOf(rects) {
if (!rects.length) return null;
const left = Math.min(...rects.map((rect) => rect.x));
const top = Math.min(...rects.map((rect) => rect.y));
const right = Math.max(...rects.map((rect) => rect.x + rect.w));
const bottom = Math.max(...rects.map((rect) => rect.y + rect.h));
return { x: left, y: top, w: right - left, h: bottom - top };
}
+276
View File
@@ -0,0 +1,276 @@
// Step pictures on the pack's faces: Save Image, Save Video and Step Preview.
//
// ComfyUI sends a small picture after each sampling step of a run that asked
// for them (nodes/_step_preview_helpers.py asks when one of these nodes
// shows them). This module listens once for the whole page, keeps what the
// run is doing (step_preview_math.mjs), and lets each face draw it:
// mountStepOverlay lays the picture, a progress line and a short caption
// over a node's own picture box, and stopStepRun is the Stop button.
//
// Runs queued from another workflow tab are left to that tab.
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { nodeByExecutionId } from "./graph_ids.mjs";
import { BRAND } from "./index.mjs";
import { outputRecordQuery } from "./input_preview.mjs";
import { isForeignRun } from "./prompt_scope.mjs";
import {
IDLE,
TILE_SPLIT,
TILE_STITCH,
consumersOf,
drawsTiles,
feedsInto,
nextRunState,
resultRank,
stepFraction,
stepText,
stoppedText,
} from "./step_preview_math.mjs";
const CSS_ID = "ausboss-step-preview-css";
// How long a replaced picture's object URL lives on, so a face that is
// still swapping to the new one never shows a broken picture.
const URL_GRACE_MS = 2000;
let run = IDLE;
let wired = false;
let waiting = 0;
const listeners = new Set();
// The save nodes whose steps switch is on: node -> () => boolean.
const consumers = new Map();
const rootGraph = () => app.rootGraph ?? app.graph ?? null;
function emit() {
for (const listener of [...listeners]) {
try {
listener(run);
} catch {
// One face failing never keeps the picture from the others.
}
}
}
function apply(event) {
const before = run;
run = nextRunState(run, event);
// A step picture the run did not take (a tile kept off the whole one).
if (event.type === "picture" && event.url && event.url !== run.url) URL.revokeObjectURL(event.url);
if (run === before) return;
if (before.url && before.url !== run.url) {
const stale = before.url;
setTimeout(() => URL.revokeObjectURL(stale), URL_GRACE_MS);
}
emit();
}
function on(type, handler) {
api.addEventListener(type, ({ detail }) => {
try {
handler(detail);
} catch {
// Never in the way of a run.
}
});
}
const nodeType = (node) => node?.type ?? node?.comfyClass ?? null;
// The api's "executing" detail is the running node's id, or an object with it.
function executingId(detail) {
if (detail && typeof detail === "object") return detail.display_node ?? detail.node ?? null;
return detail ?? null;
}
// A tiled upscale's split or stitch, by its execution id: true, false or null.
function tileEdge(id) {
if (id === null || id === undefined) return null;
const type = nodeType(nodeByExecutionId(rootGraph(), String(id)));
return type === TILE_SPLIT ? true : type === TILE_STITCH ? false : null;
}
function wire() {
if (wired) return;
wired = true;
on("execution_start", (detail) => {
if (isForeignRun(detail?.prompt_id)) return;
apply({ type: "start", promptId: detail?.prompt_id ?? null });
});
on("b_preview_with_metadata", (detail) => {
if (!detail?.blob || isForeignRun(detail.jobId)) return;
const maker = String(detail.displayNodeId ?? detail.nodeId ?? "");
apply({
type: "picture",
promptId: detail.jobId ?? null,
maker,
url: URL.createObjectURL(detail.blob),
tile: drawsTiles(nodeType(nodeByExecutionId(rootGraph(), maker))),
});
});
// The tiles of a tiled upscale run between its split and its stitch.
on("executing", (detail) => {
const promptId = typeof detail === "object" ? detail?.prompt_id : undefined;
if (isForeignRun(promptId)) return;
const edge = tileEdge(executingId(detail));
if (edge !== null) apply({ type: "tiles", promptId: promptId ?? null, on: edge });
});
// A split kept from the last run sends its tiles on without running.
on("execution_cached", (detail) => {
if (isForeignRun(detail?.prompt_id)) return;
const edges = (detail?.nodes ?? []).map(tileEdge);
// A stitch kept too means no tile runs at all.
if (edges.includes(true) && !edges.includes(false)) {
apply({ type: "tiles", promptId: detail?.prompt_id ?? null, on: true });
}
});
on("progress", (detail) => {
if (isForeignRun(detail?.prompt_id)) return;
apply({ type: "progress", promptId: detail?.prompt_id ?? null, node: detail?.node, value: detail?.value, max: detail?.max });
});
// A node of the run showed a picture: the finished one, for Step Preview.
on("executed", (detail) => {
if (run.status !== "running" || isForeignRun(detail?.prompt_id)) return;
const query = outputRecordQuery(detail?.output);
if (!query) return;
const id = String(detail.display_node ?? detail.node ?? "");
const node = nodeByExecutionId(rootGraph(), id);
const fed = Boolean(node && run.maker && feedsInto(rootGraph(), node, run.maker));
apply({
type: "result",
promptId: detail.prompt_id ?? null,
url: api.apiURL(`/view?${query}${app.getPreviewFormatParam?.() ?? ""}${app.getRandParam?.() ?? ""}`),
rank: resultRank(node?.type ?? node?.comfyClass, fed),
});
});
on("execution_success", (detail) => apply({ type: "end", promptId: detail?.prompt_id ?? null, kind: "success" }));
on("execution_interrupted", (detail) => apply({ type: "end", promptId: detail?.prompt_id ?? null, kind: "interrupted" }));
on("execution_error", (detail) => apply({ type: "end", promptId: detail?.prompt_id ?? null, kind: "error" }));
on("status", (detail) => {
const remaining = Number(detail?.exec_info?.queue_remaining);
const next = Number.isFinite(remaining) ? remaining : 0;
if (next === waiting) return;
waiting = next;
emit();
});
}
// Hear about every change to the run, starting with how it is now.
export function watchSteps(listener) {
wire();
listeners.add(listener);
try {
listener(run);
} catch {
// Same as emit().
}
return () => listeners.delete(listener);
}
export function stepRun() {
return run;
}
// Runs in the queue, the one running included.
export function queuedRuns() {
return waiting;
}
// Stop the run that is making the pictures; the queue goes on.
export async function stopStepRun() {
await api.interrupt(run.status === "running" ? run.promptId ?? undefined : undefined);
}
// Empty the queue, then stop the run.
export async function stopAllRuns() {
await api.clearItems("queue");
await api.interrupt();
}
// A save node whose steps switch is `isOn()`. Returns the way to forget it.
export function addStepConsumer(node, isOn) {
consumers.set(node, isOn);
return () => consumers.delete(node);
}
// Whether `node` (a save node) shows the run's current picture: its switch
// is on and the picture's maker feeds it, or feeds none of them.
export function showsStepsOn(node, current = run) {
if (!current.maker || current.status === "idle") return false;
const isOn = consumers.get(node);
if (!isOn?.()) return false;
const active = [...consumers].filter(([, on]) => on()).map(([consumer]) => consumer);
return consumersOf(rootGraph(), active, current.maker).includes(node);
}
export function ensureStepCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-steps{display:none;position:absolute;inset:0;z-index:4;pointer-events:none;}
.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.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-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;}
.ausboss-video-stage.is-still .ausboss-steps-tools>.ausboss-steps-loop{display:none;}
`;
document.head.appendChild(style);
}
// The step picture laid over `stage` (a node's own picture box, which must
// be positioned). show() draws a run, hide() gives the box back.
export function mountStepOverlay(stage) {
ensureStepCss();
const root = document.createElement("div");
root.className = "ausboss-steps";
const img = document.createElement("img");
img.alt = "";
const line = document.createElement("div");
line.className = "ausboss-steps-line";
const chip = document.createElement("div");
chip.className = "ausboss-steps-chip";
root.append(img, line, chip);
stage.append(root);
let shown = false;
return {
root,
get shown() {
return shown;
},
// `saves`: the node saves the result, so a stopped run says nothing was.
// `result`: show the finished picture once the run has one.
show(current, { saves = false, result = false } = {}) {
const url = (result && current?.result) || current?.url;
if (!url) return this.hide();
if (img.getAttribute("src") !== url) img.src = url;
const stopped = current.status === "stopped";
root.classList.toggle("stopped", stopped);
line.style.width = `${Math.round(stepFraction(current) * 100)}%`;
chip.textContent = stopped ? stoppedText(current, { saves }) : stepText(current);
root.classList.add("on");
stage.classList.add("ausboss-steps-showing");
shown = true;
return true;
},
hide() {
root.classList.remove("on", "stopped");
stage.classList.remove("ausboss-steps-showing");
img.removeAttribute("src");
shown = false;
return false;
},
};
}
+203
View File
@@ -0,0 +1,203 @@
// The pictures ComfyUI sends after each sampling step: what the run is
// doing now, which node made the picture, which nodes show it, and the
// finished picture once the run has saved it.
//
// No DOM and no ComfyUI imports, so node:test covers it
// (tests/step_preview.test.mjs). js/shared/step_preview.mjs wires it to the
// server's events and draws it.
import { executionIdOf } from "./graph_ids.mjs";
import { linkById } from "./input_preview.mjs";
// Nothing running, nothing to show.
export const IDLE = Object.freeze({
status: "idle",
promptId: null,
maker: null,
value: 0,
max: 0,
url: null,
// The finished picture (a /view URL) once a node of this run has saved or
// previewed it, and how well it matches the steps shown (resultRank).
result: null,
resultRank: -1,
// Between a tiled upscale's split and its stitch every step picture is one
// tile, not the whole picture (`tiling`). `tilePass`: the steps counted
// now are a tile's; `whole`: `url` shows the whole picture.
tiling: false,
tilePass: false,
whole: false,
});
const sameId = (a, b) => a !== null && a !== undefined && b !== null && b !== undefined && String(a) === String(b);
// One server event at a time, oldest first; returns the next state and
// never changes the one passed in. Events:
// { type: "start", promptId } a run began
// { type: "picture", promptId, maker, url, tile } a step picture arrived;
// `tile`: its maker draws tiles
// { type: "tiles", promptId, on } a tiled upscale split (on) or
// stitched (off) the picture
// { type: "progress", promptId, node, value, max }
// { type: "result", promptId, url, rank } a node of the run showed a picture
// { type: "end", promptId, kind } "success" | "interrupted" | "error"
// A step's picture arrives just before its progress report, so a picture
// from a new node starts that node's count at nothing until the report.
// A tile's picture is counted but never replaces a whole one: a tile alone
// looks like a zoomed-in crop of the picture.
export function nextRunState(state, event) {
const run = state ?? IDLE;
switch (event?.type) {
case "start":
return { ...IDLE, status: "running", promptId: event.promptId ?? null };
case "picture": {
if (!event.url) return run;
// A late picture from a run that already ended changes nothing.
if (run.status !== "running" && run.status !== "idle" && sameId(event.promptId, run.promptId)) return run;
const otherRun = run.status !== "running" || (event.promptId && run.promptId && !sameId(event.promptId, run.promptId));
const base = otherRun ? { ...IDLE, status: "running", promptId: event.promptId ?? null } : run;
const sameMaker = !otherRun && sameId(base.maker, event.maker);
const tile = Boolean(base.tiling || event.tile);
const keep = tile && Boolean(base.url) && base.whole;
return {
...base,
maker: event.maker ?? null,
url: keep ? base.url : event.url,
whole: keep || !tile,
tilePass: tile,
value: sameMaker ? base.value : 0,
max: sameMaker ? base.max : 0,
// A new step means the picture is being made again (an upscale pass).
result: null,
resultRank: -1,
};
}
case "result": {
if (run.status !== "running" || !event.url) return run;
if (event.promptId && run.promptId && !sameId(event.promptId, run.promptId)) return run;
const rank = Number.isFinite(event.rank) ? event.rank : 0;
if (rank < run.resultRank) return run;
return { ...run, result: event.url, resultRank: rank };
}
case "tiles": {
if (run.status !== "running") return run;
if (event.promptId && run.promptId && !sameId(event.promptId, run.promptId)) return run;
const tiling = Boolean(event.on);
if (tiling === run.tiling) return run;
// The upscale starts before its first tile: say so from the split on.
return tiling ? { ...run, tiling, tilePass: true, value: 0, max: 0 } : { ...run, tiling };
}
case "progress": {
if (run.status !== "running" || !sameId(event.node, run.maker)) return run;
if (event.promptId && run.promptId && !sameId(event.promptId, run.promptId)) return run;
const value = Number(event.value);
const max = Number(event.max);
if (!Number.isFinite(value) || !Number.isFinite(max)) return run;
return { ...run, value, max };
}
case "end": {
if (run.status !== "running") return run;
if (event.promptId && run.promptId && !sameId(event.promptId, run.promptId)) return run;
const status = event.kind === "interrupted" ? "stopped" : event.kind === "error" ? "failed" : "done";
return { ...run, status };
}
default:
return run;
}
}
// "step 3 of 8", or "starting" before the first report; a tiled upscale
// says so ahead of its tile's count.
const TILES_TEXT = "upscaling in tiles";
export function stepText(run) {
if (!(run?.max > 0)) return run?.tilePass ? TILES_TEXT : "starting";
const steps = `step ${Math.min(Math.max(0, run.value), run.max)} of ${run.max}`;
return run.tilePass ? `${TILES_TEXT} · ${steps}` : steps;
}
// What a stopped run leaves on the picture. `saves` adds that the save
// node never got to save it.
export function stoppedText(run, { saves = false } = {}) {
const where = run?.max > 0 ? `stopped at step ${Math.min(run.value, run.max)} of ${run.max}` : "stopped";
return saves ? `${where} · nothing saved` : where;
}
// How far along the picture's node is, 0..1.
export function stepFraction(run) {
if (!(run?.max > 0)) return 0;
return Math.min(1, Math.max(0, run.value / run.max));
}
// The nodes whose steps are tiles: a tiled upscale's split hands each tile
// to the sampler after it (`TILE_SPLIT` .. `TILE_STITCH`), and Ultimate SD
// Upscale samples its tiles inside one node.
export const TILE_SPLIT = "AUSBOSS_NODES_TiledUpscale";
export const TILE_STITCH = "AUSBOSS_NODES_TiledUpscaleStitch";
const TILE_MAKERS = /^UltimateSDUpscale/;
export function drawsTiles(nodeType) {
return TILE_MAKERS.test(String(nodeType ?? ""));
}
// How well a picture a node of the run showed stands for the finished
// result: a save or preview node fed by the sampler whose steps were shown
// is best, any other save or preview node next, anything else last.
const RESULT_NODES = /SaveImage|PreviewImage/;
export function resultRank(nodeType, fedByMaker) {
if (!RESULT_NODES.test(String(nodeType ?? ""))) return 0;
return fedByMaker ? 2 : 1;
}
// The ids of every node a node's inputs reach back to, on its own graph.
// `outside` says the walk met a link from outside that graph (a box's own
// inputs), which it cannot follow.
export function upstreamIds(graph, start) {
const ids = new Set();
let outside = false;
const queue = [start];
const seen = new Set([start]);
while (queue.length) {
const node = queue.pop();
for (const input of node?.inputs ?? []) {
const link = linkById(graph, input?.link);
if (!link) continue;
const id = String(link.origin_id);
ids.add(id);
const origin = graph?.getNodeById?.(link.origin_id) ?? null;
if (!origin) {
outside = true;
continue;
}
if (seen.has(origin)) continue;
seen.add(origin);
queue.push(origin);
}
}
return { ids, outside };
}
// Whether the node with this execution id feeds `consumer` (a Save Image),
// so the steps it draws belong in the consumer's picture. A maker in a box
// counts as the box. When the answer cannot be read off the graph (the
// consumer sits in a box the maker is outside of) the steps are shown.
export function feedsInto(rootGraph, consumer, executionId) {
if (!consumer || executionId === null || executionId === undefined) return false;
const own = executionIdOf(rootGraph, consumer);
if (!own) return true;
const prefix = own.split(":").slice(0, -1);
const maker = String(executionId).split(":");
for (let index = 0; index < prefix.length; index += 1) {
if (maker[index] !== prefix[index]) return true;
}
const local = maker[prefix.length];
if (local === undefined) return true;
return upstreamIds(consumer.graph, consumer).ids.has(local);
}
// Which of the consumers show a maker's steps: the ones it feeds, or every
// one when it feeds none that the graph can tell (a pack's wireless
// set/get nodes break the trail).
export function consumersOf(rootGraph, consumers, executionId) {
const list = [...(consumers ?? [])];
const fed = list.filter((consumer) => feedsInto(rootGraph, consumer, executionId));
return fed.length ? fed : list;
}
+14
View File
@@ -10,6 +10,7 @@
// The backend's choices, in its order; the first is the default.
export const SEAM_CHOICES = ["classic", "blend in"];
export const PASTE_ONTO_CHOICES = ["crop", "original"];
// The gear menu (js/shared/settings_menu.mjs). persist: false - the choice
// belongs to this node and is never a default for new ones.
@@ -27,6 +28,19 @@ export const SEAM_MENU = [
"blend in": "for outpaints and turned pictures; Tone match still works.",
},
},
{
key: "paste_onto",
label: "Paste onto",
type: "choice",
options: PASTE_ONTO_CHOICES,
default: PASTE_ONTO_CHOICES[0],
persist: false,
hint: "Where the result lands. Stored on this node.",
optionHints: {
crop: "gives back the framed picture, as it always has.",
original: "your whole picture, after a hand crop in Crop + Rotate + Pad.",
},
},
];
export function isBlendIn(values) {
+15 -1
View File
@@ -1,6 +1,8 @@
// The Text node's two looks, chosen per node from the gear on its card: the
// usual text box (the default), or "big", a word or two in large centred
// letters that are easy to spot, for a label typed over a picture.
// letters that are easy to spot, for a label typed over a picture. Integer
// and Float take the same two looks, kept in the same property: big is one
// large centred number, for a setting people change on every run.
//
// The choice is kept in the node's properties, so it is saved with the
// workflow and nothing else changes: the node's inputs, its saved widget
@@ -47,6 +49,18 @@ export const TEXT_LOOK_MENU = [
},
];
// The same choice on Integer and Float, said for a number.
export const NUMBER_LOOK_MENU = [
{
...TEXT_LOOK_MENU[0],
hint: "How this box shows its number. Stored on this node.",
optionHints: {
normal: "the usual number box with its label.",
big: "one large centred number, for a setting you change on every run.",
},
},
];
// Room the gear takes from the card's control column. The usual box keeps
// the gear beside it; the big field spans the card and the gear sits in its
// top corner instead.
+32 -2
View File
@@ -68,6 +68,10 @@ const BIG_FONT = 22;
const BIG_LINE = 26;
const BIG_PAD_Y = 4;
const BIG_PAD_X = 30;
// A number row in the big look (Integer and Float): one large centred number
// between a step arrow on each side, in a field as wide as the card, with the
// gear in its corner as on Text.
const BIG_NUMBER_FONT = 30;
export function ensureCardCss() {
if (document.getElementById(CSS_ID)) return;
@@ -90,6 +94,7 @@ export function ensureCardCss() {
.ausboss-card-chip:hover{background:rgba(0,180,170,.24);color:#fff}
.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-control{display:flex;align-items:center;gap:6px;min-width:0;height:100%}
@@ -122,6 +127,19 @@ export function ensureCardCss() {
.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.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>.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: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}}
@@ -203,6 +221,9 @@ function isFloatWidget(widget) {
// - `top` lifts the widgets' sockets
// into the node's slot column
// { section: "Caption" }
// { panel: (node) => ({ element, sync(values) }), height }
// - the node's own display, on a row
// of the card
// { group: "advanced", label: "More" } - a disclosure; later rows with
// group: "advanced" sit under it
// Rows may also carry `mute: (values) => boolean` and `muteTitle`: a muted
@@ -504,6 +525,14 @@ export function mountWidgetCard(node, { rows, minWidth = 300, first = false, hid
state.rows.push({ row: { ...row, inGroup: currentGroup }, element: caption, syncs: [] });
root.append(caption); continue;
}
if (row.panel) {
const line = el("div", "ausboss-card-row panel");
line.style.height = `${rowHeight(row)}px`;
const panel = row.panel(node);
line.append(panel.element);
state.rows.push({ row, element: line, syncs: panel.sync ? [panel.sync] : [] });
root.append(line); continue;
}
if (row.group !== undefined && row.widget === undefined && row.pair === undefined) {
currentGroup = row.group;
const header = el("button", "ausboss-card-group");
@@ -711,9 +740,10 @@ export function mountWidgetCard(node, { rows, minWidth = 300, first = false, hid
if (state.look) root.classList.remove(`look-${state.look}`);
if (look) root.classList.add(`look-${look}`);
state.look = look;
// A textarea row takes its height in this look.
// A textarea row, and any row with a height of its own in some look,
// takes its height in this look.
for (const entry of state.rows) {
if (entry.row.kind !== "textarea") continue;
if (entry.row.kind !== "textarea" && !entry.row.looks) continue;
const height = `${rowHeight(entry.row, look)}px`;
if (entry.row.grow) entry.element.style.minHeight = height;
else entry.element.style.height = height;
+21
View File
@@ -64,6 +64,7 @@ export function cardChangeRestore({ restoring = false, savedHeight, lookChanged
// height a textarea row asks for in the card's look.
export function rowHeight(row, look = null) {
if (row.section !== undefined) return SECTION_HEIGHT;
if (row.panel) return Number(row.height) || ROW_HEIGHT;
if (row.widget === undefined && row.pair === undefined) return GROUP_HEIGHT;
const height = Number(rowInLook(row, look).height);
return height > 0 ? height : ROW_HEIGHT;
@@ -171,6 +172,26 @@ export function resetUnknownValues(widgets, fallbacks = {}) {
return reset;
}
// A widget the card sets from its own controls (a gear menu), or the node's
// own face writes, is never wired. Its input gets one of the card's widget
// types (js/widget_cards), which make the stock widget marked socketless, so
// the frontend gives it no input slot in either renderer: no dot to draw,
// and nothing for a link dragged over the node to land on. A STRING gets a
// one-line text box, a choice the combo.
export const SOCKETLESS_TEXT = "AUSBOSS_SOCKETLESS_TEXT";
export const SOCKETLESS_CHOICE = "AUSBOSS_SOCKETLESS_COMBO";
export function markSocketless(nodeData, names) {
for (const name of names) {
const spec = nodeData?.input?.optional?.[name] ?? nodeData?.input?.required?.[name];
const type = Array.isArray(spec) ? spec[0] : nodeData?.inputs?.[name]?.type;
const widgetType = type === "STRING" ? SOCKETLESS_TEXT : SOCKETLESS_CHOICE;
for (const options of [nodeData?.input?.optional?.[name]?.[1], nodeData?.input?.required?.[name]?.[1], nodeData?.inputs?.[name]]) {
if (options && typeof options === "object") Object.assign(options, { widgetType, socketless: true });
}
}
}
// A choice's words on the card: the row's own, else the value itself.
// `labels` is a map from value to words, or a function of the value and of
// all the values, for a list that is only known on the user's machine (the
+216 -15
View File
@@ -16,6 +16,12 @@
// A made switch can also have an off side: nodes and groups that run only
// while the row is off. Switching the row on turns them off, and switching it
// off turns them on, the opposite of the nodes the row holds itself.
//
// A made switch can also be a choice: one way of setting some nodes, saved
// under a name. Choices have no off side of their own. What a choice changes
// is what it holds differently from the other choices on its card, so two
// saves make a pill and a third value on one control needs nothing more. A
// card that has choices is one pill and nothing else.
// LiteGraph's node modes. Mute is the frontend's "never".
export const MODE_ALWAYS = 0;
@@ -43,7 +49,8 @@ export const DEFAULT_SETTINGS = Object.freeze({
// own off | on) | "pill" (every row is a choice in one pill, one always picked)
show: "rows",
// the made switches: { id, title, nodes, groups, on, off }, and nodes_off /
// groups_off (the off side) while they hold something
// groups_off (the off side) while they hold something, and choice: true for
// a choice
switches: Object.freeze([]),
});
@@ -151,6 +158,7 @@ export function normalizeSwitches(raw) {
groups,
...(nodesOff.length ? { nodes_off: nodesOff } : {}),
...(groupsOff.length ? { groups_off: groupsOff } : {}),
...(item.choice === true ? { choice: true } : {}),
on,
off,
});
@@ -176,9 +184,17 @@ export function normalizeSettings(raw) {
if (typeof source.match === "string") settings.match = source.match.slice(0, 200);
if (typeof source.exclusive === "boolean") settings.exclusive = source.exclusive;
settings.switches = normalizeSwitches(source.switches);
// A card that has choices is one pill, and a group row would be one more
// choice in it.
if (hasChoices(settings)) Object.assign(settings, { exclusive: true, show: "pill", groups: "none" });
return settings;
}
// Whether a card holds choices, read from its settings.
export function hasChoices(settings) {
return Boolean(settings?.switches?.some((entry) => entry.choice));
}
export function offMode(settings) {
return settings?.off === "mute" ? MODE_NEVER : MODE_BYPASS;
}
@@ -405,12 +421,31 @@ function rowAt(row, side) {
return Object.keys(side).every((key) => now[key] === side[key]);
}
// A stack's groups and whether each is switched off. A group is off when any
// of its rows says so; the LoRA Loader keeps them all alike.
function groupStates(rows) {
const states = new Map();
for (const row of rows) {
if (typeof row.group !== "string" || !row.group) continue;
states.set(row.group, Boolean(states.get(row.group)) || row.group_off === true);
}
return states;
}
function withoutGroupOff(row) {
const { group_off, ...rest } = row;
return rest;
}
// The settings a switch changes: every control that held one value when "on"
// was saved and another when "off" was saved. A LoRA stack is compared row
// by row, by file name, so the switch moves only the rows that differ and
// leaves whatever else was changed in the stack since; on such a row it sets
// only what differs between the sides (see rowSides). Returns
// { node, widget, on, off } and, for a stack row, { ..., row }.
// only what differs between the sides (see rowSides). A group of the stack
// switched on in one side and off in the other is one setting of its own:
// the switch moves the group's switch and leaves each row in it to the
// person. Returns { node, widget, on, off } and, for a stack row,
// { ..., row }, for a stack group { ..., group }.
export function valuePairs(entry) {
const pairs = [];
for (const [id, on] of Object.entries(entry?.on ?? {})) {
@@ -430,13 +465,39 @@ export function valuePairs(entry) {
const sides = other && rowSides(row, other);
if (sides) pairs.push({ node: id, widget, row: row.name, on: sides[0], off: sides[1] });
}
const offGroups = groupStates(offRows);
for (const [group, onOff] of groupStates(onRows)) {
if (!offGroups.has(group) || offGroups.get(group) === onOff) continue;
pairs.push({ node: id, widget, group, on: { group_off: onOff }, off: { group_off: offGroups.get(group) } });
}
}
}
return pairs;
}
// What a choice changes: every setting it holds differently from another
// choice on the card. A setting all of them hold alike is left alone, so a
// node can be saved whole and still be changed by hand. A choice has no off
// value of its own: `off` here is only the first other value found.
export function choicePairs(entry, switches) {
const found = new Map();
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 });
}
}
return [...found.values()];
}
// Where a setting stands now: at its on value, its off value, or neither.
export function pairNow(pair, current) {
if (pair.group !== undefined) {
const rows = stackRows(current);
const off = rows ? groupStates(rows).get(pair.group) : undefined;
if (off === undefined) return "other";
return off === pair.on.group_off ? "on" : off === pair.off.group_off ? "off" : "other";
}
if (pair.row === undefined) return current === pair.on ? "on" : current === pair.off ? "off" : "other";
const rows = stackRows(current);
const row = rows && findRow(rows, pair.row);
@@ -447,8 +508,16 @@ export function pairNow(pair, current) {
// The value a control should hold for one side of a switch. For a stack row
// it is the stack as it is now with that one row changed, and only in what
// the switch sets on it: the row's other strengths and its triggers stay.
// For a stack group it is every row now in that group, rows added since the
// switch was saved included, and only the group's switch changes.
export function pairValue(pair, side, current) {
const want = side === "on" ? pair.on : pair.off;
if (pair.group !== undefined) {
const rows = stackRows(current);
if (!rows) return current;
return JSON.stringify(rows.map((row) => row.group !== pair.group ? row
: want.group_off ? { ...row, group_off: true } : withoutGroupOff(row)));
}
if (pair.row === undefined) return want;
const rows = stackRows(current);
if (!rows) return current;
@@ -480,6 +549,47 @@ export function stackRowOwners(boards, nodeId, widget, rowName) {
return owners;
}
// The made switches that move one group of a LoRA stack, answered like
// stackRowOwners. A group is known by its exact name.
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(
(pair) => pair.group === group && String(pair.node) === String(nodeId) && pair.widget === widget,
);
if (moves) owners.push({ node, id: entry.id, title: entry.title });
}
}
return owners;
}
// Rewrite one stack value of a switch's saved sides, row list in, row list out.
function changeStackSides(entry, nodeId, widget, change) {
const edit = (side) => {
const values = side?.[String(nodeId)];
const rows = stackRows(values?.[widget]);
if (!rows) return side;
return { ...side, [String(nodeId)]: { ...values, [widget]: JSON.stringify(change(rows)) } };
};
return { on: edit(entry.on), off: edit(entry.off) };
}
// Take one group of a LoRA stack away from a switch: both saved sides forget
// whether it was switched off, so the switch leaves it alone.
export function releaseStackGroup(settings, id, nodeId, widget, group) {
return changeSwitch(settings, id, (entry) => changeStackSides(entry, nodeId, widget,
(rows) => rows.map((row) => row.group === group ? withoutGroupOff(row) : row)));
}
// A group renamed in the stack: every switch that saved it follows the name.
export function renameStackGroup(settings, nodeId, widget, from, to) {
const current = normalizeSettings(settings);
const rename = (rows) => rows.map((row) => row.group === from ? { ...row, group: to } : row);
return withSwitches(current, current.switches.map((entry) => tidy({ ...entry, ...changeStackSides(entry, nodeId, widget, rename) })));
}
// Take one row of a LoRA stack away from a switch: both of its saved sides
// forget that row, so the switch leaves it alone and no longer reads
// "changed" because of it. The rest of what the switch does stays.
@@ -496,14 +606,16 @@ export function releaseStackRow(settings, id, nodeId, widget, rowName) {
// A switch's settings as they stand on the graph: each pair with its node,
// its control and where it is now. A pair whose node or control is gone is
// left out.
export function readPairs(entry, nodeById) {
// left out. A choice's setting is either at its value or not: the next choice
// along is a third value, not a change made by hand.
export function readPairs(entry, nodeById, held = valuePairs(entry)) {
const pairs = [];
for (const pair of valuePairs(entry)) {
for (const pair of held) {
const live = nodeById.get(String(pair.node))?.node;
const control = live?.widgets?.find((widget) => widget?.name === pair.widget);
if (!control) continue;
pairs.push({ ...pair, live, control, now: pairNow(pair, control.value) });
const now = pairNow(pair, control.value);
pairs.push({ ...pair, live, control, now: pair.choice && now === "other" ? "off" : now });
}
return pairs;
}
@@ -606,7 +718,8 @@ export function readSwitches(switches, placed, groupRows, { counts = () => true
valueNodes.push(hit.node);
rects.push(hit.rect);
}
const pairs = readPairs(entry, nodeById);
const pairs = readPairs(entry, nodeById, entry.choice ? choicePairs(entry, switches) : valuePairs(entry));
const stand = partState(members, counts, pairs, offMembers);
return {
kind: "switch",
entry,
@@ -617,10 +730,13 @@ export function readSwitches(switches, placed, groupRows, { counts = () => true
offMembers,
valueNodes,
pairs,
// Saved for one side only so far: nothing to switch between yet.
half: [...sides].some((id) => !(entry.on?.[id] && entry.off?.[id])),
// Saved for one side only so far: nothing to switch between yet. A
// choice is in the same place until another choice differs from it.
half: entry.choice ? !pairs.length : [...sides].some((id) => !(entry.on?.[id] && entry.off?.[id])),
gone,
...partState(members, counts, pairs, offMembers),
...stand,
// A choice is picked or it is not, however many settings match.
...(entry.choice && stand.state === "mixed" ? { state: "off" } : {}),
};
});
}
@@ -741,22 +857,32 @@ export function switchAllPlan(rows, on, settings) {
// The settings one click changes, as [pair, side]: every setting of the row
// goes to its on or its off value, and one at a time first sends the other
// rows' settings to off. Only the settings not already there are returned.
// One setting's address: a control, or one row or group of a LoRA stack.
function pairKey(pair) {
const part = pair.row ?? (pair.group !== undefined ? `\u0000group:${pair.group}` : "");
return `${pair.node}|${pair.widget}|${part}`;
}
export function valuePlan(rows, target, on, settings) {
const sides = new Map();
const key = (pair) => `${pair.node}|${pair.widget}|${pair.row ?? ""}`;
if (on && settings?.exclusive) {
for (const row of rows) {
if (row === target) continue;
for (const pair of row.pairs ?? []) sides.set(key(pair), [pair, "off"]);
for (const pair of row.pairs ?? []) if (!pair.choice) sides.set(pairKey(pair), [pair, "off"]);
}
}
for (const pair of target?.pairs ?? []) sides.set(key(pair), [pair, on ? "on" : "off"]);
for (const pair of target?.pairs ?? []) {
// A choice has no off: another choice is what replaces it.
if (on || !pair.choice) sides.set(pairKey(pair), [pair, on ? "on" : "off"]);
}
return [...sides.values()].filter(([pair, side]) => pair.now !== side);
}
export function valueAllPlan(rows, on) {
const sides = new Map();
for (const row of rows) for (const pair of row.pairs ?? []) sides.set(`${pair.node}|${pair.widget}|${pair.row ?? ""}`, [pair, on ? "on" : "off"]);
for (const row of rows) {
for (const pair of row.pairs ?? []) if (!pair.choice) sides.set(pairKey(pair), [pair, on ? "on" : "off"]);
}
return [...sides.values()].filter(([pair, side]) => pair.now !== side);
}
@@ -836,6 +962,19 @@ export function choosePlan(rows, target, settings) {
return { modes: switchPlan(rows, target, true, one), values: valuePlan(rows, target, true, one) };
}
// What settles a One at a time card that has more than one row on or partly
// on, which a click can't cause but a new switch or the setting itself can:
// nodes on a canvas start out running, so the rows they make read on. One row
// stays on and the others go off. `fresh` is a row just made, which gives way
// to the rows that were there; a row that is fully on goes before one that is
// partly on, then the top one.
export function settlePlan(rows, settings, fresh = null) {
const running = (rows ?? []).filter((row) => row.state === "on" || row.state === "mixed");
if (!settings?.exclusive || running.length < 2) return { modes: [], values: [] };
const others = running.filter((row) => row !== fresh);
return choosePlan(rows, others.find((row) => row.state === "on") ?? others[0], settings);
}
// ---------- making and changing switches ----------
function withSwitches(settings, switches) {
@@ -879,6 +1018,37 @@ export function addSwitch(settings, { title, nodes, groups } = {}) {
return { settings: withSwitches(current, [...current.switches, entry]), id };
}
// A new choice at the end of the list, holding what `snapshots` hold
// ([{ id, values }], as saveValues takes them). Answers like addSwitch.
export function addChoice(settings, { title, snapshots } = {}) {
const made = addSwitch(settings, { title });
if (made.id === null) return made;
const saved = saveValues(changeSwitch(made.settings, made.id, () => ({ choice: true })), made.id, "on", snapshots);
return { settings: normalizeSettings(saved), id: made.id };
}
// The node ids a card's choices hold: a choice made next holds them too, so
// the second one needs no selection.
export function choiceNodeIds(settings) {
return [...new Set((settings?.switches ?? []).flatMap((entry) => (entry.choice ? Object.keys(entry.on ?? {}) : [])))];
}
// What a choice may take from a selection: a node the card's choices set stays
// on, or picking one would switch off what it just set.
export function freeForChoice(settings, ids) {
const set = new Set(choiceNodeIds(settings));
return { ...ids, nodes: (ids?.nodes ?? []).filter((id) => !set.has(String(id))) };
}
// The choice on the card that `snapshots` would only repeat, or null: nothing
// they share differs, so the two could not be told apart.
export function sameChoice(settings, snapshots) {
const on = Object.fromEntries((snapshots ?? []).map((item) => [String(item.id), item.values]));
return (settings?.switches ?? []).find((entry) => entry.choice
&& Object.keys(on).some((id) => id in entry.on)
&& !valuePairs({ on, off: entry.on }).length) ?? null;
}
// An off side nobody is on is left out of the switch, the way
// normalizeSwitches stores it.
function tidy(entry) {
@@ -1033,6 +1203,37 @@ export function saveValues(settings, id, side, snapshots) {
}));
}
// ---------- after a paste ----------
// A pasted card's switches, pointed at the pasted copies of their parts.
// `nodes` and `groups` map an old id (as text) to its copy's id
// (js/shared/paste_ids.mjs). An id the paste did not copy is kept, so a card
// pasted on its own still switches the parts it switched before. Returns
// null when no id changes.
export function repointSwitches(raw, nodes, groups) {
const settings = normalizeSettings(raw);
let changed = false;
const follow = (map) => (id) => {
const copy = map?.get(String(id));
if (copy === undefined || copy === null) return id;
changed = true;
return copy;
};
const node = follow(nodes);
const group = follow(groups);
const side = (values) => Object.fromEntries(Object.entries(values).map(([id, held]) => [String(node(id)), held]));
const switches = settings.switches.map((entry) => ({
...entry,
nodes: entry.nodes.map(node),
groups: entry.groups.map(group),
...(entry.nodes_off ? { nodes_off: entry.nodes_off.map(node) } : {}),
...(entry.groups_off ? { groups_off: entry.groups_off.map(group) } : {}),
on: side(entry.on),
off: side(entry.off),
}));
return changed ? normalizeSettings({ ...settings, switches }) : null;
}
// ---------- change detection ----------
// Everything the card draws, as one string: when it matches the last one,
+369
View File
@@ -0,0 +1,369 @@
import { app } from "/scripts/app.js";
import { BRAND, chainCallback } from "../shared/index.mjs";
import {
SNAP_SCREEN_PX,
guidesFor,
insideAny,
overlaps,
rectLines,
snapEdges,
snapMove,
unionOf,
} from "../shared/snap_lines.mjs";
import { boundsOf, nodeElements, outlineOf } from "../shared/outline.mjs";
// Snap into line: a node or group being dragged lines up with the nodes and
// groups around it, and a teal line shows what it lines up with. A corner
// being pulled lines up the same way. Shift drags freely, which is what
// ComfyUI's own grid snap uses Shift for.
//
// The frontend's own drag moves the items first: the canvas's pointermove in
// the classic look, a requestAnimationFrame write in Nodes 2.0. This file
// listens on the window behind it. Which items move is read from what moved,
// not from the selection rules, so a group's contents, Ctrl's frame-only drag
// and an Alt-drag copy all come out right. Each step places every moving item
// at its start plus the pointer's travel plus the catch, so a catch never
// builds up across steps.
const SETTING = "AusBoss.Canvas.SnapLines";
// Another pack's snapping moving the same nodes would fight this one, so this
// one steps aside while it is on.
const OTHER_SNAPPERS = ["Pixaroma.Align.Enabled"];
const MARK_PX = 2.5;
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"
drag: null, // the running move, or { idle: true } for a gesture left alone
guides: [],
frame: 0,
pending: null,
};
function settingOn(id) {
try {
return app.ui?.settings?.getSettingValue?.(id) === true;
} catch {
return false;
}
}
function vueNodes() {
return globalThis.LiteGraph?.vueNodesMode === true;
}
function itemsOf(graph) {
const nodes = graph?._nodes ?? graph?.nodes ?? [];
const groups = graph?._groups ?? graph?.groups ?? [];
const reroutes = graph?.reroutes?.values ? [...graph.reroutes.values()] : [];
return { nodes, groups, reroutes };
}
function pair(value) {
return value ? [value[0], value[1]] : null;
}
// Every item's place and outline when the button went down. Items that do
// not move keep these for the whole drag, so they are measured only here.
function takeBefore(canvas) {
const before = new Map();
const { nodes, groups, reroutes } = itemsOf(canvas.graph);
const keep = (item, kind, rect) => {
if (item?.pos) before.set(item, { kind, pos: pair(item.pos), size: pair(item.size), rect });
};
const elements = nodeElements();
for (const node of nodes) keep(node, "node", outlineOf(node, canvas, { elements }));
for (const group of groups) keep(group, "group", boundsOf(group));
for (const reroute of reroutes) keep(reroute, "reroute", null);
return before;
}
function graphPoint(canvas, event) {
const box = canvas.canvas.getBoundingClientRect();
const { scale, offset } = canvas.ds;
return [(event.clientX - box.left) / scale - offset[0], (event.clientY - box.top) / scale - offset[1]];
}
function reachOf(canvas) {
return SNAP_SCREEN_PX / (canvas.ds?.scale || 1);
}
function viewOf(canvas, reach) {
const area = canvas.visible_area;
if (!area) return null;
return { x: area[0] - reach, y: area[1] - reach, w: area[2] + 2 * reach, h: area[3] + 2 * reach };
}
// The nodes and groups a moving rect can line up with: the ones on screen
// that are not moving. A moving group skips the nodes inside other groups, so
// it lines up with their frames rather than with what sits inside them.
function targetsFor(canvas, moving, { skipGrouped = false } = {}) {
const view = viewOf(canvas, reachOf(canvas));
const seen = (rect) => !view || overlaps(rect, view);
const groupRects = [];
const nodeRects = [];
for (const [item, { kind, rect }] of state.before) {
if (!rect || moving.has(item)) continue;
if (kind === "group") groupRects.push(rect);
else if (kind === "node") nodeRects.push(rect);
}
const targets = groupRects.filter(seen);
for (const rect of nodeRects) {
if (!seen(rect)) continue;
if (skipGrouped && insideAny(rect, groupRects)) continue;
targets.push(rect);
}
return targets;
}
function setGuides(guides) {
if (!guides.length && !state.guides.length) return;
state.guides = guides;
app.canvas?.setDirty?.(true, false);
}
function sameSize(a, b) {
if (!a || !b) return a === b;
return a[0] === b[0] && a[1] === b[1];
}
// The first step after anything moved: which items move, where each one
// started, and the outline that lines up.
function startMove(canvas, event) {
const { nodes, groups, reroutes } = itemsOf(canvas.graph);
const movers = [];
for (const item of [...nodes, ...groups, ...reroutes]) {
if (!item?.pos || item.pinned) continue;
const was = state.before.get(item);
if (!was) {
if (item.selected) movers.push(item); // a copy made by Alt-drag
continue;
}
if (item.pos[0] !== was.pos[0] || item.pos[1] !== was.pos[1]) movers.push(item);
}
if (!movers.length) return null;
for (const item of movers) {
const was = state.before.get(item);
if (was && !sameSize(pair(item.size), was.size)) return { idle: true }; // a resize
}
const outlines = [];
for (const item of movers) {
if (!item.size) continue; // reroutes ride along but are too small to line up
const was = state.before.get(item);
if (!was) {
// A new copy has not been drawn yet, so measure it where it is now.
item.updateArea?.(canvas.ctx);
const rect = outlineOf(item, canvas);
if (rect) outlines.push(rect);
continue;
}
const rect = was.rect;
if (!rect) continue;
outlines.push({ ...rect, x: rect.x + item.pos[0] - was.pos[0], y: rect.y + item.pos[1] - was.pos[1] });
}
const box = unionOf(outlines);
if (!box) return { idle: true };
const moving = new Set(movers);
return {
anchor: graphPoint(canvas, event),
box,
items: movers.map((item) => ({ item, start: pair(item.pos) })),
moving,
skipGrouped: movers.some((item) => groups.includes(item)),
};
}
function place(item, x, y) {
if (item.pos[0] === x && item.pos[1] === y) return;
item.pos = [x, y];
}
function stepMove(canvas, drag, event) {
const point = graphPoint(canvas, event);
const dx = point[0] - drag.anchor[0];
const dy = point[1] - drag.anchor[1];
const raw = { ...drag.box, x: drag.box.x + dx, y: drag.box.y + dy };
let shift = { dx: 0, dy: 0 };
let targets = [];
if (!event.shiftKey) {
targets = targetsFor(canvas, drag.moving, drag);
shift = snapMove(raw, targets, reachOf(canvas));
}
for (const { item, start } of drag.items) place(item, start[0] + dx + shift.dx, start[1] + dy + shift.dy);
const placed = { ...raw, x: raw.x + shift.dx, y: raw.y + shift.dy };
setGuides(targets.length ? guidesFor(placed, targets, rectLines(placed)) : []);
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.
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.
const corner = state.corner;
const was = state.before.get(node);
if (event.shiftKey || typeof corner !== "string" || !was?.rect) return setGuides([]);
const title = was.pos[1] - was.rect.y;
const edges = { left: corner.includes("W"), right: corner.includes("E"), top: corner.includes("N"), bottom: corner.includes("S") };
const rect = { x: node.pos[0], y: node.pos[1] - title, w: node.size[0], h: node.size[1] + title };
const least = node.computeSize?.() ?? [0, 0];
const targets = targetsFor(canvas, new Set([node]));
const placed = snapEdges(rect, edges, targets, reachOf(canvas), { w: least[0], h: least[1] + title });
if (placed.x !== rect.x || placed.y !== rect.y || placed.w !== rect.w || placed.h !== rect.h) {
node.pos = [placed.x, placed.y + title];
node.setSize([placed.w, placed.h - title]);
}
setGuides(guidesFor(placed, targets, rectLines(placed, edges)));
}
// A group is pulled by its bottom-right corner in both looks. Its own nodes
// stay targets, so the frame can be fitted to them.
function stepGroupResize(canvas, group, event) {
if (event.shiftKey) return setGuides([]);
const rect = { x: group.pos[0], y: group.pos[1], w: group.size[0], h: group.size[1] };
const edges = { right: true, bottom: true };
const targets = targetsFor(canvas, new Set([group]));
const LGraphGroup = globalThis.LiteGraph?.LGraphGroup;
const least = { w: LGraphGroup?.minWidth ?? 0, h: LGraphGroup?.minHeight ?? 0 };
const placed = snapEdges(rect, edges, targets, reachOf(canvas), least);
if (placed.w !== rect.w || placed.h !== rect.h) group.resize(placed.w, placed.h);
setGuides(guidesFor(placed, targets, rectLines(placed, edges)));
}
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 (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);
}
function onPointerDown(event) {
end();
if (event.button !== 0 || !state.enabled) return;
if (globalThis.LiteGraph?.alwaysSnapToGrid) return; // the grid places every drop
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);
}
function cornerOf(canvas) {
const corner = canvas?.pointer?.resizeDirection;
return typeof corner === "string" && corner ? corner : null;
}
// Behind LiteGraph's own pointerdown, which names the corner a resize pulls.
function afterPointerDown() {
if (state.before) state.corner = cornerOf(app.canvas) ?? state.corner;
}
function onPointerMove(event) {
if (!state.before) return;
if (!(event.buttons & 1)) return end();
if (!vueNodes()) return step(event);
// Nodes 2.0 writes a dragged node in a frame callback queued by this same
// event, so queue behind it and keep only the newest event.
state.pending = event;
if (state.frame) return;
state.frame = requestAnimationFrame(() => {
state.frame = 0;
const latest = state.pending;
state.pending = null;
if (latest && state.before) step(latest);
});
}
function end() {
state.before = null;
state.corner = null;
state.drag = null;
state.pending = null;
setGuides([]);
}
function drawGuides(canvas, ctx) {
if (!state.guides.length || !canvas?.ds) return;
let pen = ctx;
const overlay = vueNodes() ? canvas.overlayCtx : null;
if (overlay && canvas.overlayCanvas) {
// Nodes 2.0 draws nodes above the canvas; its link overlay sits above them.
pen = overlay;
pen.save();
const ratio = canvas.overlayCanvas.width / (canvas.overlayCanvas.clientWidth || 1);
pen.setTransform(ratio, 0, 0, ratio, 0, 0);
canvas.ds.toCanvasContext(pen);
} else {
pen.save();
}
const unit = 1 / (canvas.ds.scale || 1);
const overhang = OVERHANG_PX * unit;
pen.strokeStyle = BRAND;
pen.fillStyle = BRAND;
pen.lineWidth = unit;
for (const guide of state.guides) {
pen.setLineDash(guide.middle ? [4 * unit, 3 * unit] : []);
pen.beginPath();
if (guide.axis === "x") {
pen.moveTo(guide.at, guide.from - overhang);
pen.lineTo(guide.at, guide.to + overhang);
} else {
pen.moveTo(guide.from - overhang, guide.at);
pen.lineTo(guide.to + overhang, guide.at);
}
pen.stroke();
pen.beginPath();
for (const mark of guide.marks) {
const [x, y] = guide.axis === "x" ? [guide.at, mark] : [mark, guide.at];
pen.moveTo(x + MARK_PX * unit, y);
pen.arc(x, y, MARK_PX * unit, 0, Math.PI * 2);
}
pen.fill();
}
pen.restore();
}
function install() {
window.addEventListener("pointerdown", onPointerDown, true);
window.addEventListener("pointerdown", afterPointerDown);
window.addEventListener("pointermove", onPointerMove);
// Capture phase: the classic canvas stops its pointerup from bubbling.
window.addEventListener("pointerup", end, true);
window.addEventListener("pointercancel", end, true);
window.addEventListener("blur", end);
if (app.canvas) {
chainCallback(app.canvas, "onDrawForeground", function (ctx) {
drawGuides(this, ctx);
});
}
}
app.registerExtension({
name: "AusBoss.SnapLines",
settings: [
{
id: SETTING,
name: "Snap into line while dragging",
type: "boolean",
defaultValue: true,
category: ["🆎 AusBoss", "Canvas", "Snap into line"],
tooltip:
"Nodes and groups you drag or resize line up with the ones around them, " +
"and a teal line shows what they line up with. Hold Shift while dragging to skip it.",
onChange(value) {
state.enabled = value !== false;
if (!state.enabled) end();
},
},
],
setup() {
// onChange only fires on later edits on some frontends, so read the
// stored value here.
state.enabled = app.ui?.settings?.getSettingValue?.(SETTING) !== false;
install();
},
});
+148
View File
@@ -0,0 +1,148 @@
// Step Preview 🆎: the picture while it is being made, one step at a time,
// with a Stop button. It needs no wires: it shows whichever sampler in this
// workflow is making a picture, then the finished picture once the run has
// saved it, and keeps that after the run.
//
// The pictures and the run come from shared/step_preview.mjs; the server
// side asks for them (nodes/_step_preview_helpers.py).
import { app } from "/scripts/app.js";
import { BRAND, chainCallback, keepDomWidgetWidthAuto } from "../shared/index.mjs";
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
import { ensureStepCss, mountStepOverlay, queuedRuns, stopAllRuns, stopStepRun, watchSteps } from "../shared/step_preview.mjs";
const NODE_CLASS = "AUSBOSS_NODES_StepPreview";
const CSS_ID = "ausboss-step-preview-node-css";
const WIDGET_NAME = "ausboss_step_preview_panel";
const DEFAULT_SIZE = [320, 340];
const PANEL_MIN_WIDTH = 240;
const BAR_HEIGHT = 20;
const STAGE_MIN_HEIGHT = 140;
const PANEL_MIN_HEIGHT = BAR_HEIGHT + 4 + STAGE_MIN_HEIGHT + 6;
const IDLE_HINT = "Run the workflow to watch the picture form here.";
function ensureCss() {
ensureStepCss();
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-steppv{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-steppv-bar{box-sizing:border-box;flex:none;display:flex;align-items:center;gap:6px;height:${BAR_HEIGHT}px;padding:0 2px;}
.ausboss-steppv-bar button{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;white-space:nowrap;cursor:pointer;pointer-events:auto;}
.ausboss-steppv-bar button:hover{border-color:${BRAND};color:#fff;background:rgba(0,79,75,.78);}
.ausboss-steppv-bar button:disabled{opacity:.38;cursor:default;pointer-events:none;}
.ausboss-steppv-bar button.ausboss-steps-stop:disabled{border-color:rgba(0,180,170,.3)!important;background:rgba(0,0,0,.5)!important;color:#7d9593!important;}
.ausboss-steppv-bar button.ausboss-steps-stop:disabled::before{background:#5f7674;}
.ausboss-steppv-bar .all[hidden]{display:none;}
.ausboss-steppv-state{margin-left:auto;color:#5f7674;font:10px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.08em;text-transform:uppercase;white-space:nowrap;}
.ausboss-steppv-state.live{color:${BRAND};}
.ausboss-steppv-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-steppv-stage{box-sizing:border-box;min-height:${STAGE_MIN_HEIGHT}px;}
.ausboss-steppv-stage.ausboss-steps-showing .ausboss-steps-hint{display:none;}
`;
document.head.appendChild(style);
}
function button(label, title, onClick, signal) {
const element = document.createElement("button");
element.type = "button";
element.textContent = label;
element.title = title;
// A press here is a click on the button, not the start of a node drag.
element.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
Promise.resolve(onClick()).catch(() => {});
}, { signal });
return element;
}
const STATE_TEXT = { running: "running", done: "done", stopped: "stopped", failed: "failed" };
function sync(state, run) {
const running = run.status === "running";
const waiting = Math.max(0, queuedRuns() - (running ? 1 : 0));
state.stop.disabled = !running;
state.all.hidden = waiting < 1;
state.all.textContent = `STOP ALL · ${waiting}`;
state.all.title = `Stop this run and empty the queue (${waiting} waiting).`;
state.readout.textContent = STATE_TEXT[run.status] ?? "";
state.readout.classList.toggle("live", running);
// The finished picture replaces the blurry last step as soon as the run
// saves it, and stays after the run, until the next one starts.
if (run.url || run.result) state.overlay.show(run, { result: true });
else if (running || run.status === "idle") state.overlay.hide();
}
function buildPanel(node) {
if (node.__ausbossStepPreview) return node.__ausbossStepPreview;
ensureCss();
const abort = new AbortController();
const root = document.createElement("div");
root.className = "ausboss-steppv";
const bar = document.createElement("div");
bar.className = "ausboss-steppv-bar";
const stage = document.createElement("div");
stage.className = "ausboss-steppv-stage";
const hint = document.createElement("div");
hint.className = "ausboss-steps-hint";
hint.textContent = IDLE_HINT;
stage.append(hint);
root.append(bar, stage);
const stop = button("STOP", "Stop this run now. The next run in the queue still starts.", stopStepRun, abort.signal);
stop.classList.add("ausboss-steps-stop");
const all = button("STOP ALL", "Stop this run and empty the queue.", stopAllRuns, abort.signal);
all.classList.add("all");
all.hidden = true;
const readout = document.createElement("span");
readout.className = "ausboss-steppv-state";
bar.append(stop, all, readout);
const widget = node.addDOMWidget(WIDGET_NAME, "ausboss_step_preview", root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => PANEL_MIN_HEIGHT + WIDGET_FRAME,
});
keepDomWidgetWidthAuto(widget);
// Not saved with the workflow either: options.serialize only keeps it out
// of the prompt, and saved values come back by position.
widget.serialize = false;
fillNodeHeight(widget, {
minWidth: PANEL_MIN_WIDTH,
minHeight: () => PANEL_MIN_HEIGHT + WIDGET_FRAME,
minNodeSize: [PANEL_MIN_WIDTH, PANEL_MIN_HEIGHT + 40],
exactMinWidth: true,
});
const state = { node, root, stage, stop, all, readout, widget, abort };
state.overlay = mountStepOverlay(stage);
state.forget = watchSteps((run) => sync(state, run));
node.__ausbossStepPreview = state;
return state;
}
app.registerExtension({
name: "ausboss.step_preview",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_CLASS) return;
chainCallback(nodeType.prototype, "onNodeCreated", function () {
buildPanel(this);
// A new node opens at a picture's size; onConfigure puts a saved
// workflow's own size back after this.
this.setSize?.([
Math.max(this.size?.[0] ?? 0, DEFAULT_SIZE[0]),
Math.max(this.size?.[1] ?? 0, DEFAULT_SIZE[1]),
]);
});
chainCallback(nodeType.prototype, "onRemoved", function () {
const state = this.__ausbossStepPreview;
if (!state) return;
state.forget?.();
state.abort.abort();
this.__ausbossStepPreview = null;
});
},
});
+93 -38
View File
@@ -9,12 +9,14 @@ import { app } from "/scripts/app.js";
import { chainCallback, notifyAusbossChange } from "../shared/index.mjs";
import { formatWidgetVisibility } from "../shared/save_video_formats.mjs";
import { hideInputsInDef } from "../shared/widget_visibility.mjs";
import { castChips } from "../shared/h3_cast_chips.mjs";
import { momentStrip } from "../shared/moment_strip.mjs";
import { mountWidgetCard } from "../shared/widget_card.mjs";
import { commitWidgetValue, resetUnknownValues } from "../shared/widget_card_math.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";
import { SEAM_MENU, SEAM_MUTE_TITLES, isBlendIn, seamCornerReserve } from "../shared/stitch_seam.mjs";
import { TEXT_LOOK_MENU, setTextLook, textLook, textLookCornerReserve } from "../shared/text_look.mjs";
import { NUMBER_LOOK_MENU, TEXT_LOOK_MENU, setTextLook, textLook, textLookCornerReserve } from "../shared/text_look.mjs";
import { fineEdgeLabel } from "../shared/touch_math.mjs";
import { valueToTake, wiredLimits } from "../shared/wired_limits.mjs";
@@ -44,9 +46,9 @@ function seamCorner(node, card) {
schema: SEAM_MENU,
anchor: gear.getBoundingClientRect(),
title: "Stitch Inpaint settings",
initial: { seam: card.values().seam },
initial: { seam: card.values().seam, paste_onto: card.values().paste_onto },
onChange: (values, key) => {
if (key === "seam") card.setWidget("seam", values.seam);
if (key === "seam" || key === "paste_onto") card.setWidget(key, values[key]);
},
});
gear.addEventListener("click", open);
@@ -67,20 +69,21 @@ function seamCorner(node, card) {
},
};
}
// Text: the gear in the card's corner picks the node's look, the usual box
// or big centred letters (shared/text_look.mjs). The look is kept on the
// node, not in a widget, so the node's inputs and saved values never change.
function textLookCorner(node, card) {
// Text, Integer and Float: the gear in the card's corner picks the node's
// look, the usual box or big and centred (shared/text_look.mjs). The look is
// kept on the node, not in a widget, so the node's inputs and saved values
// never change.
const lookCorner = (schema, title) => (node, card) => {
const gear = document.createElement("button");
gear.type = "button";
gear.className = "ausboss-card-gear";
gear.title = "Text settings";
gear.title = title;
gear.innerHTML = gearIconSvg();
gear.addEventListener("click", () => openSettingsMenu({
scope: "text_look",
schema: TEXT_LOOK_MENU,
schema,
anchor: gear.getBoundingClientRect(),
title: "Text settings",
title,
initial: { look: textLook(node) },
onChange: (values) => {
if (values.look === textLook(node)) return;
@@ -91,7 +94,9 @@ function textLookCorner(node, card) {
},
}));
return { element: gear, sync: () => textLookCornerReserve(textLook(node)) };
}
};
const textLookCorner = lookCorner(TEXT_LOOK_MENU, "Text settings");
const numberLookCorner = lookCorner(NUMBER_LOOK_MENU, "Number settings");
// Save Video rows that only some formats read (crf, save_metadata).
const formatReads = (name) => (values) => formatWidgetVisibility(String(values.format))[name];
@@ -140,11 +145,12 @@ const CARDS = {
AUSBOSS_NODES_StitchInpaint: {
minWidth: 300,
// A workflow saved before Seam existed holds the card's own empty value
// in the slot Seam now takes; it loads as classic.
resetUnknown: { seam: "classic" },
// Seam has no row and no socket: the gear menu sets it.
hide: ["seam"],
socketless: ["seam"],
// in the slot Seam now takes; it loads as classic. One saved with Seam
// holds it in Paste onto's slot, and loads as crop.
resetUnknown: { seam: "classic", paste_onto: "crop" },
// Seam and Paste onto have no row and no socket: the gear menu sets them.
hide: ["seam", "paste_onto"],
socketless: ["seam", "paste_onto"],
corner: seamCorner,
rows: [
{ widget: "color_match", label: "Tone match" },
@@ -198,9 +204,11 @@ const CARDS = {
],
},
// The settings of Image Folder. Its gallery (js/image_folder) sits under
// the card and owns the hidden `pictures` widget.
// the card and owns the hidden `pictures` widget. The ticks on the gallery
// write it, so it takes no link: a second text link would land there once
// Folder is wired.
AUSBOSS_NODES_ImageFolder: {
minWidth: 320, first: true, hide: ["pictures"],
minWidth: 320, first: true, hide: ["pictures"], socketless: ["pictures"],
rows: [
{ widget: "source", label: "From", kind: "segment" },
{ widget: "folder", label: "Folder", placeholder: "the folder itself",
@@ -215,9 +223,11 @@ const CARDS = {
],
},
// The settings of Mask by Touch. Its picture (js/mask_by_touch) sits above
// the card and owns the hidden `touches` and `ask` widgets.
// the card and owns the hidden `touches` and `ask` widgets. The picture
// writes both, so neither takes a link: with a socket, a text link dragged
// over the node lit a drop target at the card's top edge.
AUSBOSS_NODES_MaskByTouch: {
minWidth: 400, hide: ["touches", "ask"],
minWidth: 400, hide: ["touches", "ask"], socketless: ["touches", "ask"],
rows: [
{ widget: "grow", label: "Grow", suffix: "px" },
// Half a pixel a step, a tenth with Shift.
@@ -299,9 +309,16 @@ const CARDS = {
// Like ComfyUI's own Primitive node, both take over the limits of the
// input they are wired to: wired to a sampler's denoise, Float stops at 1.
// A new box also takes the value of the first input it is wired to
// (`takesWired`), so wiring it up does not change that setting.
AUSBOSS_NODES_Integer: { minWidth: 260, takesWired: "value", rows: [{ widget: "value", label: "Value", limits: wiredLimits }] },
AUSBOSS_NODES_Float: { minWidth: 260, takesWired: "value", rows: [{ widget: "value", label: "Value", decimals: 3, trimZeros: true, limits: wiredLimits }] },
// (`takesWired`), so wiring it up does not change that setting. The gear
// picks the look; big is one large centred number.
AUSBOSS_NODES_Integer: {
minWidth: 260, takesWired: "value", look: textLook, corner: numberLookCorner,
rows: [{ widget: "value", label: "Value", limits: wiredLimits, looks: { big: { height: 56 } } }],
},
AUSBOSS_NODES_Float: {
minWidth: 260, takesWired: "value", look: textLook, corner: numberLookCorner,
rows: [{ widget: "value", label: "Value", decimals: 3, trimZeros: true, limits: wiredLimits, looks: { big: { height: 56 } } }],
},
// The gear picks the look; big is a compact field of large centred
// letters, for a word or two over a picture.
AUSBOSS_NODES_Text: {
@@ -309,6 +326,34 @@ const CARDS = {
rows: [{ widget: "text", kind: "textarea", placeholder: "Prompt, caption, or shared text", height: 100, grow: true,
looks: { big: { height: 62, placeholder: "type here" } } }],
},
// 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.
AUSBOSS_NODES_H3PromptWriter: {
minWidth: 420,
hide: ["job_1", "job_2", "job_3", "job_4", "video_job"],
socketless: ["job_1", "job_2", "job_3", "job_4", "video_job"],
resetUnknown: { sound: "", music: "" },
rows: [
{ section: "What happens" },
{
widget: "script", kind: "textarea", height: 118, grow: true,
placeholder: "@1 walks through a crowded night market.\n\n@1 stands on a rooftop in the rain.\n\nOne line for each thing that happens. A blank line cuts to a new place. @1 is Picture 1.",
},
{ panel: momentStrip, height: 76 },
{ widget: "sound", label: "Sound", placeholder: "rain on a window" },
{ widget: "music", label: "Music", placeholder: "slow piano: music makes it louder" },
{ section: "Pictures and settings" },
{ panel: castChips, height: 30 },
{ widget: "seconds", label: "Length", suffix: "s" },
{ widget: "camera", label: "Camera", kind: "select" },
{ widget: "look", label: "Look", kind: "select" },
{ group: "more", label: "More" },
{ widget: "song_start", label: "Song starts at", suffix: "s", decimals: 1, trimZeros: true, group: "more" },
{ widget: "cut_on_beat", label: "Moments on the beat", group: "more" },
{ widget: "language", label: "Language", group: "more" },
],
},
// Saved by a 1.x release, these two hold that empty value too and open
// with preview on, like Mask Refine.
AUSBOSS_NODES_SelectFrame: { minWidth: 280, first: true, resetUnknown: { preview: true }, rows: [{ widget: "frame_number", label: "Frame" }] },
@@ -339,6 +384,16 @@ const CARDS = {
{ widget: "max_frames", label: "Limit" },
],
},
AUSBOSS_NODES_LoadAudio: {
// The wave, IN and OUT live in the panel under the card (js/load_audio).
minWidth: 280, first: true, hide: ["upload"],
rows: [
{ widget: "audio", label: "Source", kind: "select",
button: { text: "Upload", title: "Upload a sound file into ComfyUI's input folder", onClick: (node) => node.widgets?.find((w) => w.name === "upload")?.callback?.() } },
{ widget: "max_seconds", label: "Max length", suffix: "s", decimals: 1, trimZeros: true,
title: "The longest the sound may be. Anything past it is cut off and shows in red. 0 means no limit." },
],
},
AUSBOSS_NODES_LoadImagePad: {
minWidth: 340, first: true, hide: ["upload"],
rows: [
@@ -358,7 +413,8 @@ const CARDS = {
],
},
AUSBOSS_NODES_SaveVideo: {
minWidth: 320, first: true,
// step_preview is the STEPS button on the player (js/save_video).
minWidth: 320, first: true, hide: ["step_preview"],
rows: [
{ widget: "filename_prefix", label: "Prefix", placeholder: "video" },
{ widget: "format", label: "Format", kind: "select" },
@@ -376,21 +432,12 @@ function cardWidgetNames(config) {
return [...config.rows.flatMap((row) => row.pair ?? (row.widget ? [row.widget] : [])), ...(config.hide ?? [])];
}
// A choice a card sets from its own controls (a gear menu), never from a
// link: the stock combo, marked socketless so the frontend gives it no input
// slot - no dot to draw or aim at in either renderer, no row to reserve.
const SOCKETLESS_CHOICE = "AUSBOSS_SOCKETLESS_COMBO";
function markSocketless(nodeData, names) {
for (const name of names) {
for (const spec of [nodeData?.input?.optional?.[name]?.[1], nodeData?.input?.required?.[name]?.[1], nodeData?.inputs?.[name]]) {
if (spec && typeof spec === "object") Object.assign(spec, { widgetType: SOCKETLESS_CHOICE, socketless: true });
}
}
}
app.registerExtension({
name: "ausboss.widget_cards",
// The widgets of the inputs a card lists as socketless (markSocketless in
// shared/widget_card_math.mjs): the stock ones, marked socketless so the
// frontend gives them no input slot - no dot to draw or aim at in either
// renderer, no row to reserve.
getCustomWidgets() {
return {
// inputData is the input's spec: its options hold the choices.
@@ -401,6 +448,14 @@ app.registerExtension({
widget.options.socketless = true;
return { widget };
},
// A one-line STRING, as the stock text widget is made.
[SOCKETLESS_TEXT](node, inputName, inputData) {
const spec = inputData?.[1] ?? {};
const widget = node.addWidget("text", inputName, spec.default ?? "", () => {}, {});
if (typeof spec.dynamicPrompts === "boolean") widget.dynamicPrompts = spec.dynamicPrompts;
widget.options.socketless = true;
return { widget };
},
};
},
beforeRegisterNodeDef(nodeType, nodeData) {
+1
View File
@@ -151,6 +151,7 @@ function ensureCss() {
.ausboss-note-file{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:1px;}
.ausboss-note-name{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#e6f4f3;font:11.5px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;pointer-events:auto;cursor:copy;}
.ausboss-note-meta{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#78908e;font-size:10.5px;}
.ausboss-note-file .ausboss-note-meta{white-space:normal;overflow-wrap:anywhere;line-height:1.35;}
.ausboss-note-dl{flex:none;display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 9px;border:1px solid var(--accent);border-radius:5px;background:rgba(0,180,170,.14);color:#e6f4f3;font-size:11px;font-weight:600;text-decoration:none;white-space:nowrap;pointer-events:auto;cursor:pointer;}
.ausboss-note-dl:hover{background:var(--accent);color:#08211f;}
.ausboss-note-dl .size{font-weight:400;opacity:.8;}
+173 -53
View File
@@ -19,6 +19,8 @@
// A card with One at a time on can show its rows as one control instead
// (Show as: one pill): every row is a choice in a segmented pill, or in a
// list when the names are too many or too long, and one is always picked.
// + Choice gets there in one press: it saves how nodes are set now under a
// name, and the card becomes that pill by itself.
//
// Only what the user made is stored (which nodes each switch holds, by id,
// the values it remembers and the settings, in node.properties). Every row's
@@ -36,12 +38,13 @@ import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { holdNodeMinWidth, holdVueNodeMinWidth, pinVuePanelHeight } from "../shared/panel_layout.mjs";
import { cardHeight, commitWidgetValue, ensureCardCss } from "../shared/widget_card.mjs";
import {
DEFAULT_SETTINGS, SETTINGS_PROPERTY, SWITCHES_MAX, TITLE_MAX, addMembers, addOffMembers, addSwitch, cardView, choiceState,
choosePlan, flipTo, frameBounds, groupBounds, hasOffSide, heldCount, listedRows, modeMatters, moveSwitch, normalizeSettings,
offFreshCount, offHeldCount, pairValue, placeNodes, readGroups, readSwitches, removeMembers, removeOffMembers, removeSwitch,
renameSwitch, rowLabel, rowsSignature, saveValues, snapshotNode, suggestTitle, switchAllPlan, switchPlan, valueAllPlan,
valuePlan,
DEFAULT_SETTINGS, SETTINGS_PROPERTY, SWITCHES_MAX, TITLE_MAX, addChoice, addMembers, addOffMembers, addSwitch, cardView, choiceNodeIds,
choiceState, choosePlan, flipTo, frameBounds, freeForChoice, groupBounds, hasChoices, hasOffSide, heldCount, listedRows, modeMatters, moveSwitch,
normalizeSettings, offFreshCount, offHeldCount, pairValue, placeNodes, readGroups, readSwitches, removeMembers, removeOffMembers,
removeSwitch, renameSwitch, repointSwitches, rowLabel, rowsSignature, sameChoice, saveValues, settlePlan, snapshotNode, suggestTitle,
switchAllPlan, switchPlan, valueAllPlan, valuePlan,
} from "../shared/workflow_switches.mjs";
import { onPaste } from "../shared/paste_ids.mjs";
const NODE_CLASS = "AUSBOSS_NODES_WorkflowSwitches";
const CSS_ID = "ausboss-workflow-switches-css";
@@ -90,6 +93,9 @@ function ensureCss() {
.ausboss-ws .ausboss-ws-row{grid-template-columns:10px minmax(0,1fr) auto 84px;gap:7px}
.ausboss-ws.edit .ausboss-ws-row{grid-template-columns:10px minmax(0,1fr) auto 84px 26px 26px}
.ausboss-ws-chip{width:10px;height:10px;border-radius:3px;background:var(--chip);box-shadow:inset 0 0 0 1px rgba(255,255,255,.16)}
.ausboss-ws .ausboss-ws-row.choice{grid-template-columns:10px minmax(0,1fr) auto}
.ausboss-ws.edit .ausboss-ws-row.choice{grid-template-columns:10px minmax(0,1fr) auto 26px 26px}
.ausboss-ws-row.choice .ausboss-ws-pill{display:none}
.ausboss-ws-row.off .ausboss-ws-chip{background:transparent;box-shadow:inset 0 0 0 2px var(--chip),0 0 0 1px rgba(255,255,255,.1)}
.ausboss-ws-row.mixed .ausboss-ws-chip{background:linear-gradient(90deg,var(--chip) 50%,transparent 50%);box-shadow:inset 0 0 0 2px var(--chip),0 0 0 1px rgba(255,255,255,.1)}
.ausboss-ws-row.empty .ausboss-ws-chip{opacity:.4}
@@ -126,6 +132,8 @@ function ensureCss() {
.ausboss-ws-hint.link:hover{color:#d8ecea}
.ausboss-ws-hint.hidden,.ausboss-ws-bar.hidden{display:none}
.ausboss-ws .ausboss-ws-bar{grid-template-columns:minmax(0,1fr) auto;gap:7px}
.ausboss-ws .ausboss-ws-bar.both{grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto}
.ausboss-ws-add.hidden{display:none}
.ausboss-ws-add{min-width:0;height:100%;padding:0 10px;border:1px dashed #33514f;border-radius:6px;background:transparent;color:#78908e;font:600 11px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:left;cursor:pointer}
.ausboss-ws-add.ready{border-style:solid;border-color:rgba(0,180,170,.55);background:rgba(0,180,170,.14);color:#d8ecea}
.ausboss-ws-add:hover{border-color:${BRAND};color:#fff}
@@ -368,12 +376,28 @@ function chooseRow(state, key) {
refresh(state);
}
// Nodes just given to a choice follow the pick at once: off, when another
// choice is picked.
function repick(state) {
const row = choiceState(read(state).rows).picked;
if (row) chooseRow(state, rowKey(row));
}
function switchAll(state, on) {
const { settings, rows } = read(state);
applyPlan(state, switchAllPlan(rows, on, settings), valueAllPlan(rows, on));
refresh(state);
}
// One at a time holds for rows that did not come from a click on a row: a
// new switch (`fresh`, its key) and the setting being turned on.
function settleRows(state, fresh) {
const { settings, rows } = read(state);
const { modes, values } = settlePlan(rows, settings, rows.find((row) => rowKey(row) === fresh));
applyPlan(state, modes, values);
refresh(state);
}
function frameRow(state, key) {
const row = read(state).rows.find((candidate) => rowKey(candidate) === key);
const found = row?.kind === "group" ? groupBounds(row.group) : row?.rect;
@@ -435,7 +459,10 @@ function segmentRow(state, key, label, title, values, labels = {}, { flips = fal
const buttons = new Map();
for (const value of values) {
const choice = button("", labels[value] ?? String(value), title);
choice.addEventListener("click", () => patchSettings(state, { [key]: flips ? !settingsOf(state.node)[key] : value }));
choice.addEventListener("click", () => {
patchSettings(state, { [key]: flips ? !settingsOf(state.node)[key] : value });
if (key === "exclusive") settleRows(state);
});
seg.append(choice);
buttons.set(value, choice);
}
@@ -521,13 +548,15 @@ function buildSettings(state) {
const rows = { off, groups, match: { row: match.row }, order, exclusive, show };
const sync = (settings, edit, open) => {
// A card of choices is one pill: none of these mean anything on it.
const shown = edit && !hasChoices(settings);
header.classList.toggle("open", open);
header.style.display = edit ? "" : "none";
header.style.display = shown ? "" : "none";
const listing = settings.groups !== "none";
for (const entry of Object.values(rows)) entry.row.classList.toggle("hidden", !edit || !open);
match.row.classList.toggle("hidden", !edit || !open || settings.groups !== "matching");
order.row.classList.toggle("hidden", !edit || !open || !listing);
show.row.classList.toggle("hidden", !edit || !open || !settings.exclusive);
for (const entry of Object.values(rows)) entry.row.classList.toggle("hidden", !shown || !open);
match.row.classList.toggle("hidden", !shown || !open || settings.groups !== "matching");
order.row.classList.toggle("hidden", !shown || !open || !listing);
show.row.classList.toggle("hidden", !shown || !open || !settings.exclusive);
for (const entry of [off, groups, order, exclusive, show]) entry.sync(settings);
if (document.activeElement !== text) text.value = settings.match;
};
@@ -568,9 +597,56 @@ function newSwitch(state) {
return;
}
writeSettings(state, made.settings);
settleRows(state, `s${made.id}`);
startRename(state, `s${made.id}`);
}
// The nodes to read for a choice: what is selected, and on a card that already
// 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);
return [...new Set([...nodes, ...held])]
.map((node) => ({ id: node.id, values: snapshotNode(node) }))
.filter((item) => Object.keys(item.values).length);
}
// Save how the nodes are set right now as a choice with a name. The new
// choice is what the nodes hold already, so it reads as the picked one and
// nothing on the graph changes.
function newChoice(state) {
const settings = settingsOf(state.node);
const first = !hasChoices(settings);
const snapshots = choiceSnapshots(state, picked(state).nodes);
if (!snapshots.length) {
showToast({ summary: "Workflow Switches", detail: "Select a node with settings first, set it the way this choice should be, then press + Choice." });
return;
}
const same = sameChoice(settings, snapshots);
if (same) {
showToast({ summary: "Workflow Switches", detail: `That is how "${same.title}" is set. Change a setting first, then press + Choice.` });
return;
}
const title = suggestTitle({ nodes: [{ title: "Choice" }] }, settings.switches.map((entry) => entry.title));
const made = addChoice(settings, { title, snapshots });
if (made.id === null) {
showToast({ summary: "Workflow Switches", severity: "warn", detail: `One card holds up to ${SWITCHES_MAX} choices.` });
return;
}
writeSettings(state, made.settings);
startRename(state, `s${made.id}`);
if (first) showToast({ summary: "Workflow Switches", detail: "Saved. Now set the node another way and press + Choice again." });
}
// Save a choice again, the way its nodes and any selected ones are set now.
function resaveChoice(state, key, pick) {
const row = read(state).rows.find((candidate) => rowKey(candidate) === key);
if (!row) return;
const snapshots = choiceSnapshots(state, [...pick.nodes, ...row.valueNodes]);
writeSettings(state, saveValues(settingsOf(state.node), Number(key.slice(1)), "on", snapshots));
showToast({ summary: "Workflow Switches", detail: `"${row.title}" now holds what its nodes are set to.` });
}
function startRename(state, key) {
const entry = state.rows.get(key);
if (!entry?.name) return;
@@ -651,9 +727,9 @@ function showMenu(state, key, anchor) {
const entry = settings.switches[index];
if (!entry) return;
const pick = picked(state);
const ids = pickedIds(pick);
const ids = entry.choice ? freeForChoice(settings, pickedIds(pick)) : pickedIds(pick);
const held = heldCount(entry, ids);
const fresh = pick.count - held;
const fresh = ids.nodes.length + ids.groups.length - held;
const offHeld = offHeldCount(entry, ids);
const offFresh = offFreshCount(entry, ids);
const inSwitch = held + offHeld;
@@ -672,44 +748,61 @@ function showMenu(state, key, anchor) {
item("Rename", () => startRename(state, key));
item("Select its nodes", () => selectMembers(state, key), { title: "Select every node this switch turns off and on" });
rule();
item(
fresh > 0 ? `Add the ${fresh} selected` : "Add the selected",
() => writeSettings(state, addMembers(settings, id, ids)),
{ disabled: fresh < 1, title: "Select nodes or groups on the canvas first, then add them to this switch" },
);
item(
const takeOut = () => item(
inSwitch > 0 ? `Take out the ${inSwitch} selected` : "Take out the selected",
() => writeSettings(state, removeMembers(settings, id, ids)),
{ disabled: inSwitch < 1, title: "Select nodes this switch holds first, then take them out of it" },
);
rule();
// The off side: nodes that run only while this switch is off.
item(
offFresh > 0 ? `The ${offFresh} selected ${offFresh === 1 ? "runs" : "run"} only while this is off` : "The selected run only while this is off",
() => writeSettings(state, addOffMembers(settings, id, ids)),
{
disabled: offFresh < 1,
title: `Select nodes or groups first. They run only while this switch is off, and are ${settings.off === "mute" ? "muted" : "bypassed"} while it is on.`,
},
);
item(
offHeld > 0 ? `Take the ${offHeld} selected off that side` : "Take the selected off that side",
() => writeSettings(state, removeOffMembers(settings, id, ids)),
{ disabled: offHeld < 1, title: "Select nodes that run only while this is off, then take them off that side. They stay as they are." },
);
rule();
// A switch that changes settings instead of turning nodes off.
menu.append(el("div", "ausboss-ws-menu-head sub", "Change settings instead"));
const count = pick.nodes.length;
const how = "Makes this a switch that changes settings instead of turning nodes off. Set the selected nodes the way they should be for this side, then save it. Do both sides: the switch changes the values that differ.";
item(count ? `Save the ${count} selected as on` : "Save the selected as on", () => saveSide(state, key, "on", pick), { disabled: !count, title: how });
item(count ? `Save the ${count} selected as off` : "Save the selected as off", () => saveSide(state, key, "off", pick), { disabled: !count, title: how });
if (entry.choice) {
item("Save how it is set now", () => resaveChoice(state, key, pick), {
title: "Set the nodes the way this choice should be, then pick this",
});
item(
fresh > 0 ? `The ${fresh} selected ${fresh === 1 ? "runs" : "run"} only with this choice` : "The selected run only with this choice",
() => {
writeSettings(state, addMembers(settings, id, ids));
repick(state);
},
{ disabled: fresh < 1, title: "Select the nodes only this choice needs. They are switched off while another choice is picked." },
);
takeOut();
}
else {
item(
fresh > 0 ? `Add the ${fresh} selected` : "Add the selected",
() => writeSettings(state, addMembers(settings, id, ids)),
{ disabled: fresh < 1, title: "Select nodes or groups on the canvas first, then add them to this switch" },
);
takeOut();
rule();
// The off side: nodes that run only while this switch is off.
item(
offFresh > 0 ? `The ${offFresh} selected ${offFresh === 1 ? "runs" : "run"} only while this is off` : "The selected run only while this is off",
() => writeSettings(state, addOffMembers(settings, id, ids)),
{
disabled: offFresh < 1,
title: `Select nodes or groups first. They run only while this switch is off, and are ${settings.off === "mute" ? "muted" : "bypassed"} while it is on.`,
},
);
item(
offHeld > 0 ? `Take the ${offHeld} selected off that side` : "Take the selected off that side",
() => writeSettings(state, removeOffMembers(settings, id, ids)),
{ disabled: offHeld < 1, title: "Select nodes that run only while this is off, then take them off that side. They stay as they are." },
);
rule();
// A switch that changes settings instead of turning nodes off.
menu.append(el("div", "ausboss-ws-menu-head sub", "Change settings instead"));
const count = pick.nodes.length;
const how = "Makes this a switch that changes settings instead of turning nodes off. Set the selected nodes the way they should be for this side, then save it. Do both sides: the switch changes the values that differ.";
item(count ? `Save the ${count} selected as on` : "Save the selected as on", () => saveSide(state, key, "on", pick), { disabled: !count, title: how });
item(count ? `Save the ${count} selected as off` : "Save the selected as off", () => saveSide(state, key, "off", pick), { disabled: !count, title: how });
}
rule();
item("Move up", () => writeSettings(state, moveSwitch(settings, id, -1)), { disabled: index < 1 });
item("Move down", () => writeSettings(state, moveSwitch(settings, id, 1)), { disabled: index >= settings.switches.length - 1 });
rule();
item("Delete this switch", () => writeSettings(state, removeSwitch(settings, id)), {
danger: true, title: "Removes the switch. Its nodes stay as they are.",
item(entry.choice ? "Delete this choice" : "Delete this switch", () => writeSettings(state, removeSwitch(settings, id)), {
danger: true, title: `Removes the ${entry.choice ? "choice" : "switch"}. Its nodes stay as they are.`,
});
document.body.append(menu);
@@ -793,6 +886,8 @@ function buildRow(state, key, made) {
row.classList.toggle("off", data.state === "off");
row.classList.toggle("mixed", data.state === "mixed");
row.classList.toggle("empty", data.state === "empty");
const choice = Boolean(data.entry?.choice);
row.classList.toggle("choice", choice);
chip.style.setProperty("--chip", data.color ?? (made ? BRAND : NO_COLOR));
title.textContent = label;
const pairs = data.pairs ?? [];
@@ -801,7 +896,7 @@ function buildRow(state, key, made) {
const tail = data.members.length > 6 ? ` and ${data.members.length - 6} more` : "";
const offNames = offNodes.slice(0, 6).map(nodeTitle).join(", ");
const offTail = offNodes.length > 6 ? ` and ${offNodes.length - 6} more` : "";
const sets = pairs.slice(0, 6).map((pair) => `${nodeTitle(pair.live)} (${pair.row ?? pair.widget})`).join(", ");
const sets = pairs.slice(0, 6).map((pair) => `${nodeTitle(pair.live)} (${pair.row ?? (pair.group !== undefined ? `group ${pair.group}` : pair.widget)})`).join(", ");
const parts = [];
if (data.members.length) parts.push(`turns off and on ${names}${tail}`);
if (offNodes.length) parts.push(`runs only while it is off: ${offNames}${offTail}`);
@@ -813,7 +908,7 @@ function buildRow(state, key, made) {
: "Nothing is on its off side right now: the nodes or groups put there are gone or empty.";
}
title.title = made
? parts.length ? `${label}: ${parts.join("; ")}` : data.half ? `${label}: saved for one side only` : `${label}: its nodes are gone`
? parts.length ? `${label}: ${parts.join("; ")}` : data.half ? `${label}: ${choice ? "add one more choice to pick between" : "saved for one side only"}` : `${label}: its nodes are gone`
: `${label}: every node inside this group`;
const waiting = data.half && !pairs.length && !data.members.length;
// A setting changed by hand to a third value is neither on nor off.
@@ -822,13 +917,13 @@ function buildRow(state, key, made) {
const unit = offNodes.length
? pairs.length ? "nodes and settings are set for on" : "nodes are set for on"
: pairs.length ? "nodes and settings are on" : "nodes run";
count.textContent = changed ? "changed" : data.state === "mixed" ? `${data.on}/${data.total}` : waiting ? "half set" : data.state === "empty" ? "empty" : "";
count.textContent = changed ? "changed" : data.state === "mixed" ? `${data.on}/${data.total}` : waiting ? (choice ? "needs one more" : "half set") : data.state === "empty" ? "empty" : "";
count.title = changed
? "A setting this switch changes was set by hand to something else. Click off or on to set it again."
: data.state === "mixed" ? `${data.on} of its ${data.total} ${unit}`
: waiting ? "Saved for one side only. Set its nodes the other way, select them, and save that side from its menu."
: waiting ? (choice ? "Set the node another way and press + Choice." : "Saved for one side only. Set its nodes the other way, select them, and save that side from its menu.")
: data.state !== "empty" ? ""
: made ? "Its nodes are gone. Add some from its menu in the edit view, or delete the switch." : "No nodes inside this group yet";
: made ? "None of this switch's nodes are in this workflow. A workflow pasted and saved on an older AusBoss version stays like this: open the original file again, or add nodes from the switch's menu in the edit view." : "No nodes inside this group yet";
pill.classList.toggle("mixed", data.state === "mixed");
offButton.classList.toggle("on", data.state === "off");
onButton.classList.toggle("on", data.state === "on");
@@ -970,8 +1065,11 @@ function measureCard(listed, edit, open, settings, picking) {
if (picking) rows.push({ widget: "pick" });
if (!picking || edit) for (let index = 0; index < listed; index += 1) rows.push({ widget: "switch" });
if (edit) {
rows.push({ widget: "bar" }, { group: "settings" });
if (open) for (let index = 0; index < settingsRowCount(settings); index += 1) rows.push({ widget: "setting" });
rows.push({ widget: "bar" });
if (!hasChoices(settings)) {
rows.push({ group: "settings" });
if (open) for (let index = 0; index < settingsRowCount(settings); index += 1) rows.push({ widget: "setting" });
}
}
return cardHeight(rows);
}
@@ -1023,8 +1121,16 @@ function render(state, settings, rows, groupCount, pick) {
state.done.title = picking
? "Show only the pill. Right-click the node and pick Edit switches to edit again."
: "Show only the labels and their switches. Double-click a label, or right-click the node, to edit again.";
// A card holds choices or switches: + Choice is offered until a switch is
// made, and + Switch until a choice is.
const choosing = hasChoices(settings);
const both = !settings.switches.length && pick.nodes.length > 0;
state.bar.classList.toggle("both", both);
state.add.classList.toggle("hidden", choosing);
state.choose.classList.toggle("hidden", !choosing && !both);
state.choose.classList.toggle("ready", choosing || both);
state.add.classList.toggle("ready", pick.count > 0);
state.add.textContent = pick.count > 0 ? `+ Switch from ${pick.count} selected` : "Select nodes, then add a switch";
state.add.textContent = both ? "+ Switch" : pick.count > 0 ? `+ Switch from ${pick.count} selected` : "Select nodes, then add a switch";
state.settings.sync(settings, edit, open);
const height = measureCard(rows.length, edit, open, settings, picking);
@@ -1103,12 +1209,13 @@ function buildPanel(node) {
const hint = button("ausboss-ws-hint hidden");
const bar = el("div", "ausboss-card-row ausboss-ws-bar hidden");
const add = button("ausboss-ws-add", "+ Switch", "Select the nodes of one part on the canvas, then press this: they get one switch");
const choose = button("ausboss-ws-add hidden", "+ Choice", "Set the nodes the way this choice should be, then press this and give it a name. Do it again for each choice: the card becomes one pill, and picking one sets the nodes that way.");
const done = button("ausboss-ws-done", "Done");
bar.append(add, done);
bar.append(add, choose, done);
root.append(card);
const state = {
node, root, list, hint, bar, add, done, rows: new Map(), settings: null, pick: null, pillWidth: 0, renaming: null,
node, root, list, hint, bar, add, choose, done, rows: new Map(), settings: null, pick: null, pillWidth: 0, renaming: null,
height: 0, signature: "", checkedAt: 0, timer: null, disposed: false,
};
node.__ausbossWorkflowSwitches = state;
@@ -1118,6 +1225,7 @@ function buildPanel(node) {
card.append(state.settings.header, ...state.settings.rows);
add.addEventListener("click", () => newSwitch(state));
choose.addEventListener("click", () => newChoice(state));
done.addEventListener("click", () => setEdit(state, false));
hint.addEventListener("click", () => setEdit(state, true));
@@ -1170,6 +1278,18 @@ app.registerExtension({
name: "AusBoss.WorkflowSwitches",
setup() {
ensureRedrawHook();
// A card copied with its parts follows the copies. Without this a whole
// workflow pasted into a new tab reads "empty" on every row.
onPaste(({ nodes, groups, created }) => {
for (const node of created) {
if (!isSwitches(node)) continue;
const moved = repointSwitches(node.properties?.[SETTINGS_PROPERTY], nodes, groups);
if (!moved) continue;
node.properties[SETTINGS_PROPERTY] = moved;
const state = node.__ausbossWorkflowSwitches;
if (state) refresh(state);
}
});
},
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_CLASS) return;
File diff suppressed because it is too large Load Diff
+162
View File
@@ -0,0 +1,162 @@
"""Trimmed audio decoding for Load Audio.
The window rules live in ``audio_window`` so the node and its tests agree on
what one Run hands on; ``js/load_audio/audio_window.mjs`` mirrors them for the
face. Decoding counts samples from the start of the sound track and stops as
soon as the window is full, so a short pick from a long file never decodes
the whole file.
"""
from __future__ import annotations
import math
from pathlib import Path
import av
import numpy as np
import torch
from ._execution_helpers import raise_if_interrupted
try:
import folder_paths
except ImportError: # offline tests
folder_paths = None
AUDIO_EXTENSIONS = {
".aac",
".aif",
".aiff",
".flac",
".m4a",
".mp3",
".oga",
".ogg",
".opus",
".wav",
".webm",
".wma",
}
# Video files carry sound too: a song is often only on hand inside a clip.
SOUND_IN_VIDEO_EXTENSIONS = {".mkv", ".mov", ".mp4", ".m4v"}
FALLBACK_SAMPLE_RATE = 44100
# Shorter than this is no window at all; the face keeps its handles this far apart.
MIN_WINDOW_SECONDS = 0.1
def _number(value, default: float = 0.0) -> float:
try:
number = float(value)
except (TypeError, ValueError):
return default
return number if math.isfinite(number) else default
def list_input_audio() -> list[str]:
"""Sound files (and videos, for their sound) in ComfyUI's input folder."""
if folder_paths is None:
return [""]
root = Path(folder_paths.get_input_directory())
kinds = AUDIO_EXTENSIONS | SOUND_IN_VIDEO_EXTENSIONS
results = [
path.relative_to(root).as_posix()
for path in root.rglob("*")
if path.is_file() and not path.name.startswith(".") and path.suffix.lower() in kinds
]
return sorted(results, key=str.casefold) or [""]
def audio_window(start_seconds, end_seconds, max_seconds=0.0, duration=None) -> dict:
"""The part of a sound file one Run hands on.
end 0 means "to the end of the file". max_seconds above 0 caps the
window's length: the pick keeps its start and anything past
start + max_seconds is cut off. ``duration`` is the file's length when it
is known; None leaves an open end open.
Returns start, end (None when open), the end the person picked
(``picked_end``) and how many seconds the cap cut off (``cut``).
"""
start = max(0.0, _number(start_seconds))
picked_end = _number(end_seconds)
end = picked_end if picked_end > 0 else None
if duration is not None:
length = max(0.0, _number(duration))
start = min(start, length)
end = length if end is None else min(end, length)
if end is not None:
end = max(start, end)
picked = end
limit = max(0.0, _number(max_seconds))
if limit > 0 and (end is None or end - start > limit):
end = start + limit
if duration is not None:
end = min(end, max(0.0, _number(duration)))
cut = 0.0
if picked is not None and end is not None:
cut = max(0.0, picked - end)
return {"start": start, "end": end, "picked_end": picked, "cut": cut}
def decode_audio_window(path: Path, start_seconds, end_seconds, max_seconds=0.0) -> dict:
"""ComfyUI AUDIO for the window ``audio_window`` describes.
Raises a plain ValueError for a file with no sound, or a start at or past
the end of the sound.
"""
window = audio_window(start_seconds, end_seconds, max_seconds)
start = window["start"]
with av.open(str(path)) as container:
stream = next((candidate for candidate in container.streams if candidate.type == "audio"), None)
if stream is None:
raise ValueError(f"{path.name} has no sound in it.")
rate = int(stream.rate or FALLBACK_SAMPLE_RATE)
first = round(start * rate)
last = None if window["end"] is None else round(window["end"] * rate)
resampler = av.AudioResampler(format="fltp", layout=stream.layout, rate=rate)
chunks: list[np.ndarray] = []
position = 0
def take(frame) -> None:
nonlocal position
for resampled in resampler.resample(frame):
data = resampled.to_ndarray()
begin = position
position += data.shape[1]
if position <= first:
continue
low = max(0, first - begin)
high = data.shape[1] if last is None else min(data.shape[1], last - begin)
if high > low:
chunks.append(data[:, low:high])
for frame in container.decode(stream):
raise_if_interrupted()
take(frame)
if last is not None and position >= last:
break
else:
take(None) # the resampler hands back what it still holds
if not chunks:
if position == 0:
raise ValueError(f"{path.name} has no sound in it.")
raise ValueError(
f"The start ({start:.2f} s) is past the end of {path.name}, "
f"which is {position / rate:.2f} s long. Drag the start back into the sound."
)
data = np.concatenate(chunks, axis=1).astype(np.float32, copy=False)
return {
"waveform": torch.from_numpy(np.ascontiguousarray(data)).unsqueeze(0),
"sample_rate": rate,
}
__all__ = [
"AUDIO_EXTENSIONS",
"MIN_WINDOW_SECONDS",
"SOUND_IN_VIDEO_EXTENSIONS",
"audio_window",
"decode_audio_window",
"list_input_audio",
]
+88 -18
View File
@@ -153,12 +153,34 @@ def erase_mask(strokes: list[dict], width: int, height: int) -> np.ndarray:
def mix_pictures(top: np.ndarray, under: np.ndarray, gone: np.ndarray) -> np.ndarray:
"""The top picture over the one under it, with ``gone`` rubbed out of the top."""
"""The top picture over the one under it, with ``gone`` rubbed out of the top.
Two opaque pictures (HxWx3) mix to an opaque one. When either has
see-through parts (HxWx4) the mix has them too: the picture underneath
shows through the top one's, and where the top is rubbed away the
picture underneath is as see-through as it was.
"""
weight = gone[:, :, None]
mixed = top.astype(np.float32) * (1.0 - weight) + under.astype(np.float32) * weight
if top.shape[2] == 3 and under.shape[2] == 3:
mixed = top.astype(np.float32) * (1.0 - weight) + under.astype(np.float32) * weight
return np.clip(np.round(mixed), 0, 255).astype(np.uint8)
top, under = _with_alpha(top).astype(np.float32), _with_alpha(under).astype(np.float32)
upper = top[..., 3:] / 255.0 * (1.0 - weight)
lower = under[..., 3:] / 255.0 * (1.0 - upper)
alpha = upper + lower
rgb = (top[..., :3] * upper + under[..., :3] * lower) / np.maximum(alpha, 1e-6)
mixed = np.concatenate([rgb, alpha * 255.0], axis=-1)
return np.clip(np.round(mixed), 0, 255).astype(np.uint8)
def _with_alpha(pixels: np.ndarray) -> np.ndarray:
"""HxWx4 pixels: an opaque HxWx3 picture gets a solid fourth channel."""
if pixels.shape[2] == 4:
return pixels
solid = np.full(pixels.shape[:2] + (1,), 255, dtype=pixels.dtype)
return np.concatenate([pixels, solid], axis=-1)
def clean_save_name(name) -> str:
"""The name typed on the node for the saved files, checked. Empty is the default name.
@@ -227,13 +249,16 @@ def align_pair(*, picture_a, picture_b, temp_root, name: str = "") -> dict:
also the new ``file`` and the ``pair`` key that stands for A with it.
"""
try:
first = _load_rgb(Path(picture_a))
second = _load_rgb(Path(picture_b))
first = _load_picture(Path(picture_a))
second = _load_picture(Path(picture_b))
except OSError as error:
raise CompareSaveError(ALIGN_GONE, status=404) from error
if first.shape[2] == 4 or second.shape[2] == 4:
first, second = _with_alpha(first), _with_alpha(second)
source = torch.from_numpy(np.array(first)).float() / 255.0
edit = torch.from_numpy(np.array(second)).float() / 255.0
result = measure(source, edit, "zoom + shift", ALIGN_MAX_ZOOM)
# Measured on what shows: see-through parts over plain gray.
result = measure(_on_gray(source), _on_gray(edit), "zoom + shift", ALIGN_MAX_ZOOM)
if not result.reliable:
logging.info("[AusBoss] Image Compare: Align left B as it is: %s", result.reason)
return {"aligned": False, "message": ALIGN_FAILED}
@@ -243,27 +268,70 @@ def align_pair(*, picture_a, picture_b, temp_root, name: str = "") -> dict:
height, width = first.shape[:2]
lined = crop_if_shift(edit, result.matrix, width, height)
if lined is None:
lined, _empty = warp_to_source(edit, result.matrix, width, height, "source", source)
lined, _empty = warp_to_source(_premultiplied(edit), result.matrix, width, height, "source", _premultiplied(source))
lined = _unpremultiplied(lined)
target = Path(picture_b).with_name(f"{Path(picture_b).stem}_aligned.png")
try:
require_output_path(target, Path(temp_root))
except ValueError as error:
raise CompareSaveError("Image Compare: the previews are not in ComfyUI's temp folder. Run the workflow again.") from error
pixels = np.round(lined.numpy() * 255.0).clip(0, 255).astype(np.uint8)
if pixels.shape[2] == 4 and pixels[..., 3].min() == 255:
pixels = np.ascontiguousarray(pixels[..., :3])
Image.fromarray(pixels).save(target, compress_level=1)
return {
"aligned": True,
"file": target,
"width": int(width),
"height": int(height),
"alpha": pixels.shape[2] == 4,
"pair": remember_pair(picture_a, target, name),
"message": f"B now lines up with A. It was off by up to {max(1, round(off))} px.",
}
def _load_rgb(path: Path) -> np.ndarray:
def _on_gray(pixels: torch.Tensor) -> torch.Tensor:
"""HWC colours as they show: see-through parts laid over plain gray."""
if pixels.shape[-1] < 4:
return pixels
alpha = pixels[..., 3:]
return pixels[..., :3] * alpha + 0.5 * (1.0 - alpha)
def _premultiplied(pixels: torch.Tensor) -> torch.Tensor:
"""Colours weighted by how solid they are, so resampling never pulls the
hidden colour of a clear pixel into its neighbours."""
if pixels.shape[-1] < 4:
return pixels
return torch.cat([pixels[..., :3] * pixels[..., 3:], pixels[..., 3:]], dim=-1)
def _unpremultiplied(pixels: torch.Tensor) -> torch.Tensor:
if pixels.shape[-1] < 4:
return pixels
alpha = pixels[..., 3:]
rgb = torch.where(alpha > 0, pixels[..., :3] / alpha.clamp_min(1e-6), torch.zeros_like(pixels[..., :3]))
return torch.cat([rgb.clamp(0.0, 1.0), alpha], dim=-1)
def _load_picture(path: Path, size: tuple[int, int] | None = None) -> np.ndarray:
"""A preview's pixels: HxWx3, or HxWx4 when it has see-through parts.
With ``size`` (width, height) a picture of another size is fitted to it,
a see-through one with its colours weighted by how solid they are, so its
clear parts do not bleed into its edges.
"""
with Image.open(path) as image:
return np.asarray(image.convert("RGB"))
clear = image.mode in ("RGBA", "LA", "PA") or "transparency" in image.info
image = image.convert("RGBA" if clear else "RGB")
if clear and image.getextrema()[3][0] == 255:
image, clear = image.convert("RGB"), False
if size is not None and image.size != size:
if clear:
image = image.convert("RGBa").resize(size, Image.LANCZOS).convert("RGBA")
else:
image = image.resize(size, Image.LANCZOS)
return np.asarray(image)
def save_mix(*, picture_a, picture_b, output_dir, top="A", strokes=None, layers=False, name=SAVE_NAME) -> dict:
@@ -279,14 +347,10 @@ def save_mix(*, picture_a, picture_b, output_dir, top="A", strokes=None, layers=
raise CompareSaveError("Image Compare: the top picture is A or B.")
name = clean_save_name(name)
try:
first = _load_rgb(Path(picture_a))
with Image.open(Path(picture_b)) as image:
image = image.convert("RGB")
height, width = first.shape[:2]
# The mix is always A's size; a B of another size is fitted to it.
if image.size != (width, height):
image = image.resize((width, height), Image.LANCZOS)
second = np.asarray(image)
first = _load_picture(Path(picture_a))
height, width = first.shape[:2]
# The mix is always A's size; a B of another size is fitted to it.
second = _load_picture(Path(picture_b), (width, height))
except OSError as error:
raise CompareSaveError(PICTURES_GONE, status=404) from error
if max(height, width) > PSD_MAX_SIDE:
@@ -315,8 +379,9 @@ def save_mix(*, picture_a, picture_b, output_dir, top="A", strokes=None, layers=
shown = np.round((1.0 - gone) * 255.0).astype(np.uint8)
write_psd(
folder / wanted[1],
[{"name": under_name, "rgb": under}, {"name": upper_name, "rgb": upper, "mask": shown}],
mixed,
[_layer(under_name, under), {**_layer(upper_name, upper), "mask": shown}],
mixed[..., :3],
alpha=mixed[..., 3] if mixed.shape[2] == 4 else None,
)
Image.fromarray(255 - shown).save(folder / wanted[2], compress_level=4)
@@ -327,6 +392,11 @@ def save_mix(*, picture_a, picture_b, output_dir, top="A", strokes=None, layers=
}
def _layer(name: str, pixels: np.ndarray) -> dict:
"""A PSD layer from a picture, with its see-through parts when it has them."""
return {"name": name, "rgb": pixels[..., :3], "alpha": pixels[..., 3] if pixels.shape[2] == 4 else None}
__all__ = [
"ALIGN_FAILED",
"ALIGN_GONE",
+25
View File
@@ -11,6 +11,9 @@ compute device live here too, so no helper module keeps its own copy.
from __future__ import annotations
import functools
import inspect
def raise_if_interrupted() -> None:
"""Abort the running node as soon as the queue is cancelled.
@@ -112,10 +115,32 @@ def comfy_torch_device():
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
def ignore_extra_inputs(validate):
"""Let a VALIDATE_INPUTS shrug off inputs it does not name.
ComfyUI hands a validator only the inputs its signature names, and keeps
its own range and list checks for every other input. Another pack that
wraps validators in a catch-all can hand over all of them instead, and a
plain signature then fails the run with "unexpected keyword argument".
The wrapper drops what the check does not read, and shows ComfyUI the
original signature, so ComfyUI still checks the rest as before.
"""
signature = inspect.signature(validate)
names = set(signature.parameters)
@functools.wraps(validate)
def wrapper(*args, **kwargs):
return validate(*args, **{name: value for name, value in kwargs.items() if name in names})
wrapper.__signature__ = signature
return wrapper
__all__ = [
"advance_progress",
"comfy_torch_device",
"frame_progress",
"ignore_extra_inputs",
"progress_bar",
"raise_if_interrupted",
"warn_once",
+351
View File
@@ -0,0 +1,351 @@
"""Who and what a MiniMax H3 clip is made from, in H3's own labels.
Every picture has one job and the video has one job. This numbers them the way
H3 counts them, and writes what each job needs in subject_definitions and
retention_analysis. Plain text, so the tests run without a server.
"""
from __future__ import annotations
from dataclasses import dataclass
import re
PERSON = "person"
SHEET = "character sheet"
PLACE = "place"
OUTFIT = "outfit"
THING = "thing"
STYLE = "style"
START = "start on it"
END = "end on it"
JOBS = (PERSON, SHEET, PLACE, OUTFIT, THING, STYLE, START, END)
MOVES = "copy its moves"
SWAP = "swap the person"
KEEP = "keep going"
REVOICE = "new voice"
VIDEO_JOBS = (MOVES, SWAP, KEEP, REVOICE)
KEEPS_ITS_LENGTH = (SWAP, REVOICE) # These change a clip, so the new one runs as long as the old one.
NEEDS_NO_WORDS = (SWAP, KEEP)
TOKEN = re.compile(r"@(\d)\b")
MAIN = "@0" # The main person, for lines the node writes itself.
BACKGROUND = "The background of {picture} is not kept."
NOTHING_IN = "Connect a picture or a video. H3 needs one to start from."
NEEDS_A_PERSON = {
MOVES: "To copy the moves, connect a picture of your person and pick person for it.",
SWAP: "To swap the person, connect a picture of the new person and pick person for it.",
}
# (what the label is, how it is kept). {picture} is H3's number for the picture.
JOB_WORDS = {
PERSON: (
"{subject} is the person in {picture}, with the same face, hair and clothes.",
"fully_preserved - the face, hair and clothes are retained.",
),
SHEET: (
"{subject} is the single character shown in the views of {picture}, with the same face, build, clothes and colours.",
"attribute_transfer - the character moves into the target scene, and the views are identity evidence only.",
),
PLACE: (
"{subject} is the place in {picture}, with the same layout, light and colours. Any people in {picture} are not reused.",
"fully_preserved - the place is retained.",
),
OUTFIT: (
"{subject} is the outfit in {picture}. Any person and the background of {picture} are not reused.",
"attribute_transfer - put only this outfit on {wearer}.",
),
THING: (
"{subject} is the object in {picture}, with the same shape, colours and lettering.",
"fully_preserved - the shape, colours and lettering are retained.",
),
STYLE: (
"{subject} is the visual style of {picture}. Its people and layout are not target content.",
"attribute_transfer - apply its form, shading and colours to the target scene.",
),
START: (
"{subject} is the main subject in {picture}, with the same look.",
"fully_preserved - the look is retained.",
),
}
DRESSED = (
"{subject} is the person in {picture}, with the same face and hair.",
"partially_preserved - the face and hair are retained, and the clothes change to {outfit}.",
)
REDRAWN = (
"{subject} is the person in {picture}, {redraw}",
"attribute_transfer - the hair colour, hairstyle and clothing carry over to the new look.",
)
FIRST_FRAME = "{picture} is the first frame of [Shot 1], in the exact pose and framing of the picture."
LAST_FRAME = "{picture} is the final frame of [Shot {last}], defining the final pose, camera angle, lighting and composition."
FIRST_KEPT = "{picture} ([Shot 1] first frame): fully_preserved - the opening framing, pose and light are retained."
LAST_KEPT = "{picture} ([Shot {last}] last frame): fully_preserved - the final framing, pose and light are retained."
MOVES_ARE = "{moves} is the body motion in <Video 1>: its moves, gestures and timing.\n<Video 1> provides the camera movement."
MOVES_KEPT = (
"{moves} (appears in {shots}): attribute_transfer - apply only the body motion, choreography and timing to {main}; "
"keep the source performer's face, body, clothes, props, background and colours out of the target.\n"
"<Video 1>: attribute_transfer - apply only its camera movement to the target scene."
)
SWAP_VIDEO = "<Video 1> supplies the place, the action, the timing, the camera movement and the look."
SWAP_IS = "{replaced} is {who} in <Video 1>, selected for replacement."
SWAP_VIDEO_KEPT = (
"<Video 1>: partially_preserved - keep the place, the action, the timing, the camera movement and the look; "
"change only the {people}."
)
SWAP_KEPT = "{replaced}: removed - replace {who} with {main} throughout."
ONE_GOES = "the original person"
# Where each person stands when two or more go and the script doesn't say, from left to right like the pictures.
SPOTS = {2: ("left", "right"), 3: ("left", "centre", "right"), 4: ("far left", "left", "right", "far right")}
SAYS_WHO = re.compile(
r"^(?:is|are|replaces|takes\s+the\s+place\s+of|goes\s+in\s+for|swaps?\s+(?:in\s+)?for|in\s+place\s+of|for|as)\b\s*"
r"|^[:=\-\u2013\u2014>]+\s*",
re.I,
)
SWAPPED = "attribute_transfer - apply the defined appearance to the original performance; the background of {picture} is not transferred."
KEEP_IS = (
"<Video 1> is the video being continued, and the target video starts from its last moment.\n"
"{main} is the main subject in <Video 1>, with the same look."
)
KEEP_KEPT = (
"<Video 1> (continuation point): fully_preserved - the place, the light, the colours and the camera carry on from its last moment.\n"
"{main} (appears in {shots}): fully_preserved - the look is retained."
)
SOUND_GOES_ON = "<Audio 1> is the soundtrack of <Video 1>, whose sound carries on in the target video."
SOUND_GOES_ON_KEPT = "<Audio 1>: reference - the sound continues without copying the source signal."
REVOICE_IS = "<Video 1> is the source video for the target video edit.\n{main} is the person speaking in <Video 1>, with the same face, hair and clothing."
REVOICE_KEPT = (
"<Video 1> (whole video): fully_preserved - every action, lip movement and camera move is retained.\n"
"{main} (appears in {shots}): fully_preserved - the face, hair and clothing are retained."
)
def lower_first(text):
first = text.split(" ", 1)[0]
return text if text[:1] == "<" or (len(first) > 1 and first.isupper()) else text[:1].lower() + text[1:]
def subject_label(number):
return f"<Subject {number}>"
def picture_label(number):
return f"<Picture {number}>"
def shot_list(numbers):
return ", ".join(f"[Shot {number}]" for number in numbers)
@dataclass
class Picture:
slot: int # Its number on the node, and what follows @ in a script.
job: str
picture: int # H3 counts only what is connected.
subject: int = 0 # A picture that only marks the last frame has none.
def swapped_in(jobs):
"""How many people a swap puts in: one for each person or sheet picture."""
return max(1, sum(job in (PERSON, SHEET) for job in jobs.values()))
def number_pictures(jobs, video=""):
"""Connected pictures in order, from {number on the node: job}. In a swap
the people being replaced come first, as <Subject 1>, <Subject 2> and on."""
pictures = []
subjects = swapped_in(jobs) if video == SWAP else 0
for picture, slot in enumerate(sorted(jobs), 1):
subjects += jobs[slot] != END
pictures.append(Picture(slot, jobs[slot], picture, 0 if jobs[slot] == END else subjects))
return pictures
class Cast:
"""The labels of one clip."""
def __init__(self, pictures, video=""):
self.pictures = pictures
self.video = video
last = max((picture.subject for picture in pictures), default=0)
self.moves = subject_label(last + 1) if video == MOVES else ""
# In a swap each person or sheet picture takes one person's place in the video, in picture order. The script
# can name who goes: "the child in the seat", or "@1 the man on the left. @2 the woman in red".
self.swappers = [picture for picture in pictures if picture.job in (PERSON, SHEET)] if video == SWAP else []
goes = len(self.swappers)
self.whos = [f"the person on the {spot}" for spot in SPOTS[goes]] if goes > 1 else [ONE_GOES]
self.replaced = subject_label(1) if video == SWAP else ""
self.in_video = subject_label(last + 1) if video in (KEEP, REVOICE) else ""
self.people = [picture for picture in pictures if picture.job in (PERSON, SHEET, START)]
self.subjects = {picture.slot: subject_label(picture.subject) for picture in pictures if picture.subject}
def of(self, *jobs):
return [picture for picture in self.pictures if picture.job in jobs]
@property
def who(self):
return self.whos[0]
@who.setter
def who(self, words):
self.whos[0] = words
@property
def swaps(self):
"""(who goes, the words for them, who comes in) for each person a swap replaces."""
coming = [subject_label(picture.subject) for picture in self.swappers] or [self.main]
return [(subject_label(number), who, new) for number, (who, new) in enumerate(zip(self.whos, coming), 1)]
def name_who_goes(self, lines):
"""Who each new person replaces, from the lines typed in a swap. With one, the words are who goes. With more,
each @ names one, or else the lines go in picture order; anything else keeps left to right."""
lines = [line.strip() for line in lines if line.strip(" .")]
text = " ".join(lines)
if len(self.whos) == 1:
self.who = lower_first(SAYS_WHO.sub("", TOKEN.sub("", text).strip()).rstrip(". ") or ONE_GOES)
return
slots = [picture.slot for picture in self.swappers]
pieces = re.split(r"@(\d)\b", text)
named = {int(slot): words for slot, words in zip(pieces[1::2], pieces[2::2]) if int(slot) in slots}
if not named:
named = dict(zip(slots, lines)) if len(lines) == len(slots) else {}
for index, slot in enumerate(slots):
words = SAYS_WHO.sub("", named.get(slot, "").strip(" .,;")).strip(" .,;")
words = re.sub(r"\s+and$", "", words)
if words:
self.whos[index] = lower_first(words)
@property
def main(self):
"""Who a line is about when it names nobody."""
if self.in_video:
return self.in_video
if self.people:
return subject_label(self.people[0].subject)
return next(iter(self.subjects.values()), subject_label(1))
@property
def opens(self):
return next(iter(self.of(START)), None)
@property
def ends(self):
return next(iter(self.of(END)), None)
def labelled(self, text):
return TOKEN.sub(lambda match: self.subjects.get(int(match.group(1)), self.main), text)
def problem(self, text):
"""What is wrong with the @ numbers in a script, if anything."""
if not self.pictures and not self.video:
return NOTHING_IN
if self.video in NEEDS_A_PERSON and not self.people:
return NEEDS_A_PERSON[self.video]
typed = sorted({int(number) for number in TOKEN.findall(text)} - {0})
slots = {picture.slot: picture for picture in self.pictures}
for slot in typed:
if slot not in slots:
return f"@{slot} has no picture. Turn Picture {slot} on, or take @{slot} out of your script."
if slots[slot].job == END:
return f"Picture {slot} is where the clip ends, so @{slot} can't be in a line. Say what happens on the way there."
if typed:
for picture in self.of(PERSON, SHEET, THING, OUTFIT):
if picture.slot not in typed:
return f"Picture {picture.slot} is on, but no line names @{picture.slot}."
return ""
def wearers(self, moments):
"""Who wears each outfit: the person named just before it, or else the main person."""
found = {}
for picture in self.of(OUTFIT):
found[picture.slot] = self.main
token = f"@{picture.slot}"
for moment in moments:
before = moment.text.partition(token)[0]
if token in moment.text:
named = [int(number) for number in TOKEN.findall(before)]
people = [slot for slot in named if slot in {person.slot for person in self.people}]
if people:
found[picture.slot] = self.subjects[people[-1]]
break
return found
def definitions(self, last, redraw, wearers):
ends = self.ends
lines = []
if self.video == SWAP:
lines.append(SWAP_VIDEO)
lines += [SWAP_IS.format(replaced=gone, who=who) for gone, who, _ in self.swaps]
worn = set(wearers.values())
for picture in self.pictures:
if picture.job == END:
continue
words = dict(subject=subject_label(picture.subject), picture=picture_label(picture.picture), redraw=redraw)
if picture.job == START and ends:
words["picture"] += f" and {picture_label(ends.picture)}"
elif picture.job == PERSON and redraw:
lines.append(REDRAWN[0].format(**words))
continue
elif picture.job == PERSON and words["subject"] in worn:
lines.append(DRESSED[0].format(**words))
continue
lines.append(JOB_WORDS[picture.job][0].format(**words))
if self.opens and ends:
lines.append(FIRST_FRAME.format(picture=picture_label(self.opens.picture)))
if ends:
lines.append(LAST_FRAME.format(picture=picture_label(ends.picture), last=last))
if self.moves:
lines.append(MOVES_ARE.format(moves=self.moves))
if self.video == KEEP:
lines.append(KEEP_IS.format(main=self.in_video))
if self.video == REVOICE:
lines.append(REVOICE_IS.format(main=self.in_video))
return "\n".join(lines)
def kept(self, picture, redraw, wearers):
"""How one picture's subject is kept in the target video."""
subject = subject_label(picture.subject)
label = picture_label(picture.picture)
wearing = [slot for slot, wearer in wearers.items() if wearer == subject]
if self.video == SWAP and picture.job in (PERSON, SHEET):
return SWAPPED.format(picture=label)
if picture.job == PERSON and redraw:
words = REDRAWN[1]
elif picture.job == PERSON and wearing:
words = DRESSED[1].format(outfit=self.subjects[wearing[0]])
else:
wearer = wearers.get(picture.slot, self.main)
words = JOB_WORDS[picture.job][1].format(wearer=wearer)
if picture.job in (PERSON, SHEET, THING):
words += " " + BACKGROUND.format(picture=label)
return words
def retention(self, shots, redraw, wearers):
"""One line for each label: shots holds the words of each shot, to see who is in it."""
everywhere = list(range(1, len(shots) + 1))
def seen(label):
return [number for number, words in enumerate(shots, 1) if label in words] or everywhere[:1]
lines = []
if self.video == SWAP:
lines.append(SWAP_VIDEO_KEPT.format(people="people" if len(self.swaps) > 1 else "person"))
lines += [SWAP_KEPT.format(replaced=gone, who=who, main=new) for gone, who, new in self.swaps]
for picture in self.pictures:
if picture.job == END:
continue
label = subject_label(picture.subject)
where = everywhere if picture.job == STYLE else seen(label)
lines.append(f"{label} (appears in {shot_list(where)}): {self.kept(picture, redraw, wearers)}")
if self.opens and self.ends:
lines.append(FIRST_KEPT.format(picture=picture_label(self.opens.picture)))
if self.ends:
lines.append(LAST_KEPT.format(picture=picture_label(self.ends.picture), last=len(shots)))
if self.moves:
lines.append(MOVES_KEPT.format(moves=self.moves, shots=shot_list(seen(self.main)), main=self.main))
if self.video == KEEP:
lines.append(KEEP_KEPT.format(main=self.in_video, shots=shot_list(seen(self.in_video))))
if self.video == REVOICE:
lines.append(REVOICE_KEPT.format(main=self.in_video, shots=shot_list(seen(self.in_video))))
return "\n".join(lines)
+268
View File
@@ -0,0 +1,268 @@
"""Finishes a MiniMax H3 prompt.
The words come from a typed scene (_h3_script_helpers.py) or from someone's
own six parts. A voice or a song that is connected adds its lines to the right
parts and takes the next free label. Plain text and numpy, so the tests run
without a server.
"""
from __future__ import annotations
import re
import numpy as np
from ._h3_cast_helpers import KEEP, NEEDS_NO_WORDS, REVOICE, SWAP, Cast, number_pictures
from ._h3_script_helpers import ACTION_GOES_ON, Moment, add_moments, add_sound, read_script, script_parts, speakers
SECTIONS = (
"subject_definitions",
"summary",
"retention_analysis",
"detailed_description",
"overall_soundscape",
"non_diegetic_music",
)
CUT = re.compile(r"(\bAt )(\d\d):(\d\d\.\d{3})")
NO_SCRIPT = "Type what happens first: one line for each moment."
VOICE_NO_WORDS = (
"A voice is connected, but nobody speaks. Put what they say in quotes, or "
"turn the voice off."
)
def split_sections(text):
"""The six parts of a prompt by name, or None when one is missing."""
found = {}
current = None
for line in text.split("\n"):
name = line.strip().rstrip(":")
if line.strip().endswith(":") and name in SECTIONS:
current = found.setdefault(name, [])
elif current is not None:
current.append(line)
if len(found) != len(SECTIONS):
return None
return {name: "\n".join(found[name]).strip() for name in SECTIONS}
def join_sections(parts):
return "\n\n".join(f"{name}:\n{parts[name]}" for name in SECTIONS)
def next_label(text, kind):
"""The next free number of a label kind: <Audio 1> in use makes it 2."""
used = [int(number) for number in re.findall(rf"<{kind} (\d+)>", text)]
return max(used, default=0) + 1
def one_line(text):
return " ".join((text or "").split())
def clean_words(text):
"""Spoken words: one line, no tags, ending the way H3 wants a line to end."""
text = one_line(text).replace("<", "").replace(">", "")
if text and text[-1] not in ".?!":
text += "."
return text
def add_task(summary, task):
"""Adds a task to the [a + b] tag the summary opens with."""
match = re.match(r"\[([^\]]*)\]", summary)
if match is None:
return f"[{task}] {summary}"
tasks = [part.strip() for part in match.group(1).split("+")]
if task not in tasks:
tasks.append(task)
return "[" + " + ".join(tasks) + "]" + summary[match.end() :]
def spoken_line(speaker, words, language, voice_label, on_screen, number=1, lips=""):
"""The sentence that carries the words, with the speaker's mouth stated
the way H3 needs it: moving and then closed on screen, closed in a
voiceover. lips names a video whose lip movement the words land on."""
line = f"<d>[{language}] {words}</d>"
if on_screen:
how = "says,"
if voice_label:
landing = f", each word landing exactly on the lip movement of {lips}," if lips else ""
how = f"speaks using the voice timbre referenced from {voice_label}{landing} and says,"
return (
f"{speaker} (S{number}) {how} {line} The jaw and lips of {speaker} move "
f"clearly through every word. {speaker} closes the lips."
)
timbre = f" The voiceover follows the voice timbre referenced from {voice_label}." if voice_label else ""
closed = f" while the on-screen version of {speaker} keeps the lips completely closed."
return f"{speaker} (S{number}) says in an off-screen voiceover, {line}{closed}{timbre}"
def snap_cuts(detailed, beats, gap=0.4):
"""Moves every timed moment to the nearest beat, keeping them in order and
at least `gap` seconds apart."""
last = 0.0
def move(match):
nonlocal last
time = int(match.group(2)) * 60 + float(match.group(3))
options = [beat for beat in beats if beat >= last + gap]
if options:
time = min(options, key=lambda beat: abs(beat - time))
last = time
minutes, seconds = divmod(time, 60)
return f"{match.group(1)}{int(minutes):02d}:{seconds:06.3f}"
return CUT.sub(move, detailed)
def add_voice(parts, label, speaker=None):
speaker = speaker or ("<Subject 1>" if "<Subject 1>" in parts["subject_definitions"] else None)
who = f"{speaker} (S1)" if speaker else "the off-screen voice (S1)"
parts["subject_definitions"] += f"\n{label} is the voice-timbre reference for {who}."
parts["summary"] = add_task(parts["summary"], "audio reference")
guided = speaker or "the off-screen voice"
parts["summary"] += f" {label} guides the voice timbre of {guided} without copying the original audio signal."
parts["retention_analysis"] += (
f"\n{label}: reference - the target speaker follows {label}'s voice "
"timbre and delivery without copying the source signal."
)
def cut_on_beat(parts, beats):
if beats:
parts["detailed_description"] = snap_cuts(parts["detailed_description"], beats).replace(
"the camera cuts to", "the camera cuts on the beat to"
)
def add_song(parts, label, beats):
parts["subject_definitions"] += f"\n{label} is the complete source soundtrack reused in the target video."
parts["summary"] = add_task(parts["summary"], "audio reuse")
if beats:
parts["summary"] += f" Every moment lands on a beat of {label}."
parts["retention_analysis"] += (
f"\n{label}: fully_copy - {label} is reused 1:1 as the target video's complete final audio track."
)
parts["overall_soundscape"] = "N/A"
parts["non_diegetic_music"] = f"{label} is directly reused as the complete audience-only score."
cut_on_beat(parts, beats)
def add_song_under(parts, beats):
"""A song to lay under the finished clip. H3 drops a song it is asked to
keep when someone also speaks, so it hears none and makes no music."""
parts["non_diegetic_music"] = "N/A"
cut_on_beat(parts, beats)
def write_prompt(script, seconds=15.0, camera="", look="", sound="", language="English", voice=False, song_beats=None,
ask=None, jobs=None, video="", soundtrack=False):
"""The finished prompt.
A typed scene is read and laid out over seconds; H3's own six parts go
through as they are. jobs says what each connected picture is for, as
{number on the node: job}, and video what a connected video is for.
soundtrack says a video's own sound is connected, for H3 to carry on from.
voice says whether one is connected. song_beats is None with no song, and
otherwise the beats to land on, which may be an empty list. With a voice
too, the song goes under the finished clip and H3 hears none. ask(question,
longest) puts a question to a small model, if one is connected: it fills in
a one-line idea and the sound of the place."""
parts = split_sections(script)
audio = next_label(script, "Audio")
speaker = None
if parts is None:
cast = Cast(number_pictures(jobs if jobs is not None else {1: "person"}, video), video)
scene = read_script(script)
problem = cast.problem(" ".join(moment.text for moment in scene.moments))
if problem:
raise ValueError(problem)
if not scene.moments and video not in NEEDS_NO_WORDS:
raise ValueError(NO_SCRIPT)
if video == SWAP and scene.moments: # Typed words in a swap say which person goes, for a clip with two.
cast.name_who_goes([moment.text for moment in scene.moments])
scene.moments = []
if not scene.moments:
scene.moments.append(Moment(ACTION_GOES_ON if video == KEEP else ""))
if voice and not any(moment.speech for moment in scene.moments):
raise ValueError(VOICE_NO_WORDS)
if ask is not None:
add_moments(scene, seconds, ask, jobs)
add_sound(scene, ask, jobs)
audio += soundtrack and video == KEEP
speaker = next(iter(speakers(scene.moments, cast)), None)
voice_label = f"<Audio {audio}>" if voice else None
language = one_line(language) or "English"
if parts is None:
def say(words, on_screen=True, who="<Subject 1>", number=1):
lips = "<Video 1>" if video == REVOICE else ""
return spoken_line(who, clean_words(words), language, voice_label if number == 1 else None, on_screen, number, lips)
keeps_song = song_beats is not None and not voice # The song is then all the sound there is.
parts = script_parts(
scene, seconds, camera=camera, look=look, sound=sound, say=say, cast=cast, sounds=not keeps_song, soundtrack=soundtrack
)
if voice:
add_voice(parts, voice_label, speaker)
if song_beats is not None and voice:
add_song_under(parts, song_beats)
elif song_beats is not None:
add_song(parts, f"<Audio {audio}>", song_beats)
return re.sub(r"[ \t]{2,}", " ", join_sections(parts)).replace(" \n", "\n").strip()
def find_beats(samples, rate, seconds=30.0):
"""Beat times in seconds of a mono track, read as one steady grid.
Listens for the moments the sound jumps, finds the tempo that lines up
with the most of them, then tries every offset of that grid. A clip is 15
seconds at most, so a steady grid is close enough, and the first 30
seconds are plenty. Returns [] for silence."""
samples = np.asarray(samples, dtype=np.float32)[: int(seconds * rate)]
hop = max(1, round(rate * 256 / 22050))
window = hop * 4
frames = (len(samples) - window) // hop
if frames < 8 or float(np.abs(samples).max(initial=0.0)) < 1e-4:
return []
taper = np.hanning(window)
spectra = np.stack(
[np.abs(np.fft.rfft(samples[index * hop : index * hop + window] * taper)) for index in range(frames)]
)
rise = np.maximum(np.diff(np.log1p(1000 * spectra), axis=0), 0).sum(axis=1)
rise = np.maximum(rise - np.convolve(rise, np.ones(43) / 43, "same"), 0)
if rise.max() <= 0:
return []
rise = rise / rise.max()
per_second = rate / hop
repeat = np.fft.irfft(np.abs(np.fft.rfft(rise, 2 * len(rise))) ** 2)[: len(rise)]
low, high = int(per_second * 60 / 190), min(int(per_second * 60 / 55), len(rise) - 1)
if high <= low:
return []
lags = np.arange(low, high)
leaning = np.exp(-0.5 * (np.log2(60 * per_second / lags / 110)) ** 2) # people tap near 110 BPM
guess = lags[int(np.argmax(repeat[low:high] * leaning))]
best = (-1.0, 0.0, float(guess))
for period in np.arange(guess * 0.97, guess * 1.03, 0.02):
grid = np.arange(0.0, len(rise) - 1, period)
for offset in np.arange(0.0, period, 0.5):
hits = np.round(grid + offset).astype(int)
hits = hits[hits < len(rise)]
score = float(rise[hits].mean()) if len(hits) else 0.0
if score > best[0]:
best = (score, float(offset), float(period))
_, offset, period = best
# A frame is heard at the middle of its window, one step on from where it starts.
start = offset / per_second + (window / 2 + hop) / rate
step = period / per_second
count = int((len(samples) / rate - start) / step) + 1
return [round(start + index * step, 3) for index in range(max(count, 0))]
+862
View File
@@ -0,0 +1,862 @@
"""Reads a scene someone typed and writes it in MiniMax H3's six parts.
One line for each thing that happens, and a blank line to cut to a new place.
Every moment stays, in the order it was typed, and gets its own second. Code
does all of it, so nothing typed can be lost or moved around. Plain text, so
the tests run without a server.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import re
from ._h3_cast_helpers import (
KEEP,
MAIN,
OUTFIT,
PERSON,
PLACE,
REVOICE,
SHEET,
STYLE,
SWAP,
SOUND_GOES_ON,
SOUND_GOES_ON_KEPT,
THING,
TOKEN,
ONE_GOES,
SPOTS,
Cast,
lower_first,
number_pictures,
picture_label,
subject_label,
)
QUOTES = {"“": '"', "”": '"', "‘": "'", "’": "'", "—": " - ", "–": " - ", "…": "..."}
# A word that starts a new moment inside a line. It goes, the order says it.
THEN = r"(?:,?\s*(?:and\s+)?then\b|,?\s*and\s+after\s+that\b|,?\s*after\s+that\b|,?\s*afterwards?\b|,\s*next\b|,\s*later\b|,?\s*moments?\s+later\b)"
# A word that starts a new moment and stays in it.
TURN = r"(?=,?\s*\b(?:suddenly|all\s+of\s+a\s+sudden|out\s+of\s+nowhere|finally|at\s+last|(?:hard\s+)?cut\s+to|(?:it\s+|and\s+|(?<!\bit\s))ends?\s+(?:with|on|in|as)|ending\s+(?:with|on|in))\b)"
# Where a line breaks into sentences: a full stop that is not a title, the end of a quote, or a semicolon.
SENTENCE = r"(?<!\bMr\.)(?<!\bMs\.)(?<!\bDr\.)(?<!\bSt\.)(?<!\bMrs\.)(?<=[.!?])\s+(?=\S)|(?<=\x00)\s+(?=[A-Z])|\s*;\s*"
CUT_WORDS = re.compile(r"^(?:hard\s+)?(?:cut|cuts|smash\s+cut|jump\s+cut)\s+to\b[:,]?\s*", re.I)
NEW_SHOT = re.compile(r"^(?:new\s+shot|next\s+shot|shot\s+\d+)\b[:,.\-]?\s*", re.I)
START = re.compile(r"^(?:it\s+|the\s+(?:clip|video|scene|shot)\s+)?(?:starts?|begins?|opens?)(?:\s+(?:with|on|in|at|as)\b[:,]?|\s*:)\s*", re.I)
END = re.compile(r"^(?:it\s+|the\s+(?:clip|video|scene|shot)\s+)?(?:ends?|ending|finish(?:es)?|closes?)(?:\s+(?:with|on|in|at|as)\b[:,]?|\s*:)\s*", re.I)
FINALLY = re.compile(r"^(?:finally|at\s+the\s+end|in\s+the\s+end)\b[:,]?\s*", re.I)
UNIT = r"(?:seconds?|secs?|s)\b"
TIMED = re.compile(
r"^\[?(?:at\s+)?(?:(\d+):(\d\d(?:\.\d+)?)|(\d+(?:\.\d+)?)\s*(?:" + UNIT + r"|(?=-\s*\d)))"
r"(?:\s*-\s*\d+(?:\.\d+)?\s*(?:" + UNIT + r")?)?\]?\s*[:,\-]?\s*",
re.I,
)
NUMBER = re.compile(r"^\s*(?:\d+[.)]|[-*•]|(?:first|second|third|fourth|fifth|sixth)(?:ly)?\b[:,]?|step\s+\d+[:.]?)\s*", re.I)
LABEL = re.compile(r"^(camera|style|look|sound|sounds|audio|music)\s*:\s*(.*)$", re.I)
SPEECH_CUE = re.compile(
r"\b(?:says?|said|asks?|whispers?|shouts?|yells?|screams?|mutters?|sings?|replies|answers?|tells?\s+\w+|goes)\b[^\"]{0,24}$",
re.I,
)
SIGN_CUE = re.compile(
r"\b(?:title|titled|called|named|sign|signs|text|caption|card|poster|reads?|saying|logo|banner|headline|words?)\b[^\"]{0,24}$",
re.I,
)
NO_MUSIC = re.compile(r"\b(?:no|without|skip)\s+(?:any\s+)?(?:music|soundtrack|score)\b|\bjust\s+(?:the\s+)?(?:ambien\w+|sound of|natural sound)\b", re.I)
NO_SOUND = re.compile(r"\b(?:no\s+sound|no\s+audio|silent\s+(?:film|movie|clip|video)|completely\s+silent|total\s+silence|mute(?:d)?\b)", re.I)
ONE_TAKE = re.compile(r"\b(?:one|single|continuous|unbroken)\s+(?:long\s+)?(?:shot|take)\b|\b(?:no|without)\s+cuts?\b", re.I)
SIDE_NOTE = re.compile(
r"^\s*(?:(?:no|without)\s+(?:any\s+)?(?:music|soundtrack|score|sound|audio|cuts?)(?:\s*,?\s*(?:just|only)\s+(?P<only>.+?))?"
r"|one\s+(?:long\s+)?(?:shot|take)|single\s+(?:shot|take)|continuous\s+(?:shot|take))\s*[.!]?\s*$",
re.I,
)
VOICEOVER = re.compile(r"\b(?:voice-?\s?over|narrator|off-?\s?screen|a\s+voice)\b", re.I)
UNSEEN = re.compile(r"\b(?:feels?|thinks?|remembers?|reali[sz]es?|decides?|wants?|hopes?|wonders?|knows?|understands?|believes?|imagines?)\b", re.I)
MOVING = re.compile(r"\b(?:pov|walks?|walking|runs?|running|forward|follows?|tracks?)\b", re.I)
FRAME_WORDS = re.compile(r"^(?:(?:an?|the)\s+(?:[\w-]+\s+){0,2}?(?:close-?up|wide|medium|shot|view|angle|overhead|profile|insert|aerial)\b|black\b)", re.I)
# What a small model is asked. It adds to a script; it never holds what was typed.
NEXT = (
"This is a {seconds} second video.\nFirst this happens: {first}\n\n"
"Write {count} short lines for what happens after that, in order, one thing per line. Each line follows from the one "
"before, and in each line something clearly changes on screen. Write what is seen, in plain words, under twelve words "
"a line. Stay in the same place. No speech. Nobody new unless asked. Write only the lines."
)
HEARD = (
"This happens in a video:\n{lines}\n\nList the sounds you would hear there, the ones that never stop. Write one "
"sentence of under fifteen words, like: Low hum of the fridges, faint traffic outside, a ticking clock."
)
LINE_WORDS = 18 # A longer line is the model telling a story, not naming a moment.
SOUND_WORDS = 22
NAMED = {PLACE: "the place", OUTFIT: "the outfit", THING: "the thing"} # What @2 reads as to the small model.
WORDS_A_SECOND = 3 # How fast H3 speaks.
HOLD = f"{MAIN} holds still" # What fills the time before a first moment set later.
KEEP_LOOK = "The target video keeps the look, the light and the colours of {picture}."
KEEP_COLOURS = "The target video keeps the colours and the film grain of {picture}."
OWN_COLOURS = "Each place keeps its own light and colours."
ONE_SHOT = "One continuous shot with no cuts."
EVERY_PLACE = "Every shot is a new place."
AND_THEN = "Then {line}"
STILL_MOVING = "the camera keeps moving forward."
QUIET = "{speaker} stays silent with the lips closed."
ONE_LINE = "No other words are spoken."
PLACE_SOUND = "The steady sound of the place continues throughout."
EACH_PLACE = "The sound of each place carries through its shot."
ROOM_TONE = "Quiet room tone continues throughout with faint fabric movement and soft breathing."
OLD_SOUND = "The sound of <Audio 1> continues throughout the target video."
OPENING = "{subject} is as in {picture}."
OPENS_ON = "The shot begins from {picture}."
ENDS_ON = "The shot ends on {picture}."
ON_SHEET = "{subject} appears once, without the panels, labels or other views of {picture}."
THE_MOVES = "{main} performs the same moves as {moves}, at the same timing."
IN_THE_PLACE = "The shot takes place in {place}."
WEARS = "{wearer} wears {outfit}."
IN_THE_SHOT = "{main} is in the shot."
TAKES_OVER = "{main} takes the place of {who} and does the same thing at the same pace."
ONE_PERSON = "Show one person throughout."
OTHERS_STAY = "Everyone else stays as they are in <Video 1>."
FOLLOWS_CAMERA = "The camera follows the movement of <Video 1>."
CARRIES_ON = "The shot carries straight on from the end of <Video 1>."
ACTION_GOES_ON = "The action keeps going the way it was heading, and the camera keeps moving the same way."
FACES_US = "{main} faces the camera as in <Video 1>."
VIDEO_LOOKS = {
SWAP: "Retain the look of <Video 1>.",
KEEP: "The target video keeps the look, colours and camera of <Video 1>.",
REVOICE: "The target video keeps the look, light and camera of <Video 1>.",
}
NAMES_A_SPOT = re.compile(r"\b(?:left|right|behind|in\s+front|foreground|background)\b", re.I)
OPENS_ON_THEM = re.compile(r"^(?:She|He)\b")
FOLLOWS_A_COMMA = re.compile(r"^(?:A|An|The|She|He|They|It|Her|His|Their|Its|This|That|These|Those|There|Someone|Everyone|Nobody)\b")
# How someone asks for a camera, in the words H3 follows.
CAMERAS = {
"handheld": "The camera is handheld and shakes slightly throughout.",
"shaky": "The camera is handheld and shakes strongly throughout.",
"walking pov": "The whole clip is a first-person POV shot that moves forward at fast speed and shakes strongly with every step.",
"push in": "The camera pushes in on <Subject 1> at slow speed throughout.",
"pull back": "The camera pulls back from <Subject 1> at slow speed throughout.",
"orbit": "The camera arcs around <Subject 1> with large amplitude throughout.",
"follow": "The camera tracks <Subject 1> from behind throughout.",
"still": "The camera stays static throughout.",
}
# (the whole look, how the subject changes, what later shots repeat). A new
# subject line tells H3 to redraw the person, not to paint over a photo. Each
# shot is drawn on its own, so later shots say the look again.
LOOKS = {
"anime": (
"The target video is a 1990s Japanese television anime: hand-drawn cel animation, bold outlines, flat saturated "
"colours, painted backgrounds and light film grain. Every frame is drawn, nothing is photographic.",
"redrawn as a hand-drawn 1990s anime character with the same hair colour, hairstyle and clothing.",
"Still hand-drawn anime, nothing photographic.",
),
"watercolor": (
"The target video is a hand-painted watercolour animation: soft washes of colour, wet edges, visible paper texture "
"and loose brush lines. Every frame is painted, nothing is photographic.",
"redrawn as a hand-painted watercolour character with the same hair colour, hairstyle and clothing.",
"Still a hand-painted watercolour, nothing photographic.",
),
"pencil sketch": (
"The target video is a black pencil sketch on white paper: rough hand-drawn lines, shading by hatching and the "
"paper showing through. Every frame is drawn, nothing is photographic.",
"redrawn as a pencil sketch with the same hair colour, hairstyle and clothing.",
"Still a black pencil sketch on white paper, nothing photographic.",
),
"comic book": (
"The target video is an American comic book come to life: thick black ink outlines, flat bold colours and halftone "
"dots. Every frame is drawn, nothing is photographic.",
"redrawn as a comic book character with the same hair colour, hairstyle and clothing.",
"Still a comic book drawing, nothing photographic.",
),
}
# What a moment sounds like, said where it happens. H3 leaves a clip close to
# silent between big events unless each sound is named in its own moment.
MOMENT_SOUNDS = tuple(
(re.compile(pattern, re.I), line)
for pattern, line in (
(r"\b(?:lights?|lamps?|bulbs?|neon)\b.*\b(?:flicker\w*|buzz\w*|go(?:es)?\s+(?:out|off|dark)|dim\w*)\b", "The lights buzz and crackle."),
(r"\b(?:screens?|tvs?|televisions?|monitors?)\b.*\b(?:flicker\w*|static|glitch\w*|go(?:es)?\s+(?:black|dark|off)|turn\w*\s+(?:on|off|to))\b", "Static hisses and crackles."),
(r"\b(?:thunder|lightning)\b", "Thunder cracks loudly."),
(r"\b(?:rain|rainy|raining|downpour)\b", "Rain hisses on the ground."),
(r"\b(?:wind|windy|gusts?|gale)\b", "The wind howls."),
(r"\b(?:the|ocean|sea|big|huge|giant|crashing|breaking|rolling)\s+(?:\w+\s+)?waves\b|\bwaves\s+(?:crash|break|roll|pound|hit|rise|swell)\w*\b|\bsurf\b", "The water crashes and roars."),
(r"\b(?:splash\w*|puddles?)\b", "Water splashes."),
(r"\b(?:explo[ds]\w*|blasts?)\b", "A deep boom shakes the air."),
(r"\b(?:fire|flames?|burns?|burning)\b", "Flames crackle and roar."),
(r"(?<!a )\b(?:breaks?|breaking)\b(?!\s+(?:into|down|out|free|away|up|off|through))|\b(?:shatter\w*|smash\w*)\b", "Something shatters with a loud crack."),
(r"\bdoors?\b.*\b(?:opens?|opening|closes?|closing|slams?|swings?|swung|shuts?)\b(?!-)|\b(?:opens?|opening|closes?|closing|slams?|shuts?)\b(?!-).*\bdoors?\b", "The door creaks and thuds."),
(r"\b(?:engine|motorbike|motorcycle)\b|\b(?:car|truck)\b.*\b(?:speeds?|drives?|races?|pulls?\s+away)\b", "The engine roars."),
(r"\btrains?\b", "The train rumbles on the rails."),
(r"\bflash(?:es)?\b(?!\s+(?:a|her|his|their)\b)|\bshutter\b|\btakes? a (?:photo|picture)\b", "The camera shutter clicks loudly."),
(r"\b(?:birds?|crows?)\b", "Wings flap."),
(r"\b(?:runs?|running)\b(?!\s+(?:her|his|their|one|a)\b)|\b(?:sprints?|sprinting|flees?|fleeing)\b", "Fast footsteps hit the ground."),
(r"\b(?:walks?|walking|strides?|striding)\b", "Footsteps sound on the ground."),
(r"(?<!night )(?<!snow )(?<!hair )(?<!tears )(?<!silence )(?<!darkness )\b(?:falls?|falling)\b(?!\s+(?:asleep|silent|quiet|still|in\s+love))|\b(?:drops?|dropping|crash(?:es)?|collapses?|slams?)\b", "It lands with a heavy thud."),
)
)
# What stays once it has happened: how it starts, what would change it, what to
# say as it happens, and the line that holds it. H3 draws each moment on its
# own, so the lights come back on in the next moment unless that moment says not.
LASTING = tuple(
(re.compile(started, re.I), re.compile(topic, re.I), now, line)
for started, topic, now, line in (
(
r"\blights?\b.*\b(?:go(?:es)?\s+(?:out|off|dark)|die|dies|cut\s+out)\b|\bblackout\b|\b(?:goes|go|went)\s+dark\b",
r"\b(?:lights?|lamps?|bulbs?|dark\w*)\b",
"The whole place goes dark.",
"The lights stay off.",
),
(r"\b(?:rain|raining|downpour)\b", r"\b(?:rain|raining)\b", "", "The rain keeps falling."),
(r"(?<!static )(?<!tv )\bsnow(?:s|ing|fall|storms?)?\b", r"\bsnow", "", "The snow keeps falling."),
(
r"\b(?:screens?|tvs?|televisions?|monitors?)\b.*\b(?:go(?:es)?\s+(?:black|dark|off)|turn\w*\s+(?:black|off))\b",
r"\b(?:screens?|tvs?|televisions?|monitors?)\b",
"",
"The screens stay dark.",
),
(r"\bon\s+fire\b|\bcatch(?:es)?\s+fire\b|\bbursts?\s+into\s+flames\b", r"\b(?:fire|flames?)\b", "", "The fire keeps burning."),
)
)
NAMES_A_SOUND = re.compile(r"\b(?:sounds?|hears?|heard|noises?|loud\w*|silen\w*|quiet\w*|rumbl\w*|bangs?|booms?|roars?|hiss\w*|creak\w*|buzz\w*|howl\w*)\b", re.I)
@dataclass
class Moment:
text: str
cut: bool = False # A new shot starts here.
at: float | None = None # A second the person asked for.
speech: str = ""
voiceover: bool = False # Heard, not seen saying it.
start: bool = False # The person said this is how it starts.
end: bool = False # The person said this is how it ends.
camera: str = "" # A camera: line typed in this shot's block.
sound: str = "" # A sound: line typed in this shot's block.
line: int | None = None # The typed line this moment opens, counted from 0.
@dataclass
class Script:
moments: list = field(default_factory=list)
camera: str = ""
look: str = ""
sound: str = ""
music: str = "" # "N/A" is no music.
silent: bool = False
one_take: bool = False
def clean(text):
for old, new in QUOTES.items():
text = text.replace(old, new)
return text.replace("\r\n", "\n").replace("\r", "\n").strip()
def mask_quotes(line):
"""Hides quoted words so a full stop inside them can't split a moment."""
found = []
def hide(match):
found.append(match.group(0))
return f"\x00{len(found) - 1}\x00"
return re.sub(r'"[^"\n]*"', hide, line), found
def unmask(text, found):
return re.sub(r"\x00(\d+)\x00", lambda match: found[int(match.group(1))], text)
def split_line(line):
"""One typed line as its moments, in order."""
masked, found = mask_quotes(line)
pieces = []
for part in re.split(SENTENCE, masked):
for bit in re.split(THEN + r"\s+|" + TURN, part, flags=re.I):
bit = re.sub(r"[\s,;]*\band$", "", bit, flags=re.I)
if bit and bit.strip(" ,.;"):
pieces.append(unmask(bit.strip(" ,;"), found))
return pieces
def take_label(line, script):
"""camera: look: sound: music: lines go to their own part."""
match = LABEL.match(line)
if not match:
return False
name, value = match.group(1).lower(), match.group(2).strip()
if name == "camera":
script.camera = value
elif name in ("style", "look"):
script.look = value
elif name in ("sound", "sounds", "audio"):
script.sound = value
else:
script.music = "N/A" if value.lower().rstrip(".") in ("none", "no", "n/a", "off") else value
return True
def read_notes(text, script):
if NO_SOUND.search(text):
script.silent = True
if NO_MUSIC.search(text):
script.music = "N/A"
if ONE_TAKE.search(text):
script.one_take = True
def quoted_words(moment):
"""Quoted words after a sign word stay on screen, as does a bare ALL CAPS
quote. Any other quote is words someone says."""
match = re.search(r'"([^"\n]+)"', moment.text)
if not match:
return
before = moment.text[: match.start()]
words = match.group(1).strip()
if not (SIGN_CUE.search(before) or (words.isupper() and not SPEECH_CUE.search(before))):
moment.speech = words
moment.voiceover = VOICEOVER.search(before) is not None
def read_moment(moment):
text = moment.text
timed = TIMED.match(text)
if timed:
if timed.group(1) is not None:
moment.at = int(timed.group(1)) * 60 + float(timed.group(2))
else:
moment.at = float(timed.group(3))
text = text[timed.end() :]
cut = CUT_WORDS.match(text) or NEW_SHOT.match(text)
if cut:
moment.cut = True
text = text[cut.end() :]
start = START.match(text)
end = END.match(text) or FINALLY.match(text)
if start and len(text[start.end() :].split()) > 1:
moment.start = True
text = text[start.end() :]
elif end and len(text[end.end() :].split()) > 1:
moment.end = True
text = text[end.end() :]
text = re.sub(r"\s+", " ", text).strip(" ,.")
moment.text = text[:1].upper() + text[1:] if text else ""
quoted_words(moment)
def put_in_order(script):
"""An opening or an ending goes first or last, wherever it was typed. Lines
that all name their second play by the second."""
moments = script.moments
if moments and all(moment.at is not None for moment in moments):
moments.sort(key=lambda moment: moment.at)
starts = [moment for moment in moments if moment.start]
ends = [moment for moment in moments if moment.end]
rest = [moment for moment in moments if not moment.start and not moment.end]
moments[:] = starts + rest + ends
if moments:
if moments[0].at:
moments.insert(0, Moment(HOLD, at=0.0))
moments[0].cut = False
def read_script(text):
"""Whatever was typed, as a Script. A blank line or "cut to" starts a new
shot. camera: and sound: lines belong to the shot they are typed in; on
their own, or in a clip of one shot, they are for the whole clip."""
script = Script()
raw_text = str(text or "").replace("\r\n", "\n").replace("\r", "\n")
text = clean(text)
# Where the cleaned text starts among the typed lines, so each moment can
# name its line for the timeline.
first_line = raw_text[: len(raw_text) - len(raw_text.lstrip())].count("\n")
found = 0
for number, block in enumerate(re.split(r"\n\s*\n", text)):
found = max(text.find(block, found), found)
block_line = first_line + text.count("\n", 0, found)
found += len(block)
after_gap = number > 0
count = len(script.moments)
whole_clip = script.camera, script.sound
script.camera = script.sound = ""
for offset, raw in enumerate(block.split("\n")):
line = raw.strip()
if not line or take_label(line, script):
continue
opens = block_line + offset
for piece in split_line(NUMBER.sub("", line).strip()):
note = SIDE_NOTE.match(piece)
if note: # "no music, just rain" on its own is a note, not a moment.
read_notes(piece, script)
if note.group("only"):
script.sound = note.group("only").strip(" .")
continue
moment = Moment(piece, cut=after_gap, line=opens)
opens = None
after_gap = False
read_moment(moment)
if moment.text:
script.moments.append(moment)
typed = script.camera, script.sound
script.camera, script.sound = whole_clip
if len(script.moments) > count:
script.moments[count].camera, script.moments[count].sound = typed
else:
script.camera, script.sound = typed[0] or script.camera, typed[1] or script.sound
read_notes(text, script)
put_in_order(script)
if script.one_take or not any(moment.cut for moment in script.moments):
for moment in script.moments:
script.camera, script.sound = moment.camera or script.camera, moment.sound or script.sound
moment.camera = moment.sound = ""
return script
def most_moments(seconds):
"""H3 plays about one clear moment a second."""
return max(3, int(seconds))
def timeline(script, seconds):
"""When each moment starts. A second someone asked for stays; the others
share what is left, one after the other."""
moments = script.moments
if not moments:
return []
starts = [moment.at for moment in moments]
starts[0] = starts[0] or 0.0
index = 1
while index < len(starts):
if starts[index] is not None:
starts[index] = max(starts[index], starts[index - 1])
index += 1
continue
after = index
while after < len(starts) and starts[after] is None:
after += 1
last = starts[after] if after < len(starts) else seconds
step = max(last - starts[index - 1], 0.0) / (after - index + 1)
for place in range(index, after):
starts[place] = starts[place - 1] + step
index = after
return [round(min(start, seconds), 3) for start in starts]
def moments_for(seconds):
"""How many moments a one-line idea grows to: one for about each 2.5 seconds."""
return max(2, round(seconds / 2.5))
def answer_lines(answer):
"""The lines of a model's answer, without numbers or bullets."""
lines = (NUMBER.sub("", line).strip() for line in str(answer).split("\n"))
return [line for line in lines if line]
def bare(line):
return line.strip(" ,.!?").lower()
def usable_line(line, kept):
words = line.split()
return 1 < len(words) <= LINE_WORDS and not set(line) & set('"<>[]{}') and all(bare(line) != bare(other) for other in kept)
def plain_names(text, jobs):
"""The line with each @ swapped for what that picture is."""
return TOKEN.sub(lambda match: NAMED.get((jobs or {}).get(int(match.group(1))), "the person"), text)
def add_moments(script, seconds, ask, jobs=None):
"""One typed line in a clip with time to spare: a small model adds what
happens next. The typed line stays first, and a line that fails the check
is left out, so a bad answer costs nothing."""
if len(script.moments) != 1 or script.moments[0].speech:
return
wanted = moments_for(seconds) - 1
first = plain_names(script.moments[0].text, jobs)
kept = [first]
for line in answer_lines(ask(NEXT.format(seconds=seconds, first=first, count=wanted), 256)):
if len(kept) <= wanted and usable_line(line, kept):
kept.append(line)
script.moments.append(Moment(line.strip(" ,.")))
def add_sound(script, ask, jobs=None):
"""What this place sounds like, unless someone said."""
if script.sound or script.silent:
return
lines = "\n".join(plain_names(moment.text, jobs) for moment in script.moments)
answer = " ".join(answer_lines(ask(HEARD.format(lines=lines), 64)))
if 1 < len(answer.split()) <= SOUND_WORDS and not set(answer) & set('"<>[]{}'):
script.sound = answer
def script_warning(script, seconds, cast=None):
"""What to tell someone before they spend a run on this."""
problem = cast.problem(" ".join(moment.text for moment in script.moments)) if cast else ""
if problem:
return problem
count = len(script.moments)
if count > most_moments(seconds):
return f"{count} moments in {seconds:g} seconds. H3 plays about one a second, so some may be skipped."
if any((moment.at or 0.0) >= seconds for moment in script.moments):
return f"A moment starts after the clip ends, at {seconds:g} seconds."
starts = timeline(script, seconds)
for moment, start, end in zip(script.moments, starts, [*starts[1:], seconds]):
unseen = UNSEEN.search(without_speech(moment))
if unseen:
return f'A camera can\'t see "{unseen.group(0).lower()}". Say what we would see, like "she smiles".'
takes = len(moment.speech.split()) / WORDS_A_SECOND
if takes > end - start + 0.5:
return f"A spoken line takes about {takes:.0f} seconds to say, and its moment has {end - start:.0f}. Give it more room."
return ""
def script_report(text, seconds, jobs=None, video=""):
"""What a script will do, for the timeline on the node."""
script = read_script(text)
starts = timeline(script, seconds)
ends = [*starts[1:], seconds]
moments = [
{
"start": start,
"end": end,
"text": moment.text.replace(HOLD, "Holds still"),
"cut": moment.cut,
"speech": bool(moment.speech),
"line": moment.line,
}
for moment, start, end in zip(script.moments, starts, ends)
]
cast = Cast(number_pictures(jobs, video), video) if jobs is not None else None
return {"moments": moments, "seconds": seconds, "warning": script_warning(script, seconds, cast)}
def stamp(seconds):
minutes, rest = divmod(seconds, 60)
return f"{int(minutes):02d}:{rest:06.3f}"
def after_comma(text):
"""A sentence carried on after "At 00:03.000,". Names keep their capital."""
return text[:1].lower() + text[1:] if FOLLOWS_A_COMMA.match(text) else text
def sentence(text):
text = text.strip()
return text if text[-1:] in '.!?"' else text + "."
def capital(text):
return sentence(text[:1].upper() + text[1:])
def without_speech(moment):
"""A moment's words with what is said taken out, and what says it."""
if not moment.speech:
return moment.text
before, _, after = moment.text.partition(f'"{moment.speech}"')
spoken = SPEECH_CUE.search(before)
if spoken:
# "She looks up and says" keeps "She looks up". In "She says" the words
# in front only name who speaks.
action = before[: spoken.start()].rstrip()
linked = re.search(r",?\s*\band(?:\s+\w+ly)?$", action, re.I)
before = action[: linked.start()] if linked else action.rpartition(",")[0]
if VOICEOVER.search(before):
before = ""
return " ".join(part.strip(" ,:") for part in (before, after) if part.strip(" ,:."))
def moment_words(moment, say, cast):
words = with_labels(without_speech(moment), cast)
if moment.start:
words = "The shot opens on " + lower_first(words)
elif moment.end:
words = "The shot ends on " + lower_first(words)
line = sentence(words) if words else ""
if moment.speech:
line = f"{line} {say(moment.speech, not moment.voiceover)}".strip()
return line
def moment_sound(moment, script):
"""The sound of a moment, unless its words or the sound of the place already say it."""
words = without_speech(moment)
if NAMES_A_SOUND.search(words):
return ""
for pattern, line in MOMENT_SOUNDS:
if pattern.search(words):
return "" if pattern.search(script.sound) else line
return ""
def still_true(moments):
"""For each moment, the lines that hold what earlier moments changed. A
moment that speaks of the same thing again decides for itself, and a new
place starts clean."""
held, found = {}, []
for moment in moments:
seen = without_speech(moment)
if moment.cut and not FRAME_WORDS.match(seen):
held = {}
lines = [line for topic, line in held.items() if not topic.search(seen)]
for started, topic, now, line in LASTING:
if started.search(seen):
held[topic] = line
lines += [now] if now else []
found.append(lines)
return found
def speaker_of(moment, cast):
"""Who says a moment's words: the first person it names, or the main person."""
people = {picture.slot for picture in cast.people}
for slot in TOKEN.findall(moment.text):
if int(slot) in people:
return cast.subjects[int(slot)]
return cast.main
def speakers(moments, cast):
"""Everyone who speaks, in the order H3 numbers them."""
found = []
for moment in moments:
who = speaker_of(moment, cast)
if moment.speech and who not in found:
found.append(who)
return found
def with_labels(words, cast):
"""@1 in H3's label. A line that opens on She or He is the one person, when
there is one."""
if len(cast.people) == 1 or cast.in_video:
words = OPENS_ON_THEM.sub(MAIN, words)
return cast.labelled(words)
def shots_of(script):
"""The moments of each shot, as positions. Only a real cut starts a new one."""
shots = []
for index, moment in enumerate(script.moments):
if not shots or (moment.cut and not script.one_take):
shots.append([])
shots[-1].append(index)
return shots
def people_in(text, cast):
"""Who a shot's words name. A shot that names nobody has everyone, as in "they sit down"."""
named = [cast.subjects[picture.slot] for picture in cast.people if cast.subjects[picture.slot] in text]
if named or any(label in text for label in cast.subjects.values()):
return named
return [cast.subjects[picture.slot] for picture in cast.people]
def before_the_action(number, text, cast, wearers):
"""What H3 needs to hear about a shot before anything happens in it."""
notes = []
opens = cast.opens
if number == 1 and opens:
words = OPENS_ON if cast.ends else OPENING
notes.append(words.format(subject=subject_label(opens.subject), picture=picture_label(opens.picture)))
if cast.video == SWAP:
notes += [TAKES_OVER.format(main=new, who=who) for _, who, new in cast.swaps]
notes.append(ONE_PERSON if cast.whos == [ONE_GOES] else OTHERS_STAY)
if number == 1 and cast.video == KEEP:
notes += [CARRIES_ON]
if number == 1 and cast.video == REVOICE:
notes += [FACES_US.format(main=cast.main)]
places = [subject_label(picture.subject) for picture in cast.of(PLACE)]
if places and not any(place in text for place in places):
notes.append(IN_THE_PLACE.format(place=places[0]))
people = people_in(text, cast)
if len(people) > 1 and not NAMES_A_SPOT.search(text) and cast.video != SWAP: # A swap's people stand where the video has them.
spots = [f"{person} is on the {spot}" for person, spot in zip(people, SPOTS[len(people)])]
notes.append(sentence(", ".join(spots[:-1]) + f" and {spots[-1]} of the frame"))
for slot, wearer in wearers.items():
outfit = cast.subjects[slot]
if outfit not in text and (wearer in people or wearer in text):
notes.append(WEARS.format(wearer=wearer, outfit=outfit))
return notes
def after_the_action(text, cast):
"""What H3 needs to hear about a shot once its action has been said."""
notes = []
if cast.moves and cast.main in text:
notes.append(THE_MOVES.format(main=cast.main, moves=cast.moves))
if cast.video == SWAP:
notes.append(FOLLOWS_CAMERA)
for picture in cast.of(SHEET):
subject = subject_label(picture.subject)
if subject in text or len(cast.people) == 1:
notes.append(ON_SHEET.format(subject=subject, picture=picture_label(picture.picture)))
return notes
def cut_to(words, text, cast):
"""How a later shot opens, and what is left of its first moment to say."""
if FRAME_WORDS.match(words):
return f"the camera cuts to {lower_first(words)}", ""
places = [subject_label(picture.subject) for picture in cast.of(PLACE)]
named = next((place for place in places if place in text), None)
if named:
return f"the camera cuts to {named}.", words
return f"the camera cuts to {'a new view' if places else 'a new place'}.", words
def shot_lines(script, seconds, say, cast, wearers, move="", hold="", sounds=True):
"""One line for each shot. H3 jumps at a timed [Shot] mark whatever the words
say, so the moments of one take are sentences inside one shot."""
starts = timeline(script, seconds)
talking = speakers(script.moments, cast)
quiet = " ".join(QUIET.format(speaker=speaker) for speaker in talking)
kept = still_true(script.moments)
lines = []
for number, shot in enumerate(shots_of(script), 1):
shot_camera = script.moments[shot[0]].camera
shot_camera = CAMERAS.get(shot_camera.lower().strip(), shot_camera and sentence(shot_camera)).replace("<Subject 1>", cast.main)
moving = MOVING.search(shot_camera or move)
said = []
for index in shot:
moment = script.moments[index]
def speak(words, on_screen=True, moment=moment):
who = speaker_of(moment, cast)
return say(words, on_screen, who, talking.index(who) + 1)
words = " ".join(
[
moment_words(moment, speak, cast),
*kept[index],
*([quiet] if quiet and not moment.speech else []),
]
).strip()
if sounds and not script.silent:
words = f"{words} {moment_sound(moment, script)}".strip()
said.append(words)
text = " ".join(said)
first = shot[0]
head, said[0] = (cut_to(said[0], text, cast) if number > 1 else ("", said[0]))
for place, index in enumerate(shot[1:], 1):
lead = f"{STILL_MOVING} " if moving else ""
said[place] = f"At {stamp(starts[index])}, {lead}{after_comma(said[place])}"
body = [
f"At {stamp(starts[first])}, {head}" if head else "",
*before_the_action(number, text, cast, wearers),
shot_camera,
*said,
*after_the_action(text, cast),
*([hold] if number > 1 else []),
capital(script.moments[first].sound) if script.moments[first].sound and not script.silent else "",
]
lines.append(f"[Shot {number}] " + " ".join(part for part in body if part))
if not any(cast.main in line for line in lines):
lines[0] = lines[0].replace("[Shot 1] ", f"[Shot 1] {IN_THE_SHOT.format(main=cast.main)} ", 1)
return lines
def script_sound(script, sound, places=False):
"""The two sound parts. Words in the script win over the pick."""
if script.silent or sound == "none":
return "N/A", "N/A"
typed = capital(script.sound) if script.sound else (EACH_PLACE if places else PLACE_SOUND)
return typed, capital(script.music) if script.music not in ("", "N/A") else "N/A"
def task_of(cast):
"""The tag a summary opens with."""
if cast.video == SWAP or cast.video == REVOICE:
return "video editing"
if cast.video == KEEP:
return "video continuation"
if cast.opens and cast.ends:
return "keyframe completion"
return "reference generation + keyframe completion" if cast.ends else "reference generation"
def summary_of(script, cast, new_places, soundtrack):
task = task_of(cast)
if cast.video == SWAP:
swaps = " and ".join(f"{gone} with {new}" for gone, _, new in cast.swaps)
return f"[{task}] Replace {swaps} while preserving the original action, place, camera and look of <Video 1>."
if cast.video == REVOICE:
return (
f"[{task}] The target video is an edited version of <Video 1>. The picture, the timing and the lip movement "
f"stay as they are, and {cast.main} speaks in a new voice."
)
if cast.video == KEEP:
tag = f"{task} + audio reference" if soundtrack else task
return f"[{tag}] The target video continues directly from the end of <Video 1> in the same place."
opening = with_labels(without_speech(script.moments[0]), cast) or f"{cast.main} speaks"
later = [with_labels(without_speech(script.moments[shot[0]]), cast) for shot in shots_of(script)[1:]]
if cast.opens and cast.ends:
moving = f"moving from {picture_label(cast.opens.picture)} to {picture_label(cast.ends.picture)}"
return f"[{task}] In the target video, {lower_first(opening.rstrip('.!? '))}, {moving}."
summary = f"[{task}] In the target video, {lower_first(sentence(opening))}"
summary += "".join(f" {AND_THEN.format(line=lower_first(sentence(line)))}" for line in later if line)
if cast.moves:
summary += " " + THE_MOVES.format(main=cast.main, moves=cast.moves)
return f"{summary} {EVERY_PLACE}" if new_places else summary
def script_parts(script, seconds, camera="", look="", sound="", say=None, cast=None, sounds=True, soundtrack=False):
"""H3's six parts, from a Script. say turns spoken words into H3's line.
cast says what each picture and the video are for. sounds says H3 makes
the sound, so each moment names its own. soundtrack says a video's own
sound is connected."""
say = say or (lambda words, on_screen=True, speaker="", number=1: words)
cast = cast or Cast(number_pictures({1: PERSON}))
style, redraw, hold = LOOKS.get(look, ("", "", ""))
if script.look:
style, redraw, hold = LOOKS.get(script.look.lower().strip(), (sentence(script.look), "", ""))
for picture in cast.of(STYLE)[:1]:
subject = subject_label(picture.subject)
style, redraw, hold = f"The target video uses the visual style of {subject}.", "", f"Still in the visual style of {subject}."
typed = script.camera.lower().strip()
move = CAMERAS.get(typed, script.camera and sentence(script.camera)) or CAMERAS.get(camera, "")
move = move.replace("<Subject 1>", cast.main)
wearers = cast.wearers(script.moments)
lines = shot_lines(script, seconds, say, cast, wearers, move, hold, sounds)
last = len(lines)
cuts = [script.moments[shot[0]] for shot in shots_of(script)[1:]]
moved = any(not FRAME_WORDS.match(without_speech(moment)) for moment in cuts)
new_places = last > 1 and not cast.of(PLACE) and moved and all(not FRAME_WORDS.match(without_speech(moment)) for moment in cuts)
first = next(iter(cast.of(PLACE) or cast.pictures), None)
if not style and len(cast.of(PLACE)) > 1:
style = OWN_COLOURS
elif not style and not (first and first.job == THING):
words = KEEP_LOOK if cast.opens else KEEP_COLOURS
style = VIDEO_LOOKS.get(cast.video) or words.format(picture=picture_label(first.picture if first else 1))
takes = [EVERY_PLACE] if new_places else []
if last == 1 and (len(script.moments) > 1 or cast.moves or script.one_take):
takes = [ONE_SHOT]
if cast.ends:
lines[-1] += " " + ENDS_ON.format(picture=picture_label(cast.ends.picture))
subjects = cast.definitions(last, redraw, wearers)
kept = cast.retention(lines, redraw, wearers)
ambience, tune = script_sound(script, sound, new_places)
if soundtrack and cast.video == KEEP:
subjects += f"\n{SOUND_GOES_ON}"
kept += f"\n{SOUND_GOES_ON_KEPT}"
ambience = OLD_SOUND
if cast.video == REVOICE and ambience == PLACE_SOUND:
ambience = ROOM_TONE
if ambience != "N/A" and any(moment.speech for moment in script.moments):
ambience = f"{ambience} {ONE_LINE}"
return {
"subject_definitions": subjects,
"summary": summary_of(script, cast, new_places, soundtrack),
"retention_analysis": kept,
"detailed_description": "\n".join([" ".join(part for part in (style, move, *takes) if part), *lines]),
"overall_soundscape": ambience,
"non_diegetic_music": tune,
}
+198 -12
View File
@@ -46,6 +46,9 @@ STITCHER_VERSION = 1
SEAM_CLASSIC = "classic"
SEAM_BLEND_IN = "blend in"
SEAM_MODES = (SEAM_CLASSIC, SEAM_BLEND_IN)
PASTE_ONTO_CROP = "crop"
PASTE_ONTO_ORIGINAL = "original"
PASTE_ONTO_MODES = (PASTE_ONTO_CROP, PASTE_ONTO_ORIGINAL)
Rect = tuple[int, int, int, int] # (x, y, w, h)
@@ -128,6 +131,63 @@ def clamp_rect_to_bounds(rect: Rect, bounds_w: int, bounds_h: int) -> Rect:
return (x, y, w, h)
def _rect_contains(outer: Rect, inner: Rect) -> bool:
ox, oy, ow, oh = outer
ix, iy, iw, ih = inner
return ox <= ix and oy <= iy and ix + iw <= ox + ow and iy + ih <= oy + oh
def match_rect_shape(
rect: Rect,
target_w: int,
target_h: int,
bounds_w: int,
bounds_h: int,
keep_inside: bool = False,
keep_box: Rect | None = None,
) -> Rect:
"""Reshape a crop window to the sampler size's proportions.
The sampler size is rounded to a multiple on each side separately, so
it rarely has the crop's exact proportions. Resizing one into the
other would stretch the picture a little more one way than the other.
Instead the window takes in a few more pixels of context on its short
side, so a single scale maps it onto the sampler size.
With ``keep_inside``, a window that would then run past the picture
gives up a few pixels of context on its long side instead, as long as
it still holds ``keep_box`` (the mask's box). If neither fits, it grows
and the overflow is padded with copies of the edge pixels, as before.
"""
x, y, w, h = rect
tw, th = max(1, int(target_w)), max(1, int(target_h))
if w * th == h * tw:
return rect
if w * th < h * tw:
grown = (max(1, round(h * tw / th)), h)
shrunk = (w, max(1, round(w * th / tw)))
else:
grown = (w, max(1, round(w * th / tw)))
shrunk = (max(1, round(h * tw / th)), h)
def place(size: tuple[int, int], center: tuple[float, float]) -> Rect:
nw, nh = size
placed = (round(center[0] - nw / 2.0), round(center[1] - nh / 2.0), nw, nh)
return fit_rect(placed, bounds_w, bounds_h)
center = (x + w / 2.0, y + h / 2.0)
wider = place(grown, center)
if not keep_inside or (grown[0] <= bounds_w and grown[1] <= bounds_h):
return wider
keep = keep_box if keep_box is not None else rect
keep_center = (keep[0] + keep[2] / 2.0, keep[1] + keep[3] / 2.0)
for anchor in (center, keep_center):
narrower = place(shrunk, anchor)
if _rect_contains(narrower, keep):
return narrower
return wider
def rect_margins(rect: Rect, bounds_w: int, bounds_h: int) -> tuple[int, int, int, int]:
"""(left, top, right, bottom) overflow of a rect past the bounds."""
x, y, w, h = rect
@@ -221,6 +281,10 @@ _PYMATTING_HINT = (
# Notes already printed this process; each prints once, with no cap.
_warned: set[str] = set()
_NO_ORIGINAL_NOTE = (
"Stitch Inpaint: this stitcher has no original picture to paste onto, so the "
"result stays on the crop. Image Crop + Rotate + Pad has one; the video nodes don't."
)
def _foreground_estimator():
@@ -406,7 +470,19 @@ def build_crop(
target_w = max(1, round(rect[2] * area_scale))
target_h = max(1, round(rect[3] * area_scale))
use_target = True
if not use_target:
if use_target:
# Settle the sampler size first, then give the crop window that
# exact shape: one scale for both sides, so the round trip never
# stretches the picture more one way than the other.
if target_w <= 0:
target_w = max(1, round(rect[2] * target_h / rect[3]))
if target_h <= 0:
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)
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.
rect = expand_rect_to_multiple(rect, multiple)
rect = fit_rect(rect, width, height)
@@ -434,12 +510,6 @@ def build_crop(
scale = None
if use_target:
if target_w <= 0:
target_w = max(1, round(cw * target_h / ch))
if target_h <= 0:
target_h = max(1, round(ch * target_w / cw))
target_w = round_up_to_multiple(target_w, multiple)
target_h = round_up_to_multiple(target_h, multiple)
if (target_w, target_h) != (cw, ch):
cropped = _resize_image(cropped, target_w, target_h, rescale_algorithm)
sampling = _resize_mask(sampling, target_w, target_h)
@@ -454,6 +524,9 @@ def build_crop(
"blend": blend,
"scale": scale,
"algorithm": rescale_algorithm,
# The size the crop went to the sampler at, so the stitch can tell
# when a different size came back.
"sampler_size": (int(cropped.shape[2]), int(cropped.shape[1])),
}
return cropped, sampling, stitcher
@@ -616,7 +689,10 @@ def apply_stitch(
# Qwen Image 2.1's VAE decodes RGBA; an RGB source has nowhere to
# put the alpha, so it goes rather than failing the run.
inpainted = inpainted[..., :3]
if inpainted.shape[3] != canvas.shape[3]:
# Most models paint RGB. Over a picture with see-through parts the patch
# takes the picture's own alpha where it lands, so they stay as they were.
keep_alpha = inpainted.shape[3] == 3 and canvas.shape[3] == 4
if inpainted.shape[3] != canvas.shape[3] and not keep_alpha:
raise ValueError(
f"Inpainted channels ({inpainted.shape[3]}) do not match the "
f"cropped image ({canvas.shape[3]})."
@@ -628,8 +704,26 @@ def apply_stitch(
)
patch = inpainted.to(dtype=out.dtype, device=out.device)
sent = stitcher.get("sampler_size")
came_back = (int(patch.shape[2]), int(patch.shape[1]))
trimmed = sent is not None and tuple(sent) != came_back and all(
0 <= int(a) - b < 32 for a, b in zip(sent, came_back)
)
if trimmed:
# Most often a VAE that trims the picture to a multiple of 16 or 32:
# the trimmed edge is gone, so stretching the rest over the crop
# window makes the result look slightly bigger than the original.
warn_once(
f"Stitch Inpaint: the crop went to the sampler at {sent[0]}x{sent[1]} "
f"but came back {came_back[0]}x{came_back[1]}, so it is stretched to fit "
"and can look slightly bigger. Set Crop For Inpaint's output_multiple "
"to 32 (or your model's own step) so the size comes back unchanged.",
_warned,
)
if (patch.shape[1], patch.shape[2]) != (ch, cw):
patch = _resize_image(patch, cw, ch, stitcher.get("algorithm", "bilinear"))
if keep_alpha:
patch = torch.cat([patch, out[:, cy : cy + ch, cx : cx + cw, 3:]], dim=-1)
if seam == SEAM_BLEND_IN:
plan = seam_plan(stitcher)
@@ -681,7 +775,8 @@ def apply_stitch(
# of the drift below the picture - a faint dark line along the seam.
patch = shift_tone(patch, field * alpha.unsqueeze(-1), min(1.0, float(color_match)))
if fix_edge_halo:
patch = spread_edge_colors(patch, alpha)
# The colours only: a see-through channel is not a colour to spread.
patch = torch.cat([spread_edge_colors(patch[..., :3], alpha), patch[..., 3:]], dim=-1)
weights = alpha.unsqueeze(-1)
region = out[:, cy : cy + ch, cx : cx + cw, :]
# canvas + blend * (inpainted - canvas): identical input reproduces the
@@ -711,11 +806,17 @@ def estimate_tone_offset(
diff = rgb_to_lab(inpainted[..., :3]) - rgb_to_lab(canvas[..., :3])
band = _band_weights(blend)
# See-through pixels carry whatever colour sits under the clear part
# (often black or white), not the picture's tone: read only what shows.
trust = torch.ones_like(band)
for layer in (canvas, inpainted):
if layer.shape[-1] > 3:
trust = trust * layer[..., 3].clamp(0.0, 1.0)
weights = band.unsqueeze(-1)
numerator = (weights * diff).sum(dim=(1, 2))
denominator = (weights * weights).sum(dim=(1, 2)).clamp_min(1e-6)
numerator = (trust.unsqueeze(-1) * weights * diff).sum(dim=(1, 2))
denominator = (trust.unsqueeze(-1) * weights * weights).sum(dim=(1, 2)).clamp_min(1e-6)
offset = numerator / denominator
measured = (band.sum(dim=(1, 2)) > 0).unsqueeze(-1)
measured = ((trust * band).sum(dim=(1, 2)) > 1e-6).unsqueeze(-1)
return torch.where(measured, offset, torch.zeros_like(offset))
@@ -1712,6 +1813,8 @@ __all__ = [
"seam_ramps",
"build_canvas_stitcher",
"build_transform_stitcher",
"paste_on_original",
"remember_original",
"transform_picture_box",
"stitch_blend_from_mask",
"STITCHER_VERSION",
@@ -1774,3 +1877,86 @@ def build_transform_stitcher(
stitcher = build_canvas_stitcher(frames, blend, bbox=bbox, source=source)
stitcher["generated"] = mask
return stitcher
def remember_original(stitcher: dict, original: torch.Tensor, geometry, rotation_degrees: float) -> dict:
"""Let Stitch Inpaint put a transform's result on the whole picture.
``original`` is the picture before the transform, as a BHWC batch, and
``rotation_degrees`` the transform's normalized turn. Read only by
:func:`paste_on_original`, so a stitcher without it still stitches.
"""
stitcher["original"] = {"frames": original, "geometry": geometry, "rotation": float(rotation_degrees)}
return stitcher
def paste_on_original(stitcher: dict, stitched: torch.Tensor, mask: torch.Tensor) -> tuple:
"""A transform's stitched canvas and blend mask, laid on its whole picture.
Returns the picture and the mask at the picture's own size. Only the
pixels the stitch changed reach it, and only inside the crop: padding has
no place on the picture, and pixels where the mask is zero stay
bit-identical. A straight crop at its own size is a plain paste. A turn or
a resize samples the canvas once per picture pixel. Crop For Inpaint and
Load Image + Pad stitch onto the whole picture already and pass through.
"""
record = stitcher.get("original")
if record is None:
if "generated" in stitcher:
warn_once(_NO_ORIGINAL_NOTE, _warned)
return stitched, mask
frames = stitched.shape[0]
base = record["frames"][:frames].to(stitched)
if base.shape[0] == 1:
base = base.expand(frames, -1, -1, -1)
picture_h, picture_w = base.shape[1], base.shape[2]
geometry = record["geometry"]
rotation = record["rotation"]
canvas_h, canvas_w = stitched.shape[1], stitched.shape[2]
x0, y0, x1, y1 = transform_picture_box(geometry, canvas_w, canvas_h)
crop_w, crop_h = geometry.crop_width, geometry.crop_height
if not rotation and (x1 - x0, y1 - y0) == (crop_w, crop_h):
left, top = geometry.crop_x, geometry.crop_y
weight = base.new_zeros((frames, picture_h, picture_w))
weight[:, top : top + crop_h, left : left + crop_w] = mask[:, y0:y1, x0:x1]
result = base.clone()
result[:, top : top + crop_h, left : left + crop_w] = stitched[:, y0:y1, x0:x1]
# The stitch already feathered; the guard pins what it did not touch.
return torch.where(weight.unsqueeze(-1) > 0, result, base).contiguous(), weight
rows, cols = torch.meshgrid(
torch.arange(picture_h, dtype=base.dtype, device=base.device) + 0.5,
torch.arange(picture_w, dtype=base.dtype, device=base.device) + 0.5,
indexing="ij",
)
if rotation:
# Pillow's expanded rotate: both pictures turn about their centres.
cos, sin = math.cos(math.radians(rotation)), math.sin(math.radians(rotation))
across, down = cols - picture_w / 2.0, rows - picture_h / 2.0
cols = cos * across - sin * down + geometry.rotated_width / 2.0
rows = sin * across + cos * down + geometry.rotated_height / 2.0
x = x0 + (cols - geometry.crop_x) * (x1 - x0) / crop_w
y = y0 + (rows - geometry.crop_y) * (y1 - y0) / crop_h
grid = torch.stack((2.0 * x / canvas_w - 1.0, 2.0 * y / canvas_h - 1.0), dim=-1)
grid = grid.unsqueeze(0).expand(frames, -1, -1, -1)
def sample(image):
placed = functional.grid_sample(
image.movedim(-1, 1), grid, mode="bicubic", padding_mode="border", align_corners=False
)
return placed.movedim(1, -1).clamp(0.0, 1.0)
weight = functional.grid_sample(
mask.unsqueeze(1), grid, mode="bilinear", padding_mode="zeros", align_corners=False
).squeeze(1).clamp(0.0, 1.0)
weight = weight * ((x >= x0) & (x <= x1) & (y >= y0) & (y <= y1)).to(weight)
weights = weight.unsqueeze(-1)
canvas = stitcher["canvas"][:frames].to(stitched)
if canvas.shape[0] == 1:
canvas = canvas.expand(frames, -1, -1, -1)
# What the stitch changed on the canvas, with the canvas's own soft copy
# of the picture swapped for the sharp one under the same feather.
before = sample(canvas)
pasted = base + weights * (before - base) + (sample(stitched) - before)
return torch.where(weights > 0, pasted.clamp(0.0, 1.0), base).contiguous(), weight
+12 -7
View File
@@ -9,6 +9,7 @@ batches that upload only the unique source frames each batch needs.
from __future__ import annotations
import hashlib
import logging
import math
import re
from functools import lru_cache
@@ -204,7 +205,7 @@ def _verify_checkpoint_hash(path: Path) -> None:
expected = match.group(1).lower()
if not digest.hexdigest().startswith(expected):
raise RuntimeError(
f"Frame Interpolate found a corrupt RAFT checkpoint at '{path}': "
f"Frame Interpolate found a corrupt RAFT checkpoint, '{path.name}': "
f"its SHA-256 does not start with the expected {expected}."
)
@@ -223,12 +224,15 @@ def _load_raft_model():
weights = Raft_Small_Weights.DEFAULT
checkpoint = raft_checkpoint_path(weights)
if not checkpoint.is_file():
# The folder goes to the console: an error shown in the browser never
# names a place on the server's disk.
logging.warning("[AusBoss] Frame Interpolate expects the RAFT-small checkpoint at %s", checkpoint)
raise RuntimeError(
"Frame Interpolate's optical flow method will not download model "
"weights automatically. Download the RAFT-small checkpoint only "
"after approving that model download, then place it at "
f"'{checkpoint}', or switch the method to 'blend'. Source URL: "
f"{weights.url}"
f"after approving that model download, then place {checkpoint.name} "
"in PyTorch's checkpoints folder (the server console shows where), "
f"or switch the method to 'blend'. Source URL: {weights.url}"
)
try:
_verify_checkpoint_hash(checkpoint)
@@ -236,11 +240,12 @@ def _load_raft_model():
model = raft_small(weights=None, progress=False)
model.load_state_dict(state_dict)
except Exception as exc:
detail = str(exc).encode("ascii", "replace").decode("ascii")
logging.warning("[AusBoss] Frame Interpolate could not load %s: %s", checkpoint, exc)
raise RuntimeError(
"Frame Interpolate could not load the RAFT-small optical flow "
f"weights from '{checkpoint}'. Replace the cached file with the "
f"official checkpoint or switch to 'blend'. Original error: {detail}"
f"weights from '{checkpoint.name}'. Replace the cached file with the "
"official checkpoint or switch to 'blend'. The server console has "
"the details."
) from exc
model.eval()
return model
+26
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import hashlib
import logging
from functools import lru_cache
from pathlib import Path
@@ -21,6 +23,13 @@ except ImportError: # Offline tests import this module without ComfyUI.
MODEL_FOLDER = "lama"
MODEL_EXTENSIONS = {".pt", ".pth"}
DEFAULT_MODEL_NAME = "big-lama.pt"
# The two builds of big-lama.pt in common use: the release the help page links,
# and the one on Hugging Face (fashn-ai/LaMa). TorchScript has no safe mode and
# a file can run code when it opens, so nothing else reaches torch.jit.load.
KNOWN_MODEL_SHA256 = {
"344c77bbcb158f17dd143070d1e789f38a66c04202311ae3a258ef66667a9ea9",
"7ba7aa7ac37a4d41fdbbeba3a2af7ead18058552997e3a3cd1a3b2210c9e6b4c",
}
PREVIEW_MAX_SIZE = 512
@@ -67,8 +76,25 @@ def resolve_lama_model(model_name: str) -> Path:
)
def _verify_known_model(path: Path) -> None:
"""Refuse any file but a known build of the model, whatever its name."""
digest = hashlib.sha256()
with path.open("rb") as checkpoint:
for chunk in iter(lambda: checkpoint.read(1024 * 1024), b""):
digest.update(chunk)
if digest.hexdigest() not in KNOWN_MODEL_SHA256:
logging.warning("[AusBoss] LaMa Inpaint refused %s, SHA-256 %s", path, digest.hexdigest())
raise ValueError(
f"LaMa Inpaint did not open '{path.name}' because it is not a known "
f"{DEFAULT_MODEL_NAME}. A LaMa model file can run code when it opens, "
"so only known copies are used. The node's help page has the "
"download link."
)
@lru_cache(maxsize=2)
def load_lama_model(model_path: str):
_verify_known_model(Path(model_path))
# Cached on CPU so ComfyUI's VRAM stays free between runs; run_lama_inpaint
# moves the module to the compute device only for the duration of a batch.
model = torch.jit.load(model_path, map_location="cpu")
+64 -1
View File
@@ -57,12 +57,15 @@ def parse_lora_stack(raw: str | None) -> list[dict[str, Any]]:
# they are skipped rather than rejected so a half-built stack runs.
continue
strength = clamp_strength(entry.get("strength", 1.0))
# A row in a group that is switched off does not run; its own
# "enabled" is kept for when the group comes back on.
enabled = bool(entry.get("enabled", True)) and entry.get("group_off") is not True
rows.append(
{
"name": name,
"strength": strength,
"strength_clip": clamp_strength(entry.get("strength_clip", strength), strength),
"enabled": bool(entry.get("enabled", True)),
"enabled": enabled,
"triggers": str(entry.get("triggers") or "").strip(),
}
)
@@ -728,6 +731,53 @@ def save_civitai_sidecar(name: str, payload: Any) -> dict[str, Any]:
return info
# Civitai's preview picture, saved beside the LoRA so the hover preview finds
# it. The browser downloads it; this server only checks and writes it.
MAX_PICTURE_BYTES = 8 * 1024 * 1024
MAX_PICTURE_PIXELS = 40_000_000
PICTURE_EDGE = 768
def save_civitai_picture(name: str, data: bytes) -> Path:
"""Save a picture as <model>.preview.jpg next to the LoRA.
The bytes come from the browser, so they are decoded here and written
again as a small JPEG with no metadata. A LoRA that already has any
preview picture keeps it: nothing is replaced, even in a race.
"""
if not isinstance(data, (bytes, bytearray)) or not data:
raise ValueError("No picture was sent.")
if len(data) > MAX_PICTURE_BYTES:
raise ValueError("The picture is too large.")
path = resolve_lora_path(name)
if find_thumbnail(name) is not None:
raise ValueError("This LoRA already has a preview picture.")
from io import BytesIO
from PIL import Image
try:
with Image.open(BytesIO(data)) as picture:
width, height = picture.size
if width * height > MAX_PICTURE_PIXELS:
raise ValueError("The picture is too large.")
picture = picture.convert("RGB")
picture.thumbnail((PICTURE_EDGE, PICTURE_EDGE))
out = BytesIO()
picture.save(out, "JPEG", quality=88)
except ValueError:
raise
except Exception as exc:
raise ValueError("That is not a picture.") from exc
target = Path(str(path.with_suffix("")) + ".preview.jpg")
try:
with open(target, "xb") as handle:
handle.write(out.getvalue())
except FileExistsError as exc:
raise ValueError("This LoRA already has a preview picture.") from exc
return target
def lora_info(name: str) -> dict[str, Any]:
path = resolve_lora_path(name)
metadata = read_safetensors_metadata(path)
@@ -812,6 +862,19 @@ def register_lora_routes() -> None:
except Exception as exc:
return web.json_response({"ok": False, "error": safe_route_error(exc)}, status=400)
@prompt_server.routes.post("/ausboss/lora/picture")
async def ausboss_lora_picture(request):
try:
if (request.content_length or 0) > MAX_PICTURE_BYTES:
raise ValueError("The picture is too large.")
data = await request.read()
await asyncio_run_in_executor(
save_civitai_picture, request.query.get("name", ""), data
)
return web.json_response({"ok": True})
except Exception as exc:
return web.json_response({"ok": False, "error": safe_route_error(exc)}, status=400)
@prompt_server.routes.post("/ausboss/lora/triggers")
async def ausboss_lora_triggers(request):
try:
+6
View File
@@ -118,6 +118,11 @@ NOTHING_PICKED = (
"Mask by Touch: nothing is picked yet. Click a thing on the node's "
"picture, or paint over it, then run again."
)
OTHER_PICTURE = (
"Mask by Touch: the picks were made on another picture. The 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."
)
NOT_TOUCHES = (
"Mask by Touch could not read what was picked. Press Clear on the node "
"and pick again."
@@ -1138,6 +1143,7 @@ __all__ = [
"NOT_SAM3",
"NOT_TOUCHES",
"NO_TEXT_READER",
"OTHER_PICTURE",
"PIECES_MAX",
"PLANE_BITS",
"POINTS_MAX",
+9
View File
@@ -58,6 +58,11 @@ def _as_bhw(mask: torch.Tensor) -> torch.Tensor:
return mask.float().clamp(0.0, 1.0)
# Growing or blurring a whole clip at once costs the CPU many times the mask's
# own size, so long batches go a few frames at a time. Same result to the bit.
MASK_CHUNK = 8
def grow_shrink_mask(mask: torch.Tensor, pixels: int) -> torch.Tensor:
"""Dilate (positive) or erode (negative) by whole pixels; soft values survive.
@@ -72,6 +77,8 @@ def grow_shrink_mask(mask: torch.Tensor, pixels: int) -> torch.Tensor:
steps = abs(int(pixels))
if steps == 0:
return mask
if mask.shape[0] > MASK_CHUNK:
return torch.cat([grow_shrink_mask(part, pixels) for part in mask.split(MASK_CHUNK)])
size = 2 * steps + 1
grown = mask.unsqueeze(1)
if pixels > 0:
@@ -187,6 +194,8 @@ def fill_mask_holes(mask: torch.Tensor, max_hole_size: float = 0.0) -> torch.Ten
def blur_mask(mask: torch.Tensor, sigma: float) -> torch.Tensor:
if sigma <= 0.0:
return mask
if mask.shape[0] > MASK_CHUNK:
return torch.cat([blur_mask(part, sigma) for part in mask.split(MASK_CHUNK)])
radius = max(1, int(math.ceil(float(sigma) * 3.0)))
coords = torch.arange(-radius, radius + 1, dtype=torch.float32, device=mask.device)
kernel = torch.exp(-(coords**2) / (2.0 * float(sigma) ** 2))
+28 -14
View File
@@ -36,6 +36,13 @@ SERVER_FILE_RULE = (
"Pick the video from the node's Uploads or Outputs list. A whole path is not read; "
"a video is named the way ComfyUI names it, such as clip.mp4 [output]."
)
# The loaders' own source (Load Image + Pad, Image Crop + Rotate + Pad, Load
# Video) takes the names core Load Image takes: a file in the input folder,
# or one ComfyUI made, "photo.png [output]" or "photo.png [temp]".
INPUT_FILE_RULE = (
"Pick the file from the node's list or upload it. A whole path is not read; "
"a file is named the way ComfyUI names it, such as photo.png or photo.png [output]."
)
_SERVER_FOLDERS = {
"input": "get_input_directory",
"output": "get_output_directory",
@@ -93,15 +100,13 @@ def resolve_input_path(selection: str) -> Path:
if str(selection).replace("\\", "/").startswith("//"):
raise ValueError("Network source paths are not allowed.")
if folder_paths is None:
raise ValueError("Source files are read only inside ComfyUI's input folder.")
path = Path(folder_paths.get_annotated_filepath(selection)).resolve()
input_root = Path(folder_paths.get_input_directory()).resolve()
try:
path.relative_to(input_root)
except ValueError as exc:
raise ValueError("The selected source is outside ComfyUI's input folder.") from exc
raise ValueError("Source files are read only inside ComfyUI's input, output and temp folders.")
folder, inside, path = _locate_comfy_file(str(selection).replace("\\", "/"), INPUT_FILE_RULE)
if not path.is_file():
raise ValueError("The selected source file no longer exists.")
raise ValueError(
f"This file is no longer in ComfyUI's {folder} folder: {inside}. "
"Pick it again from the node's list or upload it."
)
return path
@@ -139,6 +144,17 @@ def resolve_server_file(name: str) -> Path:
text = str(name or "").strip().strip('"').replace("\\", "/")
if not text:
raise ValueError("Pick a video from the node's Outputs list first.")
folder, inside, path = _locate_comfy_file(text, SERVER_FILE_RULE)
if not path.is_file():
raise ValueError(f"This video is no longer in ComfyUI's {folder} folder: {inside}")
return path
def _locate_comfy_file(text: str, rule: str) -> tuple[str, str, Path]:
"""The folder, the name inside it and the resolved path for a name
written the way ComfyUI writes it, with forward slashes. Anything else
raises ``rule``: a whole path, "~", "." or ".." is refused from the text
alone, and a link that leads out of the folder once it is resolved."""
folder, inside = "input", text
for tag in _SERVER_FOLDERS:
tail = f" [{tag}]"
@@ -150,16 +166,14 @@ def resolve_server_file(name: str) -> Path:
or any(part in ("", ".", "..") for part in parts)
or (os.name == "nt" and ":" in inside)
):
raise ValueError(SERVER_FILE_RULE)
raise ValueError(rule)
root = _server_folders().get(folder)
if root is None:
raise ValueError(SERVER_FILE_RULE)
raise ValueError(rule)
path = root.joinpath(*parts).resolve()
if root not in path.parents:
raise ValueError(SERVER_FILE_RULE)
if not path.is_file():
raise ValueError(f"This video is no longer in ComfyUI's {folder} folder: {inside}")
return path
raise ValueError(rule)
return folder, inside, path
def list_output_videos() -> list[str]:
+15 -6
View File
@@ -35,12 +35,13 @@ def temp_prefix(node: str) -> str:
return f"ausboss_{node}_" + "".join(random.choices(string.ascii_lowercase, k=5))
def first_frame_to_pil(image: torch.Tensor, source: str = "Preview") -> Image.Image:
def first_frame_to_pil(image: torch.Tensor, source: str = "Preview", keep_alpha: bool = False) -> Image.Image:
"""First frame of a BHWC float batch as a PIL image, ready to save.
A single-channel frame becomes an 8-bit grayscale image rather than a
1-channel RGB one, which is how a mask arrives here once its BHW shape has
been unsqueezed into BHWC.
been unsqueezed into BHWC. With ``keep_alpha`` a four-channel frame with
see-through parts stays RGBA; an opaque one is RGB either way.
"""
if not isinstance(image, torch.Tensor) or image.ndim != 4:
raise ValueError(f"{source} expected a BHWC IMAGE batch.")
@@ -51,6 +52,8 @@ def first_frame_to_pil(image: torch.Tensor, source: str = "Preview") -> Image.Im
if array.shape[-1] == 1:
array = array[..., 0]
elif array.shape[-1] > 3:
if keep_alpha and array.shape[-1] == 4 and int(array[..., 3].min()) < 255:
return Image.fromarray(array, "RGBA")
# Drop alpha: the panel composites onto its own dark stage, so a
# transparent preview would read as a hole rather than as coverage.
array = array[..., :3]
@@ -64,11 +67,14 @@ def mask_to_preview_batch(mask: torch.Tensor, source: str = "Preview") -> torch.
return mask.unsqueeze(-1)
def save_temp_preview(image: torch.Tensor, prefix: str, source: str = "Preview") -> dict:
"""Write one preview PNG to the temp folder; returns its ui.images entry."""
def save_temp_preview(image: torch.Tensor, prefix: str, source: str = "Preview", keep_alpha: bool = False) -> dict:
"""Write one preview PNG to the temp folder; returns its ui.images entry.
With ``keep_alpha`` a picture with see-through parts is saved with them,
and its entry says so with ``"alpha": True``."""
if folder_paths is None:
raise RuntimeError(f"{source} requires ComfyUI's folder_paths at runtime.")
preview = first_frame_to_pil(image, source)
preview = first_frame_to_pil(image, source, keep_alpha)
full_output_folder, filename, counter, subfolder, _prefix = (
folder_paths.get_save_image_path(
prefix,
@@ -79,13 +85,16 @@ def save_temp_preview(image: torch.Tensor, prefix: str, source: str = "Preview")
)
file = f"{filename}_{counter:05}_.png"
preview.save(Path(full_output_folder) / file, compress_level=4)
return {
entry = {
"filename": file,
"subfolder": subfolder,
"type": "temp",
"width": preview.width,
"height": preview.height,
}
if preview.mode == "RGBA":
entry["alpha"] = True
return entry
def preview_payload(image: torch.Tensor, prefix: str, source: str, result: tuple) -> dict:
+32 -14
View File
@@ -1,8 +1,9 @@
"""Layered PSD files, written from plain arrays.
One small writer for the one shape the pack needs: 8-bit RGB layers that are
all the size of the picture, each with an optional layer mask, plus the flat
picture every PSD carries for programs that do not read layers. The layout
all the size of the picture, each with optional see-through parts and an
optional layer mask, plus the flat picture every PSD carries for programs
that do not read layers. The layout
is the plain one an editor writes itself, so Photoshop, GIMP and other
editors that read PSD files open the result with its layers and masks.
@@ -75,12 +76,15 @@ def _record(name: str, height: int, width: int, has_mask: bool) -> bytes:
return record + struct.pack(">I", len(extra)) + extra
def write_psd(path, layers, composite) -> Path:
def write_psd(path, layers, composite, alpha=None) -> Path:
"""Write ``layers`` (bottom one first) and the flat ``composite`` to ``path``.
A layer is ``{"name": str, "rgb": HxWx3 uint8, "mask": HxW uint8 or None}``.
A mask shows its layer where it is white and hides it where it is black,
as in every editor. All pictures must share one size.
A layer is ``{"name": str, "rgb": HxWx3 uint8, "alpha": HxW uint8 or
None, "mask": HxW uint8 or None}``. ``alpha`` is how solid each pixel is,
255 where it is fully there; without one the layer is solid. A mask
shows its layer where it is white and hides it where it is black, as in
every editor. ``alpha`` beside the composite is the flat picture's own.
All pictures must share one size.
"""
flat = _rgb(composite, "The flat picture")
height, width = flat.shape[:2]
@@ -99,12 +103,23 @@ def write_psd(path, layers, composite) -> Path:
if rgb.shape[:2] != (height, width):
raise ValueError(f"{source} must be {width}x{height}, the size of the flat picture.")
mask = layer.get("mask")
solid = layer.get("alpha")
records.append(_record(layer.get("name", ""), height, width, mask is not None))
planes.append(opaque)
planes.append(opaque if solid is None else _plane(solid, height, width, f"{source}'s alpha"))
planes.extend(rgb[:, :, channel] for channel in range(3))
if mask is not None:
planes.append(_plane(mask, height, width, f"{source}'s mask"))
if alpha is not None:
solid = _plane(alpha, height, width, "The flat picture's alpha")
# Photoshop keeps a see-through flat picture laid over white, and
# readers take the white back out with its alpha.
weight = solid[:, :, None].astype(np.float32) / 255.0
flat = np.round(flat * weight + 255.0 * (1.0 - weight)).astype(np.uint8)
flat_planes = [flat[:, :, channel] for channel in range(3)]
if alpha is not None:
flat_planes.append(solid)
plane_bytes = len(RAW) + height * width
layer_info = 2 + sum(len(record) for record in records) + plane_bytes * len(planes)
padding = layer_info % 2 # the list of layers ends on an even byte
@@ -114,14 +129,16 @@ def write_psd(path, layers, composite) -> Path:
target = Path(path)
with open(target, "wb") as file:
# Version 1, three channels, 8 bits each, RGB.
file.write(b"8BPS" + struct.pack(">H6xHIIHH", 1, 3, height, width, 8, 3))
# Version 1, three channels (four with the flat picture's alpha), 8
# bits each, RGB.
file.write(b"8BPS" + struct.pack(">H6xHIIHH", 1, len(flat_planes), height, width, 8, 3))
file.write(struct.pack(">I", 0)) # no colour table
file.write(struct.pack(">I", 0)) # no image resources
# The layer block: its length, the list of layers, then an empty
# block for the document-wide mask settings.
# block for the document-wide mask settings. A negative count says
# the flat picture's fourth channel is its alpha.
file.write(struct.pack(">I", 4 + layer_info + 4))
file.write(struct.pack(">Ih", layer_info, len(records)))
file.write(struct.pack(">Ih", layer_info, -len(records) if alpha is not None else len(records)))
for record in records:
file.write(record)
for plane in planes:
@@ -129,10 +146,11 @@ def write_psd(path, layers, composite) -> Path:
file.write(plane.tobytes())
file.write(b"\x00" * padding)
file.write(struct.pack(">I", 0))
# The flat picture: one compression mark, then red, green and blue.
# The flat picture: one compression mark, then red, green and blue
# (and its alpha).
file.write(RAW)
for channel in range(3):
file.write(np.ascontiguousarray(flat[:, :, channel]).tobytes())
for plane in flat_planes:
file.write(np.ascontiguousarray(plane).tobytes())
return target
+109
View File
@@ -0,0 +1,109 @@
"""Step pictures for the runs that show them.
ComfyUI makes a picture after every sampling step only when the run's
preview method is something other than `none`, and a fresh install starts
with `none` (`--preview-method`). The frontend's own setting, "Live preview
method", sends `default` unless the user picked one, which means "the
server's flag".
So when a queued workflow holds a Step Preview node, or a Save Image or
Save Video with its steps switch on, the pack asks for the cheap preview
(`auto`) for that one run. It never overrides a method the user chose in
the frontend, and never one the server was started with: only "nothing
chosen anywhere" becomes `auto`. ComfyUI resets the method at the start of
every run, so nothing carries over to the next one.
"""
from __future__ import annotations
STEP_PREVIEW_NODE = "AUSBOSS_NODES_StepPreview"
# The save nodes whose picture area can show the steps, and their switch.
STEP_SWITCH_NODES = ("AUSBOSS_NODES_SaveImage", "AUSBOSS_NODES_SaveVideo")
STEP_SWITCH = "step_preview"
# What the switch holds when a workflow or an API prompt leaves it out.
STEP_PREVIEW_DEFAULT = False
# The method asked for: ComfyUI's quick one, which every image model has.
STEP_METHOD = "auto"
# What the frontend sends when the user picked nothing.
UNCHOSEN = (None, "", "default")
def _switch_on(value, default: bool) -> bool:
if value is None:
return default
# A linked switch is decided by another node during the run: show the
# steps rather than guess they are off.
if isinstance(value, (list, tuple)):
return True
return value is True
def wants_step_pictures(prompt) -> bool:
"""Whether any node in an API prompt shows the steps of the run."""
if not isinstance(prompt, dict):
return False
for entry in prompt.values():
if not isinstance(entry, dict):
continue
kind = entry.get("class_type")
if kind == STEP_PREVIEW_NODE:
return True
if kind in STEP_SWITCH_NODES:
inputs = entry.get("inputs") if isinstance(entry.get("inputs"), dict) else {}
# Save Image with its picture switched off has nowhere to show them.
if inputs.get("preview") is False:
continue
if _switch_on(inputs.get(STEP_SWITCH), STEP_PREVIEW_DEFAULT):
return True
return False
def ask_for_step_pictures(json_data, server_method: str | None):
"""Set `auto` on a queued prompt that shows its steps, when nobody chose.
`json_data` is the body of POST /prompt; `server_method` is the method
ComfyUI was started with (`none` on a fresh install). Returns the same
object, changed or not.
"""
if not isinstance(json_data, dict) or not wants_step_pictures(json_data.get("prompt")):
return json_data
if server_method not in UNCHOSEN and server_method != "none":
return json_data
extra = json_data.get("extra_data")
if extra is None:
extra = json_data["extra_data"] = {}
if not isinstance(extra, dict) or extra.get("preview_method") not in UNCHOSEN:
return json_data
extra["preview_method"] = STEP_METHOD
return json_data
def _server_method() -> str | None:
try:
from latent_preview import default_preview_method
except Exception:
return None
return str(getattr(default_preview_method, "value", default_preview_method))
def register_step_preview_handler() -> None:
"""Hook ask_for_step_pictures onto every queued prompt, once per server."""
try:
from server import PromptServer
except Exception:
return
instance = getattr(PromptServer, "instance", None)
if instance is None or getattr(instance, "_ausboss_step_preview", False):
return
if not callable(getattr(instance, "add_on_prompt_handler", None)):
return
instance._ausboss_step_preview = True
def handler(json_data):
try:
return ask_for_step_pictures(json_data, _server_method())
except Exception:
# Never in the way of a run: it simply goes without step pictures.
return json_data
instance.add_on_prompt_handler(handler)
+10 -2
View File
@@ -125,8 +125,12 @@ def _estimate_window_frames(metadata: dict, start: float, end: float) -> int:
def clip_load_window(path, start_seconds, end_seconds, max_frames=0,
start_frame=None, end_frame=None, frame_load_cap=None):
"""Linked source-frame bounds override seconds; the end frame is exclusive."""
start_frame=None, end_frame=None, frame_load_cap=None, last_seconds=0):
"""Linked source-frame bounds override seconds; the end frame is exclusive.
last_seconds keeps only that much of the end of the window, so a job that
reads only the end of a clip never decodes the rest of it.
"""
if start_frame is not None or end_frame is not None:
fps = float(video_metadata(path).get("fps") or 0)
if not math.isfinite(fps) or fps <= 0:
@@ -135,6 +139,10 @@ def clip_load_window(path, start_seconds, end_seconds, max_frames=0,
start_seconds = max(0, int(start_frame)) / fps
if end_frame is not None:
end_seconds = max(0, int(end_frame)) / fps
if float(last_seconds or 0) > 0:
duration = float(video_metadata(path).get("duration") or 0)
end = min(float(end_seconds) or duration, duration)
start_seconds = max(float(start_seconds), end - float(last_seconds))
cap = max_frames if frame_load_cap is None else frame_load_cap
return float(start_seconds), float(end_seconds), max(0, int(cap))
+13 -7
View File
@@ -128,6 +128,8 @@ def _register_save_route() -> None:
"width": result["width"],
"height": result["height"],
}
if result.get("alpha"):
answer["b"]["alpha"] = True
answer["pair"] = result["pair"]
return web.json_response(answer)
@@ -140,7 +142,7 @@ class AusBossCompare:
"Erase rubs parts of one picture away with a brush so the other shows "
"through, and saves that mix as a PNG, with a layered PSD and the mask "
"if you want them. "
"image_a passes through unchanged so the node can sit mid-graph."
"image_a and image_b pass through unchanged so the node can sit mid-graph."
)
SEARCH_ALIASES = [
"image compare", "compare", "a/b", "before after", "slider", "diff",
@@ -161,7 +163,7 @@ class AusBossCompare:
{
"tooltip": (
"Baseline batch. Its first frame is preview A, and the whole "
"batch passes through the output unchanged."
"batch passes through the image_a output unchanged."
),
},
),
@@ -170,7 +172,8 @@ class AusBossCompare:
{
"tooltip": (
"Comparison batch. Its first frame is preview B, revealed by "
"sliding across the panel or flipping the A/B toggle."
"sliding across the panel or flipping the A/B toggle. The whole "
"batch passes through the image_b output unchanged."
),
},
),
@@ -190,10 +193,11 @@ class AusBossCompare:
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image_a",)
RETURN_TYPES = ("IMAGE", "IMAGE")
RETURN_NAMES = ("image_a", "image_b")
OUTPUT_TOOLTIPS = (
"image_a passed through unchanged, so downstream nodes keep working while you compare.",
"The image_b batch, untouched: the picture you compared against, often the edited one.",
)
OUTPUT_NODE = True
FUNCTION = "compare"
@@ -211,7 +215,7 @@ class AusBossCompare:
# The name the files will get, when one is wired in.
"save_name": [wired],
},
"result": (image_a,),
"result": (image_a, image_b),
}
@staticmethod
@@ -220,7 +224,9 @@ class AusBossCompare:
return Path(folder_paths.get_temp_directory()) / preview.get("subfolder", "") / preview["filename"]
def _save_temp_preview(self, image):
return save_temp_preview(image, self._prefix, "Compare")
# See-through parts are kept: the panel shows them over a checkerboard,
# and Erase can bring them back into a mix.
return save_temp_preview(image, self._prefix, "Compare", keep_alpha=True)
_register_save_route()
+384
View File
@@ -0,0 +1,384 @@
"""H3 Prompt Writer 🆎."""
from __future__ import annotations
import sys
from ._h3_cast_helpers import JOBS, KEEP, KEEPS_ITS_LENGTH, PERSON, PLACE, START, THING, VIDEO_JOBS
from ._h3_prompt_helpers import find_beats, split_sections, write_prompt
from ._h3_script_helpers import CAMERAS, LOOKS, script_report
from ._route_errors import safe_route_error
VOICE_SECONDS = 15.0 # A longer clip only costs memory.
SONG_UNDER_LEVEL = 0.035 # H3 speaks at about 0.05, so the words stay clear.
FRAME_RATE = 24
LONGEST_SECONDS = 15
KEEP_FRAMES = 44 # What H3 reads to carry a clip on: about its last 2 seconds. H3 reads a video from its start.
LONGEST_SCRIPT = 20000 # All the timeline reads of what is sent to it.
PICTURES = (1, 2, 3, 4)
NO_SHAPE = (1344, 756) # For a pasted prompt with nothing connected: H3's own width, wide.
AS_TYPED = "as typed"
FROM_PICTURE = "from the picture"
JOB_HELP = (
"What this picture is for. Person goes wherever your lines say. Place is where it happens. Outfit and thing work "
"best with nobody in the picture. Style gives only the look. Start on it and end on it fix the first or last moment."
)
NEEDS_TEXT_GENERATE = "This ComfyUI has no Generate Text node. Update ComfyUI, or disconnect the model on clip."
SAMPLING = {
"sampling_mode": "on",
"temperature": 0.5,
"top_k": 20,
"top_p": 0.9,
"min_p": 0.0,
"repetition_penalty": 1.0,
"presence_penalty": 0.0,
"seed": 1,
}
def _core_nodes() -> dict:
"""ComfyUI's own node classes, by key. Empty outside ComfyUI."""
mappings = getattr(sys.modules.get("nodes"), "NODE_CLASS_MAPPINGS", None)
return mappings if isinstance(mappings, dict) else {}
def with_sound_and_music(script, sound, music):
"""The Sound and Music boxes, as the whole-clip sound: and music: lines a script can carry.
H3's own six parts go through as they are, so the boxes add nothing to them."""
extra = [f"{name}: {text.strip()}" for name, text in (("sound", sound), ("music", music)) if str(text or "").strip()]
if not extra or split_sections(script) is not None:
return script
return "\n\n".join([script.rstrip(), *extra]) if script.strip() else "\n\n".join(extra)
def model_asker(clip, image):
"""ask(question, longest) for a text model, by ComfyUI's own Generate Text."""
generate = _core_nodes().get("TextGenerate")
if generate is None or not hasattr(generate, "execute"):
raise RuntimeError(NEEDS_TEXT_GENERATE)
def ask(question, longest):
output = generate.execute(clip=clip, prompt=question, max_length=longest, sampling_mode=SAMPLING, image=image)
return output.result[0]
return ask
def fill_picture(pictures, jobs):
"""What the small model may look at: the scene, never a person's photo, whose place may not be the clip's."""
return next((pictures[slot] for job in (PLACE, START) for slot in pictures if jobs.get(slot) == job), None)
def clip_shape(video, picture):
"""(width, height) of the clip: the video's when there is one, or else the first picture's."""
source = video if video is not None else picture
if source is None:
return NO_SHAPE
return int(source.shape[2]), int(source.shape[1])
def clip_frames(seconds):
"""The nearest length H3 can make: 5 frames plus steps of 17."""
return max(1, round((seconds * FRAME_RATE - 5) / 17)) * 17 + 5
def video_frames(frames):
"""A clip's own length, on H3's grid and within what it can make."""
longest = LONGEST_SECONDS * FRAME_RATE
return max(1, (min(frames, longest) - 5) // 17) * 17 + 5
def cut_audio(audio, start, seconds, name):
"""The part of a ComfyUI AUDIO that H3 should hear."""
rate = int(audio["sample_rate"])
waveform = audio["waveform"]
first = int(round(start * rate))
if first >= waveform.shape[-1]:
raise ValueError(f"{name} ends before the second it should start from. Pick an earlier start.")
return {"waveform": waveform[..., first : first + int(round(seconds * rate))], "sample_rate": rate}
def last_of_clip(video, sound):
"""The end of a clip, picture and sound, to carry on from."""
if sound is not None:
rate = int(sound["sample_rate"])
sound = {"waveform": sound["waveform"][..., -int(round(KEEP_FRAMES / FRAME_RATE * rate)) :], "sample_rate": rate}
return video[-KEEP_FRAMES:], sound
def under_voice(song):
"""A song at one steady level, so a loud master sits as low as a quiet one. It is never turned up."""
waveform = song["waveform"]
level = float(waveform.pow(2).mean().sqrt())
return {"waveform": waveform * min(1.0, SONG_UNDER_LEVEL / max(level, 1e-6)), "sample_rate": song["sample_rate"]}
def mono_samples(audio):
"""One channel of a ComfyUI AUDIO, as numbers."""
return audio["waveform"][0].mean(dim=0).float().cpu().numpy()
class AusBossH3PromptWriter:
CATEGORY = "🆎 AusBoss/Utility"
DESCRIPTION = (
"Type what happens, one line for each moment, and leave a blank line to "
"cut to a new place. Connect up to four pictures, give each one a job "
"such as person, place or outfit, and name them @1 and @2 in your "
"lines. Connect a video to copy its moves or carry it on. Add a voice "
"or a song and it writes the lines H3 needs. H3's own six parts go "
"through as they are. Connect a small text model and it fills in a "
"one-line idea."
)
SEARCH_ALIASES = [
"h3 prompt",
"minimax prompt",
"pika prompt",
"scene script",
"timeline",
"cut on beat",
"ausboss",
]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"script": (
"STRING",
{
"default": "",
"multiline": True,
"dynamicPrompts": False,
"tooltip": (
"What happens, one line for each moment. A blank "
"line cuts to a new place. @1 is Picture 1. Start "
"a line with 3s: to put it on that second."
),
},
),
"seconds": (
"INT",
{
"default": 8,
"min": 5,
"max": 15,
"step": 1,
"tooltip": "How long the clip is. Plan on about one moment a second at most.",
},
),
},
"optional": {
"clip": (
"CLIP",
{
"tooltip": (
"Optional: a small text model such as Qwen3-VL 8B. It adds "
"moments after a one-line idea and matches the sound to the place."
),
},
),
"picture_1": ("IMAGE", {"tooltip": "Your first picture. Type @1 to name it in a line."}),
"picture_2": ("IMAGE", {"tooltip": "Optional: a second picture, with its own job. Type @2 to name it."}),
"picture_3": ("IMAGE", {"tooltip": "Optional: a third picture, with its own job. Type @3 to name it."}),
"picture_4": ("IMAGE", {"tooltip": "Optional: a fourth picture, with its own job. Type @4 to name it."}),
"video": ("IMAGE", {"tooltip": "Optional: the frames of a video. Pick what it is for under Video does."}),
"video_sound": (
"AUDIO",
{"tooltip": "Optional: the video's own sound. Only keep going uses it."},
),
"voice": (
"AUDIO",
{"tooltip": "A clip of the voice to use, about 10 seconds."},
),
"song": (
"AUDIO",
{"tooltip": "The music to use. Each moment moves onto its beat."},
),
"camera": (
[AS_TYPED, *CAMERAS],
{
"default": AS_TYPED,
"tooltip": "How the camera moves the whole way through. As typed leaves it to your words.",
},
),
"look": (
[FROM_PICTURE, *LOOKS],
{
"default": FROM_PICTURE,
"tooltip": "From the picture keeps your photo's look. The others redraw it in that style.",
},
),
"song_start": (
"FLOAT",
{
"default": 0.0,
"min": 0.0,
"max": 86400.0,
"step": 0.1,
"tooltip": (
"The second of the song to start from, so the "
"clip gets the good part."
),
},
),
"cut_on_beat": (
"BOOLEAN",
{
"default": True,
"tooltip": (
"Moves each moment onto the nearest beat of the "
"song. Pick off to keep your own seconds."
),
},
),
"language": (
"STRING",
{
"default": "English",
"dynamicPrompts": False,
"tooltip": "The language of the words in quotes.",
},
),
"job_1": (list(JOBS), {"default": PERSON, "tooltip": JOB_HELP}),
"job_2": (list(JOBS), {"default": PERSON, "tooltip": JOB_HELP}),
"job_3": (list(JOBS), {"default": PERSON, "tooltip": JOB_HELP}),
"job_4": (list(JOBS), {"default": THING, "tooltip": JOB_HELP}),
"video_job": (
list(VIDEO_JOBS),
{
"default": VIDEO_JOBS[0],
"tooltip": (
"What the video is for: copy its moves, swap the person in it, keep it going, or "
"give it a new voice. Swap and new voice ignore Seconds and keep the video's length."
),
},
),
"sound": (
"STRING",
{
"default": "",
"dynamicPrompts": False,
"tooltip": "What you hear the whole way through, like rain on a window. Leave it empty for none.",
},
),
"music": (
"STRING",
{
"default": "",
"dynamicPrompts": False,
"tooltip": "The music, like a slow lo-fi beat. A clip with music comes out louder. Empty means no music.",
},
),
},
}
RETURN_TYPES = ("STRING", "IMAGE", "IMAGE", "IMAGE", "IMAGE", "IMAGE", "AUDIO", "AUDIO", "AUDIO", "AUDIO", "INT", "INT", "INT")
RETURN_NAMES = (
"prompt",
"picture_1",
"picture_2",
"picture_3",
"picture_4",
"video",
"video_sound",
"voice",
"song",
"song_under",
"frames",
"width",
"height",
)
OUTPUT_TOOLTIPS = (
"The finished prompt. Wire it into H3's prompt.",
"Your pictures in the order the prompt counts them. Wire it into H3's first ref_image.",
"The next picture. Wire it into H3's second ref_image. Empty when there is no such picture.",
"The next picture. Wire it into H3's third ref_image. Empty when there is no such picture.",
"The next picture. Wire it into H3's fourth ref_image. Empty when there is no such picture.",
"The video, or only its end to keep going from. Wire it into H3's first ref_video.",
"The video's sound, when it should reach H3. Wire it into H3's first ref_video_audio.",
"The voice, 15 seconds at most. Wire it into H3's first ref_audio.",
"The song, cut to the clip. Wire it into H3's next ref_audio. Empty when a voice is connected too.",
"With a voice and a song, the song to mix under the finished clip's sound. Wire it into Audio Merge.",
"The clip's length in frames. Wire it into H3's length.",
"The width of your video, or else of your first picture. Wire it into H3's width.",
"The height of your video, or else of your first picture. Wire it into H3's height.",
)
FUNCTION = "write"
def write(self, script, seconds, clip=None, picture_1=None, picture_2=None, picture_3=None, picture_4=None, video=None,
video_sound=None, voice=None, song=None, camera=AS_TYPED, look=FROM_PICTURE, song_start=0.0, cut_on_beat=True,
language="English", job_1=PERSON, job_2=PERSON, job_3=PERSON, job_4=THING, video_job=VIDEO_JOBS[0],
sound="", music=""):
script = with_sound_and_music(script, sound, music)
pictures = {1: picture_1, 2: picture_2, 3: picture_3, 4: picture_4}
given = {slot: picture for slot, picture in pictures.items() if picture is not None}
kinds = {1: job_1, 2: job_2, 3: job_3, 4: job_4}
jobs = {slot: kinds[slot] for slot in given}
video_job = "" if video is None else video_job
frames = clip_frames(seconds)
if video_job in KEEPS_ITS_LENGTH:
frames = video_frames(video.shape[0])
seconds = frames / FRAME_RATE
length = frames / FRAME_RATE
if video_job == KEEP:
video, video_sound = last_of_clip(video, video_sound)
else:
video_sound = None
if voice is not None:
voice = cut_audio(voice, 0.0, VOICE_SECONDS, "Your voice")
song_beats = song_under = None
if song is not None:
song = cut_audio(song, song_start, length, "Your song")
song_beats = find_beats(mono_samples(song), song["sample_rate"]) if cut_on_beat else []
if voice is not None:
song_under = under_voice(song)
song = None
text = write_prompt(
script,
seconds=seconds,
camera="" if camera == AS_TYPED else camera,
look="" if look == FROM_PICTURE else look,
language=language,
voice=voice is not None,
song_beats=song_beats,
ask=None if clip is None else model_asker(clip, fill_picture(given, jobs)),
jobs=jobs,
video=video_job,
soundtrack=video_sound is not None,
)
ordered = [*given.values(), None, None, None, None][: len(PICTURES)]
return (text, *ordered, video, video_sound, voice, song, song_under, frames, *clip_shape(video, ordered[0]))
def _register_routes():
try:
from aiohttp import web
from server import PromptServer
except Exception:
return
if getattr(PromptServer.instance, "_ausboss_h3_writer_routes", False):
return
PromptServer.instance._ausboss_h3_writer_routes = True
@PromptServer.instance.routes.post("/ausboss/h3_writer/read")
async def read(request):
try:
body = await request.json()
seconds = min(max(int(body.get("seconds", 8)), 5), LONGEST_SECONDS)
jobs = {int(slot): job for slot, job in dict(body.get("jobs") or {}).items() if job in JOBS and int(slot) in PICTURES}
video = body.get("video") if body.get("video") in VIDEO_JOBS else ""
return web.json_response(script_report(str(body.get("script", ""))[:LONGEST_SCRIPT], seconds, jobs, video))
except (TypeError, ValueError) as error:
return web.json_response({"error": safe_route_error(error)}, status=400)
_register_routes()
NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_H3PromptWriter": AusBossH3PromptWriter}
NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_H3PromptWriter": "H3 Prompt Writer 🆎"}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+8 -3
View File
@@ -2,8 +2,9 @@
from __future__ import annotations
from ._execution_helpers import ignore_extra_inputs
from ._media_helpers import list_input_images, load_image_frames, resolve_input_path
from ._inpaint_crop_helpers import build_transform_stitcher
from ._inpaint_crop_helpers import build_transform_stitcher, remember_original
from ._transform_engine import (
original_image_batch,
resize_then_pad,
@@ -136,6 +137,8 @@ class AusBossImageCropRotatePad:
output, mask, geometry, int(stitch_blend), int(stitch_grow),
source="Image Crop + Rotate + Pad",
)
# The picture as stored: a MaskEditor stroke keeps what is under it.
remember_original(stitcher, original_image_batch(frames, True), geometry, spec.normalized().rotation_degrees)
# Nothing see-through: the prompt view is the image itself, as a copy
# so no consumer can change one through the other.
if prompt_image is None:
@@ -146,12 +149,14 @@ class AusBossImageCropRotatePad:
)
@classmethod
@ignore_extra_inputs
def VALIDATE_INPUTS(cls, image):
# ComfyUI skips its own range and list checks for every input named
# here and files a failure once per named input, so only the source
# is named. Its list check would refuse uploads it has not listed yet
# and MaskEditor saves ("... [input]"); resolve_input_path takes any
# file inside the input folder instead, and nothing outside it.
# and MaskEditor saves ("... [input]"); resolve_input_path takes the
# names core Load Image takes instead, "... [output]" and "[temp]"
# included, and nothing outside those three folders.
try:
resolve_input_path(image)
except Exception as exc:
+3 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from pathlib import Path
from ._execution_helpers import ignore_extra_inputs
from ._image_folder_helpers import (
AFTER_RUN,
AT_THE_END,
@@ -231,7 +232,7 @@ class AusBossImageFolder:
list(AT_THE_END),
{
"default": AT_THE_END[0],
"tooltip": "With one per run, after the last picture: stop the run with a message, or start over at the first.",
"tooltip": "With one per run, after the last picture: stop the run with a message, or go back to the first and keep going.",
},
),
},
@@ -258,6 +259,7 @@ class AusBossImageFolder:
return f"{fingerprint(chosen)}|{run}|{position if run == 'one per run' else ''}|{at_the_end}"
@classmethod
@ignore_extra_inputs
def VALIDATE_INPUTS(cls, source, folder):
if source is None or folder is None: # wired in: only known when the run gets there
return True
+44 -12
View File
@@ -2,12 +2,17 @@
from __future__ import annotations
from ._execution_helpers import ignore_extra_inputs
from ._inpaint_crop_helpers import (
PASTE_ONTO_CROP,
PASTE_ONTO_MODES,
PASTE_ONTO_ORIGINAL,
RESIZE_ALGORITHMS,
SEAM_CLASSIC,
SEAM_MODES,
apply_stitch,
build_crop,
paste_on_original,
stitch_blend_mask,
)
from ._unpainted_helpers import unpainted_notice, unpainted_share
@@ -15,6 +20,8 @@ from ._unpainted_helpers import unpainted_notice, unpainted_share
# A workflow saved before Seam existed can hold the card's empty value in
# the slot Seam now takes; it loads, and stitches, as classic.
_UNSET_SEAM = ("", None)
# The same for Paste onto, the card's empty value one slot on.
_UNSET_PASTE_ONTO = ("", None)
class AusBossCropForInpaint:
@@ -92,13 +99,14 @@ class AusBossCropForInpaint:
"output_multiple": (
"INT",
{
"default": 8,
"default": 32,
"min": 1,
"max": 128,
"step": 1,
"tooltip": (
"Crop and target dimensions are rounded up to a "
"multiple of this so samplers accept them."
"The crop's size is rounded up to a multiple of "
"this. 32 suits most models; a smaller step can "
"come back trimmed and look slightly bigger."
),
},
),
@@ -357,7 +365,9 @@ class AusBossStitchInpaint:
"from one image broadcasts across an inpainted frame batch. With "
"classic, turn on fix_edge_halo when the seam shows a dark or light "
"rim. The blend_mask output marks what the stitch changed, in the "
"stitched image's own coordinates, ready for a downstream color match."
"stitched image's own coordinates, ready for a downstream color match. "
"After a hand crop in Image Crop + Rotate + Pad 🆎, set Paste onto "
"to original to get your whole picture back with the edit in place."
)
SEARCH_ALIASES = [
"stitch inpaint",
@@ -433,6 +443,18 @@ class AusBossStitchInpaint:
),
},
),
"paste_onto": (
list(PASTE_ONTO_MODES),
{
"default": PASTE_ONTO_CROP,
"tooltip": (
"Where the result lands. crop gives back the "
"framed picture. original puts the edit on your "
"whole picture, for a hand crop in Image Crop + "
"Rotate + Pad 🆎."
),
},
),
},
}
@@ -446,17 +468,24 @@ class AusBossStitchInpaint:
"survived. Wire it to a color-match or compositing node to treat "
"exactly the pasted region without rebuilding the mask. With Seam "
"on blend in it shows the blend instead: white on the new area, "
"fading to zero where your picture is untouched.",
"fading to zero where your picture is untouched. With Paste onto on "
"original it is the size of your whole picture.",
)
FUNCTION = "stitch"
def stitch(self, stitcher, inpainted, fix_edge_halo=False, color_match=0.0, seam=SEAM_CLASSIC):
def stitch(
self, stitcher, inpainted, fix_edge_halo=False, color_match=0.0, seam=SEAM_CLASSIC, paste_onto=PASTE_ONTO_CROP
):
if seam in _UNSET_SEAM:
seam = SEAM_CLASSIC
if seam not in SEAM_MODES:
raise ValueError(f"Stitch Inpaint: seam must be 'classic' or 'blend in', not {seam!r}.")
image = apply_stitch(stitcher, inpainted, bool(fix_edge_halo), float(color_match), seam)
if paste_onto not in _UNSET_PASTE_ONTO and paste_onto not in PASTE_ONTO_MODES:
raise ValueError(f"Stitch Inpaint: paste_onto must be 'crop' or 'original', not {paste_onto!r}.")
result = (image, stitch_blend_mask(stitcher, image.shape[0], seam))
if paste_onto == PASTE_ONTO_ORIGINAL:
result = paste_on_original(stitcher, *result)
# Flat fill can be intentional. Keep this advisory in the console;
# it must not interrupt a successful render with a warning popup.
notice = unpainted_notice(unpainted_share(stitcher, inpainted))
@@ -465,12 +494,15 @@ class AusBossStitchInpaint:
return result
@classmethod
def VALIDATE_INPUTS(cls, seam=SEAM_CLASSIC):
# Naming seam here takes it out of ComfyUI's own list check, so the
# empty value an older workflow carries in its place still queues.
if seam in _UNSET_SEAM or seam in SEAM_MODES:
return True
return f"Stitch Inpaint: seam must be 'classic' or 'blend in', not {seam!r}."
@ignore_extra_inputs
def VALIDATE_INPUTS(cls, seam=SEAM_CLASSIC, paste_onto=PASTE_ONTO_CROP):
# Naming these here takes them out of ComfyUI's own list check, so the
# empty value an older workflow carries in their place still queues.
if seam not in _UNSET_SEAM and seam not in SEAM_MODES:
return f"Stitch Inpaint: seam must be 'classic' or 'blend in', not {seam!r}."
if paste_onto not in _UNSET_PASTE_ONTO and paste_onto not in PASTE_ONTO_MODES:
return f"Stitch Inpaint: paste_onto must be 'crop' or 'original', not {paste_onto!r}."
return True
NODE_CLASS_MAPPINGS = {

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