fix(youtube): smooth download progress + post-process heartbeat
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 데이터가 들어올 때만 움직이며, 거짓 증가는 하지 않는다.
This commit is contained in:
@@ -325,9 +325,22 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
||||
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<void>((resolve, reject) => {
|
||||
// yt-dlp 는 파이썬 스크립트라 stdout 이 pipe 로 연결되면 block-buffered 가 되어
|
||||
// 진행률 라인들이 즉시 흘러나오지 않는다. PYTHONUNBUFFERED=1 로 강제 unbuffered.
|
||||
@@ -344,10 +357,24 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
||||
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<void> {
|
||||
|
||||
// 사용자가 "원본도 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)
|
||||
|
||||
Reference in New Issue
Block a user