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:
@@ -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 (성공해야 원본 교체 진행).
|
||||
|
||||
@@ -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 → 해상도 → 비트레이트 → 확장자.
|
||||
|
||||
Reference in New Issue
Block a user