Files
metamesh-plugin-ffmpeg/src/plugin.ts
T
Worph 1b194bf965
Build and Push Docker Image / build (push) Failing after 18s
fix resolution
2026-09-12 21:35:44 +02:00

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);
}