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 | 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 } /** * 원본 영상을 다운스트림 요구사항에 맞추는 후처리. * * 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 { 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. 로 저장한다. * stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩. */ export async function applyTrimToVideo( videoId: string, trim: VideoTrim ): Promise { 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 { 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 { 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) } }) }) }