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