feat(import): cap downloaded video at FHD (1920x1080)

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 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-05-16 03:48:35 +09:00
parent 5728c42ab7
commit 958fc9da70
2 changed files with 55 additions and 21 deletions

View File

@@ -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 (성공해야 원본 교체 진행).

View File

@@ -187,6 +187,9 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
'--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 → 해상도 → 비트레이트 → 확장자.