Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8c2c878bb |
+3
-12
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(" .,;")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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):
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user