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
|
job.url
|
||||||
]
|
]
|
||||||
// 다운로드 단계는 전체 진행률의 0~50% 를 차지하고, 60fps 후처리가 50~99%.
|
// 다운로드 단계는 전체 진행률의 0~50% 를 차지하고, 60fps 후처리가 50~99%.
|
||||||
// (yt-dlp 가 video+audio 두 스트림을 받으면 각 스트림이 0→100 % 를 반복하므로
|
//
|
||||||
// 단조 증가만 인정해 막대가 역행하지 않게 한다.)
|
// yt-dlp 가 `bv*+ba` 로 영상/오디오 두 스트림을 순서대로 받으면 각 스트림이
|
||||||
let downloadPctRaw = 0
|
// 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) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
// yt-dlp 는 파이썬 스크립트라 stdout 이 pipe 로 연결되면 block-buffered 가 되어
|
// yt-dlp 는 파이썬 스크립트라 stdout 이 pipe 로 연결되면 block-buffered 가 되어
|
||||||
// 진행률 라인들이 즉시 흘러나오지 않는다. PYTHONUNBUFFERED=1 로 강제 unbuffered.
|
// 진행률 라인들이 즉시 흘러나오지 않는다. PYTHONUNBUFFERED=1 로 강제 unbuffered.
|
||||||
@@ -344,10 +357,24 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
|||||||
if (m) {
|
if (m) {
|
||||||
const pct = Number(m[1])
|
const pct = Number(m[1])
|
||||||
if (Number.isFinite(pct)) {
|
if (Number.isFinite(pct)) {
|
||||||
downloadPctRaw = Math.max(downloadPctRaw, Math.min(100, pct))
|
const clamped = Math.max(0, Math.min(100, pct))
|
||||||
const mapped = Math.round(downloadPctRaw * 0.5) // 0..50
|
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
|
if (mapped > job.progress) job.progress = mapped
|
||||||
job.message = `다운로드 ${Math.round(downloadPctRaw)}%`
|
job.message = `다운로드 ${Math.round(phasePct)}%`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const o = /^OUT\s+(.+)$/.exec(line.trim())
|
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 자체가
|
// 사용자가 "원본도 60fps 로" 요청 — yt-dlp 가 fps 1순위로 잡아도 60fps 자체가
|
||||||
// 없는 영상이 있으니, 다운로드 후 원본을 ffprobe 로 확인해 <60fps 면
|
// 없는 영상이 있으니, 다운로드 후 원본을 ffprobe 로 확인해 <60fps 면
|
||||||
// minterpolate 로 60fps 까지 끌어올린다. 실패해도 원본은 그대로 둠.
|
// 프레임 복제로 60fps 까지 끌어올린다. 실패해도 원본은 그대로 둠.
|
||||||
|
//
|
||||||
|
// 5/16 버그 ("60fps 변환 확인 중에서 하루종일 멈춰있어") 대응:
|
||||||
|
// - ffmpeg `-progress pipe:1` 이 영상 길이를 모르거나 짧은 영상이 1초 안에
|
||||||
|
// 끝나면 onProgress 가 한 번도 안 불려 progress 가 50% 에 박혀 있었다.
|
||||||
|
// - 사용자 입장에선 "멈춤" 으로 보이므로, ffmpeg 진행률과 무관하게
|
||||||
|
// 매 2초마다 message 에 경과시간을 박아 화면이 살아있다는 신호를 준다.
|
||||||
|
// (progress 막대 자체는 실제 데이터가 들어올 때만 움직임 — 거짓 증가 X.)
|
||||||
try {
|
try {
|
||||||
// 50% 부터 시작해 50~99% 구간을 ffmpeg 진행률이 채우게 한다.
|
const phaseStartedAt = Date.now()
|
||||||
job.progress = 50
|
job.progress = 50
|
||||||
job.message = '60fps 변환 준비 중'
|
job.message = '60fps/FHD 변환 시작'
|
||||||
await persistJob(job)
|
await persistJob(job)
|
||||||
// ffmpeg 진행률은 매우 자주 들어오므로 디스크 persist 는 throttle.
|
// ffmpeg 진행률은 매우 자주 들어오므로 디스크 persist 는 throttle.
|
||||||
let lastPersistAt = 0
|
let lastPersistAt = 0
|
||||||
let lastBumpPct = 0
|
let lastBumpPct = 0
|
||||||
const bumped = await upscaleOriginalTo60Fps(dir, finalName, (pct) => {
|
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
|
if (pct <= lastBumpPct) return
|
||||||
|
realProgressSeen = true
|
||||||
lastBumpPct = pct
|
lastBumpPct = pct
|
||||||
const mapped = 50 + Math.round((pct / 100) * 49) // 50..99
|
const mapped = 50 + Math.round((pct / 100) * 49) // 50..99
|
||||||
if (mapped > job.progress) job.progress = mapped
|
if (mapped > job.progress) job.progress = mapped
|
||||||
job.message = `60fps 변환 ${Math.round(pct)}%`
|
job.message = `60fps/FHD 변환 ${Math.round(pct)}%`
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now - lastPersistAt > 2000) {
|
if (now - lastPersistAt > 2000) {
|
||||||
lastPersistAt = now
|
lastPersistAt = now
|
||||||
void persistJob(job)
|
void persistJob(job)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
clearInterval(heartbeat)
|
||||||
|
}
|
||||||
if (bumped !== finalName) {
|
if (bumped !== finalName) {
|
||||||
job.message = '60fps 변환 완료'
|
job.message = '60fps/FHD 변환 완료'
|
||||||
finalName = bumped
|
finalName = bumped
|
||||||
} else {
|
} else {
|
||||||
// upscaleOriginalTo60Fps 가 inputName 을 그대로 돌려준 경우는
|
// upscaleOriginalTo60Fps 가 inputName 을 그대로 돌려준 경우는
|
||||||
// (a) 이미 60fps 이상이거나 (b) ffmpeg 없거나 (c) 보간 실패.
|
// (a) 이미 60fps + FHD 이하라 변환 자체가 필요 없거나
|
||||||
// 어느 경우든 후처리 단계는 끝났다고 보고 진행률만 99 까지 채운다.
|
// (b) ffmpeg 없거나 (c) 인코딩 실패. 어느 경우든 후처리는 끝났다고 보고
|
||||||
|
// 진행률만 99 까지 채운다.
|
||||||
job.progress = 99
|
job.progress = 99
|
||||||
}
|
}
|
||||||
await persistJob(job)
|
await persistJob(job)
|
||||||
|
|||||||
Reference in New Issue
Block a user