feat(playlist): per-entry/bulk trim, preview, and trim editor on import
플레이리스트 가져오기 화면에 다음을 추가: - 항목별 제목 입력을 절반으로 줄이고 오른쪽에 시작/종료 시간 입력 추가 (기본 시작 0, 종료는 영상 길이) - 정렬 옵션 옆에 일괄 시작/종료 입력과 적용/초기화 버튼 추가 (초기화 = 시작 0, 종료 = 영상 길이) - 항목 썸네일 클릭 시 YouTube 임베드로 미리보기 재생 - 항목 우클릭 → 자르기 메뉴 → /video/editor 식 타임라인 모달(YouTube IFrame) 에서 구간을 정하면 항목의 시작/종료 입력에 반영 - 백엔드: playlist/start 가 항목별 startSec/endSec 를 받아 다운로드 잡에 실어 보내고, 60fps 후처리 후 ffmpeg 로 해당 구간을 잘라 편집본 생성 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
@@ -2,13 +2,14 @@ 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 { upscaleOriginalTo60Fps, applyTrimToVideo } from './editor.js'
|
||||
import {
|
||||
createVideo,
|
||||
getFolder,
|
||||
getVideo,
|
||||
updateVideo,
|
||||
videoDiskDir
|
||||
videoDiskDir,
|
||||
type VideoTrim
|
||||
} from './storeDb.js'
|
||||
|
||||
export class YtDlpUnavailableError extends Error {
|
||||
@@ -192,6 +193,8 @@ export interface DownloadJob {
|
||||
finishedAt: string | null
|
||||
outputFile: string | null
|
||||
error: string | null
|
||||
/** 다운로드 완료 후 적용할 자르기 구간. null 이면 자르기 없음(전체). */
|
||||
trim: VideoTrim | null
|
||||
}
|
||||
|
||||
const jobs = new Map<string, DownloadJob>()
|
||||
@@ -266,6 +269,10 @@ export interface StartDownloadOpts {
|
||||
title?: string
|
||||
/** 호출자가 영상 ID 를 지정 (플레이리스트 일괄 등록용). 미지정 시 랜덤 생성. */
|
||||
videoId?: string
|
||||
/** 다운로드 후 적용할 자르기 시작 초 (기본 0). */
|
||||
startSec?: number
|
||||
/** 다운로드 후 적용할 자르기 종료 초. null/미지정 이면 끝까지. */
|
||||
endSec?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,6 +295,16 @@ export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<Dow
|
||||
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),
|
||||
@@ -300,7 +317,8 @@ export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<Dow
|
||||
startedAt: now,
|
||||
finishedAt: null,
|
||||
outputFile: null,
|
||||
error: null
|
||||
error: null,
|
||||
trim
|
||||
}
|
||||
jobs.set(job.id, job)
|
||||
void persistJob(job)
|
||||
@@ -474,6 +492,21 @@ async function runJob(job: DownloadJob, bin: string): Promise<void> {
|
||||
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 = '완료'
|
||||
|
||||
Reference in New Issue
Block a user