471 lines
17 KiB
TypeScript
471 lines
17 KiB
TypeScript
/**
|
|
* 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<string, Record<string, string>>;
|
|
audio?: Record<string, Record<string, string>>;
|
|
subtitle?: Record<string, Record<string, string>>;
|
|
embeddedimage?: Record<string, Record<string, string>>;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run FFprobe on a file path or URL
|
|
* FFprobe natively supports HTTP URLs for input
|
|
*/
|
|
function runFFprobe(inputPath: string): Promise<FFprobeData> {
|
|
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<string, unknown>,
|
|
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<string, string>,
|
|
existing: Record<string, string> = {}
|
|
): Record<string, string> {
|
|
for (let i = 0; metadata[`stream/${i}`] != null; i++) {
|
|
let stream: Record<string, unknown>;
|
|
try {
|
|
stream = JSON.parse(metadata[`stream/${i}`]);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (stream?.type !== 'video') continue;
|
|
applyDisplayRotation(stream);
|
|
|
|
const promoted: Record<string, string> = {};
|
|
// 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<void>
|
|
): Promise<void> {
|
|
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<FileInfoCache>(`${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<string, string> = {};
|
|
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<string, string | number | boolean> = {
|
|
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<void> {
|
|
const metadata: Record<string, string> = {};
|
|
|
|
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<string, string | number | boolean> = {
|
|
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);
|
|
}
|