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:
2026-05-15 16:42:00 +09:00
parent 8d13d155de
commit 0db04cf5cd
30 changed files with 3300 additions and 0 deletions

197
public/editor.js Normal file
View 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'
}
})()