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

@@ -197,6 +197,10 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
'-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<void>((resolve, reject) => {
const child = spawn(bin, args)
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/)) {
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<void> {
// 없는 영상이 있으니, 다운로드 후 원본을 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)