/** * FFmpeg Plugin * * Extracts video/audio/subtitle stream metadata using FFprobe. * Depends on file-info plugin to determine file type. * * Storage format: * - fileinfo/duration = "123.45" * - fileinfo/formatName = "matroska,webm" * - stream/0 = '{"type":"video","codec":"h264","width":1920,...}' (JSON string) * - stream/1 = '{"type":"audio","codec":"aac","language":"eng",...}' (JSON string) * - resolutionW = "1920" / resolutionH = "1080" (primary video stream, promoted) * * Each stream is stored as a JSON string at the stream level. */ import ffmpeg from 'fluent-ffmpeg'; import type { PluginManifest, ProcessRequest, CallbackPayload } from './types.js'; import { MetaCoreClient } from './meta-core-client.js'; import { readJson, writeJson } from './cache.js'; import { createWebDAVClient, WebDAVClient } from './webdav-client.js'; // Initialize WebDAV client if WEBDAV_URL is set const webdavClient = createWebDAVClient(); if (webdavClient) { console.log('[ffmpeg] Using WebDAV for file access'); } else { console.log('[ffmpeg] Using direct filesystem access'); } export const manifest: PluginManifest = { id: 'ffmpeg', name: 'FFmpeg Metadata', version: '1.0.0', description: 'Extracts video/audio/subtitle stream metadata using FFprobe', author: 'MetaMesh', dependencies: ['file-info'], priority: 15, color: '#4CAF50', defaultQueue: 'fast', timeout: 60000, schema: { 'fileinfo/duration': { label: 'Duration', type: 'string', readonly: true }, 'fileinfo/formatName': { label: 'Format Name', type: 'string', readonly: true }, // Pixel dimensions of the primary video stream, promoted to the top level // (METADATA_KEYS §3) so readers don't have to walk stream/*. 'resolutionW': { label: 'Width', type: 'string', readonly: true }, 'resolutionH': { label: 'Height', type: 'string', readonly: true }, // Streams are stored as stream/0, stream/1, etc. - each is a JSON string 'stream/*': { label: 'Stream', type: 'json', readonly: true, hint: 'Stream metadata (video/audio/subtitle) as JSON string', }, }, config: {}, }; interface FFprobeData { format?: { duration?: number; format_name?: string; }; streams?: Array<{ codec_type?: string; codec_name?: string; index?: number; duration?: string; width?: number; height?: number; bit_rate?: string; avg_frame_rate?: string; sample_rate?: string; channel_layout?: string; disposition?: { forced?: number; default?: number; }; // fluent-ffmpeg flattens the Display Matrix [SIDE_DATA] block into the // stream: a Number for positive degrees, a string for negative ones // (its numeric regex has no sign) - e.g. 90, "-90", "-180". rotation?: number | string; tags?: { language?: string; title?: string; rotate?: string; }; }>; } interface FileInfoCache { duration?: string; formatName?: string; streamdetails?: { video?: Record>; audio?: Record>; subtitle?: Record>; embeddedimage?: Record>; }; } /** * Run FFprobe on a file path or URL * FFprobe natively supports HTTP URLs for input */ function runFFprobe(inputPath: string): Promise { return new Promise((resolve, reject) => { ffmpeg(inputPath).ffprobe((err, data) => { if (err) reject(err); else resolve(data as FFprobeData); }); }); } /** * Get the input path for FFprobe - WebDAV URL or local file path */ function getFFprobeInput(filePath: string): string { if (webdavClient) { // FFprobe can read from HTTP URLs directly return webdavClient.toWebDAVUrl(filePath); } return filePath; } /** * True when a display rotation (degrees, any sign, Number or string) turns the * frame a quarter turn - i.e. the displayed width is the coded height. */ export function isQuarterTurn(rotation: unknown): boolean { const n = Number(rotation); return Number.isFinite(n) && Math.abs(Math.round(n)) % 180 === 90; } /** * Swap `width`/`height` in place when `rotation` is a quarter turn, and drop * `rotation`. ffprobe reports the *coded* frame: a portrait phone clip is coded * 1920x1080 with a display matrix that stands it upright. Records store what a * player displays (METADATA_KEYS §3/§7), so the rotation is consumed here and * never stored. */ export function applyDisplayRotation( stream: Record, rotation: unknown = stream.rotation ): void { if (isQuarterTurn(rotation)) { const { width, height } = stream; if (height !== undefined) stream.width = height; else delete stream.width; if (width !== undefined) stream.height = width; else delete stream.height; } delete stream.rotation; } /** * Promote the primary video stream's pixel dimensions to the top-level * `resolutionW` / `resolutionH` keys (METADATA_KEYS §3). * * §7 (`stream/{n}`) stays the per-stream truth for multi-stream files; these * two keys exist so no reader has to walk `stream/*` to answer the single most * common question about a file. On locally-ingested files the values are * authoritative — they are the *display* dimensions of the FIRST video stream, * i.e. exactly what `stream/{n}` already carries. (A legacy stored stream that * still carries `rotation` is normalized the same way before promotion.) * * Operates on the already-built `stream/{n}` map rather than on the raw * ffprobe payload, so the fresh-probe path, the midhash cache path and the * backfill of an already-processed record all share one implementation and * cannot drift. * * Returns the keys to merge (empty when the record has no usable video * stream); never overwrites values already present on the record. */ export function promoteResolution( metadata: Record, existing: Record = {} ): Record { for (let i = 0; metadata[`stream/${i}`] != null; i++) { let stream: Record; try { stream = JSON.parse(metadata[`stream/${i}`]); } catch { continue; } if (stream?.type !== 'video') continue; applyDisplayRotation(stream); const promoted: Record = {}; // Width and height are promoted independently: a reader must treat a // missing `resolutionW` as unknown, not as "no resolution at all". for (const [key, raw] of [ ['resolutionW', stream.width], ['resolutionH', stream.height], ] as const) { const n = Number(raw); if (!Number.isFinite(n) || n <= 0) continue; if (existing[key]) continue; promoted[key] = String(Math.round(n)); } return promoted; } return {}; } export async function process( request: ProcessRequest, sendCallback: (payload: CallbackPayload) => Promise ): Promise { const startTime = Date.now(); const metaCore = new MetaCoreClient(request.metaCoreUrl); try { const { cid, filePath, existingMeta } = request; // Only process video files (from file-info plugin) const fileType = existingMeta?.fileType; if (fileType !== 'video') { await sendCallback({ taskId: request.taskId, status: 'skipped', duration: Date.now() - startTime, reason: 'Not a video file', }); return; } // Check if already processed if (existingMeta?.['fileinfo/duration']) { // Records ingested before resolutionW/resolutionH existed already // carry the stream/* they are promoted from, so backfill them here // rather than re-probing: it is a pure re-read of stored keys. const backfill = promoteResolution(existingMeta, existingMeta); if (Object.keys(backfill).length > 0) { await metaCore.mergeMetadata(cid, backfill); } await sendCallback({ taskId: request.taskId, status: 'skipped', duration: Date.now() - startTime, reason: 'Already processed', }); return; } // Check cache using midhash256 if available const midhash = existingMeta?.['cid_midhash256']; if (midhash) { const cachedData = await readJson(`${midhash}.json`); if (cachedData) { console.log(`[ffmpeg] Using cached FFprobe data for ${filePath}`); await applyCachedMetadata(metaCore, cid, cachedData); await sendCallback({ taskId: request.taskId, status: 'completed', duration: Date.now() - startTime, }); return; } } // Run FFprobe (uses WebDAV URL if available, otherwise local path) const ffprobeInput = getFFprobeInput(filePath); const data = await runFFprobe(ffprobeInput); const metadata: Record = {}; const fileinfo: FileInfoCache = { streamdetails: {} }; // Format metadata if (data.format?.duration != null) { const duration = String(data.format.duration); metadata['fileinfo/duration'] = duration; fileinfo.duration = duration; } if (data.format?.format_name) { const formatName = String(data.format.format_name); metadata['fileinfo/formatName'] = formatName; fileinfo.formatName = formatName; } // Global stream counter (all streams share one index) let globalStreamIndex = 0; // Process each stream - store as JSON string at stream level for (const stream of data.streams || []) { if (!stream) continue; const codecType = String(stream.codec_type || ''); let streamType: 'video' | 'audio' | 'subtitle' | 'embeddedimage'; if (codecType === 'video') { streamType = 'video'; } else if (codecType === 'audio') { streamType = 'audio'; } else if (codecType === 'subtitle') { streamType = 'subtitle'; } else if (codecType === 'embeddedimage') { streamType = 'embeddedimage'; } else { continue; } // Build stream object with all relevant fields const streamData: Record = { type: streamType, }; // Common fields if (stream.codec_name) { streamData.codec = String(stream.codec_name); } if (stream.index != null) { streamData.index = stream.index; } if (stream.duration) { streamData.duration = String(stream.duration); } if (stream.disposition?.forced != null) { streamData.forced = stream.disposition.forced === 1; } if (stream.disposition?.default != null) { streamData.default = stream.disposition.default === 1; } if (stream.tags?.language) { streamData.language = String(stream.tags.language); } if (stream.tags?.title) { streamData.title = String(stream.tags.title); } // Video-specific fields if (stream.width && stream.width !== ('N/A' as unknown)) { streamData.width = stream.width; } if (stream.height && stream.height !== ('N/A' as unknown)) { streamData.height = stream.height; } if (stream.bit_rate && stream.bit_rate !== 'N/A') { streamData.bitrate = String(stream.bit_rate); } if (stream.avg_frame_rate && stream.avg_frame_rate !== '0/0') { streamData.frameRate = String(stream.avg_frame_rate); } // Store display dimensions: side-data rotation (ffprobe >= 5), or // the pre-5 `rotate` tag. applyDisplayRotation(streamData, stream.rotation ?? stream.tags?.rotate); // Audio-specific fields if (stream.sample_rate) { streamData.sampleRate = String(stream.sample_rate); } if (stream.channel_layout) { streamData.channelLayout = String(stream.channel_layout); } // Store stream as JSON string at stream/{n} metadata[`stream/${globalStreamIndex}`] = JSON.stringify(streamData); // Initialize stream object for cache (keep old format for backwards compat) if (!fileinfo.streamdetails![streamType]) { fileinfo.streamdetails![streamType] = {}; } // Store stringified fields in cache for reconstruction const typeStreamIndex = Object.keys(fileinfo.streamdetails![streamType]!).length; fileinfo.streamdetails![streamType]![typeStreamIndex] = {}; const streamCache = fileinfo.streamdetails![streamType]![typeStreamIndex]; for (const [key, value] of Object.entries(streamData)) { if (key !== 'type') { streamCache[key] = String(value); } } globalStreamIndex++; } // Promote the primary video stream's dimensions to the top level Object.assign(metadata, promoteResolution(metadata)); // Write to meta-core await metaCore.mergeMetadata(cid, metadata); // Cache the fileinfo object for future use if (midhash) { await writeJson(`${midhash}.json`, fileinfo); } const duration = Date.now() - startTime; const mode = webdavClient ? 'WebDAV' : 'filesystem'; console.log(`[ffmpeg] Processed in ${duration}ms (${mode})`); await sendCallback({ taskId: request.taskId, status: 'completed', duration, }); } catch (error) { const duration = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : String(error); console.error(`[ffmpeg] Error:`, errorMessage); console.error( 'Make sure FFmpeg/FFprobe is installed. ' + 'Windows: set FFMPEG_PATH and FFPROBE_PATH environment variables. ' + 'Linux: install ffmpeg package.' ); await sendCallback({ taskId: request.taskId, status: 'failed', duration, error: errorMessage, }); } } /** * Apply cached metadata to meta-core */ async function applyCachedMetadata( metaCore: MetaCoreClient, cid: string, fileinfo: FileInfoCache ): Promise { const metadata: Record = {}; if (fileinfo.duration != null) { metadata['fileinfo/duration'] = String(fileinfo.duration); } if (fileinfo.formatName) { metadata['fileinfo/formatName'] = String(fileinfo.formatName); } // Apply streamdetails - store each stream as JSON string at stream/{n} const streamdetails = fileinfo.streamdetails || {}; let globalStreamIndex = 0; for (const streamType of ['video', 'audio', 'subtitle', 'embeddedimage'] as const) { const streams = streamdetails[streamType] || {}; for (const [, stream] of Object.entries(streams)) { // Build stream object with type const streamData: Record = { type: streamType, }; for (const [key, value] of Object.entries(stream)) { if (value != null) { // Try to parse numbers and booleans back to their original types if (value === 'true') { streamData[key] = true; } else if (value === 'false') { streamData[key] = false; } else if (!isNaN(Number(value)) && value !== '') { streamData[key] = Number(value); } else { streamData[key] = value; } } } // Cache entries written before display dimensions still carry // coded width/height + `rotation`. applyDisplayRotation(streamData); // Store as JSON string metadata[`stream/${globalStreamIndex}`] = JSON.stringify(streamData); globalStreamIndex++; } } // Same promotion as the fresh-probe path - a cache hit must produce the // exact same record as a probe, or a file's keys would depend on whether // its midhash happened to be cached. Object.assign(metadata, promoteResolution(metadata)); await metaCore.mergeMetadata(cid, metadata); }