/** * Still Extractor Plugin Tests * * Pure helpers run anywhere; the extraction tests need ffmpeg and the * generated fixtures, so they run in the test container (./test.sh). */ import { describe, it, expect, beforeAll } from 'vitest'; import { execSync } from 'child_process'; import { existsSync, mkdirSync } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { manifest, process as processFile, candidateOffsets, frameStats, isUsableFrame, sanitizeFilename, stillFilename, computeMidHash256FromBuffer, selectPrimaryVideoStream, extractStill, grabFrame, configure, withExtractionSlot, probeDuration, durationFromMeta, probeMedia, pickPrimaryVideoStream, recentlyFailed, rememberNoUsableFrame, } from '../src/plugin.js'; import type { CallbackPayload } from '../src/types.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const FIXTURES = path.join(__dirname, 'fixtures'); const TEMP = '/tmp/still-extractor-test'; function hasFfmpeg(): boolean { try { execSync('ffmpeg -version', { stdio: 'ignore' }); return true; } catch { return false; } } function fixture(name: string): string { return path.join(FIXTURES, name); } /** Build the `stream/{n}` table exactly the way the ffmpeg plugin writes it. */ function streamTableFor(file: string): Record { const probe = JSON.parse( execSync(`ffprobe -v quiet -print_format json -show_streams "${file}"`, { encoding: 'utf-8' }) ) as { streams: Array> }; const table: Record = {}; let n = 0; for (const s of probe.streams) { if (s.codec_type !== 'video' && s.codec_type !== 'audio' && s.codec_type !== 'subtitle') continue; const entry: Record = { type: s.codec_type }; if (s.codec_name) entry.codec = String(s.codec_name); if (s.index != null) entry.index = s.index; if (s.width) entry.width = s.width; if (s.height) entry.height = s.height; // The ffmpeg plugin drops avg_frame_rate when it is "0/0" — the very // signal we lean on to spot attached cover art. if (s.avg_frame_rate && s.avg_frame_rate !== '0/0') entry.frameRate = String(s.avg_frame_rate); table[`stream/${n}`] = JSON.stringify(entry); n++; } return table; } const ffmpegAvailable = hasFfmpeg(); const fixturesReady = existsSync(fixture('color.mp4')); describe('manifest', () => { it('declares its dependencies and queue', () => { expect(manifest.id).toBe('still-extractor'); expect(manifest.dependencies).toEqual(['file-info', 'ffmpeg']); expect(manifest.defaultQueue).toBe('background'); expect(manifest.schema?.still).toBeDefined(); }); it('does not advertise writing poster or stillPath', () => { const keys = Object.keys(manifest.schema ?? {}); expect(keys).toEqual(['still']); }); }); describe('candidateOffsets', () => { it('spreads candidates through the middle of the file', () => { expect(candidateOffsets(100, [20, 45, 70])).toEqual([20, 45, 70]); }); it('keeps away from both ends', () => { const offsets = candidateOffsets(10, [1, 50, 99]); for (const t of offsets) { expect(t).toBeGreaterThanOrEqual(2); expect(t).toBeLessThanOrEqual(8); } }); it('falls back to a blind attempt when duration is unknown', () => { expect(candidateOffsets(undefined)).toEqual([10, 0]); expect(candidateOffsets(0)).toEqual([10, 0]); }); it('takes the midpoint of a very short clip', () => { expect(candidateOffsets(3)).toEqual([1.5]); }); it('deduplicates offsets that clamp onto each other', () => { const offsets = candidateOffsets(6, [10, 12, 14]); expect(new Set(offsets).size).toBe(offsets.length); }); }); describe('frameStats / isUsableFrame', () => { it('measures a flat buffer as zero-variance', () => { const stats = frameStats(Buffer.alloc(1024, 128)); expect(stats.mean).toBe(128); expect(stats.stddev).toBe(0); }); it('rejects black, white and flat frames', () => { expect(isUsableFrame({ mean: 2, stddev: 1 })).toBe(false); // black expect(isUsableFrame({ mean: 252, stddev: 1 })).toBe(false); // blown out expect(isUsableFrame({ mean: 128, stddev: 2 })).toBe(false); // flat grey }); it('accepts a frame with real detail', () => { expect(isUsableFrame({ mean: 110, stddev: 45 })).toBe(true); }); }); describe('filenames', () => { it('strips path-hostile characters', () => { expect(sanitizeFilename('a/b:c*d?')).toBe('abcd'); }); it('carries the source CID so two files cannot collide', () => { const a = stillFilename('/files/watch/Show/episode.mkv', 'bafkreiaaaaaaaaaaaa111111'); const b = stillFilename('/files/watch/Other/episode.mkv', 'bafkreiaaaaaaaaaaaa222222'); expect(a).not.toBe(b); expect(a.endsWith('_still.jpg')).toBe(true); expect(a.startsWith('episode[')).toBe(true); }); }); describe('computeMidHash256FromBuffer', () => { // Vector produced by packages/meta-hash's own computeMidHash256Sync over a // file holding exactly this string. This function is a hand-rolled copy of // that algorithm; the vector is what pins the copy to the original. it('matches meta-hash for a known buffer', () => { const cid = computeMidHash256FromBuffer(Buffer.from('metamesh-still-extractor-cid-vector')); expect(cid).toBe('bagacbabaecybg7wcyxl7su3dvjleuwgwil5tgeoybrwc35jasqtywb6bnjzk4'); }); }); describe('selectPrimaryVideoStream', () => { it('returns null when there is no video stream', () => { expect(selectPrimaryVideoStream({ 'stream/0': JSON.stringify({ type: 'audio', index: 0, codec: 'aac' }), })).toBeNull(); expect(selectPrimaryVideoStream(undefined)).toBeNull(); expect(selectPrimaryVideoStream({})).toBeNull(); }); it('reads the flattened `stream` array form too', () => { const picked = selectPrimaryVideoStream({ stream: JSON.stringify([ JSON.stringify({ type: 'audio', index: 0 }), JSON.stringify({ type: 'video', index: 1, width: 1920, height: 1080, codec: 'h264', frameRate: '24/1' }), ]), } as unknown as Record); expect(picked?.index).toBe(1); }); it('prefers the larger of two real video streams', () => { const picked = selectPrimaryVideoStream({ 'stream/0': JSON.stringify({ type: 'video', index: 0, width: 640, height: 480, codec: 'h264', frameRate: '24/1' }), 'stream/1': JSON.stringify({ type: 'video', index: 1, width: 1920, height: 1080, codec: 'h264', frameRate: '24/1' }), }); expect(picked?.index).toBe(1); }); it('ignores a bigger cover art stream in favour of the moving picture', () => { const picked = selectPrimaryVideoStream({ 'stream/0': JSON.stringify({ type: 'video', index: 0, width: 320, height: 240, codec: 'h264', frameRate: '24/1' }), // attached_pic: no frameRate, still-image codec, but much larger 'stream/1': JSON.stringify({ type: 'video', index: 1, width: 600, height: 900, codec: 'mjpeg' }), }); expect(picked?.index).toBe(0); }); it('still returns something when nothing looks like moving video', () => { const picked = selectPrimaryVideoStream({ 'stream/0': JSON.stringify({ type: 'video', index: 0, width: 600, height: 900, codec: 'mjpeg' }), }); expect(picked?.index).toBe(0); }); it.skipIf(!ffmpegAvailable || !fixturesReady)( 'picks the film, not the cover, on a real file', () => { const picked = selectPrimaryVideoStream(streamTableFor(fixture('cover-art.mp4'))); expect(picked).not.toBeNull(); expect(picked!.width).toBe(320); expect(picked!.height).toBe(240); } ); }); describe.skipIf(!ffmpegAvailable || !fixturesReady)('extraction', () => { beforeAll(() => { mkdirSync(TEMP, { recursive: true }); }); it('extracts a usable frame from a colourful video', async () => { const result = await extractStill(fixture('color.mp4'), 0, 10, TEMP); expect(result).not.toBeNull(); expect(result!.jpeg.subarray(0, 2)).toEqual(Buffer.from([0xff, 0xd8])); // JPEG SOI expect(isUsableFrame(result!.stats)).toBe(true); }); it('skips past a black head to a later offset', async () => { // 20s file, 6s of black: the 20% candidate (4s) is black and must lose. const result = await extractStill(fixture('black-head.mp4'), 0, 20, TEMP); expect(result).not.toBeNull(); expect(result!.offsetSec).toBeGreaterThan(6); expect(isUsableFrame(result!.stats)).toBe(true); }); it('returns null for a file with nothing to decode', async () => { const frame = await grabFrame(fixture('audio-only.mp3'), 0, 1, path.join(TEMP, 'nope.jpg')); expect(frame).toBeNull(); }); it('scales the frame down to the configured width', async () => { const result = await extractStill(fixture('color.mp4'), 0, 10, TEMP); expect(result).not.toBeNull(); // Source is 320x240, below the 640 cap, so it must come back untouched. expect(result!.jpeg.length).toBeGreaterThan(0); expect(result!.jpeg.length).toBeLessThan(200_000); }); }); describe('process - skip logic', () => { const collect = () => { const seen: CallbackPayload[] = []; return { seen, cb: async (p: CallbackPayload) => { seen.push(p); } }; }; const req = (existingMeta: Record) => ({ taskId: 't1', cid: 'bafkreitest', filePath: '/files/watch/test.mkv', callbackUrl: 'http://localhost/callback', // Unreachable on purpose: every case here must bail before any I/O. metaCoreUrl: 'http://127.0.0.1:9', existingMeta, }); it('skips non-video files', async () => { const { seen, cb } = collect(); await processFile(req({ fileType: 'image' }), cb); expect(seen[0].status).toBe('skipped'); expect(seen[0].reason).toBe('Not a video file'); }); it('skips a record that already has a still', async () => { const { seen, cb } = collect(); await processFile(req({ fileType: 'video', still: 'bafkreialready' }), cb); expect(seen[0].status).toBe('skipped'); expect(seen[0].reason).toBe('Still already present'); }); it('skips when there is no video stream', async () => { const { seen, cb } = collect(); await processFile( req({ fileType: 'video', 'stream/0': JSON.stringify({ type: 'audio', index: 0 }) }), cb ); expect(seen[0].status).toBe('skipped'); expect(seen[0].reason).toBe('No video stream'); }); }); describe('withExtractionSlot', () => { const measurePeak = async (jobs: number) => { let active = 0; let peak = 0; const job = () => withExtractionSlot(async () => { active++; peak = Math.max(peak, active); await new Promise((r) => setTimeout(r, 20)); active--; }); await Promise.all(Array.from({ length: jobs }, job)); return peak; }; it('never runs more extractions than maxConcurrent', async () => { // The dev-stack OOM: four concurrent 1080p grabs in a 512 MiB container. configure({ maxConcurrent: 1 }); expect(await measurePeak(4)).toBe(1); }); it('allows parallelism when configured, and releases every slot', async () => { configure({ maxConcurrent: 3 }); expect(await measurePeak(6)).toBe(3); configure({ maxConcurrent: 1 }); expect(await measurePeak(3)).toBe(1); }); it('releases the slot when the job throws', async () => { configure({ maxConcurrent: 1 }); await expect(withExtractionSlot(async () => { throw new Error('boom'); })).rejects.toThrow('boom'); expect(await measurePeak(2)).toBe(1); // would hang forever if the slot leaked }); }); describe('configure', () => { it.skipIf(!ffmpegAvailable || !fixturesReady)( 'treats null numeric values as absent instead of zero', async () => { // Number(null) === 0: before the fix this set a 0 px width and a 0 ms // timeout, killing every grab on the spot. configure({ maxWidth: null, jpegQuality: null, frameTimeoutMs: null, maxConcurrent: null }); mkdirSync(TEMP, { recursive: true }); const result = await extractStill(fixture('color.mp4'), 0, 10, TEMP); expect(result).not.toBeNull(); } ); }); describe.skipIf(!ffmpegAvailable || !fixturesReady)('probeDuration', () => { it('reads the duration from the file header', async () => { const d = await probeDuration(fixture('color.mp4')); expect(d).toBeGreaterThan(9); expect(d).toBeLessThan(11); }); it('returns undefined for an unreadable input instead of throwing', async () => { expect(await probeDuration(fixture('does-not-exist.mkv'))).toBeUndefined(); }); it('turns a record with no duration into real candidate offsets, not the 10s/0s fallback', async () => { const d = await probeDuration(fixture('black-head.mp4')); expect(d).toBeDefined(); expect(candidateOffsets(d)).not.toEqual([10, 0]); // and the 20% candidate lands past the 6s black head expect(candidateOffsets(d)[0]).toBeGreaterThanOrEqual(4); }); }); describe('durationFromMeta', () => { it('reads the nested document form meta-sort actually sends', () => { expect(durationFromMeta({ fileType: 'video', fileinfo: { duration: '1433.089', formatName: 'matroska,webm' } })) .toBeCloseTo(1433.089); }); it('reads a numeric nested duration, the exact shape metasort-app/meta returns', () => { // Captured from the dev stack: fileinfo.duration is a JSON number there, not a string. expect(durationFromMeta({ fileinfo: { duration: 1433.089, formatName: 'matroska,webm' } })).toBe(1433.089); }); it('reads the flat meta-core key', () => { expect(durationFromMeta({ 'fileinfo/duration': '1380.031' })).toBeCloseTo(1380.031); }); it('reads a JSON-string fileinfo', () => { expect(durationFromMeta({ fileinfo: JSON.stringify({ duration: '42.5' }) })).toBe(42.5); }); it('treats missing, zero and junk values as absent', () => { expect(durationFromMeta(undefined)).toBeUndefined(); expect(durationFromMeta({})).toBeUndefined(); expect(durationFromMeta({ fileinfo: {} })).toBeUndefined(); expect(durationFromMeta({ fileinfo: { duration: '0' } })).toBeUndefined(); expect(durationFromMeta({ fileinfo: { duration: 'N/A' } })).toBeUndefined(); expect(durationFromMeta({ fileinfo: 'not json' })).toBeUndefined(); }); }); describe('selectPrimaryVideoStream — nested payload shapes', () => { it('reads a nested stream object keyed by index', () => { const picked = selectPrimaryVideoStream({ stream: { '0': JSON.stringify({ type: 'audio', index: 0 }), '1': JSON.stringify({ type: 'video', index: 1, width: 1280, height: 720, codec: 'h264', frameRate: '24/1' }), }, } as unknown as Record); expect(picked?.index).toBe(1); }); it('reads a nested stream array of objects', () => { const picked = selectPrimaryVideoStream({ stream: [ { type: 'video', index: 0, width: 1920, height: 1080, codec: 'av1', frameRate: '24000/1001' }, { type: 'audio', index: 1 }, ], } as unknown as Record); expect(picked?.index).toBe(0); }); }); describe('pickPrimaryVideoStream', () => { it('never prefers an attached picture, even one with a frame rate', () => { const picked = pickPrimaryVideoStream([ { index: 0, width: 640, height: 480, codec: 'h264', frameRate: '24/1' }, { index: 1, width: 1000, height: 1500, codec: 'h264', frameRate: '24/1', attachedPic: true }, ]); expect(picked?.index).toBe(0); }); it('returns null with no candidates', () => { expect(pickPrimaryVideoStream([])).toBeNull(); }); }); describe.skipIf(!ffmpegAvailable || !fixturesReady)('probeMedia', () => { it('reads the duration and video stream of a plain video', async () => { const probe = await probeMedia(fixture('color.mp4')); expect(probe.duration).toBeGreaterThan(9); expect(pickPrimaryVideoStream(probe.videoStreams)?.width).toBe(320); }); it('flags attached cover art and never picks it', async () => { const probe = await probeMedia(fixture('cover-art.mp4')); expect(probe.videoStreams.some((v) => v.attachedPic)).toBe(true); const primary = pickPrimaryVideoStream(probe.videoStreams); expect(primary?.attachedPic).toBe(false); expect(primary?.width).toBe(320); }); it('finds no video stream in audio-only input', async () => { expect(pickPrimaryVideoStream((await probeMedia(fixture('audio-only.mp3'))).videoStreams)).toBeNull(); }); it('returns an empty probe for unreadable input instead of throwing', async () => { expect(await probeMedia(fixture('does-not-exist.mkv'))).toEqual({ videoStreams: [] }); }); }); describe('no-usable-frame memory', () => { it('remembers a failed cid for an hour, then forgets it', () => { const t0 = 1_000_000; expect(recentlyFailed('bafk-fail', t0)).toBe(false); rememberNoUsableFrame('bafk-fail', t0); expect(recentlyFailed('bafk-fail', t0 + 59 * 60 * 1000)).toBe(true); expect(recentlyFailed('bafk-fail', t0 + 61 * 60 * 1000)).toBe(false); }); it('is keyed per cid', () => { rememberNoUsableFrame('bafk-a', 5); expect(recentlyFailed('bafk-b', 5)).toBe(false); }); });