fix(editor): per-job libx264 fallback when HW encoder fails at real encode (STEP B P1)

리뷰어 P1: 1프레임 lavfi 테스트는 통과해도 실제 영상의 픽셀 포맷/색공간/
해상도 조합에서 HW 인코더가 깨지는 경우가 흔하다 (NVENC yuv444 거부,
VAAPI 포맷 chain 까다로움 등). 기존 코드는 HW 가 실제 인코딩에서 실패하면
그대로 포기해 "원본도 60fps + 최대 FHD" 보장이 깨졌다.

수정:
- upscaleOriginalTo60Fps: 선택된 인코더가 실제 영상에서 실패하고 그게
  libx264 가 아니면 libx264 ultrafast 로 한 번 더 시도. 둘 다 실패해야
  진짜 실패로 보고 원본 유지. 인자 조립을 buildUpscaleArgs(profile) 로
  추출해 같은 vfilter/onProgress 흐름을 재사용.
- applyTrimToVideo 재인코딩 경로: 동일 패턴. buildTrimEncArgs(profile)
  로 추출해 HW 실패 시 libx264 재시도. 둘 다 실패해야 throw.
- detectH264Encoder 의 캐시는 그대로 둔다 (다른 잡은 더 일반적인 입력
  일 수 있어 HW 가 동작할 여지를 남긴다).

검증 (fake ffmpeg + fake ffprobe, 3 시나리오, 정리 완료):
- HW 테스트 PASS, REAL FAIL → libx264 재시도로 변환본 생성 ✓
- HW 테스트 PASS, REAL PASS → HW 그대로 사용, 재시도 없음 ✓
- 모든 인코더 REAL FAIL → 원본 그대로, 변환 안 함 ✓
This commit is contained in:
Claude (owner)
2026-06-01 10:15:56 +09:00
parent a0a094b517
commit 4909d18493

View File

@@ -297,17 +297,17 @@ export async function upscaleOriginalTo60Fps(
`scale=trunc(iw/2)*2:trunc(ih/2)*2`
)
}
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 buildUpscaleArgs = (profile: EncoderProfile): string[] => {
const vfilter = filters.join(',') + profile.vfilterSuffix
return [
'-y',
...enc.preInputArgs,
...profile.preInputArgs,
'-i', inputPath,
'-vf', vfilter,
...enc.codecArgs,
...profile.codecArgs,
'-c:a', 'aac', '-b:a', '160k',
'-movflags', '+faststart',
// 진행률을 stdout 으로 key=value 로 받기 위해 -progress pipe:1, -nostats 를 켠다.
@@ -315,14 +315,26 @@ export async function upscaleOriginalTo60Fps(
'-nostats',
tmpPath
]
const ok = await runFfmpegWithProgress(bin, args, (outTimeUs) => {
}
const onProgressCb = (outTimeUs: number) => {
if (!onProgress || !sourceDurationSec || sourceDurationSec <= 0) return
const pct = Math.max(0, Math.min(100, (outTimeUs / 1e6 / sourceDurationSec) * 100))
onProgress(pct)
})
}
const enc = await detectH264Encoder(bin)
let ok = await runFfmpegWithProgress(bin, buildUpscaleArgs(enc), onProgressCb)
if (!ok && enc.name !== 'libx264') {
// 5/16 STEP B P1 (리뷰어): 1프레임 lavfi 테스트로 통과한 HW 인코더가 실제
// 영상(픽셀 포맷/색공간/해상도 조합)에서 깨지는 경우가 흔하다. 이 파일 한
// 건에 한해 libx264 ultrafast 로 재시도 — 캐시는 그대로 두므로 다음 잡이
// 더 일반적인 입력이면 다시 HW 가 동작할 여지를 남긴다.
await fs.unlink(tmpPath).catch(() => undefined)
console.warn(`[upscale] ${enc.name} 변환 실패 — libx264 ultrafast 로 재시도`)
ok = await runFfmpegWithProgress(bin, buildUpscaleArgs(SOFTWARE_FALLBACK), onProgressCb)
}
if (!ok) {
await fs.unlink(tmpPath).catch(() => undefined)
console.warn(`[upscale] 후처리 실패 (vf=${vfilter}) — 원본 ${inputName} 유지`)
console.warn(`[upscale] 후처리 실패 (filters=${filters.join(',')}) — 원본 ${inputName} 유지`)
return inputName
}
// 안전 순서: 먼저 tmp → outPath rename (성공해야 원본 교체 진행).
@@ -384,31 +396,42 @@ export async function applyTrimToVideo(
if (!ok) {
// 시도 2: 재인코딩. 60fps 미만 소스는 fps=60 프레임 복제로 끌어올림 (속도 우선).
// (다운로드 단계에서 이미 60fps 로 끌어올리므로 이 경로는 직접 업로드용 안전망.)
const enc = await detectH264Encoder(bin)
//
// baseArgs(['-y', '-ss', start, ['-to', end]?, '-i', inputPath]) 를 그대로 쓰면
// preInputArgs(`-vaapi_device ...` 같은 ffmpeg 전역 옵션) 가 들어갈 곳이 없다.
// 전역 옵션은 -ss/-to/-i 보다 더 앞이어야 안전하므로 args 를 처음부터 다시 만든다.
const buildTrimEncArgs = (profile: EncoderProfile): string[] => {
let vfilter: string | null = needBumpFps ? `fps=${TARGET_FPS}` : null
if (enc.vfilterSuffix) {
if (profile.vfilterSuffix) {
// vaapi: hwupload 가 반드시 필터 끝에 필요. 사용자 필터 없어도 format 단계 들어가야 함.
vfilter = (vfilter ?? '') + enc.vfilterSuffix
vfilter = (vfilter ?? '') + profile.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 = [
return [
'-y',
...enc.preInputArgs,
...profile.preInputArgs,
'-ss', String(startSec),
...(endSec !== null ? ['-to', String(endSec)] : []),
'-i', inputPath,
...(vfilter ? ['-vf', vfilter] : []),
...enc.codecArgs,
...profile.codecArgs,
'-c:a', 'aac', '-b:a', '128k',
'-movflags', '+faststart',
tmpPath
]
ok = await runFfmpeg(bin, encArgs)
}
const enc = await detectH264Encoder(bin)
ok = await runFfmpeg(bin, buildTrimEncArgs(enc))
if (!ok && enc.name !== 'libx264') {
// 5/16 STEP B P1 (리뷰어): 탐지 통과해도 실파일에서 HW 인코더가 실패할
// 수 있다. 이 파일에 한해 libx264 ultrafast 로 한 번 더 시도. 둘 다
// 실패해야 진짜 실패로 던진다.
await fs.unlink(tmpPath).catch(() => undefined)
console.warn(`[trim] ${enc.name} 재인코딩 실패 — libx264 ultrafast 재시도`)
ok = await runFfmpeg(bin, buildTrimEncArgs(SOFTWARE_FALLBACK))
}
if (!ok) throw new Error('ffmpeg trim 실패')
}
await fs.rename(tmpPath, outPath)