feat: optimize ffmpeg encoding for screen recordings on social media

Add hardware encoder detection with automatic fallback:
- h264_videotoolbox (macOS) → h264_nvenc/qsv/amf (Windows) → h264_nvenc/qsv (Linux) → libx264
- Probe caches result per process, 5s timeout per encoder test
- h264_vaapi excluded (needs -vaapi_device + hwupload in filter graph)

Add social-media-optimized encoding parameters:
- libx264: CRF 18 (near visually lossless for sharp text), preset fast,
  deblock=-1,-1 (preserves text edges), no -tune flag (animation blurs
  text, zerolatency is for real-time capture only)
- h264_videotoolbox: -q:v 80 (≈CRF 18 for screen content)
- Common: yuv420p, maxrate 25M (X.com cap), bufsize 50M, movflags +faststart

Skip redundant fps/scale filters on normal-speed passthrough segments
when output dimensions and frame rate match the probed input values.

Previously no -c:v or quality params were specified at all — ffmpeg
defaulted to libx264 -preset medium -crf 23, which is tuned for natural
video, not screen recordings, and purely CPU-based.

Tested on 48s 3440x1964 screen recording: 17s encode time with
VideoToolbox hardware acceleration, 5.5 Mbps output bitrate.
This commit is contained in:
Tommy D. Rossi
2026-02-26 20:31:19 +01:00
parent 85b7fb8a57
commit c9492fb4b0
+174 -5
View File
@@ -7,6 +7,7 @@
*/ */
import { spawn } from 'node:child_process' import { spawn } from 'node:child_process'
import os from 'node:os'
import path from 'node:path' import path from 'node:path'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -16,6 +17,136 @@ import path from 'node:path'
/** Seconds of normal-speed buffer kept before and after each execution */ /** Seconds of normal-speed buffer kept before and after each execution */
export const INTERACTION_BUFFER_SECONDS = 1 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: Record<string, string[]> = {
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'],
}
interface EncoderInfo {
/** Encoder name for `-c:v`, e.g. "h264_videotoolbox" or "libx264" */
codec: string
/** true when using a hardware encoder */
isHardware: boolean
}
/** Cache so we only probe once per process */
let cachedEncoder: EncoderInfo | undefined
/**
* 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(): Promise<EncoderInfo> {
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: string): Promise<boolean> {
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: EncoderInfo): string[] {
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,
]
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -247,7 +378,11 @@ function buildSegments(sections: SpeedSection[]): Segment[] {
/** /**
* Build the filter string for a single segment. * Build the filter string for a single segment.
* Returns `[0:v]trim=...,setpts=...,fps=...,scale=...[vN]` *
* 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({ function buildSegmentFilter({
segment, segment,
@@ -255,12 +390,20 @@ function buildSegmentFilter({
frameRate, frameRate,
width, width,
height, height,
inputWidth,
inputHeight,
inputFrameRate,
}: { }: {
segment: Segment segment: Segment
index: number index: number
frameRate: number frameRate: number
width: number width: number
height: number height: number
/** Original input dimensions — when they match output, scale is skipped on passthrough */
inputWidth?: number
inputHeight?: number
/** Original input fps — when it matches output, fps filter is skipped on passthrough */
inputFrameRate?: number
}): string { }): string {
const trimParts = [`start=${segment.start}`] const trimParts = [`start=${segment.start}`]
if (segment.end !== undefined) { if (segment.end !== undefined) {
@@ -275,6 +418,17 @@ function buildSegmentFilter({
? 'setpts=PTS-STARTPTS' ? 'setpts=PTS-STARTPTS'
: `setpts=(PTS-STARTPTS)/${segment.speed}` : `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 // Cap output FPS to the probed source FPS. This prevents sped-up sections
// from producing excessive frame rates when timestamps are compressed. // from producing excessive frame rates when timestamps are compressed.
const fps = `fps=fps=${frameRate}:round=down` const fps = `fps=fps=${frameRate}:round=down`
@@ -299,6 +453,9 @@ export async function concatenateVideos(
const timerId = `concat-${inputFiles.length}-videos-${path.basename(outputFile)}` const timerId = `concat-${inputFiles.length}-videos-${path.basename(outputFile)}`
console.time(timerId) console.time(timerId)
const encoder = await detectEncoder()
const encodingArgs = buildEncodingArgs(encoder)
// Build argv: -i file1 -i file2 ... -filter_complex "..." -map "[v_out]" output // Build argv: -i file1 -i file2 ... -filter_complex "..." -map "[v_out]" output
const inputArgs = inputFiles.flatMap((file) => { const inputArgs = inputFiles.flatMap((file) => {
return ['-i', file.path] return ['-i', file.path]
@@ -328,7 +485,13 @@ export async function concatenateVideos(
) )
const filterComplex = filterComplexParts.join('; ') const filterComplex = filterComplexParts.join('; ')
const args = [...inputArgs, '-filter_complex', filterComplex, '-map', '[v_out]', outputFile] const args = [
...inputArgs,
'-filter_complex', filterComplex,
'-map', '[v_out]',
...encodingArgs,
outputFile,
]
console.log('Running FFmpeg concat:', args.join(' ')) console.log('Running FFmpeg concat:', args.join(' '))
@@ -379,16 +542,18 @@ export async function speedUpSections(
const outputFile = options.outputFile ?? defaultOutputPath(inputFile) const outputFile = options.outputFile ?? defaultOutputPath(inputFile)
// Probe input for defaults // Probe input when needed for defaults or for passthrough optimization
const dims = options.outputDimensions const dims = options.outputDimensions
const fps = options.frameRate const fps = options.frameRate
const needsProbe = !dims || !fps const probed = (!dims || !fps) ? await probeVideo(inputFile) : undefined
const probed = needsProbe ? await probeVideo(inputFile) : undefined
const width = dims?.width ?? probed!.width const width = dims?.width ?? probed!.width
const height = dims?.height ?? probed!.height const height = dims?.height ?? probed!.height
const frameRate = fps ?? probed!.frameRate const frameRate = fps ?? probed!.frameRate
const encoder = await detectEncoder()
const encodingArgs = buildEncodingArgs(encoder)
const timerId = `speedup-${sections.length}-sections-${path.basename(outputFile)}` const timerId = `speedup-${sections.length}-sections-${path.basename(outputFile)}`
console.time(timerId) console.time(timerId)
@@ -401,6 +566,9 @@ export async function speedUpSections(
frameRate, frameRate,
width, width,
height, height,
inputWidth: probed?.width,
inputHeight: probed?.height,
inputFrameRate: probed?.frameRate,
}) })
}) })
@@ -418,6 +586,7 @@ export async function speedUpSections(
'-filter_complex', filterComplex, '-filter_complex', filterComplex,
'-map', '[v_out]', '-map', '[v_out]',
'-r', String(frameRate), '-r', String(frameRate),
...encodingArgs,
outputFile, outputFile,
] ]