(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) { // cache: 'no-store' 로 304 가 나지 않게 강제. 304 면 body 가 비어 // r.json() 이 reject → 폴링 중단되는 문제 방지. fetch('/op/job/' + encodeURIComponent(jobId), { cache: 'no-store' }) .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) } }) .catch(function (err) { // 네트워크 일시 오류로 폴링이 영구 중단되지 않도록 짧게 백오프 후 재시도. console.warn('[pollJob] fetch 실패, 재시도:', err) setTimeout(function () { pollJob(jobId, videoId) }, 2000) }) } // ── 타임라인 트림 (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) } function applyMetadata() { var d = editVideo.duration if (!d || !isFinite(d) || d <= 0) return duration = d // 잘못 저장돼있던 값 보정 trimStart = clamp(trimStart, 0, Math.max(0, duration - MIN_SELECTION)) if (trimEnd != null) trimEnd = clamp(trimEnd, trimStart + MIN_SELECTION, duration) renderTimeline() } if (editVideo) { // 스크립트가 loadedmetadata 이후에 실행되는 경우 (인라인 src) 도 커버. if (editVideo.readyState >= 1) applyMetadata() editVideo.addEventListener('loadedmetadata', applyMetadata) editVideo.addEventListener('durationchange', applyMetadata) 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' } })()