fix(youtube): upscale downloaded original to 60fps after yt-dlp finishes
Review P1: yt-dlp 가 fps 1순위로 정렬해 가져와도, 영상 자체에 60fps
포맷이 없는 경우 original.* 이 30fps 그대로 저장되어 "원본도 60fps 로
받아줘" 요청과 어긋났습니다.
다운로드 완료 직후 후처리 단계를 추가:
- editor.ts 에 `upscaleOriginalTo60Fps(dir, inputName)` 노출
· ffprobe 로 source fps 측정
· ≥60fps 면 그대로 두고 inputName 반환
· <60fps 면 minterpolate(mci, aobmc, bidir) 로 60fps 까지 끌어올려
`original.mp4` 로 저장하고 기존 파일 제거
· ffmpeg/ffprobe 없거나 보간 실패하면 원본 그대로 유지 (다운로드 살림)
- youtube.ts `runJob` 마지막에 이 함수를 호출하고, 새 파일명이 돌아오면
meta.originalFile 도 업데이트. 후처리 중 진행률을 99% 로 표시하고
실패해도 다운로드 자체는 성공으로 마감.
이로써 편집기에서 다시 보간할 일도 없어집니다 (이미 60fps 원본).
편집 코드 쪽 보호 분기는 직접 업로드 경로용 안전망으로 그대로 둡니다.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,7 @@ export function getFfmpegPath(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 입력 영상의 평균 fps 를 ffprobe 로 조회. 실패하면 null. */
|
/** 입력 영상의 평균 fps 를 ffprobe 로 조회. 실패하면 null. */
|
||||||
function probeVideoFps(inputPath: string): number | null {
|
export function probeVideoFps(inputPath: string): number | null {
|
||||||
const r = spawnSync('ffprobe', [
|
const r = spawnSync('ffprobe', [
|
||||||
'-v', 'error',
|
'-v', 'error',
|
||||||
'-select_streams', 'v:0',
|
'-select_streams', 'v:0',
|
||||||
@@ -44,7 +44,70 @@ function probeVideoFps(inputPath: string): number | null {
|
|||||||
return Number.isFinite(single) && single > 0 ? single : null
|
return Number.isFinite(single) && single > 0 ? single : null
|
||||||
}
|
}
|
||||||
|
|
||||||
const TARGET_FPS = 60
|
export const TARGET_FPS = 60
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원본 영상이 60fps 미만이면 minterpolate(mci) 로 60fps 까지 끌어올려
|
||||||
|
* 원본 파일을 같은 디렉토리의 `original.mp4` 로 교체한다.
|
||||||
|
*
|
||||||
|
* - 반환값: 새 파일명 (이미 60fps 이상이면 inputName 그대로)
|
||||||
|
* - ffmpeg/ffprobe 가 없거나 보간에 실패하면 inputName 을 그대로 반환해 호출자에게 영향 안 줌.
|
||||||
|
* - 보간 성공 시 원래 파일은 삭제하고 새 파일로 대체.
|
||||||
|
*
|
||||||
|
* minterpolate(mci) 는 무겁다. 원본 길이의 수 배 시간이 걸릴 수 있으니
|
||||||
|
* 호출자는 백그라운드 잡 안에서 부르고 상태를 적절히 갱신해 주세요.
|
||||||
|
*/
|
||||||
|
export async function upscaleOriginalTo60Fps(
|
||||||
|
dir: string,
|
||||||
|
inputName: string
|
||||||
|
): Promise<string> {
|
||||||
|
let bin: string
|
||||||
|
try {
|
||||||
|
bin = getFfmpegPath()
|
||||||
|
} catch {
|
||||||
|
// ffmpeg 없으면 조용히 건너뜀 (다운로드 자체는 살림)
|
||||||
|
console.warn('[upscale] ffmpeg 없음 — 60fps 변환 건너뜀')
|
||||||
|
return inputName
|
||||||
|
}
|
||||||
|
const inputPath = path.join(dir, inputName)
|
||||||
|
const sourceFps = probeVideoFps(inputPath)
|
||||||
|
if (sourceFps === null) {
|
||||||
|
console.warn(`[upscale] fps 확인 실패 (${inputPath}) — 60fps 변환 건너뜀`)
|
||||||
|
return inputName
|
||||||
|
}
|
||||||
|
if (sourceFps >= TARGET_FPS - 0.5) {
|
||||||
|
// 이미 충분히 부드러움.
|
||||||
|
return inputName
|
||||||
|
}
|
||||||
|
|
||||||
|
// 재인코딩 결과는 항상 mp4 로 통일 (소스가 webm/mkv 여도).
|
||||||
|
const outName = 'original.mp4'
|
||||||
|
const outPath = path.join(dir, outName)
|
||||||
|
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 args = [
|
||||||
|
'-y',
|
||||||
|
'-i', inputPath,
|
||||||
|
'-vf', vfilter,
|
||||||
|
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
|
||||||
|
'-c:a', 'aac', '-b:a', '160k',
|
||||||
|
'-movflags', '+faststart',
|
||||||
|
tmpPath
|
||||||
|
]
|
||||||
|
const ok = await runFfmpeg(bin, args)
|
||||||
|
if (!ok) {
|
||||||
|
await fs.unlink(tmpPath).catch(() => undefined)
|
||||||
|
console.warn(`[upscale] minterpolate 실패 — 원본 ${inputName} 유지`)
|
||||||
|
return inputName
|
||||||
|
}
|
||||||
|
// 같은 이름으로 덮어쓰는 경우를 대비해 원본을 먼저 지운다.
|
||||||
|
if (path.resolve(inputPath) !== path.resolve(outPath)) {
|
||||||
|
await fs.unlink(inputPath).catch(() => undefined)
|
||||||
|
}
|
||||||
|
await fs.rename(tmpPath, outPath)
|
||||||
|
return outName
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 원본 파일을 그대로 둔 채 trim 결과를 edited.<ext> 로 저장한다.
|
* 원본 파일을 그대로 둔 채 trim 결과를 edited.<ext> 로 저장한다.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { spawn, spawnSync } from 'node:child_process'
|
|||||||
import { promises as fs } from 'node:fs'
|
import { promises as fs } from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { jobsDir, projectRoot } from './paths.js'
|
import { jobsDir, projectRoot } from './paths.js'
|
||||||
|
import { upscaleOriginalTo60Fps } from './editor.js'
|
||||||
import {
|
import {
|
||||||
loadVideoMeta,
|
loadVideoMeta,
|
||||||
newVideoId,
|
newVideoId,
|
||||||
@@ -231,6 +232,24 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
|||||||
const found = entries.find((n) => n.startsWith('original.'))
|
const found = entries.find((n) => n.startsWith('original.'))
|
||||||
if (found) finalName = found
|
if (found) finalName = found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 사용자가 "원본도 60fps 로" 요청 — yt-dlp 가 fps 1순위로 잡아도 60fps 자체가
|
||||||
|
// 없는 영상이 있으니, 다운로드 후 원본을 ffprobe 로 확인해 <60fps 면
|
||||||
|
// minterpolate 로 60fps 까지 끌어올린다. 실패해도 원본은 그대로 둠.
|
||||||
|
try {
|
||||||
|
job.progress = 99
|
||||||
|
job.message = '60fps 변환 확인 중'
|
||||||
|
await persistJob(job)
|
||||||
|
const bumped = await upscaleOriginalTo60Fps(dir, finalName)
|
||||||
|
if (bumped !== finalName) {
|
||||||
|
job.message = '60fps 변환 완료'
|
||||||
|
finalName = bumped
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 후처리 실패는 다운로드 자체를 실패시키지 않는다. 원본 보존이 우선.
|
||||||
|
console.error('[youtube] 60fps 후처리 실패:', err)
|
||||||
|
}
|
||||||
|
|
||||||
const meta = await loadVideoMeta(job.folder, job.videoId)
|
const meta = await loadVideoMeta(job.folder, job.videoId)
|
||||||
if (meta) {
|
if (meta) {
|
||||||
meta.originalFile = finalName
|
meta.originalFile = finalName
|
||||||
|
|||||||
Reference in New Issue
Block a user