feat(video): prefer max-fps source and bump edited output to 60fps

- 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 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-05-16 02:33:37 +09:00
parent 59f96a12a6
commit 105c5bf09d
2 changed files with 48 additions and 4 deletions

View File

@@ -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.<ext> 로 저장한다.
* 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',

View File

@@ -186,6 +186,11 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
'--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'),