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:
@@ -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 (빠름)
|
||||
// 출력은 항상 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]
|
||||
let ok = await runFfmpeg(bin, copyArgs)
|
||||
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',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user