143 lines
5.0 KiB
JavaScript
143 lines
5.0 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { LocalFilmstripMedia, sampleLocalVideo } from '../../web/ui/local_media.js';
|
|
|
|
function fakeVideo(duration) {
|
|
const listeners = new Map();
|
|
let currentTime = 0;
|
|
const fire = (name) => queueMicrotask(() => {
|
|
const list = listeners.get(name) || [];
|
|
listeners.set(name, []);
|
|
for (const cb of list) cb();
|
|
});
|
|
return {
|
|
seeks: [],
|
|
duration,
|
|
videoWidth: 1920,
|
|
videoHeight: 1080,
|
|
set src(value) { this._src = value; fire('loadedmetadata'); },
|
|
get src() { return this._src; },
|
|
get currentTime() { return currentTime; },
|
|
set currentTime(value) { currentTime = value; this.seeks.push(value); fire('seeked'); },
|
|
addEventListener(name, cb) { listeners.set(name, [...(listeners.get(name) || []), cb]); },
|
|
removeEventListener(name, cb) { listeners.set(name, (listeners.get(name) || []).filter((item) => item !== cb)); },
|
|
removeAttribute() {},
|
|
load() {},
|
|
};
|
|
}
|
|
|
|
function installFakeDom(video) {
|
|
const canvas = {
|
|
width: 0,
|
|
height: 0,
|
|
getContext: () => ({ drawImage: () => {} }),
|
|
toDataURL: () => 'data:image/jpeg;base64,fake',
|
|
};
|
|
globalThis.document = { createElement: (tag) => (tag === 'video' ? video : canvas) };
|
|
return () => { delete globalThis.document; };
|
|
}
|
|
|
|
test('an untrimmed 10s clip samples every frame at the 24 fps midpoint', async () => {
|
|
const video = fakeVideo(10);
|
|
const restore = installFakeDom(video);
|
|
try {
|
|
const result = await sampleLocalVideo('/api/view?filename=clip.mp4', { fps: 24 });
|
|
assert.equal(result.frameCount, 240);
|
|
assert.equal(result.frames.length, 240);
|
|
assert.ok(Math.abs(video.seeks[0] - 0.5 / 24) < 1e-9);
|
|
assert.ok(Math.abs(video.seeks[1] - 1.5 / 24) < 1e-9);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('skip_first_frames and select_every_nth shift and stretch the sampled times', async () => {
|
|
const video = fakeVideo(60);
|
|
const restore = installFakeDom(video);
|
|
try {
|
|
const result = await sampleLocalVideo('/api/view?filename=clip.mp4', {
|
|
fps: 24,
|
|
startFrame: 100,
|
|
stride: 2,
|
|
frameCountHint: 48,
|
|
});
|
|
assert.equal(result.frameCount, 48, 'the load cap bounds the output stream');
|
|
// output frame 0 -> source frame 100, output frame 1 -> source frame 102
|
|
assert.ok(Math.abs(video.seeks[0] - 100.5 / 24) < 1e-9);
|
|
assert.ok(Math.abs(video.seeks[1] - 102.5 / 24) < 1e-9);
|
|
assert.ok(Math.abs(video.seeks.at(-1) - (100 + 47 * 2 + 0.5) / 24) < 1e-9);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('without a cap the available output length accounts for skip and stride', async () => {
|
|
const video = fakeVideo(10);
|
|
const restore = installFakeDom(video);
|
|
try {
|
|
const result = await sampleLocalVideo('/api/view?filename=clip.mp4', { fps: 24, startFrame: 40, stride: 2 });
|
|
assert.equal(result.frameCount, 100, '(240 - 40) / 2');
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('thumbnails stay inside the 160x90 budget', async () => {
|
|
const video = fakeVideo(1);
|
|
const restore = installFakeDom(video);
|
|
try {
|
|
const result = await sampleLocalVideo('/api/view?filename=clip.mp4', { fps: 24 });
|
|
assert.equal(result.frames.length, 24);
|
|
assert.match(result.frames[0].url, /^data:image\/jpeg/);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('onProgress reports one call per sampled frame, in order, ending at the total', async () => {
|
|
const video = fakeVideo(2);
|
|
const restore = installFakeDom(video);
|
|
try {
|
|
const calls = [];
|
|
const result = await sampleLocalVideo('/api/view?filename=clip.mp4', {
|
|
fps: 24,
|
|
onProgress: (done, total) => calls.push([done, total]),
|
|
});
|
|
assert.equal(calls.length, result.frameCount);
|
|
assert.deepEqual(calls[0], [1, result.frameCount]);
|
|
assert.deepEqual(calls.at(-1), [result.frameCount, result.frameCount]);
|
|
for (let index = 1; index < calls.length; index += 1) assert.equal(calls[index][0], calls[index - 1][0] + 1);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('sampling without onProgress is unaffected', async () => {
|
|
const video = fakeVideo(1);
|
|
const restore = installFakeDom(video);
|
|
try {
|
|
const result = await sampleLocalVideo('/api/view?filename=clip.mp4', { fps: 24 });
|
|
assert.equal(result.frames.length, 24);
|
|
} finally {
|
|
restore();
|
|
}
|
|
});
|
|
|
|
test('a newer local media load cannot be overwritten by an older pending load', async () => {
|
|
const pending = [];
|
|
const media = new LocalFilmstripMedia({
|
|
urls: { createObjectURL: (file) => `blob:${file.name}`, revokeObjectURL() {} },
|
|
sampleVideo: (url) => new Promise((resolve) => pending.push({ url, resolve })),
|
|
});
|
|
|
|
const first = media.loadFiles([{ name: 'first.mp4', type: 'video/mp4' }]);
|
|
const second = media.loadFiles([{ name: 'second.mp4', type: 'video/mp4' }]);
|
|
pending[1].resolve({ frameCount: 2, frames: [{ frame: 0, url: 'second-frame' }] });
|
|
await second;
|
|
pending[0].resolve({ frameCount: 1, frames: [{ frame: 0, url: 'first-frame' }] });
|
|
await first;
|
|
|
|
assert.equal(media.snapshot().label, 'second.mp4');
|
|
});
|