feat: implement video site per README spec
- 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>
This commit is contained in:
197
public/editor.js
Normal file
197
public/editor.js
Normal file
@@ -0,0 +1,197 @@
|
||||
(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'
|
||||
}
|
||||
})()
|
||||
Reference in New Issue
Block a user