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:
Claude (owner)
2026-06-02 00:50:04 +09:00
parent 066ae6b112
commit 34c040c15d
5 changed files with 596 additions and 12 deletions

View File

@@ -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 = '완료'