Files
make_video_site/src/editor.ts
Claude (owner) 0abd21533b fix: 동시 영상 처리 시 공유 임시파일 레이스 제거 (고유 tmp 경로)
ensureThumbnail / upscaleOriginalTo60Fps / applyTrimToVideo 가 영상당
고정된 tmp 파일명(thumb.jpg.tmp.jpg, original.bump.tmp.mp4, edited.<ext>.tmp.<ext>)
을 써서, 같은 영상에 대한 요청이 동시에 들어오면 ffmpeg 프로세스들이 서로의
tmp 를 덮어쓰고 rename 이 경합해 일부 요청이 헛되이 실패했다.

증상: 같은 썸네일을 동시 10회 요청하면 6건만 성공하고 4건이 404.
원인: 호출마다 같은 tmp 경로 공유. 해결: randomUUID 로 호출별 고유 tmp 경로.
검증: 수정 후 동일 동시요청 10/10 성공, tmp 잔여물 없음.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 02:53:36 +09:00

651 lines
24 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 { randomUUID } from 'node:crypto'
import path from 'node:path'
import { getVideoInFolder, updateVideo, videoDiskDir, type VideoTrim } from './storeDb.js'
import { binDir } from './paths.js'
export class FfmpegUnavailableError extends Error {
constructor() {
super('ffmpeg 가 설치되어 있지 않습니다. ffmpeg 를 PATH 에 설치한 뒤 다시 시도해 주세요.')
}
}
/**
* 바이너리 해석: data/bin 에 재설치본이 있으면 그것을 우선 쓰고,
* 없으면 PATH 에서 찾는다. 둘 다 없으면 null.
* (win32 면 .exe 후보도 본다.)
*/
function resolveBinary(name: string): string | null {
const candidates =
process.platform === 'win32'
? [path.join(binDir, name + '.exe'), path.join(binDir, name), name]
: [path.join(binDir, name), name]
for (const cand of candidates) {
const r = spawnSync(cand, ['-version'])
if (r.status === 0) return cand
}
return null
}
let resolvedFfmpegPath: string | null = null
let resolvedFfprobePath: string | null = null
export function getFfmpegPath(): string {
if (resolvedFfmpegPath) return resolvedFfmpegPath
const p = resolveBinary('ffmpeg')
if (!p) throw new FfmpegUnavailableError()
resolvedFfmpegPath = p
return p
}
/** ffprobe 경로. data/bin 우선, 없으면 PATH. 못 찾으면 null (호출부에서 graceful 처리). */
function getFfprobePath(): string | null {
if (resolvedFfprobePath) return resolvedFfprobePath
const p = resolveBinary('ffprobe')
if (p) resolvedFfprobePath = p
return p
}
/** data/bin 에 새 바이너리를 설치한 뒤 캐시된 해석 결과를 비운다 (다음 호출에서 재탐색). */
export function resetFfmpegResolution(): void {
resolvedFfmpegPath = null
resolvedFfprobePath = null
}
/** 입력 영상의 평균 fps 를 ffprobe 로 조회. 실패하면 null. */
export function probeVideoFps(inputPath: string): number | null {
const probe = getFfprobePath()
if (!probe) return null
const r = spawnSync(probe, [
'-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
}
/**
* fps + 해상도 + 길이를 한 번의 ffprobe 호출로 동시에 받아온다.
* upscaleOriginalTo60Fps 의 핫 패스에서 ffprobe 를 3번 spawnSync 하지 않기 위함.
* 각 필드 실패는 null 로 표현 (개별 helper 의 의미론과 동일).
*/
function probeVideoMeta(inputPath: string): {
fps: number | null
width: number | null
height: number | null
durationSec: number | null
} {
const empty = { fps: null, width: null, height: null, durationSec: null }
const probe = getFfprobePath()
if (!probe) return empty
const r = spawnSync(probe, [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=avg_frame_rate,width,height:format=duration',
'-of', 'default=noprint_wrappers=1',
inputPath
])
if (r.status !== 0) return empty
const fields: Record<string, string> = {}
for (const line of String(r.stdout).split(/\r?\n/)) {
const eq = line.indexOf('=')
if (eq <= 0) continue
fields[line.slice(0, eq).trim()] = line.slice(eq + 1).trim()
}
// fps: "N/D" 또는 단일 숫자
let fps: number | null = null
const fpsRaw = fields.avg_frame_rate
if (fpsRaw) {
const m = /^(\d+)\/(\d+)$/.exec(fpsRaw)
if (m) {
const n = Number(m[1]); const d = Number(m[2])
if (d > 0 && Number.isFinite(n / d) && n / d > 0) fps = n / d
} else {
const single = Number(fpsRaw)
if (Number.isFinite(single) && single > 0) fps = single
}
}
const w = Number(fields.width); const h = Number(fields.height)
const width = Number.isFinite(w) && w > 0 ? w : null
const height = Number.isFinite(h) && h > 0 ? h : null
const dur = Number(fields.duration)
const durationSec = Number.isFinite(dur) && dur > 0 ? dur : null
return { fps, width, height, durationSec }
}
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 meta = probeVideoMeta(inputPath)
const sourceFps = meta.fps
const sourceWidth = meta.width
const sourceHeight = meta.height
if (sourceFps === null && sourceWidth === null && sourceHeight === null) {
console.warn(`[upscale] 메타 확인 실패 (${inputPath}) — 후처리 건너뜀`)
return inputName
}
const needBumpFps = sourceFps !== null && sourceFps < TARGET_FPS - 0.5
const needDownscale =
sourceWidth !== null && sourceHeight !== null &&
(sourceWidth > MAX_WIDTH || sourceHeight > MAX_HEIGHT)
if (!needBumpFps && !needDownscale) {
// 이미 충분히 부드럽고 해상도도 캡 이하.
return inputName
}
const sourceDurationSec = meta.durationSec
// 재인코딩 결과는 항상 mp4 로 통일 (소스가 webm/mkv 여도).
const outName = 'original.mp4'
const outPath = path.join(dir, outName)
// 동시 호출이 같은 tmp 파일을 덮어쓰지 않도록 호출마다 고유한 임시 경로를 쓴다.
const tmpPath = path.join(dir, `original.bump.${randomUUID()}.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 ok = await encodeWithFallback(
bin,
buildUpscaleArgs,
tmpPath,
(b, a) => runFfmpegWithProgress(b, a, onProgressCb),
'upscale'
)
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(
folderId: number,
videoId: string,
trim: VideoTrim
): Promise<string> {
const bin = getFfmpegPath()
const meta = getVideoInFolder(folderId, videoId)
if (!meta) throw new Error('비디오를 찾을 수 없습니다.')
const dir = videoDiskDir(folderId, 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)
// 동시 호출이 같은 tmp 파일을 덮어쓰지 않도록 호출마다 고유한 임시 경로를 쓴다.
const tmpPath = `${outPath}.${randomUUID()}.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
]
}
ok = await encodeWithFallback(bin, buildTrimEncArgs, tmpPath, runFfmpeg, 'trim')
if (!ok) throw new Error('ffmpeg trim 실패')
}
await fs.rename(tmpPath, outPath)
updateVideo(folderId, videoId, {
editedFile: outName,
trim: { startSec, endSec }
})
return outName
}
// ─── 썸네일 ────────────────────────────────────────────────────────────
const THUMB_NAME = 'thumb.jpg'
/**
* 영상 디렉토리에 thumb.jpg 가 없으면 ffmpeg 로 한 장 추출해 만든다.
* - 편집본이 있으면 편집본, 없으면 원본에서 1초 지점 프레임을 뽑는다.
* (1초가 영상보다 길면 0초로 재시도.)
* - 폭 480 으로 다운스케일 (카드 썸네일용).
* - ffmpeg 없음 / 소스 없음 / 실패 시 null 반환 → 호출자가 404 로 응답하고
* 카드에서는 ▶ placeholder 로 폴백.
* 한 번 만들면 파일로 캐시되어 다음 요청부터는 즉시 응답.
*/
export async function ensureThumbnail(folderId: number, videoId: string): Promise<string | null> {
const meta = getVideoInFolder(folderId, videoId)
if (!meta) return null
const dir = videoDiskDir(folderId, videoId)
const thumbPath = path.join(dir, THUMB_NAME)
try {
await fs.access(thumbPath)
return thumbPath
} catch {
/* 없으면 아래에서 생성 */
}
const sourceName = meta.editedFile || meta.originalFile
if (!sourceName || sourceName.includes('%(ext)s')) return null
const inputPath = path.join(dir, sourceName)
try {
await fs.access(inputPath)
} catch {
return null
}
let bin: string
try {
bin = getFfmpegPath()
} catch {
return null
}
// 동시 호출이 같은 tmp 파일을 덮어쓰지 않도록 호출마다 고유한 임시 경로를 쓴다.
// (두 명이 같은 영상을 동시에 열면 thumb 가 아직 없을 때 generation 이 겹쳐
// 서로의 tmp 를 덮어써 일부 요청이 헛되이 실패하던 레이스를 막는다.)
const tmpPath = `${thumbPath}.${randomUUID()}.tmp.jpg`
const vf = "scale='min(480,iw)':-2"
// -ss 1 (1초 지점). 영상이 1초보다 짧으면 프레임이 안 나와 실패하므로 0초로 재시도.
let ok = await runFfmpeg(bin, [
'-y', '-ss', '1', '-i', inputPath,
'-frames:v', '1', '-vf', vf, '-q:v', '4', tmpPath
])
if (!ok) {
ok = await runFfmpeg(bin, [
'-y', '-i', inputPath,
'-frames:v', '1', '-vf', vf, '-q:v', '4', tmpPath
])
}
if (!ok) {
await fs.unlink(tmpPath).catch(() => undefined)
return null
}
try {
await fs.rename(tmpPath, thumbPath)
} catch {
await fs.unlink(tmpPath).catch(() => undefined)
return null
}
return thumbPath
}
/**
* HW 인코더로 한 번 시도하고, 실패하면 같은 잡에 한해 libx264 ultrafast 로 재시도.
*
* 5/16 STEP B P1: 1프레임 lavfi 테스트는 통과했는데 실제 영상(픽셀 포맷/색공간/
* 해상도 조합)에서 깨지는 HW 인코더가 흔하다. 잡 단위 폴백을 한 군데로 모아
* 캐시는 그대로 두고(`resolvedEncoder` invalidate X), 다음 잡 입력이 더 일반적
* 이면 HW 가 다시 동작할 여지를 남긴다.
*/
async function encodeWithFallback(
bin: string,
build: (profile: EncoderProfile) => string[],
tmpPath: string,
runner: (bin: string, args: string[]) => Promise<boolean>,
label: string
): Promise<boolean> {
const enc = await detectH264Encoder(bin)
let ok = await runner(bin, build(enc))
if (!ok && enc.name !== 'libx264') {
await fs.unlink(tmpPath).catch(() => undefined)
console.warn(`[${label}] ${enc.name} 실패 — libx264 ultrafast 로 재시도`)
ok = await runner(bin, build(SOFTWARE_FALLBACK))
}
return ok
}
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)
}
})
})
}