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:
2026-05-15 18:31:07 +09:00
parent aae58f645a
commit f587dce5ce
3 changed files with 242 additions and 30 deletions

View File

@@ -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 = '저장 중...'