플레이리스트 가져오기 화면에 다음을 추가: - 항목별 제목 입력을 절반으로 줄이고 오른쪽에 시작/종료 시간 입력 추가 (기본 시작 0, 종료는 영상 길이) - 정렬 옵션 옆에 일괄 시작/종료 입력과 적용/초기화 버튼 추가 (초기화 = 시작 0, 종료 = 영상 길이) - 항목 썸네일 클릭 시 YouTube 임베드로 미리보기 재생 - 항목 우클릭 → 자르기 메뉴 → /video/editor 식 타임라인 모달(YouTube IFrame) 에서 구간을 정하면 항목의 시작/종료 입력에 반영 - 백엔드: playlist/start 가 항목별 startSec/endSec 를 받아 다운로드 잡에 실어 보내고, 60fps 후처리 후 ffmpeg 로 해당 구간을 잘라 편집본 생성 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
520 lines
20 KiB
TypeScript
520 lines
20 KiB
TypeScript
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, applyTrimToVideo } from './editor.js'
|
|
import {
|
|
createVideo,
|
|
getFolder,
|
|
getVideo,
|
|
updateVideo,
|
|
videoDiskDir,
|
|
type VideoTrim
|
|
} from './storeDb.js'
|
|
|
|
export class YtDlpUnavailableError extends Error {
|
|
constructor(message?: string) {
|
|
super(message || 'yt-dlp 가 설치되어 있지 않습니다. yt-dlp 를 PATH 에 설치한 뒤 다시 시도해 주세요.')
|
|
}
|
|
}
|
|
|
|
let resolvedYtDlpPath: string | null = null
|
|
|
|
export function getYtDlpPath(): string {
|
|
if (resolvedYtDlpPath) return resolvedYtDlpPath
|
|
// 1) 프로젝트 bin/yt-dlp(.exe)
|
|
const localCandidates =
|
|
process.platform === 'win32'
|
|
? ['bin/yt-dlp.exe', 'bin/yt-dlp']
|
|
: ['bin/yt-dlp']
|
|
for (const rel of localCandidates) {
|
|
const abs = path.join(projectRoot, rel)
|
|
const r = spawnSync(abs, ['--version'])
|
|
if (r.status === 0) {
|
|
resolvedYtDlpPath = abs
|
|
return abs
|
|
}
|
|
}
|
|
// 2) PATH 에서 검색
|
|
const r = spawnSync('yt-dlp', ['--version'])
|
|
if (r.status === 0) {
|
|
resolvedYtDlpPath = 'yt-dlp'
|
|
return 'yt-dlp'
|
|
}
|
|
throw new YtDlpUnavailableError()
|
|
}
|
|
|
|
export interface ProbeResult {
|
|
title: string
|
|
durationSec: number
|
|
filesizeApprox: number | null
|
|
/** 추정 다운로드 ETA (초). filesize_approx / 가정대역폭. 모를 때 null. */
|
|
etaSec: number | null
|
|
warnOver5min: boolean
|
|
}
|
|
|
|
const ASSUMED_BPS = 5 * 1024 * 1024 // 5 MB/s 가정. 실측은 어렵지만 경고 트리거용으로만 사용.
|
|
|
|
export async function probeYoutube(url: string): Promise<ProbeResult> {
|
|
const bin = getYtDlpPath()
|
|
return new Promise<ProbeResult>((resolve, reject) => {
|
|
const child = spawn(bin, [
|
|
'--no-warnings',
|
|
'--skip-download',
|
|
'--print',
|
|
'%(title)s\n%(duration)s\n%(filesize_approx)s'
|
|
].concat([url]))
|
|
let stdout = ''
|
|
let stderr = ''
|
|
child.stdout.on('data', (chunk) => (stdout += chunk.toString()))
|
|
// stderr 는 에러 메시지 끝부분만 의미가 있어 직렬화에 비례해 자란다 — 마지막 2KB 만 유지.
|
|
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-2000) })
|
|
child.on('error', (err) => reject(err))
|
|
child.on('close', (code) => {
|
|
if (code !== 0) {
|
|
reject(new Error(stderr.trim() || `yt-dlp probe 실패 (code=${code})`))
|
|
return
|
|
}
|
|
const lines = stdout.trim().split('\n')
|
|
const title = lines[0] || ''
|
|
const durationSec = Number(lines[1]) || 0
|
|
const sizeRaw = lines[2]
|
|
const filesizeApprox =
|
|
sizeRaw && sizeRaw !== 'NA' && Number.isFinite(Number(sizeRaw))
|
|
? Number(sizeRaw)
|
|
: null
|
|
const etaSec = filesizeApprox ? Math.round(filesizeApprox / ASSUMED_BPS) : null
|
|
const warnOver5min = (etaSec ?? 0) > 5 * 60 || durationSec > 60 * 60
|
|
resolve({ title, durationSec, filesizeApprox, etaSec, warnOver5min })
|
|
})
|
|
})
|
|
}
|
|
|
|
// ─── 플레이리스트 probe ────────────────────────────────────────────────────
|
|
|
|
export interface PlaylistEntry {
|
|
/** 단일 영상 시작 endpoint 에 그대로 넘기는 watch URL. */
|
|
url: string
|
|
title: string
|
|
/** 메타에 없으면 null. UI 표시용. */
|
|
durationSec: number | null
|
|
/** 미리보기용 썸네일 URL. 못 구하면 null. */
|
|
thumbnailUrl: string | null
|
|
/** 1 부터 시작하는 원래 플레이리스트 위치 — 시퀀셜 ID zero-pad 자릿수 계산용. */
|
|
originalIndex: number
|
|
}
|
|
|
|
export interface PlaylistProbeResult {
|
|
entries: PlaylistEntry[]
|
|
}
|
|
|
|
/** yt-dlp 로 플레이리스트의 각 항목 메타만 빠르게 긁어온다 (실제 다운로드 X). */
|
|
export async function probeYoutubePlaylist(url: string): Promise<PlaylistProbeResult> {
|
|
const bin = getYtDlpPath()
|
|
return new Promise<PlaylistProbeResult>((resolve, reject) => {
|
|
// --flat-playlist + --dump-json 한 줄당 한 JSON 항목.
|
|
const child = spawn(bin, [
|
|
'--no-warnings',
|
|
'--flat-playlist',
|
|
'--dump-json',
|
|
url
|
|
])
|
|
let stdout = ''
|
|
let stderr = ''
|
|
child.stdout.on('data', (chunk) => (stdout += chunk.toString()))
|
|
// stdout 은 항목별 JSON 라인이 끝에 모두 필요하므로 그대로 유지. stderr 만 캡.
|
|
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-2000) })
|
|
child.on('error', (err) => reject(err))
|
|
child.on('close', (code) => {
|
|
if (code !== 0) {
|
|
reject(new Error(stderr.trim() || `yt-dlp playlist probe 실패 (code=${code})`))
|
|
return
|
|
}
|
|
const entries: PlaylistEntry[] = []
|
|
let idx = 0
|
|
for (const line of stdout.split(/\r?\n/)) {
|
|
const trimmed = line.trim()
|
|
if (!trimmed) continue
|
|
let obj: Record<string, unknown>
|
|
try {
|
|
obj = JSON.parse(trimmed) as Record<string, unknown>
|
|
} catch {
|
|
continue
|
|
}
|
|
// 상위 _type==='playlist' 같은 컨테이너 라인이 섞이면 스킵.
|
|
if (obj._type === 'playlist') continue
|
|
idx += 1
|
|
const id = typeof obj.id === 'string' ? obj.id : null
|
|
const webpageUrl =
|
|
typeof obj.webpage_url === 'string' && obj.webpage_url ? obj.webpage_url : null
|
|
const rawUrl = typeof obj.url === 'string' && obj.url ? obj.url : null
|
|
const entryUrl =
|
|
webpageUrl ||
|
|
rawUrl ||
|
|
(id ? `https://www.youtube.com/watch?v=${id}` : null)
|
|
if (!entryUrl) continue
|
|
const title =
|
|
typeof obj.title === 'string' && obj.title.trim()
|
|
? obj.title.trim()
|
|
: `항목 ${idx}`
|
|
const dur = obj.duration
|
|
const durationSec =
|
|
typeof dur === 'number' && Number.isFinite(dur) ? Math.round(dur) : null
|
|
// 썸네일: YouTube video id 가 있으면 표준 mqdefault URL 로 만든다(목록용 320x180).
|
|
// id 가 없으면 yt-dlp 가 준 thumbnail 필드를 폴백으로 사용.
|
|
const thumbnailUrl = id
|
|
? `https://i.ytimg.com/vi/${id}/mqdefault.jpg`
|
|
: typeof obj.thumbnail === 'string' && obj.thumbnail
|
|
? obj.thumbnail
|
|
: null
|
|
entries.push({ url: entryUrl, title, durationSec, thumbnailUrl, originalIndex: idx })
|
|
}
|
|
if (entries.length === 0) {
|
|
reject(new Error('플레이리스트에서 항목을 찾지 못했습니다.'))
|
|
return
|
|
}
|
|
resolve({ entries })
|
|
})
|
|
})
|
|
}
|
|
|
|
// ─── 백그라운드 다운로드 잡 ────────────────────────────────────────────────
|
|
export type JobStatus = 'queued' | 'downloading' | 'done' | 'error'
|
|
|
|
export interface DownloadJob {
|
|
id: string
|
|
folderId: number
|
|
videoId: string
|
|
url: string
|
|
status: JobStatus
|
|
progress: number // 0..100
|
|
message: string
|
|
startedAt: string
|
|
finishedAt: string | null
|
|
outputFile: string | null
|
|
error: string | null
|
|
/** 다운로드 완료 후 적용할 자르기 구간. null 이면 자르기 없음(전체). */
|
|
trim: VideoTrim | null
|
|
}
|
|
|
|
const jobs = new Map<string, DownloadJob>()
|
|
|
|
// 동시 실행 상한. 플레이리스트 일괄 등록 시 yt-dlp+ffmpeg 가 N개 동시에 떠
|
|
// 서버가 터지는 것을 막는다. 기본 2 — 네트워크-바운드 다운로드와 CPU-바운드
|
|
// 60fps 후처리가 한 번에 한쌍 정도는 같이 진행될 수 있게.
|
|
const MAX_CONCURRENCY = (() => {
|
|
const raw = Number(process.env.MAX_DOWNLOAD_CONCURRENCY)
|
|
if (Number.isFinite(raw) && raw >= 1) return Math.floor(raw)
|
|
return 2
|
|
})()
|
|
let activeJobCount = 0
|
|
|
|
async function persistJob(job: DownloadJob): Promise<void> {
|
|
await fs.mkdir(jobsDir, { recursive: true })
|
|
await fs.writeFile(path.join(jobsDir, `${job.id}.json`), JSON.stringify(job, null, 2))
|
|
}
|
|
|
|
/**
|
|
* 대기 큐를 끌어올린다.
|
|
* - 동시 실행 슬롯이 남는 동안, 가장 먼저 등록된 `queued` 잡부터 launchJob 호출.
|
|
* - launchJob 의 finally 에서 다시 호출되어 새 슬롯을 채운다.
|
|
*/
|
|
function pump(): void {
|
|
if (activeJobCount >= MAX_CONCURRENCY) return
|
|
for (const job of jobs.values()) {
|
|
if (activeJobCount >= MAX_CONCURRENCY) return
|
|
if (job.status !== 'queued') continue
|
|
let bin: string
|
|
try {
|
|
bin = getYtDlpPath()
|
|
} catch (err) {
|
|
// yt-dlp 가 중간에 사라진 경우. 이 잡만 실패시키고 다음 잡으로 진행 (다음도 같은 사유로 실패하겠지만).
|
|
job.status = 'error'
|
|
job.error = err instanceof Error ? err.message : String(err)
|
|
job.finishedAt = new Date().toISOString()
|
|
void persistJob(job)
|
|
continue
|
|
}
|
|
launchJob(job, bin)
|
|
}
|
|
}
|
|
|
|
function launchJob(job: DownloadJob, bin: string): void {
|
|
activeJobCount += 1
|
|
runJob(job, bin)
|
|
.catch((err) => {
|
|
job.status = 'error'
|
|
job.error = err instanceof Error ? err.message : String(err)
|
|
job.finishedAt = new Date().toISOString()
|
|
void persistJob(job)
|
|
})
|
|
.finally(() => {
|
|
activeJobCount -= 1
|
|
// 비동기 완료 시점에 다음 큐 슬롯을 즉시 채운다.
|
|
pump()
|
|
})
|
|
}
|
|
|
|
export function getJob(id: string): DownloadJob | null {
|
|
return jobs.get(id) ?? null
|
|
}
|
|
|
|
export function listActiveJobs(): DownloadJob[] {
|
|
return Array.from(jobs.values()).filter((j) => j.status === 'queued' || j.status === 'downloading')
|
|
}
|
|
|
|
export interface StartDownloadOpts {
|
|
folderId: number
|
|
url: string
|
|
title?: string
|
|
/** 호출자가 영상 ID 를 지정 (플레이리스트 일괄 등록용). 미지정 시 랜덤 생성. */
|
|
videoId?: string
|
|
/** 다운로드 후 적용할 자르기 시작 초 (기본 0). */
|
|
startSec?: number
|
|
/** 다운로드 후 적용할 자르기 종료 초. null/미지정 이면 끝까지. */
|
|
endSec?: number | null
|
|
}
|
|
|
|
/**
|
|
* 백그라운드 yt-dlp 다운로드를 큐잉. videoId 도 함께 생성/저장한다.
|
|
* 실제 실행은 동시성 한도(MAX_CONCURRENCY) 안에서 pump() 가 결정한다.
|
|
*/
|
|
export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<DownloadJob> {
|
|
const folder = getFolder(opts.folderId)
|
|
if (!folder) throw new Error('폴더를 찾을 수 없습니다.')
|
|
// yt-dlp 없으면 큐잉도 안 한다. 100건 등록 후 전부 error 로 깨지는 것보다
|
|
// 즉시 endpoint 가 503 으로 거절하는 편이 낫다.
|
|
getYtDlpPath()
|
|
|
|
const video = await createVideo({
|
|
id: opts.videoId,
|
|
folderId: opts.folderId,
|
|
title: opts.title?.trim() || '제목 없음',
|
|
originalFile: 'original.%(ext)s', // 다운로드 완료 후 실제 확장자로 갱신
|
|
sourceType: 'youtube',
|
|
sourceUrl: opts.url
|
|
})
|
|
|
|
// 자르기 구간 정규화. start>0 이거나 end 가 지정된 경우에만 의미가 있다.
|
|
const startSec = Math.max(0, Number(opts.startSec) || 0)
|
|
const endSecRaw = opts.endSec == null ? null : Number(opts.endSec)
|
|
const endSec =
|
|
endSecRaw != null && Number.isFinite(endSecRaw) && endSecRaw > startSec
|
|
? endSecRaw
|
|
: null
|
|
const trim: VideoTrim | null =
|
|
startSec > 0.01 || endSec != null ? { startSec, endSec } : null
|
|
|
|
const now = new Date().toISOString()
|
|
const job: DownloadJob = {
|
|
id: 'job-' + Math.random().toString(36).slice(2, 14),
|
|
folderId: opts.folderId,
|
|
videoId: video.id,
|
|
url: opts.url,
|
|
status: 'queued',
|
|
progress: 0,
|
|
message: '대기 중',
|
|
startedAt: now,
|
|
finishedAt: null,
|
|
outputFile: null,
|
|
error: null,
|
|
trim
|
|
}
|
|
jobs.set(job.id, job)
|
|
void persistJob(job)
|
|
// 같은 tick 에서 여러 startYoutubeDownload 가 연달아 호출될 수 있으니
|
|
// pump 는 다음 tick 에 한 번만 돌아도 충분 (반복 호출이 안전하긴 함).
|
|
setImmediate(pump)
|
|
return job
|
|
}
|
|
|
|
async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
|
job.status = 'downloading'
|
|
job.message = '다운로드 시작'
|
|
await persistJob(job)
|
|
|
|
const dir = videoDiskDir(job.videoId)
|
|
// yt-dlp 가 다운로드 후 결정한 실제 파일명을 알려주도록 --print after_move:filepath
|
|
// 진행률은 --newline + --progress-template 으로 stdout 줄 단위 파싱.
|
|
const args = [
|
|
'--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 가 `bv*+ba` 로 영상/오디오 두 스트림을 순서대로 받으면 각 스트림이
|
|
// 0→100% 를 반복한다. 예전엔 `Math.max(prev, pct)` 로만 누적했는데, 그러면
|
|
// 첫 스트림이 100% 도달한 순간 매핑값이 50% 에 박혀서 두번째 스트림 진행이
|
|
// 사용자에게 안 보였다 (짧은 영상은 첫 스트림 청크 한 번에 100% 가 돼서
|
|
// "0 → 50% 한방 점프" 처럼 보이는 5/16 버그의 직접 원인).
|
|
//
|
|
// 해결: 스트림 카운트로 가중평균. pct 가 크게 떨어지면(>5%p) 새 스트림 시작.
|
|
// overall = (완료스트림수 * 100 + 현재스트림pct) / 예상스트림수 / 100
|
|
// 예상스트림수 = 2 (bv + ba). 단일 결합 포맷 `b` 폴백이면 한 스트림만
|
|
// 끝나고 끝나지만, 그땐 곧이어 ffmpeg 후처리 메시지로 점프하므로
|
|
// 사용자가 "멈췄다"고 느낄 시간이 없다.
|
|
const EXPECTED_STREAMS = 2
|
|
let streamsCompleted = 0
|
|
let lastPctSeen = -1
|
|
let currentStreamPct = 0
|
|
return new Promise<void>((resolve, reject) => {
|
|
// 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) => {
|
|
const text = chunk.toString()
|
|
for (const line of text.split(/\r?\n/)) {
|
|
const m = /PROGRESS\s+([\d.]+)%/.exec(line)
|
|
if (m) {
|
|
const pct = Number(m[1])
|
|
if (Number.isFinite(pct)) {
|
|
const clamped = Math.max(0, Math.min(100, pct))
|
|
if (lastPctSeen >= 0 && clamped + 5 < lastPctSeen) {
|
|
// 새 스트림 시작 (이전 스트림이 100 근처에서 다시 0~ 으로 떨어짐)
|
|
streamsCompleted += 1
|
|
currentStreamPct = clamped
|
|
} else {
|
|
// 단조 증가만 인정 (같은 스트림 내부)
|
|
currentStreamPct = Math.max(currentStreamPct, clamped)
|
|
}
|
|
lastPctSeen = clamped
|
|
const totalUnits = Math.min(
|
|
EXPECTED_STREAMS * 100,
|
|
streamsCompleted * 100 + currentStreamPct
|
|
)
|
|
const phasePct = (totalUnits / (EXPECTED_STREAMS * 100)) * 100
|
|
const mapped = Math.round(phasePct * 0.5) // 0..50
|
|
if (mapped > job.progress) job.progress = mapped
|
|
job.message = `다운로드 ${Math.round(phasePct)}%`
|
|
}
|
|
}
|
|
const o = /^OUT\s+(.+)$/.exec(line.trim())
|
|
if (o) outputFile = o[1].trim()
|
|
}
|
|
})
|
|
child.stderr.on('data', (chunk) => {
|
|
stderrTail = (stderrTail + chunk.toString()).slice(-2000)
|
|
})
|
|
child.on('error', (err) => reject(err))
|
|
child.on('close', async (code) => {
|
|
if (code !== 0) {
|
|
reject(new Error(stderrTail.trim() || `yt-dlp 실패 (code=${code})`))
|
|
return
|
|
}
|
|
// 실제 파일명 결정
|
|
let finalName = 'original'
|
|
if (outputFile) {
|
|
finalName = path.basename(outputFile)
|
|
} else {
|
|
// fallback: 디렉토리 내 original.* 찾기
|
|
const entries = await fs.readdir(dir).catch(() => [])
|
|
const found = entries.find((n) => n.startsWith('original.'))
|
|
if (found) finalName = found
|
|
}
|
|
|
|
// 사용자가 "원본도 60fps 로" 요청 — yt-dlp 가 fps 1순위로 잡아도 60fps 자체가
|
|
// 없는 영상이 있으니, 다운로드 후 원본을 ffprobe 로 확인해 <60fps 면
|
|
// 프레임 복제로 60fps 까지 끌어올린다. 실패해도 원본은 그대로 둠.
|
|
//
|
|
// 5/16 버그 ("60fps 변환 확인 중에서 하루종일 멈춰있어") 대응:
|
|
// - ffmpeg `-progress pipe:1` 이 영상 길이를 모르거나 짧은 영상이 1초 안에
|
|
// 끝나면 onProgress 가 한 번도 안 불려 progress 가 50% 에 박혀 있었다.
|
|
// - 사용자 입장에선 "멈춤" 으로 보이므로, ffmpeg 진행률과 무관하게
|
|
// 매 2초마다 message 에 경과시간을 박아 화면이 살아있다는 신호를 준다.
|
|
// (progress 막대 자체는 실제 데이터가 들어올 때만 움직임 — 거짓 증가 X.)
|
|
try {
|
|
const phaseStartedAt = Date.now()
|
|
job.progress = 50
|
|
job.message = '60fps/FHD 변환 시작'
|
|
await persistJob(job)
|
|
// ffmpeg 진행률은 매우 자주 들어오므로 디스크 persist 는 throttle.
|
|
let lastPersistAt = 0
|
|
let lastBumpPct = 0
|
|
let realProgressSeen = false
|
|
const heartbeat = setInterval(() => {
|
|
if (realProgressSeen) return
|
|
const elapsed = Math.floor((Date.now() - phaseStartedAt) / 1000)
|
|
job.message = `60fps/FHD 변환 중 (${elapsed}s 경과)`
|
|
}, 2000)
|
|
let bumped: string
|
|
try {
|
|
bumped = await upscaleOriginalTo60Fps(dir, finalName, (pct) => {
|
|
if (pct <= lastBumpPct) return
|
|
realProgressSeen = true
|
|
lastBumpPct = pct
|
|
const mapped = 50 + Math.round((pct / 100) * 49) // 50..99
|
|
if (mapped > job.progress) job.progress = mapped
|
|
job.message = `60fps/FHD 변환 ${Math.round(pct)}%`
|
|
const now = Date.now()
|
|
if (now - lastPersistAt > 2000) {
|
|
lastPersistAt = now
|
|
void persistJob(job)
|
|
}
|
|
})
|
|
} finally {
|
|
clearInterval(heartbeat)
|
|
}
|
|
if (bumped !== finalName) {
|
|
job.message = '60fps/FHD 변환 완료'
|
|
finalName = bumped
|
|
} else {
|
|
// upscaleOriginalTo60Fps 가 inputName 을 그대로 돌려준 경우는
|
|
// (a) 이미 60fps + FHD 이하라 변환 자체가 필요 없거나
|
|
// (b) ffmpeg 없거나 (c) 인코딩 실패. 어느 경우든 후처리는 끝났다고 보고
|
|
// 진행률만 99 까지 채운다.
|
|
job.progress = 99
|
|
}
|
|
await persistJob(job)
|
|
} catch (err) {
|
|
// 후처리 실패는 다운로드 자체를 실패시키지 않는다. 원본 보존이 우선.
|
|
console.error('[youtube] 60fps 후처리 실패:', err)
|
|
}
|
|
|
|
const meta = getVideo(job.videoId)
|
|
if (meta) {
|
|
updateVideo(job.videoId, { originalFile: finalName })
|
|
}
|
|
|
|
// 사용자가 지정한 자르기 구간이 있으면 다운로드/60fps 후처리가 끝난 원본에
|
|
// ffmpeg 로 적용해 edited.<ext> 를 만든다. 실패해도 원본은 그대로 두고
|
|
// 다운로드 자체는 성공으로 본다 (썸네일 후처리와 동일한 정책).
|
|
if (job.trim) {
|
|
try {
|
|
job.message = '자르기 적용 중'
|
|
await persistJob(job)
|
|
await applyTrimToVideo(job.videoId, job.trim)
|
|
job.message = '자르기 완료'
|
|
} catch (err) {
|
|
console.error('[youtube] 자르기 적용 실패:', err)
|
|
}
|
|
}
|
|
|
|
job.progress = 100
|
|
job.status = 'done'
|
|
job.message = '완료'
|
|
job.outputFile = finalName
|
|
job.finishedAt = new Date().toISOString()
|
|
await persistJob(job)
|
|
resolve()
|
|
})
|
|
})
|
|
}
|