UNPKG

@omnimedia/omnitool

Version:

open source video processing tools

330 lines 14.3 kB
import { Science, test, expect } from "@e280/science"; import { O } from "../sugar/o.js"; import { ms } from "../../units/ms.js"; import { Omni } from "../sugar/omni.js"; import { fps } from "../../units/fps.js"; import { Datafile } from "../utils/datafile.js"; import { Driver } from "../../driver/driver.js"; import { AudioMix } from "./export/parts/audio-mix.js"; import { resolveTransformAnimation } from "../utils/anim.js"; import { produceAudio } from "./export/parts/produce-audio.js"; import { resampleToPlanar } from "./export/parts/resamplers.js"; import { loadVideo } from "../../demo/routines/load-video.js"; import { produceVideo } from "./export/parts/produce-video.js"; import { CursorVisualSampler } from "./export/parts/cursor.js"; import { applyGainToPlanar } from "./export/parts/audio-gain.js"; import { createVisualSampler } from "./parts/samplers/visual/sampler.js"; const workerUrl = new URL("../driver/driver.worker.bundle.min.js", import.meta.url); export async function setupTest() { const driver = await Driver.setup({ workerUrl }); const omni = new Omni(driver); const testVideo = await loadVideo("/assets/temp/test.mp4"); const { videoA } = await omni.load({ videoA: Datafile.make(testVideo, "test.mp4") }); const resolveMedia = (hash) => omni.resources.require(hash).blob; return { driver, omni, testVideo, videoA, resolveMedia }; } async function collect(iterable) { const out = []; for await (const item of iterable) out.push(item); return out; } const near = (actual, expected, eps = 1e-6) => Math.abs(actual - expected) < eps; export default Science.suite({ "empty transform animation tracks resolve to identity transform defaults": test(async () => { const transform = resolveTransformAnimation(ms(100), { terp: "linear", track: { position: { x: [], y: [] }, scale: { x: [], y: [] }, rotation: [], }, }); expect(transform[0][0]).is(0); expect(transform[0][1]).is(0); expect(transform[1][0]).is(1); expect(transform[1][1]).is(1); expect(transform[2]).is(0); }), "cursor visual sampler cannot get previous samples": test(async () => { const { omni, videoA, resolveMedia, driver } = await setupTest(); const { timeline } = new O({ timeline: omni.timeline(o => o.sequence(o.video(videoA, { duration: 2000 }), o.gap(500), o.video(videoA, { duration: 2000 }), o.audio(videoA, { duration: 500 }))) }); const cursor = new CursorVisualSampler(driver, resolveMedia, timeline); await cursor.next(ms(1000)); await expect(async () => await cursor.next(ms(100))).throwsAsync(); }), "visual sampler gives correct layer at x time": test(async () => { const { omni, videoA, resolveMedia } = await setupTest(); const { timeline } = new O({ timeline: omni.timeline(o => o.sequence(o.video(videoA, { duration: 2000 }), o.gap(500), o.video(videoA, { duration: 2000 }), o.audio(videoA, { duration: 500 }), o.text("123", { duration: 1000 }))) }); const sampler = createVisualSampler(resolveMedia); const imgLayer = await sampler.sample(timeline, ms(1000)); expect(imgLayer[0].kind).is("image"); const gapLayer = await sampler.sample(timeline, ms(2300)); expect(gapLayer[0].kind).is("gap"); const imgLayer1 = await sampler.sample(timeline, ms(2700)); expect(imgLayer1[0].kind).is("image"); const textLayer = await sampler.sample(timeline, ms(5500)); expect(textLayer[0].kind).is("text"); }), "disabled visual item keeps sequence time but does not render": test(async () => { const { omni, videoA, resolveMedia } = await setupTest(); const { timeline } = new O({ timeline: omni.timeline(o => o.sequence(o.video(videoA, { duration: 2000, enabled: false }), o.video(videoA, { duration: 2000 }))) }); const sampler = createVisualSampler(resolveMedia); const disabledLayers = await sampler.sample(timeline, ms(1000)); expect(disabledLayers.length).is(0); const nextLayers = await sampler.sample(timeline, ms(2500)); expect(nextLayers[0].kind).is("image"); }), "audio mix sums overlapping chunks": test(async () => { const mixer = new AudioMix({ chunkFrames: 4, clamp: false }); async function* samples() { yield { planes: [new Float32Array([0.25, 0.25, 0.25, 0.25])], sampleRate: 4, timestamp: 0 }; yield { planes: [new Float32Array([0.5, 0.5, 0.5, 0.5])], sampleRate: 4, timestamp: 0.5 }; } const mixed = await collect(mixer.mix(samples())); expect(mixed.length).is(2); expect(mixed[0].startFrame).is(0); expect(mixed[0].frames).is(4); expect(mixed[0].channels).is(1); expect(mixed[0].planar[0]).is(0.25); expect(mixed[0].planar[1]).is(0.25); expect(mixed[0].planar[2]).is(0.75); expect(mixed[0].planar[3]).is(0.75); expect(mixed[1].startFrame).is(4); expect(mixed[1].planar[0]).is(0.5); expect(mixed[1].planar[1]).is(0.5); expect(mixed[1].planar[2]).is(0); expect(mixed[1].planar[3]).is(0); }), "audio mix clamps output to [-1, 1]": test(async () => { const mixer = new AudioMix({ chunkFrames: 4, clamp: true }); async function* samples() { yield { planes: [new Float32Array([0.9, 0.9, 0.9, 0.9])], sampleRate: 4, timestamp: 0 }; yield { planes: [new Float32Array([0.8, 0.8, 0.8, 0.8])], sampleRate: 4, timestamp: 0 }; } const mixed = await collect(mixer.mix(samples())); expect(mixed.length).is(1); expect(mixed[0].planar[0]).is(1); expect(mixed[0].planar[1]).is(1); expect(mixed[0].planar[2]).is(1); expect(mixed[0].planar[3]).is(1); }), "audio mix fills gaps with silence": test(async () => { const mixer = new AudioMix({ chunkFrames: 4, clamp: false }); async function* samples() { yield { planes: [new Float32Array([1, 1, 1, 1])], sampleRate: 4, timestamp: 0 }; yield { planes: [new Float32Array([2, 2, 2, 2])], sampleRate: 4, timestamp: 2 }; } const mixed = await collect(mixer.mix(samples())); expect(mixed.length).is(3); expect(mixed[0].startFrame).is(0); expect(mixed[0].planar[0]).is(1); expect(mixed[1].startFrame).is(4); expect(mixed[1].planar[0]).is(0); expect(mixed[1].planar[3]).is(0); expect(mixed[2].startFrame).is(8); expect(mixed[2].planar[0]).is(2); }), "audio mix truncates negative timestamps": test(async () => { const mixer = new AudioMix({ chunkFrames: 4, clamp: false }); async function* samples() { yield { planes: [new Float32Array([1, 2, 3, 4])], sampleRate: 4, timestamp: -0.5 }; } const mixed = await collect(mixer.mix(samples())); expect(mixed.length).is(1); expect(mixed[0].startFrame).is(0); expect(mixed[0].planar[0]).is(3); expect(mixed[0].planar[1]).is(4); expect(mixed[0].planar[2]).is(0); expect(mixed[0].planar[3]).is(0); }), "audio mix handles stereo planar layout": test(async () => { const mixer = new AudioMix({ chunkFrames: 4, clamp: false }); async function* samples() { yield { planes: [ new Float32Array([1, 1, 1, 1]), new Float32Array([2, 2, 2, 2]) ], sampleRate: 4, timestamp: 0 }; } const mixed = await collect(mixer.mix(samples())); expect(mixed.length).is(1); expect(mixed[0].channels).is(2); expect(mixed[0].planar.length).is(8); expect(mixed[0].planar[0]).is(1); expect(mixed[0].planar[4]).is(2); }), "applyGainToPlanar scales values across multiple channels": test(async () => { const planes = [ new Float32Array([0.2, -0.4]), new Float32Array([0.6, -0.8]) ]; applyGainToPlanar(planes, 0.5); expect(near(planes[0][0], 0.1)).ok(); expect(near(planes[0][1], -0.2)).ok(); expect(near(planes[1][0], 0.3)).ok(); expect(near(planes[1][1], -0.4)).ok(); }), "applyGainToPlanar does not mutate when gain is 1": test(async () => { const planes = [new Float32Array([0.5, -0.5])]; applyGainToPlanar(planes, 1); expect(near(planes[0][0], 0.5)).ok(); expect(near(planes[0][1], -0.5)).ok(); }), "applyGainToPlanar mutes entirely when gain is 0": test(async () => { const planes = [new Float32Array([0.9, -0.9])]; applyGainToPlanar(planes, 0); expect(planes[0][0]).is(0); expect(planes[0][1]).is(0); }), "resampleToPlanar returns original data when sample rates match": test(async () => { const sample = { numberOfFrames: 3, numberOfChannels: 1, sampleRate: 48000, copyTo: (dest) => dest.set([0.1, 0.2, 0.3]) }; const result = resampleToPlanar(sample, 48000); expect(result.frames).is(3); expect(result.data.length).is(1); expect(near(result.data[0][0], 0.1)).ok(); expect(near(result.data[0][1], 0.2)).ok(); expect(near(result.data[0][2], 0.3)).ok(); }), "resampleToPlanar upsamples using linear interpolation": test(async () => { const sample = { numberOfFrames: 2, numberOfChannels: 1, sampleRate: 1, copyTo: (dest) => dest.set([10, 20]) }; const result = resampleToPlanar(sample, 2); expect(result.frames).is(4); expect(near(result.data[0][0], 10)).ok(); expect(near(result.data[0][1], 15)).ok(); expect(near(result.data[0][2], 20)).ok(); expect(near(result.data[0][3], 20)).ok(); }), "resampleToPlanar downsamples correctly": test(async () => { const sample = { numberOfFrames: 4, numberOfChannels: 1, sampleRate: 4, copyTo: (dest) => dest.set([1, 2, 3, 4]) }; const result = resampleToPlanar(sample, 2); expect(result.frames).is(2); expect(near(result.data[0][0], 1)).ok(); expect(near(result.data[0][1], 3)).ok(); }), "resampleToPlanar processes multi-channel planar data": test(async () => { const planes = [ new Float32Array([1, 2]), new Float32Array([3, 4]) ]; const sample = { numberOfFrames: 2, numberOfChannels: 2, sampleRate: 1, copyTo: (dest, options) => { dest.set(planes[options.planeIndex]); } }; const result = resampleToPlanar(sample, 2); expect(result.data.length).is(2); expect(near(result.data[0][1], 1.5)).ok(); expect(near(result.data[1][1], 3.5)).ok(); }), "resampleToPlanar handles zero channels gracefully": test(async () => { const sample = { numberOfFrames: 10, numberOfChannels: 0, sampleRate: 48000, copyTo: () => { throw new Error("Should not be called"); } }; const result = resampleToPlanar(sample, 44100); expect(result.data.length).is(0); expect(result.frames).is(0); }), "resampleToPlanar enforces minimum of 1 frame on aggressive downsample": test(async () => { const sample = { numberOfFrames: 1, numberOfChannels: 1, sampleRate: 48000, copyTo: (dest) => dest.set([0.8]) }; const result = resampleToPlanar(sample, 8000); expect(result.frames).is(1); expect(result.data[0].length).is(1); expect(near(result.data[0][0], 0.8)).ok(); }), "resampleToPlanar calls copyTo with strict contract shape": test(async () => { let passedOptions = null; const sample = { numberOfFrames: 2, numberOfChannels: 1, sampleRate: 48000, copyTo: (dest, options) => { passedOptions = options; } }; resampleToPlanar(sample, 44100); expect(passedOptions).not.is(null); expect(passedOptions.planeIndex).is(0); expect(passedOptions.format).is("f32-planar"); }), "5s long export at 30fps renders exacly 150 frames": test(async () => { const { omni, videoA, driver, resolveMedia } = await setupTest(); const { timeline } = new O({ timeline: omni.timeline(o => o.sequence(o.video(videoA, { duration: 2000 }), o.gap(500), o.video(videoA, { duration: 2000 }), o.audio(videoA, { duration: 500 }))) }); const readable = produceVideo({ timeline, fps: fps(30), driver, resolveMedia }); let frames = 0; for await (const frame of readable) { frame.close(); frames++; } expect(frames).is(150); }), "5s long audio export at 48000Hz renders exactly 240000 frames": test(async () => { const { omni, videoA, resolveMedia } = await setupTest(); const { timeline } = new O({ timeline: omni.timeline(o => o.sequence(o.video(videoA, { duration: 2000 }), o.gap(500), o.video(videoA, { duration: 2000 }), o.audio(videoA, { duration: 500 }))) }); const readable = produceAudio({ timeline, resolveMedia }); let totalFrames = 0; for await (const chunk of readable) { totalFrames += chunk.numberOfFrames; chunk.close(); } expect(totalFrames).is(240000); }) }); //# sourceMappingURL=renderers.test.js.map