/** * 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 -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 -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