Files
2026-08-28 01:01:12 +02:00

260 lines
11 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { buildReferenceFrames, referencePlan, sampleUrlFor } from '../../web/ui/reference_media.js';
import { previewSourceSignature } from '../../web/ui/preview_sources.js';
import { AUTO_PREVIEW_FRAMES, isLocalMediaActive, LocalFilmstripMedia, localPreviewFrameIndices, MAX_PREVIEW_FRAMES } from '../../web/ui/local_media.js';
function videoLoaderGraph({ file = 'shot.mp4', comfyClass = 'LoadVideo' } = {}) {
const source = { id: 2, comfyClass, widgets: [{ name: 'file', value: file }] };
const node = { id: 1, inputs: [{ name: 'reference_media', link: 5 }] };
node.graph = { links: { 5: { origin_id: 2 } }, getNodeById: (id) => (id === 2 ? source : null) };
return { node, source };
}
test('filmstrip preview budget is 800 frames', () => {
assert.equal(MAX_PREVIEW_FRAMES, 800);
assert.equal(localPreviewFrameIndices(800).length, 800);
const sampled = localPreviewFrameIndices(5000);
assert.equal(sampled.length, 800);
assert.equal(sampled[0], 0);
assert.equal(sampled.at(-1), 4999);
});
test('an image batch plan keeps original frame indices and caps the thumbnail count', () => {
const urls = Array.from({ length: 1200 }, (_, index) => `/view?filename=f${index}.png`);
const plan = referencePlan({ kind: 'frames', urls, frameCount: urls.length, status: 'ready' });
assert.equal(plan.kind, 'frames');
assert.equal(plan.frameCount, 1200);
assert.equal(plan.frames.length, MAX_PREVIEW_FRAMES);
assert.equal(plan.frames[0].frame, 0);
assert.equal(plan.frames.at(-1).frame, 1199);
assert.equal(plan.frames.at(-1).url, urls[1199]);
});
test('a video plan forwards fps and the loader frame cap as a sampling hint', () => {
const plan = referencePlan({ kind: 'video', url: '/view?filename=a.mp4', frameCount: 96, fps: 12, status: 'ready' });
assert.equal(plan.kind, 'video');
assert.equal(plan.fps, 12);
assert.equal(plan.frameCountHint, 96);
assert.equal(plan.maxFrames, MAX_PREVIEW_FRAMES);
});
test('unresolved or empty sources report an actionable message instead of throwing', () => {
assert.equal(referencePlan({ kind: 'none', status: 'unavailable' }).kind, 'error');
assert.match(referencePlan(null).message, /reference_media/);
assert.match(referencePlan({ kind: 'frames', urls: [], status: 'ready' }).message, /no frame preview/);
});
test('preview params are merged into the sampled video query', () => {
const url = sampleUrlFor({ url: '/view?filename=a.mp4&force_rate=0', previewParams: { force_rate: 24, select_every_nth: 2 } });
const query = new URLSearchParams(url.split('?')[1]);
assert.equal(query.get('filename'), 'a.mp4');
assert.equal(query.get('force_rate'), '24');
assert.equal(query.get('select_every_nth'), '2');
});
test('a connected video loader is sampled without executing the graph', async () => {
const { node } = videoLoaderGraph();
const calls = [];
const built = await buildReferenceFrames(node, 'reference_media', {
toUrl: (path, type) => `/view?filename=${path}&type=${type}`,
sampleVideo: async (url, options) => {
calls.push([url, options]);
return { frameCount: 240, frames: [{ frame: 0, url: 'data:image/jpeg;base64,x' }] };
},
});
assert.equal(calls.length, 1);
assert.equal(calls[0][0], '/view?filename=shot.mp4&type=input');
assert.equal(calls[0][1].maxFrames, MAX_PREVIEW_FRAMES);
assert.equal(built.frameCount, 240);
assert.match(built.label, /240 frames/);
});
test('an unconnected reference input fails with a message, not a crash', async () => {
const node = { id: 1, inputs: [{ name: 'reference_media', link: null }], graph: { links: {} } };
await assert.rejects(
() => buildReferenceFrames(node, 'reference_media', { toUrl: (path) => path }),
/Connect a Load Video/,
);
});
test('reference frames drive the filmstrip and lock the manual frame count', () => {
const media = new LocalFilmstripMedia({ urls: { createObjectURL: () => '', revokeObjectURL: () => {} } });
const snapshot = media.setReferenceFrames({
frames: [{ frame: 0, url: 'a' }, { frame: 120, url: 'b' }],
frameCount: 240,
label: 'reference video · 240 frames',
});
assert.equal(snapshot.kind, 'reference');
assert.equal(snapshot.frameCount, 240);
assert.equal(snapshot.frames.length, 2);
assert.equal(isLocalMediaActive(snapshot), true);
assert.equal(media.clear().kind, 'empty');
});
// --- Scenarios verified against the live /object_info of a running ComfyUI ---
// LoadVideo(file) -> VIDEO -> GetVideoComponents(video) -> IMAGE
// VHS_LoadVideo(video, force_rate, frame_load_cap, skip_first_frames, select_every_nth) -> IMAGE
function graph(nodes, links) {
const byId = new Map(nodes.map((item) => [item.id, item]));
const g = { links, getNodeById: (id) => byId.get(id) || null };
for (const item of nodes) item.graph = g;
return g;
}
function vhsLoader({ force_rate = 0, frame_load_cap = 0, skip_first_frames = 0, select_every_nth = 1 } = {}) {
return {
id: 2,
comfyClass: 'VHS_LoadVideo',
widgets: [
{ name: 'video', value: 'shot.mp4' },
{ name: 'force_rate', value: force_rate },
{ name: 'frame_load_cap', value: frame_load_cap },
{ name: 'skip_first_frames', value: skip_first_frames },
{ name: 'select_every_nth', value: select_every_nth },
],
};
}
function guideMaster() {
return { id: 1, inputs: [{ name: 'reference_media', link: 7 }] };
}
async function build(node, sampleVideo) {
return buildReferenceFrames(node, 'reference_media', {
toUrl: (path, type) => `/api/view?filename=${path}&type=${type}`,
sampleVideo,
});
}
test('VHS_LoadVideo trim is replayed on the seekable original file', async () => {
const source = vhsLoader({ force_rate: 24, frame_load_cap: 48, skip_first_frames: 100, select_every_nth: 2 });
const node = guideMaster();
graph([node, source], { 7: { origin_id: 2 } });
let options = null;
await build(node, async (url, opts) => {
options = { url, ...opts };
return { frameCount: 48, frames: [] };
});
// The raw file is byte-range seekable; the loader's chunked transcode is not.
assert.equal(options.url, '/api/view?filename=shot.mp4&type=input');
assert.equal(options.fps, 24);
assert.equal(options.startFrame, 100);
assert.equal(options.stride, 2);
assert.equal(options.frameCountHint, 48);
});
test('a load cap alone still bounds the filmstrip when the source length is unknown', async () => {
const source = vhsLoader({ frame_load_cap: 96 });
const node = guideMaster();
graph([node, source], { 7: { origin_id: 2 } });
let options = null;
await build(node, async (url, opts) => { options = opts; return { frameCount: 96, frames: [] }; });
assert.equal(options.frameCountHint, 96);
assert.equal(options.fps, 24, 'no forced rate falls back to the H3 timeline rate');
assert.equal(options.stride, 1);
});
test('LoadVideo through GetVideoComponents resolves back to the video file', async () => {
const loader = { id: 3, comfyClass: 'LoadVideo', widgets: [{ name: 'file', value: 'clip.mp4' }] };
const components = { id: 2, comfyClass: 'GetVideoComponents', inputs: [{ name: 'video', link: 9 }], widgets: [] };
const node = guideMaster();
graph([node, components, loader], { 7: { origin_id: 2 }, 9: { origin_id: 3 } });
let options = null;
const built = await build(node, async (url, opts) => {
options = { url, ...opts };
return { frameCount: 300, frames: [{ frame: 0, url: 'data:,' }] };
});
assert.equal(options.url, '/api/view?filename=clip.mp4&type=input');
assert.equal(options.startFrame, 0);
assert.equal(options.stride, 1);
assert.equal(built.frameCount, 300);
});
test('an executed IMAGE batch is used directly, no video sampling at all', async () => {
const source = {
id: 2,
comfyClass: 'VHS_LoadVideo',
widgets: [{ name: 'video', value: 'shot.mp4' }],
imgs: [{ src: '/api/view?filename=a.png' }, { src: '/api/view?filename=b.png' }],
};
const node = guideMaster();
graph([node, source], { 7: { origin_id: 2 } });
let sampled = false;
const built = await build(node, async () => { sampled = true; return { frameCount: 0, frames: [] }; });
assert.equal(sampled, false);
assert.equal(built.frameCount, 2);
assert.equal(built.frames[0].url, '/api/view?filename=a.png');
});
test('the automatic budget is a fraction of the forced one', () => {
assert.equal(AUTO_PREVIEW_FRAMES, 120);
assert.ok(AUTO_PREVIEW_FRAMES < MAX_PREVIEW_FRAMES);
});
test('the automatic pass samples the same span at a coarser resolution', () => {
const urls = Array.from({ length: 4000 }, (_, index) => `/view?filename=f${index}.png`);
const media = { kind: 'frames', urls, frameCount: urls.length, status: 'ready' };
const auto = referencePlan(media, { maxFrames: AUTO_PREVIEW_FRAMES });
const full = referencePlan(media, { maxFrames: MAX_PREVIEW_FRAMES });
assert.equal(auto.frames.length, AUTO_PREVIEW_FRAMES);
assert.equal(full.frames.length, MAX_PREVIEW_FRAMES);
// Both describe the same 4000-frame timeline and both reach its last frame.
assert.equal(auto.frameCount, full.frameCount);
assert.equal(auto.frames[0].frame, 0);
assert.equal(auto.frames.at(-1).frame, 3999);
assert.equal(full.frames.at(-1).frame, 3999);
});
test('the automatic budget is forwarded to video sampling', async () => {
const { node } = videoLoaderGraph();
let options = null;
await buildReferenceFrames(node, 'reference_media', {
toUrl: (path, type) => `/api/view?filename=${path}&type=${type}`,
maxFrames: AUTO_PREVIEW_FRAMES,
sampleVideo: async (url, opts) => { options = opts; return { frameCount: 900, frames: [] }; },
});
assert.equal(options.maxFrames, AUTO_PREVIEW_FRAMES);
});
test('progress from video sampling is forwarded through buildReferenceFrames', async () => {
const { node } = videoLoaderGraph();
const seen = [];
await buildReferenceFrames(node, 'reference_media', {
toUrl: (path, type) => `/api/view?filename=${path}&type=${type}`,
sampleVideo: async (url, opts) => {
opts.onProgress?.(1, 3);
opts.onProgress?.(3, 3);
return { frameCount: 3, frames: [] };
},
onProgress: (done, total) => seen.push([done, total]),
});
assert.deepEqual(seen, [[1, 3], [3, 3]]);
});
test('an image batch plan never calls onProgress: there is nothing to sample', async () => {
const source = { id: 2, comfyClass: 'VHS_LoadVideo', widgets: [{ name: 'video', value: 'shot.mp4' }], imgs: [{ src: '/a.png' }] };
const node = guideMaster();
graph([node, source], { 7: { origin_id: 2 } });
let called = false;
await build(node, async () => ({ frameCount: 0, frames: [] }));
const built = await buildReferenceFrames(node, 'reference_media', {
toUrl: (path, type) => `/api/view?filename=${path}&type=${type}`,
onProgress: () => { called = true; },
});
assert.equal(called, false);
assert.equal(built.frameCount, 1);
});
test('preview source signatures include executed images and video metadata', () => {
const source = { id: 3, widgets: [], images: ['a'], video_query: { loaded: { frames: 12 } } };
const before = previewSourceSignature(source);
source.images = ['b'];
assert.notEqual(previewSourceSignature(source), before);
source.images = ['a'];
source.video_query.loaded.frames = 13;
assert.notEqual(previewSourceSignature(source), before);
});