Files
Portfolio/scripts/lib/video-frames.mjs

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