diff --git a/src/editor.ts b/src/editor.ts index 40de2bd..6124ad5 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -21,6 +21,31 @@ export function getFfmpegPath(): string { throw new FfmpegUnavailableError() } +/** 입력 영상의 평균 fps 를 ffprobe 로 조회. 실패하면 null. */ +function probeVideoFps(inputPath: string): number | null { + const r = spawnSync('ffprobe', [ + '-v', 'error', + '-select_streams', 'v:0', + '-show_entries', 'stream=avg_frame_rate', + '-of', 'default=nokey=1:noprint_wrappers=1', + inputPath + ]) + if (r.status !== 0) return null + const raw = String(r.stdout).trim() + // 예: "60000/1001" → 59.94, "30/1" → 30 + const m = /^(\d+)\/(\d+)$/.exec(raw) + if (m) { + const n = Number(m[1]) + const d = Number(m[2]) + if (d > 0 && Number.isFinite(n / d)) return n / d + return null + } + const single = Number(raw) + return Number.isFinite(single) && single > 0 ? single : null +} + +const TARGET_FPS = 60 + /** * 원본 파일을 그대로 둔 채 trim 결과를 edited. 로 저장한다. * stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩. @@ -49,13 +74,27 @@ export async function applyTrimToVideo( if (endSec !== null) baseArgs.push('-to', String(endSec)) baseArgs.push('-i', inputPath) - // 시도 1: stream copy (빠름) - const copyArgs = [...baseArgs, '-c', 'copy', '-movflags', '+faststart', tmpPath] - let ok = await runFfmpeg(bin, copyArgs) + // 출력은 항상 60fps 이상이 되어야 한다. + // 원본이 이미 60fps 이상이면 stream copy 로 빠르게 자르고, + // 그 미만이면 minterpolate 로 모션 보간해 60fps 까지 끌어올린다. + const sourceFps = probeVideoFps(inputPath) + const needBumpFps = sourceFps !== null && sourceFps < TARGET_FPS - 0.5 + + let ok = false + if (!needBumpFps) { + // 시도 1: stream copy (빠름, 소스가 이미 ≥60fps 이거나 fps 확인 불가일 때) + const copyArgs = [...baseArgs, '-c', 'copy', '-movflags', '+faststart', tmpPath] + ok = await runFfmpeg(bin, copyArgs) + } if (!ok) { - // 시도 2: 재인코딩 + // 시도 2: 재인코딩. 60fps 미만 소스는 minterpolate(mci) 로 모션 보간. + // mci 는 느리지만 단순 프레임 복제보다 훨씬 자연스럽다. + const vfilter = needBumpFps + ? `minterpolate=fps=${TARGET_FPS}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir` + : null const encArgs = [ ...baseArgs, + ...(vfilter ? ['-vf', vfilter] : []), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', diff --git a/src/youtube.ts b/src/youtube.ts index 6122044..3d3d47b 100644 --- a/src/youtube.ts +++ b/src/youtube.ts @@ -186,6 +186,11 @@ async function runJob(job: DownloadJob, bin: string): Promise { '--no-warnings', '--no-playlist', '--newline', + // 가능한 한 부드러운 영상을 받기 위해 fps 를 최우선으로 정렬한다. + // yt-dlp 기본 정렬은 fps 를 가중치로 안 써서 60fps 가 있어도 30fps 를 잡아오는 일이 잦다. + // 우선순위: fps → 해상도 → 비트레이트 → 확장자. + // 60fps 가 아예 없는 영상은 자연스럽게 그 영상의 최고 fps 로 폴백된다. + '-S', 'fps,res,br,ext', '--progress-template', 'download:PROGRESS %(progress._percent_str)s', '--print', 'after_move:OUT %(filepath)s', '-o', path.join(dir, 'original.%(ext)s'),