Files
ausboss-ComfyUI-AusBoss/tests/core_preview.test.mjs
ausboss 0c404b37f3 Save Video: a saved gif or webp shows on the node
With format set to gif or webp the file was saved, but the node's stage
stayed empty and said "Execution finished without video metadata".

The node has shown a gif or webp in an <img> since the two formats were
added, because a <video> cannot play either. The run's result was still
searched with a list of video file endings only, so for these two nothing
was found and the <img> was never filled. The list of picture endings now
sits beside that lookup in js/shared/video_preview.mjs, and both the
lookup and the choice of tag read it. A png is still not taken.

In the classic renderer a saved webp was also drawn a second time under
the stage. ComfyUI shows an animated webp in a widget of its own, neither
its video preview nor its picture preview, and only those two were stood
down. suppressCoreVideoPreview hides that widget as well. The node's
smallest height with a saved webp goes from 469 px back to 415 px.

Tests: the lookup finds a saved gif and webp and sends them to the <img>;
every row of the backend's VIDEO_FORMATS is read from its source and has
to be found and sent to the right tag; the animated widget is hidden on
arrival and when it is already on the node.
2026-10-06 17:35:31 -05:00

180 lines
8.0 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import {
CORE_ANIMATED_PREVIEW_WIDGET,
CORE_IMAGE_PREVIEW_WIDGET,
CORE_VIDEO_PREVIEW_WIDGET,
hideCanvasWidget,
suppressCoreImagePreview,
suppressCoreVideoPreview,
} from "../js/shared/core_preview.mjs";
function fakeNode() {
const node = {
widgets: [],
addCustomWidget(widget) {
this.widgets.push(widget);
return widget;
},
addDOMWidget(name, type, element) {
const widget = { name, type, element };
this.widgets.push(widget);
return widget;
},
};
return node;
}
const isHidden = (widget) =>
widget.hidden === true && widget.computeSize()[1] === -4 &&
widget.computeLayoutSize().minHeight === 0;
test("a widget the frontend adds later is hidden on arrival", () => {
const node = fakeNode();
suppressCoreImagePreview(node);
const widget = node.addCustomWidget({ name: CORE_IMAGE_PREVIEW_WIDGET });
assert.ok(isHidden(widget));
});
test("a widget already on the node when we arrive is swept up", () => {
const node = fakeNode();
const widget = { name: CORE_IMAGE_PREVIEW_WIDGET };
node.widgets.push(widget);
suppressCoreImagePreview(node);
assert.ok(isHidden(widget));
});
test("only the core preview widget is touched", () => {
const node = fakeNode();
suppressCoreImagePreview(node);
const mine = node.addCustomWidget({ name: "ausboss_input_preview" });
assert.equal(mine.hidden, undefined);
assert.equal(typeof mine.computeSize, "undefined");
});
test("the image suppressor also stands down Nodes 2.0's own copy", () => {
// Nodes 2.0 draws a node's output images under its widgets unless the node
// sets hideOutputImages (classic never reads it). Left on, Save Image showed
// the result twice there and grew past the node under it.
const node = fakeNode();
assert.equal(node.hideOutputImages, undefined);
suppressCoreImagePreview(node);
assert.equal(node.hideOutputImages, true);
});
test("the image and video suppressors do not stand in for each other", () => {
// They wrap different methods and are flagged separately, so a node that
// draws both kinds can ask for both.
const node = fakeNode();
suppressCoreImagePreview(node);
const video = node.addDOMWidget(CORE_VIDEO_PREVIEW_WIDGET, "video", {
style: {},
});
assert.equal(video.hidden, undefined, "image suppression must not hide the video widget");
suppressCoreVideoPreview(node);
const second = node.addDOMWidget(CORE_VIDEO_PREVIEW_WIDGET, "video", { style: {} });
assert.ok(isHidden(second));
// ...and the one added before suppression is swept on the way in.
assert.ok(isHidden(video));
});
test("suppressing twice keeps one wrapper, not a growing chain", () => {
const node = fakeNode();
const original = node.addCustomWidget;
suppressCoreImagePreview(node);
const wrapped = node.addCustomWidget;
suppressCoreImagePreview(node);
assert.notEqual(wrapped, original);
assert.equal(node.addCustomWidget, wrapped);
});
test("hiding is idempotent and never re-stashes a live method", () => {
const widget = { name: CORE_IMAGE_PREVIEW_WIDGET };
hideCanvasWidget(widget);
const hiddenDraw = widget.draw;
hideCanvasWidget(widget);
assert.equal(widget.draw, hiddenDraw);
});
test("the DOM widget's element is hidden along with it", () => {
const node = fakeNode();
suppressCoreVideoPreview(node);
const element = { style: {} };
node.addDOMWidget(CORE_VIDEO_PREVIEW_WIDGET, "video", element);
assert.equal(element.style.display, "none");
});
// The Crop + Rotate + Pad nodes send no picture of their own, so a core
// preview on one can only be another node's result: in practice a run from
// another workflow tab, filed under the same node id. All three kinds stand
// both core previews down, not just the image node.
test("every transform node stands down both core previews", async () => {
const { readFileSync } = await import("node:fs");
const source = readFileSync(new URL("../js/shared/transform_editor.mjs", import.meta.url), "utf-8");
const install = source.slice(source.indexOf("export function installTransformNode"), source.indexOf("function installVideoDrop"));
assert.match(install, /\n\s*suppressCoreImagePreview\(node\);/);
assert.match(install, /\n\s*suppressCoreVideoPreview\(node\);/);
assert.doesNotMatch(install, /if \(kind === "image"\) suppressCoreImagePreview/);
});
test("the video suppression also stands down Nodes 2.0's own output copy", () => {
const node = fakeNode();
suppressCoreVideoPreview(node);
assert.equal(node.hideOutputImages, true);
});
test("the video suppression also hides the animated picture preview", () => {
// A clip saved as an animated webp is a picture to the frontend. It comes
// in a third widget, neither the video one nor the canvas picture, so
// Save Video drew it twice in the classic renderer: in its stage and again
// underneath, where it also made the node's smallest height 54 px taller.
const node = fakeNode();
const early = node.addDOMWidget(CORE_ANIMATED_PREVIEW_WIDGET, "img", { style: {} });
suppressCoreVideoPreview(node);
assert.ok(isHidden(early), "one already on the node is swept up");
const element = { style: {} };
const late = node.addDOMWidget(CORE_ANIMATED_PREVIEW_WIDGET, "img", element);
assert.ok(isHidden(late), "one the frontend adds after a run is hidden on arrival");
assert.equal(element.style.display, "none");
// The video widget is still caught, and the node's own panel is left alone.
assert.ok(isHidden(node.addDOMWidget(CORE_VIDEO_PREVIEW_WIDGET, "video", { style: {} })));
assert.equal(node.addDOMWidget("ausboss_save_video_viewer", "ausboss_video", { style: {} }).hidden, undefined);
});
// Either way of standing Nodes 2.0's copy down: setting the flag, or calling
// one of the shared suppressors above, which set it.
function standsVueCopyDown(source) {
if (/\bhideOutputImages\s*=\s*true\b/.test(source)) return true;
const shared = source.match(/import\s*\{([^}]*)\}\s*from\s*"[^"]*core_preview\.mjs"/)?.[1] ?? "";
return ["suppressCoreImagePreview", "suppressCoreVideoPreview"]
.some((name) => new RegExp(`\\b${name}\\b`).test(shared) && source.includes(`${name}(`));
}
// A file that stands the classic preview down by hand names its widget.
// Nodes 2.0 has no such widget to remove, only the flag. Load Image + Pad
// removed the widget and never set the flag, so in Nodes 2.0 its picture
// showed twice and the second copy took the pad stage's height.
test("every file that stands the classic image preview down also stands Nodes 2.0's copy down", async () => {
const { readdirSync, readFileSync } = await import("node:fs");
const { fileURLToPath } = await import("node:url");
const { join } = await import("node:path");
const js = fileURLToPath(new URL("../js", import.meta.url));
const files = readdirSync(js, { recursive: true }).map(String).filter((file) => /\.m?js$/.test(file)).map((file) => file.replaceAll("\\", "/"));
const byHand = files.filter((file) => readFileSync(join(js, file), "utf-8").includes(`"${CORE_IMAGE_PREVIEW_WIDGET}"`));
assert.ok(byHand.includes("shared/core_preview.mjs"), "the scan found the pack's scripts");
const twice = byHand.filter((file) => !standsVueCopyDown(readFileSync(join(js, file), "utf-8")));
assert.deepEqual(twice, [], "these show the node's picture twice in Nodes 2.0");
// Load Image + Pad by name, however it comes to do it.
assert.ok(standsVueCopyDown(readFileSync(join(js, "load_image_pad/index.js"), "utf-8")));
});
test("the scan tells a file that sets the flag from one that only removes the widget", () => {
assert.equal(standsVueCopyDown("node.hideOutputImages = true;"), true);
assert.equal(standsVueCopyDown('import { suppressCoreVideoPreview } from "./core_preview.mjs";\nsuppressCoreVideoPreview(node);'), true);
// Imported but never called, and a local function of the same name.
assert.equal(standsVueCopyDown('import { suppressCoreVideoPreview } from "./core_preview.mjs";'), false);
assert.equal(standsVueCopyDown("function suppressCoreImagePreview(node) { node.widgets.splice(0, 1); }\nsuppressCoreImagePreview(node);"), false);
});