Files
make_video_site/public/editor.js
claude-bot f587dce5ce 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>
2026-05-15 18:31:07 +09:00

346 lines
13 KiB
JavaScript

(function () {
var ctx = window.__EDITOR__ || { folder: '', video: null }
var folder = ctx.folder
var video = ctx.video
var dropZone = document.getElementById('dropZone')
var fileInput = document.getElementById('fileInput')
var ytUrl = document.getElementById('ytUrl')
var ytProbeBtn = document.getElementById('ytProbeBtn')
var ytStartBtn = document.getElementById('ytStartBtn')
var probeInfo = document.getElementById('probeInfo')
var dlProgress = document.getElementById('downloadProgress')
var uploadStatus = document.getElementById('uploadStatus')
var videoPanel = document.getElementById('videoPanel')
var editVideo = document.getElementById('editVideo')
var titleInput = document.getElementById('titleInput')
var startSec = document.getElementById('startSec')
var endSec = document.getElementById('endSec')
var saveBtn = document.getElementById('saveBtn')
// 드래그&드롭
;['dragenter', 'dragover'].forEach(function (evt) {
dropZone.addEventListener(evt, function (e) {
e.preventDefault(); e.stopPropagation()
dropZone.classList.add('dragOver')
})
})
;['dragleave', 'drop'].forEach(function (evt) {
dropZone.addEventListener(evt, function (e) {
e.preventDefault(); e.stopPropagation()
dropZone.classList.remove('dragOver')
})
})
dropZone.addEventListener('drop', function (e) {
var files = e.dataTransfer && e.dataTransfer.files
if (files && files.length > 0) uploadFile(files[0])
})
fileInput.addEventListener('change', function () {
if (fileInput.files && fileInput.files[0]) uploadFile(fileInput.files[0])
})
function uploadFile(file) {
var form = new FormData()
form.append('file', file)
form.append('title', titleInput.value || file.name)
uploadStatus.textContent = '업로드 중...'
var xhr = new XMLHttpRequest()
xhr.open('POST', '/op/folder/' + encodeURIComponent(folder) + '/video/upload')
xhr.upload.addEventListener('progress', function (e) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100)
uploadStatus.textContent = '업로드 ' + pct + '%'
}
})
xhr.onload = function () {
try {
var res = JSON.parse(xhr.responseText)
if (res.ok) {
location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(res.videoId)
} else {
uploadStatus.textContent = '업로드 실패: ' + (res.message || '')
}
} catch (err) {
uploadStatus.textContent = '업로드 실패'
}
}
xhr.onerror = function () { uploadStatus.textContent = '업로드 실패 (네트워크)' }
xhr.send(form)
}
// YouTube probe
ytProbeBtn.addEventListener('click', function () {
var url = ytUrl.value.trim()
if (!url) return
probeInfo.textContent = '확인 중...'
ytStartBtn.disabled = true
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/probe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: url })
}).then(function (r) { return r.json() }).then(function (j) {
if (!j.ok) {
probeInfo.textContent = j.message || '확인 실패'
return
}
var p = j.probe
var parts = [
'제목: ' + p.title,
'길이: ' + formatDuration(p.durationSec)
]
if (p.filesizeApprox) parts.push('대략 ' + formatSize(p.filesizeApprox))
if (p.etaSec) parts.push('예상 다운로드: ' + formatDuration(p.etaSec))
probeInfo.textContent = parts.join(' · ')
if (p.warnOver5min) {
if (!window.confirm('가져오는 데 5분 이상 걸릴 수 있습니다. 진행할까요?\n(다른 페이지에서 작업해도 백그라운드로 계속 진행됩니다.)')) {
return
}
}
if (!titleInput.value) titleInput.value = p.title
ytStartBtn.disabled = false
}).catch(function (e) {
probeInfo.textContent = '확인 실패: ' + e.message
})
})
ytStartBtn.addEventListener('click', function () {
var url = ytUrl.value.trim()
if (!url) return
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/youtube/start', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: url, title: titleInput.value })
}).then(function (r) { return r.json() }).then(function (j) {
if (!j.ok) {
probeInfo.textContent = j.message || '시작 실패'
return
}
dlProgress.hidden = false
probeInfo.textContent = '백그라운드 다운로드 시작...'
pollJob(j.jobId, j.videoId)
})
})
function pollJob(jobId, videoId) {
fetch('/op/job/' + encodeURIComponent(jobId)).then(function (r) { return r.json() }).then(function (j) {
if (!j.ok) {
probeInfo.textContent = j.message || '작업을 찾을 수 없음'
return
}
var job = j.job
dlProgress.value = job.progress
probeInfo.textContent = job.message
if (job.status === 'done') {
location.href = '/op/folder/' + encodeURIComponent(folder) + '/video/editor?id=' + encodeURIComponent(videoId)
} else if (job.status === 'error') {
probeInfo.textContent = '실패: ' + (job.error || '')
} else {
setTimeout(function () { pollJob(jobId, videoId) }, 1500)
}
})
}
// ── 타임라인 트림 (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()
})
// 저장
saveBtn.addEventListener('click', function () {
if (!video) { alert('먼저 영상을 추가하세요.'); return }
var payload = {
id: video.id,
title: titleInput.value,
startSec: trimStart,
endSec: trimEnd
}
saveBtn.disabled = true
saveBtn.textContent = '저장 중...'
fetch('/op/folder/' + encodeURIComponent(folder) + '/video/save', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
}).then(function (r) { return r.json() }).then(function (j) {
saveBtn.disabled = false
saveBtn.textContent = '저장'
if (j.ok) {
alert(j.note || '저장 완료')
location.href = '/op/folder/' + encodeURIComponent(folder)
} else {
alert(j.message || '저장 실패')
}
})
})
function formatDuration(sec) {
if (!sec || sec <= 0) return '0초'
var s = Math.round(sec)
var h = Math.floor(s / 3600); s = s % 3600
var m = Math.floor(s / 60); s = s % 60
if (h > 0) return h + '시간 ' + m + '분 ' + s + '초'
if (m > 0) return m + '분 ' + s + '초'
return s + '초'
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB'
return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB'
}
})()