Compare commits

..
Author SHA1 Message Date
ausboss e8c2c878bb Release 2.21.2
Ship the H3 video edit controls and shared note tiles from #293, with
the classic canvas tile fit fix from #294, as 2.21.2.
2026-11-07 01:12:26 +00:00
10 changed files with 13 additions and 181 deletions
+3 -12
View File
@@ -2,19 +2,10 @@
All notable changes to ComfyUI-AusBoss are documented here.
## 2.21.2 - 2026-11-07
## Unreleased
## 2.21.2 - 2026-11-06
- **H3 Prompt Writer reads a plain swap sentence.** "Replace the man in the red
shirt with @1", "Swap the woman for @1" and similar phrasings now name only the
person who goes, instead of putting the whole sentence into the prompt.
- **A swapped-in person is there from the first frame.** Swaps with a picture
always ask for the replacement throughout, with no transition or morph.
- **A text model on H3 Prompt Writer's `clip` input writes the `@1 looks like`
line in a swap.** It describes the person's hair and face from the picture
when no line is typed. Without a model on `clip`, nothing changes.
- **Workflow Note tiles start compact in the classic canvas again.** A leftover
image preview filed under the node's id made the classic canvas grow a new
tile to hold the picture; notes now stand that preview down.
- **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
-6
View File
@@ -21,7 +21,6 @@ import { copyToClipboard } from "../shared/clipboard.mjs";
import { confirmDiscard } from "../shared/discard_prompt.mjs";
import { WIDGET_FRAME, fillNodeHeight, holdNodeMinHeight } from "../shared/panel_layout.mjs";
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
import { suppressCoreImagePreview } from "../shared/core_preview.mjs";
import {
COMMON_FOLDERS,
INPUT_FOLDER,
@@ -1121,11 +1120,6 @@ function buildPanel(node) {
const widget = node.widgets?.find((item) => item.name === "note");
if (!widget) return null;
hideWidget(widget);
// A note never has output images of its own. The frontend files previews
// by node id, so one left over from another node can still land here, and
// in the classic canvas its widget asks for the picture's full height:
// the node grows to hold it and a tile stops starting as a tile.
suppressCoreImagePreview(node);
const root = el("div", "ausboss-note");
const card = el("div", "ausboss-note-card");
+2 -51
View File
@@ -102,33 +102,6 @@ ONE_GOES = "the original person"
APPEARANCE_LINE = re.compile(r'^\s*@([1-4])\s+looks like\s+([^"\n]+?)\s*$', re.I)
# Hair and face only, no clothes, expression or background. When the line names clothes that differ from what the
# person in the clip wears, H3 tends to open on the picture's own backdrop before cutting to the clip.
DESCRIBE_PERSON = (
"Describe only the hair and face of the person in this picture, in one short line for a video prompt: hair colour "
"and style, and glasses, a beard or other face details you can see. No clothes, no expression, no background. "
"Start with 'a man' or 'a woman'."
)
def add_looks(text, jobs, describe):
"""A swap's script with an @N looks like line for each new person who has none, written by describe(slot), a
small picture-reading model shown that person's picture. Without such a line H3 kept the original person for the
first seconds (2026-11-07), so a connected model writes it when the user didn't."""
typed = {int(match.group(1)) for match in map(APPEARANCE_LINE.match, text.splitlines()) if match}
for slot in sorted(jobs):
if jobs[slot] not in (PERSON, SHEET) or slot in typed:
continue
words = next((line for line in str(describe(slot) or "").splitlines() if line.strip()), "")
words = re.sub(r'["\u201c\u201d]', "", words).strip(" .,;:")
words = re.sub(r"^(?:the\s+)?(?:person|subject)\s+(?:in\s+the\s+picture\s+)?(?:is|looks\s+like)\s+", "", words, flags=re.I)
# "and no visible beard": naming what isn't there can put it in the picture.
words = re.sub(r",?\s*(?:and\s+|with\s+)*(?:no|without)\s+(?:visible\s+)?[^,.;]*", "", words, flags=re.I).strip(" .,;:")
if words:
text = f"{text.rstrip()}\n@{slot} looks like {words[:1].lower() + words[1:]}"
return text
def swap_appearances(text, jobs, video):
"""An optional separate @1 looks like line describes a replacement, not who goes.
@@ -170,21 +143,6 @@ REPLACES = re.compile(
r"|swaps?\s+(?:in\s+)?for|goes\s+in\s+for|stands?\s+in\s+for)\s+",
re.I,
)
# A plain sentence: "Replace the man in red with @1", "swap the guy for @1", "make the dancer look like @1",
# "swap @1 in for the singer". The words caught are who goes.
ASKS = re.compile(
r"^\s*(?:please\s+)?(?:replace|swap(?:\s+out)?|switch(?:\s+out)?|change|turn|make)\s+(?!@\d)(.+?)\s+"
r"(?:with|for|to|into|by|look\s+like|become)\s+@\d\b"
r"|^\s*(?:please\s+)?(?:swap|put|place|drop|switch)\s+@\d\s+(?:in\s+)?(?:with|for|over|on|onto|in\s+place\s+of)\s+(.+)",
re.I,
)
# "the cashier is now @1", "the man on the left becomes @1".
BECOMES = re.compile(
r"^\s*(.+?)\s+(?:is\s+now|becomes|turns\s+into|is\s+replaced\s+(?:by|with)|gets\s+replaced\s+(?:by|with)"
r"|is\s+swapped\s+(?:for|with|to)|gets\s+swapped\s+(?:for|with)|->|\u2192)\s*@\d\b",
re.I,
)
IN_THE_CLIP = re.compile(r"\s+(?:in|from|of)\s+the\s+(?:clip|video|original(?:\s+(?:clip|video))?)\b", re.I)
# "Elon is @1," names the new person, not the one who goes.
# "the man on the left, Dario is" ends where the next person's name starts.
NAMES_NEXT = re.compile(r",?\s*(?:[A-Z][\w'.-]*\s+)+(?:is|as|plays)\s*$")
@@ -231,16 +189,9 @@ REVOICE_KEPT = (
def who_goes_in(text):
"""Who a typed swap line says goes, without the rest of the line: "Elon is @1, he replaces the man at the
counter" is the man at the counter, and so is "Replace the man at the counter with @1."."""
counter" is the man at the counter."""
replaces = REPLACES.search(text)
plain = None if replaces else next((match for match in (ASKS.search(text), BECOMES.search(text)) if match), None)
if replaces:
text = text[replaces.end() :]
elif plain:
text = next(group for group in plain.groups() if group)
else:
text = NAMES_THE_NEW.sub("", text)
text = IN_THE_CLIP.sub("", text)
text = text[replaces.end() :] if replaces else NAMES_THE_NEW.sub("", text)
text = re.split(r"[.;!?]", text.strip(" .;!?"), maxsplit=1)[0]
text = NAMES_NEXT.sub("", text)
text = SAYS_WHO.sub("", TOKEN.sub("", text).strip(" .,;")).strip(" .,;")
+1 -1
View File
@@ -347,7 +347,7 @@ def write_prompt(script, seconds=15.0, camera="", look="", sound="", language="E
scene.moments.append(Moment(ACTION_GOES_ON if video == KEEP else ""))
if voice and not any(moment.speech for moment in scene.moments):
raise ValueError(VOICE_NO_WORDS)
if ask is not None and video != SWAP: # A swap keeps the clip's own action; its model only writes the looks.
if ask is not None:
add_moments(scene, seconds, ask, jobs)
add_sound(scene, ask, jobs)
audio += soundtrack and video == KEEP
+3 -8
View File
@@ -727,16 +727,11 @@ def before_the_action(number, text, cast, wearers):
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 picture:
# Every swap gets the firm wording. Without it H3 kept the original person for the first seconds,
# or for the whole clip at Fast preview; with it the new
# person is there from the first frame. An @1 looks like line only adds its own words.
looks = (f"has the defined appearance from {picture_label(picture.picture)}: {appearance}" if appearance
else f"has the face, hair and clothes of the person in {picture_label(picture.picture)}")
if appearance:
notes.append(
f"{new} occupies the starting position of {who} and follows the same route, movements, timing and pace. "
f"{new} {looks}. "
f"The replacement stays present throughout, from the first frame, ending at the original performance's final position. "
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:
+2 -6
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import sys
from ._h3_cast_helpers import DESCRIBE_PERSON, add_looks, EDIT, 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
@@ -177,8 +177,7 @@ class AusBossH3PromptWriter:
{
"tooltip": (
"Optional: a small text model such as Qwen3-VL 8B. It adds "
"moments after a one-line idea and matches the sound to the place. "
"In a swap it writes what the new person looks like, from their picture."
"moments after a one-line idea and matches the sound to the place."
),
},
),
@@ -336,9 +335,6 @@ class AusBossH3PromptWriter:
kinds = {1: job_1, 2: job_2, 3: job_3, 4: job_4}
jobs = {slot: kinds[slot] for slot in given}
video_job = "" if video is None else video_job
if clip is not None and video_job == SWAP:
# The model on clip writes each new person's @N looks like line from their picture, when none is typed.
script = add_looks(script, jobs, lambda slot: model_asker(clip, given[slot])(DESCRIBE_PERSON, 60))
frames = clip_frames(seconds)
if video_job in KEEPS_ITS_LENGTH:
frames = video_frames(video.shape[0])
+1 -4
View File
@@ -94,10 +94,7 @@ for (const type of types) {
const seen = await evalJs(`(async () => {
const { app } = window;
app.canvas.linkConnector?.reset?.();
// clear() resets the counter, but frontend preview caches can outlive
// the graph. Reserve fresh ids before clearing so probes stay isolated.
const nextNodeId = app.graph.last_node_id + 50;
app.graph.clear(); app.graph.last_node_id = nextNodeId;
app.graph.clear(); app.graph.last_node_id += 50;
let node;
try { node = window.LiteGraph.createNode(${JSON.stringify(type)}); } catch (error) { return { error: "createNode threw: " + (error?.message ?? error) }; }
if (!node) return { error: "createNode gave nothing" };
-28
View File
@@ -1,28 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
test("face smoke gives each probe a fresh node id after clearing the graph", () => {
const source = readFileSync(new URL("../scripts/dev/face_smoke.mjs", import.meta.url), "utf8");
// Run the browser setup used by every probe. ComfyUI clears the node-id
// counter with the graph, but output/preview caches can retain those ids.
const setup = source.match(/const seen = await evalJs\(`\(async \(\) => \{([\s\S]*?)\n let node;/)?.[1];
assert.ok(setup, "the browser probe setup must be exercised");
const previews = new Map();
const graph = { last_node_id: 0, clear() { this.last_node_id = 0; } };
let resets = 0;
const context = vm.createContext({ window: { app: {
graph, canvas: { linkConnector: { reset() { resets++; } } },
} } });
const ids = [];
for (let probe = 0; probe < 48; probe++) {
vm.runInContext(`(() => { ${setup} })()`, context);
const id = ++graph.last_node_id;
assert.equal(previews.has(id), false, "a later node must not inherit an earlier node's preview");
previews.set(id, `preview-${probe}`);
ids.push(id);
}
assert.equal(new Set(ids).size, 48);
assert.equal(resets, 48);
});
+1 -59
View File
@@ -733,63 +733,6 @@ class VideoJobTests(unittest.TestCase):
self.assertIn("<Video 1>: partially_preserved", parts["retention_analysis"])
self.assertIn("the background of <Picture 1> is not transferred", parts["retention_analysis"])
def test_a_connected_model_writes_the_new_persons_looks_in_a_swap(self):
from nodes._h3_cast_helpers import add_looks
asked = []
def describe(slot):
asked.append(slot)
return '"A man with curly brown hair, wearing glasses."\nExtra line.'
script = add_looks("Replace the guy with @1.", {1: "person"}, describe)
self.assertEqual(script, "Replace the guy with @1.\n@1 looks like a man with curly brown hair, wearing glasses")
self.assertEqual(asked, [1])
text = write_prompt(script, seconds=8.0, jobs={1: "person"}, video="swap the person")
self.assertIn("<Subject 2> has the defined appearance from <Picture 1>: a man with curly brown hair", text)
# A typed line wins, places and empty answers add nothing.
typed = "Replace the guy with @1.\n@1 looks like a tall woman."
self.assertEqual(add_looks(typed, {1: "person"}, describe), typed)
self.assertEqual(add_looks("Swap him for @1.", {1: "place"}, describe), "Swap him for @1.")
self.assertEqual(add_looks("Swap him for @1.", {1: "person"}, lambda slot: ""), "Swap him for @1.")
said = "a man with curly brown hair, wearing dark-framed glasses, and no visible beard or facial hair."
self.assertEqual(add_looks("Swap him for @1.", {1: "person"}, lambda slot: said),
"Swap him for @1.\n@1 looks like a man with curly brown hair, wearing dark-framed glasses")
def test_a_swap_gets_the_firm_wording_without_a_looks_like_line(self):
text = write_prompt("Replace the guy with @1.", seconds=8.0, jobs={1: "person"}, video="swap the person")
self.assertIn("<Subject 2> has the face, hair and clothes of the person in <Picture 1>", text)
self.assertIn("stays present throughout, from the first frame", text)
self.assertIn("Do not show the guy in that role at any time", text)
def test_a_swap_with_a_model_adds_no_moments(self):
node = AusBossH3PromptWriter()
calls = []
import nodes.node_h3_prompt_writer as writer
real = writer.model_asker
writer.model_asker = lambda clip, image: (lambda question, longest: calls.append(question) or "a woman with a dark bob")
try:
text = node.write("Swap the woman for @1.", 6, clip=object(), picture_1=torch.zeros(1, 8, 8, 3),
video=torch.zeros(40, 8, 8, 3), video_job="swap the person")[0]
finally:
writer.model_asker = real
self.assertEqual(len(calls), 1)
self.assertIn("a woman with a dark bob", text)
self.assertNotIn("[Shot 2]", text)
def test_a_plain_swap_sentence_names_only_who_goes(self):
for script, who in (
("Replace the man in the red shirt with @1.", "the man in the red shirt"),
("swap the guy with @1", "the guy"),
("swap out the guy in the hat for @1 in the clip", "the guy in the hat"),
("the cashier is now @1", "the cashier"),
("make the dancer look like @1", "the dancer"),
("swap @1 in for the drummer", "the drummer"),
("Replace the woman with black hair with @1.\n@1 looks like a woman with platinum hair.", "the woman with black hair"),
):
text = write_prompt(script, seconds=8.0, jobs={1: "person"}, video="swap the person")
self.assertIn(f"<Subject 1> is {who} in <Video 1>, selected for replacement.", text)
self.assertNotIn("@1", text)
def test_typed_words_in_a_swap_say_who_goes(self):
parts = parts_of("The child in the seat.", jobs={1: "person"}, video="swap the person")
self.assertIn("<Subject 1> is the child in the seat in <Video 1>", parts["subject_definitions"])
@@ -805,8 +748,7 @@ class VideoJobTests(unittest.TestCase):
self.assertIn("<Subject 4> is the person in <Picture 2>", parts["subject_definitions"])
self.assertIn("replace the person on the right with <Subject 4>", parts["retention_analysis"])
self.assertIn("change only the people", parts["retention_analysis"])
self.assertIn("<Subject 4> occupies the starting position of the person on the right", parts["detailed_description"])
self.assertIn("<Subject 4> has the face, hair and clothes of the person in <Picture 2>", parts["detailed_description"])
self.assertIn("<Subject 4> takes the place of the person on the right", parts["detailed_description"])
self.assertNotIn("Show one person", parts["detailed_description"])
def test_a_swap_with_two_people_reads_who_goes_from_the_script(self):
-6
View File
@@ -326,9 +326,3 @@ 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");
});
test("note panels stand down the frontend image preview", () => {
const source = readFileSync(new URL("../js/workflow_note/index.js", import.meta.url), "utf-8");
assert.match(source, /import \{ suppressCoreImagePreview \} from "\.\.\/shared\/core_preview\.mjs";/, "the note imports the pack's preview suppression");
assert.match(source, /suppressCoreImagePreview\(node\);/, "building a note panel stands the core image preview down");
});