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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 선택: 0.0초
+
+
+
재생바의 파란 핸들을 끌어 구간을 정합니다. 적용하면 항목의 시작/종료 시간이 갱신되고, 다운로드 완료 후 이 구간으로 잘립니다.
+
+
+
+
+
+
+
+
+