Compare commits
113
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4d0b18920 | ||
|
|
bd8ead7ef9 | ||
|
|
3fd491c839 | ||
|
|
93bf3dc325 | ||
|
|
5753f4048a | ||
|
|
841bfa7d06 | ||
|
|
0ffb6a576b | ||
|
|
5656144a9f | ||
|
|
3b16d1e254 | ||
|
|
dff58e6b88 | ||
|
|
ef06093d56 | ||
|
|
8582a0219d | ||
|
|
939d320b8b | ||
|
|
9bb8a201fe | ||
|
|
52c3353c69 | ||
|
|
ded731859c | ||
|
|
e14682f54f | ||
|
|
1129232f83 | ||
|
|
9ba82c45b4 | ||
|
|
099b57f755 | ||
|
|
f27e1293b0 | ||
|
|
75621c341f | ||
|
|
ba8d2b8048 | ||
|
|
00164f1df5 | ||
|
|
2316afee62 | ||
|
|
6b1b1cfabc | ||
|
|
aaf63b9d9c | ||
|
|
17fb493a66 | ||
|
|
3e2ae322f8 | ||
|
|
a61d3d4ee0 | ||
|
|
dff08a3b6e | ||
|
|
91b53162cc | ||
|
|
1ea57b6538 | ||
|
|
6d6876dd38 | ||
|
|
f9e17c1fdd | ||
|
|
21ca6e9f47 | ||
|
|
5a41046e3f | ||
|
|
264c133618 | ||
|
|
310d2ba5c3 | ||
|
|
b5bce60c22 | ||
|
|
e532e56ed9 | ||
|
|
f7d6596937 | ||
|
|
5f32967ee5 | ||
|
|
b01e9a4c70 | ||
|
|
c669f1100d | ||
|
|
39da7a3931 | ||
|
|
8b75948359 | ||
|
|
33ce662398 | ||
|
|
7f7481c505 | ||
|
|
68f4f4b301 | ||
|
|
ff6706a918 | ||
|
|
4afb60c921 | ||
|
|
c4a64366a6 | ||
|
|
e4b9a08cec | ||
|
|
a5358c4f78 | ||
|
|
d4df8f7508 | ||
|
|
e93f3337a1 | ||
|
|
d0658ea8b2 | ||
|
|
0853872174 | ||
|
|
2545b82219 | ||
|
|
13145ed881 | ||
|
|
fe04d93afa | ||
|
|
4591dd4ef9 | ||
|
|
cd05a91e14 | ||
|
|
1faca35331 | ||
|
|
3e79954bbe | ||
|
|
8837c75553 | ||
|
|
69fbceb0a1 | ||
|
|
714b638ee8 | ||
|
|
5d00076254 | ||
|
|
8b7a717004 | ||
|
|
6af50147fc | ||
|
|
c54ee8984f | ||
|
|
3ba27901cb | ||
|
|
f131718cf8 | ||
|
|
e8419b2667 | ||
|
|
ef851094ba | ||
|
|
9aefc53134 | ||
|
|
1ec9027413 | ||
|
|
641b907796 | ||
|
|
1985103fa2 | ||
|
|
acad58b613 | ||
|
|
15718c07ee | ||
|
|
438ab30b79 | ||
|
|
a37dfb93b6 | ||
|
|
a6ea043462 | ||
|
|
da98850591 | ||
|
|
812a22c070 | ||
|
|
0afae99b30 | ||
|
|
840f0003dd | ||
|
|
7469c5869e | ||
|
|
a1b14cc4a4 | ||
|
|
1051bee665 | ||
|
|
0a098e1027 | ||
|
|
8f6ac16d27 | ||
|
|
e808f4ff51 | ||
|
|
35651e3445 | ||
|
|
571f4f1896 | ||
|
|
6c705b2e42 | ||
|
|
b063e6f2c1 | ||
|
|
9917dc3d15 | ||
|
|
de88c8d5e4 | ||
|
|
8062c35f70 | ||
|
|
1f07ad8ba1 | ||
|
|
b6012c48fb | ||
|
|
cc5617db04 | ||
|
|
3d94052489 | ||
|
|
5b8cad57d7 | ||
|
|
2b34005150 | ||
|
|
48a2ce3b33 | ||
|
|
c017554d05 | ||
|
|
249275b78d | ||
|
|
adf9cda5c0 |
@@ -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
@@ -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.**
|
||||
|
||||
+336
-1
@@ -16,7 +16,7 @@ issue.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- The contributor guide is [AGENTS.md](AGENTS.md), and a new public node
|
||||
- Read the contributor guide below first, and a new public node
|
||||
follows [docs/adding_a_node.md](docs/adding_a_node.md). A new node is a
|
||||
conversation before it is a pull request.
|
||||
- Mapping keys, input names and order, and output order are a permanent API:
|
||||
@@ -41,3 +41,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.
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
@@ -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
@@ -13,8 +13,10 @@ follows a few fixed rules.
|
||||
never take a file to read from the browser: they work on the two previews
|
||||
that node made in its last run, and Align writes its lined-up copy of B
|
||||
beside them in ComfyUI's temp folder. The name typed for the new files is a plain name
|
||||
with no folder in it, and they go straight into the output folder. Load Video picks from the input
|
||||
folder; the video transform nodes read a video picked from a list of the
|
||||
with no folder in it, and they go straight into the output folder. Load Video, Load Image + Pad
|
||||
and Image Crop + Rotate + Pad pick from the input folder, and take a file ComfyUI made by
|
||||
the name it gives it (`photo.png [output]`), as core Load Image does; the video transform
|
||||
nodes read a video picked from a list of the
|
||||
input or the output folder, by the name ComfyUI gives it there
|
||||
(`clip.mp4 [output]`), and their uploader streams into the input folder.
|
||||
No input opens a whole path as given. Absolute paths, `..`, `~`, Windows drive and
|
||||
@@ -32,12 +34,21 @@ follows a few fixed rules.
|
||||
`<model>.civitai.info` (inside ComfyUI's LoRA folders). The pack checks
|
||||
that the answer describes the same file before saving it. Nothing is sent
|
||||
until you click, and the button can be turned off in the node's settings.
|
||||
The same click also has your browser download the LoRA's first picture
|
||||
from Civitai's picture server (a small copy, without cookies, rated PG-13
|
||||
or milder unless you allow more). The pack checks it is a picture and
|
||||
saves it beside the LoRA as `<model>.preview.jpg`, never replacing a
|
||||
picture that is there. The gear menu's **Civitai picture** option turns
|
||||
this off.
|
||||
- **No code execution.** Math Expression parses its text into an
|
||||
allowlisted syntax tree of arithmetic and a few math functions; it never
|
||||
runs Python source. The pack starts no subprocesses and installs nothing
|
||||
at runtime.
|
||||
- **No automatic downloads.** You place model weights (LaMa, RAFT optical
|
||||
flow) yourself; the pack never fetches them.
|
||||
- **LaMa opens only known files.** A LaMa model file can run code when it
|
||||
opens, so LaMa Inpaint checks a file's SHA-256 first and opens only known
|
||||
copies of `big-lama.pt`.
|
||||
- **No raw HTML from workflows.** Workflow Note renders its Markdown into
|
||||
text nodes, and its links open only `http(s)` addresses.
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ NODE_MODULES = [
|
||||
"node_compare",
|
||||
"node_frame_interpolate",
|
||||
"node_free_memory",
|
||||
"node_h3_prompt_writer",
|
||||
"node_image_crop_rotate_pad",
|
||||
"node_image_folder",
|
||||
"node_image_resize",
|
||||
@@ -35,6 +36,7 @@ NODE_MODULES = [
|
||||
"node_layout_boxes",
|
||||
"node_literals",
|
||||
"node_load_image_pad",
|
||||
"node_load_audio",
|
||||
"node_load_video",
|
||||
"node_lora_loader",
|
||||
"node_mask_by_name",
|
||||
@@ -49,6 +51,7 @@ NODE_MODULES = [
|
||||
"node_seed",
|
||||
"node_select_frame",
|
||||
"node_show_text",
|
||||
"node_step_preview",
|
||||
"node_tiled_upscale",
|
||||
"node_video_crop_rotate_pad",
|
||||
"node_video_crop_rotate_pad_clip",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -21,7 +21,7 @@ Every visible field and output must earn its place. Published IDs, input order,
|
||||
1. Put independently testable processing in an underscore-prefixed helper under `nodes/`.
|
||||
2. Add one V1 wrapper in `nodes/node_<purpose>.py` with `INPUT_TYPES`, `RETURN_TYPES`, `FUNCTION`, `CATEGORY`, and both mapping dictionaries — keys as string literals, never a `NODE_ID` variable, or registry scanners cannot see the node.
|
||||
3. Add the module to `NODE_MODULES` in `__init__.py` and the key to `PUBLIC_NODE_IDS` in `scripts/validate_nodes.py`; the validator fails on a registered key it does not know. A key kept only so old workflows load goes in `LEGACY_NODE_IDS` instead.
|
||||
4. Give the node its face. Any INT, FLOAT, BOOLEAN, COMBO or STRING widget on a public node is shown through a widget card entry in `js/widget_cards/index.js` (see AGENTS.md §Conventions: numbers scrub, booleans are off | on pills, one linkable widget per row, `top: true` or `forceInput` for values people wire). Write more frontend JavaScript only when the card grammar cannot provide the interaction (a stage, a player, an editor).
|
||||
4. Give the node its face. Any INT, FLOAT, BOOLEAN, COMBO or STRING widget on a public node is shown through a widget card entry in `js/widget_cards/index.js` (see CONTRIBUTING.md, Conventions: numbers scrub, booleans are off | on pills, one linkable widget per row, `top: true` or `forceInput` for values people wire). Write more frontend JavaScript only when the card grammar cannot provide the interaction (a stage, a player, an editor).
|
||||
5. Namespace every route, extension, event, DOM marker, CSS class, cache, and browser state with `ausboss`.
|
||||
6. Chain lifecycle hooks through `chainCallback`; never replace core or third-party prototypes directly.
|
||||
7. Keep normal workflow and API execution independent from the custom frontend.
|
||||
@@ -31,7 +31,7 @@ Every visible field and output must earn its place. Published IDs, input order,
|
||||
## Document and prove
|
||||
|
||||
- Add rich help at `js/docs/<exact-mapping-key>.md` (the validator checks it exists) and a section in `README.md`. Name card controls the way the node face shows them, with the widget name alongside: **Tone match** (`color_match`).
|
||||
- Show the node in an example under `example_workflows/`, a new one or an existing one it fits: a Workflow Note, numbered groups holding every node, no overlaps, and a matching `.jpg` of a real output. AGENTS.md § Example workflows lists the rest.
|
||||
- Show the node in an example under `example_workflows/`, a new one or an existing one it fits: a Workflow Note, numbered groups holding every node, no overlaps, and a matching `.jpg` of a real output. CONTRIBUTING.md, Example workflows, lists the rest.
|
||||
- A DOM panel goes into `tests/panel_guards.test.mjs` as `mustGrow` or `fixedByDesign`.
|
||||
- At the next release, name the node in the pyproject `description`: Registry and Manager search read it.
|
||||
- Extend `scripts/validate_nodes.py` when the new contract needs a permanent assertion.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
File diff suppressed because it is too large
Load Diff
@@ -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"]);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -32,13 +32,20 @@ side of the picture, which is how the pair outpaints.
|
||||
the paste mask is widened by this many pixels and blurred. The sampling
|
||||
mask sent to the inpainter stays hard-edged, so feathering never weakens
|
||||
what the model sees. `0` pastes with the raw mask.
|
||||
- **Multiple** (`output_multiple`): Crop and target dimensions are rounded
|
||||
up to a multiple of this so samplers accept them.
|
||||
- **Multiple** (`output_multiple`): The crop's size is rounded up to a
|
||||
multiple of this. Keep it at `32` (the default) for Qwen Image 2.1, Flux
|
||||
and most newer models. Their VAE trims a picture that is not a multiple
|
||||
of 16 or 32, and the trimmed result, stretched back over the crop, looks
|
||||
slightly bigger than the original. Stitch Inpaint says so in the console
|
||||
when that happens.
|
||||
- **Target width / Target height** (`target_width` / `target_height`, in
|
||||
the **Target size, extend** fold): Rescale the crop to a fixed size for
|
||||
the sampler. `0` keeps the native crop size; setting only one dimension
|
||||
derives the other from the crop's aspect ratio. Explicit values here
|
||||
override `target_megapixels`.
|
||||
override `target_megapixels`. When the crop is rescaled, the crop area
|
||||
takes in a few more pixels of context on one side so it has the exact
|
||||
shape of the target size, and the picture is scaled the same amount
|
||||
both ways, never stretched.
|
||||
|
||||
### Shaping the mask
|
||||
|
||||
@@ -95,6 +102,12 @@ Any inpainting sampler fits between the two nodes the same way — connect
|
||||
the crop's `image` and `mask` to it, then its output to Stitch Inpaint's
|
||||
`inpainted` input.
|
||||
|
||||
A picture with see-through parts keeps them through the pair. ComfyUI's
|
||||
Load Image splits them off into its `mask`; wire its `IMAGE` and `MASK`
|
||||
into **Join Image with Alpha** to put them back on before the crop. The
|
||||
sampler gets the colours, and Stitch Inpaint gives the result its clear
|
||||
parts back.
|
||||
|
||||
## Technical details
|
||||
|
||||
- Starting values: Context `1.2`, Blend `16` px, Multiple `8`; Grow, Blur,
|
||||
|
||||
@@ -8,6 +8,11 @@ every node that needs it, and change it in one place.
|
||||
- **Value** (`value`): The number to send out. It starts at `0`. The box
|
||||
shows the decimals its steps use (`0.00`) and holds up to three, so a
|
||||
typed `0.125` stays `0.125`.
|
||||
- The gear, right: **Look**. **normal** is the usual box with its label.
|
||||
**big** shows the number large in the middle of the node, with an arrow
|
||||
on each side, for a setting people change on every run, such as the
|
||||
length of a clip. A drag and typing work the same. Each node keeps its own look, and it is
|
||||
saved with the workflow.
|
||||
|
||||
## Limits
|
||||
|
||||
@@ -44,3 +49,7 @@ into **Math Expression 🆎** instead of retyping derived numbers.
|
||||
API is passed on as it is.
|
||||
- Wired only to inputs that share no value (one stops at 1, the other starts
|
||||
at 2), the box keeps no limits.
|
||||
- The look is stored in the node's properties as `ausboss_text_look: "big"`, the
|
||||
same as Text 🆎. It is not an input, so the saved value and what the node
|
||||
sends are the same in both looks, and an older version of the pack shows
|
||||
the usual box.
|
||||
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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).
|
||||
|
||||
@@ -58,7 +58,8 @@ folders. A folder from anywhere else is copied in:
|
||||
a tile makes it the next.
|
||||
|
||||
After the last picture, **At the end** either stops the run with a message
|
||||
or starts over at the first.
|
||||
or starts over at the first. With **start over**, Picture goes back to 1 and
|
||||
the pictures repeat in order for as many runs as you queue.
|
||||
|
||||
## The card
|
||||
|
||||
@@ -73,7 +74,9 @@ or starts over at the first.
|
||||
run loads, counted from 1.
|
||||
- **After a run** (`after_run`): **next** moves Picture on, **stay** keeps
|
||||
loading the same one, **random** jumps to any of the picked ones.
|
||||
- **At the end** (`at_the_end`): **stop the run** or **start over**.
|
||||
- **At the end** (`at_the_end`): after the last picture, **stop the run**
|
||||
stops with a message, and **start over** goes back to the first and keeps
|
||||
going.
|
||||
|
||||
## The tiles
|
||||
|
||||
@@ -104,7 +107,8 @@ or starts over at the first.
|
||||
There is no setting that changes this.
|
||||
- The picked pictures are saved in the workflow as a list of names
|
||||
(`pictures`). Empty means every picture, so a picture added to the folder
|
||||
later is in too. A name that is no longer in the folder is skipped.
|
||||
later is in too. A name that is no longer in the folder is skipped. It
|
||||
takes no wire: the ticks on the node write it.
|
||||
- Pictures are PNG, JPG, WebP, BMP, GIF (first frame) and TIFF. A photo that
|
||||
was taken turned comes out upright. Files whose names start with a dot are
|
||||
skipped.
|
||||
@@ -116,6 +120,9 @@ or starts over at the first.
|
||||
their own sizes. They are not resized or stacked into one batch.
|
||||
- Moving Picture on is done by the editor when a run is queued. A workflow
|
||||
sent through the API loads exactly the picture `position` names.
|
||||
- With **start over**, a Picture number past the last picture counts round
|
||||
from the first. A workflow saved with such a number loads that picture and
|
||||
moves on from there.
|
||||
- The node runs again when a picked file is added, removed or rewritten.
|
||||
- A folder lists up to 5,000 pictures, and a folder that is brought in is
|
||||
copied up to 3,000.
|
||||
|
||||
@@ -8,6 +8,11 @@ wire it to every node that needs it.
|
||||
- **Value** (`value`): The whole number to send out. It starts at `0`. The
|
||||
arrows and a drag move it by 1, or click and type. It holds numbers as
|
||||
big as a seed.
|
||||
- The gear, right: **Look**. **normal** is the usual box with its label.
|
||||
**big** shows the number large in the middle of the node, with an arrow
|
||||
on each side, for a setting people change on every run, such as the
|
||||
length of a clip. A drag and typing work the same. Each node keeps its own look, and it is
|
||||
saved with the workflow.
|
||||
|
||||
## Limits
|
||||
|
||||
@@ -40,3 +45,7 @@ For arithmetic on the way — halving a size, adding a margin — feed this into
|
||||
- On its own the box goes up to 9,007,199,254,740,991, the largest whole
|
||||
number a browser holds exactly. It stopped at 2,147,483,647 before, which
|
||||
most seeds are above.
|
||||
- The look is stored in the node's properties as `ausboss_text_look: "big"`, the
|
||||
same as Text 🆎. It is not an input, so the saved value and what the node
|
||||
sends are the same in both looks, and an older version of the pack shows
|
||||
the usual box.
|
||||
|
||||
@@ -9,7 +9,8 @@ done one at a time, so a long clip does not need more graphics memory.
|
||||
|
||||
## Model setup
|
||||
|
||||
Place `big-lama.pt` in:
|
||||
Download [`big-lama.pt`](https://github.com/Sanster/models/releases/download/add_big_lama/big-lama.pt)
|
||||
and place it in:
|
||||
|
||||
```text
|
||||
ComfyUI/models/lama/big-lama.pt
|
||||
@@ -20,6 +21,12 @@ refresh the browser or restart ComfyUI so the model list is updated. Until
|
||||
the file is there, the list still shows `big-lama.pt`, and a run stops with a
|
||||
message that says where to put it.
|
||||
|
||||
The node only opens known copies of this file, whatever you name them: the
|
||||
download above, or the one from
|
||||
[fashn-ai/LaMa](https://huggingface.co/fashn-ai/LaMa). A LaMa model file can
|
||||
run code when it opens, so any other file stops the run with a message and is
|
||||
never loaded.
|
||||
|
||||
## Controls
|
||||
|
||||
- **image**: BHWC images or video frames.
|
||||
@@ -28,7 +35,7 @@ message that says where to put it.
|
||||
another size is stretched to fit the picture. A picture with no mask
|
||||
painted on it stops the run with "No mask painted" instead of coming back
|
||||
unchanged.
|
||||
- **Model** (`model`): A `.pt` or `.pth` TorchScript checkpoint from `models/lama`.
|
||||
- **Model** (`model`): A known copy of `big-lama.pt` from `models/lama`.
|
||||
|
||||
## Output
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Load Audio
|
||||
|
||||
Loads a sound file and hands on only the part you pick. Use it for a voice
|
||||
clip, a song or any sound a video model or a save node takes, when you want
|
||||
one part of the file and not the whole thing.
|
||||
|
||||
The node shows the whole file as a wave, so you can see where the loud parts
|
||||
are. The part that comes out is bright. Drag the white **IN** and **OUT**
|
||||
handles to set where it starts and stops, drag the bright part to slide it,
|
||||
or click somewhere else on the wave to move it there. You can also type a time
|
||||
under the wave, in seconds (`12.5`) or minutes and seconds (`1:02.5`).
|
||||
|
||||
Press **▶** to hear only the part that comes out. **LOOP** plays it again and
|
||||
again.
|
||||
|
||||
## Max length
|
||||
|
||||
Set **Max length** to the longest sound the next step can take, such as the
|
||||
length of the video you are making. The start stays where it is, and anything
|
||||
past the limit turns red with a dashed line where it is cut. Next to OUT,
|
||||
the node shows how long the sound that comes out is, such as `15.0 s`. It
|
||||
turns red when part of your pick is cut; hold the mouse over it to see how
|
||||
much, and click it to move OUT to the cut. Play stops at the cut, so what you
|
||||
hear is what comes out. `0` means no limit.
|
||||
|
||||
Max length can be wired. When the wire comes from a number node such as
|
||||
Float 🆎, the wave shows the cut for that number; otherwise the length's tooltip says
|
||||
`max wired` and the run still cuts the sound to the wired length.
|
||||
|
||||
## Controls
|
||||
|
||||
- **Source** (`audio`): a sound file in ComfyUI's input folder. **Upload**
|
||||
adds one. Videos are listed as well, for their sound.
|
||||
- **Max length** (`max_seconds`): the longest the sound may be, in seconds.
|
||||
`0` means no limit.
|
||||
- **IN** (`start_seconds`): where the sound starts.
|
||||
- **OUT** (`end_seconds`): where the sound stops. `0` means the end of the
|
||||
file.
|
||||
|
||||
## Outputs
|
||||
|
||||
- `audio`: the part you picked, for any node that takes `AUDIO`.
|
||||
- `seconds`: how long that sound is.
|
||||
|
||||
## Technical details
|
||||
|
||||
- Only the picked part is decoded: decoding stops once the window is full,
|
||||
so a short pick from a long recording stays quick.
|
||||
- The sound keeps its own sample rate and channels.
|
||||
- A start past the end of the file stops the run with a message that says so.
|
||||
- The wave is drawn in the browser from the file itself; nothing extra is
|
||||
stored with the workflow besides the four values above.
|
||||
@@ -41,7 +41,7 @@ padding value unchanged.
|
||||
|
||||
## Controls
|
||||
|
||||
- **Source** (`image`): Choose or upload from ComfyUI's input folder; the list previews the image under the pointer and filters as you type.
|
||||
- **Source** (`image`): Choose or upload from ComfyUI's input folder; the list previews the image under the pointer and filters as you type. A picture ComfyUI made, named like `photo.png [output]`, loads too, as it does in core Load Image.
|
||||
- **source_image** (optional socket): Wire an image from another node here
|
||||
and it is padded instead of the file; the Source row dims while the wire
|
||||
is connected. The canvas can only show a wired picture after a run, so it
|
||||
|
||||
@@ -47,6 +47,7 @@ embedded workflow replaces the whole graph.
|
||||
## Controls
|
||||
|
||||
- **Source** (`video`): A file in the input folder; **Upload** adds new ones.
|
||||
A video ComfyUI made, named like `clip.mp4 [output]`, loads too.
|
||||
- **IN** (`start_seconds`): Skip everything before this time.
|
||||
- **OUT** (`end_seconds`): Stop at this time; `0` means the source end until a
|
||||
handle is moved.
|
||||
|
||||
@@ -29,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -52,6 +52,16 @@ file's name. A caption on the `caption_text` input writes the paired `.txt`.
|
||||
still lists it. When a run saves more than one image (a batch, or a list
|
||||
such as a folder of images), arrows beside the switch (‹ 2 / 5 ›) step
|
||||
through them.
|
||||
- **Steps** (`step_preview`, off by default): the switch left of
|
||||
**preview** shows the picture while it is being made, one step at a time,
|
||||
where the saved image goes, with a **STOP** button on the bar. Stop a run
|
||||
that is going wrong after the first steps instead of waiting for the end;
|
||||
a stopped run saves nothing and says so on the picture. Once the image is
|
||||
saved it replaces the steps. With several samplers, the node shows the one
|
||||
that feeds it. During an upscale in tiles it keeps the whole picture and
|
||||
says "upscaling in tiles" instead of showing each tile. Display only: the
|
||||
saved file is the same with it on or off.
|
||||
The [Step Preview](AUSBOSS_NODES_StepPreview.md) page has the details.
|
||||
|
||||
## Inputs
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@ duration, shows a tall video whole, and has a **LOOP** control. `gif` and
|
||||
`webp` are shown as pictures with just the file name, since a video player
|
||||
cannot play either.
|
||||
|
||||
**STEPS** (`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
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Step Preview
|
||||
|
||||
Shows the picture while it is being made, one step at a time, with a
|
||||
**Stop** button. If the picture is going wrong (the pose, the framing, an
|
||||
extra arm) you can see it after the first few steps and stop the run,
|
||||
instead of waiting for the last step.
|
||||
|
||||
Put it anywhere on the canvas. It needs no wires: it shows whichever sampler
|
||||
in the workflow is making a picture right now, and the caption under the
|
||||
picture says how far it is ("step 3 of 8").
|
||||
|
||||
## The buttons
|
||||
|
||||
- **STOP**: stops this run now. Nothing after the sampler runs, so nothing
|
||||
is saved. The next run in the queue still starts.
|
||||
- **STOP ALL · N**: shows when more runs are waiting in the queue. It
|
||||
empties the queue and stops this run.
|
||||
|
||||
As soon as the run saves the picture, the finished picture replaces the
|
||||
blurry last step, and it stays on the node until the next run starts. The
|
||||
word at the right of the bar says how the run ended: done, stopped or
|
||||
failed. A stopped run keeps the last step it reached.
|
||||
|
||||
An upscale in tiles works on one small piece of the picture at a time.
|
||||
Those pieces are not shown as the picture: the node keeps the last whole
|
||||
picture and says "upscaling in tiles", with the steps of the piece being
|
||||
worked on.
|
||||
|
||||
## Save Image and Save Video can do it too
|
||||
|
||||
Save Image 🆎 and Save Video 🆎 have a **steps** switch of their own (off by
|
||||
default). On, the picture area of the save node shows the steps of the
|
||||
picture it is about to save, with the same Stop button. Use the save node's
|
||||
switch when you look at the result anyway, and this node when you want a
|
||||
bigger view, or one view for a workflow with several samplers.
|
||||
|
||||
## Sharper step pictures
|
||||
|
||||
The step pictures are small and blurry. That is enough to judge the pose
|
||||
and the layout. Some models have a small preview decoder (a TAESD file)
|
||||
that makes them sharper: put it in ComfyUI's `models/vae_approx` folder and
|
||||
ComfyUI uses it.
|
||||
|
||||
## Technical details
|
||||
|
||||
- ComfyUI only makes step pictures when its preview method is something
|
||||
other than `none`, and a fresh install starts with `none`. When a queued
|
||||
workflow holds this node, or a Save Image or Save Video with its steps
|
||||
switch on, the pack asks for the `auto` method for that one run.
|
||||
- It never overrides a choice you made: if you picked a method in
|
||||
ComfyUI's **Live preview method** setting, or started ComfyUI with
|
||||
`--preview-method`, that one is used. `--preview-method none` on the
|
||||
command line is treated as "not chosen", since it is the default.
|
||||
- `auto` uses the model's TAESD decoder from `models/vae_approx` when it is
|
||||
installed, and a quick color estimate (latent2rgb) otherwise. Some models
|
||||
have neither (SeedVR2, for one), and then no step pictures come.
|
||||
- The pictures are at most 512 px on their long side (`--preview-size`).
|
||||
They do not change the result: the same seed makes the same picture with
|
||||
the steps shown or not.
|
||||
- They cost next to nothing. Measured with Qwen Image 2.1 on one desktop:
|
||||
8.50 s with the steps off and 8.40 s on for 8 steps, 17.62 s and 17.64 s
|
||||
for 25 steps, the same picture every time.
|
||||
- While previews are on, a ComfyUI sampler that sits on the top level of
|
||||
the workflow (not inside a box) shows the steps on its own face too, as
|
||||
it does whenever previews are turned on in ComfyUI. The node grows to
|
||||
hold the picture and keeps the last step after the run.
|
||||
- Runs queued from another workflow tab are not shown here.
|
||||
- With video models the step picture is the clip's first frame.
|
||||
@@ -7,6 +7,22 @@ from **Crop For Inpaint 🆎**, **Load Image + Pad 🆎** or any
|
||||
result, it is resized to fit first, then joined to your picture the way
|
||||
**Seam** says.
|
||||
|
||||
## Paste onto: crop or original
|
||||
|
||||
**Paste onto** picks what you get back. It lives in the gear menu, next to
|
||||
Seam.
|
||||
|
||||
- **crop** (the default) gives back the picture you framed.
|
||||
- **original** puts the edit on your whole picture, at its full size. Use it
|
||||
after you crop by hand in **Image Crop + Rotate + Pad 🆎**: the model works
|
||||
on the crop, and everything you did not paint stays exactly as it was. It
|
||||
also works when you turned or resized the crop.
|
||||
|
||||
Padding has no place on your picture, so anything painted in added space is
|
||||
left out. With **Crop For Inpaint** and **Load Image + Pad** the result is
|
||||
your whole picture either way. The video nodes don't have this yet: their
|
||||
result stays on the crop, and the console says so once.
|
||||
|
||||
## Seam: classic or blend in
|
||||
|
||||
**Seam** picks how the new area joins your picture. It lives in the gear
|
||||
@@ -60,6 +76,17 @@ are in the model's own picture.
|
||||
4n+1 (Wan) frames and drop the tail - so a long run is never thrown away
|
||||
over the count. More inpainted frames than source frames is still an error.
|
||||
|
||||
## Pictures with see-through parts
|
||||
|
||||
A picture with a clear background, such as a cut-out, keeps it. Most
|
||||
models paint colour only, so the new area takes your picture's own
|
||||
see-through parts where it lands: what was clear stays clear, and the edit
|
||||
shows where your picture is solid. A model that paints see-through parts
|
||||
of its own (Qwen Image 2.1 can) has them blended in like the colours.
|
||||
|
||||
So with a colour-only model, new things painted into a clear area stay
|
||||
hidden. Fill that area in first if the edit needs it.
|
||||
|
||||
## Controls
|
||||
|
||||
- **stitcher**: The stitcher from one of the nodes above. It carries the
|
||||
@@ -70,6 +97,8 @@ are in the model's own picture.
|
||||
- **Seam** (`seam`, in the gear menu): `classic` (the default) or
|
||||
`blend in`. Read "Seam: classic or blend in" above. It has no socket,
|
||||
so a link cannot drive it.
|
||||
- **Paste onto** (`paste_onto`, in the gear menu): `crop` (the default) or
|
||||
`original`. Read "Paste onto: crop or original" above. It has no socket.
|
||||
- **Fix edge halo** (`fix_edge_halo`, classic seam only): Off by default. Recovers the true
|
||||
color under the feathered seam before pasting, so half-transparent edge
|
||||
pixels stop blending their background in a second time. It costs real
|
||||
@@ -207,6 +236,8 @@ you brighten it first.
|
||||
## Outputs
|
||||
|
||||
- **image**: The original-size image with the inpainted region blended in.
|
||||
After a crop in a Crop + Rotate + Pad node, that means the crop's size,
|
||||
unless **Paste onto** is on original.
|
||||
- **blend_mask**: The feathered paste mask in the stitched image's own
|
||||
coordinates — white where the inpaint blended in, zero where the original
|
||||
survived untouched. Wire it into a color-match or compositing node to
|
||||
@@ -222,6 +253,16 @@ you brighten it first.
|
||||
The rest of this page is for people who want to know how blend in works
|
||||
and how it was tested.
|
||||
|
||||
### How Paste onto original works
|
||||
|
||||
Image Crop + Rotate + Pad's stitcher carries your picture and the crop's
|
||||
position. A straight crop at its own size is a plain paste, so every pixel
|
||||
the mask does not touch is bit-identical. A turned or resized crop is sampled
|
||||
back (bicubic) once per picture pixel, so the edit in those cases is as soft
|
||||
as the canvas it was painted on. Untouched pixels stay bit-identical there
|
||||
too, and in the feathered band your full-size picture replaces the canvas's
|
||||
softer copy of it.
|
||||
|
||||
### How the empty check works
|
||||
|
||||
The new area is cut into 16 px blocks, and only blocks fully inside it are
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { BRAND, notifyAusbossChange, showToast } from "../shared/index.mjs";
|
||||
import { enoughFor, lineUp, matchWidth, sameSize, sharedSides, spreadEvenly } from "../shared/line_up.mjs";
|
||||
import { outlineOf } from "../shared/outline.mjs";
|
||||
|
||||
// Line up: with two or more nodes or groups selected, ComfyUI's selection
|
||||
// toolbar gets one button that pops out a small panel. The panel lines the
|
||||
// selection up, gives it one width or height, or spaces it evenly. Every tool
|
||||
// is also a command, so it can be found in the command palette and given a key.
|
||||
//
|
||||
// Resize together: with two or more nodes selected, pulling the edge or corner
|
||||
// of one resizes them all, in both looks. A column shares the width, a row
|
||||
// shares the height, and a grid shares both.
|
||||
//
|
||||
// A node inside a selected group moves with its group, the way a drag moves
|
||||
// it. Pinned items count when the selection is measured but never move.
|
||||
|
||||
const PANEL = "AusBoss.LineUp.Panel";
|
||||
const SETTING = "AusBoss.Canvas.LineUpButton";
|
||||
const TOGETHER_SETTING = "AusBoss.Canvas.ResizeTogether";
|
||||
const ICON_CLASS = "ausboss-line-up-icon";
|
||||
|
||||
const TOOLS = [
|
||||
{ kind: "lineUp", how: "left", id: "AusBoss.LineUp.Left", label: "Line up left edges" },
|
||||
{ kind: "lineUp", how: "middleX", id: "AusBoss.LineUp.MiddleX", label: "Line up middles in a column" },
|
||||
{ kind: "lineUp", how: "right", id: "AusBoss.LineUp.Right", label: "Line up right edges" },
|
||||
{ kind: "lineUp", how: "top", id: "AusBoss.LineUp.Top", label: "Line up top edges" },
|
||||
{ kind: "lineUp", how: "middleY", id: "AusBoss.LineUp.MiddleY", label: "Line up middles in a row" },
|
||||
{ kind: "lineUp", how: "bottom", id: "AusBoss.LineUp.Bottom", label: "Line up bottom edges" },
|
||||
{ kind: "size", how: "widest", id: "AusBoss.LineUp.Widest", label: "Same width as the widest" },
|
||||
{ kind: "size", how: "narrowest", id: "AusBoss.LineUp.Narrowest", label: "Same width as the narrowest" },
|
||||
{ kind: "size", how: "tallest", id: "AusBoss.LineUp.Tallest", label: "Same height as the tallest" },
|
||||
{ kind: "size", how: "shortest", id: "AusBoss.LineUp.Shortest", label: "Same height as the shortest" },
|
||||
{ kind: "spacing", how: "across", id: "AusBoss.LineUp.SpaceAcross", label: "Even spacing across" },
|
||||
{ kind: "spacing", how: "down", id: "AusBoss.LineUp.SpaceDown", label: "Even spacing down" },
|
||||
];
|
||||
|
||||
const SECTIONS = [
|
||||
{ title: "Line up", kind: "lineUp" },
|
||||
{ title: "Same size", kind: "size" },
|
||||
{ title: "Even spacing", kind: "spacing" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icons: grey boxes in the text color, and the line or arrows they line up on
|
||||
// in the pack's teal, the color of the snap lines.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const box = (x, y, w, h) => ({ x, y, w, h });
|
||||
const turn = (parts) => ({
|
||||
boxes: parts.boxes.map(({ x, y, w, h }) => box(y, x, h, w)),
|
||||
lines: parts.lines.map(([x1, y1, x2, y2]) => [y1, x1, y2, x2]),
|
||||
heads: parts.heads.map(([x, y, dir]) => [y, x, { l: "u", r: "d", u: "l", d: "r" }[dir]]),
|
||||
});
|
||||
|
||||
const WIDEST = {
|
||||
boxes: [box(3, 4, 18, 5), box(8, 15, 8, 5)],
|
||||
lines: [[7, 12, 3.5, 12], [17, 12, 20.5, 12]],
|
||||
heads: [[3.5, 12, "l"], [20.5, 12, "r"]],
|
||||
};
|
||||
const NARROWEST = {
|
||||
boxes: [box(8, 4, 8, 5), box(3, 15, 18, 5)],
|
||||
lines: [[3, 12, 7, 12], [21, 12, 17, 12]],
|
||||
heads: [[7, 12, "r"], [17, 12, "l"]],
|
||||
};
|
||||
const ACROSS = {
|
||||
boxes: [box(2, 7, 4.5, 10), box(9.75, 7, 4.5, 10), box(17.5, 7, 4.5, 10)],
|
||||
lines: [[7.5, 12, 8.75, 12], [15.25, 12, 16.5, 12], [7.5, 9.5, 7.5, 14.5], [8.75, 9.5, 8.75, 14.5], [15.25, 9.5, 15.25, 14.5], [16.5, 9.5, 16.5, 14.5]],
|
||||
heads: [],
|
||||
};
|
||||
|
||||
const ICONS = {
|
||||
left: { boxes: [box(7, 5, 13, 5), box(7, 14, 8, 5)], lines: [[4, 3, 4, 21]], heads: [] },
|
||||
middleX: { boxes: [box(5, 5, 14, 5), box(8, 14, 8, 5)], lines: [[12, 3, 12, 21]], heads: [] },
|
||||
right: { boxes: [box(4, 5, 13, 5), box(9, 14, 8, 5)], lines: [[20, 3, 20, 21]], heads: [] },
|
||||
top: { boxes: [box(5, 7, 5, 13), box(14, 7, 5, 8)], lines: [[3, 4, 21, 4]], heads: [] },
|
||||
middleY: { boxes: [box(5, 5, 5, 14), box(14, 8, 5, 8)], lines: [[3, 12, 21, 12]], heads: [] },
|
||||
bottom: { boxes: [box(5, 4, 5, 13), box(14, 9, 5, 8)], lines: [[3, 20, 21, 20]], heads: [] },
|
||||
widest: WIDEST,
|
||||
narrowest: NARROWEST,
|
||||
tallest: turn(WIDEST),
|
||||
shortest: turn(NARROWEST),
|
||||
across: ACROSS,
|
||||
down: turn(ACROSS),
|
||||
};
|
||||
|
||||
function headPath([x, y, dir]) {
|
||||
const s = 2.6;
|
||||
const points = {
|
||||
l: [[x + s, y - s], [x, y], [x + s, y + s]],
|
||||
r: [[x - s, y - s], [x, y], [x - s, y + s]],
|
||||
u: [[x - s, y + s], [x, y], [x + s, y + s]],
|
||||
d: [[x - s, y - s], [x, y], [x + s, y - s]],
|
||||
}[dir];
|
||||
return `M${points.map((p) => p.join(" ")).join(" L")}`;
|
||||
}
|
||||
|
||||
function boxesSvg(parts, color, fillOpacity) {
|
||||
return parts.boxes
|
||||
.map(({ x, y, w, h }) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="1.4" fill="${color}" fill-opacity="${fillOpacity}" stroke="${color}" stroke-width="1.5"/>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function marksSvg(parts, color) {
|
||||
const lines = parts.lines.map(([x1, y1, x2, y2]) => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"/>`).join("");
|
||||
const heads = parts.heads.map((head) => `<path d="${headPath(head)}" fill="none"/>`).join("");
|
||||
return `<g stroke="${color}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${lines}${heads}</g>`;
|
||||
}
|
||||
|
||||
function iconSvg(how) {
|
||||
const parts = ICONS[how];
|
||||
return `<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">${boxesSvg(parts, "currentColor", 0.2)}${marksSvg(parts, BRAND)}</svg>`;
|
||||
}
|
||||
|
||||
// The toolbar button is an <i> with a class, so its icon is drawn in CSS:
|
||||
// the boxes take the button's text color, the line stays teal.
|
||||
function maskUrl(body) {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">${body}</svg>`;
|
||||
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||
}
|
||||
|
||||
function styleSheet() {
|
||||
const parts = ICONS.left;
|
||||
const boxes = maskUrl(boxesSvg(parts, "black", 0.25));
|
||||
const marks = maskUrl(marksSvg(parts, "black"));
|
||||
return `
|
||||
.${ICON_CLASS} { position: relative; display: inline-block; width: 1rem; height: 1rem; }
|
||||
.${ICON_CLASS}::before, .${ICON_CLASS}::after {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
-webkit-mask: var(--ausboss-mask) center / contain no-repeat; mask: var(--ausboss-mask) center / contain no-repeat;
|
||||
}
|
||||
.${ICON_CLASS}::before { --ausboss-mask: ${boxes}; background: currentColor; }
|
||||
.${ICON_CLASS}::after { --ausboss-mask: ${marks}; background: ${BRAND}; }
|
||||
.ausboss-line-up {
|
||||
position: fixed; z-index: 1000; padding: 6px 6px 4px; border-radius: 10px;
|
||||
background: var(--interface-panel-surface, #1f2225); color: var(--base-foreground, #e6edf3);
|
||||
border: 1px solid var(--interface-stroke, #3a3f44); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
font-size: 11px; line-height: 1.2; user-select: none;
|
||||
}
|
||||
.ausboss-line-up-title { margin: 2px 6px 3px; color: var(--muted-foreground, #8b949e); }
|
||||
.ausboss-line-up-row { display: flex; gap: 2px; }
|
||||
.ausboss-line-up-rule { height: 1px; margin: 5px 2px; background: var(--interface-stroke, #30363d); }
|
||||
.ausboss-line-up button {
|
||||
display: grid; place-items: center; width: 30px; height: 30px; padding: 0;
|
||||
border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer;
|
||||
}
|
||||
.ausboss-line-up button:hover:not(:disabled) { background: color-mix(in srgb, ${BRAND} 18%, transparent); }
|
||||
.ausboss-line-up button:focus-visible { outline: 1px solid ${BRAND}; }
|
||||
.ausboss-line-up button:disabled { opacity: 0.35; cursor: default; }
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function graphSets(graph) {
|
||||
return {
|
||||
nodes: new Set(graph?._nodes ?? graph?.nodes ?? []),
|
||||
groups: new Set(graph?._groups ?? graph?.groups ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
function pickedCount(canvas) {
|
||||
const { nodes, groups } = graphSets(canvas?.graph);
|
||||
let count = 0;
|
||||
for (const item of canvas?.selectedItems ?? []) if (nodes.has(item) || groups.has(item)) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
// The selected nodes and groups that move on their own, each with the
|
||||
// outline people see. A node or group inside a selected group is left out:
|
||||
// it rides along with that group.
|
||||
function selectionUnits(canvas) {
|
||||
const { nodes, groups } = graphSets(canvas?.graph);
|
||||
const picked = [...(canvas?.selectedItems ?? [])].filter((item) => nodes.has(item) || groups.has(item));
|
||||
const carried = new Set();
|
||||
for (const item of picked) {
|
||||
if (!groups.has(item)) continue;
|
||||
item.recomputeInsideNodes?.();
|
||||
for (const child of item.children ?? item._children ?? []) carried.add(child);
|
||||
}
|
||||
const units = [];
|
||||
for (const item of picked) {
|
||||
if (carried.has(item)) continue;
|
||||
const group = groups.has(item);
|
||||
if (!group) item.updateArea?.(canvas.ctx); // fresh after a size change this frame
|
||||
const rect = outlineOf(item, canvas, { node: !group });
|
||||
if (rect) units.push({ item, group, rect });
|
||||
}
|
||||
return units;
|
||||
}
|
||||
|
||||
function isPinned(item) {
|
||||
return item.pinned === true || item.flags?.pinned === true;
|
||||
}
|
||||
|
||||
function moveUnit({ item, group, rect }, { x, y }) {
|
||||
const dx = x - rect.x;
|
||||
const dy = y - rect.y;
|
||||
if ((!dx && !dy) || isPinned(item)) return;
|
||||
if (group) item.move(dx, dy);
|
||||
else item.pos = [item.pos[0] + dx, item.pos[1] + dy];
|
||||
}
|
||||
|
||||
function resizeUnit({ item, group, rect }, { w, h }) {
|
||||
if (isPinned(item) || item.flags?.collapsed) return;
|
||||
if (w === rect.w && h === rect.h) return;
|
||||
if (group) {
|
||||
item.resize(w, h);
|
||||
return;
|
||||
}
|
||||
// A node's outline includes its title bar and any frame drawn around it;
|
||||
// its size does not, so set the size that gives the outline asked for.
|
||||
const extraW = rect.w - item.size[0];
|
||||
const extraH = rect.h - item.size[1];
|
||||
const least = item.computeSize?.() ?? [0, 0];
|
||||
item.setSize([Math.max(w - extraW, least[0]), Math.max(h - extraH, least[1])]);
|
||||
}
|
||||
|
||||
function run(tool) {
|
||||
const canvas = app.canvas;
|
||||
if (!canvas?.graph) return;
|
||||
const units = selectionUnits(canvas);
|
||||
if (!enoughFor(tool.kind, units.length)) {
|
||||
showToast({ detail: `Select ${tool.kind === "spacing" ? "three" : "two"} or more nodes or groups first.`, life: 3000 });
|
||||
return;
|
||||
}
|
||||
const rects = units.map((unit) => unit.rect);
|
||||
if (tool.kind === "size") {
|
||||
const sizes = sameSize(rects, tool.how);
|
||||
units.forEach((unit, index) => resizeUnit(unit, sizes[index]));
|
||||
} else {
|
||||
const places = tool.kind === "lineUp" ? lineUp(rects, tool.how) : spreadEvenly(rects, tool.how);
|
||||
units.forEach((unit, index) => moveUnit(unit, places[index]));
|
||||
}
|
||||
canvas.setDirty?.(true, true);
|
||||
notifyAusbossChange();
|
||||
refreshPanel(true);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resize together
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The frontend resizes only the node under the pointer: the canvas's own
|
||||
// pointermove in the classic look, a pointermove on the window in Nodes 2.0.
|
||||
// This listens on the window and catches up a frame later, so it always reads
|
||||
// the size the frontend (and Snap into line) settled on. Which sizes are
|
||||
// shared comes from how the selection was laid out when the button went down
|
||||
// (sharedSides in js/shared/line_up.mjs). The grabbed node is
|
||||
// the canvas's resizing node, or in Nodes 2.0 the node whose corner handle
|
||||
// took the press. Every other selected node takes its size from where it
|
||||
// stood when the button went down, so nothing builds up across steps.
|
||||
|
||||
const together = { enabled: true, before: null, grabbed: null, handleOf: null, sides: null, frame: 0 };
|
||||
|
||||
function resizableSelection(canvas) {
|
||||
const { nodes } = graphSets(canvas?.graph);
|
||||
return [...(canvas?.selectedItems ?? [])].filter(
|
||||
(item) => nodes.has(item) && item.size && item.pos && !isPinned(item) && !item.flags?.collapsed,
|
||||
);
|
||||
}
|
||||
|
||||
function onTogetherDown(event) {
|
||||
endTogether();
|
||||
if (event.button !== 0 || !together.enabled) return;
|
||||
const picked = resizableSelection(app.canvas);
|
||||
if (picked.length < 2) return;
|
||||
together.before = new Map(picked.map((node) => [node, { pos: [node.pos[0], node.pos[1]], size: [node.size[0], node.size[1]] }]));
|
||||
// Nodes 2.0 draws a corner handle on each node (a div with data-corner).
|
||||
const handle = event.target?.closest?.("[data-corner]");
|
||||
together.handleOf = handle?.closest?.("[data-node-id]")?.dataset?.nodeId ?? null;
|
||||
}
|
||||
|
||||
function grabbedNode(canvas) {
|
||||
const classic = canvas?.resizing_node;
|
||||
if (classic) return together.before.has(classic) ? classic : null;
|
||||
if (together.handleOf == null) return null;
|
||||
for (const node of together.before.keys()) if (String(node.id) === together.handleOf) return node;
|
||||
return null;
|
||||
}
|
||||
|
||||
function syncSizes() {
|
||||
const canvas = app.canvas;
|
||||
if (!together.before || !canvas?.graph) return;
|
||||
together.grabbed ??= grabbedNode(canvas);
|
||||
const grabbed = together.grabbed;
|
||||
if (!grabbed) return;
|
||||
const was = together.before.get(grabbed);
|
||||
together.sides ??= sharedSides(
|
||||
[...together.before.values()].map(({ pos, size }) => ({ x: pos[0], y: pos[1], w: size[0], h: size[1] })),
|
||||
);
|
||||
const others = [...together.before].filter(([node]) => node !== grabbed);
|
||||
const least = others.map(([node]) => node.computeSize?.() ?? [0, 0]);
|
||||
// Heights go through the same rule as widths, with y for x.
|
||||
const widths = together.sides.width
|
||||
? matchWidth(
|
||||
grabbed.size[0],
|
||||
others.map(([, start], i) => ({ x: start.pos[0], w: start.size[0], least: least[i][0] })),
|
||||
{ fromLeft: grabbed.pos[0] !== was.pos[0] },
|
||||
)
|
||||
: null;
|
||||
const heights = together.sides.height
|
||||
? matchWidth(
|
||||
grabbed.size[1],
|
||||
others.map(([, start], i) => ({ x: start.pos[1], w: start.size[1], least: least[i][1] })),
|
||||
{ fromLeft: grabbed.pos[1] !== was.pos[1] },
|
||||
)
|
||||
: null;
|
||||
let moved = false;
|
||||
others.forEach(([node], index) => {
|
||||
const x = widths ? widths[index].x : node.pos[0];
|
||||
const y = heights ? heights[index].x : node.pos[1];
|
||||
const w = widths ? widths[index].w : node.size[0];
|
||||
const h = heights ? heights[index].w : node.size[1];
|
||||
if (node.pos[0] !== x || node.pos[1] !== y) {
|
||||
node.pos = [x, y];
|
||||
moved = true;
|
||||
}
|
||||
if (node.size[0] !== w || node.size[1] !== h) {
|
||||
node.setSize([w, h]);
|
||||
moved = true;
|
||||
}
|
||||
});
|
||||
if (moved) canvas.setDirty?.(true, true);
|
||||
}
|
||||
|
||||
function onTogetherMove(event) {
|
||||
if (!together.before) return;
|
||||
if (!(event.buttons & 1)) return endTogether();
|
||||
if (together.frame) return;
|
||||
together.frame = requestAnimationFrame(() => {
|
||||
together.frame = 0;
|
||||
syncSizes();
|
||||
});
|
||||
}
|
||||
|
||||
function onTogetherUp() {
|
||||
if (together.before) syncSizes(); // the last step, before the frontend records the change
|
||||
endTogether();
|
||||
}
|
||||
|
||||
function endTogether() {
|
||||
if (together.frame) cancelAnimationFrame(together.frame);
|
||||
together.frame = 0;
|
||||
together.before = null;
|
||||
together.grabbed = null;
|
||||
together.handleOf = null;
|
||||
together.sides = null;
|
||||
}
|
||||
|
||||
function installTogether() {
|
||||
window.addEventListener("pointerdown", onTogetherDown, true);
|
||||
window.addEventListener("pointermove", onTogetherMove);
|
||||
// Capture phase: the classic canvas stops its pointerup from bubbling.
|
||||
window.addEventListener("pointerup", onTogetherUp, true);
|
||||
window.addEventListener("pointercancel", endTogether, true);
|
||||
window.addEventListener("blur", endTogether);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const panel = { el: null, frame: 0, buttons: [], count: -1 };
|
||||
|
||||
function anchorButton() {
|
||||
return document.querySelector(`.selection-toolbox .${ICON_CLASS}`)?.closest("button") ?? null;
|
||||
}
|
||||
|
||||
function labelAnchor() {
|
||||
const button = anchorButton();
|
||||
if (!button || button.title) return;
|
||||
button.title = "Line up, same size, even spacing";
|
||||
button.setAttribute("aria-label", button.title);
|
||||
}
|
||||
|
||||
function buildPanel() {
|
||||
const el = document.createElement("div");
|
||||
el.className = "ausboss-line-up";
|
||||
el.setAttribute("role", "toolbar");
|
||||
el.setAttribute("aria-label", "Line up the selection");
|
||||
panel.buttons = [];
|
||||
SECTIONS.forEach((section, index) => {
|
||||
if (index) el.append(Object.assign(document.createElement("div"), { className: "ausboss-line-up-rule" }));
|
||||
el.append(Object.assign(document.createElement("div"), { className: "ausboss-line-up-title", textContent: section.title }));
|
||||
const row = Object.assign(document.createElement("div"), { className: "ausboss-line-up-row" });
|
||||
for (const tool of TOOLS.filter((t) => t.kind === section.kind)) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.title = tool.label;
|
||||
button.setAttribute("aria-label", tool.label);
|
||||
button.innerHTML = iconSvg(tool.how);
|
||||
button.addEventListener("click", () => run(tool));
|
||||
panel.buttons.push({ button, tool });
|
||||
row.append(button);
|
||||
}
|
||||
el.append(row);
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function refreshPanel(force = false) {
|
||||
if (!panel.el) return;
|
||||
const count = pickedCount(app.canvas);
|
||||
if (!force && count === panel.count) return;
|
||||
panel.count = count;
|
||||
const units = selectionUnits(app.canvas).length;
|
||||
for (const { button, tool } of panel.buttons) button.disabled = !enoughFor(tool.kind, units);
|
||||
}
|
||||
|
||||
// Above the selection toolbar, or below it when there is no room above. The
|
||||
// toolbar follows the selection, so the panel follows the toolbar.
|
||||
function placePanel() {
|
||||
const anchor = anchorButton();
|
||||
if (!panel.el || !anchor) return closePanel();
|
||||
const bar = (anchor.closest(".selection-toolbox") ?? anchor).getBoundingClientRect();
|
||||
const at = anchor.getBoundingClientRect();
|
||||
const size = panel.el.getBoundingClientRect();
|
||||
const left = Math.min(Math.max(8, at.left + at.width / 2 - size.width / 2), window.innerWidth - size.width - 8);
|
||||
let top = bar.top - size.height - 6;
|
||||
if (top < 8) top = bar.bottom + 6;
|
||||
panel.el.style.left = `${Math.round(left)}px`;
|
||||
panel.el.style.top = `${Math.round(top)}px`;
|
||||
refreshPanel();
|
||||
}
|
||||
|
||||
function follow() {
|
||||
panel.frame = 0;
|
||||
if (!panel.el) return;
|
||||
placePanel();
|
||||
if (panel.el) panel.frame = requestAnimationFrame(follow);
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
if (panel.el || !anchorButton()) return;
|
||||
panel.el = buildPanel();
|
||||
panel.count = -1;
|
||||
document.body.append(panel.el);
|
||||
window.addEventListener("pointerdown", onOutside, true);
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
follow();
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
if (!panel.el) return;
|
||||
panel.el.remove();
|
||||
panel.el = null;
|
||||
panel.buttons = [];
|
||||
if (panel.frame) cancelAnimationFrame(panel.frame);
|
||||
panel.frame = 0;
|
||||
window.removeEventListener("pointerdown", onOutside, true);
|
||||
window.removeEventListener("keydown", onKey, true);
|
||||
}
|
||||
|
||||
function onOutside(event) {
|
||||
if (!panel.el || panel.el.contains(event.target)) return;
|
||||
if (anchorButton()?.contains(event.target)) return; // its own click closes the panel
|
||||
closePanel();
|
||||
}
|
||||
|
||||
function onKey(event) {
|
||||
if (event.key === "Escape") closePanel();
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
if (panel.el) closePanel();
|
||||
else openPanel();
|
||||
}
|
||||
|
||||
function buttonOn() {
|
||||
return app.ui?.settings?.getSettingValue?.(SETTING) !== false;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "AusBoss.LineUp",
|
||||
commands: [
|
||||
{ id: PANEL, label: "AusBoss: Line up, same size, even spacing", icon: ICON_CLASS, function: togglePanel },
|
||||
...TOOLS.map((tool) => ({ id: tool.id, label: `AusBoss: ${tool.label}`, function: () => run(tool) })),
|
||||
],
|
||||
getSelectionToolboxCommands() {
|
||||
if (!buttonOn() || pickedCount(app.canvas) < 2) return [];
|
||||
// The toolbar names extension buttons from its own translations, which
|
||||
// have no entry for this one; name it once it is on the page.
|
||||
requestAnimationFrame(labelAnchor);
|
||||
return [PANEL];
|
||||
},
|
||||
settings: [
|
||||
{
|
||||
id: SETTING,
|
||||
name: "Line-up button on the selection toolbar",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
category: ["🆎 AusBoss", "Canvas", "Line-up button"],
|
||||
tooltip:
|
||||
"With two or more nodes or groups selected, the toolbar over them gets a button " +
|
||||
"that lines them up, gives them one size or spaces them evenly.",
|
||||
onChange(value) {
|
||||
if (value === false) closePanel();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: TOGETHER_SETTING,
|
||||
name: "Resize selected nodes together",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
category: ["🆎 AusBoss", "Canvas", "Resize together"],
|
||||
tooltip:
|
||||
"With two or more nodes selected, pulling the edge or corner of one resizes them all: " +
|
||||
"a column gets one width, a row one height, a grid both.",
|
||||
onChange(value) {
|
||||
together.enabled = value !== false;
|
||||
if (!together.enabled) endTogether();
|
||||
},
|
||||
},
|
||||
],
|
||||
setup() {
|
||||
const style = document.createElement("style");
|
||||
style.id = "ausboss-line-up-style";
|
||||
style.textContent = styleSheet();
|
||||
document.head.append(style);
|
||||
// onChange only fires on later edits on some frontends, so read the
|
||||
// stored value here.
|
||||
together.enabled = app.ui?.settings?.getSettingValue?.(TOGETHER_SETTING) !== false;
|
||||
installTogether();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// Load Audio's window rules for the face, mirroring audio_window in
|
||||
// nodes/_audio_trim_helpers.py so the wave shows exactly what one Run hands
|
||||
// on. Pure: tests/load_audio_window.test.mjs runs it without a browser.
|
||||
|
||||
function finite(value, fallback = 0) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
}
|
||||
|
||||
// The pick, and what comes out of it. `end` 0 means "to the end of the
|
||||
// file". A max above 0 keeps the start and cuts off anything past
|
||||
// start + max. All times in seconds; duration is the file's length.
|
||||
export function outputWindow(durationValue, startValue, endValue, maxValue = 0) {
|
||||
const duration = Math.max(0, finite(durationValue));
|
||||
const start = Math.min(Math.max(0, finite(startValue)), duration);
|
||||
const pickedEnd = finite(endValue) > 0 ? Math.min(finite(endValue), duration) : duration;
|
||||
const end = Math.max(start, pickedEnd);
|
||||
const max = Math.max(0, finite(maxValue));
|
||||
const keepEnd = max > 0 && end - start > max ? Math.min(duration, start + max) : end;
|
||||
return { start, end, keepEnd, length: keepEnd - start, cut: Math.max(0, end - keepEnd) };
|
||||
}
|
||||
|
||||
// "0:12.0" style, the way the IN and OUT fields write a time.
|
||||
export function secondsLabel(value) {
|
||||
const seconds = Math.max(0, finite(value));
|
||||
if (seconds < 60) return `${seconds.toFixed(1)} s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${minutes}:${(seconds - minutes * 60).toFixed(1).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
// The line between IN and OUT: what comes out, and what the max cuts off.
|
||||
export function outputSummary(window, { maxFromLink = false } = {}) {
|
||||
if (!window || window.length <= 0) return "";
|
||||
const parts = [secondsLabel(window.length)];
|
||||
if (window.cut > 0.005) parts.push(`cut ${secondsLabel(window.cut)}`);
|
||||
if (maxFromLink) parts.push("max wired");
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
// Loud and quiet across the file: for each of `buckets` slices, the lowest
|
||||
// and highest sample of the channels mixed down. Channel data is a list of
|
||||
// Float32Arrays (AudioBuffer.getChannelData). Returns a Float32Array of
|
||||
// [min0, max0, min1, max1, ...].
|
||||
export function wavePeaks(channels, bucketsValue = 1200) {
|
||||
const list = Array.isArray(channels) ? channels.filter(Boolean) : [];
|
||||
const buckets = Math.max(1, Math.floor(finite(bucketsValue, 1200)));
|
||||
const peaks = new Float32Array(buckets * 2);
|
||||
const length = list.length ? list[0].length : 0;
|
||||
if (!length) return peaks;
|
||||
const count = list.length;
|
||||
for (let bucket = 0; bucket < buckets; bucket += 1) {
|
||||
const from = Math.floor((bucket * length) / buckets);
|
||||
const to = Math.max(from + 1, Math.floor(((bucket + 1) * length) / buckets));
|
||||
let low = 0;
|
||||
let high = 0;
|
||||
for (let index = from; index < to && index < length; index += 1) {
|
||||
let sample = 0;
|
||||
for (let channel = 0; channel < count; channel += 1) sample += list[channel][index] || 0;
|
||||
sample /= count;
|
||||
if (sample < low) low = sample;
|
||||
if (sample > high) high = sample;
|
||||
}
|
||||
peaks[bucket * 2] = low;
|
||||
peaks[bucket * 2 + 1] = high;
|
||||
}
|
||||
return peaks;
|
||||
}
|
||||
|
||||
// Which part of the wave a press at `fraction` (0..1 across it) grabs: a
|
||||
// handle within `hitPixels` of it, the inside of the pick to slide it, or
|
||||
// "jump" to move the pick there.
|
||||
export function grabZone(window, durationValue, fraction, widthValue, hitPixels = 10) {
|
||||
const duration = Math.max(0, finite(durationValue));
|
||||
const width = Math.max(0, finite(widthValue));
|
||||
if (duration <= 0 || width <= 0) return "none";
|
||||
const x = Math.min(1, Math.max(0, finite(fraction))) * width;
|
||||
const startX = (window.start / duration) * width;
|
||||
const endX = (window.end / duration) * width;
|
||||
const toStart = Math.abs(x - startX);
|
||||
const toEnd = Math.abs(x - endX);
|
||||
if (Math.min(toStart, toEnd) <= hitPixels) return toStart <= toEnd ? "start" : "end";
|
||||
if (x > startX && x < endX) return "move";
|
||||
return "jump";
|
||||
}
|
||||
@@ -0,0 +1,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,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
File diff suppressed because it is too large
Load Diff
+75
-2
@@ -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;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -72,12 +72,14 @@ export function panelRoot(target) {
|
||||
return root;
|
||||
}
|
||||
|
||||
// A drag that carries files, from the file manager or the desktop. A row
|
||||
// being dragged inside a panel carries none and stays the panel's own.
|
||||
// A drag that carries files, from the file manager or the desktop, or a card
|
||||
// from ComfyUI's Assets panel, which names a picture it made ("... [output]")
|
||||
// instead of carrying the file. A row being dragged inside a panel carries
|
||||
// neither and stays the panel's own.
|
||||
export function dragCarriesFiles(event) {
|
||||
const types = event?.dataTransfer?.types;
|
||||
if (!types) return false;
|
||||
return Array.from(types).includes("Files");
|
||||
return Array.from(types).some((type) => type === "Files" || type === "application/x-comfy-asset-info");
|
||||
}
|
||||
|
||||
// Does a file dropped here have to be handed to its node by the pack? The
|
||||
|
||||
@@ -26,6 +26,81 @@ export function parseCivitaiBody(text) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
// What to tell the person when a Civitai call fails. A browser that cannot
|
||||
// reach the site at all only says "Failed to fetch", which explains nothing.
|
||||
export function civitaiFailureText(error) {
|
||||
if (error instanceof TypeError) {
|
||||
return "Could not reach civitai.com. A firewall, a VPN or starting ComfyUI "
|
||||
+ "with --disable-api-nodes can block it.";
|
||||
}
|
||||
return String(error?.message || error || "The Civitai lookup failed.");
|
||||
}
|
||||
|
||||
export function civitaiButtonLabel(hasCivitai) {
|
||||
return hasCivitai ? "Refresh Civitai info" : "Fetch Civitai info";
|
||||
}
|
||||
|
||||
// ---------- the LoRA's preview picture ----------
|
||||
// After the lookup, the browser also downloads the LoRA's first picture from
|
||||
// Civitai's picture server, and the pack's server saves it beside the LoRA.
|
||||
|
||||
export const PICTURE_HOST = "image.civitai.com";
|
||||
export const PICTURE_WIDTH = 450;
|
||||
export const MAX_PICTURE_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
// Civitai rates each picture. The number is a set of flags: 1 = PG,
|
||||
// 2 = PG-13, 4 = R, 8 = X, 16 = XXX. Older answers give a word instead.
|
||||
const MILD_LEVEL = 2;
|
||||
const MILD_WORDS = new Set(["none", "soft"]);
|
||||
|
||||
export function isMildPicture(image) {
|
||||
const level = Number(image?.nsfwLevel);
|
||||
if (Number.isFinite(level) && level > 0) return level <= MILD_LEVEL;
|
||||
if (typeof image?.nsfw === "string") return MILD_WORDS.has(image.nsfw.toLowerCase());
|
||||
if (image?.nsfw === false) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// The first still picture in Civitai's answer that may be saved: PG and
|
||||
// PG-13 only, unless any rating is allowed.
|
||||
export function pickCivitaiPicture(info, anyRating = false) {
|
||||
const images = Array.isArray(info?.images) ? info.images : [];
|
||||
return images.find((image) =>
|
||||
image && typeof image.url === "string"
|
||||
&& (image.type ?? "image") === "image"
|
||||
&& (anyRating || isMildPicture(image))
|
||||
&& smallPictureUrl(image.url) !== null) ?? null;
|
||||
}
|
||||
|
||||
// The address of a 450-pixel copy of the picture, or null for any address
|
||||
// that is not Civitai's picture server over https.
|
||||
export function smallPictureUrl(url) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.protocol !== "https:" || parsed.hostname !== PICTURE_HOST) return null;
|
||||
const parts = parsed.pathname.split("/");
|
||||
const sizing = parts.findIndex((part, index) => index > 1 && index < parts.length - 1 && part.includes("="));
|
||||
if (sizing >= 0) parts[sizing] = `width=${PICTURE_WIDTH}`;
|
||||
else if (parts.length >= 3) parts.splice(parts.length - 1, 0, `width=${PICTURE_WIDTH}`);
|
||||
else return null;
|
||||
parsed.pathname = parts.join("/");
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
return parsed.href;
|
||||
}
|
||||
|
||||
// Where the picture really came from, after Civitai's redirect to its file
|
||||
// store: Civitai's own servers, over https.
|
||||
export function isCivitaiAddress(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "https:"
|
||||
&& (parsed.hostname === "civitai.com" || parsed.hostname.endsWith(".civitai.com"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,18 @@ export function fitRect(boxWidth, boxHeight, width, height) {
|
||||
return { left: (boxWidth - fitWidth) / 2, top: (boxHeight - fitHeight) / 2, width: fitWidth, height: fitHeight, scale };
|
||||
}
|
||||
|
||||
// A see-through picture shows over a checkerboard, as in an editor: its
|
||||
// squares' side in CSS pixels and their two colours.
|
||||
export const CHECKER = { side: 8, light: "#4d4d4d", dark: "#383838" };
|
||||
|
||||
// The padding that holds a contained picture's own background to the
|
||||
// picture: the bars beside it, as CSS. Empty when there is nothing to fit.
|
||||
export function backdropPadding(boxWidth, boxHeight, width, height) {
|
||||
const fit = fitRect(boxWidth, boxHeight, width, height);
|
||||
if (!(fit.scale > 0)) return "";
|
||||
return `${fit.top}px ${fit.left}px`;
|
||||
}
|
||||
|
||||
// A place in the box as a place on the picture, in picture pixels.
|
||||
export function toPicture(fit, x, y) {
|
||||
if (!(fit?.scale > 0)) return null;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
// 2. an uploadable image/video file combo widget -> deterministic /view query
|
||||
// 3. nothing -> the caller shows a quiet placeholder
|
||||
|
||||
import { parseImageReference } from "./pad_canvas.mjs";
|
||||
import { splitMediaName } from "./video_preview.mjs";
|
||||
|
||||
// Core loader nodes keep the uploaded file name in a combo widget with one of
|
||||
@@ -37,11 +38,13 @@ export function sourceFileWidget(sourceNode) {
|
||||
return sourceNode?.widgets?.find((widget) => FILE_WIDGET_NAMES.has(widget?.name)) ?? null;
|
||||
}
|
||||
|
||||
// Deterministic /view query (no cache buster) for an input-folder file name,
|
||||
// tolerating Windows separators and " [input]" annotations.
|
||||
// Deterministic /view query (no cache buster) for a file widget's name,
|
||||
// tolerating Windows separators. A name without a tag is in the input
|
||||
// folder; "photo.png [output]" or "[temp]" is read from that folder.
|
||||
export function viewQueryForFile(name) {
|
||||
const { filename, subfolder } = splitMediaName(name);
|
||||
return new URLSearchParams({ filename, subfolder, type: "input" }).toString();
|
||||
const type = parseImageReference(name)?.type ?? "input";
|
||||
return new URLSearchParams({ filename, subfolder, type }).toString();
|
||||
}
|
||||
|
||||
// Describe how to preview a source node:
|
||||
@@ -117,9 +120,13 @@ export function describeNodePreview(node, inputName, storedUrl = null) {
|
||||
?? describeSourcePreview(upstreamNode(node, inputName));
|
||||
}
|
||||
|
||||
// Quiet ASCII placeholder copy; never an error.
|
||||
export function placeholderText(connected, noun = "an image") {
|
||||
return connected ? "Run the workflow to see it here" : `Connect ${noun} to see it here`;
|
||||
// Quiet ASCII placeholder copy; never an error. `running`: a run is on its
|
||||
// way to this node, which shows its steps ("steps") or only the result.
|
||||
export function placeholderText(connected, noun = "an image", running = false) {
|
||||
if (!connected) return `Connect ${noun} to see it here`;
|
||||
if (running === "steps") return "Running. The steps show here once the picture starts";
|
||||
if (running) return "Running. The picture shows here when it is saved";
|
||||
return "Run the workflow to see it here";
|
||||
}
|
||||
|
||||
// The node ran while its preview was off, so its last picture is older
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// Line up, same size and even spacing for a selection of nodes and groups.
|
||||
// No LiteGraph here; js/line_up/index.js reads the selection and moves it.
|
||||
//
|
||||
// A rect is { x, y, w, h } in graph units: the outline people see, title bar
|
||||
// included. Each function returns one result per rect, in the order given.
|
||||
|
||||
import { unionOf } from "./snap_lines.mjs";
|
||||
|
||||
export const LINE_UP = ["left", "middleX", "right", "top", "middleY", "bottom"];
|
||||
export const SAME_SIZE = ["widest", "narrowest", "tallest", "shortest"];
|
||||
export const SPACING = ["across", "down"];
|
||||
|
||||
// Where each rect goes, as { x, y }, so they all share one edge or middle.
|
||||
// Edges line up on the outermost edge of the selection, middles on its middle.
|
||||
export function lineUp(rects, how) {
|
||||
const bounds = unionOf(rects);
|
||||
if (!bounds) return [];
|
||||
return rects.map((rect) => {
|
||||
switch (how) {
|
||||
case "left":
|
||||
return { x: bounds.x, y: rect.y };
|
||||
case "middleX":
|
||||
return { x: bounds.x + bounds.w / 2 - rect.w / 2, y: rect.y };
|
||||
case "right":
|
||||
return { x: bounds.x + bounds.w - rect.w, y: rect.y };
|
||||
case "top":
|
||||
return { x: rect.x, y: bounds.y };
|
||||
case "middleY":
|
||||
return { x: rect.x, y: bounds.y + bounds.h / 2 - rect.h / 2 };
|
||||
case "bottom":
|
||||
return { x: rect.x, y: bounds.y + bounds.h - rect.h };
|
||||
default:
|
||||
return { x: rect.x, y: rect.y };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The size each rect takes, as { w, h }, so they share the width or height of
|
||||
// the widest, narrowest, tallest or shortest one.
|
||||
export function sameSize(rects, how) {
|
||||
if (!rects.length) return [];
|
||||
const widths = rects.map((rect) => rect.w);
|
||||
const heights = rects.map((rect) => rect.h);
|
||||
const w = how === "widest" ? Math.max(...widths) : how === "narrowest" ? Math.min(...widths) : null;
|
||||
const h = how === "tallest" ? Math.max(...heights) : how === "shortest" ? Math.min(...heights) : null;
|
||||
return rects.map((rect) => ({ w: w ?? rect.w, h: h ?? rect.h }));
|
||||
}
|
||||
|
||||
// Nodes never end up on top of each other: a gap under this, overlaps
|
||||
// included, becomes this, and the selection grows from its first edge.
|
||||
export const MIN_GAP = 20;
|
||||
|
||||
// Equal gaps between the rects along one axis, as { x, y } per rect. They keep
|
||||
// their order (by middle) and the selection keeps its outer edges, unless the
|
||||
// gaps would come out under MIN_GAP. Fewer than three rects stay put.
|
||||
export function spreadEvenly(rects, axis) {
|
||||
const [start, size] = axis === "across" ? ["x", "w"] : ["y", "h"];
|
||||
const out = rects.map((rect) => ({ x: rect.x, y: rect.y }));
|
||||
if (rects.length < 3) return out;
|
||||
const order = rects.map((rect, index) => index).sort((a, b) => {
|
||||
const middle = (rect) => rect[start] + rect[size] / 2;
|
||||
return middle(rects[a]) - middle(rects[b]) || rects[a][start] - rects[b][start];
|
||||
});
|
||||
const first = Math.min(...rects.map((rect) => rect[start]));
|
||||
const last = Math.max(...rects.map((rect) => rect[start] + rect[size]));
|
||||
const filled = rects.reduce((sum, rect) => sum + rect[size], 0);
|
||||
const gap = Math.max(MIN_GAP, (last - first - filled) / (rects.length - 1));
|
||||
let at = first;
|
||||
for (const index of order) {
|
||||
out[index][start] = at;
|
||||
at += rects[index][size] + gap;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// How many rects each kind of tool needs before it can do anything.
|
||||
export function enoughFor(kind, count) {
|
||||
return kind === "spacing" ? count >= 3 : count >= 2;
|
||||
}
|
||||
|
||||
// One node of a selection is being resized: every other one takes its width.
|
||||
// `starts` are where the others were when the button went down, as
|
||||
// { x, w, least } (least is the narrowest that node can be). A pull on the
|
||||
// left edge keeps each right edge where it was; any other pull keeps each
|
||||
// left edge. Returns { x, w } per start, in the order given.
|
||||
export function matchWidth(width, starts, { fromLeft = false } = {}) {
|
||||
return starts.map(({ x, w, least = 0 }) => {
|
||||
const next = Math.max(width, least);
|
||||
return { x: fromLeft ? x + w - next : x, w: next };
|
||||
});
|
||||
}
|
||||
|
||||
// Which sizes a selection shares when one of it is resized, read from how it
|
||||
// is laid out: nodes stacked in a column share a width, nodes side by side in
|
||||
// a row share a height, and a grid shares both. A loose scatter shares a
|
||||
// width, the way a column does.
|
||||
export function sharedSides(rects) {
|
||||
const span = (a, b, start, size) => a[start] < b[start] + b[size] && b[start] < a[start] + a[size];
|
||||
let column = false;
|
||||
let row = false;
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
for (let j = i + 1; j < rects.length; j++) {
|
||||
const sameX = span(rects[i], rects[j], "x", "w");
|
||||
const sameY = span(rects[i], rects[j], "y", "h");
|
||||
if (sameX && !sameY) column = true;
|
||||
if (sameY && !sameX) row = true;
|
||||
}
|
||||
}
|
||||
return { width: column || !row, height: row };
|
||||
}
|
||||
+266
-22
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Pure decisions behind js/shared/moment_strip.mjs: what the rows and the
|
||||
// line under them say. No DOM, no app import, so tests run it under node:test.
|
||||
|
||||
const trimmed = (seconds) => String(Math.round(seconds * 10) / 10);
|
||||
const percent = (part, whole) => Math.min(Math.max((part / whole) * 100, 0), 100);
|
||||
|
||||
// The job of each picture that is connected, by its number, for the server to
|
||||
// check the @ numbers in a script against.
|
||||
export function wiredJobs(values, wired) {
|
||||
const jobs = {};
|
||||
for (const number of [1, 2, 3, 4]) {
|
||||
if (wired(`picture_${number}`)) jobs[number] = values[`job_${number}`];
|
||||
}
|
||||
return jobs;
|
||||
}
|
||||
|
||||
// One row for each moment the server read: its second, its words, and where
|
||||
// its bar sits in the clip, as percentages.
|
||||
export function stripRows(report) {
|
||||
const moments = report?.moments ?? [];
|
||||
const seconds = report?.seconds || moments.at(-1)?.end || 1;
|
||||
return moments.map((moment) => ({
|
||||
second: trimmed(moment.start),
|
||||
words: moment.text,
|
||||
from: percent(moment.start, seconds),
|
||||
to: Math.max(percent(moment.end, seconds), percent(moment.start, seconds) + 1),
|
||||
cut: Boolean(moment.cut),
|
||||
speech: Boolean(moment.speech),
|
||||
line: Number.isInteger(moment.line) ? moment.line : null,
|
||||
start: Number(moment.start) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// A time typed at the start of a line: 3s:, 3 s, at 3 seconds, [3s].
|
||||
const TYPED_TIME = /^\[?(?:at\s+)?\d+(?:\.\d+)?\s*(?:seconds?|secs?|s)\b\]?\s*[:,\-]?\s*/i;
|
||||
|
||||
// The script with one line set to start at a second: its old time, if any,
|
||||
// gives way to the new one. line counts typed lines from 0.
|
||||
export function retime(text, line, second) {
|
||||
const lines = String(text ?? "").split("\n");
|
||||
if (!Number.isInteger(line) || line < 0 || line >= lines.length) return String(text ?? "");
|
||||
const lead = lines[line].match(/^\s*/)[0];
|
||||
const words = lines[line].slice(lead.length).replace(TYPED_TIME, "");
|
||||
lines[line] = `${lead}${trimmed(second)}s: ${words}`;
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// The whole seconds a moment's start can be dragged to: after the moment
|
||||
// before it, before the one after it (or the end of the clip).
|
||||
export function dragRange(rows, index, seconds) {
|
||||
const before = rows[index - 1]?.start ?? 0;
|
||||
const after = rows[index + 1]?.start ?? seconds;
|
||||
const low = Math.floor(before) + 1;
|
||||
const high = Math.ceil(after) - 1;
|
||||
return low <= high ? [low, high] : null;
|
||||
}
|
||||
|
||||
// The line under the rows: a warning first, then what the script adds up to.
|
||||
export function stripCaption(report) {
|
||||
if (report?.warning) return { text: report.warning, warn: true };
|
||||
const moments = report?.moments ?? [];
|
||||
if (!moments.length) return { text: "Each line becomes a moment.", warn: false };
|
||||
const count = moments.length === 1 ? "1 moment" : `${moments.length} moments`;
|
||||
const cuts = moments.filter((moment) => moment.cut).length;
|
||||
if (cuts) return { text: `${count}, ${cuts === 1 ? "1 cut" : `${cuts} cuts`}.`, warn: false };
|
||||
const each = moments.map((moment) => trimmed(moment.end - moment.start));
|
||||
if (moments.length === 1) return { text: `${count}, ${each[0]} s, one take.`, warn: false };
|
||||
const even = each.every((length) => length === each[0]);
|
||||
return { text: even ? `${count}, ${each[0]} s each, one take.` : `${count}, one take.`, warn: false };
|
||||
}
|
||||
|
||||
// The seconds the bar under the script marks: every second up to 10 s,
|
||||
// every other one after, always with the end.
|
||||
export function rulerSeconds(seconds) {
|
||||
const total = Math.max(1, Math.round(Number(seconds) || 0));
|
||||
const step = total > 10 ? 2 : 1;
|
||||
const marks = [];
|
||||
for (let mark = 0; mark < total; mark += step) marks.push(mark);
|
||||
if (total - marks.at(-1) < step) marks.pop();
|
||||
return [...marks, total];
|
||||
}
|
||||
|
||||
|
||||
// The script with one moment's words moved to another place on the bar.
|
||||
// slots: the typed line of each draggable moment, in bar order. The words
|
||||
// move; each place keeps its own time (3s:) and the cuts stay where they
|
||||
// are, so the bar's marks don't jump.
|
||||
export function moveMoment(text, slots, from, to) {
|
||||
const lines = String(text ?? "").split("\n");
|
||||
if (from === to || !slots.every((line) => Number.isInteger(line) && line >= 0 && line < lines.length)) return String(text ?? "");
|
||||
const split = (line) => {
|
||||
const lead = lines[line].match(/^\s*/)[0];
|
||||
const body = lines[line].slice(lead.length);
|
||||
const time = body.match(TYPED_TIME)?.[0] ?? "";
|
||||
return { lead, time, words: body.slice(time.length) };
|
||||
};
|
||||
const parts = slots.map(split);
|
||||
const words = parts.map((part) => part.words);
|
||||
const [moved] = words.splice(from, 1);
|
||||
words.splice(Math.max(0, Math.min(to, words.length)), 0, moved);
|
||||
slots.forEach((line, index) => { lines[line] = parts[index].lead + parts[index].time + words[index]; });
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// The script with a cut added or taken away just before a typed line: a cut
|
||||
// is a blank line.
|
||||
export function toggleCut(text, line) {
|
||||
const lines = String(text ?? "").split("\n");
|
||||
if (!Number.isInteger(line) || line <= 0 || line >= lines.length) return String(text ?? "");
|
||||
let top = line;
|
||||
while (top > 0 && lines[top - 1].trim() === "") top -= 1;
|
||||
if (top < line) lines.splice(top, line - top);
|
||||
else lines.splice(line, 0, "");
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// The outline of a node or group as people see it: { x, y, w, h } in graph
|
||||
// units, title bar included.
|
||||
//
|
||||
// The classic look draws a node at its measured bounds (boundingRect). Nodes
|
||||
// 2.0 draws each node as an element on the page, which can run past those
|
||||
// bounds (its frame, a footer), so there the element itself is measured.
|
||||
// Groups are drawn on the canvas in both looks.
|
||||
|
||||
function graphRectOf(element, canvas) {
|
||||
const box = element.getBoundingClientRect();
|
||||
if (!(box.width > 0) || !(box.height > 0)) return null;
|
||||
const view = canvas.canvas.getBoundingClientRect();
|
||||
const { scale, offset } = canvas.ds;
|
||||
return {
|
||||
x: (box.left - view.left) / scale - offset[0],
|
||||
y: (box.top - view.top) / scale - offset[1],
|
||||
w: box.width / scale,
|
||||
h: box.height / scale,
|
||||
};
|
||||
}
|
||||
|
||||
export function boundsOf(item) {
|
||||
const box = item?.boundingRect;
|
||||
if (!box || !(box[2] > 0) || !(box[3] > 0)) return null;
|
||||
return { x: box[0], y: box[1], w: box[2], h: box[3] };
|
||||
}
|
||||
|
||||
// Nodes 2.0's node elements by node id, found in one pass for measuring many.
|
||||
export function nodeElements() {
|
||||
const elements = new Map();
|
||||
if (globalThis.LiteGraph?.vueNodesMode !== true) return elements;
|
||||
for (const element of document.querySelectorAll("[data-node-id]")) elements.set(element.getAttribute("data-node-id"), element);
|
||||
return elements;
|
||||
}
|
||||
|
||||
export function outlineOf(item, canvas, { node = true, elements = null } = {}) {
|
||||
if (node && globalThis.LiteGraph?.vueNodesMode === true && item?.id != null && canvas?.canvas && canvas.ds) {
|
||||
const element = elements
|
||||
? elements.get(String(item.id))
|
||||
: document.querySelector(`[data-node-id="${CSS.escape(String(item.id))}"]`);
|
||||
const rect = element && graphRectOf(element, canvas);
|
||||
if (rect) return rect;
|
||||
}
|
||||
return boundsOf(item);
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const emptyWidget = (inputs, name) => !linked(inputs?.[name]) && blank(inputs?.[
|
||||
|
||||
const picture = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "picture" } : null);
|
||||
const video = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "video" } : null);
|
||||
const sound = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "sound file" } : null);
|
||||
|
||||
function videoSource(inputs) {
|
||||
const mode = inputs?.source_mode;
|
||||
@@ -38,6 +39,7 @@ export const SOURCE_LOADERS = Object.freeze({
|
||||
AUSBOSS_NODES_ImageCropRotatePad: picture("image"),
|
||||
// A wired source_image replaces the file, so the file may stay blank.
|
||||
AUSBOSS_NODES_LoadImagePad: (inputs) => (linked(inputs?.source_image) ? null : picture("image")(inputs)),
|
||||
AUSBOSS_NODES_LoadAudio: sound("audio"),
|
||||
AUSBOSS_NODES_LoadVideo: video("video"),
|
||||
AUSBOSS_NODES_VideoCropRotatePad: videoSource,
|
||||
AUSBOSS_NODES_VideoCropRotatePadClip: videoSource,
|
||||
@@ -110,7 +112,8 @@ export function emptySourceMessage(found) {
|
||||
if (!first) return "";
|
||||
const more = found.length - 1;
|
||||
const tail = more > 0 ? ` (and ${more} more node${more === 1 ? "" : "s"})` : "";
|
||||
return `Load a ${first.kind === "video" ? "video" : "picture"} first: ${first.title}${tail}`;
|
||||
const noun = first.kind === "video" || first.kind === "sound file" ? first.kind : "picture";
|
||||
return `Load a ${noun} first: ${first.title}${tail}`;
|
||||
}
|
||||
|
||||
// "Save Image: exact_name may not ..." names the input it is about, after
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Help while typing in H3 Prompt Writer's script box:
|
||||
// - a "?" beside "What happens" opens a card of everything the node reads in
|
||||
// the words (@1, 3s:, a blank line, quotes, sound:, music:, camera:, look:);
|
||||
// - typing @ lists the pictures plugged in, with their job; the first letters
|
||||
// of a line offer sound:, music:, camera: and look:; a number offers a time;
|
||||
// after camera: or look: the node's own choices are listed.
|
||||
// Suggestions only open on those, so they stay out of other packs' way.
|
||||
// Picking one types it into the box the browser's way, so undo still works.
|
||||
import { CHEAT_SHEET, suggestAt } from "./script_assist_math.mjs";
|
||||
|
||||
const CSS_ID = "ausboss-script-assist-css";
|
||||
|
||||
function ensureCss() {
|
||||
if (document.getElementById(CSS_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = CSS_ID;
|
||||
style.textContent = `
|
||||
.ausboss-help-dot{margin-left:auto;pointer-events:auto;width:15px;height:15px;padding:0;border:1px solid #2f4a48;border-radius:50%;background:transparent;color:#6f8886;font:700 9.5px/13px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:0;text-align:center;cursor:help}
|
||||
.ausboss-help-dot:hover,.ausboss-help-dot.open{border-color:#00b4aa;color:#a5f3ec}
|
||||
.ausboss-assist-pop{position:fixed;z-index:10000;box-sizing:border-box;border:1px solid #2a3437;border-radius:8px;background:#0f1516;color:#d8ecea;box-shadow:0 8px 28px rgba(0,0,0,.55);font:500 12px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||
.ausboss-assist-help{width:300px;padding:10px 12px}
|
||||
.ausboss-assist-help h4{margin:0 0 6px;color:#a5f3ec;font-size:12px;font-weight:700}
|
||||
.ausboss-assist-help dl{display:grid;grid-template-columns:auto 1fr;gap:5px 10px;margin:0}
|
||||
.ausboss-assist-help dt{color:#f5b53d;font:600 11px/1.35 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap}
|
||||
.ausboss-assist-help dd{margin:0;color:#c3d6d4}
|
||||
.ausboss-assist-help p{margin:8px 0 0;color:#6f8886;font-size:11px}
|
||||
.ausboss-assist-list{min-width:170px;max-width:280px;padding:4px}
|
||||
.ausboss-assist-item{display:flex;align-items:center;gap:7px;padding:4px 6px;border-radius:5px;cursor:pointer;white-space:nowrap}
|
||||
.ausboss-assist-item.active{background:rgba(0,180,170,.22)}
|
||||
.ausboss-assist-item img{flex:none;width:20px;height:20px;border-radius:4px;object-fit:cover}
|
||||
.ausboss-assist-item b{color:#a5f3ec;font:700 11.5px ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||
.ausboss-assist-item em{overflow:hidden;color:#6f8886;font-style:normal;text-overflow:ellipsis}
|
||||
.ausboss-assist-foot{padding:3px 6px 1px;color:#56706d;font-size:10px}
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
const make = (tag, className, text) => {
|
||||
const element = document.createElement(tag);
|
||||
if (className) element.className = className;
|
||||
if (text !== undefined) element.textContent = text;
|
||||
return element;
|
||||
};
|
||||
|
||||
// Screen position of the caret: a hidden copy of the box with a marker where
|
||||
// the caret is. The copy sits in the box's own place, so zoom is included.
|
||||
function caretPoint(area, layer) {
|
||||
const copy = layer.cloneNode(false);
|
||||
copy.className = "ausboss-colours";
|
||||
copy.style.visibility = "hidden";
|
||||
copy.textContent = area.value.slice(0, area.selectionEnd);
|
||||
const mark = make("span", "", "");
|
||||
copy.append(mark);
|
||||
area.parentElement.append(copy);
|
||||
copy.scrollTop = area.scrollTop;
|
||||
const box = mark.getBoundingClientRect();
|
||||
copy.remove();
|
||||
return { x: box.left, y: box.bottom };
|
||||
}
|
||||
|
||||
// node: the H3 Prompt Writer; area: its script box; layer: the coloured copy
|
||||
// under it; context(): { pictures, cameras, looks, seconds }.
|
||||
export function attachScriptAssist(node, area, layer, context) {
|
||||
const none = { dispose: () => {} };
|
||||
if (!area?.parentElement) return none;
|
||||
ensureCss();
|
||||
|
||||
// The "?" card.
|
||||
const row = area.closest(".ausboss-card-row");
|
||||
const header = row?.previousElementSibling?.classList?.contains("ausboss-card-section") ? row.previousElementSibling : null;
|
||||
let help = null;
|
||||
let dot = null;
|
||||
let closeHelpTimer = null;
|
||||
const closeHelp = () => { help?.remove(); help = null; dot?.classList.remove("open"); };
|
||||
const openHelp = () => {
|
||||
clearTimeout(closeHelpTimer);
|
||||
if (help) return;
|
||||
help = make("div", "ausboss-assist-pop ausboss-assist-help");
|
||||
help.append(make("h4", "", "What you can type"));
|
||||
const list = make("dl");
|
||||
for (const entry of CHEAT_SHEET) list.append(make("dt", "", entry.code), make("dd", "", entry.text));
|
||||
help.append(list, make("p", "", "Typing @ lists your pictures. Tab picks a suggestion, Esc closes it."));
|
||||
help.addEventListener("mouseenter", () => clearTimeout(closeHelpTimer));
|
||||
help.addEventListener("mouseleave", () => { closeHelpTimer = setTimeout(closeHelp, 250); });
|
||||
document.body.append(help);
|
||||
const box = dot.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(window.innerWidth - help.offsetWidth - 8, box.right - help.offsetWidth));
|
||||
const below = box.bottom + 6;
|
||||
help.style.left = `${left}px`;
|
||||
help.style.top = `${below + help.offsetHeight > window.innerHeight - 8 ? Math.max(8, box.top - help.offsetHeight - 6) : below}px`;
|
||||
dot.classList.add("open");
|
||||
};
|
||||
if (header) {
|
||||
dot = make("button", "ausboss-help-dot", "?");
|
||||
dot.type = "button";
|
||||
dot.title = "";
|
||||
dot.setAttribute("aria-label", "What you can type in the words");
|
||||
dot.addEventListener("mouseenter", openHelp);
|
||||
dot.addEventListener("mouseleave", () => { closeHelpTimer = setTimeout(closeHelp, 250); });
|
||||
dot.addEventListener("pointerdown", (event) => event.stopPropagation());
|
||||
dot.addEventListener("click", (event) => { event.stopPropagation(); if (help) closeHelp(); else openHelp(); });
|
||||
header.append(dot);
|
||||
}
|
||||
|
||||
// Suggestions.
|
||||
let pop = null;
|
||||
let offer = null;
|
||||
let active = 0;
|
||||
const close = () => { pop?.remove(); pop = null; offer = null; };
|
||||
const take = (item) => {
|
||||
if (!offer) return;
|
||||
area.focus();
|
||||
area.setSelectionRange(offer.from, offer.to);
|
||||
// insertText keeps the browser's own undo.
|
||||
if (!document.execCommand?.("insertText", false, item.insert)) {
|
||||
area.setRangeText(item.insert, offer.from, offer.to, "end");
|
||||
area.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
close();
|
||||
};
|
||||
const draw = () => {
|
||||
if (!pop || !offer) return;
|
||||
pop.textContent = "";
|
||||
offer.items.forEach((item, index) => {
|
||||
const line = make("div", `ausboss-assist-item${index === active ? " active" : ""}`);
|
||||
if (item.number && item.picture) { const img = make("img"); img.src = item.picture; line.append(img); }
|
||||
line.append(make("b", "", item.label));
|
||||
if (item.hint) line.append(make("em", "", item.hint));
|
||||
line.addEventListener("pointerdown", (event) => { event.preventDefault(); event.stopPropagation(); take(item); });
|
||||
pop.append(line);
|
||||
});
|
||||
pop.append(make("div", "ausboss-assist-foot", offer.strong ? "Enter or Tab to pick · Esc to close" : "Tab to pick · Esc to close"));
|
||||
};
|
||||
const update = () => {
|
||||
if (document.activeElement !== area || area.selectionStart !== area.selectionEnd) return close();
|
||||
const ctx = context();
|
||||
const next = suggestAt(area.value, area.selectionEnd, ctx);
|
||||
if (!next) return close();
|
||||
for (const item of next.items) if (item.number) item.picture = ctx.pictures.find((picture) => picture.number === item.number)?.url ?? "";
|
||||
const same = offer && offer.from === next.from && offer.items.map((item) => item.label).join() === next.items.map((item) => item.label).join();
|
||||
offer = next;
|
||||
if (!same) active = 0;
|
||||
if (!pop) { pop = make("div", "ausboss-assist-pop ausboss-assist-list"); document.body.append(pop); }
|
||||
draw();
|
||||
const point = caretPoint(area, layer);
|
||||
pop.style.left = `${Math.max(8, Math.min(window.innerWidth - pop.offsetWidth - 8, point.x))}px`;
|
||||
pop.style.top = `${point.y + pop.offsetHeight + 4 > window.innerHeight ? point.y - pop.offsetHeight - 22 : point.y + 4}px`;
|
||||
};
|
||||
const onKey = (event) => {
|
||||
if (!offer) return;
|
||||
const count = offer.items.length;
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
active = (active + (event.key === "ArrowDown" ? 1 : count - 1)) % count;
|
||||
offer.strong = true; // Choosing with the arrows makes Enter pick too.
|
||||
draw();
|
||||
} else if (event.key === "Tab" || (event.key === "Enter" && offer.strong && !event.shiftKey)) {
|
||||
take(offer.items[active]);
|
||||
} else if (event.key === "Escape") {
|
||||
close();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
const onBlur = () => setTimeout(() => { if (document.activeElement !== area) close(); }, 0);
|
||||
area.addEventListener("input", update);
|
||||
area.addEventListener("keydown", onKey, true);
|
||||
area.addEventListener("blur", onBlur);
|
||||
area.addEventListener("click", update);
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
close();
|
||||
closeHelp();
|
||||
dot?.remove();
|
||||
area.removeEventListener("input", update);
|
||||
area.removeEventListener("keydown", onKey, true);
|
||||
area.removeEventListener("blur", onBlur);
|
||||
area.removeEventListener("click", update);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Pure decisions behind js/shared/script_assist.mjs: what the help card says,
|
||||
// and what to suggest at the caret in H3 Prompt Writer's script box. No DOM,
|
||||
// so tests run it under node:test.
|
||||
|
||||
// The help card: each thing the node reads in the words, in plain words.
|
||||
export const CHEAT_SHEET = [
|
||||
{ code: "@1 to @4", text: "A picture you plugged in. @1 is Picture 1." },
|
||||
{ code: "3s:", text: "At the start of a line: this moment starts 3 seconds in. Lines with no time share what is left evenly. Dragging a block's left edge on the bar types this for you." },
|
||||
{ code: "blank line", text: "A cut to a new shot. No blank lines means one take." },
|
||||
{ code: "\"in quotes\"", text: "Said out loud, word for word." },
|
||||
{ code: "sound:", text: "On its own line: what the clip sounds like. Same as the Sound box." },
|
||||
{ code: "music:", text: "On its own line: the music. Music makes the clip louder." },
|
||||
{ code: "camera:", text: "On its own line: how the camera moves, like push in or handheld." },
|
||||
{ code: "look:", text: "On its own line: a style, like anime or watercolour." },
|
||||
{ code: "no cuts", text: "Anywhere in the words: keep it all one shot." },
|
||||
];
|
||||
|
||||
const LABELS = [
|
||||
{ word: "sound", hint: "what the clip sounds like" },
|
||||
{ word: "music", hint: "the music, makes it louder" },
|
||||
{ word: "camera", hint: "how the camera moves" },
|
||||
{ word: "look", hint: "a style for the whole clip" },
|
||||
];
|
||||
|
||||
const starts = (value, typed) => value.toLowerCase().startsWith(typed.toLowerCase());
|
||||
|
||||
// What to offer at the caret, or null.
|
||||
// pictures: [{ number, job, plugged }] for @1 to @4
|
||||
// cameras, looks: the choices the node knows, for camera: and look: lines
|
||||
// seconds: the clip's length, so a time past the end isn't offered
|
||||
// The answer replaces text[from, to) with an item's insert. `strong` means
|
||||
// the person typed a trigger (@, camera:, look:), so Enter may take an item;
|
||||
// a guess from the first letters of a line only takes Tab or a click, so
|
||||
// Enter still starts a new line.
|
||||
export function suggestAt(text, caret, { pictures = [], cameras = [], looks = [], seconds = 8 } = {}) {
|
||||
const value = String(text ?? "");
|
||||
const before = value.slice(0, caret);
|
||||
const line = before.slice(before.lastIndexOf("\n") + 1);
|
||||
|
||||
const at = before.match(/(?:^|[^\w@])@(\d?)$/);
|
||||
if (at) {
|
||||
const typed = at[1];
|
||||
const plugged = pictures.filter((picture) => picture.plugged);
|
||||
const offer = (plugged.length ? plugged : pictures).filter((picture) => String(picture.number).startsWith(typed));
|
||||
if (!offer.length) return null;
|
||||
return {
|
||||
from: caret - typed.length - 1,
|
||||
to: caret,
|
||||
strong: true,
|
||||
items: offer.map((picture) => ({
|
||||
label: `@${picture.number}`,
|
||||
insert: `@${picture.number}`,
|
||||
hint: picture.plugged ? picture.job || "" : "not plugged in",
|
||||
number: picture.number,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const choice = line.match(/^\s*(camera|look)\s*:\s*([\w ]*)$/i);
|
||||
if (choice) {
|
||||
const typed = choice[2];
|
||||
const list = choice[1].toLowerCase() === "camera" ? cameras : looks;
|
||||
const offer = list.filter((name) => starts(name, typed) && name.toLowerCase() !== typed.toLowerCase());
|
||||
if (!offer.length) return null;
|
||||
return { from: caret - typed.length, to: caret, strong: true, items: offer.map((name) => ({ label: name, insert: name, hint: "" })) };
|
||||
}
|
||||
|
||||
const time = line.match(/^(\s*)(\d{1,2})$/);
|
||||
if (time) {
|
||||
const second = Number(time[2]);
|
||||
if (second < 1 || second >= seconds) return null;
|
||||
return {
|
||||
from: caret - time[2].length,
|
||||
to: caret,
|
||||
strong: false,
|
||||
items: [{ label: `${second}s:`, insert: `${second}s: `, hint: `this moment starts ${second} seconds in` }],
|
||||
};
|
||||
}
|
||||
|
||||
const word = line.match(/^(\s*)([a-z]{2,6})$/i);
|
||||
if (word) {
|
||||
const offer = LABELS.filter((label) => starts(label.word, word[2]) && label.word !== word[2].toLowerCase());
|
||||
if (!offer.length) return null;
|
||||
return {
|
||||
from: caret - word[2].length,
|
||||
to: caret,
|
||||
strong: false,
|
||||
items: offer.map((label) => ({ label: `${label.word}:`, insert: `${label.word}: `, hint: label.hint })),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Colours in H3 Prompt Writer's script box: the words the node reads as
|
||||
// instructions stand out while you type. @1 is a picture, 3s: is a time, a
|
||||
// blank line is a cut, sound: and music: lines go to their own part, and
|
||||
// words in quotes are spoken.
|
||||
//
|
||||
// The real textarea stays on top with see-through text, so typing, undo,
|
||||
// copy and the caret are the browser's own. A copy of the text sits under it
|
||||
// with the colours. Only colours and backgrounds change, never the font or
|
||||
// the spacing, so both lay out the same lines.
|
||||
import { scriptMarkup } from "./script_colours_math.mjs";
|
||||
|
||||
const CSS_ID = "ausboss-script-colours-css";
|
||||
|
||||
function ensureCss() {
|
||||
if (document.getElementById(CSS_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = CSS_ID;
|
||||
style.textContent = `
|
||||
.ausboss-colours-host{position:relative}
|
||||
.ausboss-colours-host>:not(.ausboss-colours):not(textarea){display:none !important}
|
||||
.ausboss-colours{position:absolute;inset:0;box-sizing:border-box;margin:0;overflow:hidden;white-space:pre-wrap;overflow-wrap:break-word;word-break:normal;pointer-events:none;color:#d8ecea;border:1px solid transparent;border-radius:6px;background:#0b0f10}
|
||||
.ausboss-colours-host>textarea.ausboss-colours-on{position:relative;background:transparent !important;color:transparent !important;caret-color:#d8ecea;-webkit-text-fill-color:transparent}
|
||||
.ausboss-colours-host>textarea.ausboss-colours-on::placeholder{color:#4f6462;-webkit-text-fill-color:#4f6462}
|
||||
.ausboss-colours-host>textarea.ausboss-colours-on::selection{background:rgba(0,180,170,.35)}
|
||||
.ausboss-colours .at{border-radius:3px;background:rgba(0,180,170,.3);box-shadow:0 0 0 1px rgba(0,180,170,.45);color:#a5f3ec}
|
||||
.ausboss-colours .at.unknown{background:rgba(245,181,61,.18);box-shadow:0 0 0 1px rgba(245,181,61,.5);color:#f5c76b}
|
||||
.ausboss-colours .time{color:#f5b53d}
|
||||
.ausboss-colours .key{color:#6f8886}
|
||||
.ausboss-colours .say{color:#f0dcae}
|
||||
.ausboss-colours .cut{display:inline-block;width:100%;background:linear-gradient(transparent calc(50% - 1px),#2f4a48 calc(50% - 1px),#2f4a48 50%,transparent 50%);position:relative}
|
||||
.ausboss-colours .cut::after{content:"cut";position:absolute;right:0;top:0;padding-left:4px;background:#0b0f10;color:#56706d;font-size:9px;letter-spacing:.1em;text-transform:uppercase}
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
// The box's own text settings, copied so the coloured copy wraps the same.
|
||||
const COPIED = ["fontFamily", "fontSize", "fontWeight", "fontStyle", "lineHeight", "letterSpacing", "wordSpacing", "tabSize", "textIndent", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth"];
|
||||
|
||||
export function attachScriptColours(node) {
|
||||
const none = { sync: () => {}, dispose: () => {} };
|
||||
const area = node.__ausbossCard?.root?.querySelector?.("textarea.ausboss-card-area");
|
||||
if (!area?.parentElement) return none;
|
||||
ensureCss();
|
||||
const host = area.parentElement;
|
||||
const layer = document.createElement("div");
|
||||
layer.className = "ausboss-colours";
|
||||
layer.setAttribute("aria-hidden", "true");
|
||||
host.classList.add("ausboss-colours-host");
|
||||
host.insertBefore(layer, area);
|
||||
area.classList.add("ausboss-colours-on");
|
||||
// Another pack that colours every text box puts its own copy beside the
|
||||
// box; the rule above hides it, so the text never shows twice.
|
||||
area.dataset.ausbossColoured = "true";
|
||||
|
||||
let wiredNumbers = new Set([1, 2, 3, 4]);
|
||||
const draw = () => {
|
||||
const style = getComputedStyle(area);
|
||||
for (const key of COPIED) layer.style[key] = style[key];
|
||||
// A scroll bar takes width from the text; the copy gives up the same.
|
||||
const bar = area.offsetWidth - area.clientWidth - (parseFloat(style.borderLeftWidth) || 0) - (parseFloat(style.borderRightWidth) || 0);
|
||||
layer.style.paddingRight = `${(parseFloat(style.paddingRight) || 0) + Math.max(0, bar)}px`;
|
||||
layer.innerHTML = scriptMarkup(area.value, (number) => wiredNumbers.has(number));
|
||||
layer.scrollTop = area.scrollTop;
|
||||
};
|
||||
const onScroll = () => { layer.scrollTop = area.scrollTop; };
|
||||
area.addEventListener("input", draw);
|
||||
area.addEventListener("scroll", onScroll);
|
||||
const observer = typeof ResizeObserver === "function" ? new ResizeObserver(draw) : null;
|
||||
observer?.observe(area);
|
||||
queueMicrotask(draw);
|
||||
return {
|
||||
area,
|
||||
layer,
|
||||
// values: the node's widget values; numbers: the pictures plugged in.
|
||||
sync(values, numbers) {
|
||||
if (numbers) wiredNumbers = new Set(numbers);
|
||||
if (document.activeElement !== area && typeof values?.script === "string" && area.value !== values.script) area.value = values.script;
|
||||
draw();
|
||||
},
|
||||
dispose() {
|
||||
observer?.disconnect();
|
||||
area.removeEventListener("input", draw);
|
||||
area.removeEventListener("scroll", onScroll);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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") ? " " : "");
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
"""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.",
|
||||
}
|
||||
# A hat or a shirt with no person picture: the user wants the video's own person to wear it.
|
||||
DRESS_THE_VIDEO_PERSON = (
|
||||
"To put this on the person in your video, load a still of them from the video as Picture 1 "
|
||||
"and pick person for it. Give the hat or outfit its own picture, pick outfit or thing, and type "
|
||||
"\"@1 wears @2\" with video job swap the person."
|
||||
)
|
||||
|
||||
# (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:
|
||||
if self.of(OUTFIT, THING):
|
||||
return DRESS_THE_VIDEO_PERSON
|
||||
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)
|
||||
@@ -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))]
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user