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:
93
src/editor.ts
Normal file
93
src/editor.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user