From 5c8385c59bd271ef22264c0f6b48972ef665f7cc Mon Sep 17 00:00:00 2001 From: Worph Date: Sun, 13 Sep 2026 11:53:05 +0200 Subject: [PATCH] align plugin with new architecture --- README.md | 44 ++++++- src/plugin.ts | 312 +++++++++++++++++++++++++++++++++++++++------ test/still.test.ts | 180 ++++++++++++++++++++++++++ 3 files changed, 489 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index ab97b83..acf694a 100644 --- a/README.md +++ b/README.md @@ -38,14 +38,28 @@ in `src/plugin.ts`. ## How a frame is chosen -1. Candidate offsets at 20 / 45 / 70 % of `fileinfo/duration`, clamped 2s away - from both ends (the head is logos and black, the tail is credits). -2. At each offset, one ffmpeg run with **input seeking** (`-ss` before `-i`, so - it Range-seeks over WebDAV instead of decoding from zero) and - `thumbnail=60`, which picks the most representative of the next 60 frames. -3. The encoded JPEG is measured (mean + stddev of a 32×32 grayscale reduction). +1. **Record first, file second.** `fileinfo/duration` and the stream table + from the record; if either is missing, one header-only `ffprobe` reads it + from the file (a record with no stream data at all has been seen on the dev + stack). meta-sort treats a `failed` or + `skipped` ffmpeg dependency as complete, so a missing duration is a normal + case, not a bug. Only if the probe fails too does it fall back to a blind + 10s then 0s — and that fallback lands on distributor idents and opening + credits, so it is a last resort. +2. Candidate offsets at 20 / 45 / 70 % of the duration, clamped 2s away from + both ends (the head is logos and black, the tail is credits). +3. At each offset, one ffmpeg run with **input seeking** (`-ss` before `-i`, so + it Range-seeks over WebDAV instead of decoding from zero), scaled down + **before** `thumbnail=60` picks the most representative of the next 60 + frames. That order matters: `thumbnail` buffers all 60 frames, and at full + 1080p a single grab peaked at 409 MiB versus 196 MiB scaled-first. +4. The encoded JPEG is measured (mean + stddev of a 32×32 grayscale reduction). Black, blown-out and flat frames are rejected and the next offset is tried. -4. All candidates rejected → `skipped`, no field written. +5. All candidates rejected → `skipped`, no field written. + +Extractions run **one at a time** by default (`maxConcurrent`). meta-sort's +background queue sends several tasks at once, and four concurrent 1080p grabs +OOM-killed the 512 MiB container; with one CPU, parallel decodes buy nothing. The primary video stream is the largest one that **looks like moving video** — not `0:v:0`, and not simply the largest. ffprobe reports attached cover art as @@ -68,6 +82,7 @@ curl -k -X PUT https://metasort-dev.localhost:8180/api/plugins/still-extractor/c | `maxWidth` | `640` | Frame is scaled down to at most this width | | `jpegQuality` | `4` | ffmpeg `-q:v`; lower is better quality | | `frameTimeoutMs` | `120000` | Hard kill for one ffmpeg grab | +| `maxConcurrent` | `1` | Extractions allowed to run at once in this instance | ## Development @@ -80,3 +95,18 @@ docker build -t metamesh-plugin-still-extractor:main . Depends on `file-info` (for `fileType`) and `ffmpeg` (for `fileinfo/duration` and the `stream/{n}` table). Runs on the **background** queue. + +### What meta-sort actually sends + +⚠ The `existingMeta` in a `/process` request is the **nested document form** — +`fileinfo: { duration }`, `stream: [...]`, `cids: [...]` — not the flat +`fileinfo/duration` / `stream/{n}` keys you see in meta-core's flat record. +Reading only the flat key made every task fall back to 10s/0s on the dev stack. +`durationFromMeta` and `selectPrimaryVideoStream` accept both shapes; keep it +that way. + +A replay or rescan queues several tasks for the same file. Each reads the +record at dispatch, so the plugin re-checks `still` in meta-core once it holds +an extraction slot, instead of recomputing the same frame for every duplicate. +A file that yields no usable frame is remembered in memory for an hour, so its +queued duplicates skip instead of decoding it again; a later rescan retries. diff --git a/src/plugin.ts b/src/plugin.ts index 2f8db33..29a1b99 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -77,6 +77,7 @@ export const manifest: PluginManifest = { maxWidth: { type: 'number', label: 'Max Width (px)', default: 640 }, jpegQuality: { type: 'number', label: 'JPEG Quality (ffmpeg -q:v, lower is better)', default: 4 }, frameTimeoutMs: { type: 'number', label: 'Per-Frame ffmpeg Timeout (ms)', default: 120000 }, + maxConcurrent: { type: 'number', label: 'Max Concurrent Extractions', default: 1 }, }, }; @@ -86,6 +87,20 @@ let seekPercents = [20, 45, 70]; let maxWidth = 640; let jpegQuality = 4; let frameTimeoutMs = 120000; +let maxConcurrent = 1; + +/** + * A config value as a number, or undefined when it is absent. + * + * ⚠ Not `Number.isFinite(Number(v))`: `Number(null)` and `Number('')` are both + * `0`, so a config payload carrying `"maxWidth": null` would silently set a + * 0-pixel width or a 0 ms timeout. + */ +function configNumber(v: unknown): number | undefined { + if (v === null || v === undefined || v === '') return undefined; + const n = Number(v); + return Number.isFinite(n) ? n : undefined; +} export function configure(config: Record): void { forceRecompute = config.forceRecompute === true; @@ -96,15 +111,72 @@ export function configure(config: Record): void { .filter((p) => Number.isFinite(p) && p > 0 && p < 100); if (parsed.length > 0) seekPercents = parsed; } - if (Number.isFinite(Number(config.maxWidth))) maxWidth = Number(config.maxWidth); - if (Number.isFinite(Number(config.jpegQuality))) jpegQuality = Number(config.jpegQuality); - if (Number.isFinite(Number(config.frameTimeoutMs))) frameTimeoutMs = Number(config.frameTimeoutMs); + const width = configNumber(config.maxWidth); + if (width !== undefined && width > 0) maxWidth = width; + const quality = configNumber(config.jpegQuality); + if (quality !== undefined && quality >= 1 && quality <= 31) jpegQuality = quality; + const timeout = configNumber(config.frameTimeoutMs); + if (timeout !== undefined && timeout > 0) frameTimeoutMs = timeout; + const concurrent = configNumber(config.maxConcurrent); + if (concurrent !== undefined && concurrent >= 1) maxConcurrent = Math.floor(concurrent); console.log( `[still-extractor] Config: forceRecompute=${forceRecompute}, seekPercents=${seekPercents.join(',')}, ` + - `maxWidth=${maxWidth}, jpegQuality=${jpegQuality}, frameTimeoutMs=${frameTimeoutMs}` + `maxWidth=${maxWidth}, jpegQuality=${jpegQuality}, frameTimeoutMs=${frameTimeoutMs}, maxConcurrent=${maxConcurrent}` ); } +let activeExtractions = 0; +const extractionWaiters: Array<() => void> = []; + +/** + * Run `fn` once an extraction slot is free. + * + * meta-sort's background queue dispatches several tasks at this single + * instance at once, and each ffmpeg grab of a 1080p file peaks near 200 MiB + * even with the scale-first chain. Four at a time OOM-killed the 512 MiB + * container on the dev stack (`OOMKilled=true`, ffmpeg dying on SIGKILL). The + * container gets one CPU, so running them concurrently buys no throughput + * anyway — the cap lives here so it holds whatever the scheduler sends. + */ +export async function withExtractionSlot(fn: () => Promise): Promise { + while (activeExtractions >= maxConcurrent) { + await new Promise((resolve) => extractionWaiters.push(resolve)); + } + activeExtractions++; + try { + return await fn(); + } finally { + activeExtractions--; + extractionWaiters.shift()?.(); + } +} + +/** + * Files that produced no usable frame, remembered briefly by cid. + * + * A failed extraction writes no `still`, so the slot-time re-check cannot stop + * the duplicates a replay or rescan queues behind it — on the dev stack a 17s + * title-card test clip was fully decoded a dozen times in a row. An in-memory + * TTL absorbs that burst without inventing a persisted "tried and failed" key, + * and a rescan after the TTL still gets a fresh attempt. + */ +const NO_USABLE_FRAME_TTL_MS = 60 * 60 * 1000; +const noUsableFrameAt = new Map(); + +export function recentlyFailed(cid: string, now = Date.now()): boolean { + const at = noUsableFrameAt.get(cid); + if (at === undefined) return false; + if (now - at > NO_USABLE_FRAME_TTL_MS) { + noUsableFrameAt.delete(cid); + return false; + } + return true; +} + +export function rememberNoUsableFrame(cid: string, now = Date.now()): void { + noUsableFrameAt.set(cid, now); +} + /** * Compute midhash256 CID from a Buffer (matches meta-hash algorithm). * Copied verbatim from metamesh-plugin-subtitle-extractor. @@ -185,6 +257,32 @@ export interface VideoStream { height?: number; codec?: string; frameRate?: string; + /** ffprobe's `attached_pic` disposition. Probe path only — the ffmpeg + * plugin's stream table does not carry dispositions. */ + attachedPic?: boolean; +} + +/** + * `fileinfo/duration` out of whichever shape the payload carries. + * + * ⚠ meta-sort's /process payload is the NESTED document form — `fileinfo: + * { duration }`, `stream: [...]`, `cids: [...]` — not meta-core's flat + * `fileinfo/duration` keys. On the dev stack every task's existingMeta had + * `fileinfo` and `stream`, never `fileinfo/duration`, and reading only the flat + * key sent 16 of 16 stills to the 10s/0s fallback. Accept both, plus a + * JSON-string `fileinfo`. + */ +export function durationFromMeta(existingMeta: Record | undefined): number | undefined { + if (!existingMeta) return undefined; + let fileinfo: unknown = existingMeta['fileinfo']; + if (typeof fileinfo === 'string') { + try { fileinfo = JSON.parse(fileinfo); } catch { fileinfo = undefined; } + } + const raw = existingMeta['fileinfo/duration'] + ?? (fileinfo && typeof fileinfo === 'object' ? (fileinfo as Record)['duration'] : undefined); + if (raw === null || raw === undefined || raw === '') return undefined; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : undefined; } /** @@ -210,19 +308,21 @@ const STILL_IMAGE_CODECS = new Set(['mjpeg', 'png', 'bmp', 'gif', 'webp', 'tiff' * within the better class; if nothing looks like moving video we fall back to * the largest of whatever is there rather than giving up. * - * Accepts both shapes meta-core hands back: the namespaced `stream/{n}` keys - * the ffmpeg plugin writes, and the flattened `stream` array. + * Accepts both shapes: the nested `stream` collection meta-sort's /process + * payload actually carries (array, JSON string, or index-keyed object), and the + * namespaced `stream/{n}` keys meta-core stores. */ export function selectPrimaryVideoStream(existingMeta: Record | undefined): VideoStream | null { if (!existingMeta) return null; const raw: unknown[] = []; - const flattened = existingMeta['stream'] as unknown; - if (flattened) { + const nested = existingMeta['stream'] as unknown; + if (nested) { try { - const arr = Array.isArray(flattened) ? flattened : JSON.parse(String(flattened)); - if (Array.isArray(arr)) raw.push(...arr); + const parsed = typeof nested === 'string' ? JSON.parse(nested) : nested; + if (Array.isArray(parsed)) raw.push(...parsed); + else if (parsed && typeof parsed === 'object') raw.push(...Object.values(parsed as Record)); } catch { // fall through to the namespaced form } @@ -234,9 +334,7 @@ export function selectPrimaryVideoStream(existingMeta: Record | } } - const moving: VideoStream[] = []; - const other: VideoStream[] = []; - + const candidates: VideoStream[] = []; for (const entry of raw) { let stream: { type?: string; index?: number; width?: number; height?: number; @@ -249,22 +347,31 @@ export function selectPrimaryVideoStream(existingMeta: Record | } if (!stream || stream.type !== 'video' || stream.index == null) continue; - const candidate: VideoStream = { + candidates.push({ index: Number(stream.index), width: stream.width != null ? Number(stream.width) : undefined, height: stream.height != null ? Number(stream.height) : undefined, codec: stream.codec, frameRate: stream.frameRate, - }; - - const looksMoving = - !!candidate.frameRate && !STILL_IMAGE_CODECS.has((candidate.codec ?? '').toLowerCase()); - (looksMoving ? moving : other).push(candidate); + }); } + return pickPrimaryVideoStream(candidates); +} - const area = (s: VideoStream) => (s.width ?? 0) * (s.height ?? 0); - const pool = moving.length > 0 ? moving : other; - return pool.reduce((best, s) => (!best || area(s) > area(best) ? s : best), null); +/** + * The moving picture among video candidates, wherever they came from. + * + * Prefers streams that have a frame rate, are not a still-image codec and are + * not flagged as an attached picture; the largest area wins within that class, + * falling back to the largest of the rest rather than giving up. + */ +export function pickPrimaryVideoStream(candidates: VideoStream[]): VideoStream | null { + const looksMoving = (v: VideoStream) => + !v.attachedPic && !!v.frameRate && !STILL_IMAGE_CODECS.has((v.codec ?? '').toLowerCase()); + const moving = candidates.filter(looksMoving); + const pool = moving.length > 0 ? moving : candidates; + const area = (v: VideoStream) => (v.width ?? 0) * (v.height ?? 0); + return pool.reduce((best, v) => (!best || area(v) > area(best) ? v : best), null); } /** @@ -323,7 +430,7 @@ function run( cmd: string, args: string[], timeoutMs: number -): Promise<{ code: number | null; stdout: Buffer; stderr: string; timedOut: boolean }> { +): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: Buffer; stderr: string; timedOut: boolean }> { return new Promise((resolve) => { const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); const stdout: Buffer[] = []; @@ -342,15 +449,74 @@ function run( }); child.on('error', (err) => { clearTimeout(timer); - resolve({ code: null, stdout: Buffer.concat(stdout), stderr: stderr + String(err), timedOut }); + resolve({ code: null, signal: null, stdout: Buffer.concat(stdout), stderr: stderr + String(err), timedOut }); }); - child.on('close', (code) => { + child.on('close', (code, signal) => { clearTimeout(timer); - resolve({ code, stdout: Buffer.concat(stdout), stderr, timedOut }); + resolve({ code, signal, stdout: Buffer.concat(stdout), stderr, timedOut }); }); }); } +export interface MediaProbe { + duration?: number; + videoStreams: VideoStream[]; +} + +/** + * Duration and video streams read straight from the file. + * + * Both normally arrive from the ffmpeg plugin, but meta-sort marks a dependency + * complete on `failed` and `skipped` callbacks as well as `completed` + * (ContainerPluginScheduler.markPluginCompleted), so a still task can run + * against a record missing either — or both: on the dev stack a midhash record + * had `fileType=video` and no `fileinfo` or `stream` at all. Without a duration + * frame choice falls back to 10s/0s (idents and opening credits); without + * streams there is nothing to map. One header-only ffprobe answers both. + */ +export async function probeMedia(input: string): Promise { + const args = ['-v', 'error']; + if (/^https?:\/\//i.test(input)) { + args.push('-rw_timeout', '30000000'); // microseconds + } + args.push('-print_format', 'json', '-show_format', '-show_streams', input); + + const result = await run('ffprobe', args, 30000); + if (result.timedOut || result.code !== 0) return { videoStreams: [] }; + + let parsed: { + format?: { duration?: string }; + streams?: Array<{ + codec_type?: string; index?: number; codec_name?: string; + width?: number; height?: number; avg_frame_rate?: string; + disposition?: { attached_pic?: number }; + }>; + }; + try { + parsed = JSON.parse(result.stdout.toString()); + } catch { + return { videoStreams: [] }; + } + + const duration = Number(parsed.format?.duration); + const videoStreams = (parsed.streams ?? []) + .filter((v) => v.codec_type === 'video' && v.index != null) + .map((v): VideoStream => ({ + index: Number(v.index), + width: v.width, + height: v.height, + codec: v.codec_name, + frameRate: v.avg_frame_rate && v.avg_frame_rate !== '0/0' ? v.avg_frame_rate : undefined, + attachedPic: v.disposition?.attached_pic === 1, + })); + return { duration: Number.isFinite(duration) && duration > 0 ? duration : undefined, videoStreams }; +} + +/** Container duration read from the file — see probeMedia. */ +export async function probeDuration(input: string): Promise { + return (await probeMedia(input)).duration; +} + /** * Grab one frame at `offsetSec` and return the encoded JPEG. * @@ -365,7 +531,9 @@ export async function grabFrame( offsetSec: number, outputPath: string ): Promise { - const args = ['-y', '-hide_banner', '-loglevel', 'error']; + // -threads caps the decoder's thread pool. Left alone, dav1d/hevc size it + // to the host's cores, and every thread holds its own frame buffers. + const args = ['-y', '-hide_banner', '-loglevel', 'error', '-threads', '2']; // rw_timeout is a protocol option; only http/tcp accept it, and passing it // to the file protocol makes ffmpeg bail before it starts. @@ -380,8 +548,11 @@ export async function grabFrame( '-an', '-sn', '-dn', '-frames:v', '1', // thumbnail=N picks the most representative of the next N frames rather - // than whatever the seek happened to land on. - '-vf', `thumbnail=60,scale='min(iw,${maxWidth})':-2`, + // than whatever the seek happened to land on — and it BUFFERS all N. + // ⚠ Scale first. `thumbnail=60,scale=…` holds 60 full-resolution frames; + // on a 1080p AV1 episode that peaked at 409 MiB, and `scale=…,thumbnail=60` + // at 196 MiB (and 2s instead of 4s) for a byte-for-byte comparable JPEG. + '-vf', `scale='min(iw,${maxWidth})':-2,thumbnail=60`, '-q:v', String(jpegQuality), '-f', 'image2', outputPath @@ -393,7 +564,12 @@ export async function grabFrame( return null; } if (result.code !== 0 || !existsSync(outputPath)) { - console.warn(`[still-extractor] ffmpeg failed at ${offsetSec}s: ${result.stderr.trim() || `exit ${result.code}`}`); + // A signal with no stderr is the kernel, not ffmpeg: in practice the + // OOM killer. Say so, instead of the old unhelpful "exit null". + const why = result.signal + ? `killed by ${result.signal}${result.signal === 'SIGKILL' ? ' (likely out of memory)' : ''}` + : result.stderr.trim() || `exit ${result.code}`; + console.warn(`[still-extractor] ffmpeg failed at ${offsetSec}s: ${why}`); return null; } @@ -497,12 +673,16 @@ export async function process( const { taskId, cid, filePath, existingMeta } = request; const metaCore = new MetaCoreClient(request.metaCoreUrl); - const skip = (reason: string) => - sendCallback({ taskId, status: 'skipped', duration: Date.now() - startTime, reason }); + const skip = (reason: string, quiet = false) => { + // Every non-video file in the library comes through here too — logging + // those would bury the skips that matter ("No video stream" on a video). + if (!quiet) console.log(`[still-extractor] ${filePath}: skipped — ${reason}`); + return sendCallback({ taskId, status: 'skipped', duration: Date.now() - startTime, reason }); + }; try { if (existingMeta?.fileType !== 'video') { - await skip('Not a video file'); + await skip('Not a video file', true); return; } @@ -514,16 +694,17 @@ export async function process( return; } - const stream = selectPrimaryVideoStream(existingMeta); - if (!stream) { - await skip('No video stream'); - return; - } + let stream = selectPrimaryVideoStream(existingMeta); // WebDAV of the very core we're enriching, so the frame bytes and the // CID we write can never point at different cores. const webdavClient = await getWebDAVClient(request.metaCoreUrl); if (!webdavClient) { + if (!stream) { + // Nothing in the record and no way to read the file. + await skip('No video stream'); + return; + } await sendCallback({ taskId, status: 'failed', @@ -533,10 +714,61 @@ export async function process( return; } - const duration = Number(existingMeta?.['fileinfo/duration']); const input = webdavClient.toWebDAVUrl(filePath); + let probe: MediaProbe | undefined; + const probeOnce = async () => (probe ??= await probeMedia(input)); - const extracted = await extractStill(input, stream.index, Number.isFinite(duration) ? duration : undefined); + let streamSource = ''; + if (!stream) { + stream = pickPrimaryVideoStream((await probeOnce()).videoStreams); + streamSource = ' [probed]'; + if (!stream) { + await skip('No video stream'); + return; + } + } + + const recordDuration = durationFromMeta(existingMeta as Record | undefined); + + if (recordDuration === undefined) { + // Name what this task actually received, so an upstream cause of a + // missing duration stays diagnosable even though we recover from it. + const keys = Object.keys(existingMeta ?? {}); + console.warn( + `[still-extractor] ${filePath}: no usable fileinfo/duration ` + + `(fileinfo=${JSON.stringify((existingMeta as Record | undefined)?.['fileinfo'] ?? existingMeta?.['fileinfo/duration'] ?? null).slice(0, 120)}), probing the file; ` + + `existingMeta has ${keys.length} keys: ${keys.slice(0, 40).join(', ')}` + ); + } + const duration = recordDuration ?? (await probeOnce()).duration; + const durationSource = recordDuration !== undefined ? 'record' : duration !== undefined ? 'probed' : 'unknown'; + + console.log( + `[still-extractor] ${filePath}: stream=0:${stream.index}${streamSource} ` + + `${stream.width ?? '?'}x${stream.height ?? '?'} ${stream.codec ?? ''} ` + + `duration=${duration ?? 'unknown'} (${durationSource}) ` + + `offsets=${candidateOffsets(duration).join(',')}` + ); + + const extracted = await withExtractionSlot(async () => { + // existingMeta was read at dispatch, before this task queued for a slot. + // A replay or rescan queues several tasks per file, so re-check here — + // otherwise every duplicate behind the first recomputes the same still. + if (!forceRecompute && await metaCore.getProperty(cid, 'still')) return 'present' as const; + if (!forceRecompute && recentlyFailed(cid)) return 'recently-failed' as const; + const result = await extractStill(input, stream.index, duration); + if (!result) rememberNoUsableFrame(cid); + return result; + }); + if (extracted === 'present') { + await skip('Still already present'); + return; + } + if (extracted === 'recently-failed') { + // The first attempt already logged "No usable frame". + await skip('No usable frame (recent attempt, not retried)', true); + return; + } if (!extracted) { await skip('No usable frame'); return; diff --git a/test/still.test.ts b/test/still.test.ts index 4b05789..7b3b777 100644 --- a/test/still.test.ts +++ b/test/still.test.ts @@ -23,6 +23,14 @@ import { selectPrimaryVideoStream, extractStill, grabFrame, + configure, + withExtractionSlot, + probeDuration, + durationFromMeta, + probeMedia, + pickPrimaryVideoStream, + recentlyFailed, + rememberNoUsableFrame, } from '../src/plugin.js'; import type { CallbackPayload } from '../src/types.js'; @@ -282,3 +290,175 @@ describe('process - skip logic', () => { 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); + }); +});