diff --git a/public/playlist.js b/public/playlist.js index c952bad..74dd475 100644 --- a/public/playlist.js +++ b/public/playlist.js @@ -19,7 +19,9 @@ var ID_RE = /^[a-zA-Z0-9_-]{1,64}$/ // ─── state ──────────────────────────────────────────────────────────── - // entries: [{ url, title, videoId, durationSec, idStatus:'pending'|'ok'|'bad', idReason:string, idDebounce:number }] + // entries: [{ url, title, videoId, durationSec, thumbnailUrl, ytId, + // startSec:number(기본0), endSec:number|null(null=끝까지), + // idStatus:'pending'|'ok'|'bad', idReason:string, idDebounce:number }] var state = { entries: [], idMode: 'random', // 'random' | 'sequential' @@ -175,7 +177,7 @@ li.innerHTML = '' + '' + '' + (idx + 1) + '' - + '
' + + '
' + '
' + '' + '
' @@ -183,12 +185,18 @@ + '' + '
' + '
' + + '
' + + '' + + '' + + '
' + '' + '' var thumbBox = li.querySelector('.entryThumb') var titleInput = li.querySelector('.entryTitleInput') var idInput = li.querySelector('.entryIdInput') + var startInput = li.querySelector('.entryStartInput') + var endInput = li.querySelector('.entryEndInput') var durSpan = li.querySelector('.entryDuration') var removeBtn = li.querySelector('.entryRemove') @@ -207,6 +215,7 @@ titleInput.value = entry.title || '' idInput.value = entry.videoId || '' durSpan.textContent = formatDuration(entry.durationSec) + paintTrimInputs(idx, startInput, endInput) titleInput.addEventListener('input', function () { state.entries[idx].title = titleInput.value @@ -217,6 +226,28 @@ // 요청 내 중복은 다른 행에도 영향 → 가시적으로 갱신 revalidateOtherIds(idx) }) + startInput.addEventListener('change', function () { + var e = state.entries[idx] + var sec = parseClock(startInput.value) + e.startSec = sec != null && sec > 0 ? sec : 0 + // 시작이 종료 이상이면 종료를 풀어 끝까지로. + if (e.endSec != null && e.endSec <= e.startSec) e.endSec = null + paintTrimInputs(idx, startInput, endInput) + }) + endInput.addEventListener('change', function () { + var e = state.entries[idx] + var sec = parseClock(endInput.value) + e.endSec = normalizeEnd(sec, e.startSec, e.durationSec) + paintTrimInputs(idx, startInput, endInput) + }) + thumbBox.addEventListener('click', function () { openPreview(idx) }) + li.addEventListener('contextmenu', function (ev) { + // input/버튼 위에서의 우클릭은 기본 메뉴를 살려 둔다 (텍스트 편집 편의). + var t = ev.target + if (t && (t.tagName === 'INPUT' || t.tagName === 'BUTTON')) return + ev.preventDefault() + openEntryCtxMenu(idx, ev.clientX, ev.clientY) + }) removeBtn.addEventListener('click', function () { state.entries.splice(idx, 1) // sequential 이면 번호 재계산 @@ -229,6 +260,34 @@ return li } + // 항목의 시작/종료 입력칸 값을 상태로부터 다시 그린다. + // 종료는 endSec 이 null 이면 영상 길이(기본값)를 표시한다. + function paintTrimInputs(idx, startInput, endInput) { + var e = state.entries[idx] + if (!e) return + if (!startInput) { + var row = findRow(idx) + if (!row) return + startInput = row.querySelector('.entryStartInput') + endInput = row.querySelector('.entryEndInput') + if (!startInput || !endInput) return + } + startInput.value = formatClock(e.startSec || 0) + if (e.endSec != null) { + endInput.value = formatClock(e.endSec) + } else { + endInput.value = e.durationSec != null ? formatClock(e.durationSec) : '' + } + } + + function findRow(idx) { + var rows = entryList.children + for (var i = 0; i < rows.length; i++) { + if (Number(rows[i].dataset.idx) === idx) return rows[i] + } + return null + } + function paintEntryStatus(idx) { var rows = entryList.children for (var i = 0; i < rows.length; i++) { @@ -379,7 +438,10 @@ title: e.title || '제목 없음', durationSec: e.durationSec, thumbnailUrl: e.thumbnailUrl || null, + ytId: extractYouTubeId(e.url), videoId: '', + startSec: 0, + endSec: null, idStatus: 'pending', idReason: '' } @@ -405,7 +467,9 @@ return { url: e.url, title: (e.title || '').trim(), - videoId: (e.videoId || '').trim() + videoId: (e.videoId || '').trim(), + startSec: e.startSec || 0, + endSec: e.endSec != null ? e.endSec : null } }) } @@ -441,6 +505,370 @@ }) }) + // ─── 시간 파싱/표시 + YouTube id 추출 ────────────────────────────────── + // "ss" / "mm:ss" / "hh:mm:ss" / 소수 초 를 모두 받아 초(number)로. 빈칸/오류는 null. + function parseClock(str) { + if (str == null) return null + str = String(str).trim() + if (!str) return null + var total = 0 + if (str.indexOf(':') >= 0) { + var parts = str.split(':') + for (var i = 0; i < parts.length; i++) { + var n = Number(parts[i]) + if (!isFinite(n) || n < 0) return null + total = total * 60 + n + } + } else { + var v = Number(str) + if (!isFinite(v) || v < 0) return null + total = v + } + return total + } + // 입력칸 표시용. formatDuration 과 동일 포맷(mm:ss / h:mm:ss). + function formatClock(sec) { + if (sec == null || !isFinite(sec)) return '' + return formatDuration(sec) + } + function extractYouTubeId(url) { + if (!url) return null + var m = String(url).match( + /(?:youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|shorts\/|v\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/ + ) + return m ? m[1] : null + } + // 종료초 정규화: null/오류/시작이하/영상끝 이상 → null(끝까지), 그 외 number. + function normalizeEnd(sec, startSec, durationSec) { + if (sec == null || !isFinite(sec)) return null + if (sec <= (startSec || 0)) return null + if (durationSec != null && sec >= durationSec - 0.01) return null + return sec + } + function clampNum(v, lo, hi) { return Math.min(hi, Math.max(lo, v)) } + + // ─── 일괄 시작/종료 적용 + 초기화 ────────────────────────────────────── + var bulkStart = document.getElementById('bulkStart') + var bulkEnd = document.getElementById('bulkEnd') + var bulkApplyBtn = document.getElementById('bulkApplyBtn') + var bulkResetBtn = document.getElementById('bulkResetBtn') + + bulkApplyBtn.addEventListener('click', function () { + var s = parseClock(bulkStart.value) + var startSec = s != null && s > 0 ? s : 0 + var endRaw = parseClock(bulkEnd.value) // 빈칸이면 null → 끝까지 + for (var i = 0; i < state.entries.length; i++) { + var e = state.entries[i] + e.startSec = startSec + e.endSec = normalizeEnd(endRaw, startSec, e.durationSec) + } + render() + }) + bulkResetBtn.addEventListener('click', function () { + for (var i = 0; i < state.entries.length; i++) { + state.entries[i].startSec = 0 + state.entries[i].endSec = null // 끝까지(=영상 길이) + } + bulkStart.value = '' + bulkEnd.value = '' + render() + }) + + // ─── YouTube IFrame API 준비 ─────────────────────────────────────────── + var ytReady = false + var ytReadyCbs = [] + window.onYouTubeIframeAPIReady = function () { + ytReady = true + var cbs = ytReadyCbs; ytReadyCbs = [] + cbs.forEach(function (cb) { cb() }) + } + function whenYt(cb) { + if (ytReady && window.YT && window.YT.Player) cb() + else ytReadyCbs.push(cb) + } + function resetHost(wrap, id) { + if (!wrap) return + wrap.innerHTML = '
' + } + + // ─── 미리보기 재생 모달 ──────────────────────────────────────────────── + var previewOverlay = document.getElementById('previewOverlay') + var previewClose = document.getElementById('previewClose') + var previewTitle = document.getElementById('previewTitle') + var previewPlayer = null + + function openPreview(idx) { + var e = state.entries[idx] + if (!e) return + if (!e.ytId) { + setRegisterStatus('이 항목은 YouTube 영상이 아니라 미리보기를 지원하지 않습니다.', 'idStatusBad') + return + } + previewTitle.textContent = e.title || '' + previewOverlay.hidden = false + whenYt(function () { + if (previewOverlay.hidden) return // API 준비 전에 닫혔으면 무시 + destroyPreview() + resetHost(previewOverlay.querySelector('.ytEmbedWrap'), 'previewPlayer') + previewPlayer = new YT.Player('previewPlayer', { + videoId: e.ytId, + playerVars: { autoplay: 1, rel: 0, modestbranding: 1, start: Math.floor(e.startSec || 0) } + }) + }) + } + function destroyPreview() { + if (previewPlayer) { try { previewPlayer.destroy() } catch (_) {} previewPlayer = null } + } + function closePreview() { + previewOverlay.hidden = true + destroyPreview() + } + previewClose.addEventListener('click', closePreview) + previewOverlay.addEventListener('click', function (e) { + if (e.target === previewOverlay) closePreview() + }) + + // ─── 우클릭 컨텍스트 메뉴 (자르기) ───────────────────────────────────── + var entryCtxMenu = document.getElementById('entryCtxMenu') + var ctxIdx = -1 + function openEntryCtxMenu(idx, x, y) { + ctxIdx = idx + entryCtxMenu.hidden = false + // 화면 밖으로 넘치지 않게 살짝 보정 + var mw = 180, mh = 60 + entryCtxMenu.style.left = Math.min(x, window.innerWidth - mw) + 'px' + entryCtxMenu.style.top = Math.min(y, window.innerHeight - mh) + 'px' + } + function closeCtxMenu() { entryCtxMenu.hidden = true } + document.addEventListener('click', closeCtxMenu) + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { closeCtxMenu(); closePreview(); closeTrim() } + }) + entryCtxMenu.querySelector('[data-action="trim"]').addEventListener('click', function () { + var idx = ctxIdx + closeCtxMenu() + if (idx >= 0) openTrim(idx) + }) + + // ─── 자르기(트림) 모달 — YouTube 임베드 + 타임라인 ───────────────────── + var trimOverlay = document.getElementById('trimOverlay') + var trimClose = document.getElementById('trimClose') + var trimCancel = document.getElementById('trimCancel') + var trimApply = document.getElementById('trimApply') + var trimTitle = document.getElementById('trimTitle') + var plTrimBar = document.getElementById('plTrimBar') + var plTrimRangeFill = document.getElementById('plTrimRangeFill') + var plTrimHandleStart = document.getElementById('plTrimHandleStart') + var plTrimHandleEnd = document.getElementById('plTrimHandleEnd') + var plTrimPlayhead = document.getElementById('plTrimPlayhead') + var plTimeReadout = document.getElementById('plTimeReadout') + var plTrimDuration = document.getElementById('plTrimDuration') + var plStartSec = document.getElementById('plStartSec') + var plEndSec = document.getElementById('plEndSec') + + var MIN_SEL = 0.05 + var trimPlayer = null + var trimPoll = null + var selStopAt = null // 선택 재생 정지 지점(초). null 이면 비활성. + // 현재 편집 중 컨텍스트: { idx, duration, start, end(null=끝까지) } + var tctx = null + + function tEnd() { return tctx.end != null ? tctx.end : tctx.duration } + + function formatTimeF(sec) { + if (!isFinite(sec) || sec < 0) sec = 0 + var m = Math.floor(sec / 60) + var s = sec - m * 60 + var sStr = s.toFixed(1) + if (s < 10) sStr = '0' + sStr + return (m < 10 ? '0' + m : '' + m) + ':' + sStr + } + + function renderTrimBar() { + if (!tctx) return + var d = tctx.duration || 0 + var end = tEnd() + var startPct = d ? (tctx.start / d) * 100 : 0 + var endPct = d ? (end / d) * 100 : 0 + plTrimRangeFill.style.left = startPct + '%' + plTrimRangeFill.style.width = Math.max(0, endPct - startPct) + '%' + plTrimHandleStart.style.left = startPct + '%' + plTrimHandleEnd.style.left = endPct + '%' + plTrimDuration.textContent = '선택: ' + (end - tctx.start).toFixed(1) + '초' + plStartSec.value = (tctx.start || 0).toFixed(2) + plEndSec.value = tctx.end == null ? '' : tctx.end.toFixed(2) + } + function renderPlayhead() { + if (!tctx) return + var d = tctx.duration || 0 + var cur = 0 + if (trimPlayer && trimPlayer.getCurrentTime) { + try { cur = trimPlayer.getCurrentTime() || 0 } catch (_) { cur = 0 } + } + plTrimPlayhead.style.left = (d ? clampNum((cur / d) * 100, 0, 100) : 0) + '%' + plTimeReadout.textContent = formatTimeF(cur) + ' / ' + formatTimeF(d) + if (selStopAt != null && cur >= selStopAt - 0.05) { + try { trimPlayer.pauseVideo() } catch (_) {} + selStopAt = null + } + } + + function openTrim(idx) { + var e = state.entries[idx] + if (!e) return + if (!e.ytId) { + setRegisterStatus('이 항목은 YouTube 영상이 아니라 자르기를 지원하지 않습니다.', 'idStatusBad') + return + } + trimTitle.textContent = e.title || '' + trimOverlay.hidden = false + tctx = { + idx: idx, + duration: e.durationSec || 0, + start: e.startSec || 0, + end: e.endSec != null ? e.endSec : null + } + selStopAt = null + renderTrimBar() + renderPlayhead() + whenYt(function () { + if (trimOverlay.hidden) return // API 준비 전에 닫혔으면 무시 + destroyTrimPlayer() + resetHost(trimOverlay.querySelector('.ytEmbedWrap'), 'trimPlayer') + trimPlayer = new YT.Player('trimPlayer', { + videoId: e.ytId, + playerVars: { rel: 0, modestbranding: 1, start: Math.floor(tctx.start || 0) }, + events: { + onReady: function () { + var d = 0 + try { d = trimPlayer.getDuration() } catch (_) {} + if (d && isFinite(d) && d > 0) { + tctx.duration = d + tctx.start = clampNum(tctx.start, 0, Math.max(0, d - MIN_SEL)) + if (tctx.end != null) tctx.end = clampNum(tctx.end, tctx.start + MIN_SEL, d) + renderTrimBar() + } + startTrimPoll() + } + } + }) + }) + } + function startTrimPoll() { + if (trimPoll) clearInterval(trimPoll) + trimPoll = setInterval(renderPlayhead, 200) + } + function destroyTrimPlayer() { + if (trimPoll) { clearInterval(trimPoll); trimPoll = null } + if (trimPlayer) { try { trimPlayer.destroy() } catch (_) {} trimPlayer = null } + } + function closeTrim() { + trimOverlay.hidden = true + selStopAt = null + destroyTrimPlayer() + tctx = null + } + trimClose.addEventListener('click', closeTrim) + trimCancel.addEventListener('click', closeTrim) + trimOverlay.addEventListener('click', function (e) { + if (e.target === trimOverlay) closeTrim() + }) + trimApply.addEventListener('click', function () { + if (!tctx) return + var e = state.entries[tctx.idx] + if (e) { + e.startSec = tctx.start > 0 ? tctx.start : 0 + e.endSec = normalizeEnd(tctx.end, e.startSec, tctx.duration) + if (tctx.duration && e.durationSec == null) e.durationSec = tctx.duration + paintTrimInputs(tctx.idx) + } + closeTrim() + }) + + // 타임라인 드래그/클릭/마크/숫자입력 + function trimClientXtoTime(clientX) { + var rect = plTrimBar.getBoundingClientRect() + var ratio = clampNum((clientX - rect.left) / rect.width, 0, 1) + return ratio * (tctx ? tctx.duration : 0) + } + function attachTrimHandle(handle, which) { + handle.addEventListener('pointerdown', function (e) { + if (!tctx || !tctx.duration) return + e.preventDefault() + handle.setPointerCapture(e.pointerId) + handle.classList.add('dragging') + function onMove(ev) { + var t = trimClientXtoTime(ev.clientX) + if (which === 'start') tctx.start = clampNum(t, 0, tEnd() - MIN_SEL) + else tctx.end = clampNum(t, tctx.start + MIN_SEL, tctx.duration) + renderTrimBar() + } + function onUp() { + handle.classList.remove('dragging') + handle.removeEventListener('pointermove', onMove) + handle.removeEventListener('pointerup', onUp) + handle.removeEventListener('pointercancel', onUp) + } + handle.addEventListener('pointermove', onMove) + handle.addEventListener('pointerup', onUp) + handle.addEventListener('pointercancel', onUp) + }) + } + attachTrimHandle(plTrimHandleStart, 'start') + attachTrimHandle(plTrimHandleEnd, 'end') + plTrimBar.addEventListener('pointerdown', function (e) { + if (!tctx || !tctx.duration) return + if (e.target === plTrimHandleStart || e.target === plTrimHandleEnd) return + var t = trimClientXtoTime(e.clientX) + if (trimPlayer && trimPlayer.seekTo) { try { trimPlayer.seekTo(t, true) } catch (_) {} } + renderPlayhead() + }) + trimOverlay.querySelectorAll('[data-pmark]').forEach(function (btn) { + btn.addEventListener('click', function () { + if (!tctx || !tctx.duration || !trimPlayer) return + var t = 0 + try { t = trimPlayer.getCurrentTime() || 0 } catch (_) {} + if (btn.getAttribute('data-pmark') === 'start') tctx.start = clampNum(t, 0, tEnd() - MIN_SEL) + else tctx.end = clampNum(t, tctx.start + MIN_SEL, tctx.duration) + renderTrimBar() + }) + }) + trimOverlay.querySelectorAll('[data-paction]').forEach(function (btn) { + btn.addEventListener('click', function () { + var action = btn.getAttribute('data-paction') + if (action === 'reset') { + if (!tctx) return + tctx.start = 0; tctx.end = null + renderTrimBar() + } else if (action === 'playSelection') { + if (!tctx || !trimPlayer) return + try { + trimPlayer.seekTo(tctx.start, true) + trimPlayer.playVideo() + } catch (_) {} + selStopAt = tEnd() + } + }) + }) + plStartSec.addEventListener('change', function () { + if (!tctx) return + var v = Number(plStartSec.value || 0) + if (!isFinite(v) || v < 0) v = 0 + tctx.start = tctx.duration ? clampNum(v, 0, tEnd() - MIN_SEL) : v + renderTrimBar() + }) + plEndSec.addEventListener('change', function () { + if (!tctx) return + if (plEndSec.value === '') { + tctx.end = null + } else { + var v = Number(plEndSec.value) + if (!isFinite(v) || v <= tctx.start) { renderTrimBar(); return } + tctx.end = tctx.duration ? clampNum(v, tctx.start + MIN_SEL, tctx.duration) : v + } + renderTrimBar() + }) + // 초기 상태 updateRegisterEnable() })() diff --git a/public/styles.css b/public/styles.css index 37d1ee2..fbe7443 100644 --- a/public/styles.css +++ b/public/styles.css @@ -363,7 +363,7 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content } .playlistEntryRow { display: grid; - grid-template-columns: 26px 36px 96px 1fr 70px 28px; + grid-template-columns: 24px 28px 90px minmax(0, 1fr) 210px 60px 26px; align-items: center; gap: 10px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; @@ -378,10 +378,18 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content color: var(--text-muted); font-size: 13px; text-align: right; } .entryThumb { - width: 96px; aspect-ratio: 16 / 9; - border-radius: 6px; overflow: hidden; + width: 90px; aspect-ratio: 16 / 9; + border-radius: 6px; overflow: hidden; position: relative; background: var(--bg); border: 1px solid var(--border); + cursor: pointer; } +.entryThumb::after { + content: '▶'; position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; + color: #fff; font-size: 18px; text-shadow: 0 1px 4px rgba(0,0,0,0.7); + opacity: 0; transition: opacity 120ms ease; pointer-events: none; +} +.entryThumb:hover::after { opacity: 1; } .entryThumb img { width: 100%; height: 100%; object-fit: cover; display: block; } .entryThumbEmpty { opacity: 0.6; } .entryFields { @@ -412,6 +420,49 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content } .entryRemove:hover { color: #e57373; border-color: #e57373; } +/* 항목별 시작/종료 시간 입력 (제목 오른쪽 절반 공간) */ +.entryTrim { + display: flex; flex-direction: column; gap: 4px; +} +.entryTrim label { + display: flex; align-items: center; gap: 6px; + font-size: 12px; color: var(--text-muted); +} +.entryTrim input { + flex: 1; min-width: 0; + background: var(--bg); border: 1px solid var(--border); border-radius: 6px; + color: var(--text); padding: 3px 6px; font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +/* 일괄 시작/종료 + 적용/초기화 */ +.bulkTrimBox { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + background: var(--bg-card); border: 1px solid var(--border); + border-radius: 10px; padding: 10px 14px; +} +.bulkTrimBox label { + display: flex; align-items: center; gap: 6px; + color: var(--text); font-size: 13px; +} +.bulkTrimBox input { + width: 70px; + background: var(--bg); border: 1px solid var(--border); border-radius: 6px; + color: var(--text); padding: 4px 8px; font-size: 13px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +/* YouTube 임베드 (미리보기 / 자르기 모달) */ +.ytEmbedWrap { + position: relative; width: 100%; aspect-ratio: 16 / 9; + background: #000; border-radius: 8px; overflow: hidden; +} +.ytEmbedWrap iframe, .ytEmbedWrap > div { + position: absolute; inset: 0; width: 100%; height: 100%; border: 0; +} +.trimModal { width: min(900px, 94vw); gap: 12px; } +.trimModal .trimTimeline { background: var(--bg-alt); } + .playlistFooter { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 8px; diff --git a/src/routes/op.ts b/src/routes/op.ts index df14ef8..9ed34d7 100644 --- a/src/routes/op.ts +++ b/src/routes/op.ts @@ -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() 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 }) } diff --git a/src/youtube.ts b/src/youtube.ts index 81856d6..e2b8939 100644 --- a/src/youtube.ts +++ b/src/youtube.ts @@ -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() @@ -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): Promise0 이거나 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 { if (meta) { updateVideo(job.videoId, { originalFile: finalName }) } + + // 사용자가 지정한 자르기 구간이 있으면 다운로드/60fps 후처리가 끝난 원본에 + // ffmpeg 로 적용해 edited. 를 만든다. 실패해도 원본은 그대로 두고 + // 다운로드 자체는 성공으로 본다 (썸네일 후처리와 동일한 정책). + 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 = '완료' diff --git a/views/op/playlist.ejs b/views/op/playlist.ejs index c398871..d4dabed 100644 --- a/views/op/playlist.ejs +++ b/views/op/playlist.ejs @@ -39,6 +39,13 @@ 자릿수 채우기 (예: 001) +
+ 일괄 구간 + + + + +
0 항목
@@ -53,12 +60,63 @@ + + + + + + + + + +