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:
103
src/editor.ts
103
src/editor.ts
@@ -297,32 +297,44 @@ 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 = [
|
||||
'-y',
|
||||
...enc.preInputArgs,
|
||||
'-i', inputPath,
|
||||
'-vf', vfilter,
|
||||
...enc.codecArgs,
|
||||
'-c:a', 'aac', '-b:a', '160k',
|
||||
'-movflags', '+faststart',
|
||||
// 진행률을 stdout 으로 key=value 로 받기 위해 -progress pipe:1, -nostats 를 켠다.
|
||||
'-progress', 'pipe:1',
|
||||
'-nostats',
|
||||
tmpPath
|
||||
]
|
||||
const ok = await runFfmpegWithProgress(bin, args, (outTimeUs) => {
|
||||
const buildUpscaleArgs = (profile: EncoderProfile): string[] => {
|
||||
const vfilter = filters.join(',') + profile.vfilterSuffix
|
||||
return [
|
||||
'-y',
|
||||
...profile.preInputArgs,
|
||||
'-i', inputPath,
|
||||
'-vf', vfilter,
|
||||
...profile.codecArgs,
|
||||
'-c:a', 'aac', '-b:a', '160k',
|
||||
'-movflags', '+faststart',
|
||||
// 진행률을 stdout 으로 key=value 로 받기 위해 -progress pipe:1, -nostats 를 켠다.
|
||||
'-progress', 'pipe:1',
|
||||
'-nostats',
|
||||
tmpPath
|
||||
]
|
||||
}
|
||||
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)
|
||||
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 = [
|
||||
'-y',
|
||||
...enc.preInputArgs,
|
||||
'-ss', String(startSec),
|
||||
...(endSec !== null ? ['-to', String(endSec)] : []),
|
||||
'-i', inputPath,
|
||||
...(vfilter ? ['-vf', vfilter] : []),
|
||||
...enc.codecArgs,
|
||||
'-c:a', 'aac', '-b:a', '128k',
|
||||
'-movflags', '+faststart',
|
||||
tmpPath
|
||||
]
|
||||
ok = await runFfmpeg(bin, encArgs)
|
||||
const buildTrimEncArgs = (profile: EncoderProfile): string[] => {
|
||||
let vfilter: string | null = needBumpFps ? `fps=${TARGET_FPS}` : null
|
||||
if (profile.vfilterSuffix) {
|
||||
// vaapi: hwupload 가 반드시 필터 끝에 필요. 사용자 필터 없어도 format 단계 들어가야 함.
|
||||
vfilter = (vfilter ?? '') + profile.vfilterSuffix
|
||||
// 빈 vfilter 였다가 suffix 가 ',format=nv12,hwupload' 형태로 붙으면
|
||||
// 선두 쉼표가 ffmpeg 파싱 오류를 낸다. 선두 쉼표 제거.
|
||||
vfilter = vfilter.replace(/^,+/, '')
|
||||
}
|
||||
return [
|
||||
'-y',
|
||||
...profile.preInputArgs,
|
||||
'-ss', String(startSec),
|
||||
...(endSec !== null ? ['-to', String(endSec)] : []),
|
||||
'-i', inputPath,
|
||||
...(vfilter ? ['-vf', vfilter] : []),
|
||||
...profile.codecArgs,
|
||||
'-c:a', 'aac', '-b:a', '128k',
|
||||
'-movflags', '+faststart',
|
||||
tmpPath
|
||||
]
|
||||
}
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user