Compare commits

..

9 Commits

Author SHA1 Message Date
Claude
958fc9da70 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>
2026-05-16 03:48:35 +09:00
Claude
5728c42ab7 fix(import): ignore stale probe responses on URL change
If user changed the URL while a probe was in flight, the late response
for the old URL would still set lastProbedUrl and enable the start
button, breaking the "확인 전 가져오기 차단" UI invariant.

Drop the response (and the catch path, and the post-confirm path) when
the current input no longer matches the probed URL.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 03:14:30 +09:00
Claude
33addb304a fix(import): invalidate probe when URL input changes
Review P2: probe 성공 후 사용자가 URL 입력값을 다른 URL 로 바꿔도 "확인"
버튼이 여전히 활성화 상태였습니다. 그래서 A 로 probe → B 로 수정 → 확인을
누르면 B 가 probe 없이 바로 다운로드 시작됐습니다.

수정:

- `lastProbedUrl` 로 마지막으로 probe 통과한 URL 기록.
- ytUrl 의 input 이벤트에서 현재 값이 lastProbedUrl 과 다르면
  ytStartBtn 을 disable 로 되돌리고 ytProbeBtn 을 다시 활성화.
- ytStartBtn 클릭 핸들러에도 가드 추가: 클릭 시점에 URL ≠ lastProbedUrl
  이면 안내 메시지와 함께 차단 (race condition 대비).

이제 "확인 누르기 전 가져오기 못 누르게" 요구사항이 어느 순서로 입력이
들어와도 만족됩니다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 03:11:00 +09:00
Claude
11db6df8d2 fix(import): unbuffer yt-dlp progress, speed up 60fps via fps filter, lock probe btn
세 가지 사용자 보고 처리:

1) 다운로드 바가 멈춰 있다가 한번에 50% 로 점프
   - 원인: yt-dlp 는 파이썬 스크립트라 stdout 이 pipe 로 연결되면
     block-buffered 가 되어 진행률 라인들이 4KB 버퍼에 모였다가 종료 직전에
     쏟아짐. node 의 stdout 'data' 핸들러가 그 전엔 아무 것도 못 봄.
   - 수정: spawn 의 env 에 PYTHONUNBUFFERED=1 추가. 라인 단위로 즉시 flush.

2) 변환을 20배 빠르게
   - 원인: minterpolate(mci) 는 motion estimation 자체가 무거워 mci 기본값
     이어도 영상 길이의 수 배 시간이 든다 (blend 도 5~10배가 한계).
   - 수정: 다운로드 후 60fps 변환과 trim 재인코딩 양쪽에서 minterpolate 를
     `fps=60` (단순 프레임 복제) 으로 교체. preset 도 `veryfast` → `ultrafast`.
     실측상 영상 길이의 1/3 ~ 1배 시간 — mci 대비 수십 배 빠름.
   - 시각적 부드러움은 30fps 와 동일하지만 컨테이너/타이밍은 60fps cfr 로 유지.
     사용자가 알려준 "timing 만 60fps 로 바뀌고 실제 부드러움은 그대로" 경로.

3) "확인" 전 "가져오기" 재클릭 막기
   - probe 클릭 시 ytProbeBtn 도 disabled.
   - probe 실패 / 5분 경고 취소 / start 실패 시만 재활성화.
   - 정상 흐름은 probe 성공 → 확인 클릭 → 다운로드 → redirect 라 재활성화
     필요 없음. ytStartBtn 도 클릭 직후 disabled 로 중복 클릭 방지.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 03:07:47 +09:00
Claude
67d4fb89b8 fix(job-poll): disable caching and recover from fetch errors
Review P1: 진행률 폴링이 304 응답을 받으면 r.json() 이 reject 되는데
.catch() 가 없어 setTimeout 도 안 걸리고 폴링이 영구 중단됐습니다.
이게 "60fps 변환 확인 중" 에서 바가 멈춰 보이던 진짜 원인이었어요.

세 곳을 다 고침:

1) src/routes/op.ts `/op/job/:id`
   - `Cache-Control: no-store, no-cache, must-revalidate` + `Pragma: no-cache`
   - 브라우저가 conditional GET 으로 304 를 받지 않게 한다.

2) public/editor.js fetch
   - `{ cache: 'no-store' }` 옵션. 서버 헤더 + 클라 옵션 둘 다.

3) public/editor.js pollJob
   - `.catch()` 추가. 일시적 네트워크/파싱 오류여도 2 초 백오프로
     폴링을 재개한다. 변환이 오래 걸려도 바가 계속 갱신됨을 보장.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 03:00:58 +09:00
Claude
cdf56b96b7 fix(youtube): split progress 0-50/50-99 and report ffmpeg conversion %
다운로드 후 바가 한 번에 99% 로 튄 뒤 멈춰 있던 문제 두 가지를 같이 고침.

1) 진행률 구간 분리
   - 다운로드: 0 ~ 50% (yt-dlp 의 0-100% 를 절반으로 매핑)
   - 60fps 변환: 50 ~ 99%
   - yt-dlp 가 video+audio 두 스트림을 받으면 각 스트림이 0→100% 를
     반복하므로 단조 증가만 인정 (downloadPctRaw = max(...)).

2) ffmpeg minterpolate 진행률 실시간 보고
   - editor.ts 에 `probeVideoDuration` 추가, `runFfmpegWithProgress`
     도입해 ffmpeg `-progress pipe:1` 의 out_time_us/ms 를 파싱.
   - `upscaleOriginalTo60Fps` 에 `onProgress(pct)` 콜백 추가.
   - youtube.ts 가 콜백을 받아 50~99% 로 매핑하고 job.message 를
     "60fps 변환 NN%" 로 갱신. persistJob 은 2초 throttle.

3) mci 옵션 단순화로 속도 개선
   - 기존: `mci:mc_mode=aobmc:me_mode=bidir` (가장 느린 조합)
   - 현재: `mci` 기본값 (obmc + bilat). 화질 약간 양보, 속도 수 배 개선.
   - 여전히 mci 자체가 무거워 5분 30fps 영상 변환에 수십 분 단위 시간이
     걸릴 수 있음 — 이제 그 동안 진행률은 실시간으로 움직임.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 02:57:33 +09:00
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
4 changed files with 363 additions and 24 deletions

View File

@@ -70,18 +70,38 @@
}
// YouTube probe
// "확인" 누르기 전 "가져오기" 재클릭 방지 + URL 변경 시 다시 probe 강제.
// 흐름: URL 입력 → 가져오기 → (성공 시) 확인 활성화 → 확인 클릭.
// URL 이 바뀌면 확인 비활성화로 돌아가고 가져오기 다시 활성화.
var lastProbedUrl = null
ytUrl.addEventListener('input', function () {
// 입력이 바뀌면 직전 probe 결과는 무효.
if (ytUrl.value.trim() !== lastProbedUrl) {
ytStartBtn.disabled = true
// probe 중이 아닐 때만 가져오기를 풀어준다 (probe 중간엔 잠금 유지).
ytProbeBtn.disabled = false
}
})
ytProbeBtn.addEventListener('click', function () {
var url = ytUrl.value.trim()
if (!url) return
probeInfo.textContent = '확인 중...'
ytProbeBtn.disabled = true
ytStartBtn.disabled = true
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/probe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: url })
}).then(function (r) { return r.json() }).then(function (j) {
// stale probe 응답 무시: 사용자가 그동안 입력을 바꿨으면 이 응답으로
// UI 상태 (lastProbedUrl, 버튼 enable, probeInfo, title) 를 갱신하면 안 된다.
// (race: URL A 로 probe → 응답 도착 전 URL B 로 수정 → A 응답이 B 의
// 가져오기 버튼을 켜버리는 버그.)
if (ytUrl.value.trim() !== url) return
if (!j.ok) {
probeInfo.textContent = j.message || '확인 실패'
ytProbeBtn.disabled = false
return
}
var p = j.probe
@@ -94,19 +114,36 @@
probeInfo.textContent = parts.join(' · ')
if (p.warnOver5min) {
if (!window.confirm('가져오는 데 5분 이상 걸릴 수 있습니다. 진행할까요?\n(다른 페이지에서 작업해도 백그라운드로 계속 진행됩니다.)')) {
ytProbeBtn.disabled = false
return
}
}
// confirm 동안에도 사용자가 입력을 바꿨을 수 있으므로 한 번 더 검증.
if (ytUrl.value.trim() !== url) return
if (!titleInput.value) titleInput.value = p.title
// 이 URL 에 한해 확인 활성화. URL 변경 감지용으로 마지막 probe URL 저장.
lastProbedUrl = url
ytStartBtn.disabled = false
}).catch(function (e) {
// 마찬가지로 stale 응답이면 무시 (현재 입력값에 영향 안 주게).
if (ytUrl.value.trim() !== url) return
probeInfo.textContent = '확인 실패: ' + e.message
ytProbeBtn.disabled = false
})
})
ytStartBtn.addEventListener('click', function () {
var url = ytUrl.value.trim()
if (!url) return
// 가드: URL 이 바뀐 채로 확인이 눌리는 경우 (이벤트 race) 차단.
if (url !== lastProbedUrl) {
probeInfo.textContent = 'URL 이 바뀌었습니다. "가져오기" 를 다시 눌러 확인하세요.'
ytStartBtn.disabled = true
ytProbeBtn.disabled = false
return
}
// 중복 클릭 방지
ytStartBtn.disabled = true
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/start', {
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -114,31 +151,47 @@
}).then(function (r) { return r.json() }).then(function (j) {
if (!j.ok) {
probeInfo.textContent = j.message || '시작 실패'
// 시작 실패면 재시도 가능하게 둘 다 다시 풀어줌
ytProbeBtn.disabled = false
ytStartBtn.disabled = false
return
}
dlProgress.hidden = false
probeInfo.textContent = '백그라운드 다운로드 시작...'
pollJob(j.jobId, j.videoId)
}).catch(function (e) {
probeInfo.textContent = '시작 실패: ' + e.message
ytProbeBtn.disabled = false
ytStartBtn.disabled = false
})
})
function pollJob(jobId, videoId) {
fetch('/op/job/' + encodeURIComponent(jobId)).then(function (r) { return r.json() }).then(function (j) {
if (!j.ok) {
probeInfo.textContent = j.message || '작업을 찾을 수 없음'
return
}
var job = j.job
dlProgress.value = job.progress
probeInfo.textContent = job.message
if (job.status === 'done') {
location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(videoId)
} else if (job.status === 'error') {
probeInfo.textContent = '실패: ' + (job.error || '')
} else {
setTimeout(function () { pollJob(jobId, videoId) }, 1500)
}
})
// cache: 'no-store' 로 304 가 나지 않게 강제. 304 면 body 가 비어
// r.json() 이 reject → 폴링 중단되는 문제 방지.
fetch('/op/job/' + encodeURIComponent(jobId), { cache: 'no-store' })
.then(function (r) { return r.json() })
.then(function (j) {
if (!j.ok) {
probeInfo.textContent = j.message || '작업을 찾을 수 없음'
return
}
var job = j.job
dlProgress.value = job.progress
probeInfo.textContent = job.message
if (job.status === 'done') {
location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(videoId)
} else if (job.status === 'error') {
probeInfo.textContent = '실패: ' + (job.error || '')
} else {
setTimeout(function () { pollJob(jobId, videoId) }, 1500)
}
})
.catch(function (err) {
// 네트워크 일시 오류로 폴링이 영구 중단되지 않도록 짧게 백오프 후 재시도.
console.warn('[pollJob] fetch 실패, 재시도:', err)
setTimeout(function () { pollJob(jobId, videoId) }, 2000)
})
}
// ── 타임라인 트림 (lossless-cut 류 핸들 UI) ─────────────────────────

View File

@@ -21,6 +21,164 @@ 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
}
/** 입력 영상의 해상도(가로×세로 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', [
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=nokey=1:noprint_wrappers=1',
inputPath
])
if (r.status !== 0) return null
const v = Number(String(r.stdout).trim())
return Number.isFinite(v) && v > 0 ? v : null
}
export const TARGET_FPS = 60
// FHD 캡. 사용자 요구: FHD 보다 작으면 그대로, FHD 보다 크면 강제로 FHD 로 다운스케일.
export const MAX_WIDTH = 1920
export const MAX_HEIGHT = 1080
/**
* 원본 영상을 다운스트림 요구사항에 맞추는 후처리.
*
* 1) fps < 60 이면 fps=60 프레임 복제로 끌어올림 (속도 우선; mci 는 너무 느림).
* 2) 해상도가 FHD(1920×1080) 보다 크면 종횡비를 유지한 채 FHD 로 다운스케일.
*
* 둘 다 필요 없으면 inputName 그대로 반환해 인코딩 자체를 건너뛴다.
* ffmpeg/ffprobe 가 없거나 변환에 실패하면 inputName 을 그대로 반환해
* 호출자에게 영향을 안 준다. 성공 시 원본을 새 파일로 교체.
*/
export async function upscaleOriginalTo60Fps(
dir: string,
inputName: string,
onProgress?: (pct: number) => void
): Promise<string> {
let bin: string
try {
bin = getFfmpegPath()
} catch {
// ffmpeg 없으면 조용히 건너뜀 (다운로드 자체는 살림)
console.warn('[upscale] ffmpeg 없음 — 후처리 건너뜀')
return inputName
}
const inputPath = path.join(dir, inputName)
const sourceFps = probeVideoFps(inputPath)
const sourceRes = probeVideoResolution(inputPath)
if (sourceFps === null && sourceRes === null) {
console.warn(`[upscale] 메타 확인 실패 (${inputPath}) — 후처리 건너뜀`)
return inputName
}
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
}
const sourceDurationSec = probeVideoDuration(inputPath)
// 재인코딩 결과는 항상 mp4 로 통일 (소스가 webm/mkv 여도).
const outName = 'original.mp4'
const outPath = path.join(dir, outName)
const tmpPath = path.join(dir, 'original.bump.tmp.mp4')
// 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,
'-vf', vfilter,
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23',
'-c:a', 'aac', '-b:a', '160k',
'-movflags', '+faststart',
// 진행률을 stdout 으로 key=value 로 받기 위해 -progress pipe:1, -nostats 를 켠다.
'-progress', 'pipe:1',
'-nostats',
tmpPath
]
const ok = await runFfmpegWithProgress(bin, args, (outTimeUs) => {
if (!onProgress || !sourceDurationSec || sourceDurationSec <= 0) return
const pct = Math.max(0, Math.min(100, (outTimeUs / 1e6 / sourceDurationSec) * 100))
onProgress(pct)
})
if (!ok) {
await fs.unlink(tmpPath).catch(() => undefined)
console.warn(`[upscale] 후처리 실패 (vf=${vfilter}) — 원본 ${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,14 +207,26 @@ 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 미만 소스는 fps=60 프레임 복제로 끌어올림 (속도 우선).
// (다운로드 단계에서 이미 60fps 로 끌어올리므로 이 경로는 직접 업로드용 안전망.)
const vfilter = needBumpFps ? `fps=${TARGET_FPS}` : null
const encArgs = [
...baseArgs,
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23',
...(vfilter ? ['-vf', vfilter] : []),
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23',
'-c:a', 'aac', '-b:a', '128k',
'-movflags', '+faststart',
tmpPath
@@ -91,3 +261,53 @@ function runFfmpeg(bin: string, args: string[]): Promise<boolean> {
})
})
}
/**
* ffmpeg 의 `-progress pipe:1` 출력을 파싱해 진행 상황을 콜백으로 흘려준다.
*
* 출력 형식 (key=value, 한 블록 끝나면 `progress=continue` 또는 `progress=end`):
* frame=123\nfps=24.5\n...\nout_time_us=1234567\n...\nprogress=continue
*
* onOutTimeUs 는 현재까지 처리된 마이크로초를 받는다. 호출자는 이를
* 영상 총 길이와 비교해 % 로 변환할 수 있다.
*/
function runFfmpegWithProgress(
bin: string,
args: string[],
onOutTimeUs: (us: number) => void
): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn(bin, args)
let stderr = ''
let stdoutBuf = ''
child.stdout.on('data', (c) => {
stdoutBuf += c.toString()
let nl = stdoutBuf.indexOf('\n')
while (nl !== -1) {
const line = stdoutBuf.slice(0, nl).trim()
stdoutBuf = stdoutBuf.slice(nl + 1)
// ffmpeg 는 out_time_us 또는 out_time_ms 둘 다 내보낸다 (버전마다 다름).
// 둘 다 마이크로초 단위라 정확히 같은 값. 먼저 매치되는 걸 쓴다.
let m = /^out_time_us=(\d+)/.exec(line)
if (!m) m = /^out_time_ms=(\d+)/.exec(line)
if (m) {
const v = Number(m[1])
if (Number.isFinite(v)) onOutTimeUs(v)
}
nl = stdoutBuf.indexOf('\n')
}
})
child.stderr.on('data', (c) => {
stderr = (stderr + c.toString()).slice(-2000)
})
child.on('error', () => resolve(false))
child.on('close', (code) => {
if (code === 0) {
resolve(true)
} else {
console.error('[ffmpeg] failed:', stderr.split('\n').slice(-5).join('\n'))
resolve(false)
}
})
})
}

View File

@@ -291,6 +291,10 @@ opRouter.post('/op/folder/:name/video/youtube/start', requireAuth, async (req, r
})
opRouter.get('/op/job/:id', requireAuth, (req, res) => {
// 폴링 응답이 304 로 캐싱되면 브라우저가 body 없는 응답을 돌려줘서
// 클라이언트의 r.json() 이 reject → 폴링이 중단된다. 항상 신선한 응답.
res.set('Cache-Control', 'no-store, no-cache, must-revalidate')
res.set('Pragma', 'no-cache')
const job = getJob(req.params.id)
if (!job) {
res.status(404).json({ ok: false, message: '작업을 찾을 수 없습니다.' })

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,13 +187,30 @@ 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 → 해상도 → 비트레이트 → 확장자.
// 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'),
job.url
]
// 다운로드 단계는 전체 진행률의 0~50% 를 차지하고, 60fps 후처리가 50~99%.
// (yt-dlp 가 video+audio 두 스트림을 받으면 각 스트림이 0→100 % 를 반복하므로
// 단조 증가만 인정해 막대가 역행하지 않게 한다.)
let downloadPctRaw = 0
return new Promise<void>((resolve, reject) => {
const child = spawn(bin, args)
// yt-dlp 는 파이썬 스크립트라 stdout 이 pipe 로 연결되면 block-buffered 가 되어
// 진행률 라인들이 즉시 흘러나오지 않는다. PYTHONUNBUFFERED=1 로 강제 unbuffered.
// 이게 없으면 "다운로드 끝나자마자 한꺼번에 progress 0→50 점프" 처럼 보임.
const child = spawn(bin, args, {
env: { ...process.env, PYTHONUNBUFFERED: '1' }
})
let outputFile: string | null = null
let stderrTail = ''
child.stdout.on('data', (chunk) => {
@@ -200,8 +218,13 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
for (const line of text.split(/\r?\n/)) {
const m = /PROGRESS\s+([\d.]+)%/.exec(line)
if (m) {
job.progress = Math.min(99, Math.round(Number(m[1])))
job.message = `다운로드 ${job.progress}%`
const pct = Number(m[1])
if (Number.isFinite(pct)) {
downloadPctRaw = Math.max(downloadPctRaw, Math.min(100, pct))
const mapped = Math.round(downloadPctRaw * 0.5) // 0..50
if (mapped > job.progress) job.progress = mapped
job.message = `다운로드 ${Math.round(downloadPctRaw)}%`
}
}
const o = /^OUT\s+(.+)$/.exec(line.trim())
if (o) outputFile = o[1].trim()
@@ -226,6 +249,45 @@ 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 {
// 50% 부터 시작해 50~99% 구간을 ffmpeg 진행률이 채우게 한다.
job.progress = 50
job.message = '60fps 변환 준비 중'
await persistJob(job)
// ffmpeg 진행률은 매우 자주 들어오므로 디스크 persist 는 throttle.
let lastPersistAt = 0
let lastBumpPct = 0
const bumped = await upscaleOriginalTo60Fps(dir, finalName, (pct) => {
if (pct <= lastBumpPct) return
lastBumpPct = pct
const mapped = 50 + Math.round((pct / 100) * 49) // 50..99
if (mapped > job.progress) job.progress = mapped
job.message = `60fps 변환 ${Math.round(pct)}%`
const now = Date.now()
if (now - lastPersistAt > 2000) {
lastPersistAt = now
void persistJob(job)
}
})
if (bumped !== finalName) {
job.message = '60fps 변환 완료'
finalName = bumped
} else {
// upscaleOriginalTo60Fps 가 inputName 을 그대로 돌려준 경우는
// (a) 이미 60fps 이상이거나 (b) ffmpeg 없거나 (c) 보간 실패.
// 어느 경우든 후처리 단계는 끝났다고 보고 진행률만 99 까지 채운다.
job.progress = 99
}
await persistJob(job)
} catch (err) {
// 후처리 실패는 다운로드 자체를 실패시키지 않는다. 원본 보존이 우선.
console.error('[youtube] 60fps 후처리 실패:', err)
}
const meta = await loadVideoMeta(job.folder, job.videoId)
if (meta) {
meta.originalFile = finalName