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 = '저장 중...'
|
||||
|
||||
Reference in New Issue
Block a user