fix(youtube): split progress 0-50/50-99 and report ffmpeg conversion %

다운로드 후 바가 한 번에 99% 로 튄 뒤 멈춰 있던 문제 두 가지를 같이 고침.

1) 진행률 구간 분리
   - 다운로드: 0 ~ 50% (yt-dlp 의 0-100% 를 절반으로 매핑)
   - 60fps 변환: 50 ~ 99%
   - yt-dlp 가 video+audio 두 스트림을 받으면 각 스트림이 0→100% 를
     반복하므로 단조 증가만 인정 (downloadPctRaw = max(...)).

2) ffmpeg minterpolate 진행률 실시간 보고
   - editor.ts 에 `probeVideoDuration` 추가, `runFfmpegWithProgress`
     도입해 ffmpeg `-progress pipe:1` 의 out_time_us/ms 를 파싱.
   - `upscaleOriginalTo60Fps` 에 `onProgress(pct)` 콜백 추가.
   - youtube.ts 가 콜백을 받아 50~99% 로 매핑하고 job.message 를
     "60fps 변환 NN%" 로 갱신. persistJob 은 2초 throttle.

3) mci 옵션 단순화로 속도 개선
   - 기존: `mci:mc_mode=aobmc:me_mode=bidir` (가장 느린 조합)
   - 현재: `mci` 기본값 (obmc + bilat). 화질 약간 양보, 속도 수 배 개선.
   - 여전히 mci 자체가 무거워 5분 30fps 영상 변환에 수십 분 단위 시간이
     걸릴 수 있음 — 이제 그 동안 진행률은 실시간으로 움직임.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-05-16 02:57:33 +09:00
parent 48f84963be
commit cdf56b96b7
2 changed files with 112 additions and 9 deletions

View File

@@ -44,6 +44,19 @@ export function probeVideoFps(inputPath: string): number | null {
return Number.isFinite(single) && single > 0 ? single : 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 export const TARGET_FPS = 60
/** /**
@@ -59,7 +72,8 @@ export const TARGET_FPS = 60
*/ */
export async function upscaleOriginalTo60Fps( export async function upscaleOriginalTo60Fps(
dir: string, dir: string,
inputName: string inputName: string,
onProgress?: (pct: number) => void
): Promise<string> { ): Promise<string> {
let bin: string let bin: string
try { try {
@@ -80,12 +94,14 @@ export async function upscaleOriginalTo60Fps(
return inputName return inputName
} }
const sourceDurationSec = probeVideoDuration(inputPath)
// 재인코딩 결과는 항상 mp4 로 통일 (소스가 webm/mkv 여도). // 재인코딩 결과는 항상 mp4 로 통일 (소스가 webm/mkv 여도).
const outName = 'original.mp4' const outName = 'original.mp4'
const outPath = path.join(dir, outName) const outPath = path.join(dir, outName)
const tmpPath = path.join(dir, 'original.bump.tmp.mp4') 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 = [ const args = [
'-y', '-y',
'-i', inputPath, '-i', inputPath,
@@ -93,9 +109,16 @@ export async function upscaleOriginalTo60Fps(
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
'-c:a', 'aac', '-b:a', '160k', '-c:a', 'aac', '-b:a', '160k',
'-movflags', '+faststart', '-movflags', '+faststart',
// 진행률을 stdout 으로 key=value 로 받기 위해 -progress pipe:1, -nostats 를 켠다.
'-progress', 'pipe:1',
'-nostats',
tmpPath 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) { if (!ok) {
await fs.unlink(tmpPath).catch(() => undefined) await fs.unlink(tmpPath).catch(() => undefined)
console.warn(`[upscale] minterpolate 실패 — 원본 ${inputName} 유지`) console.warn(`[upscale] minterpolate 실패 — 원본 ${inputName} 유지`)
@@ -162,7 +185,7 @@ export async function applyTrimToVideo(
// 시도 2: 재인코딩. 60fps 미만 소스는 minterpolate(mci) 로 모션 보간. // 시도 2: 재인코딩. 60fps 미만 소스는 minterpolate(mci) 로 모션 보간.
// mci 는 느리지만 단순 프레임 복제보다 훨씬 자연스럽다. // mci 는 느리지만 단순 프레임 복제보다 훨씬 자연스럽다.
const vfilter = needBumpFps const vfilter = needBumpFps
? `minterpolate=fps=${TARGET_FPS}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir` ? `minterpolate=fps=${TARGET_FPS}:mi_mode=mci`
: null : null
const encArgs = [ const encArgs = [
...baseArgs, ...baseArgs,
@@ -202,3 +225,53 @@ function runFfmpeg(bin: string, args: string[]): Promise<boolean> {
}) })
}) })
} }
/**
* 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)
}
})
})
}

View File

@@ -197,6 +197,10 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
'-o', path.join(dir, 'original.%(ext)s'), '-o', path.join(dir, 'original.%(ext)s'),
job.url job.url
] ]
// 다운로드 단계는 전체 진행률의 0~50% 를 차지하고, 60fps 후처리가 50~99%.
// (yt-dlp 가 video+audio 두 스트림을 받으면 각 스트림이 0→100 % 를 반복하므로
// 단조 증가만 인정해 막대가 역행하지 않게 한다.)
let downloadPctRaw = 0
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const child = spawn(bin, args) const child = spawn(bin, args)
let outputFile: string | null = null let outputFile: string | null = null
@@ -206,8 +210,13 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
for (const line of text.split(/\r?\n/)) { for (const line of text.split(/\r?\n/)) {
const m = /PROGRESS\s+([\d.]+)%/.exec(line) const m = /PROGRESS\s+([\d.]+)%/.exec(line)
if (m) { if (m) {
job.progress = Math.min(99, Math.round(Number(m[1]))) const pct = Number(m[1])
job.message = `다운로드 ${job.progress}%` 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()) const o = /^OUT\s+(.+)$/.exec(line.trim())
if (o) outputFile = o[1].trim() if (o) outputFile = o[1].trim()
@@ -237,14 +246,35 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
// 없는 영상이 있으니, 다운로드 후 원본을 ffprobe 로 확인해 <60fps 면 // 없는 영상이 있으니, 다운로드 후 원본을 ffprobe 로 확인해 <60fps 면
// minterpolate 로 60fps 까지 끌어올린다. 실패해도 원본은 그대로 둠. // minterpolate 로 60fps 까지 끌어올린다. 실패해도 원본은 그대로 둠.
try { try {
job.progress = 99 // 50% 부터 시작해 50~99% 구간을 ffmpeg 진행률이 채우게 한다.
job.message = '60fps 변환 확인 중' job.progress = 50
job.message = '60fps 변환 준비 중'
await persistJob(job) 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) { if (bumped !== finalName) {
job.message = '60fps 변환 완료' job.message = '60fps 변환 완료'
finalName = bumped finalName = bumped
} else {
// upscaleOriginalTo60Fps 가 inputName 을 그대로 돌려준 경우는
// (a) 이미 60fps 이상이거나 (b) ffmpeg 없거나 (c) 보간 실패.
// 어느 경우든 후처리 단계는 끝났다고 보고 진행률만 99 까지 채운다.
job.progress = 99
} }
await persistJob(job)
} catch (err) { } catch (err) {
// 후처리 실패는 다운로드 자체를 실패시키지 않는다. 원본 보존이 우선. // 후처리 실패는 다운로드 자체를 실패시키지 않는다. 원본 보존이 우선.
console.error('[youtube] 60fps 후처리 실패:', err) console.error('[youtube] 60fps 후처리 실패:', err)