refactor: dedupe encoder retry + collectFolderSegments + 3-probe → 1
simplify skill 결과 정리: - editor.ts: HW→libx264 잡 폴백 패턴을 encodeWithFallback() 한 곳으로 모으고 upscale/trim 양쪽에서 호출. 진행률 무전송 변형은 runner 인자로 분기. - editor.ts: upscaleOriginalTo60Fps 가 fps/해상도/길이를 위해 spawnSync ffprobe 를 3번 돌리던 것을 probeVideoMeta() 한 번 호출로 통합. 핫패스에서 이벤트 루프 블로킹을 줄임. 이제 미사용이 된 probeVideoResolution/ probeVideoDuration 은 제거. - routes/helpers.ts: op.ts/public.ts 양쪽에 동일하게 있던 collectFolderSegments 를 공용 모듈로 추출. - youtube.ts: probeYoutube/probeYoutubePlaylist 의 stderr 누적을 runFfmpeg 와 동일하게 마지막 2KB 만 유지하도록 캡. README.md 도 5/31 STEP 1~5 (중첩 폴더 + 영상 ID 편집 + 플레이리스트 임포트 + 짧은 공유 URL) 과 5/16 묶음(부드러운 진행바 + 60fps/FHD 캡 + HW 인코더 자동 감지) 까지 반영해 전반 갱신. `npm run build` 통과. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
102
README.md
102
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/<폴더이름>/<videoId>/
|
||||
meta.json # 영상 메타 (제목, trim, 원본/편집본 파일명)
|
||||
app.db # 메인 sqlite (폴더 트리, 영상 메타, 잡 큐, 세션)
|
||||
videos/<videoId>/ # 영상 파일 (id 기준 평면 디렉터리)
|
||||
original.<ext> # 원본 (항상 보존)
|
||||
edited.<ext> # 편집본 (저장 시 생성)
|
||||
jobs/<jobId>.json # YouTube 다운로드 작업 상태
|
||||
edited.<ext> # 트림 저장 시 생성
|
||||
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.<ext>` 로 따로 저장. 다시 편집해도 원본에서 시작합니다.
|
||||
|
||||
## 성능 메모
|
||||
|
||||
- 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` 상태로 큐에서 대기.
|
||||
|
||||
132
src/editor.ts
132
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<string, string> = {}
|
||||
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<boolean>,
|
||||
label: string
|
||||
): Promise<boolean> {
|
||||
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<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(bin, args)
|
||||
|
||||
16
src/routes/helpers.ts
Normal file
16
src/routes/helpers.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 라우트에서 공유하는 작은 헬퍼.
|
||||
*
|
||||
* 공개/관리자 라우트 모두 `:topName` (+ optional `:subName`) 패턴을 받는다.
|
||||
* 둘이 같은 코드를 가지고 있으면 한쪽만 고치는 버그가 나기 쉽다.
|
||||
*/
|
||||
|
||||
/** `:topName` (+ optional `:subName`) URL 파라미터를 폴더 경로 배열로 변환. */
|
||||
export function collectFolderSegments(
|
||||
params: Record<string, string | undefined>
|
||||
): string[] {
|
||||
const out: string[] = []
|
||||
if (params.topName) out.push(params.topName)
|
||||
if (params.subName) out.push(params.subName)
|
||||
return out
|
||||
}
|
||||
@@ -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, string | undefined>): 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)
|
||||
|
||||
@@ -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, string | undefined>): string[] {
|
||||
const out: string[] = []
|
||||
if (params.topName) out.push(params.topName)
|
||||
if (params.subName) out.push(params.subName)
|
||||
return out
|
||||
}
|
||||
|
||||
function collectVideoSegments(params: Record<string, string | undefined>): {
|
||||
folderSegments: string[]
|
||||
videoId: string
|
||||
|
||||
@@ -66,7 +66,8 @@ export async function probeYoutube(url: string): Promise<ProbeResult> {
|
||||
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<PlaylistProbeRe
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (chunk) => (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) {
|
||||
|
||||
Reference in New Issue
Block a user