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>
651 lines
24 KiB
TypeScript
651 lines
24 KiB
TypeScript
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)
|
||
}
|
||
})
|
||
})
|
||
}
|