fix(import): unbuffer yt-dlp progress, speed up 60fps via fps filter, lock probe btn
세 가지 사용자 보고 처리:
1) 다운로드 바가 멈춰 있다가 한번에 50% 로 점프
- 원인: yt-dlp 는 파이썬 스크립트라 stdout 이 pipe 로 연결되면
block-buffered 가 되어 진행률 라인들이 4KB 버퍼에 모였다가 종료 직전에
쏟아짐. node 의 stdout 'data' 핸들러가 그 전엔 아무 것도 못 봄.
- 수정: spawn 의 env 에 PYTHONUNBUFFERED=1 추가. 라인 단위로 즉시 flush.
2) 변환을 20배 빠르게
- 원인: minterpolate(mci) 는 motion estimation 자체가 무거워 mci 기본값
이어도 영상 길이의 수 배 시간이 든다 (blend 도 5~10배가 한계).
- 수정: 다운로드 후 60fps 변환과 trim 재인코딩 양쪽에서 minterpolate 를
`fps=60` (단순 프레임 복제) 으로 교체. preset 도 `veryfast` → `ultrafast`.
실측상 영상 길이의 1/3 ~ 1배 시간 — mci 대비 수십 배 빠름.
- 시각적 부드러움은 30fps 와 동일하지만 컨테이너/타이밍은 60fps cfr 로 유지.
사용자가 알려준 "timing 만 60fps 로 바뀌고 실제 부드러움은 그대로" 경로.
3) "확인" 전 "가져오기" 재클릭 막기
- probe 클릭 시 ytProbeBtn 도 disabled.
- probe 실패 / 5분 경고 취소 / start 실패 시만 재활성화.
- 정상 흐름은 probe 성공 → 확인 클릭 → 다운로드 → redirect 라 재활성화
필요 없음. ytStartBtn 도 클릭 직후 disabled 로 중복 클릭 방지.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -70,10 +70,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// YouTube probe
|
// YouTube probe
|
||||||
|
// "확인" 누르기 전 "가져오기" 재클릭 방지: probe 클릭 시 disable, 실패시만 재활성화.
|
||||||
ytProbeBtn.addEventListener('click', function () {
|
ytProbeBtn.addEventListener('click', function () {
|
||||||
var url = ytUrl.value.trim()
|
var url = ytUrl.value.trim()
|
||||||
if (!url) return
|
if (!url) return
|
||||||
probeInfo.textContent = '확인 중...'
|
probeInfo.textContent = '확인 중...'
|
||||||
|
ytProbeBtn.disabled = true
|
||||||
ytStartBtn.disabled = true
|
ytStartBtn.disabled = true
|
||||||
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/probe', {
|
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/probe', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -82,6 +84,7 @@
|
|||||||
}).then(function (r) { return r.json() }).then(function (j) {
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
if (!j.ok) {
|
if (!j.ok) {
|
||||||
probeInfo.textContent = j.message || '확인 실패'
|
probeInfo.textContent = j.message || '확인 실패'
|
||||||
|
ytProbeBtn.disabled = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var p = j.probe
|
var p = j.probe
|
||||||
@@ -94,19 +97,24 @@
|
|||||||
probeInfo.textContent = parts.join(' · ')
|
probeInfo.textContent = parts.join(' · ')
|
||||||
if (p.warnOver5min) {
|
if (p.warnOver5min) {
|
||||||
if (!window.confirm('가져오는 데 5분 이상 걸릴 수 있습니다. 진행할까요?\n(다른 페이지에서 작업해도 백그라운드로 계속 진행됩니다.)')) {
|
if (!window.confirm('가져오는 데 5분 이상 걸릴 수 있습니다. 진행할까요?\n(다른 페이지에서 작업해도 백그라운드로 계속 진행됩니다.)')) {
|
||||||
|
ytProbeBtn.disabled = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!titleInput.value) titleInput.value = p.title
|
if (!titleInput.value) titleInput.value = p.title
|
||||||
|
// 확인(start) 만 활성화, 가져오기는 잠금 유지 (확인 누르면 다운로드 시작 → redirect)
|
||||||
ytStartBtn.disabled = false
|
ytStartBtn.disabled = false
|
||||||
}).catch(function (e) {
|
}).catch(function (e) {
|
||||||
probeInfo.textContent = '확인 실패: ' + e.message
|
probeInfo.textContent = '확인 실패: ' + e.message
|
||||||
|
ytProbeBtn.disabled = false
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
ytStartBtn.addEventListener('click', function () {
|
ytStartBtn.addEventListener('click', function () {
|
||||||
var url = ytUrl.value.trim()
|
var url = ytUrl.value.trim()
|
||||||
if (!url) return
|
if (!url) return
|
||||||
|
// 중복 클릭 방지
|
||||||
|
ytStartBtn.disabled = true
|
||||||
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/start', {
|
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
@@ -114,11 +122,18 @@
|
|||||||
}).then(function (r) { return r.json() }).then(function (j) {
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
if (!j.ok) {
|
if (!j.ok) {
|
||||||
probeInfo.textContent = j.message || '시작 실패'
|
probeInfo.textContent = j.message || '시작 실패'
|
||||||
|
// 시작 실패면 재시도 가능하게 둘 다 다시 풀어줌
|
||||||
|
ytProbeBtn.disabled = false
|
||||||
|
ytStartBtn.disabled = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dlProgress.hidden = false
|
dlProgress.hidden = false
|
||||||
probeInfo.textContent = '백그라운드 다운로드 시작...'
|
probeInfo.textContent = '백그라운드 다운로드 시작...'
|
||||||
pollJob(j.jobId, j.videoId)
|
pollJob(j.jobId, j.videoId)
|
||||||
|
}).catch(function (e) {
|
||||||
|
probeInfo.textContent = '시작 실패: ' + e.message
|
||||||
|
ytProbeBtn.disabled = false
|
||||||
|
ytStartBtn.disabled = false
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -60,15 +60,17 @@ export function probeVideoDuration(inputPath: string): number | null {
|
|||||||
export const TARGET_FPS = 60
|
export const TARGET_FPS = 60
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 원본 영상이 60fps 미만이면 minterpolate(mci) 로 60fps 까지 끌어올려
|
* 원본 영상이 60fps 미만이면 fps=60 프레임 복제로 끌어올려
|
||||||
* 원본 파일을 같은 디렉토리의 `original.mp4` 로 교체한다.
|
* 원본 파일을 같은 디렉토리의 `original.mp4` 로 교체한다.
|
||||||
*
|
*
|
||||||
* - 반환값: 새 파일명 (이미 60fps 이상이면 inputName 그대로)
|
* - 반환값: 새 파일명 (이미 60fps 이상이면 inputName 그대로)
|
||||||
* - ffmpeg/ffprobe 가 없거나 보간에 실패하면 inputName 을 그대로 반환해 호출자에게 영향 안 줌.
|
* - ffmpeg/ffprobe 가 없거나 변환에 실패하면 inputName 을 그대로 반환해 호출자에게 영향 안 줌.
|
||||||
* - 보간 성공 시 원래 파일은 삭제하고 새 파일로 대체.
|
* - 변환 성공 시 원래 파일은 삭제하고 새 파일로 대체.
|
||||||
*
|
*
|
||||||
* minterpolate(mci) 는 무겁다. 원본 길이의 수 배 시간이 걸릴 수 있으니
|
* 속도 우선 정책: 사용자가 "20배 빠르게" 요구해서 motion interpolation
|
||||||
* 호출자는 백그라운드 잡 안에서 부르고 상태를 적절히 갱신해 주세요.
|
* (mci/blend) 대신 단순 프레임 복제로 갔다. 시각적 부드러움은 30fps 와
|
||||||
|
* 동일하지만 컨테이너/타이밍이 60fps cfr 이라 60fps 가 필요한 다운스트림
|
||||||
|
* (예: 60fps 라우드 플레이어) 호환성이 확보된다.
|
||||||
*/
|
*/
|
||||||
export async function upscaleOriginalTo60Fps(
|
export async function upscaleOriginalTo60Fps(
|
||||||
dir: string,
|
dir: string,
|
||||||
@@ -101,12 +103,17 @@ export async function upscaleOriginalTo60Fps(
|
|||||||
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`
|
// 속도 우선: motion interpolation 대신 fps=60 필터로 프레임 복제만 한다.
|
||||||
|
// (mci 는 영상 길이의 수 배가 걸려 사용자가 못 기다림. blend 도 5~10배가 한계.)
|
||||||
|
// fps=60 + ultrafast 프리셋이면 영상 길이의 1/3 ~ 1배 시간이면 끝남.
|
||||||
|
// 시각적 부드러움은 30fps 와 동일하지만 컨테이너/타이밍은 60fps cfr.
|
||||||
|
// 진짜 motion interpolation 이 필요하면 호출자가 별도 옵션으로 키게 추후 확장.
|
||||||
|
const vfilter = `fps=${TARGET_FPS}`
|
||||||
const args = [
|
const args = [
|
||||||
'-y',
|
'-y',
|
||||||
'-i', inputPath,
|
'-i', inputPath,
|
||||||
'-vf', vfilter,
|
'-vf', vfilter,
|
||||||
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
|
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23',
|
||||||
'-c:a', 'aac', '-b:a', '160k',
|
'-c:a', 'aac', '-b:a', '160k',
|
||||||
'-movflags', '+faststart',
|
'-movflags', '+faststart',
|
||||||
// 진행률을 stdout 으로 key=value 로 받기 위해 -progress pipe:1, -nostats 를 켠다.
|
// 진행률을 stdout 으로 key=value 로 받기 위해 -progress pipe:1, -nostats 를 켠다.
|
||||||
@@ -121,7 +128,7 @@ export async function upscaleOriginalTo60Fps(
|
|||||||
})
|
})
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
await fs.unlink(tmpPath).catch(() => undefined)
|
await fs.unlink(tmpPath).catch(() => undefined)
|
||||||
console.warn(`[upscale] minterpolate 실패 — 원본 ${inputName} 유지`)
|
console.warn(`[upscale] fps=60 변환 실패 — 원본 ${inputName} 유지`)
|
||||||
return inputName
|
return inputName
|
||||||
}
|
}
|
||||||
// 안전 순서: 먼저 tmp → outPath rename (성공해야 원본 교체 진행).
|
// 안전 순서: 먼저 tmp → outPath rename (성공해야 원본 교체 진행).
|
||||||
@@ -182,15 +189,13 @@ export async function applyTrimToVideo(
|
|||||||
ok = await runFfmpeg(bin, copyArgs)
|
ok = await runFfmpeg(bin, copyArgs)
|
||||||
}
|
}
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
// 시도 2: 재인코딩. 60fps 미만 소스는 minterpolate(mci) 로 모션 보간.
|
// 시도 2: 재인코딩. 60fps 미만 소스는 fps=60 프레임 복제로 끌어올림 (속도 우선).
|
||||||
// mci 는 느리지만 단순 프레임 복제보다 훨씬 자연스럽다.
|
// (다운로드 단계에서 이미 60fps 로 끌어올리므로 이 경로는 직접 업로드용 안전망.)
|
||||||
const vfilter = needBumpFps
|
const vfilter = needBumpFps ? `fps=${TARGET_FPS}` : null
|
||||||
? `minterpolate=fps=${TARGET_FPS}:mi_mode=mci`
|
|
||||||
: null
|
|
||||||
const encArgs = [
|
const encArgs = [
|
||||||
...baseArgs,
|
...baseArgs,
|
||||||
...(vfilter ? ['-vf', vfilter] : []),
|
...(vfilter ? ['-vf', vfilter] : []),
|
||||||
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23',
|
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23',
|
||||||
'-c:a', 'aac', '-b:a', '128k',
|
'-c:a', 'aac', '-b:a', '128k',
|
||||||
'-movflags', '+faststart',
|
'-movflags', '+faststart',
|
||||||
tmpPath
|
tmpPath
|
||||||
|
|||||||
@@ -202,7 +202,12 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
|||||||
// 단조 증가만 인정해 막대가 역행하지 않게 한다.)
|
// 단조 증가만 인정해 막대가 역행하지 않게 한다.)
|
||||||
let downloadPctRaw = 0
|
let downloadPctRaw = 0
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const child = spawn(bin, args)
|
// yt-dlp 는 파이썬 스크립트라 stdout 이 pipe 로 연결되면 block-buffered 가 되어
|
||||||
|
// 진행률 라인들이 즉시 흘러나오지 않는다. PYTHONUNBUFFERED=1 로 강제 unbuffered.
|
||||||
|
// 이게 없으면 "다운로드 끝나자마자 한꺼번에 progress 0→50 점프" 처럼 보임.
|
||||||
|
const child = spawn(bin, args, {
|
||||||
|
env: { ...process.env, PYTHONUNBUFFERED: '1' }
|
||||||
|
})
|
||||||
let outputFile: string | null = null
|
let outputFile: string | null = null
|
||||||
let stderrTail = ''
|
let stderrTail = ''
|
||||||
child.stdout.on('data', (chunk) => {
|
child.stdout.on('data', (chunk) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user