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

View File

@@ -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; }

View File

@@ -36,18 +36,29 @@
<div id="videoPanel" class="videoPanel" <% if (!video) { %>hidden<% } %>>
<video id="editVideo" controls preload="metadata"
<% if (video) { %>src="/api/video/<%= video.id %>/file?edited=0"<% } %>></video>
<div class="trimControls">
<label>
<span>시작(초)</span>
<input type="number" id="startSec" step="0.1" min="0" value="<%= video && video.trim ? video.trim.startSec : 0 %>" />
<button type="button" class="secondaryButton" data-set-current="start">현재 시점</button>
</label>
<label>
<span>종료(초, 비우면 끝까지)</span>
<input type="number" id="endSec" step="0.1" min="0" value="<%= video && video.trim && video.trim.endSec != null ? video.trim.endSec : '' %>" />
<button type="button" class="secondaryButton" data-set-current="end">현재 시점</button>
</label>
<p class="muted">저장하면 ffmpeg 가 원본을 보존한 채 편집본을 만듭니다.</p>
<div class="trimTimeline">
<div class="trimTimelineHeader">
<span class="timeReadout" id="timeReadout">00:00.0 / 00:00.0</span>
<div class="trimTimelineActions">
<button type="button" data-mark="start" title="현재 시점을 시작점으로">[&nbsp;시작점</button>
<button type="button" data-mark="end" title="현재 시점을 끝점으로">끝점&nbsp;]</button>
<button type="button" data-action="playSelection" title="선택 구간만 재생">▶ 선택 재생</button>
<button type="button" data-action="reset" title="자르기 초기화">초기화</button>
</div>
</div>
<div class="trimBar" id="trimBar">
<div class="trimRangeFill" id="trimRangeFill"></div>
<div class="trimHandle trimHandleStart" id="trimHandleStart" data-handle="start" title="시작 핸들"></div>
<div class="trimHandle trimHandleEnd" id="trimHandleEnd" data-handle="end" title="끝 핸들"></div>
<div class="trimPlayhead" id="trimPlayhead"></div>
</div>
<div class="trimNumericRow">
<label>시작(초) <input type="number" id="startSec" step="0.1" min="0" value="<%= video && video.trim ? video.trim.startSec : 0 %>" /></label>
<span class="trimDuration" id="trimDuration">선택: 0.0초</span>
<label>끝(초, 비우면 끝까지) <input type="number" id="endSec" step="0.1" min="0" value="<%= video && video.trim && video.trim.endSec != null ? video.trim.endSec : '' %>" /></label>
</div>
<p class="muted">재생바의 양쪽 파란 핸들을 끌어 구간을 정합니다. 타임라인을 클릭하면 그 지점으로 이동. 저장하면 ffmpeg 가 원본을 보존한 채 편집본을 만듭니다.</p>
</div>
</div>
</section>