/** * Still Extractor Plugin * * Grabs a representative frame out of a local video file and stores it as a * content-addressed JPEG, referenced from the video record as `still`. * * ============================================================================ * WHY THIS EXISTS * ============================================================================ * Artwork on a local file otherwise comes from TMDB, and only when a title * match succeeds. `still` (METADATA_KEYS.md section 6.1) is the episode-level * thumbnail slot, reserved for a future TMDB season-detail fetch and until now * written by nobody. This plugin gives it a first writer from the one source * that never 404s and never depends on a match: the file itself. * * A still is NOT a poster. Nothing gates on it and its absence is not an error * — hence `skipped` rather than `failed` on every "couldn't get a usable * frame" path. We never write `poster`: a frame grab masquerading as a real * poster would propagate to every peer that replicates the record. * * ============================================================================ * PLUGIN FILE ACCESS ARCHITECTURE - WebDAV * ============================================================================ * File access via WebDAV, on the meta-core named by each /process request * (its /urls -> webdavUrlInternal; WEBDAV_URL overrides): * - Read media files: GET /webdav/watch/... or /webdav/test/... * - Write output: PUT /webdav/plugin/still-extractor/... * - Temp: /cache/temp (frame written by ffmpeg before upload) * * We deliberately do NOT write a `stillPath` sibling. METADATA_KEYS.md names * one, but that is the gateway/peer_proxy mirroring convention: on the * meta-sort path `/files/plugin` is a watcher root * (meta-core/internal/config/config.go DefaultWatcherPaths), so the JPEG gets * its own record and midhash alias and `LookupPathByCID` resolves it through * the reverse index — "There is no path-companion sidecar field" * (meta-core/internal/storage/client.go). The tmdb plugin writes `poster` * without `posterPath` for the same reason. * ============================================================================ */ import { spawn } from 'child_process'; import { createHash, randomBytes } from 'crypto'; import { mkdirSync, readFileSync, existsSync, unlinkSync } from 'fs'; import * as path from 'path'; import type { PluginManifest, ProcessRequest, CallbackPayload } from './types.js'; import { MetaCoreClient } from './meta-core-client.js'; import { getWebDAVClient, WebDAVClient } from './webdav-client.js'; const PLUGIN_OUTPUT_WEBDAV_PATH = '/files/plugin/still-extractor'; const TEMP_PATH = '/cache/temp'; export const manifest: PluginManifest = { id: 'still-extractor', name: 'Still Extractor', version: '1.0.0', description: 'Extracts a representative frame from a video file and stores it as the record\'s `still`', author: 'MetaMesh', // file-info gives us `fileType`; ffmpeg gives us `fileinfo/duration` and // the `stream/{n}` table we pick the primary video stream out of. dependencies: ['file-info', 'ffmpeg'], priority: 55, color: '#9C27B0', // One seek + one decode over HTTP. Never the fast queue. defaultQueue: 'background', timeout: 300000, schema: { still: { label: 'Still', type: 'cid', readonly: true, hint: 'CID of a frame extracted from the video itself (best-effort episode thumbnail)', }, }, config: { forceRecompute: { type: 'boolean', label: 'Force Recompute', default: false }, seekPercents: { type: 'string', label: 'Seek Percents', default: '20,45,70' }, 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 }, }, }; // Configuration let forceRecompute = false; 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; if (typeof config.seekPercents === 'string') { const parsed = config.seekPercents .split(',') .map((p) => Number(p.trim())) .filter((p) => Number.isFinite(p) && p > 0 && p < 100); if (parsed.length > 0) seekPercents = parsed; } 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}, 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. */ export function computeMidHash256FromBuffer(data: Buffer): string { const SAMPLE_SIZE = 1024 * 1024; // 1MB const MIDHASH_VARINT = Buffer.from([0x80, 0x20]); const fileSize = data.length; const sizeBuffer = Buffer.allocUnsafe(8); sizeBuffer.writeBigUInt64BE(BigInt(fileSize), 0); let sampleData: Buffer; if (fileSize <= SAMPLE_SIZE) { sampleData = data; } else { const middleOffset = Math.floor((fileSize - SAMPLE_SIZE) / 2); sampleData = data.subarray(middleOffset, middleOffset + SAMPLE_SIZE); } const hashInput = Buffer.concat([sizeBuffer, sampleData]); const hashBuffer = createHash('sha256').update(hashInput).digest(); const cidBytes = Buffer.concat([ Buffer.from([0x01]), MIDHASH_VARINT, MIDHASH_VARINT, Buffer.from([0x20]), hashBuffer ]); const base32Chars = 'abcdefghijklmnopqrstuvwxyz234567'; let cid = 'b'; let bits = 0; let value = 0; for (const byte of cidBytes) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { bits -= 5; cid += base32Chars[(value >> bits) & 0x1f]; } } if (bits > 0) { cid += base32Chars[(value << (5 - bits)) & 0x1f]; } return cid; } /** Sanitize filename by removing invalid characters. */ export function sanitizeFilename(name: string): string { return name .replace(/[<>:"/\\|?*]/g, '') .replace(/\s+/g, ' ') .trim(); } /** * Output filename for a still. * * The source CID is part of the name — unlike the tmdb plugin, which * deliberately leaves the file CID out so every release of one movie shares a * poster. A still belongs to exactly one file, so two files with the same * basename must not collide. */ export function stillFilename(filePath: string, sourceCid: string): string { const base = sanitizeFilename(path.basename(filePath, path.extname(filePath))); const shortCid = sourceCid.slice(-12); return `${base}[${shortCid}]_still.jpg`; } export interface VideoStream { /** Absolute ffmpeg stream index — what `-map 0:` wants. */ index: number; width?: number; 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; } /** * Codecs that are a single picture, not a moving image. An `attached_pic` * stream is almost always one of these. */ const STILL_IMAGE_CODECS = new Set(['mjpeg', 'png', 'bmp', 'gif', 'webp', 'tiff']); /** * Pick the primary video stream out of the ffmpeg plugin's `stream/{n}` table. * * ⚠ NOT `0:v:0`, and ⚠ not simply the largest. ffprobe reports attached cover * art (`attached_pic`) as `codec_type=video`, and the ffmpeg plugin's * `embeddedimage` branch never fires for it, so an embedded poster lands here * as a perfectly ordinary `type:'video'` stream — which `0:v:0` would happily * extract instead of the film. Nor can we just take the biggest: a 600x900 * cover out-measures a 640x480 video. * * The stream table does not carry dispositions, so we use what it does carry. * The ffmpeg plugin drops `avg_frame_rate` when it is `0/0` (plugin.ts), and an * attached picture has no frame rate — so "has a frameRate and is not a * still-image codec" separates moving pictures from covers. Largest area wins * 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: 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 nested = existingMeta['stream'] as unknown; if (nested) { try { 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 } } if (raw.length === 0) { for (const [key, value] of Object.entries(existingMeta)) { if (key.startsWith('stream/')) raw.push(value); } } const candidates: VideoStream[] = []; for (const entry of raw) { let stream: { type?: string; index?: number; width?: number; height?: number; codec?: string; frameRate?: string; }; try { stream = typeof entry === 'string' ? JSON.parse(entry) : (entry as typeof stream); } catch { continue; } if (!stream || stream.type !== 'video' || stream.index == null) continue; 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, }); } return pickPrimaryVideoStream(candidates); } /** * 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); } /** * Seek offsets to try, in order. * * Clamped away from both ends: the head is titles/logos/black and the tail is * credits. A file with no usable duration gets a blind 10s attempt and then * the very start, which is all we can honestly do. */ export function candidateOffsets(durationSec: number | undefined, percents: number[] = seekPercents): number[] { if (!durationSec || !Number.isFinite(durationSec) || durationSec <= 0) { return [10, 0]; } if (durationSec <= 4) { return [Math.max(0, durationSec / 2)]; } const lo = 2; const hi = durationSec - 2; const offsets = percents .map((p) => (durationSec * p) / 100) .map((t) => Math.min(hi, Math.max(lo, t))); return Array.from(new Set(offsets.map((t) => Math.round(t * 1000) / 1000))); } export interface FrameStats { mean: number; stddev: number; } /** Mean + standard deviation of an 8-bit grayscale buffer, both 0..255. */ export function frameStats(gray: Buffer): FrameStats { if (gray.length === 0) return { mean: 0, stddev: 0 }; let sum = 0; for (const v of gray) sum += v; const mean = sum / gray.length; let variance = 0; for (const v of gray) variance += (v - mean) ** 2; return { mean, stddev: Math.sqrt(variance / gray.length) }; } /** * Is this frame worth keeping? * * Rejects the three things a blind seek lands on far more often than you would * expect: a black frame, a white/blown card, and a flat single-colour fade. * `thumbnail=N` already avoids most of them; this is the backstop. */ export function isUsableFrame(stats: FrameStats): boolean { if (stats.stddev < 6) return false; // flat: fade, solid colour, blank card if (stats.mean < 12 || stats.mean > 243) return false; // black / blown out return true; } /** Run a command, resolving with its exit code, stdout and stderr. Killed hard on timeout. */ function run( cmd: string, args: string[], timeoutMs: number ): 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[] = []; let stderr = ''; let timedOut = false; const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs); child.stdout.on('data', (d: Buffer) => stdout.push(d)); child.stderr.on('data', (d: Buffer) => { // Keep the tail only — a broken input can spew megabytes. stderr = (stderr + d.toString()).slice(-4000); }); child.on('error', (err) => { clearTimeout(timer); resolve({ code: null, signal: null, stdout: Buffer.concat(stdout), stderr: stderr + String(err), timedOut }); }); child.on('close', (code, signal) => { clearTimeout(timer); 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. * * ⚠ `-ss` goes BEFORE `-i`. Input seeking makes ffmpeg jump via HTTP Range; * output seeking (`-ss` after `-i`) decodes every frame from zero, which over * WebDAV means pulling the whole file to reach the 20% mark. On MKV expect a * tail read regardless — the cue index lives at EOF. */ export async function grabFrame( input: string, streamIndex: number, offsetSec: number, outputPath: string ): Promise { // -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. if (/^https?:\/\//i.test(input)) { args.push('-rw_timeout', '30000000'); // microseconds } args.push( '-ss', String(offsetSec), '-i', input, '-map', `0:${streamIndex}`, '-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 — 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 ); const result = await run('ffmpeg', args, frameTimeoutMs); if (result.timedOut) { console.warn(`[still-extractor] ffmpeg timed out at ${offsetSec}s`); return null; } if (result.code !== 0 || !existsSync(outputPath)) { // 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; } const jpeg = readFileSync(outputPath); return jpeg.length > 0 ? jpeg : null; } /** * Measure a JPEG we already have on disk. * * Deliberately a second ffmpeg pass on the local temp file rather than a second * output on the grab command: two filter chains off one input would each run * their own `thumbnail` instance, and there is no guarantee the frame we * measured is the frame we encoded. This decodes a ~40 KB local file — no * seek, no network. */ export async function statsForJpeg(jpegPath: string): Promise { const result = await run( 'ffmpeg', ['-hide_banner', '-loglevel', 'error', '-i', jpegPath, '-vf', 'scale=32:32,format=gray', '-f', 'rawvideo', '-'], 15000 ); if (result.code !== 0 || result.stdout.length === 0) return null; return frameStats(result.stdout); } export interface ExtractResult { jpeg: Buffer; offsetSec: number; stats: FrameStats; } /** * Walk the candidate offsets until one yields a usable frame. * * Returns null when every candidate was black, flat, or unreadable — that is a * `skipped`, not a failure. */ export async function extractStill( input: string, streamIndex: number, durationSec: number | undefined, tempPath: string = TEMP_PATH ): Promise { mkdirSync(tempPath, { recursive: true }); // NB: `process` is this module's own export, not node's global — hence a // random suffix rather than a pid for uniqueness between concurrent tasks. const outputPath = path.join(tempPath, `still-${randomBytes(6).toString('hex')}.jpg`); try { for (const offsetSec of candidateOffsets(durationSec)) { const jpeg = await grabFrame(input, streamIndex, offsetSec, outputPath); if (!jpeg) continue; const stats = await statsForJpeg(outputPath); if (!stats) { console.warn(`[still-extractor] Could not measure frame at ${offsetSec}s`); continue; } if (!isUsableFrame(stats)) { console.log( `[still-extractor] Rejected frame at ${offsetSec}s ` + `(mean=${stats.mean.toFixed(1)}, stddev=${stats.stddev.toFixed(1)})` ); continue; } console.log( `[still-extractor] Accepted frame at ${offsetSec}s ` + `(mean=${stats.mean.toFixed(1)}, stddev=${stats.stddev.toFixed(1)}, ${jpeg.length} bytes)` ); return { jpeg, offsetSec, stats }; } return null; } finally { if (existsSync(outputPath)) { try { unlinkSync(outputPath); } catch { /* best effort */ } } } } /** Upload the frame and return its CID. */ async function storeStill( client: WebDAVClient, jpeg: Buffer, filePath: string, sourceCid: string ): Promise { const webdavPath = `${PLUGIN_OUTPUT_WEBDAV_PATH}/${stillFilename(filePath, sourceCid)}`; await client.writeFile(webdavPath, jpeg); console.log(`[still-extractor] Uploaded still to WebDAV: ${webdavPath}`); return computeMidHash256FromBuffer(jpeg); } export async function process( request: ProcessRequest, sendCallback: (payload: CallbackPayload) => Promise ): Promise { const startTime = Date.now(); const { taskId, cid, filePath, existingMeta } = request; const metaCore = new MetaCoreClient(request.metaCoreUrl); 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', true); return; } if (existingMeta?.still && !forceRecompute) { // Also the mechanism that keeps this plugin from ever fighting a // future TMDB episode-still writer: authoritative artwork wins by // arriving first and never being overwritten from here. await skip('Still already present'); 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', duration: Date.now() - startTime, error: 'No WebDAV endpoint available', }); return; } const input = webdavClient.toWebDAVUrl(filePath); let probe: MediaProbe | undefined; const probeOnce = async () => (probe ??= await probeMedia(input)); 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; } const stillCid = await storeStill(webdavClient, extracted.jpeg, filePath, cid); await metaCore.setProperty(cid, 'still', stillCid); console.log(`[still-extractor] Set still CID: ${stillCid}`); await sendCallback({ taskId, status: 'completed', duration: Date.now() - startTime }); } catch (error) { console.error(`[still-extractor] Error processing ${filePath}:`, error); await sendCallback({ taskId, status: 'failed', duration: Date.now() - startTime, error: error instanceof Error ? error.message : String(error), }); } }