diff --git a/src/editor.ts b/src/editor.ts index 6124ad5..5571703 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -22,7 +22,7 @@ export function getFfmpegPath(): string { } /** 입력 영상의 평균 fps 를 ffprobe 로 조회. 실패하면 null. */ -function probeVideoFps(inputPath: string): number | null { +export function probeVideoFps(inputPath: string): number | null { const r = spawnSync('ffprobe', [ '-v', 'error', '-select_streams', 'v:0', @@ -44,7 +44,70 @@ function probeVideoFps(inputPath: string): number | 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 { + 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. 로 저장한다. diff --git a/src/youtube.ts b/src/youtube.ts index 3d3d47b..58549e8 100644 --- a/src/youtube.ts +++ b/src/youtube.ts @@ -2,6 +2,7 @@ import { spawn, spawnSync } from 'node:child_process' import { promises as fs } from 'node:fs' import path from 'node:path' import { jobsDir, projectRoot } from './paths.js' +import { upscaleOriginalTo60Fps } from './editor.js' import { loadVideoMeta, newVideoId, @@ -231,6 +232,24 @@ async function runJob(job: DownloadJob, bin: string): Promise { const found = entries.find((n) => n.startsWith('original.')) 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) if (meta) { meta.originalFile = finalName