feat(thumbnails): generate and serve video thumbnails for folder cards

ffmpeg로 영상 첫 프레임을 즉석 추출해 thumb.jpg로 캐시하고
공개/관리자 폴더 카드에서 실제 썸네일을 표시한다. ffmpeg가 없거나
생성 불가하면 404로 떨어져 ▶ placeholder로 폴백한다.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
Claude (owner)
2026-06-02 00:41:06 +09:00
parent 210787131b
commit 066ae6b112
5 changed files with 97 additions and 2 deletions

View File

@@ -448,6 +448,70 @@ export async function applyTrimToVideo(
return outName
}
// ─── 썸네일 ────────────────────────────────────────────────────────────
const THUMB_NAME = 'thumb.jpg'
/**
* 영상 디렉토리에 thumb.jpg 가 없으면 ffmpeg 로 한 장 추출해 만든다.
* - 편집본이 있으면 편집본, 없으면 원본에서 1초 지점 프레임을 뽑는다.
* (1초가 영상보다 길면 0초로 재시도.)
* - 폭 480 으로 다운스케일 (카드 썸네일용).
* - ffmpeg 없음 / 소스 없음 / 실패 시 null 반환 → 호출자가 404 로 응답하고
* 카드에서는 ▶ placeholder 로 폴백.
* 한 번 만들면 파일로 캐시되어 다음 요청부터는 즉시 응답.
*/
export async function ensureThumbnail(videoId: string): Promise<string | null> {
const meta = getVideo(videoId)
if (!meta) return null
const dir = videoDiskDir(videoId)
const thumbPath = path.join(dir, THUMB_NAME)
try {
await fs.access(thumbPath)
return thumbPath
} catch {
/* 없으면 아래에서 생성 */
}
const sourceName = meta.editedFile || meta.originalFile
if (!sourceName || sourceName.includes('%(ext)s')) return null
const inputPath = path.join(dir, sourceName)
try {
await fs.access(inputPath)
} catch {
return null
}
let bin: string
try {
bin = getFfmpegPath()
} catch {
return null
}
const tmpPath = thumbPath + '.tmp.jpg'
const vf = "scale='min(480,iw)':-2"
// -ss 1 (1초 지점). 영상이 1초보다 짧으면 프레임이 안 나와 실패하므로 0초로 재시도.
let ok = await runFfmpeg(bin, [
'-y', '-ss', '1', '-i', inputPath,
'-frames:v', '1', '-vf', vf, '-q:v', '4', tmpPath
])
if (!ok) {
ok = await runFfmpeg(bin, [
'-y', '-i', inputPath,
'-frames:v', '1', '-vf', vf, '-q:v', '4', tmpPath
])
}
if (!ok) {
await fs.unlink(tmpPath).catch(() => undefined)
return null
}
try {
await fs.rename(tmpPath, thumbPath)
} catch {
await fs.unlink(tmpPath).catch(() => undefined)
return null
}
return thumbPath
}
/**
* HW 인코더로 한 번 시도하고, 실패하면 같은 잡에 한해 libx264 ultrafast 로 재시도.
*

View File

@@ -11,6 +11,7 @@ import {
type Video
} from '../storeDb.js'
import { collectFolderSegments } from './helpers.js'
import { ensureThumbnail } from '../editor.js'
export const publicRouter = Router()
@@ -112,6 +113,24 @@ const streamVideoHandler: RequestHandler = (req, res, next) => {
publicRouter.get('/file/video/:videoId', streamVideoHandler)
publicRouter.get('/api/video/:videoId/file', streamVideoHandler)
/**
* 영상 썸네일 (카드용). 없으면 ffmpeg 로 즉석 생성 후 thumb.jpg 로 캐시.
* 생성 불가(ffmpeg 없음/다운로드 미완 등)면 404 → 카드에서 ▶ placeholder 로 폴백.
*/
const thumbHandler: RequestHandler = (req, res, next) => {
ensureThumbnail(req.params.videoId)
.then((thumbPath) => {
if (!thumbPath) {
res.status(404).end()
return
}
res.set('Cache-Control', 'public, max-age=3600')
res.sendFile(thumbPath)
})
.catch(next)
}
publicRouter.get('/file/video/:videoId/thumb', thumbHandler)
/** 비디오 메타 조회 (플레이어/관리자 양쪽에서 사용). */
publicRouter.get('/api/video/:videoId', (req, res, next) => {
try {