feat(editor): timeline scrubber with draggable trim handles
영상편집기에 lossless-cut 류 트림 타임라인 추가. 숫자 입력만 있던 기존 UI 를 시각적 재생바 + 좌/우 드래그 핸들 + 플레이헤드로 교체. - 좌/우 파란 핸들을 끌어 in/out 점 설정 (pointer events 기반, 터치 지원) - 흰색 플레이헤드가 영상 재생 위치 따라감 - 타임라인 빈 공간 클릭 → 그 지점으로 시킹 - "[ 시작점" / "끝점 ]" 버튼으로 현재 시점 마크 - "선택 재생" 으로 선택구간만 미리보기, "초기화" 로 전체 선택 복원 - 기존 숫자 입력은 보조 입력으로 유지하고 상태와 양방향 동기화 저장 페이로드는 그대로 (startSec/endSec). 서버측 ffmpeg 트림 로직 변경 없음. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
168
public/editor.js
168
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 = '저장 중...'
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user