Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7415f7863 | ||
|
|
6e48ca10d8 | ||
|
|
0b9259f86b | ||
|
|
0386e9c416 | ||
|
|
95a259cc69 | ||
|
|
eb3b74242a |
+12
-1
@@ -2,8 +2,19 @@
|
||||
|
||||
All notable changes to ComfyUI-AusBoss are documented here.
|
||||
|
||||
## Unreleased
|
||||
## 2.21.2 - 2026-11-07
|
||||
|
||||
- **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
|
||||
|
||||
@@ -1,9 +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>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="105" height="20" role="img" aria-label="release: v2.21.2">
|
||||
<title>release: v2.21.2</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>
|
||||
<text x="76.5" y="14">v2.21.2</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 425 B After Width: | Height: | Size: 425 B |
+1
-1
@@ -9,7 +9,7 @@ import { app } from "../../../scripts/app.js";
|
||||
// Must match `version` in pyproject.toml — scripts/release_preflight.py
|
||||
// enforces it. The backend serves its copy at /ausboss/pack_version; a
|
||||
// mismatch means the browser cached JavaScript from an older install.
|
||||
export const AUSBOSS_JS_VERSION = "2.21.1";
|
||||
export const AUSBOSS_JS_VERSION = "2.21.2";
|
||||
|
||||
export const BRAND = "#00b4aa"; // AusBoss teal — keep node accents consistent.
|
||||
export const BRAND_DARK = "#007f78";
|
||||
|
||||
@@ -166,6 +166,14 @@ export function noteIsEmpty(note) {
|
||||
);
|
||||
}
|
||||
|
||||
// A note shows its tile face when it is in tile layout and has anything to
|
||||
// show. The node is then just a button: the note itself opens in a dialog,
|
||||
// so there is no card inside the node whose height needs fitting.
|
||||
export function noteShowsTileFace(note) {
|
||||
if (!note || typeof note !== "object") return false;
|
||||
return note.layout === "tile" && !noteIsEmpty(note);
|
||||
}
|
||||
|
||||
// "ComfyUI/models/loras" - the folder hint under each group of rows.
|
||||
export function folderPath(dir) {
|
||||
if (!dir) return "ComfyUI/models";
|
||||
|
||||
@@ -21,6 +21,7 @@ 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,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
matchInstalled,
|
||||
noteFit,
|
||||
noteIsEmpty,
|
||||
noteShowsTileFace,
|
||||
normalizeNote,
|
||||
packsFromGraph,
|
||||
parseMarkdown,
|
||||
@@ -708,13 +710,13 @@ 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") {
|
||||
if (!classicCanvas()) return;
|
||||
// A tile face is a button, not a card: fitting it would measure the note
|
||||
// behind the button and grow a new tile straight back to full height.
|
||||
if (noteShowsTileFace(state.note)) {
|
||||
state.fitHeight = undefined;
|
||||
return;
|
||||
}
|
||||
if (!classicCanvas()) return;
|
||||
const { node, root, domWidget } = state;
|
||||
const width = node.size[0] - WIDGET_FRAME;
|
||||
if (!(width > 0)) return;
|
||||
@@ -1119,6 +1121,11 @@ 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,6 +102,33 @@ 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.
|
||||
|
||||
@@ -143,6 +170,21 @@ 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*$")
|
||||
@@ -189,9 +231,16 @@ 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."""
|
||||
counter" is the man at the counter, and so is "Replace the man at the counter with @1."."""
|
||||
replaces = REPLACES.search(text)
|
||||
text = text[replaces.end() :] if replaces else NAMES_THE_NEW.sub("", 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 = 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:
|
||||
if ask is not None and video != SWAP: # A swap keeps the clip's own action; its model only writes the looks.
|
||||
add_moments(scene, seconds, ask, jobs)
|
||||
add_sound(scene, ask, jobs)
|
||||
audio += soundtrack and video == KEEP
|
||||
|
||||
@@ -727,11 +727,16 @@ 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 appearance:
|
||||
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)}")
|
||||
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"{new} {looks}. "
|
||||
f"The replacement stays present throughout, from the first frame, 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 EDIT, JOBS, KEEP, KEEPS_ITS_LENGTH, PERSON, PLACE, START, SWAP, THING, VIDEO_JOBS
|
||||
from ._h3_cast_helpers import DESCRIBE_PERSON, add_looks, 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,7 +177,8 @@ 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."
|
||||
"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."
|
||||
),
|
||||
},
|
||||
),
|
||||
@@ -335,6 +336,9 @@ 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
-1
@@ -3,7 +3,7 @@ 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, 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"
|
||||
version = "2.21.2"
|
||||
# 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.
|
||||
|
||||
@@ -94,7 +94,10 @@ for (const type of types) {
|
||||
const seen = await evalJs(`(async () => {
|
||||
const { app } = window;
|
||||
app.canvas.linkConnector?.reset?.();
|
||||
app.graph.clear(); app.graph.last_node_id += 50;
|
||||
// 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;
|
||||
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" };
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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,6 +733,63 @@ 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"])
|
||||
@@ -748,7 +805,8 @@ 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> takes the place of the person on the right", parts["detailed_description"])
|
||||
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.assertNotIn("Show one person", parts["detailed_description"])
|
||||
|
||||
def test_a_swap_with_two_people_reads_who_goes_from_the_script(self):
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
matchInstalled,
|
||||
noteFit,
|
||||
noteIsEmpty,
|
||||
noteShowsTileFace,
|
||||
normalizeNote,
|
||||
packsFromGraph,
|
||||
parseInline,
|
||||
@@ -198,6 +199,23 @@ test("noteFit ignores a card it could not measure", () => {
|
||||
assert.deepEqual(noteFit({ natural: 794.125, nodeHeight: undefined, chrome: 22 }), { fit: null, grow: false });
|
||||
});
|
||||
|
||||
test("a tile face shows only for a tile layout note that has content", () => {
|
||||
assert.equal(noteShowsTileFace(normalizeNote('{"title":"Workflow info","layout":"tile"}')), true);
|
||||
assert.equal(noteShowsTileFace(normalizeNote('{"title":"Workflow info","layout":"card"}')), false);
|
||||
assert.equal(noteShowsTileFace(normalizeNote('{"layout":"tile"}')), false);
|
||||
assert.equal(noteShowsTileFace(normalizeNote("")), false);
|
||||
assert.equal(noteShowsTileFace(null), false);
|
||||
});
|
||||
|
||||
test("the classic fit leaves a tile face compact", () => {
|
||||
const source = readFileSync(new URL("../js/workflow_note/index.js", import.meta.url), "utf-8");
|
||||
assert.match(
|
||||
source,
|
||||
/function fitNote\(state\) \{\n if \(!classicCanvas\(\)\) return;\n \/\/ A tile face is a button, not a card: fitting it would measure the note\n \/\/ behind the button and grow a new tile straight back to full height\.\n if \(noteShowsTileFace\(state\.note\)\) \{\n state\.fitHeight = undefined;\n return;\n \}/,
|
||||
"fitNote skips a note that shows its tile face, so a new tile keeps its compact start",
|
||||
);
|
||||
});
|
||||
|
||||
test("parseInline handles bold, italic, code and safe links", () => {
|
||||
assert.deepEqual(parseInline("a **b** *c* `d` [e](https://f.g) [h](javascript:x) i"), [
|
||||
{ type: "text", text: "a " },
|
||||
@@ -309,34 +327,8 @@ test("note formatting leaves old prose, unsafe markup and code literal", () => {
|
||||
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));
|
||||
}
|
||||
}
|
||||
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