Files
2026-03-03 23:49:13 +01:00

523 lines
19 KiB
JavaScript

/**
* FFmpeg utilities for video concatenation and section-based speed manipulation.
*
* Both functions use a single ffmpeg filter_complex pass: trim segments from
* the input, apply setpts for speed, normalize fps/scale, then concat.
* No intermediate files, no multi-pass.
*/
import { spawn } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Seconds of normal-speed buffer kept before and after each execution */
export const INTERACTION_BUFFER_SECONDS = 1;
// ---------------------------------------------------------------------------
// Hardware encoder detection
// ---------------------------------------------------------------------------
/**
* Preferred hardware encoders by platform, in priority order.
* Each is tried via `ffmpeg -f lavfi -i nullsrc -t 0.01 -c:v <encoder> -f null -`
* to confirm the encoder actually works (drivers present, GPU available, etc).
*/
const HW_ENCODER_CANDIDATES = {
darwin: ['h264_videotoolbox'],
win32: ['h264_nvenc', 'h264_qsv', 'h264_amf'],
// h264_vaapi excluded: requires -vaapi_device and format=nv12,hwupload in the
// filter graph, which our filter_complex pipeline doesn't set up
linux: ['h264_nvenc', 'h264_qsv'],
};
/** Cache so we only probe once per process */
let cachedEncoder;
/**
* Detect the best available H.264 encoder.
* Tries platform-specific hardware encoders first, falls back to libx264.
* Result is cached for the lifetime of the process.
*/
export async function detectEncoder() {
if (cachedEncoder) {
return cachedEncoder;
}
const platform = os.platform();
const candidates = HW_ENCODER_CANDIDATES[platform] ?? [];
for (const codec of candidates) {
const works = await testEncoder(codec);
if (works) {
cachedEncoder = { codec, isHardware: true };
return cachedEncoder;
}
}
cachedEncoder = { codec: 'libx264', isHardware: false };
return cachedEncoder;
}
/**
* Quick probe: can this encoder produce even a single frame?
* Runs `ffmpeg -f lavfi -i nullsrc=s=64x64:d=0.01 -c:v <encoder> -f null -`
* and checks exit code. Timeout 5s to avoid hanging on broken drivers.
*/
function testEncoder(codec) {
return new Promise((resolve) => {
const child = spawn('ffmpeg', [
'-hide_banner', '-loglevel', 'error',
'-f', 'lavfi', '-i', 'nullsrc=s=64x64:d=0.01',
'-c:v', codec,
'-f', 'null', '-',
], { stdio: 'ignore' });
const timeout = setTimeout(() => {
child.kill();
resolve(false);
}, 5000);
child.on('close', (code) => {
clearTimeout(timeout);
resolve(code === 0);
});
child.on('error', () => {
clearTimeout(timeout);
resolve(false);
});
});
}
// ---------------------------------------------------------------------------
// Encoding quality parameters
// ---------------------------------------------------------------------------
/**
* Build encoding args optimized for screen recordings on social media.
*
* Quality rationale (screen recordings = sharp text, flat colors, UI):
* - libx264 CRF 18 = near visually lossless, prevents text compression artifacts
* - `-preset fast` = ~4x faster than default `medium`, minimal quality loss on
* screen content. NOT `ultrafast` which compresses so poorly that platforms
* (X.com, YouTube) re-encode more aggressively, making it look worse.
* - `-x264opts deblock=-1,-1` = less deblocking preserves sharp text edges
* - No `-tune` flag: `animation` blurs text edges, `zerolatency` is for
* real-time capture only
* - h264_videotoolbox `-q:v 80` ≈ CRF 18 equivalent for screen content
* - `-maxrate 25M` = X.com (Twitter) max bitrate cap
* - `-movflags +faststart` = metadata at front for web streaming
* - `-pix_fmt yuv420p` = universal compatibility across all platforms
*/
function buildEncodingArgs(encoder) {
const common = [
'-pix_fmt', 'yuv420p',
'-maxrate', '25M',
'-bufsize', '50M',
'-movflags', '+faststart',
];
if (encoder.isHardware) {
// Hardware encoders: use quality-based mode where supported
// h264_videotoolbox uses -q:v (1-100, 100=best), others use -b:v
const qualityArgs = encoder.codec === 'h264_videotoolbox'
? ['-q:v', '80']
: ['-b:v', '15M']; // high bitrate for other HW encoders
return ['-c:v', encoder.codec, ...qualityArgs, ...common];
}
// Software: libx264 with screen-recording-optimized settings
return [
'-c:v', 'libx264',
'-crf', '18',
'-preset', 'fast',
'-x264opts', 'deblock=-1,-1',
...common,
];
}
function parseFrameRate(value) {
if (!value) {
return null;
}
const [numRaw, denRaw] = value.split('/').map(Number);
if (!Number.isFinite(numRaw) || !Number.isFinite(denRaw) || denRaw === 0) {
return null;
}
const frameRate = numRaw / denRaw;
if (!Number.isFinite(frameRate) || frameRate <= 0) {
return null;
}
return frameRate;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Probe input video for dimensions and frame rate via ffprobe. */
export async function probeVideo(filePath) {
const stdout = await runCommand({
bin: 'ffprobe',
args: [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=width,height,r_frame_rate,avg_frame_rate',
'-of', 'json',
filePath,
],
});
const parsed = JSON.parse(stdout);
const stream = parsed.streams?.[0];
if (!stream) {
throw new Error(`No video stream found in ${filePath}`);
}
// Prefer avg_frame_rate for VFR recordings. r_frame_rate can report
// high timebase-like values (e.g. 30000/1) that are not usable output FPS.
const avgFrameRate = parseFrameRate(stream.avg_frame_rate);
const rawFrameRate = parseFrameRate(stream.r_frame_rate);
const selectedFrameRate = (() => {
if (avgFrameRate && avgFrameRate <= 120) {
return avgFrameRate;
}
if (rawFrameRate && rawFrameRate <= 120) {
return rawFrameRate;
}
if (avgFrameRate) {
return avgFrameRate;
}
if (rawFrameRate) {
return rawFrameRate;
}
return 30;
})();
const normalizedFrameRate = Math.min(120, Math.max(1, Math.round(selectedFrameRate)));
return {
width: stream.width,
height: stream.height,
frameRate: normalizedFrameRate,
};
}
/**
* Run a process with argv (no shell). Returns stdout as string.
* Avoids shell injection by never passing through a shell interpreter.
*/
function runCommand({ bin, args, signal, }) {
return new Promise((resolve, reject) => {
const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
resolve(stdout);
}
else {
reject(new Error(`FFmpeg error (exit ${code}): ${stderr}`));
}
});
child.on('error', (err) => {
reject(new Error(`Failed to start ${bin}`, { cause: err }));
});
if (signal) {
signal.addEventListener('abort', () => {
child.kill();
reject(signal.reason instanceof Error
? signal.reason
: new Error('Operation aborted'));
}, { once: true });
}
});
}
/** Build default output path: `/dir/name-fast.ext` */
function defaultOutputPath(inputFile) {
const ext = path.extname(inputFile);
const base = path.basename(inputFile, ext);
const dir = path.dirname(inputFile);
return path.join(dir, `${base}-fast${ext}`);
}
/**
* Given sorted, non-overlapping SpeedSections, fill gaps with normal-speed
* segments so the entire video is covered.
*/
function buildSegments(sections) {
const sorted = [...sections].sort((a, b) => {
return a.start - b.start;
});
// Validate: no overlaps
for (let i = 1; i < sorted.length; i++) {
if (sorted[i].start < sorted[i - 1].end) {
throw new Error(`Sections overlap: [${sorted[i - 1].start}-${sorted[i - 1].end}] and [${sorted[i].start}-${sorted[i].end}]`);
}
}
const segments = [];
let cursor = 0;
for (const section of sorted) {
// Gap before this section → normal speed
if (section.start > cursor) {
segments.push({ start: cursor, end: section.start, speed: 1 });
}
// The speed section itself
segments.push({
start: section.start,
end: section.end,
speed: section.speed,
});
cursor = section.end;
}
// Trailing normal-speed segment (no end bound → until EOF)
segments.push({ start: cursor, end: undefined, speed: 1 });
return segments;
}
/**
* Build the filter string for a single segment.
*
* For sped-up segments: `[0:v]trim=...,setpts=...,fps=...,scale=...[vN]`
* For normal-speed segments where dimensions/fps match input: just
* `[0:v]trim=...,setpts=PTS-STARTPTS[vN]` — skips fps and scale filters
* to avoid unnecessary pixel processing and frame-rate checking.
*/
function buildSegmentFilter({ segment, index, frameRate, width, height, inputWidth, inputHeight, inputFrameRate, }) {
const trimParts = [`start=${segment.start}`];
if (segment.end !== undefined) {
trimParts.push(`end=${segment.end}`);
}
const trim = `trim=${trimParts.join(':')}`;
// setpts=PTS-STARTPTS resets timestamps after trim.
// Dividing by speed makes it faster (speed>1) or slower (speed<1).
const setpts = segment.speed === 1
? 'setpts=PTS-STARTPTS'
: `setpts=(PTS-STARTPTS)/${segment.speed}`;
// For normal-speed segments where output matches input: skip fps/scale
// to avoid unnecessary pixel processing and frame-rate filtering
const isPassthrough = segment.speed === 1
&& inputWidth === width
&& inputHeight === height
&& inputFrameRate === frameRate;
if (isPassthrough) {
return `[0:v]${trim},${setpts}[v${index}]`;
}
// Cap output FPS to the probed source FPS. This prevents sped-up sections
// from producing excessive frame rates when timestamps are compressed.
const fps = `fps=fps=${frameRate}:round=down`;
return `[0:v]${trim},${setpts},${fps},scale=${width}:${height}[v${index}]`;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export async function concatenateVideos(options) {
const { outputDimensions, frameRate, inputFiles, outputFile, signal } = options;
if (!outputDimensions || !frameRate || !inputFiles || !outputFile) {
throw new Error('Missing required parameters');
}
const timerId = `concat-${inputFiles.length}-videos-${path.basename(outputFile)}`;
console.time(timerId);
const encoder = await detectEncoder();
const encodingArgs = buildEncodingArgs(encoder);
// Build argv: -i file1 -i file2 ... -filter_complex "..." -map "[v_out]" output
const inputArgs = inputFiles.flatMap((file) => {
return ['-i', file.path];
});
const filterComplexParts = [];
const videoStreamParts = [];
inputFiles.forEach((file, index) => {
const videoStream = `[${index}:v:0]`;
let trimmedVideo = videoStream;
if (file.start !== undefined || file.end !== undefined) {
const start = file.start ?? 0;
const end = file.end ? `end=${file.end}` : '';
trimmedVideo = `${videoStream}trim=start=${start}:${end},setpts=PTS-STARTPTS`;
}
filterComplexParts.push(`${trimmedVideo},fps=${frameRate},scale=${outputDimensions.width}:${outputDimensions.height}[v${index}]`);
videoStreamParts.push(`[v${index}]`);
});
filterComplexParts.push(`${videoStreamParts.join('')}concat=n=${inputFiles.length}:v=1:a=0[v_out]`);
const filterComplex = filterComplexParts.join('; ');
const args = [
...inputArgs,
'-filter_complex', filterComplex,
'-map', '[v_out]',
...encodingArgs,
outputFile,
];
console.log('Running FFmpeg concat:', args.join(' '));
try {
await runCommand({ bin: 'ffmpeg', args, signal });
}
finally {
console.timeEnd(timerId);
}
}
/**
* Speed up (or slow down) sections of a video by timestamp ranges.
*
* Sections not covered by any SpeedSection play at normal speed.
* Uses a single ffmpeg filter_complex: trim each segment, apply setpts
* speed, normalize fps/scale, then concat — no intermediate files.
*
* @example
* ```ts
* await speedUpSections({
* inputFile: 'recording.mp4',
* sections: [
* { start: 10, end: 20, speed: 4 }, // 4x between 10s-20s
* { start: 30, end: 40, speed: 2 }, // 2x between 30s-40s
* ],
* })
* // → outputs recording-fast.mp4
* ```
*/
export async function speedUpSections(options) {
const { inputFile, sections, signal } = options;
if (sections.length === 0) {
throw new Error('At least one speed section is required');
}
for (const s of sections) {
if (s.speed <= 0) {
throw new Error(`Speed must be > 0, got ${s.speed}`);
}
if (s.end <= s.start) {
throw new Error(`Section end (${s.end}) must be greater than start (${s.start})`);
}
}
const outputFile = options.outputFile ?? defaultOutputPath(inputFile);
// Probe input when needed for defaults or for passthrough optimization
const dims = options.outputDimensions;
const fps = options.frameRate;
const probed = (!dims || !fps) ? await probeVideo(inputFile) : undefined;
const width = dims?.width ?? probed.width;
const height = dims?.height ?? probed.height;
const frameRate = fps ?? probed.frameRate;
const encoder = await detectEncoder();
const encodingArgs = buildEncodingArgs(encoder);
const timerId = `speedup-${sections.length}-sections-${path.basename(outputFile)}`;
console.time(timerId);
const segments = buildSegments(sections);
const filterParts = segments.map((segment, index) => {
return buildSegmentFilter({
segment,
index,
frameRate,
width,
height,
inputWidth: probed?.width,
inputHeight: probed?.height,
inputFrameRate: probed?.frameRate,
});
});
const streamLabels = segments.map((_, i) => {
return `[v${i}]`;
}).join('');
filterParts.push(`${streamLabels}concat=n=${segments.length}:v=1:a=0[v_out]`);
const filterComplex = filterParts.join('; ');
const args = [
'-i', inputFile,
'-filter_complex', filterComplex,
'-map', '[v_out]',
'-r', String(frameRate),
...encodingArgs,
outputFile,
];
console.log('Running FFmpeg speedup:', args.join(' '));
try {
await runCommand({ bin: 'ffmpeg', args, signal });
}
finally {
console.timeEnd(timerId);
}
return outputFile;
}
/**
* Compute which parts of a recording are "idle" (no execute() calls)
* and return them as SpeedSections that can be passed to speedUpSections().
*
* A buffer of INTERACTION_BUFFER_SECONDS is kept around each execution
* at normal speed so the viewer sees context before/after each action.
*
* @example
* ```ts
* const { executionTimestamps, duration } = await stopRecording()
* const idleSections = computeIdleSections({
* executionTimestamps,
* totalDurationMs: duration,
* })
* await speedUpSections({
* inputFile: recordingPath,
* sections: idleSections,
* })
* ```
*/
export function computeIdleSections({ executionTimestamps, totalDurationMs, speed = 5, bufferSeconds = INTERACTION_BUFFER_SECONDS, }) {
const totalDuration = totalDurationMs / 1000;
if (executionTimestamps.length === 0) {
// No execute() boundaries were captured. This commonly happens when
// recording starts and stops inside a single execute() call.
// In this case we cannot infer idle gaps safely, so keep original speed.
return [];
}
// Apply buffer: expand each execution range by bufferSeconds on each side,
// clamp to video bounds, then filter out any ranges that become invalid
// (e.g. timestamps that exceed the video duration).
const buffered = executionTimestamps
.map((t) => ({
start: Math.max(0, t.start - bufferSeconds),
end: Math.min(totalDuration, t.end + bufferSeconds),
}))
.filter((r) => {
return Number.isFinite(r.start) && Number.isFinite(r.end) && r.end > r.start;
})
.sort((a, b) => {
return a.start - b.start;
});
// Merge overlapping/adjacent buffered ranges
const merged = [];
for (const range of buffered) {
const last = merged[merged.length - 1];
if (last && range.start <= last.end) {
last.end = Math.max(last.end, range.end);
}
else {
merged.push({ ...range });
}
}
// Gaps between merged active ranges are idle sections to speed up
const idle = [];
let cursor = 0;
for (const active of merged) {
if (active.start > cursor) {
idle.push({ start: cursor, end: active.start, speed });
}
cursor = active.end;
}
// Trailing idle after last execution
if (cursor < totalDuration) {
idle.push({ start: cursor, end: totalDuration, speed });
}
return idle;
}
/**
* Create a demo video from a recording by speeding up idle sections
* (gaps between execute() calls) while keeping interactions at normal speed.
*
* A 1-second buffer (INTERACTION_BUFFER_SECONDS) is preserved around each
* interaction so viewers see context before and after each action.
*
* Requires `ffmpeg` and `ffprobe` installed on the system.
*
* @returns The output file path
*/
export async function createDemoVideo(options) {
const { recordingPath, durationMs, executionTimestamps, speed = 5, signal, } = options;
const outputFile = options.outputFile ?? (() => {
const ext = path.extname(recordingPath);
const base = path.basename(recordingPath, ext);
const dir = path.dirname(recordingPath);
return path.join(dir, `${base}-demo${ext}`);
})();
const idleSections = computeIdleSections({
executionTimestamps,
totalDurationMs: durationMs,
speed,
});
if (idleSections.length === 0) {
// No idle sections, nothing to speed up — copy as-is
const { copyFile } = await import('node:fs/promises');
await copyFile(recordingPath, outputFile);
return outputFile;
}
return speedUpSections({
inputFile: recordingPath,
outputFile,
sections: idleSections,
signal,
});
}
//# sourceMappingURL=ffmpeg.js.map