diff --git a/src/editor.ts b/src/editor.ts index 1f146ff..bd6da25 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -44,6 +44,19 @@ export function probeVideoFps(inputPath: string): number | null { return Number.isFinite(single) && single > 0 ? single : null } +/** 입력 영상의 총 재생 길이(초) 를 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 /** @@ -59,7 +72,8 @@ export const TARGET_FPS = 60 */ export async function upscaleOriginalTo60Fps( dir: string, - inputName: string + inputName: string, + onProgress?: (pct: number) => void ): Promise { let bin: string try { @@ -80,12 +94,14 @@ export async function upscaleOriginalTo60Fps( 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') - const vfilter = `minterpolate=fps=${TARGET_FPS}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir` + const vfilter = `minterpolate=fps=${TARGET_FPS}:mi_mode=mci` const args = [ '-y', '-i', inputPath, @@ -93,9 +109,16 @@ export async function upscaleOriginalTo60Fps( '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', + // 진행률을 stdout 으로 key=value 로 받기 위해 -progress pipe:1, -nostats 를 켠다. + '-progress', 'pipe:1', + '-nostats', tmpPath ] - const ok = await runFfmpeg(bin, args) + const ok = await runFfmpegWithProgress(bin, args, (outTimeUs) => { + if (!onProgress || !sourceDurationSec || sourceDurationSec <= 0) return + const pct = Math.max(0, Math.min(100, (outTimeUs / 1e6 / sourceDurationSec) * 100)) + onProgress(pct) + }) if (!ok) { await fs.unlink(tmpPath).catch(() => undefined) console.warn(`[upscale] minterpolate 실패 — 원본 ${inputName} 유지`) @@ -162,7 +185,7 @@ export async function applyTrimToVideo( // 시도 2: 재인코딩. 60fps 미만 소스는 minterpolate(mci) 로 모션 보간. // mci 는 느리지만 단순 프레임 복제보다 훨씬 자연스럽다. const vfilter = needBumpFps - ? `minterpolate=fps=${TARGET_FPS}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir` + ? `minterpolate=fps=${TARGET_FPS}:mi_mode=mci` : null const encArgs = [ ...baseArgs, @@ -202,3 +225,53 @@ function runFfmpeg(bin: string, args: string[]): Promise { }) }) } + +/** + * 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) + } + }) + }) +} diff --git a/src/youtube.ts b/src/youtube.ts index 58549e8..1236214 100644 --- a/src/youtube.ts +++ b/src/youtube.ts @@ -197,6 +197,10 @@ async function runJob(job: DownloadJob, bin: string): Promise { '-o', path.join(dir, 'original.%(ext)s'), job.url ] + // 다운로드 단계는 전체 진행률의 0~50% 를 차지하고, 60fps 후처리가 50~99%. + // (yt-dlp 가 video+audio 두 스트림을 받으면 각 스트림이 0→100 % 를 반복하므로 + // 단조 증가만 인정해 막대가 역행하지 않게 한다.) + let downloadPctRaw = 0 return new Promise((resolve, reject) => { const child = spawn(bin, args) let outputFile: string | null = null @@ -206,8 +210,13 @@ async function runJob(job: DownloadJob, bin: string): Promise { for (const line of text.split(/\r?\n/)) { const m = /PROGRESS\s+([\d.]+)%/.exec(line) if (m) { - job.progress = Math.min(99, Math.round(Number(m[1]))) - job.message = `다운로드 ${job.progress}%` + const pct = Number(m[1]) + if (Number.isFinite(pct)) { + downloadPctRaw = Math.max(downloadPctRaw, Math.min(100, pct)) + const mapped = Math.round(downloadPctRaw * 0.5) // 0..50 + if (mapped > job.progress) job.progress = mapped + job.message = `다운로드 ${Math.round(downloadPctRaw)}%` + } } const o = /^OUT\s+(.+)$/.exec(line.trim()) if (o) outputFile = o[1].trim() @@ -237,14 +246,35 @@ async function runJob(job: DownloadJob, bin: string): Promise { // 없는 영상이 있으니, 다운로드 후 원본을 ffprobe 로 확인해 <60fps 면 // minterpolate 로 60fps 까지 끌어올린다. 실패해도 원본은 그대로 둠. try { - job.progress = 99 - job.message = '60fps 변환 확인 중' + // 50% 부터 시작해 50~99% 구간을 ffmpeg 진행률이 채우게 한다. + job.progress = 50 + job.message = '60fps 변환 준비 중' await persistJob(job) - const bumped = await upscaleOriginalTo60Fps(dir, finalName) + // ffmpeg 진행률은 매우 자주 들어오므로 디스크 persist 는 throttle. + let lastPersistAt = 0 + let lastBumpPct = 0 + const bumped = await upscaleOriginalTo60Fps(dir, finalName, (pct) => { + if (pct <= lastBumpPct) return + lastBumpPct = pct + const mapped = 50 + Math.round((pct / 100) * 49) // 50..99 + if (mapped > job.progress) job.progress = mapped + job.message = `60fps 변환 ${Math.round(pct)}%` + const now = Date.now() + if (now - lastPersistAt > 2000) { + lastPersistAt = now + void persistJob(job) + } + }) if (bumped !== finalName) { job.message = '60fps 변환 완료' finalName = bumped + } else { + // upscaleOriginalTo60Fps 가 inputName 을 그대로 돌려준 경우는 + // (a) 이미 60fps 이상이거나 (b) ffmpeg 없거나 (c) 보간 실패. + // 어느 경우든 후처리 단계는 끝났다고 보고 진행률만 99 까지 채운다. + job.progress = 99 } + await persistJob(job) } catch (err) { // 후처리 실패는 다운로드 자체를 실패시키지 않는다. 원본 보존이 우선. console.error('[youtube] 60fps 후처리 실패:', err)