- Express + EJS + express-session stack (auth/navbar ported from minecraft_launcher)
- Public: main folder list, folder video grid, internal popup player (/player/:videoId)
- Admin (/op): login, folder CRUD with right-click context menu + add-folder modal
- Admin folder: video grid with right-click edit/rename/delete, "영상 추가" -> editor
- Video editor: drag-drop upload, file picker, YouTube URL probe (ETA + 5분 경고),
background yt-dlp download with progress polling, navbar title edit, trim controls,
save runs ffmpeg trim (original preserved)
- Filesystem storage under data/folders/<name>/<videoId>/{meta.json, original.<ext>, edited.<ext>}
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
198 lines
7.2 KiB
JavaScript
198 lines
7.2 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)
|
|
}
|
|
})
|
|
}
|
|
|
|
// 트림 컨트롤 - "현재 시점" 버튼
|
|
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
|
|
})
|
|
})
|
|
|
|
// 저장
|
|
saveBtn.addEventListener('click', function () {
|
|
if (!video) { alert('먼저 영상을 추가하세요.'); return }
|
|
var payload = {
|
|
id: video.id,
|
|
title: titleInput.value,
|
|
startSec: Number(startSec.value || 0),
|
|
endSec: endSec.value === '' ? null : Number(endSec.value)
|
|
}
|
|
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'
|
|
}
|
|
})()
|