From ef65d6674f4fdb675ab5e176ea35241182f330df Mon Sep 17 00:00:00 2001 From: "Claude (owner)" Date: Mon, 1 Jun 2026 10:04:48 +0900 Subject: [PATCH] fix(youtube): smooth download progress + post-process heartbeat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5/16 사용자 보고 "진행률이 0 → 50% 한방에 튀고 60fps 변환에서 하루종일 멈춤" 의 두 가지 직접 원인을 잡습니다. 1) yt-dlp 가 `bv*+ba` 로 영상/오디오 두 스트림을 순서대로 받을 때, 기존 `Math.max(prev, pct)` 누적 방식은 첫 스트림이 100% 도달하면 매핑값이 50% 에 박혀 둘째 스트림 진행이 사용자에게 보이지 않았다. 스트림 카운트 + 가중평균 방식으로 바꿔 전체 다운로드 phase 가 부드럽게 0..50% 로 차오르게 한다. (검증: 5%, 13%, 25%, 38%, 50%, 65%, 80%, 100% 의 8단계 클라이밍을 60ms 간격으로 관찰) 2) 60fps/FHD 후처리 단계가 `-progress pipe:1` 라인을 전혀 못 받는 경우(영상 길이 unknown, 1초 미만 짧은 클립 등) progress 가 50% 에 고정돼 사용자는 "멈췄다"고 인식했다. heartbeat interval(2s) 로 메시지에 경과시간을 박아 화면이 살아있다는 신호를 준다. progress 막대 자체는 실제 ffmpeg 데이터가 들어올 때만 움직이며, 거짓 증가는 하지 않는다. --- src/youtube.ts | 95 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 71 insertions(+), 24 deletions(-) diff --git a/src/youtube.ts b/src/youtube.ts index f8db973..a404d76 100644 --- a/src/youtube.ts +++ b/src/youtube.ts @@ -325,9 +325,22 @@ async function runJob(job: DownloadJob, bin: string): Promise { job.url ] // 다운로드 단계는 전체 진행률의 0~50% 를 차지하고, 60fps 후처리가 50~99%. - // (yt-dlp 가 video+audio 두 스트림을 받으면 각 스트림이 0→100 % 를 반복하므로 - // 단조 증가만 인정해 막대가 역행하지 않게 한다.) - let downloadPctRaw = 0 + // + // yt-dlp 가 `bv*+ba` 로 영상/오디오 두 스트림을 순서대로 받으면 각 스트림이 + // 0→100% 를 반복한다. 예전엔 `Math.max(prev, pct)` 로만 누적했는데, 그러면 + // 첫 스트림이 100% 도달한 순간 매핑값이 50% 에 박혀서 두번째 스트림 진행이 + // 사용자에게 안 보였다 (짧은 영상은 첫 스트림 청크 한 번에 100% 가 돼서 + // "0 → 50% 한방 점프" 처럼 보이는 5/16 버그의 직접 원인). + // + // 해결: 스트림 카운트로 가중평균. pct 가 크게 떨어지면(>5%p) 새 스트림 시작. + // overall = (완료스트림수 * 100 + 현재스트림pct) / 예상스트림수 / 100 + // 예상스트림수 = 2 (bv + ba). 단일 결합 포맷 `b` 폴백이면 한 스트림만 + // 끝나고 끝나지만, 그땐 곧이어 ffmpeg 후처리 메시지로 점프하므로 + // 사용자가 "멈췄다"고 느낄 시간이 없다. + const EXPECTED_STREAMS = 2 + let streamsCompleted = 0 + let lastPctSeen = -1 + let currentStreamPct = 0 return new Promise((resolve, reject) => { // yt-dlp 는 파이썬 스크립트라 stdout 이 pipe 로 연결되면 block-buffered 가 되어 // 진행률 라인들이 즉시 흘러나오지 않는다. PYTHONUNBUFFERED=1 로 강제 unbuffered. @@ -344,10 +357,24 @@ async function runJob(job: DownloadJob, bin: string): Promise { if (m) { 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 + const clamped = Math.max(0, Math.min(100, pct)) + if (lastPctSeen >= 0 && clamped + 5 < lastPctSeen) { + // 새 스트림 시작 (이전 스트림이 100 근처에서 다시 0~ 으로 떨어짐) + streamsCompleted += 1 + currentStreamPct = clamped + } else { + // 단조 증가만 인정 (같은 스트림 내부) + currentStreamPct = Math.max(currentStreamPct, clamped) + } + lastPctSeen = clamped + const totalUnits = Math.min( + EXPECTED_STREAMS * 100, + streamsCompleted * 100 + currentStreamPct + ) + const phasePct = (totalUnits / (EXPECTED_STREAMS * 100)) * 100 + const mapped = Math.round(phasePct * 0.5) // 0..50 if (mapped > job.progress) job.progress = mapped - job.message = `다운로드 ${Math.round(downloadPctRaw)}%` + job.message = `다운로드 ${Math.round(phasePct)}%` } } const o = /^OUT\s+(.+)$/.exec(line.trim()) @@ -376,34 +403,54 @@ async function runJob(job: DownloadJob, bin: string): Promise { // 사용자가 "원본도 60fps 로" 요청 — yt-dlp 가 fps 1순위로 잡아도 60fps 자체가 // 없는 영상이 있으니, 다운로드 후 원본을 ffprobe 로 확인해 <60fps 면 - // minterpolate 로 60fps 까지 끌어올린다. 실패해도 원본은 그대로 둠. + // 프레임 복제로 60fps 까지 끌어올린다. 실패해도 원본은 그대로 둠. + // + // 5/16 버그 ("60fps 변환 확인 중에서 하루종일 멈춰있어") 대응: + // - ffmpeg `-progress pipe:1` 이 영상 길이를 모르거나 짧은 영상이 1초 안에 + // 끝나면 onProgress 가 한 번도 안 불려 progress 가 50% 에 박혀 있었다. + // - 사용자 입장에선 "멈춤" 으로 보이므로, ffmpeg 진행률과 무관하게 + // 매 2초마다 message 에 경과시간을 박아 화면이 살아있다는 신호를 준다. + // (progress 막대 자체는 실제 데이터가 들어올 때만 움직임 — 거짓 증가 X.) try { - // 50% 부터 시작해 50~99% 구간을 ffmpeg 진행률이 채우게 한다. + const phaseStartedAt = Date.now() job.progress = 50 - job.message = '60fps 변환 준비 중' + job.message = '60fps/FHD 변환 시작' await persistJob(job) // 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) - } - }) + let realProgressSeen = false + const heartbeat = setInterval(() => { + if (realProgressSeen) return + const elapsed = Math.floor((Date.now() - phaseStartedAt) / 1000) + job.message = `60fps/FHD 변환 중 (${elapsed}s 경과)` + }, 2000) + let bumped: string + try { + bumped = await upscaleOriginalTo60Fps(dir, finalName, (pct) => { + if (pct <= lastBumpPct) return + realProgressSeen = true + lastBumpPct = pct + const mapped = 50 + Math.round((pct / 100) * 49) // 50..99 + if (mapped > job.progress) job.progress = mapped + job.message = `60fps/FHD 변환 ${Math.round(pct)}%` + const now = Date.now() + if (now - lastPersistAt > 2000) { + lastPersistAt = now + void persistJob(job) + } + }) + } finally { + clearInterval(heartbeat) + } if (bumped !== finalName) { - job.message = '60fps 변환 완료' + job.message = '60fps/FHD 변환 완료' finalName = bumped } else { // upscaleOriginalTo60Fps 가 inputName 을 그대로 돌려준 경우는 - // (a) 이미 60fps 이상이거나 (b) ffmpeg 없거나 (c) 보간 실패. - // 어느 경우든 후처리 단계는 끝났다고 보고 진행률만 99 까지 채운다. + // (a) 이미 60fps + FHD 이하라 변환 자체가 필요 없거나 + // (b) ffmpeg 없거나 (c) 인코딩 실패. 어느 경우든 후처리는 끝났다고 보고 + // 진행률만 99 까지 채운다. job.progress = 99 } await persistJob(job)