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

@@ -385,7 +385,13 @@ opRouter.post(
throw new Error('등록할 항목이 없습니다.')
}
type Cleaned = { url: string; title: string; videoId: string }
type Cleaned = {
url: string
title: string
videoId: string
startSec: number
endSec: number | null
}
const cleaned: Cleaned[] = []
const seen = new Set<string>()
for (let i = 0; i < rawEntries.length; i += 1) {
@@ -405,8 +411,14 @@ opRouter.post(
if (videoIdExists(videoId)) {
throw new Error(`항목 ${i + 1}: 영상 ID "${videoId}" 가 이미 사용 중입니다.`)
}
// 자르기 구간(선택). 잘못된 값은 무시하고 전체로 처리.
const startRaw = Number(e?.startSec)
const startSec = Number.isFinite(startRaw) && startRaw > 0 ? startRaw : 0
const endRaw = Number(e?.endSec)
const endSec =
Number.isFinite(endRaw) && endRaw > startSec ? endRaw : null
seen.add(videoId)
cleaned.push({ url, title, videoId })
cleaned.push({ url, title, videoId, startSec, endSec })
}
// 검증 통과 → 순차적으로 잡 시작. createVideo 의 unique 제약이 마지막 안전망.
@@ -416,7 +428,9 @@ opRouter.post(
folderId: folder.id,
url: entry.url,
title: entry.title,
videoId: entry.videoId
videoId: entry.videoId,
startSec: entry.startSec,
endSec: entry.endSec
})
jobs.push({ jobId: job.id, videoId: job.videoId })
}

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