Compare commits

...

3 Commits

Author SHA1 Message Date
Claude
48f84963be fix(editor): rename tmp before unlinking original in 60fps upscale
Review P2: 변환 성공 후 `unlink(input) → rename(tmp)` 순서였는데, unlink 가
성공하고 rename 이 실패하면 원본이 사라진 채 결과물도 없는 상태가 됩니다.

순서를 뒤집어 `rename(tmp → outPath)` 이 먼저 성공한 뒤에만 기존 원본을
지우도록 바꿨습니다. rename 실패 시에는 tmp 만 정리하고 inputName 을 반환해
"실패해도 원본은 그대로" 의도와 일치하게 됩니다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 02:42:59 +09:00
Claude
cdd23b8195 fix(youtube): upscale downloaded original to 60fps after yt-dlp finishes
Review P1: yt-dlp 가 fps 1순위로 정렬해 가져와도, 영상 자체에 60fps
포맷이 없는 경우 original.* 이 30fps 그대로 저장되어 "원본도 60fps 로
받아줘" 요청과 어긋났습니다.

다운로드 완료 직후 후처리 단계를 추가:

- editor.ts 에 `upscaleOriginalTo60Fps(dir, inputName)` 노출
  · ffprobe 로 source fps 측정
  · ≥60fps 면 그대로 두고 inputName 반환
  · <60fps 면 minterpolate(mci, aobmc, bidir) 로 60fps 까지 끌어올려
    `original.mp4` 로 저장하고 기존 파일 제거
  · ffmpeg/ffprobe 없거나 보간 실패하면 원본 그대로 유지 (다운로드 살림)

- youtube.ts `runJob` 마지막에 이 함수를 호출하고, 새 파일명이 돌아오면
  meta.originalFile 도 업데이트. 후처리 중 진행률을 99% 로 표시하고
  실패해도 다운로드 자체는 성공으로 마감.

이로써 편집기에서 다시 보간할 일도 없어집니다 (이미 60fps 원본).
편집 코드 쪽 보호 분기는 직접 업로드 경로용 안전망으로 그대로 둡니다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 02:38:08 +09:00
Claude
105c5bf09d 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>
2026-05-16 02:33:37 +09:00
2 changed files with 139 additions and 4 deletions

View File

@@ -21,6 +21,103 @@ export function getFfmpegPath(): string {
throw new FfmpegUnavailableError()
}
/** 입력 영상의 평균 fps 를 ffprobe 로 조회. 실패하면 null. */
export 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
}
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<string> {
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
}
// 안전 순서: 먼저 tmp → outPath rename (성공해야 원본 교체 진행).
// rename 이 실패하면 tmp 만 정리하고 원본은 그대로 둔다.
try {
await fs.rename(tmpPath, outPath)
} catch (err) {
await fs.unlink(tmpPath).catch(() => undefined)
console.warn(`[upscale] rename 실패 — 원본 ${inputName} 유지: ${(err as Error).message}`)
return inputName
}
// rename 후 input 과 out 경로가 다르면 (확장자 변경 등) 기존 원본 제거.
// 이 단계 실패는 디스크 점유만 늘리고 동작에는 영향 없으므로 조용히 무시.
if (path.resolve(inputPath) !== path.resolve(outPath)) {
await fs.unlink(inputPath).catch(() => undefined)
}
return outName
}
/**
* 원본 파일을 그대로 둔 채 trim 결과를 edited.<ext> 로 저장한다.
* stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩.
@@ -49,13 +146,27 @@ export async function applyTrimToVideo(
if (endSec !== null) baseArgs.push('-to', String(endSec))
baseArgs.push('-i', inputPath)
// 시도 1: stream copy (빠름)
const copyArgs = [...baseArgs, '-c', 'copy', '-movflags', '+faststart', tmpPath]
let ok = await runFfmpeg(bin, copyArgs)
// 출력은 항상 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]
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',

View File

@@ -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,
@@ -186,6 +187,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'),
@@ -226,6 +232,24 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
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