Files
make_video_site/src/editor.ts
Claude (owner) 4909d18493 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 → 원본 그대로, 변환 안 함 ✓
2026-06-01 10:15:56 +09:00

515 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn, spawnSync } from 'node:child_process'
import { promises as fs, existsSync } from 'node:fs'
import path from 'node:path'
import { getVideo, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js'
export class FfmpegUnavailableError extends Error {
constructor() {
super('ffmpeg 가 설치되어 있지 않습니다. ffmpeg 를 PATH 에 설치한 뒤 다시 시도해 주세요.')
}
}
let resolvedFfmpegPath: string | null = null
export function getFfmpegPath(): string {
if (resolvedFfmpegPath) return resolvedFfmpegPath
const r = spawnSync('ffmpeg', ['-version'])
if (r.status === 0) {
resolvedFfmpegPath = 'ffmpeg'
return 'ffmpeg'
}
throw new FfmpegUnavailableError()
}
/** 입력 영상의 평균 fps 를 ffprobe 로 조회. 실패하면 null. */
export function probeVideoFps(inputPath: string): number | null {
const r = spawnSync('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=avg_frame_rate',
'-of', 'default=nokey=1:noprint_wrappers=1',
inputPath
])
if (r.status !== 0) return null
const raw = String(r.stdout).trim()
// 예: "60000/1001" → 59.94, "30/1" → 30
const m = /^(\d+)\/(\d+)$/.exec(raw)
if (m) {
const n = Number(m[1])
const d = Number(m[2])
if (d > 0 && Number.isFinite(n / d)) return n / d
return null
}
const single = Number(raw)
return Number.isFinite(single) && single > 0 ? single : null
}
/** 입력 영상의 해상도(가로×세로 px) 를 ffprobe 로 조회. 실패하면 null. */
export function probeVideoResolution(inputPath: string): { width: number; height: number } | null {
const r = spawnSync('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=width,height',
'-of', 'csv=s=x:p=0',
inputPath
])
if (r.status !== 0) return null
const raw = String(r.stdout).trim()
const m = /^(\d+)x(\d+)$/.exec(raw)
if (!m) return null
const w = Number(m[1])
const h = Number(m[2])
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null
return { width: w, height: h }
}
/** 입력 영상의 총 재생 길이(초) 를 ffprobe 로 조회. 실패하면 null. */
export function probeVideoDuration(inputPath: string): number | null {
const r = spawnSync('ffprobe', [
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=nokey=1:noprint_wrappers=1',
inputPath
])
if (r.status !== 0) return null
const v = Number(String(r.stdout).trim())
return Number.isFinite(v) && v > 0 ? v : null
}
export const TARGET_FPS = 60
// FHD 캡. 사용자 요구: FHD 보다 작으면 그대로, FHD 보다 크면 강제로 FHD 로 다운스케일.
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
}
/**
* 원본 영상을 다운스트림 요구사항에 맞추는 후처리.
*
* 1) fps < 60 이면 fps=60 프레임 복제로 끌어올림 (속도 우선; mci 는 너무 느림).
* 2) 해상도가 FHD(1920×1080) 보다 크면 종횡비를 유지한 채 FHD 로 다운스케일.
*
* 둘 다 필요 없으면 inputName 그대로 반환해 인코딩 자체를 건너뛴다.
* ffmpeg/ffprobe 가 없거나 변환에 실패하면 inputName 을 그대로 반환해
* 호출자에게 영향을 안 준다. 성공 시 원본을 새 파일로 교체.
*/
export async function upscaleOriginalTo60Fps(
dir: string,
inputName: string,
onProgress?: (pct: number) => void
): Promise<string> {
let bin: string
try {
bin = getFfmpegPath()
} catch {
// ffmpeg 없으면 조용히 건너뜀 (다운로드 자체는 살림)
console.warn('[upscale] ffmpeg 없음 — 후처리 건너뜀')
return inputName
}
const inputPath = path.join(dir, inputName)
const sourceFps = probeVideoFps(inputPath)
const sourceRes = probeVideoResolution(inputPath)
if (sourceFps === null && sourceRes === null) {
console.warn(`[upscale] 메타 확인 실패 (${inputPath}) — 후처리 건너뜀`)
return inputName
}
const needBumpFps = sourceFps !== null && sourceFps < TARGET_FPS - 0.5
const needDownscale =
sourceRes !== null && (sourceRes.width > MAX_WIDTH || sourceRes.height > MAX_HEIGHT)
if (!needBumpFps && !needDownscale) {
// 이미 충분히 부드럽고 해상도도 캡 이하.
return inputName
}
const sourceDurationSec = probeVideoDuration(inputPath)
// 재인코딩 결과는 항상 mp4 로 통일 (소스가 webm/mkv 여도).
const outName = 'original.mp4'
const outPath = path.join(dir, outName)
const tmpPath = path.join(dir, 'original.bump.tmp.mp4')
// vfilter 조립:
// - fps=60 : motion interpolation 대신 단순 프레임 복제 (속도 우선).
// - scale : iw>1920 또는 ih>1080 일 때만 종횡비 유지 다운스케일. libx264 는
// 짝수 변을 요구하므로 trunc(/2)*2 로 보정. force_original_aspect_ratio=decrease
// 를 쓰면 한 변이 캡과 같고 다른 변이 캡 이하로 떨어진다.
const filters: string[] = []
if (needBumpFps) filters.push(`fps=${TARGET_FPS}`)
if (needDownscale) {
filters.push(
`scale='min(${MAX_WIDTH},iw)':'min(${MAX_HEIGHT},ih)':force_original_aspect_ratio=decrease:flags=lanczos`,
`scale=trunc(iw/2)*2:trunc(ih/2)*2`
)
}
// vaapi 의 경우 hwupload 가 vfilter 끝에 붙어야 한다 (그 뒤로는 GPU 전용 필터만 가능).
// filters 가 비어있는 케이스는 needBumpFps/needDownscale 가 둘 다 false 인
// 케이스에서 이미 early-return 됐으므로 여기 도달하면 filters.length >= 1.
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] 후처리 실패 (filters=${filters.join(',')}) — 원본 ${inputName} 유지`)
return inputName
}
// 안전 순서: 먼저 tmp → outPath rename (성공해야 원본 교체 진행).
// rename 이 실패하면 tmp 만 정리하고 원본은 그대로 둔다.
try {
await fs.rename(tmpPath, outPath)
} catch (err) {
await fs.unlink(tmpPath).catch(() => undefined)
console.warn(`[upscale] rename 실패 — 원본 ${inputName} 유지: ${(err as Error).message}`)
return inputName
}
// rename 후 input 과 out 경로가 다르면 (확장자 변경 등) 기존 원본 제거.
// 이 단계 실패는 디스크 점유만 늘리고 동작에는 영향 없으므로 조용히 무시.
if (path.resolve(inputPath) !== path.resolve(outPath)) {
await fs.unlink(inputPath).catch(() => undefined)
}
return outName
}
/**
* 원본 파일을 그대로 둔 채 trim 결과를 edited.<ext> 로 저장한다.
* stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩.
*/
export async function applyTrimToVideo(
videoId: string,
trim: VideoTrim
): Promise<string> {
const bin = getFfmpegPath()
const meta = getVideo(videoId)
if (!meta) throw new Error('비디오를 찾을 수 없습니다.')
const dir = videoDiskDir(videoId)
const inputPath = path.join(dir, meta.originalFile)
await fs.access(inputPath)
const ext = path.extname(meta.originalFile) || '.mp4'
const outName = `edited${ext}`
const outPath = path.join(dir, outName)
const tmpPath = outPath + '.tmp' + ext
const startSec = Math.max(0, Number(trim.startSec) || 0)
const endSec = trim.endSec == null ? null : Math.max(startSec, Number(trim.endSec))
const baseArgs = ['-y', '-ss', String(startSec)]
if (endSec !== null) baseArgs.push('-to', String(endSec))
baseArgs.push('-i', inputPath)
// 출력은 항상 60fps 이상이 되어야 한다.
// 원본이 이미 60fps 이상이면 stream copy 로 빠르게 자르고,
// 그 미만이면 minterpolate 로 모션 보간해 60fps 까지 끌어올린다.
const sourceFps = probeVideoFps(inputPath)
const needBumpFps = sourceFps !== null && sourceFps < TARGET_FPS - 0.5
let ok = false
if (!needBumpFps) {
// 시도 1: stream copy (빠름, 소스가 이미 ≥60fps 이거나 fps 확인 불가일 때)
const copyArgs = [...baseArgs, '-c', 'copy', '-movflags', '+faststart', tmpPath]
ok = await runFfmpeg(bin, copyArgs)
}
if (!ok) {
// 시도 2: 재인코딩. 60fps 미만 소스는 fps=60 프레임 복제로 끌어올림 (속도 우선).
// (다운로드 단계에서 이미 60fps 로 끌어올리므로 이 경로는 직접 업로드용 안전망.)
//
// 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 (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)
updateVideo(videoId, {
editedFile: outName,
trim: { startSec, endSec }
})
return outName
}
function runFfmpeg(bin: string, args: string[]): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn(bin, args)
let stderr = ''
child.stderr.on('data', (c) => {
stderr = (stderr + c.toString()).slice(-2000)
})
child.on('error', () => resolve(false))
child.on('close', (code) => {
if (code === 0) {
resolve(true)
} else {
// 디버그용 stderr 만 콘솔로
console.error('[ffmpeg] failed:', stderr.split('\n').slice(-5).join('\n'))
resolve(false)
}
})
})
}
/**
* ffmpeg 의 `-progress pipe:1` 출력을 파싱해 진행 상황을 콜백으로 흘려준다.
*
* 출력 형식 (key=value, 한 블록 끝나면 `progress=continue` 또는 `progress=end`):
* frame=123\nfps=24.5\n...\nout_time_us=1234567\n...\nprogress=continue
*
* onOutTimeUs 는 현재까지 처리된 마이크로초를 받는다. 호출자는 이를
* 영상 총 길이와 비교해 % 로 변환할 수 있다.
*/
function runFfmpegWithProgress(
bin: string,
args: string[],
onOutTimeUs: (us: number) => void
): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn(bin, args)
let stderr = ''
let stdoutBuf = ''
child.stdout.on('data', (c) => {
stdoutBuf += c.toString()
let nl = stdoutBuf.indexOf('\n')
while (nl !== -1) {
const line = stdoutBuf.slice(0, nl).trim()
stdoutBuf = stdoutBuf.slice(nl + 1)
// ffmpeg 는 out_time_us 또는 out_time_ms 둘 다 내보낸다 (버전마다 다름).
// 둘 다 마이크로초 단위라 정확히 같은 값. 먼저 매치되는 걸 쓴다.
let m = /^out_time_us=(\d+)/.exec(line)
if (!m) m = /^out_time_ms=(\d+)/.exec(line)
if (m) {
const v = Number(m[1])
if (Number.isFinite(v)) onOutTimeUs(v)
}
nl = stdoutBuf.indexOf('\n')
}
})
child.stderr.on('data', (c) => {
stderr = (stderr + c.toString()).slice(-2000)
})
child.on('error', () => resolve(false))
child.on('close', (code) => {
if (code === 0) {
resolve(true)
} else {
console.error('[ffmpeg] failed:', stderr.split('\n').slice(-5).join('\n'))
resolve(false)
}
})
})
}