Compare commits
9 Commits
59f96a12a6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
958fc9da70 | ||
|
|
5728c42ab7 | ||
|
|
33addb304a | ||
|
|
11db6df8d2 | ||
|
|
67d4fb89b8 | ||
|
|
cdf56b96b7 | ||
|
|
48f84963be | ||
|
|
cdd23b8195 | ||
|
|
105c5bf09d |
@@ -70,18 +70,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// YouTube probe
|
// 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 () {
|
ytProbeBtn.addEventListener('click', function () {
|
||||||
var url = ytUrl.value.trim()
|
var url = ytUrl.value.trim()
|
||||||
if (!url) return
|
if (!url) return
|
||||||
probeInfo.textContent = '확인 중...'
|
probeInfo.textContent = '확인 중...'
|
||||||
|
ytProbeBtn.disabled = true
|
||||||
ytStartBtn.disabled = true
|
ytStartBtn.disabled = true
|
||||||
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/probe', {
|
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/probe', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ url: url })
|
body: JSON.stringify({ url: url })
|
||||||
}).then(function (r) { return r.json() }).then(function (j) {
|
}).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) {
|
if (!j.ok) {
|
||||||
probeInfo.textContent = j.message || '확인 실패'
|
probeInfo.textContent = j.message || '확인 실패'
|
||||||
|
ytProbeBtn.disabled = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var p = j.probe
|
var p = j.probe
|
||||||
@@ -94,19 +114,36 @@
|
|||||||
probeInfo.textContent = parts.join(' · ')
|
probeInfo.textContent = parts.join(' · ')
|
||||||
if (p.warnOver5min) {
|
if (p.warnOver5min) {
|
||||||
if (!window.confirm('가져오는 데 5분 이상 걸릴 수 있습니다. 진행할까요?\n(다른 페이지에서 작업해도 백그라운드로 계속 진행됩니다.)')) {
|
if (!window.confirm('가져오는 데 5분 이상 걸릴 수 있습니다. 진행할까요?\n(다른 페이지에서 작업해도 백그라운드로 계속 진행됩니다.)')) {
|
||||||
|
ytProbeBtn.disabled = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// confirm 동안에도 사용자가 입력을 바꿨을 수 있으므로 한 번 더 검증.
|
||||||
|
if (ytUrl.value.trim() !== url) return
|
||||||
if (!titleInput.value) titleInput.value = p.title
|
if (!titleInput.value) titleInput.value = p.title
|
||||||
|
// 이 URL 에 한해 확인 활성화. URL 변경 감지용으로 마지막 probe URL 저장.
|
||||||
|
lastProbedUrl = url
|
||||||
ytStartBtn.disabled = false
|
ytStartBtn.disabled = false
|
||||||
}).catch(function (e) {
|
}).catch(function (e) {
|
||||||
|
// 마찬가지로 stale 응답이면 무시 (현재 입력값에 영향 안 주게).
|
||||||
|
if (ytUrl.value.trim() !== url) return
|
||||||
probeInfo.textContent = '확인 실패: ' + e.message
|
probeInfo.textContent = '확인 실패: ' + e.message
|
||||||
|
ytProbeBtn.disabled = false
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
ytStartBtn.addEventListener('click', function () {
|
ytStartBtn.addEventListener('click', function () {
|
||||||
var url = ytUrl.value.trim()
|
var url = ytUrl.value.trim()
|
||||||
if (!url) return
|
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', {
|
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
@@ -114,31 +151,47 @@
|
|||||||
}).then(function (r) { return r.json() }).then(function (j) {
|
}).then(function (r) { return r.json() }).then(function (j) {
|
||||||
if (!j.ok) {
|
if (!j.ok) {
|
||||||
probeInfo.textContent = j.message || '시작 실패'
|
probeInfo.textContent = j.message || '시작 실패'
|
||||||
|
// 시작 실패면 재시도 가능하게 둘 다 다시 풀어줌
|
||||||
|
ytProbeBtn.disabled = false
|
||||||
|
ytStartBtn.disabled = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dlProgress.hidden = false
|
dlProgress.hidden = false
|
||||||
probeInfo.textContent = '백그라운드 다운로드 시작...'
|
probeInfo.textContent = '백그라운드 다운로드 시작...'
|
||||||
pollJob(j.jobId, j.videoId)
|
pollJob(j.jobId, j.videoId)
|
||||||
|
}).catch(function (e) {
|
||||||
|
probeInfo.textContent = '시작 실패: ' + e.message
|
||||||
|
ytProbeBtn.disabled = false
|
||||||
|
ytStartBtn.disabled = false
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function pollJob(jobId, videoId) {
|
function pollJob(jobId, videoId) {
|
||||||
fetch('/op/job/' + encodeURIComponent(jobId)).then(function (r) { return r.json() }).then(function (j) {
|
// cache: 'no-store' 로 304 가 나지 않게 강제. 304 면 body 가 비어
|
||||||
if (!j.ok) {
|
// r.json() 이 reject → 폴링 중단되는 문제 방지.
|
||||||
probeInfo.textContent = j.message || '작업을 찾을 수 없음'
|
fetch('/op/job/' + encodeURIComponent(jobId), { cache: 'no-store' })
|
||||||
return
|
.then(function (r) { return r.json() })
|
||||||
}
|
.then(function (j) {
|
||||||
var job = j.job
|
if (!j.ok) {
|
||||||
dlProgress.value = job.progress
|
probeInfo.textContent = j.message || '작업을 찾을 수 없음'
|
||||||
probeInfo.textContent = job.message
|
return
|
||||||
if (job.status === 'done') {
|
}
|
||||||
location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(videoId)
|
var job = j.job
|
||||||
} else if (job.status === 'error') {
|
dlProgress.value = job.progress
|
||||||
probeInfo.textContent = '실패: ' + (job.error || '')
|
probeInfo.textContent = job.message
|
||||||
} else {
|
if (job.status === 'done') {
|
||||||
setTimeout(function () { pollJob(jobId, videoId) }, 1500)
|
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) ─────────────────────────
|
// ── 타임라인 트림 (lossless-cut 류 핸들 UI) ─────────────────────────
|
||||||
|
|||||||
230
src/editor.ts
230
src/editor.ts
@@ -21,6 +21,164 @@ export function getFfmpegPath(): string {
|
|||||||
throw new FfmpegUnavailableError()
|
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> 로 저장한다.
|
* 원본 파일을 그대로 둔 채 trim 결과를 edited.<ext> 로 저장한다.
|
||||||
* stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩.
|
* stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩.
|
||||||
@@ -49,14 +207,26 @@ export async function applyTrimToVideo(
|
|||||||
if (endSec !== null) baseArgs.push('-to', String(endSec))
|
if (endSec !== null) baseArgs.push('-to', String(endSec))
|
||||||
baseArgs.push('-i', inputPath)
|
baseArgs.push('-i', inputPath)
|
||||||
|
|
||||||
// 시도 1: stream copy (빠름)
|
// 출력은 항상 60fps 이상이 되어야 한다.
|
||||||
const copyArgs = [...baseArgs, '-c', 'copy', '-movflags', '+faststart', tmpPath]
|
// 원본이 이미 60fps 이상이면 stream copy 로 빠르게 자르고,
|
||||||
let ok = await runFfmpeg(bin, copyArgs)
|
// 그 미만이면 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) {
|
if (!ok) {
|
||||||
// 시도 2: 재인코딩
|
// 시도 2: 재인코딩. 60fps 미만 소스는 fps=60 프레임 복제로 끌어올림 (속도 우선).
|
||||||
|
// (다운로드 단계에서 이미 60fps 로 끌어올리므로 이 경로는 직접 업로드용 안전망.)
|
||||||
|
const vfilter = needBumpFps ? `fps=${TARGET_FPS}` : null
|
||||||
const encArgs = [
|
const encArgs = [
|
||||||
...baseArgs,
|
...baseArgs,
|
||||||
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23',
|
...(vfilter ? ['-vf', vfilter] : []),
|
||||||
|
'-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23',
|
||||||
'-c:a', 'aac', '-b:a', '128k',
|
'-c:a', 'aac', '-b:a', '128k',
|
||||||
'-movflags', '+faststart',
|
'-movflags', '+faststart',
|
||||||
tmpPath
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -291,6 +291,10 @@ opRouter.post('/op/folder/:name/video/youtube/start', requireAuth, async (req, r
|
|||||||
})
|
})
|
||||||
|
|
||||||
opRouter.get('/op/job/:id', requireAuth, (req, res) => {
|
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)
|
const job = getJob(req.params.id)
|
||||||
if (!job) {
|
if (!job) {
|
||||||
res.status(404).json({ ok: false, message: '작업을 찾을 수 없습니다.' })
|
res.status(404).json({ ok: false, message: '작업을 찾을 수 없습니다.' })
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { spawn, spawnSync } from 'node:child_process'
|
|||||||
import { promises as fs } from 'node:fs'
|
import { promises as fs } from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { jobsDir, projectRoot } from './paths.js'
|
import { jobsDir, projectRoot } from './paths.js'
|
||||||
|
import { upscaleOriginalTo60Fps } from './editor.js'
|
||||||
import {
|
import {
|
||||||
loadVideoMeta,
|
loadVideoMeta,
|
||||||
newVideoId,
|
newVideoId,
|
||||||
@@ -186,13 +187,30 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
|||||||
'--no-warnings',
|
'--no-warnings',
|
||||||
'--no-playlist',
|
'--no-playlist',
|
||||||
'--newline',
|
'--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',
|
'--progress-template', 'download:PROGRESS %(progress._percent_str)s',
|
||||||
'--print', 'after_move:OUT %(filepath)s',
|
'--print', 'after_move:OUT %(filepath)s',
|
||||||
'-o', path.join(dir, 'original.%(ext)s'),
|
'-o', path.join(dir, 'original.%(ext)s'),
|
||||||
job.url
|
job.url
|
||||||
]
|
]
|
||||||
|
// 다운로드 단계는 전체 진행률의 0~50% 를 차지하고, 60fps 후처리가 50~99%.
|
||||||
|
// (yt-dlp 가 video+audio 두 스트림을 받으면 각 스트림이 0→100 % 를 반복하므로
|
||||||
|
// 단조 증가만 인정해 막대가 역행하지 않게 한다.)
|
||||||
|
let downloadPctRaw = 0
|
||||||
return new Promise<void>((resolve, reject) => {
|
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 outputFile: string | null = null
|
||||||
let stderrTail = ''
|
let stderrTail = ''
|
||||||
child.stdout.on('data', (chunk) => {
|
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/)) {
|
for (const line of text.split(/\r?\n/)) {
|
||||||
const m = /PROGRESS\s+([\d.]+)%/.exec(line)
|
const m = /PROGRESS\s+([\d.]+)%/.exec(line)
|
||||||
if (m) {
|
if (m) {
|
||||||
job.progress = Math.min(99, Math.round(Number(m[1])))
|
const pct = Number(m[1])
|
||||||
job.message = `다운로드 ${job.progress}%`
|
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())
|
const o = /^OUT\s+(.+)$/.exec(line.trim())
|
||||||
if (o) outputFile = o[1].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.'))
|
const found = entries.find((n) => n.startsWith('original.'))
|
||||||
if (found) finalName = found
|
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)
|
const meta = await loadVideoMeta(job.folder, job.videoId)
|
||||||
if (meta) {
|
if (meta) {
|
||||||
meta.originalFile = finalName
|
meta.originalFile = finalName
|
||||||
|
|||||||
Reference in New Issue
Block a user