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:
@@ -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 로 재시도.
|
||||
*
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user