This commit is contained in:
Pierre
2026-01-10 19:14:50 +01:00
parent 9e8df5b700
commit 544b44a590
3 changed files with 245 additions and 26 deletions
+54
View File
@@ -0,0 +1,54 @@
/**
* File-based cache utility for plugin data
* Matches the old ctx.cache API: readJson, writeJson
*/
import { readFile, writeFile, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
const CACHE_DIR = process.env.CACHE_DIR || '/cache';
/**
* Ensure cache directory exists
*/
async function ensureCacheDir(): Promise<void> {
if (!existsSync(CACHE_DIR)) {
await mkdir(CACHE_DIR, { recursive: true });
}
}
/**
* Read JSON from cache
* @param filename - Cache filename (e.g., "abc123.json")
* @returns Parsed JSON object or null if not found
*/
export async function readJson<T = unknown>(filename: string): Promise<T | null> {
try {
const filePath = path.join(CACHE_DIR, filename);
if (!existsSync(filePath)) {
return null;
}
const content = await readFile(filePath, 'utf-8');
return JSON.parse(content) as T;
} catch (error) {
console.debug(`[cache] Failed to read ${filename}:`, error);
return null;
}
}
/**
* Write JSON to cache
* @param filename - Cache filename (e.g., "abc123.json")
* @param data - Data to cache
*/
export async function writeJson(filename: string, data: unknown): Promise<void> {
try {
await ensureCacheDir();
const filePath = path.join(CACHE_DIR, filename);
await writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
console.debug(`[cache] Wrote ${filename}`);
} catch (error) {
console.warn(`[cache] Failed to write ${filename}:`, error);
}
}
+19 -1
View File
@@ -1,6 +1,24 @@
/** /**
* MetaMesh Plugin: ffmpeg * MetaMesh Plugin: ffmpeg
* Entry point *
* ============================================================================
* PLUGIN MOUNT ARCHITECTURE - DO NOT MODIFY WITHOUT AUTHORIZATION
* ============================================================================
*
* Each plugin container has exactly 3 mounts (2 for plugins without output):
*
* 1. /files (READ-ONLY) - Shared media files, read access only
* 2. /cache (READ-WRITE) - Plugin-specific cache folder
* 3. /files/plugin/<id> (READ-WRITE) - Plugin output folder (if needed)
*
* This plugin (ffmpeg) only requires mounts 1 and 2:
* - /files (RO) - to read video files for ffprobe analysis
* - /cache (RW) - to cache ffprobe results
*
* SECURITY: Plugins must NEVER write to /files directly.
* All write operations go to /cache or /files/plugin/<id> only.
*
* ============================================================================
*/ */
import Fastify from 'fastify'; import Fastify from 'fastify';
+172 -25
View File
@@ -2,11 +2,20 @@
* FFmpeg Plugin * FFmpeg Plugin
* *
* Extracts video/audio/subtitle stream metadata using FFprobe. * Extracts video/audio/subtitle stream metadata using FFprobe.
* Depends on file-info plugin to determine file type.
*
* Matches old FFMpegFileProcessor output:
* - fileinfo/duration
* - fileinfo/formatName
* - fileinfo/streamdetails/video/{n}/codec, width, height, etc.
* - fileinfo/streamdetails/audio/{n}/codec, language, etc.
* - fileinfo/streamdetails/subtitle/{n}/codec, language, etc.
*/ */
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
import type { PluginManifest, ProcessRequest, CallbackPayload } from './types.js'; import type { PluginManifest, ProcessRequest, CallbackPayload } from './types.js';
import { MetaCoreClient } from './meta-core-client.js'; import { MetaCoreClient } from './meta-core-client.js';
import { readJson, writeJson } from './cache.js';
export const manifest: PluginManifest = { export const manifest: PluginManifest = {
id: 'ffmpeg', id: 'ffmpeg',
@@ -72,6 +81,17 @@ interface FFprobeData {
}>; }>;
} }
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>>;
};
}
function runFFprobe(filePath: string): Promise<FFprobeData> { function runFFprobe(filePath: string): Promise<FFprobeData> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ffmpeg(filePath).ffprobe((err, data) => { ffmpeg(filePath).ffprobe((err, data) => {
@@ -91,7 +111,7 @@ export async function process(
try { try {
const { cid, filePath, existingMeta } = request; const { cid, filePath, existingMeta } = request;
// Only process video files // Only process video files (from file-info plugin)
const fileType = existingMeta?.fileType; const fileType = existingMeta?.fileType;
if (fileType !== 'video') { if (fileType !== 'video') {
await sendCallback({ await sendCallback({
@@ -114,70 +134,158 @@ export async function process(
return; 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 // Run FFprobe
const data = await runFFprobe(filePath); const data = await runFFprobe(filePath);
const metadata: Record<string, string> = {}; const metadata: Record<string, string> = {};
const fileinfo: FileInfoCache = { streamdetails: {} };
// Format metadata // Format metadata
if (data.format?.duration != null) { if (data.format?.duration != null) {
metadata['fileinfo/duration'] = String(data.format.duration); const duration = String(data.format.duration);
metadata['fileinfo/duration'] = duration;
fileinfo.duration = duration;
} }
if (data.format?.format_name) { if (data.format?.format_name) {
metadata['fileinfo/formatName'] = data.format.format_name; const formatName = String(data.format.format_name);
metadata['fileinfo/formatName'] = formatName;
fileinfo.formatName = formatName;
} }
// Stream counters // Stream counters
let videoIdx = 0, audioIdx = 0, subtitleIdx = 0; let videoStreamIndex = 0;
let audioStreamIndex = 0;
let subtitleStreamIndex = 0;
let embeddedImageStreamIndex = 0;
// Process each stream
for (const stream of data.streams || []) { for (const stream of data.streams || []) {
if (!stream) continue; if (!stream) continue;
const codecType = stream.codec_type || ''; const codecType = String(stream.codec_type || '');
let basePath: string; let basePath: string;
let streamType: 'video' | 'audio' | 'subtitle' | 'embeddedimage';
let streamIdx: number;
if (codecType === 'video') { if (codecType === 'video') {
basePath = `fileinfo/streamdetails/video/${videoIdx++}`; basePath = `fileinfo/streamdetails/video/${videoStreamIndex}`;
streamType = 'video';
streamIdx = videoStreamIndex++;
} else if (codecType === 'audio') { } else if (codecType === 'audio') {
basePath = `fileinfo/streamdetails/audio/${audioIdx++}`; basePath = `fileinfo/streamdetails/audio/${audioStreamIndex}`;
streamType = 'audio';
streamIdx = audioStreamIndex++;
} else if (codecType === 'subtitle') { } else if (codecType === 'subtitle') {
basePath = `fileinfo/streamdetails/subtitle/${subtitleIdx++}`; basePath = `fileinfo/streamdetails/subtitle/${subtitleStreamIndex}`;
streamType = 'subtitle';
streamIdx = subtitleStreamIndex++;
} else if (codecType === 'embeddedimage') {
basePath = `fileinfo/streamdetails/embeddedimage/${embeddedImageStreamIndex}`;
streamType = 'embeddedimage';
streamIdx = embeddedImageStreamIndex++;
} else { } else {
continue; continue;
} }
// Initialize stream object for cache
if (!fileinfo.streamdetails![streamType]) {
fileinfo.streamdetails![streamType] = {};
}
fileinfo.streamdetails![streamType]![streamIdx] = {};
const streamCache = fileinfo.streamdetails![streamType]![streamIdx];
// Common fields // Common fields
if (stream.codec_name) metadata[`${basePath}/codec`] = stream.codec_name; if (stream.codec_name) {
if (stream.index != null) metadata[`${basePath}/index`] = String(stream.index); metadata[`${basePath}/codec`] = String(stream.codec_name);
if (stream.duration) metadata[`${basePath}/duration`] = stream.duration; streamCache.codec = String(stream.codec_name);
if (stream.codec_type) metadata[`${basePath}/codecType`] = stream.codec_type; }
if (stream.index != null) {
metadata[`${basePath}/index`] = String(stream.index);
streamCache.index = String(stream.index);
}
if (stream.duration) {
metadata[`${basePath}/duration`] = String(stream.duration);
streamCache.duration = String(stream.duration);
}
if (stream.codec_type) {
metadata[`${basePath}/codecType`] = String(stream.codec_type);
streamCache.codecType = String(stream.codec_type);
}
if (stream.disposition?.forced != null) { if (stream.disposition?.forced != null) {
metadata[`${basePath}/forced`] = stream.disposition.forced ? 'true' : 'false'; const forced = stream.disposition.forced ? 'true' : 'false';
metadata[`${basePath}/forced`] = forced;
streamCache.forced = forced;
} }
if (stream.disposition?.default != null) { if (stream.disposition?.default != null) {
metadata[`${basePath}/default`] = stream.disposition.default ? 'true' : 'false'; const defaultVal = stream.disposition.default ? 'true' : 'false';
metadata[`${basePath}/default`] = defaultVal;
streamCache.default = defaultVal;
}
if (stream.tags?.language) {
metadata[`${basePath}/language`] = String(stream.tags.language);
streamCache.language = String(stream.tags.language);
}
if (stream.tags?.title) {
metadata[`${basePath}/title`] = String(stream.tags.title);
streamCache.title = String(stream.tags.title);
} }
if (stream.tags?.language) metadata[`${basePath}/language`] = stream.tags.language;
if (stream.tags?.title) metadata[`${basePath}/title`] = stream.tags.title;
// Video-specific // Video-specific fields
if (stream.width) metadata[`${basePath}/width`] = String(stream.width); if (stream.width && stream.width !== ('N/A' as unknown)) {
if (stream.height) metadata[`${basePath}/height`] = String(stream.height); metadata[`${basePath}/width`] = String(stream.width);
streamCache.width = String(stream.width);
}
if (stream.height && stream.height !== ('N/A' as unknown)) {
metadata[`${basePath}/height`] = String(stream.height);
streamCache.height = String(stream.height);
}
if (stream.bit_rate && stream.bit_rate !== 'N/A') { if (stream.bit_rate && stream.bit_rate !== 'N/A') {
metadata[`${basePath}/bitrate`] = stream.bit_rate; metadata[`${basePath}/bitrate`] = String(stream.bit_rate);
streamCache.bitrate = String(stream.bit_rate);
} }
if (stream.avg_frame_rate && stream.avg_frame_rate !== '0/0') { if (stream.avg_frame_rate && stream.avg_frame_rate !== '0/0') {
metadata[`${basePath}/frameRate`] = stream.avg_frame_rate; metadata[`${basePath}/frameRate`] = String(stream.avg_frame_rate);
streamCache.frameRate = String(stream.avg_frame_rate);
}
if (stream.tags?.rotate) {
metadata[`${basePath}/rotation`] = String(stream.tags.rotate);
streamCache.rotation = String(stream.tags.rotate);
} }
if (stream.tags?.rotate) metadata[`${basePath}/rotation`] = stream.tags.rotate;
// Audio-specific // Audio-specific fields
if (stream.sample_rate) metadata[`${basePath}/sampleRate`] = stream.sample_rate; if (stream.sample_rate) {
if (stream.channel_layout) metadata[`${basePath}/channelLayout`] = stream.channel_layout; metadata[`${basePath}/sampleRate`] = String(stream.sample_rate);
streamCache.sampleRate = String(stream.sample_rate);
}
if (stream.channel_layout) {
metadata[`${basePath}/channelLayout`] = String(stream.channel_layout);
streamCache.channelLayout = String(stream.channel_layout);
}
} }
// Write to meta-core // Write to meta-core
await metaCore.mergeMetadata(cid, metadata); 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 duration = Date.now() - startTime;
console.log(`[ffmpeg] Processed in ${duration}ms`); console.log(`[ffmpeg] Processed in ${duration}ms`);
@@ -190,6 +298,11 @@ export async function process(
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[ffmpeg] Error:`, errorMessage); 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({ await sendCallback({
taskId: request.taskId, taskId: request.taskId,
@@ -199,3 +312,37 @@ export async function process(
}); });
} }
} }
/**
* 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
const streamdetails = fileinfo.streamdetails || {};
for (const streamType of ['video', 'audio', 'subtitle', 'embeddedimage'] as const) {
const streams = streamdetails[streamType] || {};
for (const [index, stream] of Object.entries(streams)) {
const basePath = `fileinfo/streamdetails/${streamType}/${index}`;
for (const [key, value] of Object.entries(stream)) {
if (value != null) {
metadata[`${basePath}/${key}`] = String(value);
}
}
}
}
await metaCore.mergeMetadata(cid, metadata);
}