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 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 트림 컨트롤 - "현재 시점" 버튼
|
// ── 타임라인 트림 (lossless-cut 류 핸들 UI) ─────────────────────────
|
||||||
document.querySelectorAll('[data-set-current]').forEach(function (btn) {
|
var trimBar = document.getElementById('trimBar')
|
||||||
btn.addEventListener('click', function () {
|
var trimHandleStart = document.getElementById('trimHandleStart')
|
||||||
if (!editVideo) return
|
var trimHandleEnd = document.getElementById('trimHandleEnd')
|
||||||
var which = btn.getAttribute('data-set-current')
|
var trimRangeFill = document.getElementById('trimRangeFill')
|
||||||
var t = editVideo.currentTime.toFixed(2)
|
var trimPlayhead = document.getElementById('trimPlayhead')
|
||||||
if (which === 'start') startSec.value = t
|
var timeReadout = document.getElementById('timeReadout')
|
||||||
else endSec.value = t
|
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 = {
|
var payload = {
|
||||||
id: video.id,
|
id: video.id,
|
||||||
title: titleInput.value,
|
title: titleInput.value,
|
||||||
startSec: Number(startSec.value || 0),
|
startSec: trimStart,
|
||||||
endSec: endSec.value === '' ? null : Number(endSec.value)
|
endSec: trimEnd
|
||||||
}
|
}
|
||||||
saveBtn.disabled = true
|
saveBtn.disabled = true
|
||||||
saveBtn.textContent = '저장 중...'
|
saveBtn.textContent = '저장 중...'
|
||||||
|
|||||||
@@ -219,18 +219,71 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
|
|||||||
.videoPanel video {
|
.videoPanel video {
|
||||||
width: 100%; max-height: 60vh; background: #000; border-radius: 10px;
|
width: 100%; max-height: 60vh; background: #000; border-radius: 10px;
|
||||||
}
|
}
|
||||||
.trimControls {
|
.trimTimeline {
|
||||||
background: var(--bg-card); border: 1px solid var(--border);
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
border-radius: 10px; padding: 16px;
|
border-radius: 10px; padding: 16px;
|
||||||
display: flex; flex-wrap: wrap; gap: 18px;
|
display: flex; flex-direction: column; gap: 14px;
|
||||||
}
|
}
|
||||||
.trimControls label {
|
.trimTimelineHeader {
|
||||||
display: flex; flex-direction: column; gap: 6px; font-size: 13px;
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
color: var(--text-muted);
|
gap: 12px; flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.trimControls input {
|
.timeReadout {
|
||||||
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
color: var(--text); padding: 8px 10px; font-size: 14px; width: 180px;
|
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; }
|
.adminFolder { position: relative; }
|
||||||
.adminVideo { position: relative; }
|
.adminVideo { position: relative; }
|
||||||
|
|||||||
@@ -36,18 +36,29 @@
|
|||||||
<div id="videoPanel" class="videoPanel" <% if (!video) { %>hidden<% } %>>
|
<div id="videoPanel" class="videoPanel" <% if (!video) { %>hidden<% } %>>
|
||||||
<video id="editVideo" controls preload="metadata"
|
<video id="editVideo" controls preload="metadata"
|
||||||
<% if (video) { %>src="/api/video/<%= video.id %>/file?edited=0"<% } %>></video>
|
<% if (video) { %>src="/api/video/<%= video.id %>/file?edited=0"<% } %>></video>
|
||||||
<div class="trimControls">
|
|
||||||
<label>
|
<div class="trimTimeline">
|
||||||
<span>시작(초)</span>
|
<div class="trimTimelineHeader">
|
||||||
<input type="number" id="startSec" step="0.1" min="0" value="<%= video && video.trim ? video.trim.startSec : 0 %>" />
|
<span class="timeReadout" id="timeReadout">00:00.0 / 00:00.0</span>
|
||||||
<button type="button" class="secondaryButton" data-set-current="start">현재 시점</button>
|
<div class="trimTimelineActions">
|
||||||
</label>
|
<button type="button" data-mark="start" title="현재 시점을 시작점으로">[ 시작점</button>
|
||||||
<label>
|
<button type="button" data-mark="end" title="현재 시점을 끝점으로">끝점 ]</button>
|
||||||
<span>종료(초, 비우면 끝까지)</span>
|
<button type="button" data-action="playSelection" title="선택 구간만 재생">▶ 선택 재생</button>
|
||||||
<input type="number" id="endSec" step="0.1" min="0" value="<%= video && video.trim && video.trim.endSec != null ? video.trim.endSec : '' %>" />
|
<button type="button" data-action="reset" title="자르기 초기화">초기화</button>
|
||||||
<button type="button" class="secondaryButton" data-set-current="end">현재 시점</button>
|
</div>
|
||||||
</label>
|
</div>
|
||||||
<p class="muted">저장하면 ffmpeg 가 원본을 보존한 채 편집본을 만듭니다.</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user