Compare commits

..
Author SHA1 Message Date
ausboss 729084ca78 Keep compact note tiles at their chosen size 2026-11-06 19:09:36 -06:00
14 changed files with 50 additions and 215 deletions
+1 -12
View File
@@ -2,19 +2,8 @@
All notable changes to ComfyUI-AusBoss are documented here.
## 2.21.2 - 2026-11-07
## Unreleased
- **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
+3 -3
View File
@@ -1,9 +1,9 @@
<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>
<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.2</text>
<text x="76.5" y="14">v2.21.1</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 425 B

After

Width:  |  Height:  |  Size: 425 B

+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.2";
export const AUSBOSS_JS_VERSION = "2.21.1";
export const BRAND = "#00b4aa"; // AusBoss teal — keep node accents consistent.
export const BRAND_DARK = "#007f78";
-8
View File
@@ -166,14 +166,6 @@ 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";
+4 -11
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,
@@ -35,7 +34,6 @@ import {
matchInstalled,
noteFit,
noteIsEmpty,
noteShowsTileFace,
normalizeNote,
packsFromGraph,
parseMarkdown,
@@ -710,13 +708,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) {
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)) {
// 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;
if (!(width > 0)) return;
@@ -1121,11 +1119,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 -1
View File
@@ -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.2"
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.
+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):
+30 -22
View File
@@ -15,7 +15,6 @@ import {
matchInstalled,
noteFit,
noteIsEmpty,
noteShowsTileFace,
normalizeNote,
packsFromGraph,
parseInline,
@@ -199,23 +198,6 @@ 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 " },
@@ -327,8 +309,34 @@ test("note formatting leaves old prose, unsafe markup and code literal", () => {
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");
// 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));
}
}
});