From 958fc9da7002b32e8cb9097a2750b3d57a31b455 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 03:48:35 +0900 Subject: [PATCH] feat(import): cap downloaded video at FHD (1920x1080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User wants YouTube imports capped at FHD: below-FHD sources stay as-is, above-FHD sources are forcefully downscaled to FHD preserving aspect. Two layers: - yt-dlp `-f bv*[height<=1080]+ba/b[height<=1080]/bv*+ba/b` strictly picks ≤1080 formats when available; falls back to anything otherwise. - ffmpeg post-process: probeVideoResolution checks output; if >FHD, add scale='min(1920,iw)':'min(1080,ih)':force_original_aspect_ratio=decrease to the existing 60fps vfilter chain (no extra encode pass — combined into the one we already run). trunc(/2)*2 ensures even dims for libx264. The post-process now triggers when either (fps<60) OR (res>FHD), so a 60fps 4K source that previously skipped re-encoding will now get downscaled. Co-Authored-By: Claude Opus 4.7 --- src/editor.ts | 73 +++++++++++++++++++++++++++++++++++--------------- src/youtube.ts | 3 +++ 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/src/editor.ts b/src/editor.ts index 6d14fcc..ce60f83 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -44,6 +44,25 @@ export function probeVideoFps(inputPath: string): number | null { return Number.isFinite(single) && single > 0 ? single : null } +/** 입력 영상의 해상도(가로×세로 px) 를 ffprobe 로 조회. 실패하면 null. */ +export function probeVideoResolution(inputPath: string): { width: number; height: number } | null { + const r = spawnSync('ffprobe', [ + '-v', 'error', + '-select_streams', 'v:0', + '-show_entries', 'stream=width,height', + '-of', 'csv=s=x:p=0', + inputPath + ]) + if (r.status !== 0) return null + const raw = String(r.stdout).trim() + const m = /^(\d+)x(\d+)$/.exec(raw) + if (!m) return null + const w = Number(m[1]) + const h = Number(m[2]) + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null + return { width: w, height: h } +} + /** 입력 영상의 총 재생 길이(초) 를 ffprobe 로 조회. 실패하면 null. */ export function probeVideoDuration(inputPath: string): number | null { const r = spawnSync('ffprobe', [ @@ -58,19 +77,19 @@ export function probeVideoDuration(inputPath: string): number | null { } export const TARGET_FPS = 60 +// FHD 캡. 사용자 요구: FHD 보다 작으면 그대로, FHD 보다 크면 강제로 FHD 로 다운스케일. +export const MAX_WIDTH = 1920 +export const MAX_HEIGHT = 1080 /** - * 원본 영상이 60fps 미만이면 fps=60 프레임 복제로 끌어올려 - * 원본 파일을 같은 디렉토리의 `original.mp4` 로 교체한다. + * 원본 영상을 다운스트림 요구사항에 맞추는 후처리. * - * - 반환값: 새 파일명 (이미 60fps 이상이면 inputName 그대로) - * - ffmpeg/ffprobe 가 없거나 변환에 실패하면 inputName 을 그대로 반환해 호출자에게 영향 안 줌. - * - 변환 성공 시 원래 파일은 삭제하고 새 파일로 대체. + * 1) fps < 60 이면 fps=60 프레임 복제로 끌어올림 (속도 우선; mci 는 너무 느림). + * 2) 해상도가 FHD(1920×1080) 보다 크면 종횡비를 유지한 채 FHD 로 다운스케일. * - * 속도 우선 정책: 사용자가 "20배 빠르게" 요구해서 motion interpolation - * (mci/blend) 대신 단순 프레임 복제로 갔다. 시각적 부드러움은 30fps 와 - * 동일하지만 컨테이너/타이밍이 60fps cfr 이라 60fps 가 필요한 다운스트림 - * (예: 60fps 라우드 플레이어) 호환성이 확보된다. + * 둘 다 필요 없으면 inputName 그대로 반환해 인코딩 자체를 건너뛴다. + * ffmpeg/ffprobe 가 없거나 변환에 실패하면 inputName 을 그대로 반환해 + * 호출자에게 영향을 안 준다. 성공 시 원본을 새 파일로 교체. */ export async function upscaleOriginalTo60Fps( dir: string, @@ -82,17 +101,21 @@ export async function upscaleOriginalTo60Fps( bin = getFfmpegPath() } catch { // ffmpeg 없으면 조용히 건너뜀 (다운로드 자체는 살림) - console.warn('[upscale] ffmpeg 없음 — 60fps 변환 건너뜀') + console.warn('[upscale] ffmpeg 없음 — 후처리 건너뜀') return inputName } const inputPath = path.join(dir, inputName) const sourceFps = probeVideoFps(inputPath) - if (sourceFps === null) { - console.warn(`[upscale] fps 확인 실패 (${inputPath}) — 60fps 변환 건너뜀`) + const sourceRes = probeVideoResolution(inputPath) + if (sourceFps === null && sourceRes === null) { + console.warn(`[upscale] 메타 확인 실패 (${inputPath}) — 후처리 건너뜀`) return inputName } - if (sourceFps >= TARGET_FPS - 0.5) { - // 이미 충분히 부드러움. + const needBumpFps = sourceFps !== null && sourceFps < TARGET_FPS - 0.5 + const needDownscale = + sourceRes !== null && (sourceRes.width > MAX_WIDTH || sourceRes.height > MAX_HEIGHT) + if (!needBumpFps && !needDownscale) { + // 이미 충분히 부드럽고 해상도도 캡 이하. return inputName } @@ -103,12 +126,20 @@ export async function upscaleOriginalTo60Fps( const outPath = path.join(dir, outName) const tmpPath = path.join(dir, 'original.bump.tmp.mp4') - // 속도 우선: motion interpolation 대신 fps=60 필터로 프레임 복제만 한다. - // (mci 는 영상 길이의 수 배가 걸려 사용자가 못 기다림. blend 도 5~10배가 한계.) - // fps=60 + ultrafast 프리셋이면 영상 길이의 1/3 ~ 1배 시간이면 끝남. - // 시각적 부드러움은 30fps 와 동일하지만 컨테이너/타이밍은 60fps cfr. - // 진짜 motion interpolation 이 필요하면 호출자가 별도 옵션으로 키게 추후 확장. - const vfilter = `fps=${TARGET_FPS}` + // vfilter 조립: + // - fps=60 : motion interpolation 대신 단순 프레임 복제 (속도 우선). + // - scale : iw>1920 또는 ih>1080 일 때만 종횡비 유지 다운스케일. libx264 는 + // 짝수 변을 요구하므로 trunc(/2)*2 로 보정. force_original_aspect_ratio=decrease + // 를 쓰면 한 변이 캡과 같고 다른 변이 캡 이하로 떨어진다. + const filters: string[] = [] + if (needBumpFps) filters.push(`fps=${TARGET_FPS}`) + if (needDownscale) { + filters.push( + `scale='min(${MAX_WIDTH},iw)':'min(${MAX_HEIGHT},ih)':force_original_aspect_ratio=decrease:flags=lanczos`, + `scale=trunc(iw/2)*2:trunc(ih/2)*2` + ) + } + const vfilter = filters.join(',') const args = [ '-y', '-i', inputPath, @@ -128,7 +159,7 @@ export async function upscaleOriginalTo60Fps( }) if (!ok) { await fs.unlink(tmpPath).catch(() => undefined) - console.warn(`[upscale] fps=60 변환 실패 — 원본 ${inputName} 유지`) + console.warn(`[upscale] 후처리 실패 (vf=${vfilter}) — 원본 ${inputName} 유지`) return inputName } // 안전 순서: 먼저 tmp → outPath rename (성공해야 원본 교체 진행). diff --git a/src/youtube.ts b/src/youtube.ts index 2b3b515..8be95cf 100644 --- a/src/youtube.ts +++ b/src/youtube.ts @@ -187,6 +187,9 @@ async function runJob(job: DownloadJob, bin: string): Promise { '--no-warnings', '--no-playlist', '--newline', + // 해상도 캡: FHD(≤1080p) 안에서만 비디오를 선택. FHD 가 아예 없는 영상은 + // 마지막 fallback 으로 무제한 best 를 받는다 (그 경우 후처리에서 FHD 로 다운스케일). + '-f', 'bv*[height<=1080]+ba/b[height<=1080]/bv*+ba/b', // 가능한 한 부드러운 영상을 받기 위해 fps 를 최우선으로 정렬한다. // yt-dlp 기본 정렬은 fps 를 가중치로 안 써서 60fps 가 있어도 30fps 를 잡아오는 일이 잦다. // 우선순위: fps → 해상도 → 비트레이트 → 확장자.