From 105c5bf09ddd63c090eb2f4b2f200272c26d7ca2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 02:33:37 +0900 Subject: [PATCH] feat(video): prefer max-fps source and bump edited output to 60fps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - youtube.ts: yt-dlp 에 `-S 'fps,res,br,ext'` 추가. 기본 정렬은 fps 를 가중치로 안 써서 60fps 가 있어도 30fps 를 잡아오는 일이 잦았는데, 이제 fps 1순위로 정렬해 가능한 한 부드러운 원본을 받는다. 60fps 가 아예 없는 영상은 자연스럽게 그 영상의 최고 fps 로 폴백. - editor.ts: 편집본은 항상 60fps 이상이 되도록 보장. · ffprobe 로 원본 fps 확인 · ≥60fps 이면 기존대로 stream copy 로 빠르게 trim · <60fps 이면 minterpolate(mci, aobmc, bidir) 로 모션 보간해 60fps 로 재인코딩. mci 는 느리지만 단순 프레임 복제보다 모션이 훨씬 자연스러움. · ffprobe 실패 시(확인 불가) 기존 동작 유지(stream copy → 재인코딩 폴백). Co-Authored-By: Claude Opus 4.7 --- src/editor.ts | 47 +++++++++++++++++++++++++++++++++++++++++++---- src/youtube.ts | 5 +++++ 2 files changed, 48 insertions(+), 4 deletions(-) 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'),