import { execFile } from 'node:child_process'; import { mkdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); const fail = (rule) => { throw new Error(rule); }; export const runMediaCommand = async (command, args, rule, timeout = 60_000) => { try { return await execFileAsync(command, args, { encoding: 'utf8', timeout, maxBuffer: 8 * 1024 * 1024, windowsHide: true, }); } catch { fail(rule); } }; export const probeVideo = async (path, options = {}) => { const { stdout } = await runMediaCommand('ffprobe', [ '-v', 'error', '-show_entries', 'format=duration:stream=index,codec_type,width,height,duration', '-of', 'json', path, ], 'ASSET-VIDEO-PROBE-001', 20_000); let probe; try { probe = JSON.parse(stdout); } catch { fail('ASSET-VIDEO-PROBE-001'); } const videoStreams = probe.streams?.filter((stream) => stream.codec_type === 'video') ?? []; const audioStreams = probe.streams?.filter((stream) => stream.codec_type === 'audio') ?? []; const unexpected = probe.streams?.filter((stream) => !['video', 'audio'].includes(stream.codec_type)) ?? []; const duration = Number(probe.format?.duration ?? videoStreams[0]?.duration); if (videoStreams.length !== 1 || unexpected.length > 0 || (options.allowAudio === false && audioStreams.length > 0) || !Number.isFinite(duration) || duration <= 0 || duration > 120) { fail('ASSET-VIDEO-PROBE-001'); } return { duration, width: Number(videoStreams[0].width), height: Number(videoStreams[0].height) }; }; export const encodeVideoDerivatives = async ({ source, webmPath, mp4Path, posterPath, startSeconds, durationSeconds, maxWidth }) => { const common = [ '-hide_banner', '-loglevel', 'error', '-nostdin', '-y', '-threads', '1', '-ss', String(startSeconds), '-i', source, '-t', String(durationSeconds), '-map', '0:v:0', '-an', '-sn', '-dn', '-vf', `scale=w='min(${maxWidth},iw)':h=-2:flags=lanczos`, '-map_metadata', '-1', '-map_chapters', '-1', '-pix_fmt', 'yuv420p', ]; await runMediaCommand('ffmpeg', [ ...common, '-fflags', '+bitexact', '-flags:v', '+bitexact', '-c:v', 'libvpx-vp9', '-deadline', 'good', '-cpu-used', '4', '-row-mt', '0', '-b:v', '0', '-crf', '34', '-threads', '1', webmPath, ], 'ASSET-VIDEO-ENCODE-001', 120_000); await runMediaCommand('ffmpeg', [ ...common, '-fflags', '+bitexact', '-flags:v', '+bitexact', '-c:v', 'libx264', '-preset', 'slow', '-crf', '25', '-threads', '1', '-movflags', '+faststart', mp4Path, ], 'ASSET-VIDEO-ENCODE-001', 120_000); await runMediaCommand('ffmpeg', [ '-hide_banner', '-loglevel', 'error', '-nostdin', '-y', '-threads', '1', '-ss', '0.05', '-i', webmPath, '-frames:v', '1', '-an', '-sn', '-dn', '-map_metadata', '-1', posterPath, ], 'ASSET-VIDEO-POSTER-001', 30_000); }; export const extractVideoFrames = async ({ source, durationSeconds, outputDir, prefix = 'frame' }) => { await mkdir(outputDir, { recursive: true }); const points = [...new Set([0.05, durationSeconds / 2, Math.max(0.05, durationSeconds - 0.1)].map((value) => Math.min(durationSeconds, Math.max(0, value)).toFixed(3)))]; const paths = []; for (const [index, point] of points.entries()) { const path = join(outputDir, `${prefix}-${String(index + 1).padStart(2, '0')}.png`); await runMediaCommand('ffmpeg', [ '-hide_banner', '-loglevel', 'error', '-nostdin', '-y', '-threads', '1', '-ss', point, '-i', source, '-frames:v', '1', '-an', '-sn', '-dn', '-map_metadata', '-1', path, ], 'ASSET-VIDEO-FRAME-001', 30_000); if ((await readFile(path)).length === 0) fail('ASSET-VIDEO-FRAME-001'); paths.push(path); } return paths; };