From a0a094b5173a1c4240727097fde53fb4f857b4d6 Mon Sep 17 00:00:00 2001 From: "Claude (owner)" Date: Mon, 1 Jun 2026 10:11:18 +0900 Subject: [PATCH] perf(editor): pick hardware h264 encoder (nvenc/qsv/videotoolbox/vaapi) over libx264 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (폴백) --- src/editor.ts | 190 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 184 insertions(+), 6 deletions(-) diff --git a/src/editor.ts b/src/editor.ts index f03803a..d24733b 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -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 | 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 { + 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 { + 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