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:
Claude (owner)
2026-06-01 10:44:29 +09:00
parent 4909d18493
commit 5693e1c6b0
6 changed files with 169 additions and 105 deletions

View File

@@ -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
View 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
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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) {