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:
190
src/editor.ts
190
src/editor.ts
@@ -1,5 +1,5 @@
|
|||||||
import { spawn, spawnSync } from 'node:child_process'
|
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 path from 'node:path'
|
||||||
import { getVideo, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js'
|
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_WIDTH = 1920
|
||||||
export const MAX_HEIGHT = 1080
|
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`
|
`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 = [
|
const args = [
|
||||||
'-y',
|
'-y',
|
||||||
|
...enc.preInputArgs,
|
||||||
'-i', inputPath,
|
'-i', inputPath,
|
||||||
'-vf', vfilter,
|
'-vf', vfilter,
|
||||||
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23',
|
...enc.codecArgs,
|
||||||
'-c:a', 'aac', '-b:a', '160k',
|
'-c:a', 'aac', '-b:a', '160k',
|
||||||
'-movflags', '+faststart',
|
'-movflags', '+faststart',
|
||||||
// 진행률을 stdout 으로 key=value 로 받기 위해 -progress pipe:1, -nostats 를 켠다.
|
// 진행률을 stdout 으로 key=value 로 받기 위해 -progress pipe:1, -nostats 를 켠다.
|
||||||
@@ -221,11 +384,26 @@ export async function applyTrimToVideo(
|
|||||||
if (!ok) {
|
if (!ok) {
|
||||||
// 시도 2: 재인코딩. 60fps 미만 소스는 fps=60 프레임 복제로 끌어올림 (속도 우선).
|
// 시도 2: 재인코딩. 60fps 미만 소스는 fps=60 프레임 복제로 끌어올림 (속도 우선).
|
||||||
// (다운로드 단계에서 이미 60fps 로 끌어올리므로 이 경로는 직접 업로드용 안전망.)
|
// (다운로드 단계에서 이미 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 = [
|
const encArgs = [
|
||||||
...baseArgs,
|
'-y',
|
||||||
|
...enc.preInputArgs,
|
||||||
|
'-ss', String(startSec),
|
||||||
|
...(endSec !== null ? ['-to', String(endSec)] : []),
|
||||||
|
'-i', inputPath,
|
||||||
...(vfilter ? ['-vf', vfilter] : []),
|
...(vfilter ? ['-vf', vfilter] : []),
|
||||||
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23',
|
...enc.codecArgs,
|
||||||
'-c:a', 'aac', '-b:a', '128k',
|
'-c:a', 'aac', '-b:a', '128k',
|
||||||
'-movflags', '+faststart',
|
'-movflags', '+faststart',
|
||||||
tmpPath
|
tmpPath
|
||||||
|
|||||||
Reference in New Issue
Block a user