75 lines
3.7 KiB
JavaScript
75 lines
3.7 KiB
JavaScript
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;
|
|
};
|