diff --git a/README.md b/README.md index 3992988..790ec28 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # 비디오 사이트 -## 영상 업로드 및 저장을 위한 사이트 + +영상 업로드/관리/공개 재생을 다 한 곳에서 처리하는 Express + EJS + better-sqlite3 사이트입니다. 컴퓨터 파일이나 YouTube 링크(단일 영상/플레이리스트) 를 가져와 폴더 단위로 정리하고, 짧은 공유 URL로 외부에 노출합니다. ## 실행 @@ -26,58 +27,85 @@ npm start - `SESSION_SECRET` — 운영 시 반드시 충분히 긴 랜덤 문자열로 교체. - `UPLOAD_MAX_BYTES` — 업로드 용량 한도(바이트). 비우거나 미설정이면 기본 `1073741824` (1 GiB). 무제한으로 두려면 `0` 또는 `Infinity`. 잘못된 값은 기본 1 GiB 로 fallback. - `HTTP_REQUEST_TIMEOUT_MS` — 대용량 업로드용 HTTP 요청 타임아웃(밀리초). `0`/미설정이면 무제한. +- `MAX_DOWNLOAD_CONCURRENCY` — 플레이리스트 임포트 시 동시 yt-dlp 잡 개수 상한 (기본 `2`). - 관리자 비밀번호는 `account.json` 의 `password` 값 (초기값 `admin`, 운영 시 반드시 변경). ## 외부 의존 - `yt-dlp` — YouTube 영상 가져오기. `npm run setup` 이 `./bin/yt-dlp` 로 자동 설치하지만 PATH 에 이미 있어도 됩니다. Alpine / distroless 같이 glibc 가 없는 슬림 도커 베이스에서는 번들 바이너리가 안 도니 `apk add yt-dlp` (또는 `apt-get install yt-dlp`, `pip install yt-dlp`) 로 PATH 에 직접 설치하세요. 도커 빌드에서 아예 받지 않으려면 `SKIP_YT_DLP=1 npm run setup`. (검증 실패해도 setup 은 경고만 출력하고 빌드는 계속 진행됩니다.) -- `ffmpeg` — 영상 트림 저장 (`PATH` 에 설치). 없으면 trim 설정만 저장됩니다. `npm run setup` 이 설치 여부를 검사해 안내 메시지를 출력합니다. +- `ffmpeg` / `ffprobe` — 영상 트림 저장, 60fps/FHD 변환, 메타 조회 (`PATH` 에 설치). 없으면 다운로드는 되지만 후처리가 건너뛰어집니다. +- 하드웨어 인코더(`h264_nvenc` / `h264_qsv` / `h264_videotoolbox` / `h264_vaapi`) — 있으면 자동 감지해 사용. 없으면 `libx264 -preset ultrafast` 폴백. VAAPI 는 `/dev/dri/renderD128` 이 보일 때만 후보로 들어갑니다. ## 데이터 위치 ``` data/ - folders/<폴더이름>// - meta.json # 영상 메타 (제목, trim, 원본/편집본 파일명) - original. # 원본 (항상 보존) - edited. # 편집본 (저장 시 생성) - jobs/.json # YouTube 다운로드 작업 상태 + app.db # 메인 sqlite (폴더 트리, 영상 메타, 잡 큐, 세션) + videos// # 영상 파일 (id 기준 평면 디렉터리) + original. # 원본 (항상 보존) + edited. # 트림 저장 시 생성 + uploads/ # 업로드 임시 (multer) + tmp/ # 작업 임시 ``` +폴더 트리 / 영상 메타는 모두 `app.db` 에서 관리합니다. 디스크 상 영상 파일은 영상 ID 기준 평면 구조라 폴더 이름을 바꿔도 파일 이동이 일어나지 않습니다. + +## URL 규칙 + +공개: +- `/` — 최상위 폴더 목록 +- `/folder/:topName` — 최상위 폴더 (서브폴더 + 영상 혼합) +- `/folder/:topName/:subName` — 2단계 서브폴더 (영상만, 더 깊이 들어갈 수 없음) +- `/video/:topName/:videoId` 또는 `/video/:topName/:subName/:videoId` — 외부 공유용 짧은 URL. 경로의 폴더 부분이 영상 위치와 일치할 때만 200. +- `/file/video/:videoId` — 실제 파일 스트림 (편집본 있으면 편집본). `?edited=0` 으로 원본 강제. + +관리자(`/op`): +- `/op` — 폴더 목록 (로그인 필요) +- `/op/folder/:topName[/[:subName]]` — 관리자 폴더 화면 +- `/op/folder/:topName[/[:subName]]/video/editor` — 영상 추가/편집 화면 + ## 스펙 -### 메인 페이지 (/) -- 동영상이 저장되어있는 폴더를 나열합니다. -- 폴더를 선택해 안에 영상을 확인할수있습니다. -- 폴더를 선택하면 /folder/:폴더이름 으로 이동합니다. +### 메인 페이지 (`/`) +- 최상위 폴더를 카드 그리드로 보여줍니다. +- 폴더를 누르면 `/folder/:폴더이름` 으로 이동. -### 폴더 화면 페이지 (/folder/:폴더이름) -- 폴더 내부에 영상을 유튜브처럼 목록으로 보여줍니다. -- 누르면 재생 플레이어가 뜨며 영상이 재생됩니다. -- 재생플레이어는 /player/:videoId로 합니다. -- 플레이어는 내부팝업처럼 띄워줍니다. +### 폴더 화면 (`/folder/:topName[/:subName]`) +- 최상위 폴더: 서브폴더와 영상이 섞여 나옵니다. 서브폴더는 카드, 영상은 썸네일 + 제목. +- 서브폴더(2단계): 영상만 나옵니다. (폴더 깊이는 최대 2단계까지) +- 영상 카드 클릭 → `/video/:topName[/:subName]/:videoId` 로 이동, 플레이어가 뜨고 재생. -### 관리자 페이지 (/op) -- 동영상을 추가, 삭제, 관리하는 페이지입니다. -- https://git.tkrmagid.kr/tkrmagid/minecraft_launcher repo에 사이트에 있는 op페이지에 로그인 로직을 똑같이 가져와서 적용합니다. (navbar도 채택해서 가져오기) -- 메인 페이지처럼 폴더를 나열하고 우클릭으로 폴더 이름 수정, 삭제가 가능하도록 합니다. -- 폴더를 선택하면 /op/folder/:폴더이름 으로 이동합니다. -- 오른쪽위에 폴더추가버튼으로 폴더를 추가할수있도록 합니다. -- 폴더추가버튼 누르면 팝업으로 생성할 폴더이름을 작성한뒤 생성누르면 폴더 생성되게 설정합니다. +### 관리자 페이지 (`/op`) +- 로그인 로직과 navbar 는 `git.tkrmagid.kr/tkrmagid/minecraft_launcher` 의 op 페이지에서 가져온 형식을 따릅니다. +- 메인 페이지처럼 폴더를 나열, 우클릭으로 이름 수정 / 삭제 / 영상-허용 토글. +- 오른쪽 위 "폴더 추가" 버튼으로 폴더 생성 (팝업에서 이름 입력). -### 관리자 폴더 화면 (/op/folder/:폴더이름) -- 폴더 화면 페이지처럼 유튜브형식으로 영상을 나열하고 우클릭으로 수정, 삭제가 가능하도록 합니다. -- 오른쪽위에 영상추가버튼으로 영상을 추가할수있도록 합니다. -- 영상추가버튼 누르면 /op/folder/:폴더이름/video/editor 로 이동합니다. +### 관리자 폴더 화면 (`/op/folder/...`) +- 영상/서브폴더 목록을 유튜브 스타일로 나열. 우클릭으로 수정/삭제. +- 영상 ID 인라인 편집 가능 (전체 영상에 걸쳐 고유, 안전한 ID 형태만 허용. 변경하면 공유 URL 의 마지막 세그먼트가 같이 바뀝니다). +- 영상 카드를 드래그해 정렬 순서 변경. +- 오른쪽 위 "영상 추가" → `/op/folder/.../video/editor` 로 이동. +- 2단계 폴더에서만 노출되는 "플레이리스트 임포트" 버튼: YouTube 플레이리스트 URL 을 넣으면 항목을 미리 보여주고 시작할 수 있습니다. + - 항목별 드래그 정렬, 제목/ID 인라인 편집. + - ID 정책 토글: **랜덤** (8자 base32 자동) / **시퀀셜** (원래 순서 기준 1-N, zero-pad 옵션). + - "가져오기 시작" 누르면 각 항목이 큐에 들어가고 백그라운드에서 다운로드 + 후처리. -### 영상추가 화면 (/op/folder/:폴더이름/video/editor) -- 예시1: https://github.com/boostcamp-2020/Project13-Web-Video-Editor -- 예시2: https://github.com/mifi/lossless-cut -- 예시1, 예시2 처럼 영상을 수정할수있는 편집기 페이지를 보여줍니다. -- 영상 추가는 컴퓨터에있는 영상을 드래그 드랍 하거나, 직접 폴더에서 찾거나, 유튜브 영상 주소를 추가해서 영상을 추가할수있도록 합니다. -- 유튜브에서 영상 가져오는건 백그라운드에서도 진행 되도록해서 하면서 다른걸 할수있도록 합니다. -- 영상 가져오는 시간을 미리 계산해서 5분 이상 걸릴거같으면 미리 경고를 보냅니다. -- 위쪽에 네비게이션바를 만듭니다. -- 네비게이션바 왼쪽에서 영상 이름을 수정합니다. -- 오른쪽 위에 저장을 누르면 수정한 영상이 저장됩니다. -- 원본 영상과 수정한 내용을 전부 저장해두고, 관리자폴더화면에서 영상을 우클릭해 수정을 누르면 수정하던 화면을 보여줍니다. +### 영상추가 화면 (`/op/folder/.../video/editor`) +- 입력: 컴퓨터 파일 드래그&드롭, 파일 선택 다이얼로그, YouTube URL. +- YouTube URL 의 경우: + 1. "확인" 버튼으로 `probe` (title / duration / 예상 파일 크기). probe 가 끝나야 "가져오기" 버튼이 활성화됩니다 (probe 전에 가져오기 누르는 것 자체가 막힘). + 2. 예상 소요시간이 5분 이상으로 추정되면 경고를 띄움. + 3. 백그라운드 잡이 시작되고, 다른 페이지를 만지더라도 진행 상황이 계속 업데이트됩니다. +- 진행률 바: yt-dlp 가 video / audio 스트림을 순차로 받으면서 매번 0%→100% 로 보고하는 걸 누적해 부드럽게 0→50% 로 climb 합니다. 50→99% 구간은 후처리(60fps 변환 + FHD 캡) 진행률. ffmpeg 가 잠시 진행률을 안 뱉어도 멈춰 보이지 않도록 2초마다 경과시간 메시지가 갱신됩니다. +- 다운로드 해상도는 강제 FHD 캡. 원본이 FHD 이하면 그대로, 초과면 강제로 1920×1080 이하로 다운스케일. +- 다운로드 후 자동 후처리: + - 원본이 60fps 미만이면 fps=60 프레임 복제로 끌어올림. + - 해상도가 FHD 초과면 FHD 로 다운스케일 (종횡비 유지, 짝수 변 보정). + - 위 둘 모두 불필요하면 인코딩 자체를 건너뜁니다. +- 위쪽 navbar 왼쪽에서 영상 이름 수정, 오른쪽 위 "저장" 으로 트림 적용. 원본은 항상 보존, 편집본은 `edited.` 로 따로 저장. 다시 편집해도 원본에서 시작합니다. + +## 성능 메모 + +- 60fps/FHD 변환에 호스트 환경에서 사용 가능한 하드웨어 H.264 인코더를 자동 감지해 우선 사용합니다 (NVENC > QSV > VideoToolbox > VAAPI > libx264 ultrafast). 1프레임 lavfi 테스트로 실제 동작을 확인한 뒤 캐시합니다. +- 1프레임 테스트는 통과했지만 실파일 인코딩에서 깨지는 HW 인코더가 있어, 그 잡 한 건에 한해 libx264 ultrafast 로 자동 재시도합니다 (캐시는 그대로 둠 — 다음 잡 입력이 더 일반적이면 HW 가 다시 돕니다). +- 플레이리스트 임포트는 한 번에 너무 많은 yt-dlp 프로세스가 떠 디스크/CPU 를 죽이지 않도록 `MAX_DOWNLOAD_CONCURRENCY` (기본 2) 로 제한된 풀에서 순차로 시작됩니다. 대기 항목은 `queued` 상태로 큐에서 대기. diff --git a/src/editor.ts b/src/editor.ts index 7db1351..9ffc8aa 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -44,36 +44,51 @@ export function probeVideoFps(inputPath: string): number | null { return Number.isFinite(single) && single > 0 ? single : null } -/** 입력 영상의 해상도(가로×세로 px) 를 ffprobe 로 조회. 실패하면 null. */ -export function probeVideoResolution(inputPath: string): { width: number; height: number } | null { +/** + * fps + 해상도 + 길이를 한 번의 ffprobe 호출로 동시에 받아온다. + * upscaleOriginalTo60Fps 의 핫 패스에서 ffprobe 를 3번 spawnSync 하지 않기 위함. + * 각 필드 실패는 null 로 표현 (개별 helper 의 의미론과 동일). + */ +function probeVideoMeta(inputPath: string): { + fps: number | null + width: number | null + height: number | null + durationSec: number | null +} { const r = spawnSync('ffprobe', [ '-v', 'error', '-select_streams', 'v:0', - '-show_entries', 'stream=width,height', - '-of', 'csv=s=x:p=0', + '-show_entries', 'stream=avg_frame_rate,width,height:format=duration', + '-of', 'default=noprint_wrappers=1', inputPath ]) - if (r.status !== 0) return null - const raw = String(r.stdout).trim() - const m = /^(\d+)x(\d+)$/.exec(raw) - if (!m) return null - const w = Number(m[1]) - const h = Number(m[2]) - if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null - return { width: w, height: h } -} - -/** 입력 영상의 총 재생 길이(초) 를 ffprobe 로 조회. 실패하면 null. */ -export function probeVideoDuration(inputPath: string): number | null { - const r = spawnSync('ffprobe', [ - '-v', 'error', - '-show_entries', 'format=duration', - '-of', 'default=nokey=1:noprint_wrappers=1', - inputPath - ]) - if (r.status !== 0) return null - const v = Number(String(r.stdout).trim()) - return Number.isFinite(v) && v > 0 ? v : null + const empty = { fps: null, width: null, height: null, durationSec: null } + if (r.status !== 0) return empty + const fields: Record = {} + for (const line of String(r.stdout).split(/\r?\n/)) { + const eq = line.indexOf('=') + if (eq <= 0) continue + fields[line.slice(0, eq).trim()] = line.slice(eq + 1).trim() + } + // fps: "N/D" 또는 단일 숫자 + let fps: number | null = null + const fpsRaw = fields.avg_frame_rate + if (fpsRaw) { + const m = /^(\d+)\/(\d+)$/.exec(fpsRaw) + if (m) { + const n = Number(m[1]); const d = Number(m[2]) + if (d > 0 && Number.isFinite(n / d) && n / d > 0) fps = n / d + } else { + const single = Number(fpsRaw) + if (Number.isFinite(single) && single > 0) fps = single + } + } + const w = Number(fields.width); const h = Number(fields.height) + const width = Number.isFinite(w) && w > 0 ? w : null + const height = Number.isFinite(h) && h > 0 ? h : null + const dur = Number(fields.duration) + const durationSec = Number.isFinite(dur) && dur > 0 ? dur : null + return { fps, width, height, durationSec } } export const TARGET_FPS = 60 @@ -263,21 +278,24 @@ export async function upscaleOriginalTo60Fps( return inputName } const inputPath = path.join(dir, inputName) - const sourceFps = probeVideoFps(inputPath) - const sourceRes = probeVideoResolution(inputPath) - if (sourceFps === null && sourceRes === null) { + const meta = probeVideoMeta(inputPath) + const sourceFps = meta.fps + const sourceWidth = meta.width + const sourceHeight = meta.height + if (sourceFps === null && sourceWidth === null && sourceHeight === null) { console.warn(`[upscale] 메타 확인 실패 (${inputPath}) — 후처리 건너뜀`) return inputName } const needBumpFps = sourceFps !== null && sourceFps < TARGET_FPS - 0.5 const needDownscale = - sourceRes !== null && (sourceRes.width > MAX_WIDTH || sourceRes.height > MAX_HEIGHT) + sourceWidth !== null && sourceHeight !== null && + (sourceWidth > MAX_WIDTH || sourceHeight > MAX_HEIGHT) if (!needBumpFps && !needDownscale) { // 이미 충분히 부드럽고 해상도도 캡 이하. return inputName } - const sourceDurationSec = probeVideoDuration(inputPath) + const sourceDurationSec = meta.durationSec // 재인코딩 결과는 항상 mp4 로 통일 (소스가 webm/mkv 여도). const outName = 'original.mp4' @@ -321,17 +339,13 @@ export async function upscaleOriginalTo60Fps( const pct = Math.max(0, Math.min(100, (outTimeUs / 1e6 / sourceDurationSec) * 100)) onProgress(pct) } - const enc = await detectH264Encoder(bin) - let ok = await runFfmpegWithProgress(bin, buildUpscaleArgs(enc), onProgressCb) - if (!ok && enc.name !== 'libx264') { - // 5/16 STEP B P1 (리뷰어): 1프레임 lavfi 테스트로 통과한 HW 인코더가 실제 - // 영상(픽셀 포맷/색공간/해상도 조합)에서 깨지는 경우가 흔하다. 이 파일 한 - // 건에 한해 libx264 ultrafast 로 재시도 — 캐시는 그대로 두므로 다음 잡이 - // 더 일반적인 입력이면 다시 HW 가 동작할 여지를 남긴다. - await fs.unlink(tmpPath).catch(() => undefined) - console.warn(`[upscale] ${enc.name} 변환 실패 — libx264 ultrafast 로 재시도`) - ok = await runFfmpegWithProgress(bin, buildUpscaleArgs(SOFTWARE_FALLBACK), onProgressCb) - } + const ok = await encodeWithFallback( + bin, + buildUpscaleArgs, + tmpPath, + (b, a) => runFfmpegWithProgress(b, a, onProgressCb), + 'upscale' + ) if (!ok) { await fs.unlink(tmpPath).catch(() => undefined) console.warn(`[upscale] 후처리 실패 (filters=${filters.join(',')}) — 원본 ${inputName} 유지`) @@ -422,16 +436,7 @@ export async function applyTrimToVideo( tmpPath ] } - const enc = await detectH264Encoder(bin) - ok = await runFfmpeg(bin, buildTrimEncArgs(enc)) - if (!ok && enc.name !== 'libx264') { - // 5/16 STEP B P1 (리뷰어): 탐지 통과해도 실파일에서 HW 인코더가 실패할 - // 수 있다. 이 파일에 한해 libx264 ultrafast 로 한 번 더 시도. 둘 다 - // 실패해야 진짜 실패로 던진다. - await fs.unlink(tmpPath).catch(() => undefined) - console.warn(`[trim] ${enc.name} 재인코딩 실패 — libx264 ultrafast 재시도`) - ok = await runFfmpeg(bin, buildTrimEncArgs(SOFTWARE_FALLBACK)) - } + ok = await encodeWithFallback(bin, buildTrimEncArgs, tmpPath, runFfmpeg, 'trim') if (!ok) throw new Error('ffmpeg trim 실패') } await fs.rename(tmpPath, outPath) @@ -443,6 +448,31 @@ export async function applyTrimToVideo( return outName } +/** + * HW 인코더로 한 번 시도하고, 실패하면 같은 잡에 한해 libx264 ultrafast 로 재시도. + * + * 5/16 STEP B P1: 1프레임 lavfi 테스트는 통과했는데 실제 영상(픽셀 포맷/색공간/ + * 해상도 조합)에서 깨지는 HW 인코더가 흔하다. 잡 단위 폴백을 한 군데로 모아 + * 캐시는 그대로 두고(`resolvedEncoder` invalidate X), 다음 잡 입력이 더 일반적 + * 이면 HW 가 다시 동작할 여지를 남긴다. + */ +async function encodeWithFallback( + bin: string, + build: (profile: EncoderProfile) => string[], + tmpPath: string, + runner: (bin: string, args: string[]) => Promise, + label: string +): Promise { + const enc = await detectH264Encoder(bin) + let ok = await runner(bin, build(enc)) + if (!ok && enc.name !== 'libx264') { + await fs.unlink(tmpPath).catch(() => undefined) + console.warn(`[${label}] ${enc.name} 실패 — libx264 ultrafast 로 재시도`) + ok = await runner(bin, build(SOFTWARE_FALLBACK)) + } + return ok +} + function runFfmpeg(bin: string, args: string[]): Promise { return new Promise((resolve) => { const child = spawn(bin, args) diff --git a/src/routes/helpers.ts b/src/routes/helpers.ts new file mode 100644 index 0000000..043c25c --- /dev/null +++ b/src/routes/helpers.ts @@ -0,0 +1,16 @@ +/** + * 라우트에서 공유하는 작은 헬퍼. + * + * 공개/관리자 라우트 모두 `:topName` (+ optional `:subName`) 패턴을 받는다. + * 둘이 같은 코드를 가지고 있으면 한쪽만 고치는 버그가 나기 쉽다. + */ + +/** `:topName` (+ optional `:subName`) URL 파라미터를 폴더 경로 배열로 변환. */ +export function collectFolderSegments( + params: Record +): string[] { + const out: string[] = [] + if (params.topName) out.push(params.topName) + if (params.subName) out.push(params.subName) + return out +} diff --git a/src/routes/op.ts b/src/routes/op.ts index 9535253..89cecb0 100644 --- a/src/routes/op.ts +++ b/src/routes/op.ts @@ -32,6 +32,7 @@ import { startYoutubeDownload } from '../youtube.js' import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js' +import { collectFolderSegments } from './helpers.js' export const opRouter = Router() @@ -542,13 +543,6 @@ opRouter.post('/op/videos/:id/save', requireAuth, async (req, res) => { // ─── 헬퍼 ───────────────────────────────────────────────────────────── -function collectFolderSegments(params: Record): string[] { - const out: string[] = [] - if (params.topName) out.push(params.topName) - if (params.subName) out.push(params.subName) - return out -} - /** 폴더 ID → admin URL. 클라이언트가 form action 만들 때 미사용이면 제거 가능. */ export function adminFolderUrl(folderId: number): string { const names = folderPathNames(folderId) diff --git a/src/routes/public.ts b/src/routes/public.ts index 8233fa8..c231802 100644 --- a/src/routes/public.ts +++ b/src/routes/public.ts @@ -10,6 +10,7 @@ import { type Folder, type Video } from '../storeDb.js' +import { collectFolderSegments } from './helpers.js' export const publicRouter = Router() @@ -131,13 +132,6 @@ publicRouter.get('/api/video/:videoId', (req, res, next) => { // ─── 헬퍼 ───────────────────────────────────────────────────────────── -function collectFolderSegments(params: Record): string[] { - const out: string[] = [] - if (params.topName) out.push(params.topName) - if (params.subName) out.push(params.subName) - return out -} - function collectVideoSegments(params: Record): { folderSegments: string[] videoId: string diff --git a/src/youtube.ts b/src/youtube.ts index a404d76..4c56d1e 100644 --- a/src/youtube.ts +++ b/src/youtube.ts @@ -66,7 +66,8 @@ export async function probeYoutube(url: string): Promise { let stdout = '' let stderr = '' child.stdout.on('data', (chunk) => (stdout += chunk.toString())) - child.stderr.on('data', (chunk) => (stderr += chunk.toString())) + // stderr 는 에러 메시지 끝부분만 의미가 있어 직렬화에 비례해 자란다 — 마지막 2KB 만 유지. + child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-2000) }) child.on('error', (err) => reject(err)) child.on('close', (code) => { if (code !== 0) { @@ -118,7 +119,8 @@ export async function probeYoutubePlaylist(url: string): Promise (stdout += chunk.toString())) - child.stderr.on('data', (chunk) => (stderr += chunk.toString())) + // stdout 은 항목별 JSON 라인이 끝에 모두 필요하므로 그대로 유지. stderr 만 캡. + child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-2000) }) child.on('error', (err) => reject(err)) child.on('close', (code) => { if (code !== 0) {