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

93
src/editor.ts Normal file
View File

@@ -0,0 +1,93 @@
import { spawn, spawnSync } from 'node:child_process'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { videoDir, loadVideoMeta, saveVideoMeta, type VideoTrim } from './store.js'
export class FfmpegUnavailableError extends Error {
constructor() {
super('ffmpeg 가 설치되어 있지 않습니다. ffmpeg 를 PATH 에 설치한 뒤 다시 시도해 주세요.')
}
}
let resolvedFfmpegPath: string | null = null
export function getFfmpegPath(): string {
if (resolvedFfmpegPath) return resolvedFfmpegPath
const r = spawnSync('ffmpeg', ['-version'])
if (r.status === 0) {
resolvedFfmpegPath = 'ffmpeg'
return 'ffmpeg'
}
throw new FfmpegUnavailableError()
}
/**
* 원본 파일을 그대로 둔 채 trim 결과를 edited.<ext> 로 저장한다.
* stream copy 를 우선 시도해 빠르게 자르고, 실패하면 재인코딩.
*/
export async function applyTrimToVideo(
folder: string,
videoId: string,
trim: VideoTrim
): Promise<string> {
const bin = getFfmpegPath()
const meta = await loadVideoMeta(folder, videoId)
if (!meta) throw new Error('비디오를 찾을 수 없습니다.')
const dir = videoDir(folder, videoId)
const inputPath = path.join(dir, meta.originalFile)
await fs.access(inputPath)
const ext = path.extname(meta.originalFile) || '.mp4'
const outName = `edited${ext}`
const outPath = path.join(dir, outName)
const tmpPath = outPath + '.tmp' + ext
const startSec = Math.max(0, Number(trim.startSec) || 0)
const endSec = trim.endSec == null ? null : Math.max(startSec, Number(trim.endSec))
const baseArgs = ['-y', '-ss', String(startSec)]
if (endSec !== null) baseArgs.push('-to', String(endSec))
baseArgs.push('-i', inputPath)
// 시도 1: stream copy (빠름)
const copyArgs = [...baseArgs, '-c', 'copy', '-movflags', '+faststart', tmpPath]
let ok = await runFfmpeg(bin, copyArgs)
if (!ok) {
// 시도 2: 재인코딩
const encArgs = [
...baseArgs,
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23',
'-c:a', 'aac', '-b:a', '128k',
'-movflags', '+faststart',
tmpPath
]
ok = await runFfmpeg(bin, encArgs)
if (!ok) throw new Error('ffmpeg trim 실패')
}
await fs.rename(tmpPath, outPath)
meta.editedFile = outName
meta.trim = { startSec, endSec }
await saveVideoMeta(folder, meta)
return outName
}
function runFfmpeg(bin: string, args: string[]): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn(bin, args)
let stderr = ''
child.stderr.on('data', (c) => {
stderr = (stderr + c.toString()).slice(-2000)
})
child.on('error', () => resolve(false))
child.on('close', (code) => {
if (code === 0) {
resolve(true)
} else {
// 디버그용 stderr 만 콘솔로
console.error('[ffmpeg] failed:', stderr.split('\n').slice(-5).join('\n'))
resolve(false)
}
})
})
}