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:
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user