Compare commits

..
Author SHA1 Message Date
ausboss 2285166ef1 Keep compact note tiles at their chosen size 2026-11-06 19:08:21 -06:00
ausboss cb6b947f75 Fix shared note tiles and H3 video edit controls 2026-11-06 18:46:32 -06:00
ausboss 28c4f6aeb5 Add manual Registry publishing for Model Downloader (#292) 2026-11-06 15:11:41 -06:00
ausboss 3a4ce52760 Release 2.21.1
Update release metadata and notes for the repository-hosted README badge fix.
2026-11-06 11:48:17 -06:00
ausboss e761b3dd0b Serve the release badge from the repository (#290)
Keep the release badge available from GitHub and reject stale generated badge versions during release preflight.
2026-11-05 20:28:47 -06:00
50 changed files with 1352 additions and 125 deletions
@@ -0,0 +1,74 @@
name: Publish Model Downloader addon
on:
workflow_dispatch:
inputs:
addon_commit:
description: Full commit SHA from ComfyUI-AusBoss-Model-Downloader main
required: true
type: string
# The existing AusBoss publisher credential stays in this repository. This
# manual job publishes only the separately maintained Model Downloader addon.
# It does not release or change the main AusBoss node pack.
permissions:
contents: read
concurrency:
group: registry-publish-model-downloader
cancel-in-progress: false
jobs:
publish:
if: github.repository == 'ausboss/ComfyUI-AusBoss' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Require an exact addon commit
env:
ADDON_COMMIT: ${{ inputs.addon_commit }}
run: '[[ "$ADDON_COMMIT" =~ ^[0-9a-f]{40}$ ]]'
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
repository: ausboss/ComfyUI-AusBoss-Model-Downloader
ref: ${{ inputs.addon_commit }}
fetch-depth: 0
persist-credentials: false
- name: Require a commit merged into addon main
run: git merge-base --is-ancestor HEAD origin/main
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
with:
python-version: '3.12'
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '24'
package-manager-cache: false
- name: Install validation and publishing tools
run: python -m pip install -r requirements-dev.txt comfy-cli==1.25.0
- name: Offline checks
run: |
python -m unittest discover -s tests -v
ruff check --select F,E9 __init__.py downloader tests scripts
node --test tests/*.test.mjs
node --check js/model_downloader/index.js
node --check js/shared/download_controls.mjs
- name: Build and inspect Registry package
run: |
comfy --skip-prompt --no-enable-telemetry node pack
python scripts/check_registry_package.py
- name: Refuse an existing immutable version
run: python scripts/registry_status.py --require-unpublished
- name: Publish addon
id: publish
env:
REGISTRY_ACCESS_TOKEN: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
COMFY_NODE_CHANGELOG: 'Initial release: model downloads from Civitai and Hugging Face, installed-file checks, saved model lists, and AusBoss Workflow Note integration.'
run: |
if [ -z "$REGISTRY_ACCESS_TOKEN" ]; then
echo '::error::REGISTRY_ACCESS_TOKEN is not configured.'
exit 1
fi
comfy --skip-prompt --no-enable-telemetry node publish --token "$REGISTRY_ACCESS_TOKEN"
- name: Report Registry review status
if: always() && steps.publish.outcome != 'skipped'
run: python scripts/registry_status.py --report
+18
View File
@@ -4,6 +4,24 @@ All notable changes to ComfyUI-AusBoss are documented here.
## Unreleased
- **Colored link buttons and pop-out notes keep their tile layout when shared.**
Callout Tile and Workflow Note Tile save as distinct node types, so an older
install can report what is missing. Tiles have larger editors and visible
resize grips, start compact when added, and pop-out notes wrap long code lines.
- **H3 Prompt Writer can change something in a video while keeping its person and sound.**
The new `keeps_clip_sound` output helps route the source audio. Video edits
show **From the clip** for their length, and swaps accept an optional
`@1 looks like ...` line describing the connected replacement.
- **Workflow Switches' Off and On buttons now select the state they name.**
Pressing the already selected state leaves it unchanged.
## 2.21.1 - 2026-11-06
- **The README release badge stays available when the badge service is down.**
The badge is served from the repository, shows the current version, and opens
the latest GitHub Release.
## 2.21.0 - 2026-11-05
- **Crop + Rotate + Pad has clearer controls and steadier previews.** Image
+7 -1
View File
@@ -346,11 +346,17 @@ before a release that follows a ComfyUI update.
A new `version` in `pyproject.toml` that lands on main publishes to the Comfy
Registry through the publish workflow (`.github/workflows/publish_action.yml`).
After a successful upload, the same workflow tags that commit and creates a
GitHub Release with the full changelog section. The README badge follows the
GitHub Release with the full changelog section. The README badge links to the
latest GitHub Release. If only that step fails, rerun the failed jobs to retry
it without uploading the Registry version again. Existing releases are left
unchanged on a rerun. Contributors never change `version`.
For an authorized release, update both `pyproject.toml` and
`AUSBOSS_JS_VERSION`, date the changelog section, then run
`python scripts/release_badge.py`. Preflight rejects a badge that does not
match the pack version. Its SVG lives in the repository so the release
number stays visible when an external badge service is unavailable.
The Registry shows notes with each version. They are that version's section
of `CHANGELOG.md`, one line per entry: its bold opening, and for a new node
the sentence that says what it does (`scripts/changelog_notes.py`). So open
+9 -3
View File
@@ -3,9 +3,9 @@
<p>ComfyUI nodes for image and video work.</p>
<p>
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT License">
<!-- The publish workflow creates a GitHub Release for each version.
This badge follows it; release_preflight.py checks the source. -->
<a href="https://github.com/ausboss/ComfyUI-AusBoss/releases/latest"><img src="https://img.shields.io/github/v/release/ausboss/ComfyUI-AusBoss?label=release&color=00b4aa&style=flat-square" alt="Latest release"></a>
<!-- Generated by scripts/release_badge.py. Release preflight checks
the badge against pyproject.toml so it cannot silently go stale. -->
<a href="https://github.com/ausboss/ComfyUI-AusBoss/releases/latest"><img src="assets/readme/release.svg" alt="Latest release"></a>
<a href="https://www.youtube.com/@ausbossdev"><img src="https://img.shields.io/badge/YouTube-%40ausbossdev-00b4aa?style=flat-square&logo=youtube&logoColor=white" alt="YouTube @ausbossdev"></a>
<a href="https://discord.gg/RwVxY9Fuaj"><img src="https://img.shields.io/badge/Discord-AusBoss-00b4aa?style=flat-square&logo=discord&logoColor=white" alt="Discord: AusBoss"></a>
</p>
@@ -344,6 +344,8 @@ A text box that grows with the node and sends its text, unchanged, down a `STRIN
Type what happens, one line for each moment, and leave a blank line to cut to a new place. Connect up to four pictures and give each one a job: a person, a place, an outfit, a thing or a style. Copy the moves from a video, keep a clip going, or add a voice or a song. A timeline on the node shows when each moment plays before you run it. It needs no extra model and you never see H3's prompt format. Connect a small text model and it fills in a one-line idea.
**Change something** edits the named part of a video while keeping its person and sound. For a person swap, an optional `@1 looks like ...` line describes the connected replacement. Jobs that keep the source clip's length show **From the clip**; the `keeps_clip_sound` output helps a workflow choose the source audio.
### Integer
One whole number (`INT`) on a scrub control, for sharing a size, count or other whole number across nodes. Like every number box in the pack, it takes a typed sum: `1024*1.5`.
@@ -372,6 +374,10 @@ A setup card for a shared workflow: a title, instructions in Markdown, each mode
A note for the canvas whose text grows to fill it, so it reads without zooming in. Double-click to type; a blank line starts a new point and `**two stars**` stress a word. Type an arrow emoji such as ⬆️ and it turns into a teal arrow in the middle of the line. Click a node while you edit, then press an arrow, and the arrow is linked to that node: click it later and the canvas brings the node into view. It never runs and has no wires.
### Compact note and link tiles
**Callout Tile** shows a compact title or colored YouTube/Discord link button. **Workflow Note Tile** opens a full help note from a compact button. They reuse the note editors and save as distinct node types, so an older install reports a missing node instead of drawing an incompatible plain note. Install or update ComfyUI-AusBoss, restart ComfyUI and reload when either tile is missing. Existing ordinary notes and callouts keep their original types.
### Run Timer
A stopwatch for the whole run, with no wires. It starts when the queue starts running, holds the total when the run ends, and saves the last time with the workflow. Recent times are in its right-click menu. You can resize it. It only shows time and doesn't change how the graph runs.
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="105" height="20" role="img" aria-label="release: v2.21.1">
<title>release: v2.21.1</title>
<path fill="#555" d="M0 0h48v20H0z"/>
<path fill="#00b4aa" d="M48 0h57v20H48z"/>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="24" y="14">release</text>
<text x="76.5" y="14">v2.21.1</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 425 B

+100 -5
View File
@@ -8,6 +8,7 @@
import { app } from "../../../scripts/app.js";
import { setTileFace, TILE_FACE_CSS } from "../shared/tile_face.mjs";
import { CALLOUT_TYPES, serializeNoteIdentity } from "../shared/note_identity.mjs";
import { BRAND, chainCallback, keepDomWidgetWidthAuto } from "../shared/index.mjs";
import {
ARROW_ANGLE,
@@ -20,6 +21,8 @@ import {
repointLinks,
targetId,
toggleSmall,
tileLinkFields,
tileLinkText,
urlLabel,
wrapLink,
} from "../shared/callout.mjs";
@@ -28,9 +31,9 @@ import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
import { commitWidgetValue } from "../shared/widget_card_math.mjs";
import { compactAction } from "../shared/panel_controls.mjs";
import { frameBounds } from "../shared/workflow_switches.mjs";
const NODE_CLASS = "AUSBOSS_NODES_Callout";
const CSS_ID = "ausboss-callout-css";
const WIDGET_NAME = "ausboss_callout_panel";
const DEFAULT_SIZE = [380, 160];
@@ -77,6 +80,7 @@ ${TILE_FACE_CSS}
.ausboss-callout-card[data-tile] .ausboss-callout-url{color:white;text-decoration:none;display:flex;align-items:center;gap:12px}
.ausboss-callout-card[data-tile] .ausboss-callout-url svg{width:36px;height:28px;flex:none}
.ausboss-callout-card[data-tile] .ausboss-callout-out{margin-left:auto}
.ausboss-callout-card[data-tile] .ausboss-callout-tools{right:27px;}
.ausboss-callout-empty{color:#6f8987;font-size:14px;font-weight:400;}
.ausboss-callout-arrow{display:inline-block;width:1.2em;height:1.2em;margin:0 .1em;vertical-align:-.26em;filter:drop-shadow(0 0 .3em rgba(0,180,170,.55));}
.ausboss-callout-arrow svg{display:block;width:100%;height:100%;overflow:visible;}
@@ -89,7 +93,7 @@ ${TILE_FACE_CSS}
.ausboss-callout-out{display:inline-block;margin-left:.2em;font-size:.7em;font-weight:600;vertical-align:.14em;text-decoration:none;opacity:.85;}
.ausboss-callout-jump.is-gone{opacity:.45;cursor:default;text-decoration-style:dotted;}
.ausboss-callout-tools{position:absolute;top:5px;right:5px;display:flex;gap:4px;opacity:0;transition:opacity .15s;pointer-events:auto;}
.ausboss-callout-card:hover .ausboss-callout-tools,.ausboss-callout.is-selected .ausboss-callout-tools{opacity:.95;}
.ausboss-callout-card:is(:hover,:focus-within) .ausboss-callout-tools,.ausboss-callout.is-selected .ausboss-callout-tools{opacity:.95;}
.ausboss-callout-tool{height:22px;padding:0 8px;border:1px solid rgba(0,180,170,.45);border-radius:5px;background:rgba(8,14,16,.86);color:#9fd8d3;font:600 11px ${FONT};letter-spacing:.02em;cursor:pointer;}
.ausboss-callout-tool:hover{color:#fff;border-color:${BRAND};}
.ausboss-callout-editor{position:absolute;inset:0;box-sizing:border-box;display:flex;flex-direction:column;gap:6px;padding:6px;border:1px solid ${BRAND};border-radius:8px;background:rgba(5,10,11,.95);pointer-events:auto;container-type:size;}
@@ -104,6 +108,17 @@ ${TILE_FACE_CSS}
.ausboss-callout-pick svg{display:block;width:100%;height:100%;}
.ausboss-callout-size{flex:none;height:24px;margin-left:4px;padding:0 7px;border:1px solid rgba(0,180,170,.35);border-radius:5px;background:rgba(8,14,16,.9);color:#9fd8d3;font:600 11px ${FONT};cursor:pointer;}
.ausboss-callout-size:hover{border-color:${BRAND};background:rgba(0,180,170,.14);}
.ausboss-tile-editor{box-sizing:border-box;width:min(520px,94vw);max-height:86vh;overflow:auto;margin:auto;padding:24px;border:1px solid #365957;border-top:3px solid ${BRAND};border-radius:10px;background:#111b1c;color:#e6f4f3;box-shadow:0 18px 64px #0008;font:14px/1.5 ${FONT};}
.ausboss-tile-editor::backdrop{background:#06090ab8;}
.ausboss-tile-editor h2{margin:0 0 18px;font-size:21px;}
.ausboss-tile-editor label{display:flex;flex-direction:column;gap:7px;margin-bottom:16px;font-weight:600;}
.ausboss-tile-editor input,.ausboss-tile-editor textarea{box-sizing:border-box;width:100%;padding:10px 12px;border:1px solid #365957;border-radius:5px;background:#080e10;color:#e6f4f3;font:16px/1.5 ${FONT};user-select:text;}
.ausboss-tile-editor textarea{min-height:120px;resize:vertical;}
.ausboss-tile-editor :is(input,textarea):focus{outline:2px solid ${BRAND};outline-offset:1px;}
.ausboss-tile-editor footer{display:flex;justify-content:flex-end;align-items:center;gap:8px;}
.ausboss-tile-editor footer span{margin-right:auto;color:#99b0ad;font-size:12px;}
.ausboss-tile-editor .ausboss-compact-action{height:32px;padding:0 15px;font-size:13px;}
.ausboss-tile-editor .ausboss-tile-error{color:#ffc36d;margin:0 0 12px;}
@container (max-width:395px){.ausboss-callout-arrowbar>span:first-child,.ausboss-callout-link:not(.has-node){display:none;}}
@container (max-width:395px) and (min-height:112px){.ausboss-callout-arrowbar{flex-wrap:wrap;}.ausboss-callout-pick{flex:none;}}
`;
@@ -251,7 +266,7 @@ function renderText(state) {
const style = state.node.properties?.ausbossCalloutTile;
if (["youtube","discord","title"].includes(style)) state.card.dataset.tile = style;
else delete state.card.dataset.tile;
setTileFace(state.node, state.root, Boolean(state.card.dataset.tile));
setTileFace(state.node, state.root, Boolean(state.card.dataset.tile), () => startEdit(state));
const blocks = parseBlocks(state.valueWidget.value);
state.inner.replaceChildren();
state.empty = blocks.length === 0;
@@ -363,8 +378,78 @@ function markChanged() {
}
}
function startTileEdit(state) {
if (state.editor) return;
const original = String(state.valueWidget.value ?? "");
const social = ["youtube", "discord"].includes(state.card.dataset.tile);
const link = social ? tileLinkFields(original) : null;
const dialog = el("dialog", "ausboss-tile-editor");
const heading = social ? "Edit link tile" : "Edit title";
dialog.setAttribute("aria-label", heading);
dialog.append(el("h2", "", heading));
const label = el("label", "", link ? "Button text" : "Text");
const text = el(link ? "input" : "textarea");
text.value = link?.label ?? original;
label.append(text);
dialog.append(label);
let address;
if (link) {
const field = el("label", "", "Link address");
address = el("input");
address.type = "url";
address.value = link.url;
field.append(address);
dialog.append(field);
}
const error = el("p", "ausboss-tile-error");
error.setAttribute("role", "alert");
const foot = el("footer");
const cancel = compactAction("Cancel");
const save = compactAction("Save");
foot.append(el("span", "", "Ctrl + Enter to save"), cancel, save);
dialog.append(error, foot);
const commit = () => {
let value = text.value;
try {
if (address) value = tileLinkText(text.value, address.value);
} catch (problem) {
error.textContent = problem.message;
return;
}
if (value !== original) {
state.node.graph?.beforeChange?.();
commitWidgetValue(state.node, state.valueWidget, value, app.canvas);
renderText(state);
state.node.graph?.afterChange?.();
markChanged();
}
dialog.close();
};
save.addEventListener("click", commit);
cancel.addEventListener("click", () => dialog.close());
dialog.addEventListener("keydown", event => {
event.stopPropagation();
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
commit();
}
});
dialog.addEventListener("keyup", event => event.stopPropagation());
dialog.addEventListener("close", () => {
state.editor = null;
dialog.remove();
state.root.querySelector(".ausboss-callout-tool")?.focus();
}, { once: true });
state.editor = dialog;
document.body.append(dialog);
dialog.showModal();
text.focus();
text.select();
}
function startEdit(state) {
if (state.editor) return;
if (state.card.dataset.tile) return startTileEdit(state);
const wrap = el("div", "ausboss-callout-editor");
const area = el("textarea", "ausboss-callout-edit");
area.value = state.valueWidget.value ?? "";
@@ -571,7 +656,7 @@ app.registerExtension({
// 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;
if (!CALLOUT_TYPES.has(node?.comfyClass) && !CALLOUT_TYPES.has(node?.type)) continue;
const state = buildPanel(node);
if (!state) continue;
const text = repointLinks(state.valueWidget.value, nodes, groups);
@@ -582,10 +667,19 @@ app.registerExtension({
});
},
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_CLASS) return;
if (!CALLOUT_TYPES.has(nodeData?.name)) return;
hideInputsInDef(nodeData, ["text"]);
chainCallback(nodeType.prototype, "onNodeCreated", function () {
if (nodeData.name === "AUSBOSS_NODES_CalloutTile") {
this.properties ??= {};
this.properties.ausbossCalloutTile ??= "title";
}
buildPanel(this);
// Keep new tiles compact without changing ordinary or saved callouts.
if (nodeData.name === "AUSBOSS_NODES_CalloutTile") this.setSize?.([380, 80]);
});
chainCallback(nodeType.prototype, "onSerialize", function (saved) {
serializeNoteIdentity(saved, ["youtube", "discord", "title"].includes(this.properties?.ausbossCalloutTile));
});
chainCallback(nodeType.prototype, "onConfigure", function (saved) {
// Saved values land after onNodeCreated; show them.
@@ -622,6 +716,7 @@ app.registerExtension({
const state = this.__ausbossCallout;
if (!state) return;
state.observer?.disconnect();
state.editor?.close?.();
cancelAnimationFrame(state.frame);
this.__ausbossCallout = null;
});
+6 -2
View File
@@ -9,11 +9,15 @@ wires.
## Tile presets
Right-click the Callout and choose **Title tile** to give your existing text a black heading with a teal accent. **Set YouTube link tile** and **Set Discord link tile** fill in the AusBoss link and label. Click the label to open the link, drag the tile to move it, or right-click **Edit callout text** to change the label and destination. Choose **Plain callout** to return to the standard style with the current text.
Right-click the Callout and choose **Title tile** to give your existing text a black heading with a teal accent. **Set YouTube link tile** and **Set Discord link tile** fill in the AusBoss link and label. Click the label to open the link, or drag the tile to move it. Choose **Plain callout** to return to the standard style with the current text.
- Press **Edit** on a tile to open a larger editor. A link tile has separate **Button text** and **Link address** fields. Existing mixed text stays in a text box.
- Double-click a title to edit it. **Save** or `Ctrl+Enter` keeps the change; **Cancel** or `Esc` leaves the original text.
- Drag the visible bottom-right grip to resize. You can also focus the grip and use arrow keys; hold **Shift** for smaller changes. Resizing keeps the tile in place and supports Undo.
## Writing
- Double-click the note, or press **Edit**, to type. Click outside the box to
- On a plain callout, double-click the note, or press **Edit**, to type. Click outside the box to
keep the text. **Esc** drops what you typed.
- A blank line starts a new point. Each point gets a thin teal line beside it.
- Put two stars around a word to stress it: `**Resize is on.**`
+7
View File
@@ -0,0 +1,7 @@
# Callout Tile
A compact title or colored link button for a workflow. Choose **Title tile**, **Set YouTube link tile**, or **Set Discord link tile** from its menu. Press **Edit** to change its text or link; you can also double-click a title. Drag its visible corner to resize.
It uses the same text, arrows and links as [Callout](AUSBOSS_NODES_Callout.md). **Plain callout** returns to the ordinary note face. Nothing runs and it has no outputs.
Tiles save with their own node type. If ComfyUI reports this node missing, install or update **ComfyUI-AusBoss**, restart ComfyUI, and reload the workflow. An older pack cannot draw the tile correctly. Keep the missing node so its text and settings are preserved.
+31 -3
View File
@@ -94,6 +94,8 @@ Connect a video, then pick what it is for:
right. To choose who goes, type one line per picture, for example
`@1 replaces the man in the hat`. Put spoken words in quotes and name
the speaker. If a voice comes from off screen, say so in the line.
- **change something**: keep the video and describe the change, for example
`She wears a red raincoat`. Be specific about what should change.
- **keep going**: more of the same clip, from where it ends. Say what happens
next, or leave the box empty and it just carries on. Connect the whole clip:
the node sends H3 only the last 2 seconds.
@@ -105,7 +107,24 @@ If your swap keeps the video's audio, connect it to **video_sound** and turn on
line is spoken and keeps only the incoming people's words in the swap prompt.
Type a time such as `3.2s:` to set it yourself. This switch starts off.
Swap and new voice ignore **Length** and keep the video's own length.
With an active video, **swap the person**, **change something** and **new voice**
use the incoming clip to set the frame count. **Length** says **From the clip**;
set the clip's length upstream. The bar does not show a guessed duration or offer
retiming controls for these jobs. H3 rounds the frame count to a supported length.
Other video jobs, or turning the video off, use the editable **Length** again.
For a swap, you can optionally describe the replacement on its own line:
```
the woman with black hair walking toward the camera
@1 looks like a woman with blonde hair and a black hoodie.
```
The first line chooses who goes; the `@1 looks like` line describes the connected
replacement person in Picture 1. Use the number of that picture, with its job set
to **person** or **character sheet**. This helps describe the intended appearance;
it does not guarantee an exact identity match. The line is optional: existing
workflows and scripts keep their previous behavior when it is absent.
## What you can type
@@ -134,8 +153,10 @@ 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.
- **video** and **video_sound**: the frames and the sound of a video. **keep
going** sends the last part of the sound to H3. A swap uses the sound to find
speech timing when **Clip's own sound** is on. To keep source audio in a swap
or **change something**, route the original loader's audio to the save node.
- **voice**: a clip of someone talking, about 10 seconds, plus words in quotes
in your script. It goes to whoever speaks first. It works with your own voice,
or one you have permission to use. Put the spoken line last: with time left
@@ -159,6 +180,12 @@ H3's width and height: the clip takes the shape of your video, or else of your
first picture. **song_under** goes into `audio2` of Audio Merge, with the clip's
sound in `audio1`.
**keeps_clip_sound** is on when a video is connected and **Clip's own sound** is
on, or the video job is **change something**. Use it to select the source audio
for the finished clip. It is the last output; the existing outputs keep their
positions. Keeping source audio still needs the workflow to pass that audio to
its save node.
Nothing plugged in means nothing is added. So a page can turn each one on and off
with [Workflow Switches](#AUSBOSS_NODES_WorkflowSwitches): a bypassed loader reads
as not plugged in.
@@ -217,6 +244,7 @@ as not plugged in.
| `song_under` | output | AUDIO | With a voice and a song, the song to mix under the finished clip. |
| `frames` | output | INT | The clip's length in frames. |
| `width`, `height` | output | INT | The shape of your video, or else of your first picture. |
| `keeps_clip_sound` | output | BOOLEAN | Whether the finished clip should use the video's own sound. |
## Technical details
+1 -1
View File
@@ -18,7 +18,7 @@ Nothing runs: the node has no outputs, so ComfyUI never executes it.
- **Title / Subtitle / Author** make the banner. **Layout → Banner** shows
only that top block (the title, plus the subtitle and author if you filled
them in), so the same node doubles as a label for a stage or column.
- **Layout → Info tile** puts a single button on the canvas. Click the tile to read the full note, including models and links. A book marks a guide and a download arrow marks a model list. Hover or focus the tile to reveal its edit pencil.
- **Layout → Info tile** puts a single button on the canvas. Click the tile to read the full note, including models and links. A book marks a guide and a download arrow marks a model list. Click its pencil to edit, or press **Edit** inside the opened note. Drag the visible bottom-right grip to resize the tile without moving it. Focus the grip and use arrow keys for keyboard resizing; **Shift** makes smaller changes.
- **Accent color** tints the banner bar, dots and buttons; empty keeps the
pack teal. It takes a six-digit hex such as `#00b4aa`.
- **How-to** is Markdown-lite: `#`/`##`/`###` headings, `**bold**`,
@@ -0,0 +1,7 @@
# Workflow Note Tile
A compact help button that opens a full note. Click to read the instructions and model links; click the pencil to edit. Drag its visible corner to resize.
It uses the same note content as [Workflow Note](AUSBOSS_NODES_WorkflowNote.md). Choose **Card** or **Banner** in the editor to show the note directly on the canvas. Nothing runs and it has no outputs.
Tiles save with their own node type. If ComfyUI reports this node missing, install or update **ComfyUI-AusBoss**, restart ComfyUI, and reload the workflow. An older pack cannot draw the tile correctly. Keep the missing node so its instructions and settings are preserved.
+5 -4
View File
@@ -14,8 +14,8 @@ an upscale at the end.
2. Press **+ Switch from 2 selected** on the card.
3. Type a name and press Enter.
One click on its pill now turns every node you picked off, and the next click
turns them on again.
Click **off** to skip every node you picked, or **on** to run them again.
Clicking the selected half leaves it selected.
Groups work too. Every group on the canvas gets a switch of its own, unless
you change that under **Settings**. A group can also be part of a switch you
@@ -160,8 +160,9 @@ the models only that part needs.
## The edit view
- **off | on**: click anywhere on the pill to flip the switch. Off turns off
every node of the row, on sets them all to run. It is one undo step.
- **off | on**: click **off** to turn off every node of the row, or **on** to
set them all to run. Clicking the selected half leaves it selected. A change
is one undo step.
- **Mixed**: when only some of a row's nodes run (one bypassed by hand, say)
the pill is striped and a count shows how many run, such as **1/2**. Click
the half you want, off or on, to set them all. A settings switch reads **changed** when one of
+16
View File
@@ -31,6 +31,7 @@ help panel.
| Save pictures (PNG, lossless WebP or JPEG XL) | Save Image (`AUSBOSS_NODES_SaveImage`) |
| Run a workflow on each picture in a folder | Image Folder (`AUSBOSS_NODES_ImageFolder`) |
| Apply a stack of LoRAs | LoRA Loader (`AUSBOSS_NODES_LoraLoader`) |
| Write a MiniMax H3 or Pika H3 Flex prompt from plain words | H3 Prompt Writer (`AUSBOSS_NODES_H3PromptWriter`): [node help](../AUSBOSS_NODES_H3PromptWriter.md) |
| Krea 2 prompt, and Krea 2 outpainting | Krea 2 Encode (`AUSBOSS_NODES_Krea2Encode`), Krea 2 Outpaint Model Patch (`AUSBOSS_NODES_Krea2OutpaintModelPatch`) |
| Load or save a video | Load Video (`AUSBOSS_NODES_LoadVideo`), Save Video (`AUSBOSS_NODES_SaveVideo`) |
| Load a sound file and keep only part of it, up to a set length | Load Audio (`AUSBOSS_NODES_LoadAudio`) |
@@ -44,6 +45,7 @@ help panel.
| Time the whole run | Run Timer (`AUSBOSS_NODES_RunTimer`) |
| Turn parts of a workflow off and on | Workflow Switches (`AUSBOSS_NODES_WorkflowSwitches`): [its own agent guide](workflow_switches.md) |
| Explain a shared workflow on the canvas | Workflow Note (`AUSBOSS_NODES_WorkflowNote`), Callout (`AUSBOSS_NODES_Callout`) |
| Add compact help or colored link buttons | Workflow Note Tile (`AUSBOSS_NODES_WorkflowNoteTile`), Callout Tile (`AUSBOSS_NODES_CalloutTile`) |
| Lay out boxes of things and words on a canvas | Layout Boxes (`AUSBOSS_NODES_LayoutBoxes`) |
Every node's display name ends in 🆎 and sits under `🆎 AusBoss/<group>` in
@@ -63,6 +65,20 @@ the `AUSBOSS_NODES_...` key above.
- **Media loaders start blank** in the example workflows. Put in the file the
person wants to use before you queue.
## H3 swaps and clip duration
For a swap, keep the target selector on its own line. An optional separate line,
`@1 looks like a woman with blonde hair and a black hoodie.`, describes the
connected replacement person in Picture 1; use its actual picture number. This
can help express appearance but does not guarantee identity. Do not add it to
other jobs. Existing scripts without it keep their previous behavior.
An active video sets the duration for **swap the person**, **change something**
and **new voice**. The card says **From the clip** and hides numeric timeline
retiming; trim or limit the upstream clip instead. Do not infer the final frame
count from raw source metadata: upstream processing and H3 frame rounding apply.
Other jobs and video OFF still use **Length** normally.
## Example workflows
`example_workflows/` in this pack holds complete workflows, each with a
+2 -2
View File
@@ -6,7 +6,7 @@
"nodes": [
{
"id": 1,
"type": "AUSBOSS_NODES_WorkflowNote",
"type": "AUSBOSS_NODES_WorkflowNoteTile",
"pos": [
0,
0
@@ -22,7 +22,7 @@
"outputs": [],
"title": "Download models",
"properties": {
"Node name for S&R": "AUSBOSS_NODES_WorkflowNote"
"Node name for S&R": "AUSBOSS_NODES_WorkflowNoteTile"
},
"widgets_values": [
"{\"v\": 1, \"title\": \"Download models\", \"subtitle\": \"Krea 2 Outpaint\", \"author\": \"\", \"layout\": \"tile\", \"accent\": \"\", \"body\": \"Get the four files below and put each one in its listed folder. Click **check files** after adding them.\", \"models\": [{\"name\": \"krea2_turbo_fp8_scaled.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"13.1 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/diffusion_models/krea2_turbo_fp8_scaled.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_4b_fp8_scaled.safetensors\", \"dir\": \"text_encoders\", \"size\": \"5.2 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/text_encoders/qwen3vl_4b_fp8_scaled.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_vae.safetensors\", \"dir\": \"vae\", \"size\": \"254 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/vae/qwen_image_vae.safetensors\", \"note\": \"\"}, {\"name\": \"krea2_anypaint_rank32.safetensors\", \"dir\": \"loras\", \"size\": \"229 MB\", \"url\": \"https://huggingface.co/yijunwang2/krea2-anypaint/resolve/main/krea2_anypaint_rank32.safetensors\", \"note\": \"by yijunwang2\"}], \"packs\": [], \"links\": []}"
+18
View File
@@ -45,6 +45,24 @@ const LINK = `#(${ID})`;
const WORDS_LINK_RE = /\[([^\[\]\n]+)\]\(([^\s()]+(?:\([^\s()]*\)[^\s()]*)*)\)/g;
const URL_MAX = 2000;
// Only a whole, ordinary web link gets the simple label/address form. Keep
// multi-paragraph or mixed-content notes in the text editor, without loss.
export function tileLinkFields(text) {
const value = String(text ?? "");
const match = /^\[([^\[\]\n]+)\]\((.+)\)$/.exec(value);
if (!match || !linkTarget(match[2])?.url) return null;
const blocks = parseBlocks(value);
if (blocks.length !== 1 || blocks[0].length !== 1
|| blocks[0][0].some(piece => piece.url !== match[2])) return null;
return { label: match[1], url: match[2] };
}
export function tileLinkText(label, url) {
const text = `[${label}](${url.trim()})`;
if (!tileLinkFields(text)) throw new Error("Enter a label without square brackets and a complete https:// or http:// address.");
return text;
}
// Where a link goes: { url }, { link } (a node's id) or { group } (a group's
// id), or null when the text is none of them. `raw` is what stands in the
// round brackets, or after an arrow's #.
+16
View File
@@ -0,0 +1,16 @@
// The same live-input rule used by the writer timeline: muted/bypassed
// sources do not reach this node. This inspects the wire, never media bytes.
export function linkSource(node, name) {
const slot = node.inputs?.find((input) => input?.widget?.name === name || input?.name === name);
if (slot?.link === null || slot?.link === undefined) return null;
const graph = node.graph;
const link = graph?.links?.[slot.link] ?? graph?._links?.get?.(slot.link);
const source = graph?.getNodeById?.(link?.origin_id);
return (source?.mode ?? 0) === 0 ? source ?? null : null;
}
// Keep in step with Python KEEPS_ITS_LENGTH. Other jobs use seconds normally.
export function clipSetsDuration(node, values) {
return ["swap the person", "new voice", "change something"].includes(values.video_job)
&& linkSource(node, "video") !== null;
}
+1 -1
View File
@@ -9,7 +9,7 @@ import { app } from "../../../scripts/app.js";
// Must match `version` in pyproject.toml — scripts/release_preflight.py
// enforces it. The backend serves its copy at /ausboss/pack_version; a
// mismatch means the browser cached JavaScript from an older install.
export const AUSBOSS_JS_VERSION = "2.21.0";
export const AUSBOSS_JS_VERSION = "2.21.1";
export const BRAND = "#00b4aa"; // AusBoss teal — keep node accents consistent.
export const BRAND_DARK = "#007f78";
+25 -13
View File
@@ -2,6 +2,7 @@
// bar as long as the clip, with the cuts as marks. The server reads the script with the same code a run uses,
// so the bar shows what will happen. It also colours the script box, so the
// words the node reads as instructions (@1, 3s:, a cut) stand out.
import { clipSetsDuration, linkSource } from "./h3_duration.mjs";
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { BRAND, chainCallback } from "./index.mjs";
@@ -47,16 +48,6 @@ function ensureCss() {
document.head.append(style);
}
// A bypassed or muted source doesn't reach the node when it runs, so it doesn't count.
function linkSource(node, name) {
const slot = node.inputs?.find((input) => input?.widget?.name === name || input?.name === name);
if (slot?.link === null || slot?.link === undefined) return null;
const graph = node.graph;
const link = graph?.links?.[slot.link] ?? graph?._links?.get?.(slot.link);
const source = graph?.getNodeById?.(link?.origin_id);
return (source?.mode ?? 0) === 0 ? source ?? {} : null;
}
function wired(node, name) {
return linkSource(node, name) !== null;
}
@@ -93,7 +84,7 @@ export function momentStrip(node) {
})),
cameras: choices("camera"),
looks: choices("look"),
seconds: Number(lastSeconds) || 8,
seconds: clipSetsDuration(node, lastValues) ? 0 : Number(lastSeconds) || 8,
}));
let lastValues = {};
@@ -275,17 +266,38 @@ export function momentStrip(node) {
body: JSON.stringify({ script, seconds, jobs, video }),
});
const report = response.ok ? await response.json() : null;
if (turn === asked) show(report);
if (turn === asked && !clipSetsDuration(node, lastValues)) show(report);
} catch {
if (turn === asked) show(null);
if (turn === asked && !clipSetsDuration(node, lastValues)) show(null);
}
};
let latest = null;
let fromClip = false;
const sync = (values) => {
latest = values;
lastValues = values;
const nextFromClip = clipSetsDuration(node, values);
if (fromClip !== nextFromClip) {
fromClip = nextFromClip;
// Also notice a source switched off without changing this node's wires.
queueMicrotask(() => node.__ausbossCard?.refresh?.());
}
colours.sync(values, [1, 2, 3, 4].filter((number) => wired(node, `picture_${number}`)));
if (fromClip) {
shown = null;
clearTimeout(timer);
asked += 1;
ruler.textContent = "";
track.textContent = "";
const message = el("ausboss-strip-words", "Length comes from the clip");
message.style.cssText = "display:flex;align-items:center;height:100%";
track.append(message);
note.textContent = "Set the clip's length upstream. Timing controls are unavailable here.";
note.title = note.textContent;
note.classList.remove("warn");
return;
}
if (wired(node, "script")) {
shown = null;
clearTimeout(timer);
+13
View File
@@ -0,0 +1,13 @@
// A tile needs its renderer. Give saved tiles a real node identity so an old
// install reports a missing node instead of stretching a plain note over it.
export const CALLOUT_TYPES = new Set(["AUSBOSS_NODES_Callout", "AUSBOSS_NODES_CalloutTile"]);
export const NOTE_TYPES = new Set(["AUSBOSS_NODES_WorkflowNote", "AUSBOSS_NODES_WorkflowNoteTile"]);
export function serializeNoteIdentity(saved, tile) {
const types = CALLOUT_TYPES.has(saved.type) ? [...CALLOUT_TYPES]
: NOTE_TYPES.has(saved.type) ? [...NOTE_TYPES] : null;
if (!types) return;
saved.type = types[tile ? 1 : 0];
// LiteGraph's serialized properties can still be the live object's properties.
saved.properties = { ...saved.properties, "Node name for S&R": saved.type };
}
+96 -8
View File
@@ -4,7 +4,7 @@ import { app } from "../../../scripts/app.js";
// on this instance so saved colors, titles and the node type remain usable.
const originalDescriptors = new WeakMap();
export function setTileFace(node, root, enabled) {
export function setTileFace(node, root, enabled, onEdit) {
root.classList.toggle("ausboss-tile-face", enabled);
const previous = originalDescriptors.get(node);
if (enabled === Boolean(previous)) return;
@@ -28,7 +28,8 @@ export function setTileFace(node, root, enabled) {
});
}
const listeners = new AbortController();
installTileDrag(node, root, listeners.signal);
installTileDrag(node, root, listeners.signal, onEdit);
installTileResize(node, root, listeners.signal);
originalDescriptors.set(node, { descriptors, listeners });
} else {
previous.listeners.abort();
@@ -45,14 +46,24 @@ function skipBadges() {}
// A normal node moves by its header. Give the tile that gesture while
// keeping a short click on a link or the info button as its usual action.
function installTileDrag(node, root, signal) {
function installTileDrag(node, root, signal, onEdit) {
let press;
let lastClick;
let suppressClick = false;
const capture = { capture: true, signal };
root.addEventListener("pointerdown", (event) => {
suppressClick = false;
if (!event.isPrimary || event.button !== 0 || node.pinned || app.canvas?.read_only) return;
if (event.target.closest(".ausboss-callout-editor,.ausboss-callout-tools,.ausboss-note-edit")) return;
if (event.target.closest(".ausboss-callout-editor,.ausboss-callout-tools,.ausboss-note-edit,.ausboss-tile-resize")) return;
if (onEdit && !event.target.closest("a,button") && lastClick
&& event.timeStamp - lastClick.time < 500
&& Math.hypot(event.clientX - lastClick.x, event.clientY - lastClick.y) < 8) {
lastClick = null;
event.preventDefault();
event.stopPropagation();
onEdit();
return;
}
press = { id: event.pointerId, x: event.clientX, y: event.clientY, moving: false };
const target = event.target.closest("a,button") || root;
target.setPointerCapture(event.pointerId);
@@ -82,6 +93,9 @@ function installTileDrag(node, root, signal) {
if (press.moving) {
node.graph?.afterChange?.();
suppressClick = true;
lastClick = null;
} else {
lastClick = { time: event.timeStamp, x: event.clientX, y: event.clientY };
}
press = null;
};
@@ -107,13 +121,87 @@ function installTileDrag(node, root, signal) {
}, { signal });
}
// Nodes 2.0 draws its own DOM frame. Keep native move, selection and resize
// targets on the node, and show only the widget inside that frame.
// Put the resize target on the visible corner. The native node corner lies
// outside a frameless tile and is almost impossible to find without a frame.
function installTileResize(node, root, signal) {
const face = root.querySelector(".ausboss-callout-card,.ausboss-note-card") || root;
const grip = document.createElement("button");
grip.type = "button";
grip.className = "ausboss-tile-resize";
grip.setAttribute("aria-label", "Resize tile");
grip.title = "Drag to resize. Arrow keys resize; Shift makes smaller changes.";
face.append(grip);
let drag;
const minimum = () => {
// Use the same floor as the native corner, including widget margins.
// Older saved tiles can be smaller: touching their grip must not jump.
const floor = node.computeSize?.() ?? [200, 72];
return floor.map((value, axis) => Math.min(value, node.size[axis]));
};
const resize = (width, height, floor) => {
node.setSize?.([Math.max(floor[0], width), Math.max(floor[1], height)]);
node.setDirtyCanvas?.(true, true);
};
grip.addEventListener("pointerdown", event => {
event.stopPropagation();
if (event.button !== 0 || node.pinned || app.canvas?.read_only) return;
event.preventDefault();
node.graph?.beforeChange?.();
app.canvas?.selectNode?.(node);
drag = { id: event.pointerId, x: event.clientX, y: event.clientY,
size: [...node.size], floor: minimum(), zoom: app.canvas?.ds?.scale || 1 };
grip.setPointerCapture(event.pointerId);
});
grip.addEventListener("pointermove", event => {
if (event.pointerId !== drag?.id) return;
event.stopPropagation();
resize(drag.size[0] + (event.clientX - drag.x) / drag.zoom,
drag.size[1] + (event.clientY - drag.y) / drag.zoom, drag.floor);
});
const finish = event => {
if (event.pointerId !== drag?.id) return;
drag = null;
node.graph?.afterChange?.();
};
grip.addEventListener("pointerup", finish);
grip.addEventListener("pointercancel", finish);
grip.addEventListener("lostpointercapture", finish);
grip.addEventListener("click", event => { event.preventDefault(); event.stopPropagation(); });
grip.addEventListener("keydown", event => {
if (!event.key.startsWith("Arrow")) return;
event.preventDefault();
event.stopPropagation();
if (node.pinned || app.canvas?.read_only) return;
const step = event.shiftKey ? 1 : 10;
node.graph?.beforeChange?.();
resize(node.size[0] + (event.key === "ArrowRight" ? step : event.key === "ArrowLeft" ? -step : 0),
node.size[1] + (event.key === "ArrowDown" ? step : event.key === "ArrowUp" ? -step : 0), minimum());
node.graph?.afterChange?.();
});
signal.addEventListener("abort", () => { if (drag) node.graph?.afterChange?.(); grip.remove(); }, { once: true });
}
// Nodes 2.0 always adds a title's height to --node-height, even when a tile
// hides the title. Remove that unused reservation: otherwise an 88px saved
// tile gains a 30px tail and overlaps the next tile. Vue also positions the
// host at saved y minus the title height. Restore that headerless origin
// with an independent translation so faces cannot cover group headings.
// Keep the saved size and
// the native selection/resize targets, rather than changing the graph.
// Its in-body pack badges also consume 20px that Classic paints outside the
// panel. Decorative tiles and callouts use that room for their own face.
// The node-body test id and direct mt-auto badge wrapper are from the pinned
// frontend's LGraphNode.vue/NodeBadges.vue; no other node body is affected.
export const TILE_FACE_CSS = `
.ausboss-tile-face :is(.ausboss-callout-card,.ausboss-note-head){pointer-events:auto;touch-action:none;cursor:grab;}
.ausboss-tile-face{user-select:none;}
.ausboss-tile-resize{position:absolute;right:1px;bottom:1px;z-index:4;width:22px;height:22px;padding:0;border:0;border-radius:3px;background:transparent;color:#9fb9b6;opacity:.65;cursor:nwse-resize;touch-action:none;pointer-events:auto;}
.ausboss-tile-resize::after{content:"";position:absolute;right:4px;bottom:4px;width:9px;height:9px;background:repeating-linear-gradient(135deg,transparent 0 3px,currentColor 3px 4px,transparent 4px 6px);clip-path:polygon(100% 0,100% 100%,0 100%);}
.ausboss-tile-resize:hover,.ausboss-tile-resize:focus-visible{opacity:1;background:#00b4aa33;outline:1px solid #00b4aa;outline-offset:-1px;}
.lg-node:has(.ausboss-tile-face){filter:none;}
.lg-node:has(.ausboss-tile-face):not([data-collapsed]){min-height:calc(var(--node-height) - ${globalThis.LiteGraph?.NODE_TITLE_HEIGHT ?? 30}px);translate:0 ${globalThis.LiteGraph?.NODE_TITLE_HEIGHT ?? 30}px;}
.lg-node:has(.ausboss-tile-face,.ausboss-callout) [data-testid^="node-body-"]>.mt-auto{display:none;}
.lg-node:has(.ausboss-tile-face) [data-testid="node-inner-wrapper"],
.lg-node:has(.ausboss-tile-face) .bg-component-node-background{background:transparent!important;box-shadow:none;}
.lg-node:has(.ausboss-tile-face) .lg-node-header,
.lg-node:has(.ausboss-tile-face) .bg-component-node-background>div:last-child:not(.lg-node-widgets){display:none;}
.lg-node:has(.ausboss-tile-face) .lg-node-header{display:none;}
`;
+18 -1
View File
@@ -226,6 +226,8 @@ function isFloatWidget(widget) {
// of the card
// { group: "advanced", label: "More" } - a disclosure; later rows with
// group: "advanced" sit under it
// A numeric row may have readOnlyText(node, values): nonempty text replaces
// its control without changing its widget, socket, value or row height.
// Rows may also carry `mute: (values) => boolean` and `muteTitle`: a muted
// row stays in place but dims and stops taking input, with the title as its
// hover hint (a setting the current mode does not use).
@@ -291,13 +293,28 @@ export function mountWidgetCard(node, { rows, minWidth = 300, first = false, hid
onSettle: () => notifyAusbossChange(),
});
into.append(control.root);
if (typeof row.limits !== "function") return (vals) => control.set(Number(vals[name]) || 0);
// Display an unused setting honestly while keeping its saved value and socket.
const readOnly = row.readOnlyText ? el("span", "ausboss-card-readonly") : null;
if (readOnly) {
readOnly.title = row.readOnlyTitle ?? "";
readOnly.setAttribute("role", "status");
into.append(readOnly);
}
const syncReadOnly = (vals) => {
if (!readOnly) return;
const text = row.readOnlyText(node, vals) || "";
readOnly.textContent = text;
readOnly.style.display = text ? "" : "none";
control.root.style.display = text ? "none" : "";
};
if (typeof row.limits !== "function") return (vals) => { syncReadOnly(vals); control.set(Number(vals[name]) || 0); };
// Limits that come from somewhere else are read at every refresh: a wire
// made or cut changes them. The stored value follows the range, the way
// ComfyUI's own Primitive node follows the input it is wired to.
let shown = null;
const hint = el("span", "ausboss-card-range");
return (vals) => {
syncReadOnly(vals);
const outside = row.limits(node) ?? null;
const range = narrowedRange(widget?.options, outside);
const key = outside ? `${range.min}|${range.max}|${outside.step}` : "";
+3 -7
View File
@@ -809,13 +809,9 @@ export function listedRows(switchRows, groupRows, settings) {
// ---------- switching ----------
// What a click on a row's pill asks for. The pill is one switch: a click
// anywhere on it flips it, on the "off" half as much as on the "on" half.
// Only while the row reads mixed (neither half lit) does the half that was
// clicked decide: `side` is true for the on half.
export function flipTo(state, side) {
if (state === "on") return false;
if (state === "off") return true;
// Each half selects its named state, even when that state is already set.
// Keep the first argument for callers using the existing helper signature.
export function flipTo(_state, side) {
return Boolean(side);
}
+3 -2
View File
@@ -11,6 +11,7 @@ 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 { clipSetsDuration } from "../shared/h3_duration.mjs";
import { momentStrip } from "../shared/moment_strip.mjs";
import { mountWidgetCard } from "../shared/widget_card.mjs";
import { SOCKETLESS_CHOICE, SOCKETLESS_TEXT, commitWidgetValue, markSocketless, resetUnknownValues } from "../shared/widget_card_math.mjs";
@@ -339,14 +340,14 @@ const CARDS = {
{ 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.",
placeholder: "Type what happens, one line for each thing. Picture 1 is @1. A blank line cuts to a new place.\n\nSwap: leave empty, or name who goes. Optional new look on its own line: @1 looks like a woman with blonde hair and a black hoodie.",
},
{ 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: "seconds", label: "Length", suffix: "s", readOnlyText: (node, values) => clipSetsDuration(node, values) ? "From the clip" : "", readOnlyTitle: "This video job uses the incoming clip, so Length is not used." },
{ widget: "camera", label: "Camera", kind: "select" },
{ widget: "look", label: "Look", kind: "select" },
{ group: "more", label: "More" },
+26 -6
View File
@@ -13,6 +13,8 @@
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { setTileFace, TILE_FACE_CSS } from "../shared/tile_face.mjs";
import { NOTE_TYPES, serializeNoteIdentity } from "../shared/note_identity.mjs";
import { compactAction } from "../shared/panel_controls.mjs";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { collectGraphNodes } from "../shared/appearance.mjs";
import { copyToClipboard } from "../shared/clipboard.mjs";
@@ -42,7 +44,6 @@ import {
import { comboValues, commitWidgetValue } from "../shared/widget_card_math.mjs";
import { parseRows, serializeRows } from "../shared/lora_stack.mjs";
const NODE_CLASS = "AUSBOSS_NODES_WorkflowNote";
const CSS_ID = "ausboss-workflow-note-css";
const WIDGET_NAME = "ausboss_workflow_note_panel";
const PANEL_MIN_WIDTH = 300;
@@ -125,12 +126,13 @@ ${TILE_FACE_CSS}
.ausboss-note-read::after{content:"";position:absolute;right:18px;width:7px;height:7px;border:solid #a2b6b6;border-width:2px 2px 0 0;transform:rotate(45deg);}
.ausboss-note-read:hover{background:#10191b;}
.ausboss-note-read:focus-visible{outline:2px solid var(--accent);outline-offset:-3px;}
.ausboss-note.is-tile .ausboss-note-edit{top:50%;right:32px;transform:translateY(-50%);opacity:0;pointer-events:none;}
.ausboss-note.is-tile .ausboss-note-edit{top:50%;right:32px;transform:translateY(-50%);opacity:.65;pointer-events:auto;}
.ausboss-note.is-tile .ausboss-note-head:is(:hover,:focus-within) .ausboss-note-edit{opacity:1;pointer-events:auto;}
.ausboss-note-reader{box-sizing:border-box;width:min(640px,94vw);max-height:86vh;overflow:auto;margin:auto;padding:0 0 24px;border:1px solid #365154;border-radius:12px;background:#161f21;color:#e8ebe7;box-shadow:0 18px 64px #0008;font:15px/1.55 "IBM Plex Sans",system-ui,sans-serif;}
.ausboss-note-reader::backdrop{background:#06090ab8;}
.ausboss-note-reader-head{padding:20px 24px 16px;border-top:3px solid var(--accent);background:#080e10;}
.ausboss-note-reader-head button{float:right;margin:0 0 6px 10px;}
.ausboss-note-reader-head .ausboss-compact-action{height:32px;padding:0 12px;font-size:13px;}
.ausboss-note-reader-head h2{margin:0;font:650 26px/1.2 "IBM Plex Sans",system-ui,sans-serif;color:#ecf4f4;overflow-wrap:anywhere;}
.ausboss-note-reader-head p{margin:6px 0 0;color:#b4c5c5;overflow-wrap:anywhere;}
.ausboss-note-reader .ausboss-note-section{padding:14px 24px 0;}
@@ -175,6 +177,7 @@ ${TILE_FACE_CSS}
.ausboss-note-body hr{margin:8px 0;border:0;border-top:1px solid rgba(255,255,255,.08);}
.ausboss-note-body code{padding:1px 4px;border-radius:3px;background:rgba(255,255,255,.08);font:11px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#e6f4f3;}
.ausboss-note-body pre{margin:0 0 7px;padding:7px 9px;overflow-x:auto;border-radius:5px;background:rgba(0,0,0,.35);font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#e6f4f3;white-space:pre;}
.ausboss-note-reader .ausboss-note-body pre,.ausboss-note-reader .ausboss-note-body pre code{white-space:pre-wrap;overflow-wrap:anywhere;}
.ausboss-note-body a,.ausboss-note-link{color:var(--accent);text-decoration:none;pointer-events:auto;}
.ausboss-note-body a:hover,.ausboss-note-link:hover{text-decoration:underline;}
.ausboss-note-folder{margin:8px 0 4px;color:#a9c4c2;font:11px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
@@ -533,10 +536,15 @@ function render(state) {
const dialog = el("dialog", "ausboss-note-reader");
dialog.setAttribute("aria-label", note.title || "Workflow info");
dialog.style.setProperty("--accent", note.accent || BRAND);
const close = el("button", "ausboss-note-btn", "Close");
const close = compactAction("Close");
close.onclick = () => dialog.close();
const editNote = compactAction("Edit", "Edit this note");
editNote.onclick = () => {
dialog.close();
openEditor(state);
};
const header = el("header", "ausboss-note-reader-head");
header.append(close, el("h2", "", note.title || "Workflow info"));
header.append(close, editNote, el("h2", "", note.title || "Workflow info"));
if (note.subtitle) header.append(el("p", "", note.subtitle));
if (note.author) header.append(el("p", "", `by ${note.author}`));
dialog.append(header);
@@ -546,7 +554,7 @@ function render(state) {
dialog.addEventListener("close", () => {
dialog.remove();
state.reader = null;
read.focus();
if (!openDialog) read.focus();
}, {once:true});
dialog.showModal();
};
@@ -700,6 +708,12 @@ function naturalHeight(state, width) {
// as the node's floor. It never shrinks the node: a taller note keeps its
// empty space until someone drags it shorter.
function fitNote(state) {
// A tile opens its content in a dialog. Keep its chosen canvas size,
// rather than growing it to a full note's measured height.
if (state.note.layout === "tile") {
state.fitHeight = undefined;
return;
}
if (!classicCanvas()) return;
const { node, root, domWidget } = state;
const width = node.size[0] - WIDGET_FRAME;
@@ -1156,10 +1170,16 @@ function buildPanel(node) {
app.registerExtension({
name: "AusBoss.WorkflowNote",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_CLASS) return;
if (!NOTE_TYPES.has(nodeData?.name)) return;
hideInputsInDef(nodeData, ["note"]);
chainCallback(nodeType.prototype, "onNodeCreated", function () {
buildPanel(this);
// A new tile starts as a button; onConfigure restores saved dimensions.
if (nodeData.name === "AUSBOSS_NODES_WorkflowNoteTile") this.setSize?.([380, 80]);
});
chainCallback(nodeType.prototype, "onSerialize", function (saved) {
const value = this.widgets?.find(widget => widget.name === "note")?.value;
serializeNoteIdentity(saved, normalizeNote(value).layout === "tile");
});
chainCallback(nodeType.prototype, "onConfigure", function (saved) {
// Saved widget values land after onNodeCreated: re-read them, and
+4 -7
View File
@@ -349,8 +349,7 @@ function applyPlan(state, plan, values = []) {
notifyAusbossChange();
}
// A click on a row's pill. The pill is one switch, so either half flips it;
// `side` (true for the on half) only decides while the row reads mixed.
// Each half sets the state it names; clicking the selected half leaves it set.
function switchRow(state, key, side) {
const { settings, rows } = read(state);
const target = rows.find((row) => rowKey(row) === key);
@@ -835,7 +834,7 @@ function buildRow(state, key, made) {
const chip = el("span", "ausboss-ws-chip");
const title = el("span", "ausboss-ws-title");
const count = el("span", "ausboss-ws-count");
// One switch drawn as two halves: a click on either half flips it.
// Separate off and on targets, backed by the same saved switch.
const pill = el("div", "ausboss-card-seg ausboss-card-bool ausboss-ws-pill");
pill.setAttribute("role", "group");
const offButton = button("", "off");
@@ -940,10 +939,8 @@ function buildRow(state, key, made) {
offNodes.length > 0 && `${settings.off === "mute" ? "mute" : "bypass"} what runs only while it is off`,
settings.exclusive && "switch the other rows off",
]);
// Either half flips the switch, so both say what the next click does;
// a mixed row goes to the half that is clicked.
offButton.title = data.state === "off" ? `Click: ${turnOn}` : data.state === "on" ? `Click: ${turnOff}` : turnOff;
onButton.title = data.state === "off" ? `Click: ${turnOn}` : data.state === "on" ? `Click: ${turnOff}` : turnOn;
offButton.title = turnOff;
onButton.title = turnOn;
};
return { row, title, name, sync };
}
+8
View File
@@ -1583,5 +1583,13 @@
"name": "秒数"
}
}
},
"AUSBOSS_NODES_CalloutTile": {
"display_name": "标注卡片 🆎",
"description": "紧凑的标题或彩色链接按钮。双击编辑,拖动角落调整大小。旧版节点包会将它显示为缺失节点,请更新 ComfyUI-AusBoss。"
},
"AUSBOSS_NODES_WorkflowNoteTile": {
"display_name": "工作流说明卡片 🆎",
"description": "点击打开完整说明的小按钮。点击铅笔编辑,拖动角落调整大小。旧版节点包会将它显示为缺失节点,请更新 ComfyUI-AusBoss。"
}
}
+58 -3
View File
@@ -24,8 +24,9 @@ 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.
EDIT = "change something"
VIDEO_JOBS = (MOVES, SWAP, KEEP, REVOICE, EDIT)
KEEPS_ITS_LENGTH = (SWAP, REVOICE, EDIT) # 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")
@@ -37,6 +38,8 @@ 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, pick the video job "change something" and type "He wears @1".'
# (what the label is, how it is kept). {picture} is H3's number for the picture.
JOB_WORDS = {
@@ -96,6 +99,37 @@ SWAP_VIDEO_KEPT = (
)
SWAP_KEPT = "{replaced}: removed - replace {who} with {main} throughout."
ONE_GOES = "the original person"
APPEARANCE_LINE = re.compile(r'^\s*@([1-4])\s+looks like\s+([^"\n]+?)\s*$', re.I)
def swap_appearances(text, jobs, video):
"""An optional separate @1 looks like line describes a replacement, not who goes.
Other jobs and scripts without that exact line keep their original text.
The number is the visible picture slot, not its compact H3 ordinal.
"""
if video != SWAP:
return text, {}
kept, descriptions = [], {}
for line in str(text or "").splitlines():
match = APPEARANCE_LINE.fullmatch(line)
if match is None:
kept.append(line)
continue
slot = int(match.group(1))
if slot not in (jobs or {}):
raise ValueError(f"Picture {slot} is not connected. Connect the new person's picture or remove its looks like line.")
if jobs[slot] not in (PERSON, SHEET):
raise ValueError(f"Picture {slot} must be a person or character sheet for a swap's looks like line.")
if slot in descriptions:
raise ValueError(f"Describe Picture {slot} once, in one looks like line.")
description = match.group(2).strip().rstrip(" .")
if not description:
raise ValueError(f"Say what Picture {slot}'s person looks like, or remove its looks like line.")
descriptions[slot] = description
return ("\n".join(kept), descriptions) if descriptions else (text, {})
# 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(
@@ -138,6 +172,14 @@ KEEP_KEPT = (
)
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."
EDIT_IS = (
"<Video 1> supplies the place, the person, the action, the timing, the camera movement, the look and the sound.\n"
"{main} is the person in <Video 1>, kept as they are: the same face, hair, body and movements, with only what the script names changed."
)
EDIT_KEPT = (
"<Video 1>: partially_preserved - keep the place, the person, the action, the timing, the camera movement, the look and the sound; change only what the script names.\n"
"{main} (appears in {shots}): fully_preserved - the face, hair and every movement are retained; only what the script names changes."
)
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"
@@ -211,10 +253,11 @@ class Cast:
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.in_video = subject_label(last + 1) if video in (KEEP, REVOICE, EDIT) 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}
self.clip_clothes = False # A swap that keeps the clothes of whoever goes.
self.appearances = {} # Explicit looks-like lines, by the visible replacement picture slot.
self.clip_sound = False # A swap whose finished clip keeps the video's own sound.
self.speaks_as_video = False # With that sound and no words typed: the new person says what the video says.
self.typed_lines = False # The script typed words in quotes for someone to say.
@@ -286,6 +329,8 @@ class Cast:
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}
@@ -319,6 +364,8 @@ class Cast:
def definitions(self, last, redraw, wearers):
ends = self.ends
lines = []
if self.video == EDIT:
lines.append(EDIT_IS.format(main=self.in_video))
if self.video == SWAP:
lines.append(SWAP_VIDEO)
lines += [SWAP_IS.format(replaced=gone, who=who) for gone, who, _ in self.swaps]
@@ -335,6 +382,12 @@ class Cast:
elif self.clip_clothes and picture in self.swappers:
lines.append(CLOTHED_SWAP.format(who=self.goes_for(picture), **words))
continue
elif picture in self.swappers and picture.slot in self.appearances:
lines.append(
f'{words["subject"]} is the person in {words["picture"]}, with the same face and this appearance: '
f'{self.appearances[picture.slot]}. The background and lighting of {words["picture"]} are not transferred.'
)
continue
elif picture.job == PERSON and words["subject"] in worn:
lines.append(DRESSED[0].format(**words))
continue
@@ -379,6 +432,8 @@ class Cast:
return [number for number, words in enumerate(shots, 1) if label in words] or everywhere[:1]
lines = []
if self.video == EDIT:
lines.append(EDIT_KEPT.format(main=self.in_video, shots=shot_list(seen(self.in_video))))
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]
+36 -3
View File
@@ -12,7 +12,7 @@ import re
import numpy as np
from ._h3_cast_helpers import KEEP, NEEDS_NO_WORDS, ONE_GOES, REVOICE, SWAP, Cast, number_pictures
from ._h3_cast_helpers import EDIT, KEEP, NEEDS_NO_WORDS, ONE_GOES, REVOICE, SWAP, Cast, number_pictures, swap_appearances
from ._h3_cast_helpers import TOKEN
from ._h3_script_helpers import (
ACTION_GOES_ON,
@@ -40,6 +40,7 @@ SECTIONS = (
CUT = re.compile(r"(\bAt )(\d\d):(\d\d\.\d{3})")
NO_SCRIPT = "Type what happens first: one line for each moment."
NO_CHANGE = 'Type what changes in the video, like "He wears @1 in place of his cap."'
VOICE_NO_WORDS = (
"A voice is connected, but nobody speaks. Put what they say in quotes, or "
"turn the voice off."
@@ -269,6 +270,30 @@ def add_song_under(parts, beats):
cut_on_beat(parts, beats)
# In "change something" the person comes from the video, so a picture is what
# gets put on them. Pictures open as "person", and a newcomer types plain words.
PUT_ON = r"(?:wears|wearing|wear|puts on|putting on|put on|has on|dons)"
PUT_ON_TOKEN = re.compile(r"\b" + PUT_ON + r"\s+(?:a |an |the )?@(\d)\b|@(\d)\s+(?:in place of|instead of)\b", re.IGNORECASE)
PUT_ON_WORDS = re.compile(r"\b(" + PUT_ON + r")\s+(.+?)(?=\s+(?:in place of|instead of)\b|[,.;!?\n]|\s+and\s|$)", re.IGNORECASE)
def put_on_from_pictures(script, jobs):
"""For "change something": a picture the line puts on someone is an outfit,
even if its job says person. With one picture and no @ typed, the thing the
line puts on him ("wears the propeller hat") is that picture."""
jobs = dict(jobs)
if len(jobs) == 1 and not TOKEN.search(script):
slot = next(iter(jobs))
script, found = PUT_ON_WORDS.subn(lambda m: f"{m.group(1)} @{slot}", script, count=1)
if not found:
return script, jobs
for match in PUT_ON_TOKEN.finditer(script):
slot = int(match.group(1) or match.group(2))
if jobs.get(slot) == "person":
jobs[slot] = "outfit"
return script, jobs
def write_prompt(script, seconds=15.0, camera="", look="", sound="", language="English", voice=False, song_beats=None,
ask=None, jobs=None, video="", soundtrack=False, clip_sound=False, speech=None):
"""The finished prompt.
@@ -291,15 +316,23 @@ def write_prompt(script, seconds=15.0, camera="", look="", sound="", language="E
speaker = None
if parts is None:
cast = Cast(number_pictures(jobs if jobs is not None else {1: "person"}, video), video)
jobs = jobs if jobs is not None else {1: "person"}
if video == EDIT:
script, jobs = put_on_from_pictures(script, jobs)
cast = Cast(number_pictures(jobs, video), video)
script, cast.appearances = swap_appearances(script, jobs, 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 == EDIT:
raise ValueError(NO_CHANGE)
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([without_speech(moment) for moment in scene.moments])
if cast.appearances and cast.clip_clothes:
raise ValueError('Remove "keep the clip\'s clothes" or the looks like line. This appearance hint replaces the whole look.')
goes = [(who, str(picture.slot)) for who, picture in zip(cast.whos, cast.swappers) if who != ONE_GOES]
scene.moments = swap_lines(scene.moments, goes)
if video == SWAP and clip_sound:
@@ -326,7 +359,7 @@ def write_prompt(script, seconds=15.0, camera="", look="", sound="", language="E
if parts is None:
def say(words, on_screen=True, who="<Subject 1>", number=1, delivery=""):
lips = "<Video 1>" if video in (REVOICE, SWAP) else ""
lips = "<Video 1>" if video in (REVOICE, SWAP, EDIT) else ""
voiced = voice_label if number == 1 else None
return spoken_line(who, clean_words(words), language, voiced, on_screen, number, lips, delivery)
+29 -5
View File
@@ -12,6 +12,7 @@ from dataclasses import dataclass, field
import re
from ._h3_cast_helpers import (
EDIT,
KEEP,
MAIN,
OUTFIT,
@@ -32,6 +33,7 @@ from ._h3_cast_helpers import (
number_pictures,
picture_label,
subject_label,
swap_appearances,
)
QUOTES = {"“": '"', "”": '"', "‘": "'", "’": "'", "—": " - ", "–": " - ", "…": "..."}
@@ -140,6 +142,7 @@ SAYS_NOTHING = "{main} keeps the lips closed and says nothing the whole time."
SPEAKS_AS_VIDEO = "{main} speaks as in <Video 1>, each word landing exactly on the lip movement of <Video 1>."
CLIP_SOUND = "The original sound of <Video 1> continues throughout."
FOLLOWS_CAMERA = "The camera follows the movement of <Video 1>."
SAME_AS_VIDEO = "{main} has the same words, mouth movements and moves as in <Video 1>. Everything else stays as it is in <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>."
@@ -147,6 +150,7 @@ 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>.",
EDIT: "Retain the look 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")
@@ -565,6 +569,8 @@ def script_warning(script, seconds, cast=None):
def script_report(text, seconds, jobs=None, video=""):
"""What a script will do, for the timeline on the node."""
# The optional swap description is not a second timed action.
text, _ = swap_appearances(text, jobs, video)
script = read_script(text)
starts = timeline(script, seconds)
ends = [*starts[1:], seconds]
@@ -718,7 +724,18 @@ def before_the_action(number, text, cast, wearers):
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]
for _, who, new in cast.swaps:
picture = next((p for p in cast.swappers if subject_label(p.subject) == new), None)
appearance = cast.appearances.get(picture.slot) if picture else None
if appearance:
notes.append(
f"{new} occupies the starting position of {who} and follows the same route, movements, timing and pace. "
f"{new} has the defined appearance from {picture_label(picture.picture)}: {appearance}. "
f"The replacement stays present throughout, ending at the original performance's final position. "
f"Do not show {who} in that role at any time; there is no transition or morph into the replacement."
)
else:
notes.append(TAKES_OVER.format(main=new, who=who))
notes.append(ONE_PERSON if cast.whos == [ONE_GOES] else OTHERS_STAY)
for _, _, new in cast.swaps if cast.clip_sound else []:
if cast.speaks_as_video:
@@ -748,7 +765,9 @@ def after_the_action(text, cast):
notes = []
if cast.moves and cast.main in text:
notes.append(THE_MOVES.format(main=cast.main, moves=cast.moves))
if cast.video == SWAP:
if cast.video == EDIT:
notes.append(SAME_AS_VIDEO.format(main=cast.main))
if cast.video in (SWAP, EDIT):
notes.append(FOLLOWS_CAMERA)
for picture in cast.of(SHEET):
subject = subject_label(picture.subject)
@@ -833,7 +852,7 @@ def script_sound(script, sound, places=False):
def task_of(cast):
"""The tag a summary opens with."""
if cast.video == SWAP or cast.video == REVOICE:
if cast.video in (SWAP, REVOICE, EDIT):
return "video editing"
if cast.video == KEEP:
return "video continuation"
@@ -852,6 +871,9 @@ def summary_of(script, cast, new_places, soundtrack):
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 == EDIT:
changes = " ".join(sentence(with_labels(without_speech(moment), cast)) for moment in script.moments)
return f"[{task}] {changes} Everything else in <Video 1> is kept."
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."
@@ -890,6 +912,8 @@ def script_parts(script, seconds, camera="", look="", sound="", say=None, cast=N
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 cast.video == EDIT:
style = VIDEO_LOOKS[EDIT]
if not style and len(cast.of(PLACE)) > 1:
style = OWN_COLOURS
elif not style and not (first and first.job == THING):
@@ -909,10 +933,10 @@ def script_parts(script, seconds, camera="", look="", sound="", say=None, cast=N
ambience = OLD_SOUND
if cast.video == REVOICE and ambience == PLACE_SOUND:
ambience = ROOM_TONE
if cast.clip_sound and ambience == PLACE_SOUND: # The clip's own sound is what plays, unless the script says the sound.
if (cast.clip_sound or cast.video == EDIT) and ambience == PLACE_SOUND: # The clip's own sound is what plays, unless the script says the sound.
ambience = CLIP_SOUND
# With the clip's own sound, words the prompt leaves out are still heard.
if ambience != "N/A" and any(moment.speech for moment in script.moments) and not cast.clip_sound:
if ambience != "N/A" and any(moment.speech for moment in script.moments) and not cast.clip_sound and cast.video != EDIT:
ambience = f"{ambience} {ONE_LINE}"
return {
"subject_definitions": subjects,
+18 -2
View File
@@ -52,7 +52,23 @@ class AusBossCallout:
return ()
NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_Callout": AusBossCallout}
NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_Callout": "Callout 🆎"}
class AusBossCalloutTile(AusBossCallout):
DESCRIPTION = (
"A compact title or colored link button for a workflow. "
"Double-click to edit; use its menu to choose a title, YouTube or "
"Discord tile. Saved tiles require a pack that supports tiles, "
"so an older install can report the missing node. Nothing runs."
)
SEARCH_ALIASES = ["callout tile", "link button", "youtube", "discord", "ausboss"]
NODE_CLASS_MAPPINGS = {
"AUSBOSS_NODES_Callout": AusBossCallout,
"AUSBOSS_NODES_CalloutTile": AusBossCalloutTile,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AUSBOSS_NODES_Callout": "Callout 🆎",
"AUSBOSS_NODES_CalloutTile": "Callout Tile 🆎",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+12 -5
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import sys
from ._h3_cast_helpers import JOBS, KEEP, KEEPS_ITS_LENGTH, PERSON, PLACE, START, SWAP, THING, VIDEO_JOBS
from ._h3_cast_helpers import EDIT, JOBS, KEEP, KEEPS_ITS_LENGTH, PERSON, PLACE, START, SWAP, THING, VIDEO_JOBS
from ._h3_prompt_helpers import find_beats, find_speech, split_sections, write_prompt
from ._h3_script_helpers import CAMERAS, LOOKS, script_report
from ._route_errors import safe_route_error
@@ -252,8 +252,9 @@ class AusBossH3PromptWriter:
{
"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."
"What the video is for: copy its moves, swap the person in it, keep it going, give it "
"a new voice, or change something in it and keep the person (a hat, a shirt). Swap, "
"new voice and change something ignore Seconds and keep the video's length."
),
},
),
@@ -286,7 +287,7 @@ class AusBossH3PromptWriter:
},
}
RETURN_TYPES = ("STRING", "IMAGE", "IMAGE", "IMAGE", "IMAGE", "IMAGE", "AUDIO", "AUDIO", "AUDIO", "AUDIO", "INT", "INT", "INT")
RETURN_TYPES = ("STRING", "IMAGE", "IMAGE", "IMAGE", "IMAGE", "IMAGE", "AUDIO", "AUDIO", "AUDIO", "AUDIO", "INT", "INT", "INT", "BOOLEAN")
RETURN_NAMES = (
"prompt",
"picture_1",
@@ -301,6 +302,7 @@ class AusBossH3PromptWriter:
"frames",
"width",
"height",
"keeps_clip_sound",
)
OUTPUT_TOOLTIPS = (
"The finished prompt. Wire it into H3's prompt.",
@@ -316,6 +318,7 @@ class AusBossH3PromptWriter:
"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.",
"On when the finished clip should keep your video's own sound: with clip_sound on, or always for change something.",
)
FUNCTION = "write"
@@ -323,6 +326,9 @@ class AusBossH3PromptWriter:
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="", clip_sound=False):
# A clip that keeps its own sound has no room for a score: the finished clip plays the video's sound.
if video is not None and (video_job == EDIT or (video_job == SWAP and clip_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}
@@ -334,6 +340,7 @@ class AusBossH3PromptWriter:
frames = video_frames(video.shape[0])
seconds = frames / FRAME_RATE
length = frames / FRAME_RATE
keeps_clip_sound = video is not None and (bool(clip_sound) or video_job == EDIT)
clip_sound = bool(clip_sound) and video_job == SWAP
speech = find_speech(mono_samples(video_sound), video_sound["sample_rate"]) if clip_sound and video_sound is not None else None
if video_job == KEEP:
@@ -365,7 +372,7 @@ class AusBossH3PromptWriter:
speech=speech,
)
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]))
return (text, *ordered, video, video_sound, voice, song, song_under, frames, *clip_shape(video, ordered[0]), keeps_clip_sound)
def _register_routes():
+26 -2
View File
@@ -69,7 +69,31 @@ class AusBossWorkflowNote:
return ()
NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_WorkflowNote": AusBossWorkflowNote}
NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_WorkflowNote": "Workflow Note 🆎"}
class AusBossWorkflowNoteTile(AusBossWorkflowNote):
DESCRIPTION = (
"A compact help button that opens a full workflow note. "
"Click to read; use the pencil to edit its instructions and model "
"links. Saved tiles require a pack that supports tiles, so an older "
"install can report the missing node. Nothing runs."
)
SEARCH_ALIASES = ["workflow note tile", "help button", "popout note", "ausboss"]
@classmethod
def INPUT_TYPES(cls):
inputs = super().INPUT_TYPES()
inputs["required"]["note"][1]["default"] = (
'{"title":"Workflow info","layout":"tile"}'
)
return inputs
NODE_CLASS_MAPPINGS = {
"AUSBOSS_NODES_WorkflowNote": AusBossWorkflowNote,
"AUSBOSS_NODES_WorkflowNoteTile": AusBossWorkflowNoteTile,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AUSBOSS_NODES_WorkflowNote": "Workflow Note 🆎",
"AUSBOSS_NODES_WorkflowNoteTile": "Workflow Note Tile 🆎",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+2 -2
View File
@@ -2,8 +2,8 @@
name = "ausboss-nodes"
# Manager/registry search matches against this text, so it names the actual
# nodes instead of describing the collection abstractly.
description = "ComfyUI-AusBoss (AusBoss nodes): image and video nodes with compact controls: Image and Video Crop + Rotate + Pad (frame or clip), Load Image + Pad, Image Folder (run a workflow on a folder of pictures), Image Resize, Align Image, Image Size, Color Match, Image Compare A/B, Realign to Source (experimental), Save Image (PNG, lossless WebP/JXL, captions), Load Video, Save Video, Select Frame, Frame Interpolate, Mask Refine, Mask by Name and Mask by Touch (type a name or click a thing, get its mask), LaMa Inpaint, Crop For Inpaint, Stitch Inpaint, Tiled Upscale and Tiled Upscale Stitch, LoRA Loader, Krea 2 Encode and Outpaint Model Patch, Latent Size, Layout Boxes (draw where things and words go), Seed, Select Every Nth, Split Batch, Merge Batches, Text, Integer, Float, Math Expression, Show Text, Free Memory, Workflow Note, Callout (a note with arrows), Workflow Switches, Run Timer, and Step Preview (stop bad runs). Example workflows for Qwen Image 2.1, Krea 2, Klein 9B and LTX 2.3."
version = "2.21.0"
description = "ComfyUI-AusBoss (AusBoss nodes): image and video nodes with compact controls: Image and Video Crop + Rotate + Pad (frame or clip), Load Image + Pad, Image Folder, Image Resize, Align Image, Image Size, Color Match, Image Compare A/B, Realign to Source (experimental), Save Image (PNG, WebP/JXL, captions), Load Video, Load Audio, Save Video, Select Frame, Frame Interpolate, Mask Refine, Mask by Name and Mask by Touch (type a name or click a thing, get its mask), LaMa Inpaint, Crop For Inpaint, Stitch Inpaint, Tiled Upscale and Tiled Upscale Stitch, LoRA Loader, Krea 2 Encode and Outpaint Model Patch, Latent Size, Layout Boxes, Seed, Select Every Nth, Split Batch, Merge Batches, Text, H3 Prompt Writer, Integer, Float, Math Expression, Show Text, Free Memory, Workflow Note, Workflow Note Tile, Callout, Callout Tile, Workflow Switches, Run Timer, and Step Preview (stop bad runs). Example workflows for Qwen Image 2.1, Krea 2, Klein 9B and LTX 2.3."
version = "2.21.1"
# comfy-cli serializes this to JSON verbatim, so `file` publishes the literal
# string {"file": "LICENSE"} to the registry listing. `text` puts a readable
# name there instead. Both forms are supported; the LICENSE file is unchanged.
+3
View File
@@ -119,6 +119,9 @@ for (const type of types) {
if (seen.error) problems.push(seen.error);
else {
if (!(seen.size[0] > 20 && seen.size[1] > 20)) problems.push(`the node's size is ${seen.size.join(" x ")}`);
if (["AUSBOSS_NODES_CalloutTile", "AUSBOSS_NODES_WorkflowNoteTile"].includes(type) && seen.size[1] > 120) {
problems.push("a new tile must start as a compact button, not a full-height note");
}
if (vue && !seen.drawn) problems.push("Nodes 2.0 drew no element for the node");
if (seen.drawn && !(seen.drawn[0] > 20 && seen.drawn[1] > 20)) problems.push(`the node is drawn ${seen.drawn.join(" x ")}`);
for (const panel of seen.panels) {
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Regenerate the repository's release badge from pyproject.toml."""
from pathlib import Path
import re
from changelog_notes import pyproject_version
ROOT = Path(__file__).resolve().parent.parent
BADGE_PATH = "assets/readme/release.svg"
def badge_svg(version: str) -> str:
if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version):
raise ValueError(f"invalid release version: {version!r}")
label = f"v{version}"
value_width = max(54, len(label) * 7 + 8)
width = 48 + value_width
center = 48 + value_width / 2
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="20" role="img" aria-label="release: {label}">
<title>release: {label}</title>
<path fill="#555" d="M0 0h48v20H0z"/>
<path fill="#00b4aa" d="M48 0h{value_width}v20H48z"/>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="24" y="14">release</text>
<text x="{center:g}" y="14">{label}</text>
</g>
</svg>
'''
if __name__ == "__main__":
target = ROOT / BADGE_PATH
target.write_text(badge_svg(pyproject_version()), encoding="utf-8")
print(f"Updated {BADGE_PATH}")
+12 -18
View File
@@ -9,9 +9,8 @@ Checks:
BOM breaks installs silently.
2. AUSBOSS_JS_VERSION in js/shared/index.mjs equals the pyproject
version, so the stale-browser warning never fires on a fresh install.
3. The README release badge reads GitHub Releases and links to the latest
release. The publish workflow creates it after the Registry upload.
The check guards against a static version or unsupported badge route.
3. The repository-hosted README badge matches pyproject.toml and links to
the latest GitHub Release. Its image needs no external badge service.
4. The Registry archive holds runtime files only. With git it is listed
the way comfy-cli packs it - tracked files minus those .comfyignore
matches - and every shipped path must sit under the runtime allowlist
@@ -40,6 +39,7 @@ import sys
from changelog_notes import notes_for
from registry_contract import git_paths
from release_badge import BADGE_PATH, badge_svg
from workflow_contract import example_problems
ROOT = pathlib.Path(__file__).resolve().parent.parent
@@ -98,25 +98,19 @@ if pyproject_version:
except (OSError, ValueError) as exc:
errors.append(f"no release notes for {pyproject_version}: {exc}")
# --- 3. the README release badge stays dynamic -------------------------------
DYNAMIC_BADGE = (
"img.shields.io/github/v/release/ausboss/ComfyUI-AusBoss?"
)
# --- 3. the README release badge matches the version -------------------------
try:
readme = (ROOT / "README.md").read_text(encoding="utf-8")
if DYNAMIC_BADGE not in readme:
errors.append(
"README.md release badge must use the shields.io GitHub release source"
)
if f'src="{BADGE_PATH}"' not in readme:
errors.append(f"README.md release badge must use {BADGE_PATH}")
if 'href="https://github.com/ausboss/ComfyUI-AusBoss/releases/latest"' not in readme:
errors.append("README.md must link to the latest GitHub Release")
if re.search(r"badge/release-\d+\.\d+\.\d+-", readme):
errors.append(
"README.md carries a hardcoded release-X.Y.Z badge; use the "
"dynamic badge only"
)
except OSError as exc:
errors.append(f"could not read README.md: {exc}")
if pyproject_version:
badge = (ROOT / BADGE_PATH).read_text(encoding="utf-8")
if badge != badge_svg(pyproject_version):
errors.append("release badge is stale; run python scripts/release_badge.py")
except (OSError, ValueError) as exc:
errors.append(f"could not validate the README release badge: {exc}")
# --- 4. the Registry archive holds runtime files only ------------------------
# comfy-cli packs `git ls-files` minus whatever .comfyignore matches, and git
+2
View File
@@ -51,6 +51,7 @@ errors = []
PUBLIC_NODE_IDS = {
"AUSBOSS_NODES_AlignImage",
"AUSBOSS_NODES_Callout",
"AUSBOSS_NODES_CalloutTile",
"AUSBOSS_NODES_ColorMatch",
"AUSBOSS_NODES_Compare",
"AUSBOSS_NODES_CropForInpaint",
@@ -94,6 +95,7 @@ PUBLIC_NODE_IDS = {
"AUSBOSS_NODES_VideoCropRotatePad",
"AUSBOSS_NODES_VideoCropRotatePadClip",
"AUSBOSS_NODES_WorkflowNote",
"AUSBOSS_NODES_WorkflowNoteTile",
"AUSBOSS_NODES_WorkflowSwitches",
}
+2 -2
View File
@@ -129,7 +129,7 @@ def workflow_problems(data: dict) -> list[str]:
groups = data.get("groups", [])
if not groups:
problems.append("no stage groups")
notes = [n for n in nodes if n["type"] == "AUSBOSS_NODES_WorkflowNote"]
notes = [n for n in nodes if n["type"] in {"AUSBOSS_NODES_WorkflowNote", "AUSBOSS_NODES_WorkflowNoteTile"}]
if not notes:
problems.append("no Workflow Note with setup instructions")
for n in notes:
@@ -160,7 +160,7 @@ def workflow_problems(data: dict) -> list[str]:
rectangles.append((n["id"], x, y - 30, x + width, y + height))
# A Callout in the Workflow Note's own column belongs to the note, not
# to a stage: a link to a video above it, say.
beside_note = n["type"] == "AUSBOSS_NODES_Callout" and any(
beside_note = n["type"] in {"AUSBOSS_NODES_Callout", "AUSBOSS_NODES_CalloutTile"} and any(
left <= x and x + width <= right + 1 for left, right in note_columns
)
if n not in notes and not beside_note and not any(
+32 -1
View File
@@ -6,11 +6,42 @@ import { fileURLToPath } from "node:url";
import {
ARROW_ANGLE, ARROW_CHOICES, SMALL_MARK, arrowText, fitFontSize, linkTarget, parseBlocks, pastedLink, repointLinks, targetId,
toggleSmall, urlLabel, wrapLink,
toggleSmall, urlLabel, wrapLink, tileLinkFields, tileLinkText,
} from "../js/shared/callout.mjs";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
test("link tile fields round-trip the existing saved link text", () => {
for (const value of [
"[Watch Me on YouTube](https://www.youtube.com/@ausbossdev)",
"[Join for Help or More Workflows](https://discord.gg/example)",
"[**Watch** this video](https://example.com/watch_(1))",
]) {
const fields = tileLinkFields(value);
assert.ok(fields);
assert.equal(tileLinkText(fields.label, fields.url), value);
}
});
test("mixed or multi-line tile content stays in the full text editor", () => {
for (const value of [
"Title", "See [video](https://example.com)",
"[video](https://example.com)\n\nKeep this note too",
"[first](https://a.com) [second](https://b.com)",
" [video](https://example.com) ",
"[node](#12)", "[bad](javascript:alert(1))",
]) assert.equal(tileLinkFields(value), null, value);
});
test("the tile form rejects labels or addresses that would lose the link", () => {
for (const [label, url] of [
["", "https://example.com"], ["a [label]", "https://example.com"],
["two\nlines", "https://example.com"], ["video", "javascript:alert(1)"],
["video", "https://example.com/with a space"],
]) assert.throws(() => tileLinkText(label, url));
assert.equal(tileLinkText("Watch", " https://example.com "), "[Watch](https://example.com)");
});
test("a blank line starts a new point and ## is ignored", () => {
const blocks = parseBlocks("## One\nsecond line\n\n\n## Two");
assert.equal(blocks.length, 2);
+2 -1
View File
@@ -278,11 +278,12 @@ function stopsEveryKey({ event, body }) {
return stop >= 0 && !/\breturn\b/.test(top.slice(0, stop));
}
test("no keydown handler stops every key, outside the two dialogs that own the keyboard", () => {
test("no keydown handler stops every key, outside dialogs that own the keyboard", () => {
// A handler that stops a key before looking at it also stops Ctrl + Enter,
// so the workflow does not run from that box. A box uses keepKeyInField.
const js = join(dirname(fileURLToPath(import.meta.url)), "..", "js");
const ownTheKeyboard = [
"callout/index.js dialog", // the tile editor: Ctrl + Enter saves the text
"shared/discard_prompt.mjs backdrop", // the keep or discard question
"workflow_note/index.js overlay", // the note editor: Ctrl + Enter saves the note
];
+18
View File
@@ -33,6 +33,13 @@
"optional": [],
"outputs": []
},
"AUSBOSS_NODES_CalloutTile": {
"required": [
"text"
],
"optional": [],
"outputs": []
},
"AUSBOSS_NODES_ColorMatch": {
"required": [
"image",
@@ -235,6 +242,10 @@
[
"INT",
"height"
],
[
"BOOLEAN",
"keeps_clip_sound"
]
]
},
@@ -1180,6 +1191,13 @@
"optional": [],
"outputs": []
},
"AUSBOSS_NODES_WorkflowNoteTile": {
"required": [
"note"
],
"optional": [],
"outputs": []
},
"AUSBOSS_NODES_WorkflowSwitches": {
"required": [],
"optional": [],
+116
View File
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { clipSetsDuration, linkSource } from '../js/shared/h3_duration.mjs';
const graphWith = (mode = 0, useMap = false) => {
const source = { id: 150, mode };
const link = { origin_id: 150 };
return { source, node: { inputs: [{name:'video', link:8}], graph: {
...(useMap ? {_links:new Map([[8,link]])} : {links:{8:link}}),
getNodeById:id=>id===150?source:null,
} } };
};
test('only the three backend duration overrides use the clip', () => {
const {node}=graphWith();
for(const job of ['swap the person','new voice','change something']) assert.equal(clipSetsDuration(node,{video_job:job}),true);
for(const job of ['copy its moves','keep going','',undefined]) assert.equal(clipSetsDuration(node,{video_job:job}),false);
});
test('Full Video OFF/bypassed/missing/disconnected restore seconds; both link stores work', () => {
for(const map of [false,true]) {
const {node,source}=graphWith(0,map);
assert.equal(linkSource(node,'video'),source);
const values={video_job:'swap the person',seconds:12};
for(const mode of [2,4]) {source.mode=mode;assert.equal(clipSetsDuration(node,values),false);}
source.mode=0;assert.equal(clipSetsDuration(node,values),true);
node.graph.getNodeById=()=>null;assert.equal(clipSetsDuration(node,values),false);
node.inputs[0].link=null;assert.equal(clipSetsDuration(node,values),false);
assert.deepEqual(values,{video_job:'swap the person',seconds:12});
}
});
test('the predicate preserves widgets, socket IDs and literal linked Length', () => {
const {node}=graphWith();node.widgets=[{name:'seconds',value:12}];
node.inputs.push({name:'seconds',link:19,widget:{name:'seconds'}});
const before=JSON.stringify(node, (k,v)=>k==='graph'?undefined:v);
clipSetsDuration(node,{video_job:'new voice',seconds:12});
assert.equal(JSON.stringify(node,(k,v)=>k==='graph'?undefined:v),before);
});
// Run the actual timeline module with only host/DOM boundaries replaced. The
// request cancellation, live wire checks and transition logic remain real.
class Element {
constructor(){this.children=[];this.style={setProperty(){}};this.classList={toggle(){},remove(){},add(){}};this._text='';}
append(...children){this.children.push(...children);}
set textContent(text){this._text=String(text);this.children=[];}
get textContent(){return this._text+this.children.map(c=>c.textContent??'').join('');}
addEventListener(){}
}
test('timeline omits guessed duration/retiming and rejects an old async report; old timeline returns on Video OFF', async()=>{
const realDocument=globalThis.document;
globalThis.document={getElementById:()=>true,createElement:()=>new Element()};
const timers=[];const intervals=[];const requests=[];const pending=[];
globalThis.__h3DurationTest={
api:{fetchApi:async(_url,opts)=>{requests.push(JSON.parse(opts.body));return await new Promise(resolve=>pending.push(resolve));}},
app:{},chainCallback(){},
attachScriptColours:()=>({area:null,layer:null,sync(){},dispose(){}}),
attachScriptAssist:()=>({dispose(){}}),
setTimeout:fn=>{timers.push(fn);return fn;},clearTimeout:fn=>{const i=timers.indexOf(fn);if(i>=0)timers.splice(i,1);},
setInterval:fn=>{intervals.push(fn);return fn;},clearInterval(){},
};
try {
let source=readFileSync(new URL('../js/shared/moment_strip.mjs',import.meta.url),'utf8');
source=source.replace(/^import .*;$/gm,line=>{
if(line.includes('h3_duration.mjs')||line.includes('moment_strip_math.mjs')||line.includes('widget_card_math.mjs'))
return line.replace(/"\.\/(.*?)"/,(_m,file)=>JSON.stringify(new URL('../js/shared/'+file,import.meta.url).href));
if(line.includes('api.js'))return 'const {api}=globalThis.__h3DurationTest;';
if(line.includes('app.js'))return 'const {app}=globalThis.__h3DurationTest;';
if(line.includes('index.mjs'))return 'const BRAND="#0ba", {chainCallback}=globalThis.__h3DurationTest;';
if(line.includes('script_assist.mjs'))return 'const {attachScriptAssist}=globalThis.__h3DurationTest;';
if(line.includes('script_colours.mjs'))return 'const {attachScriptColours}=globalThis.__h3DurationTest;';
throw new Error('Unmocked host import '+line);
});
source='const {setTimeout,clearTimeout,setInterval,clearInterval}=globalThis.__h3DurationTest;\n'+source;
const {momentStrip}=await import('data:text/javascript;base64,'+Buffer.from(source).toString('base64'));
const {node,source:video}=graphWith();let refreshes=0;node.__ausbossCard={refresh(){refreshes++;}};
const panel=momentStrip(node), [ruler,track,note]=panel.element.children;
const values={seconds:12,script:'She walks.',video_job:'copy its moves'};
panel.sync(values);assert.equal(timers.length,1);timers.shift()();await Promise.resolve();
assert.equal(requests[0].seconds,12);
panel.sync({...values,video_job:'swap the person'});
assert.equal(ruler.children.length,0);assert.equal(track.textContent,'Length comes from the clip');
assert.doesNotMatch(panel.element.textContent,/12|one take/);assert.equal(timers.length,0);
pending.shift()({ok:true,json:async()=>({seconds:12,moments:[{start:0,end:12,text:'She walks',line:0}]})});
await new Promise(resolve=>setImmediate(resolve));
assert.equal(track.textContent,'Length comes from the clip');assert.ok(refreshes>0);
video.mode=4;intervals[0]();assert.equal(timers.length,1);
timers.shift()();await Promise.resolve();assert.equal(requests.at(-1).seconds,12);
assert.equal(requests.at(-1).video,'');
pending.shift()({ok:true,json:async()=>({seconds:12,moments:[{start:0,end:12,text:'She walks',line:0}]})});
await new Promise(resolve=>setImmediate(resolve));
assert.match(note.textContent,/12 s/);assert.ok(ruler.children.length>0);
} finally {globalThis.document=realDocument;delete globalThis.__h3DurationTest;}
});
// Exercise the actual numeric-row builder with scrub/DOM boundaries stubbed;
// this catches a read-only display that overwrites the serialized widget.
test('Length row replaces the whole number+unit, preserves its value, and restores its control',()=>{
const code=readFileSync(new URL('../js/shared/widget_card.mjs',import.meta.url),'utf8');
const start=code.indexOf('const buildScrub = ')+ 'const buildScrub = '.length;
const end=code.indexOf('\n const buildSegment',start);
const body=code.slice(start,end).trim().replace(/;$/,'');
const {node,source}=graphWith();node.widgets=[{name:'seconds',value:12,options:{min:5,max:15}}];
const scrub={root:{style:{}},set(value){this.shown=value;}};
const makeElement=()=>({style:{},setAttribute(name,value){this[name]=value;}});
const into={children:[],append(x){this.children.push(x);}};
const builder=new Function('node','findWidget','isFloatWidget','scrubSteps','makeScrubInput','UNIT_SLOT_WIDTH','el','setWidget', 'return '+body)(
node,()=>node.widgets[0],()=>false,()=>({step:1,decimals:0}),()=>scrub,24,makeElement,()=>{throw Error('read-only display wrote widget');}
);
const row={title:'Length',readOnlyText:(n,v)=>clipSetsDuration(n,v)?'From the clip':'',readOnlyTitle:'From incoming clip',suffix:'s'};
const sync=builder('seconds',row,into);
const values={seconds:12,video_job:'swap the person'};
sync(values);assert.equal(scrub.root.style.display,'none');assert.equal(into.children[1].textContent,'From the clip');
assert.equal(into.children[1].role,'status');assert.equal(node.widgets[0].value,12);
for(const mode of [2,4]){source.mode=mode;sync(values);assert.equal(scrub.root.style.display,'');assert.equal(into.children[1].style.display,'none');assert.equal(scrub.shown,12);}
source.mode=0;sync({...values,video_job:'keep going'});assert.equal(scrub.root.style.display,'');
assert.equal(node.widgets[0].value,12);assert.equal(node.inputs[0].link,8);
});
+32
View File
@@ -0,0 +1,32 @@
import test from "node:test";
import assert from "node:assert/strict";
import { CALLOUT_TYPES, NOTE_TYPES, serializeNoteIdentity } from "../js/shared/note_identity.mjs";
for (const family of [CALLOUT_TYPES, NOTE_TYPES]) {
const [plain, tile] = [...family];
for (const source of family) {
for (const enabled of [false, true]) {
test(`${source}: ${enabled ? "tile" : "plain"} serialization declares its renderer`, () => {
const properties = { "Node name for S&R": source, cnr_id: "ausboss-nodes", ausbossCalloutTile: "youtube" };
const saved = { id: 7, type: source, properties, mode: 0, size: [270, 96], widgets_values: ['unchanged\ntext'], inputs: [], outputs: [] };
const before = structuredClone(saved);
serializeNoteIdentity(saved, enabled);
assert.equal(saved.type, enabled ? tile : plain);
assert.equal(saved.properties["Node name for S&R"], saved.type);
assert.equal(saved.properties.cnr_id, "ausboss-nodes");
assert.deepEqual(properties, before.properties, "serialization cannot mutate live properties");
assert.deepEqual({ ...saved, type: source, properties: before.properties }, before);
assert.equal(new Set([plain]).has(saved.type), !enabled, "old pack must not pretend to support a tile");
assert.equal(new Set().has(saved.type), false);
assert.equal(family.has(saved.type), true);
});
}
}
}
test("unrelated nodes are not rewritten", () => {
const saved = { type: "OtherNote", properties: { layout: "tile" } };
const before = structuredClone(saved);
serializeNoteIdentity(saved, true);
assert.deepEqual(saved, before);
});
+70 -7
View File
@@ -14,6 +14,7 @@ if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
del sys.modules["nodes"]
from nodes._h3_prompt_helpers import (
put_on_from_pictures,
NO_SCRIPT,
VOICE_NO_WORDS,
add_task,
@@ -638,6 +639,67 @@ class SoundWordTests(unittest.TestCase):
class VideoJobTests(unittest.TestCase):
def test_change_something_keeps_the_video_person_and_changes_only_the_named_thing(self):
for job in ("outfit", "thing"):
text = write_prompt("He wears @1 in place of his blue cap.", seconds=8.0, jobs={1: job}, video="change something")
self.assertIn("<Subject 2> is the person in <Video 1>, kept as they are", text)
self.assertIn("[video editing] <Subject 2> wears <Subject 1> in place of his blue cap. Everything else in <Video 1> is kept.", text)
self.assertIn("Retain the look of <Video 1>.", text)
self.assertIn("<Subject 2> has the same words, mouth movements and moves as in <Video 1>.", text)
self.assertIn("The original sound of <Video 1> continues throughout.", text)
self.assertNotIn("selected for replacement", text)
self.assertNotIn("No other words are spoken.", text)
def test_change_something_reads_a_picture_left_as_person_as_what_he_puts_on(self):
# A newcomer leaves Picture 1 on its opening job, person, and types plainly.
for script in ("He wears @1 in place of his blue cap.", "He wears the propeller hat instead of his cap.",
"He is wearing a @1 now.", "He puts on the red scarf."):
text = write_prompt(script, seconds=8.0, jobs={1: "person"}, video="change something")
self.assertIn("<Subject 1> is the outfit in <Picture 1>.", text)
self.assertIn("put only this outfit on <Subject 2>", text)
self.assertNotIn("<Subject 1> is the person in <Picture 1>", text)
text = write_prompt("He wears the propeller hat instead of his cap.", seconds=8.0, jobs={1: "person"}, video="change something")
self.assertIn("[video editing] <Subject 2> wears <Subject 1> instead of his cap.", text)
def test_change_something_keeps_a_person_picture_that_is_not_put_on(self):
self.assertEqual(put_on_from_pictures("@1 stands next to him.", {1: "person"}), ("@1 stands next to him.", {1: "person"}))
self.assertEqual(put_on_from_pictures("He waves.", {1: "person"}), ("He waves.", {1: "person"}))
# Two pictures and no @ typed: nothing is guessed.
self.assertEqual(put_on_from_pictures("He wears a hat.", {1: "person", 2: "person"}), ("He wears a hat.", {1: "person", 2: "person"}))
self.assertEqual(put_on_from_pictures("He wears @2 and waves at @1.", {1: "person", 2: "person"})[1], {1: "person", 2: "outfit"})
def test_change_something_keeps_the_clip_sound_with_the_switch_off(self):
node = AusBossH3PromptWriter()
frames = torch.zeros(40, 8, 8, 3)
hat = torch.zeros(1, 8, 8, 3)
self.assertTrue(node.write("He wears @1.", 6, picture_1=hat, video=frames, video_job="change something")[-1])
self.assertFalse(node.write("@1 dances.", 6, picture_1=hat, video=frames, video_job="copy its moves")[-1])
self.assertTrue(node.write("@1 dances.", 6, picture_1=hat, video=frames, video_job="copy its moves", clip_sound=True)[-1])
self.assertFalse(node.write("@1 dances.", 6, picture_1=hat, clip_sound=True)[-1])
def test_a_clip_that_keeps_its_sound_gets_no_score_from_the_music_box(self):
node = AusBossH3PromptWriter()
frames = torch.zeros(40, 8, 8, 3)
hat = torch.zeros(1, 8, 8, 3)
score = "a dark score with deep drum hits"
edit = node.write("He wears @1.", 6, picture_1=hat, video=frames, video_job="change something", music=score)[0]
swap = node.write("", 6, picture_1=hat, video=frames, video_job="swap the person", clip_sound=True, music=score)[0]
moves = node.write("@1 dances.", 6, picture_1=hat, video=frames, video_job="copy its moves", music=score)[0]
self.assertNotIn("dark score", edit)
self.assertNotIn("dark score", swap)
self.assertIn("dark score", moves.lower())
def test_change_something_with_nothing_typed_says_what_to_type(self):
with self.assertRaises(ValueError) as caught:
write_prompt("", seconds=8.0, jobs={1: "outfit"}, video="change something")
self.assertIn("Type what changes in the video", str(caught.exception))
def test_a_hat_with_no_person_picture_points_to_change_something(self):
for job in ("outfit", "thing"):
for video in ("swap the person", "copy its moves"):
cast = Cast(number_pictures({1: job}, video), video)
self.assertIn('pick the video job "change something"', cast.problem("He wears @1."))
def test_copying_moves_keeps_the_performer_out(self):
parts = parts_of("@1 dances on a rooftop.", jobs={1: "person"}, video="copy its moves")
self.assertIn("<Subject 2> is the body motion in <Video 1>", parts["subject_definitions"])
@@ -1049,7 +1111,8 @@ class NodeTests(unittest.TestCase):
def test_connected_means_on(self):
node = AusBossH3PromptWriter()
text, *pictures, video, video_sound, voice, song, under, frames, width, height = node.write(LINES, 6, picture_1=PICTURE)
text, *pictures, video, video_sound, voice, song, under, frames, width, height, keeps = node.write(LINES, 6, picture_1=PICTURE)
self.assertFalse(keeps)
self.assertIs(pictures[0], PICTURE)
self.assertEqual(pictures[1:], [None, None, None])
self.assertIsNone(video)
@@ -1092,7 +1155,7 @@ class NodeTests(unittest.TestCase):
def test_with_a_voice_the_song_goes_under_the_clip(self):
audio = self.audio(30.0)
text, *_, voice, song, under, _, _, _ = AusBossH3PromptWriter().write(SAYS, 10, picture_1=PICTURE, voice=self.audio(), song=audio)
text, *_, voice, song, under, _, _, _, _ = AusBossH3PromptWriter().write(SAYS, 10, picture_1=PICTURE, voice=self.audio(), song=audio)
self.assertNotIn("<Audio 2>", text)
self.assertIsNotNone(voice)
self.assertIsNone(song)
@@ -1114,7 +1177,7 @@ class NodeTests(unittest.TestCase):
self.assertLess(self.under_level(0.01), 0.01)
def test_without_a_voice_the_song_goes_to_h3(self):
text, *_, song, under, _, _, _ = AusBossH3PromptWriter().write(LINES, 6, picture_1=PICTURE, song=self.audio())
text, *_, song, under, _, _, _, _ = AusBossH3PromptWriter().write(LINES, 6, picture_1=PICTURE, song=self.audio())
self.assertIsNotNone(song)
self.assertIsNone(under)
self.assertIn("<Audio 1>: fully_copy", text)
@@ -1132,7 +1195,7 @@ class NodeTests(unittest.TestCase):
def test_a_pasted_prompt_needs_nothing_connected(self):
output = AusBossH3PromptWriter().write(SIX, 6)
self.assertEqual(output[0], SIX)
self.assertEqual(output[-2:], (1344, 756))
self.assertEqual(output[-3:-1], (1344, 756))
def test_a_video_comes_out_with_its_job(self):
frames = torch.zeros(100, 8, 8, 3)
@@ -1162,9 +1225,9 @@ class NodeTests(unittest.TestCase):
tall = torch.zeros(1, 736, 560, 3)
wide = torch.zeros(1, 480, 832, 3)
video = torch.zeros(100, 720, 1280, 3)
self.assertEqual(node.write("@1 sits in @2.", 6, picture_1=tall, picture_2=wide, job_2="place")[-2:], (560, 736))
self.assertEqual(node.write("@2 waves.", 6, picture_2=wide, job_2="person")[-2:], (832, 480))
self.assertEqual(node.write("@1 dances.", 6, picture_1=tall, video=video)[-2:], (1280, 720))
self.assertEqual(node.write("@1 sits in @2.", 6, picture_1=tall, picture_2=wide, job_2="place")[-3:-1], (560, 736))
self.assertEqual(node.write("@2 waves.", 6, picture_2=wide, job_2="person")[-3:-1], (832, 480))
self.assertEqual(node.write("@1 dances.", 6, picture_1=tall, video=video)[-3:-1], (1280, 720))
def test_a_short_clip_keeps_all_it_has(self):
frames = torch.zeros(20, 2, 2, 3)
+106
View File
@@ -0,0 +1,106 @@
"""Opt-in replacement descriptions; old selectors/ports remain unchanged."""
from pathlib import Path
import sys
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import torch
from nodes._h3_cast_helpers import swap_appearances
from nodes._h3_prompt_helpers import split_sections, write_prompt
from nodes._h3_script_helpers import script_report
from nodes.node_h3_prompt_writer import AusBossH3PromptWriter
LOOK = 'a woman with pale skin, shoulder-length platinum-blonde hair, a black oversized hoodie, neck tattoos and layered silver necklaces with a cross pendant'
SELECTOR = 'the woman with black hair walking toward the camera'
SCRIPT = SELECTOR + '\n@1 looks like ' + LOOK + '.'
class SwapAppearanceTests(unittest.TestCase):
def parts(self, script=SCRIPT, jobs=None, **kw):
return split_sections(write_prompt(script, jobs={1: 'person'} if jobs is None else jobs, video='swap the person', **kw))
def test_description_does_not_become_the_original_selector(self):
parts = self.parts()
self.assertIn(f'<Subject 1> is {SELECTOR} in <Video 1>', parts['subject_definitions'])
self.assertIn(f'<Subject 2> is the person in <Picture 1>, with the same face and this appearance: {LOOK}', parts['subject_definitions'])
self.assertNotIn('looks like', parts['subject_definitions'])
shot = parts['detailed_description']
self.assertIn(LOOK, shot)
self.assertIn('same route, movements, timing and pace', shot)
self.assertIn('no transition or morph', shot)
self.assertEqual(shot.count('[Shot 1]'), 1)
def test_blank_selector_still_selects_the_original_person(self):
p = self.parts('@1 looks like a woman with red hair.')
self.assertIn('<Subject 1> is the original person in <Video 1>', p['subject_definitions'])
self.assertIn('Show one person throughout.', p['detailed_description'])
def test_two_people_keep_their_selectors_and_separate_looks(self):
text = '@1 the man on the left\n@2 the woman on the right\n@2 looks like a woman with red hair.\n@1 looks like a man in a black jacket.'
p = self.parts(text, jobs={1: 'person', 2: 'person'})
d = p['subject_definitions']
self.assertIn('<Subject 1> is the man on the left in <Video 1>', d)
self.assertIn('<Subject 2> is the woman on the right in <Video 1>', d)
self.assertIn('<Subject 3> is the person in <Picture 1>, with the same face and this appearance: a man in a black jacket', d)
self.assertIn('<Subject 4> is the person in <Picture 2>, with the same face and this appearance: a woman with red hair', d)
self.assertIn('Everyone else stays as they are', p['detailed_description'])
def test_visible_slot_binds_to_compact_picture_ordinal(self):
p = self.parts('@4 looks like a woman in a blue coat.', jobs={1: 'place', 4: 'person'})
self.assertIn('<Subject 3> is the person in <Picture 2>, with the same face and this appearance: a woman in a blue coat', p['subject_definitions'])
self.assertNotIn('from <Picture 4>', p['subject_definitions'])
def test_character_sheet_can_supply_the_replacement(self):
p = self.parts('@1 looks like a robot with a yellow body.', jobs={1: 'character sheet'})
self.assertIn('this appearance: a robot with a yellow body', p['subject_definitions'])
self.assertIn('without the panels', p['detailed_description'])
def test_missing_picture_and_wrong_role_are_clear_errors(self):
for jobs, phrase in [({}, 'not connected'), ({1: 'place'}, 'person or character sheet')]:
with self.assertRaisesRegex(ValueError, phrase):
self.parts('@1 looks like a woman in a blue coat.', jobs=jobs)
def test_duplicate_or_empty_description_is_not_silently_used(self):
for text in ['@1 looks like .', '@1 looks like a woman.\n@1 looks like another woman.']:
with self.assertRaises(ValueError):
self.parts(text)
def test_anchored_line_only_and_other_jobs_are_untouched(self):
for text in ['Put @1 looks like a friend here.', '@1 looks like a woman and says "Hello".']:
self.assertEqual(swap_appearances(text, {1: 'person'}, 'swap the person'), (text, {}))
for job in ['', 'copy its moves', 'change something', 'keep going']:
self.assertEqual(swap_appearances(SCRIPT, {1: 'person'}, job), (SCRIPT, {}))
legacy = '\r\n @1 replaces the man in the hat.\r\n'
self.assertEqual(swap_appearances(legacy, {1: 'person'}, 'swap the person'), (legacy, {}))
def test_description_is_not_a_timed_action_in_the_report(self):
r = script_report(SCRIPT, 6, {1: 'person'}, 'swap the person')
self.assertEqual(len(r['moments']), 1)
self.assertEqual(r['moments'][0]['text'].lower(), SELECTOR)
self.assertFalse(r['warning'])
def test_spoken_words_survive_the_separate_description(self):
p = self.parts('@1 the man on the left\n@1 looks like a man with red hair.\n@1 says "We are late."')
self.assertIn('<d>[English] We are late.</d>', p['detailed_description'])
self.assertNotIn('red hair', p['retention_analysis'])
def test_appearance_hint_does_not_contradict_keep_clip_clothes(self):
with self.assertRaisesRegex(ValueError, 'replaces the whole look'):
self.parts(SCRIPT + '\nkeep the original clothes')
def test_node_sound_on_off_changes_no_reference_or_output_contract(self):
picture = torch.zeros((1, 8, 8, 3))
video = torch.zeros((124, 8, 8, 3))
audio = {'sample_rate': 16000, 'waveform': torch.zeros((1, 1, 16000))}
for keep in (False, True):
result = AusBossH3PromptWriter().write(SCRIPT, 12, picture_1=picture, video=video, video_sound=audio, video_job='swap the person', clip_sound=keep, sound='footsteps synchronized with the walk and quiet rooftop ambience')
self.assertEqual(len(result), 14)
self.assertIs(result[1], picture)
self.assertIs(result[5], video)
self.assertIsNone(result[6])
self.assertEqual(result[10], 124)
self.assertEqual(result[13], keep)
self.assertIn(LOOK, result[0])
self.assertIn('footsteps synchronized with the walk', result[0].lower())
if __name__ == '__main__':
unittest.main()
+39
View File
@@ -0,0 +1,39 @@
"""The renderer identities keep the legacy data contract intact."""
import importlib.util
import json
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[1]
def load(name):
spec = importlib.util.spec_from_file_location(name, ROOT / "nodes" / f"{name}.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class TileContractTests(unittest.TestCase):
def test_callout_preserves_storage_and_no_execution(self):
module = load("node_callout")
plain, tile = module.AusBossCallout, module.AusBossCalloutTile
self.assertEqual(plain.INPUT_TYPES(), tile.INPUT_TYPES())
self.assertEqual(tile.RETURN_TYPES, ())
self.assertFalse(getattr(tile, "OUTPUT_NODE", False))
self.assertEqual(tile().noop("exact\ntext"), ())
def test_note_defaults_are_independent_and_tile_is_explicit(self):
module = load("node_workflow_note")
plain, tile = module.AusBossWorkflowNote, module.AusBossWorkflowNoteTile
original = plain.INPUT_TYPES()
tile_inputs = tile.INPUT_TYPES()
self.assertEqual(list(original["required"]), list(tile_inputs["required"]))
self.assertEqual(json.loads(tile_inputs["required"]["note"][1]["default"])["layout"], "tile")
self.assertEqual(plain.INPUT_TYPES(), original)
self.assertEqual(tile.RETURN_TYPES, ())
self.assertFalse(getattr(tile, "OUTPUT_NODE", False))
if __name__ == "__main__":
unittest.main()
+9
View File
@@ -26,6 +26,13 @@ class WorkflowContractTests(unittest.TestCase):
def test_complete_graph(self):
self.assertEqual(workflow_problems(good_graph()), [])
def test_a_tile_can_hold_the_setup_instructions(self):
graph = good_graph()
graph["nodes"][2]["type"] = "AUSBOSS_NODES_WorkflowNoteTile"
self.assertEqual(workflow_problems(graph), [])
graph["nodes"][2]["widgets_values"] = ['{"title":"Example","layout":"tile"}']
self.assertIn("needs a title and instructions", " ".join(workflow_problems(graph)))
def test_named_widget_copy_must_match_the_loaded_values(self):
graph = good_graph()
graph["nodes"][0]["widgets_values"] = [7, "fixed", ""]
@@ -108,6 +115,8 @@ class WorkflowContractTests(unittest.TestCase):
graph["nodes"].append({"id": 4, "type": "AUSBOSS_NODES_Callout", "pos": [-200, -60], "size": [100, 60],
"widgets_values": ["[Watch the video](https://example.invalid/)"]})
self.assertEqual(workflow_problems(graph), [])
graph["nodes"][3]["type"] = "AUSBOSS_NODES_CalloutTile"
self.assertEqual(workflow_problems(graph), [])
# anywhere else outside the stage groups it is still a stray node
graph["nodes"][3]["pos"] = [600, -60]
self.assertIn("node 4 is outside its stage", " ".join(workflow_problems(graph)))
+105
View File
@@ -0,0 +1,105 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
function decorativeCss(titleHeight = 30) {
const source = readFileSync(new URL("../js/shared/tile_face.mjs", import.meta.url), "utf8")
.replace(/^import .*;\n/, "").replaceAll("export ", "");
const context = vm.createContext({ LiteGraph: { NODE_TITLE_HEIGHT: titleHeight } });
vm.runInContext(`${source}\n;globalThis.css = TILE_FACE_CSS;`, context);
return context.css;
}
test("Vue tiles discard the hidden title reservation while keeping the saved height", () => {
for (const titleHeight of [30, 42]) {
const css = decorativeCss(titleHeight);
const rule = css.match(/\.lg-node:has\(\.ausboss-tile-face\):not\(\[data-collapsed\]\)\{([^}]+)\}/)?.[1];
assert.ok(rule.includes(`min-height:calc(var(--node-height) - ${titleHeight}px);`));
assert.ok(rule.includes(`translate:0 ${titleHeight}px;`));
// LGraphNode.vue sets --node-height to saved height + native title.
// Both a small info tile and a tall decorative face keep that saved size.
for (const saved of [78, 88, 520]) {
const frontendHeight = saved + titleHeight;
assert.equal(frontendHeight - titleHeight, saved);
}
}
});
test("only decorative Vue bodies release badge space, without hiding the last widget", () => {
const css = decorativeCss();
assert.ok(css.includes('.lg-node:has(.ausboss-tile-face,.ausboss-callout) [data-testid^="node-body-"]>.mt-auto{display:none;}'));
assert.ok(!css.includes("div:last-child"), "badge-free tiles must not lose their widget body");
assert.ok(!css.includes("font-size"), "fix the reserved space rather than reducing legible type");
});
// Exercise the real pointer/key handlers without a ComfyUI server. The mock
// supplies only the DOM and canvas methods used by the shared tile face.
function fixture({ size = [300, 80], floor = [210, 94], scale = 1 } = {}) {
class Element extends EventTarget {
classList = { toggle() {} };
children = [];
append(child) { this.children.push(child); }
querySelector() { return this; }
setAttribute() {}
setPointerCapture() {}
remove() { this.removed = true; }
}
const root = new Element();
let starts = 0, ends = 0;
const app = { canvas: { ds: { scale }, selectNode() {} } };
const node = {
size, pos: [20, 30], flags: {},
computeSize: () => floor,
setSize(next) { this.size = [...next]; },
graph: { beforeChange() { starts++; }, afterChange() { ends++; } },
};
const source = readFileSync(new URL("../js/shared/tile_face.mjs", import.meta.url), "utf8")
.replace(/^import .*;\n/, "").replaceAll("export ", "");
const context = vm.createContext({ app, AbortController, document: { createElement: () => new Element() } });
vm.runInContext(source, context);
context.setTileFace(node, root, true);
const grip = root.children[0];
const send = (type, values = {}) => {
const event = new Event(type, { cancelable: true });
Object.assign(event, { button: 0, pointerId: 1, clientX: 0, clientY: 0, ...values });
grip.dispatchEvent(event);
};
return { node, root, grip, send, app, disable: () => context.setTileFace(node, root, false), changes: () => [starts, ends] };
}
test("the visible tile grip resizes at canvas zoom without moving the tile", () => {
const f = fixture({ scale: 2 });
f.send("pointerdown", { clientX: 100, clientY: 100 });
f.send("pointermove", { clientX: 220, clientY: 140 });
f.send("pointerup");
assert.deepEqual(f.node.size, [360, 100]);
assert.deepEqual(f.node.pos, [20, 30]);
assert.deepEqual(f.changes(), [1, 1]);
});
test("resizing keeps a small saved tile steady and clamps at its native floor", () => {
const f = fixture();
f.send("pointerdown");
f.send("pointermove");
assert.deepEqual(f.node.size, [300, 80], "a saved tile below the current native floor must not jump");
f.send("pointermove", { clientX: -1000, clientY: -1000 });
assert.deepEqual(f.node.size, [210, 80]);
f.send("pointercancel");
assert.deepEqual(f.changes(), [1, 1]);
});
test("keyboard resize supports fine steps, respects pinning and removes its handle on plain cards", () => {
const f = fixture();
f.send("keydown", { key: "ArrowRight", shiftKey: true });
assert.deepEqual(f.node.size, [301, 80]);
f.node.pinned = true;
f.send("keydown", { key: "ArrowRight" });
f.send("pointerdown");
f.send("pointermove", { clientX: 100 });
f.send("pointerup");
assert.deepEqual(f.node.size, [301, 80]);
assert.deepEqual(f.changes(), [1, 1]);
f.disable();
assert.equal(f.grip.removed, true);
});
+32
View File
@@ -308,3 +308,35 @@ test("note formatting leaves old prose, unsafe markup and code literal", () => {
assert.equal(parseInline("`[amber]warning[/amber]`")[0].type, "code");
assert.equal(parseMarkdown("```\n|a|\n|---|\n```")[0].type, "code");
});
// Exercise the actual browser fitter with measured layout inputs. Keeping the
// DOM read behind a stub also proves a tile never measures its dialog content.
const noteFrontend = readFileSync(new URL("../js/workflow_note/index.js", import.meta.url), "utf-8");
const fitNoteSource = noteFrontend.slice(noteFrontend.indexOf("function fitNote(state) {"), noteFrontend.indexOf("// Every list widget", noteFrontend.indexOf("function fitNote(state) {")));
const runFitNote = new Function("classicCanvas", "naturalHeight", "noteFit", "WIDGET_FRAME", "PANEL_TOP", `${fitNoteSource}; return fitNote;`);
test("tile fitting preserves default and saved heights despite a stale widget position", () => {
const fit = runFitNote(() => true, () => 50, noteFit, 8, 2);
for (const height of [80, 110, 160]) {
const state = { note: { layout: "tile" }, fitHeight: 284,
node: { size: [380, height], setSize: () => assert.fail("tile fitting must preserve its size") },
root: { isConnected: true }, domWidget: { computedHeight: 50, y: 226 } };
fit(state);
assert.equal(state.fitHeight, undefined, "a tile must not retain a full note's resize floor");
assert.equal(state.node.size[1], height);
}
});
test("card and banner fitting still grows to measured content and keeps taller saved sizes", () => {
const fit = runFitNote(() => true, () => 300, noteFit, 8, 2);
for (const layout of ["card", "banner"]) {
for (const height of [80, 500]) {
const state = { note: { layout }, node: { size: [380, height],
setSize(size) { this.size = size; }, setDirtyCanvas() {} },
root: { isConnected: true }, domWidget: { computedHeight: 50, y: 2 } };
fit(state);
assert.equal(state.fitHeight, 310);
assert.equal(state.node.size[1], Math.max(height, 310));
}
}
});
+33 -8
View File
@@ -252,11 +252,34 @@ test("the card lists the made switches first, as made, then the listed groups in
assert.deepEqual(listedRows(undefined, undefined, DEFAULT_SETTINGS), []);
});
test("a click anywhere on the pill flips it; a mixed row goes to the half that was clicked", () => {
assert.deepEqual([flipTo("on", true), flipTo("on", false)], [false, false]);
assert.deepEqual([flipTo("off", true), flipTo("off", false)], [true, true]);
assert.deepEqual([flipTo("mixed", true), flipTo("mixed", false)], [true, false]);
assert.deepEqual([flipTo("empty", true), flipTo(undefined, false)], [true, false]);
test("off and on select the named state, including the state already selected", () => {
for (const state of ["on", "off", "mixed", "empty", undefined]) {
assert.equal(flipTo(state, false), false, `off must stay off from ${state}`);
assert.equal(flipTo(state, true), true, `on must stay on from ${state}`);
}
});
test("a saved Fast setting keeps its value when its selected half is clicked again", () => {
const fast = node(21, 0, 0, { widgets: [{ name: "value", value: false }] });
const settings = normalizeSettings({ switches: [{ id: 1, title: "Fast", on: { 21: { value: true } }, off: { 21: { value: false } } }] });
const serialized = JSON.stringify(settings);
const click = (side) => {
const [row] = readSwitches(settings.switches, placeNodes([fast]), []);
const on = flipTo(row.state, side);
assert.deepEqual(switchPlan([row], row, on, settings), [], "a saved setting never changes the node mode");
const values = valuePlan([row], row, on, settings);
for (const [pair, wanted] of values) pair.control.value = pairValue(pair, wanted, pair.control.value);
return values.length;
};
assert.equal(click(false), 0, "Off on an already-off Fast setting must do nothing");
assert.equal(fast.widgets[0].value, false);
assert.equal(click(true), 1);
assert.equal(fast.widgets[0].value, true);
assert.equal(click(true), 0, "On on an already-on Fast setting must do nothing");
assert.equal(fast.widgets[0].value, true);
assert.equal(click(false), 1);
assert.equal(fast.widgets[0].value, false);
assert.equal(JSON.stringify(settings), serialized, "clicks must not rewrite the saved switch definition");
});
test("a switch sets every node it holds, and reports only the nodes that change", () => {
@@ -727,7 +750,7 @@ test("switching everything on or off puts each row in its own state, off sides i
assert.deepEqual(planned(switchAllPlan(pair, false, DEFAULT_SETTINGS)), { shared: MODE_ALWAYS });
});
test("Edit a picture: one click runs the picture and switches Latent Size off, the next does the reverse", () => {
test("Edit a picture: each half selects its branch, and a repeated click leaves it selected", () => {
const nodes = [picture(), latentSize()]; // just put on the off side: both still run
const settings = twoSided();
const click = (side) => {
@@ -737,8 +760,10 @@ test("Edit a picture: one click runs the picture and switches Latent Size off, t
};
assert.equal(readOne(settings, nodes).state, "mixed");
assert.deepEqual(click(true), ["on", [MODE_ALWAYS, MODE_BYPASS]]);
assert.deepEqual(click(true), ["off", [MODE_BYPASS, MODE_ALWAYS]]);
assert.deepEqual(click(false), ["on", [MODE_ALWAYS, MODE_BYPASS]]);
assert.deepEqual(click(true), ["on", [MODE_ALWAYS, MODE_BYPASS]]);
assert.deepEqual(click(false), ["off", [MODE_BYPASS, MODE_ALWAYS]]);
assert.deepEqual(click(false), ["off", [MODE_BYPASS, MODE_ALWAYS]]);
assert.deepEqual(click(true), ["on", [MODE_ALWAYS, MODE_BYPASS]]);
// Switching everything does the same through the card's right-click menu.
for (const [on, expected] of [[false, [MODE_BYPASS, MODE_ALWAYS]], [true, [MODE_ALWAYS, MODE_BYPASS]]]) {
for (const [member, mode] of switchAllPlan([readOne(settings, nodes)], on, settings)) member.mode = mode;