feat: add deterministic cleared project media
This commit is contained in:
74
scripts/lib/video-frames.mjs
Normal file
74
scripts/lib/video-frames.mjs
Normal file
@@ -0,0 +1,74 @@
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user