diff --git a/public/editor.js b/public/editor.js index b5773be..2f30fa9 100644 --- a/public/editor.js +++ b/public/editor.js @@ -141,15 +141,163 @@ }) } - // 트림 컨트롤 - "현재 시점" 버튼 - document.querySelectorAll('[data-set-current]').forEach(function (btn) { - btn.addEventListener('click', function () { - if (!editVideo) return - var which = btn.getAttribute('data-set-current') - var t = editVideo.currentTime.toFixed(2) - if (which === 'start') startSec.value = t - else endSec.value = t + // ── 타임라인 트림 (lossless-cut 류 핸들 UI) ───────────────────────── + var trimBar = document.getElementById('trimBar') + var trimHandleStart = document.getElementById('trimHandleStart') + var trimHandleEnd = document.getElementById('trimHandleEnd') + var trimRangeFill = document.getElementById('trimRangeFill') + var trimPlayhead = document.getElementById('trimPlayhead') + var timeReadout = document.getElementById('timeReadout') + var trimDuration = document.getElementById('trimDuration') + + var duration = 0 + var trimStart = video && video.trim ? Number(video.trim.startSec) || 0 : 0 + // trimEnd 가 null 이면 "끝까지" 의미 + var trimEnd = video && video.trim && video.trim.endSec != null ? Number(video.trim.endSec) : null + var MIN_SELECTION = 0.05 + + function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)) } + function effectiveEnd() { return trimEnd != null ? trimEnd : duration } + + function formatTime(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 renderTimeline() { + if (!duration || !trimBar) return + var end = effectiveEnd() + var startPct = (trimStart / duration) * 100 + var endPct = (end / duration) * 100 + trimRangeFill.style.left = startPct + '%' + trimRangeFill.style.width = Math.max(0, endPct - startPct) + '%' + trimHandleStart.style.left = startPct + '%' + trimHandleEnd.style.left = endPct + '%' + var cur = editVideo ? editVideo.currentTime : 0 + trimPlayhead.style.left = clamp((cur / duration) * 100, 0, 100) + '%' + timeReadout.textContent = formatTime(cur) + ' / ' + formatTime(duration) + trimDuration.textContent = '선택: ' + (end - trimStart).toFixed(1) + '초' + // 숫자 입력 동기화 + startSec.value = trimStart.toFixed(2) + endSec.value = trimEnd == null ? '' : trimEnd.toFixed(2) + } + + if (editVideo) { + editVideo.addEventListener('loadedmetadata', function () { + duration = editVideo.duration || 0 + // 잘못 저장돼있던 값 보정 + trimStart = clamp(trimStart, 0, Math.max(0, duration - MIN_SELECTION)) + if (trimEnd != null) trimEnd = clamp(trimEnd, trimStart + MIN_SELECTION, duration) + renderTimeline() }) + editVideo.addEventListener('timeupdate', renderTimeline) + editVideo.addEventListener('seeked', renderTimeline) + } + + function clientXtoTime(clientX) { + var rect = trimBar.getBoundingClientRect() + var ratio = clamp((clientX - rect.left) / rect.width, 0, 1) + return ratio * duration + } + + function attachHandle(handle, which) { + handle.addEventListener('pointerdown', function (e) { + if (!duration) return + e.preventDefault() + handle.setPointerCapture(e.pointerId) + handle.classList.add('dragging') + function onMove(ev) { + var t = clientXtoTime(ev.clientX) + if (which === 'start') { + trimStart = clamp(t, 0, effectiveEnd() - MIN_SELECTION) + } else { + trimEnd = clamp(t, trimStart + MIN_SELECTION, duration) + } + renderTimeline() + } + 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) + }) + } + if (trimBar) { + attachHandle(trimHandleStart, 'start') + attachHandle(trimHandleEnd, 'end') + + // 타임라인 빈 공간 클릭 → 그 지점으로 시킹 + trimBar.addEventListener('pointerdown', function (e) { + if (!duration || !editVideo) return + var tgt = e.target + if (tgt && (tgt === trimHandleStart || tgt === trimHandleEnd)) return + editVideo.currentTime = clientXtoTime(e.clientX) + }) + } + + // 시작/끝 마크 버튼 + document.querySelectorAll('[data-mark]').forEach(function (btn) { + btn.addEventListener('click', function () { + if (!editVideo || !duration) return + var t = editVideo.currentTime + if (btn.getAttribute('data-mark') === 'start') { + trimStart = clamp(t, 0, effectiveEnd() - MIN_SELECTION) + } else { + trimEnd = clamp(t, trimStart + MIN_SELECTION, duration) + } + renderTimeline() + }) + }) + + // 초기화 / 선택 재생 + var playSelectionStopHandler = null + document.querySelectorAll('[data-action]').forEach(function (btn) { + btn.addEventListener('click', function () { + var action = btn.getAttribute('data-action') + if (action === 'reset') { + trimStart = 0; trimEnd = null + renderTimeline() + } else if (action === 'playSelection') { + if (!editVideo || !duration) return + editVideo.currentTime = trimStart + editVideo.play() + if (playSelectionStopHandler) editVideo.removeEventListener('timeupdate', playSelectionStopHandler) + playSelectionStopHandler = function () { + if (editVideo.currentTime >= effectiveEnd() - 0.02) { + editVideo.pause() + editVideo.removeEventListener('timeupdate', playSelectionStopHandler) + playSelectionStopHandler = null + } + } + editVideo.addEventListener('timeupdate', playSelectionStopHandler) + } + }) + }) + + // 숫자 입력 → 상태로 반영 + startSec.addEventListener('change', function () { + var v = Number(startSec.value || 0) + if (!isFinite(v) || v < 0) v = 0 + trimStart = clamp(v, 0, duration ? effectiveEnd() - MIN_SELECTION : v) + renderTimeline() + }) + endSec.addEventListener('change', function () { + if (endSec.value === '') { + trimEnd = null + } else { + var v = Number(endSec.value) + if (!isFinite(v) || v <= trimStart) { renderTimeline(); return } + trimEnd = duration ? clamp(v, trimStart + MIN_SELECTION, duration) : v + } + renderTimeline() }) // 저장 @@ -158,8 +306,8 @@ var payload = { id: video.id, title: titleInput.value, - startSec: Number(startSec.value || 0), - endSec: endSec.value === '' ? null : Number(endSec.value) + startSec: trimStart, + endSec: trimEnd } saveBtn.disabled = true saveBtn.textContent = '저장 중...' diff --git a/public/styles.css b/public/styles.css index a1a5625..037ffee 100644 --- a/public/styles.css +++ b/public/styles.css @@ -219,18 +219,71 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content .videoPanel video { width: 100%; max-height: 60vh; background: #000; border-radius: 10px; } -.trimControls { +.trimTimeline { background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; padding: 16px; - display: flex; flex-wrap: wrap; gap: 18px; + display: flex; flex-direction: column; gap: 14px; } -.trimControls label { - display: flex; flex-direction: column; gap: 6px; font-size: 13px; - color: var(--text-muted); +.trimTimelineHeader { + display: flex; justify-content: space-between; align-items: center; + gap: 12px; flex-wrap: wrap; } -.trimControls input { - background: var(--bg); border: 1px solid var(--border); border-radius: 8px; - color: var(--text); padding: 8px 10px; font-size: 14px; width: 180px; +.timeReadout { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 14px; color: var(--text); +} +.trimTimelineActions { display: flex; gap: 6px; flex-wrap: wrap; } +.trimTimelineActions button { + background: var(--bg); border: 1px solid var(--border); color: var(--text); + padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; +} +.trimTimelineActions button:hover { background: var(--bg-alt); border-color: var(--accent); } +.trimBar { + position: relative; height: 44px; margin: 8px 14px; + background: var(--bg); border: 1px solid var(--border); border-radius: 6px; + user-select: none; touch-action: none; cursor: pointer; +} +.trimRangeFill { + position: absolute; top: 0; bottom: 0; + background: rgba(47, 129, 247, 0.22); + border-left: 2px solid var(--accent); + border-right: 2px solid var(--accent); + pointer-events: none; +} +.trimHandle { + position: absolute; top: -6px; bottom: -6px; + width: 14px; margin-left: -7px; + background: var(--accent); border-radius: 4px; + cursor: ew-resize; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 1px 4px rgba(0,0,0,0.4); + z-index: 2; +} +.trimHandle:hover, .trimHandle.dragging { background: var(--accent-hover); } +.trimHandle::before { + content: ''; width: 2px; height: 60%; background: rgba(255,255,255,0.85); + border-radius: 1px; +} +.trimPlayhead { + position: absolute; top: -4px; bottom: -4px; width: 2px; margin-left: -1px; + background: #ffffff; pointer-events: none; + box-shadow: 0 0 4px rgba(255,255,255,0.45); + z-index: 1; +} +.trimNumericRow { + display: flex; align-items: center; gap: 18px; flex-wrap: wrap; +} +.trimNumericRow label { + display: flex; align-items: center; gap: 8px; + font-size: 13px; color: var(--text-muted); +} +.trimNumericRow input { + background: var(--bg); border: 1px solid var(--border); border-radius: 6px; + color: var(--text); padding: 6px 10px; font-size: 14px; width: 110px; +} +.trimDuration { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 13px; color: var(--text-muted); } .adminFolder { position: relative; } .adminVideo { position: relative; } diff --git a/views/op/editor.ejs b/views/op/editor.ejs index 7cb04ac..c234f97 100644 --- a/views/op/editor.ejs +++ b/views/op/editor.ejs @@ -36,18 +36,29 @@
저장하면 ffmpeg 가 원본을 보존한 채 편집본을 만듭니다.
+ +재생바의 양쪽 파란 핸들을 끌어 구간을 정합니다. 타임라인을 클릭하면 그 지점으로 이동. 저장하면 ffmpeg 가 원본을 보존한 채 편집본을 만듭니다.