perf(editor): pick hardware h264 encoder (nvenc/qsv/videotoolbox/vaapi) over libx264

5/16 사용자 요구 "변환 너무 느려 20배는 빠르게 해줘" 대응.

libx264 -preset ultrafast 보다 실질적으로 20배 빠른 길은 GPU/전용 ASIC
인코더밖에 없으므로, 첫 사용 시점에 한 번 자동 탐지해서 캐시한다.

탐지 흐름:
1) ffmpeg `-encoders` 출력에서 h264_nvenc, h264_qsv, h264_videotoolbox,
   h264_vaapi 후보를 추린다 (vaapi 는 /dev/dri/renderD128 도 필요).
2) 각 후보를 lavfi color 1프레임 인코드로 실측. 5초 타임아웃.
3) 첫 성공을 캐시; 모두 실패하면 libx264 ultrafast 폴백.
4) 우선순위: nvenc > qsv > videotoolbox > vaapi > libx264.

각 프로필은 (preInputArgs, vfilterSuffix, codecArgs) 세 묶음을 들고 있어
호출자는 자기 vfilter chain 끝과 `-i` 앞에 그대로 spread 만 하면 된다.
vaapi 의 `-vaapi_device .../renderD128` 와 `,format=nv12,hwupload` 가
이 모델로 깨끗하게 흡수된다.

연결 지점:
- upscaleOriginalTo60Fps: libx264 자리에 enc.codecArgs spread. vfilter 끝에
  enc.vfilterSuffix 이어붙임. preInputArgs 를 -i 앞에 prepend.
- applyTrimToVideo 재인코드 경로: 동일 처리. baseArgs 를 처음부터 다시
  쌓아 preInputArgs 가 ffmpeg 전역 옵션 위치(-ss/-to/-i 보다 앞)에 가게.
- stream-copy trim 경로는 그대로 둠 (인코더 안 씀).

검증 (fake ffmpeg + lavfi 1프레임 테스트):
- only libx264 listed                    → libx264 (폴백)
- nvenc+qsv listed, nvenc fails          → qsv
- nvenc works                            → nvenc
- only vaapi works                       → vaapi (+ preInput/vfilter suffix 확인)
- nvenc+qsv+vaapi listed, all HW fail    → libx264 (폴백)
This commit is contained in:
Claude (owner)
2026-06-01 10:11:18 +09:00
parent ef65d6674f
commit a0a094b517

View File

@@ -1,5 +1,5 @@
import { spawn, spawnSync } from 'node:child_process'
import { promises as fs } from 'node:fs'
import { promises as fs, existsSync } from 'node:fs'
import path from 'node:path'
import { getVideo, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js'
@@ -81,6 +81,164 @@ export const TARGET_FPS = 60
export const MAX_WIDTH = 1920
export const MAX_HEIGHT = 1080
// ─── 하드웨어 인코더 자동 선택 ─────────────────────────────────────────────
//
// 5/16 사용자 요구: "변환 너무 느려 20배는 빠르게 해줘".
// 현실적으로 libx264 -preset ultrafast 보다 20배 빠른 길은 GPU/전용 ASIC
// 인코더밖에 없다. 호스트에 깔린 ffmpeg 가 노출하는 h264_* 인코더 중
// 실제로 동작하는 것 하나를 우선순위에 따라 선택해 캐시한다.
//
// 우선순위: nvenc (NVIDIA) > qsv (Intel Quick Sync) > videotoolbox (Apple
// Silicon) > vaapi (범용 리눅스 /dev/dri) > libx264 ultrafast (소프트웨어 폴백).
// nvenc/qsv/videotoolbox 는 픽셀 포맷 변환 없이 바로 붙고, vaapi 는
// `format=nv12,hwupload` 필터를 vfilter 끝에 붙이고 `-vaapi_device` 를
// 입력 앞에 prepend 해야 한다. 그래서 각 프로필이 vfilter suffix 와
// preInputArgs 까지 함께 들고 있다.
export type H264Encoder =
| 'libx264'
| 'h264_nvenc'
| 'h264_qsv'
| 'h264_videotoolbox'
| 'h264_vaapi'
interface EncoderProfile {
name: H264Encoder
/** `-i` 앞에 prepend 되는 인자. vaapi 의 `-vaapi_device ...` 같은 것. */
preInputArgs: string[]
/**
* 호출자가 만든 vfilter chain 뒤에 그대로 이어붙이는 suffix.
* 예: vaapi 는 `,format=nv12,hwupload`. 비어 있으면 추가 변환 없음.
* 호출자 vfilter 가 빈 문자열이면 이 suffix 도 무시되므로 lead-comma 처리는 호출 측에서.
*/
vfilterSuffix: string
/** `-c:v libx264 -preset ultrafast -crf 23` 자리를 그대로 대체하는 인자 묶음. */
codecArgs: string[]
}
let resolvedEncoder: EncoderProfile | null = null
let encoderDetectInflight: Promise<EncoderProfile> | null = null
const SOFTWARE_FALLBACK: EncoderProfile = {
name: 'libx264',
preInputArgs: [],
vfilterSuffix: '',
codecArgs: ['-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23']
}
function listFfmpegEncoders(bin: string): string {
const r = spawnSync(bin, ['-hide_banner', '-encoders'])
return r.status === 0 ? String(r.stdout) : ''
}
function buildCandidates(encodersListed: string): EncoderProfile[] {
const out: EncoderProfile[] = []
if (/\bh264_nvenc\b/.test(encodersListed)) {
// p1 = 가장 빠른 NVENC 프리셋. tune=ll 는 low-latency (인코더 룩어헤드 비활성)
// — 우리 케이스는 모두 파일 입력이라 latency 보다 throughput 위주가 맞다.
// -rc vbr + -cq 23: 가변 비트레이트, 품질 23 (libx264 crf 23 과 비슷한 위치).
out.push({
name: 'h264_nvenc',
preInputArgs: [],
vfilterSuffix: '',
codecArgs: ['-c:v', 'h264_nvenc', '-preset', 'p1', '-tune', 'll', '-rc', 'vbr', '-cq', '23', '-b:v', '0']
})
}
if (/\bh264_qsv\b/.test(encodersListed)) {
out.push({
name: 'h264_qsv',
preInputArgs: [],
vfilterSuffix: '',
codecArgs: ['-c:v', 'h264_qsv', '-preset', 'veryfast', '-global_quality', '23']
})
}
if (/\bh264_videotoolbox\b/.test(encodersListed)) {
// -q:v 는 0..100, 50 이면 중상 품질.
out.push({
name: 'h264_videotoolbox',
preInputArgs: [],
vfilterSuffix: '',
codecArgs: ['-c:v', 'h264_videotoolbox', '-q:v', '50']
})
}
if (/\bh264_vaapi\b/.test(encodersListed) && existsSync('/dev/dri/renderD128')) {
out.push({
name: 'h264_vaapi',
preInputArgs: ['-vaapi_device', '/dev/dri/renderD128'],
vfilterSuffix: ',format=nv12,hwupload',
codecArgs: ['-c:v', 'h264_vaapi', '-qp', '23']
})
}
return out
}
/**
* lavfi color 소스로 1프레임만 뽑아보고 인코더가 실제로 동작하는지 확인.
* - "encoders" 리스트에 떠도 런타임에 디바이스가 없거나 라이브러리가 깨져
* 실제 인코딩에서 실패하는 경우가 흔하다.
* - 5초 안에 안 끝나면 실패로 간주 (정상적인 1프레임 인코딩은 1초 이내).
*/
function testEncoder(bin: string, prof: EncoderProfile): Promise<boolean> {
return new Promise((resolve) => {
const vfArgs =
prof.name === 'h264_vaapi' ? ['-vf', 'format=nv12,hwupload'] : []
const args = [
'-hide_banner', '-loglevel', 'error',
...prof.preInputArgs,
'-f', 'lavfi', '-i', 'color=size=128x128:duration=0.1:rate=30',
...vfArgs,
...prof.codecArgs,
'-frames:v', '1', '-f', 'null', '-'
]
let settled = false
const child = spawn(bin, args, { stdio: ['ignore', 'ignore', 'pipe'] })
const timer = setTimeout(() => {
if (settled) return
settled = true
try { child.kill('SIGKILL') } catch { /* ignore */ }
resolve(false)
}, 5000)
child.on('error', () => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(false)
})
child.on('close', (code) => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(code === 0)
})
})
}
/**
* 우선순위대로 후보 인코더를 1프레임 인코딩 테스트해 첫 성공을 캐시.
* 모두 실패하면 libx264 ultrafast 로 폴백. 동시 호출은 in-flight Promise 공유.
*/
export function detectH264Encoder(bin: string): Promise<EncoderProfile> {
if (resolvedEncoder) return Promise.resolve(resolvedEncoder)
if (encoderDetectInflight) return encoderDetectInflight
encoderDetectInflight = (async () => {
const list = listFfmpegEncoders(bin)
for (const c of buildCandidates(list)) {
const ok = await testEncoder(bin, c)
if (ok) {
console.log(`[encoder] using ${c.name}`)
resolvedEncoder = c
return c
}
console.warn(`[encoder] ${c.name} 사용 불가 — 다음 후보 시도`)
}
console.log('[encoder] using libx264 ultrafast (software fallback)')
resolvedEncoder = SOFTWARE_FALLBACK
return SOFTWARE_FALLBACK
})()
encoderDetectInflight.finally(() => { encoderDetectInflight = null })
return encoderDetectInflight
}
/**
* 원본 영상을 다운스트림 요구사항에 맞추는 후처리.
*
@@ -139,12 +297,17 @@ export async function upscaleOriginalTo60Fps(
`scale=trunc(iw/2)*2:trunc(ih/2)*2`
)
}
const vfilter = filters.join(',')
const enc = await detectH264Encoder(bin)
// vaapi 의 경우 hwupload 가 vfilter 끝에 붙어야 한다 (그 뒤로는 GPU 전용 필터만 가능).
// filters 가 비어있는 케이스는 needBumpFps/needDownscale 가 둘 다 false 인
// 케이스에서 이미 early-return 됐으므로 여기 도달하면 filters.length >= 1.
const vfilter = filters.join(',') + enc.vfilterSuffix
const args = [
'-y',
...enc.preInputArgs,
'-i', inputPath,
'-vf', vfilter,
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23',
...enc.codecArgs,
'-c:a', 'aac', '-b:a', '160k',
'-movflags', '+faststart',
// 진행률을 stdout 으로 key=value 로 받기 위해 -progress pipe:1, -nostats 를 켠다.
@@ -221,11 +384,26 @@ export async function applyTrimToVideo(
if (!ok) {
// 시도 2: 재인코딩. 60fps 미만 소스는 fps=60 프레임 복제로 끌어올림 (속도 우선).
// (다운로드 단계에서 이미 60fps 로 끌어올리므로 이 경로는 직접 업로드용 안전망.)
const vfilter = needBumpFps ? `fps=${TARGET_FPS}` : null
const enc = await detectH264Encoder(bin)
let vfilter: string | null = needBumpFps ? `fps=${TARGET_FPS}` : null
if (enc.vfilterSuffix) {
// vaapi: hwupload 가 반드시 필터 끝에 필요. 사용자 필터 없어도 format 단계 들어가야 함.
vfilter = (vfilter ?? '') + enc.vfilterSuffix
// 빈 vfilter 였다가 suffix 가 ',format=nv12,hwupload' 형태로 붙으면
// 선두 쉼표가 ffmpeg 파싱 오류를 낸다. 선두 쉼표 제거.
vfilter = vfilter.replace(/^,+/, '')
}
// baseArgs(['-y', '-ss', start, ['-to', end]?, '-i', inputPath]) 를 그대로 쓰면
// preInputArgs(`-vaapi_device ...` 같은 ffmpeg 전역 옵션) 가 들어갈 곳이 없다.
// 전역 옵션은 -ss/-to/-i 보다 더 앞이어야 안전하므로 args 를 처음부터 다시 만든다.
const encArgs = [
...baseArgs,
'-y',
...enc.preInputArgs,
'-ss', String(startSec),
...(endSec !== null ? ['-to', String(endSec)] : []),
'-i', inputPath,
...(vfilter ? ['-vf', vfilter] : []),
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23',
...enc.codecArgs,
'-c:a', 'aac', '-b:a', '128k',
'-movflags', '+faststart',
tmpPath