import { describe, expect, it } from "vitest"; import { frameAtTime, movieSpan, pickMovieFormat } from "../src/video/recordMovie"; import { readWebmDuration, withWebmDuration } from "../src/video/webmDuration"; import type { AudioPrep } from "@plate-dyn/core"; const prep = (durationS: number): AudioPrep => ({ samples: new Float32Array(2), fs: 44100, peakPa: 1, gain: 1, durationS, }); describe("the movie recorder's bookkeeping", () => { it("prefers WebM with sound, and falls back to what the browser writes", () => { // WebM with Opus first: it is the one that records with the sound expect(pickMovieFormat(() => true)?.mimeType).toBe("video/webm;codecs=vp9,opus"); expect(pickMovieFormat((m) => m === "video/webm")?.ext).toBe("webm"); expect(pickMovieFormat((m) => m.startsWith("video/mp4"))?.ext).toBe("mp4"); expect(pickMovieFormat(() => false)).toBe(null); }); it("shows the frame whose time has arrived, skipping the ones it fell behind", () => { const t = Float64Array.from([0, 0.1, 0.2, 0.3, 0.4]); expect(frameAtTime(t, 0)).toBe(0); expect(frameAtTime(t, 0.05)).toBe(0); expect(frameAtTime(t, 0.1)).toBe(1); expect(frameAtTime(t, 0.25)).toBe(2); expect(frameAtTime(t, 99)).toBe(4); // a recording that fell behind jumps forward rather than replaying expect(frameAtTime(t, 0.35, 1)).toBe(3); // and never goes back, since the search starts where it left off expect(frameAtTime(t, 0.0, 3)).toBe(3); }); it("runs as long as the frames or the sound, whichever lasts longer", () => { const t = Float64Array.from([0, 0.5, 1.0]); expect(movieSpan(t)).toBe(1.0); expect(movieSpan(t, prep(2.5))).toBe(2.5); expect(movieSpan(t, prep(0.4))).toBe(1.0); expect(movieSpan(new Float64Array(0), prep(3))).toBe(3); }); }); /** A minimal WebM: the header, a Segment of unknown size, an Info with a TimecodeScale, a Cluster. */ function fakeWebm(): Uint8Array { const header = [0x1a, 0x45, 0xdf, 0xa3, 0x84, 1, 2, 3, 4]; const segment = [0x18, 0x53, 0x80, 0x67, 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]; const info = [0x15, 0x49, 0xa9, 0x66, 0x87, 0x2a, 0xd7, 0xb1, 0x83, 0x0f, 0x42, 0x40]; const cluster = [0x1f, 0x43, 0xb6, 0x75, 0x84, 9, 8, 7, 6]; return Uint8Array.from([...header, ...segment, ...info, ...cluster]); } describe("the length written into a recorded WebM", () => { it("writes a duration the file did not have, and keeps everything after it", () => { const before = fakeWebm(); expect(readWebmDuration(before)).toBe(null); const after = withWebmDuration(before, 2.5); expect(after.length).toBe(before.length + 11); expect(readWebmDuration(after)).toBeCloseTo(2.5, 9); // the Cluster that followed the Info section is byte for byte where it was expect(Array.from(after.subarray(after.length - 9))).toEqual([ 0x1f, 0x43, 0xb6, 0x75, 0x84, 9, 8, 7, 6, ]); // and the Info section now declares the longer payload: its size byte sits // after the 9-byte header, the 12-byte Segment header and the 4-byte id expect(after[9 + 12 + 4]).toBe(0x87 + 11); }); it("overwrites a duration that is already there", () => { const once = withWebmDuration(fakeWebm(), 2.5); const twice = withWebmDuration(once, 7.25); expect(twice.length).toBe(once.length); expect(readWebmDuration(twice)).toBeCloseTo(7.25, 9); }); it("writes the length in the units the timecode scale sets", () => { // the fixture's scale is 0x0F4240 ns, a millisecond, so a second is a thousand ticks const patched = withWebmDuration(fakeWebm(), 1); const view = new DataView(patched.buffer, patched.byteOffset, patched.byteLength); expect(view.getFloat64(patched.length - 9 - 8, false)).toBeCloseTo(1000, 9); expect(readWebmDuration(patched)).toBeCloseTo(1, 9); }); it("leaves anything that is not WebM alone", () => { const notWebm = Uint8Array.from([0, 0, 0, 0x20, 0x66, 0x74, 0x79, 0x70, 1, 2, 3, 4]); expect(withWebmDuration(notWebm, 3)).toBe(notWebm); expect(readWebmDuration(notWebm)).toBe(null); expect(withWebmDuration(fakeWebm(), 0)).toEqual(fakeWebm()); }); });