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

@@ -115,10 +115,16 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
} }
.videoCard:hover { border-color: var(--accent); } .videoCard:hover { border-color: var(--accent); }
.videoThumb { .videoThumb {
position: relative;
aspect-ratio: 16/9; background: linear-gradient(135deg, #1f242c, #0d1117); aspect-ratio: 16/9; background: linear-gradient(135deg, #1f242c, #0d1117);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
font-size: 36px; color: var(--accent); font-size: 36px; color: var(--accent);
} }
.videoThumbImg {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; display: block; background: #0d1117; z-index: 1;
}
.videoThumbPlay { position: relative; z-index: 0; }
.videoTitle { padding: 10px 12px; font-size: 14px; } .videoTitle { padding: 10px 12px; font-size: 14px; }
/* login */ /* login */

View File

@@ -448,6 +448,70 @@ export async function applyTrimToVideo(
return outName 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 로 재시도. * HW 인코더로 한 번 시도하고, 실패하면 같은 잡에 한해 libx264 ultrafast 로 재시도.
* *

View File

@@ -11,6 +11,7 @@ import {
type Video type Video
} from '../storeDb.js' } from '../storeDb.js'
import { collectFolderSegments } from './helpers.js' import { collectFolderSegments } from './helpers.js'
import { ensureThumbnail } from '../editor.js'
export const publicRouter = Router() export const publicRouter = Router()
@@ -112,6 +113,24 @@ const streamVideoHandler: RequestHandler = (req, res, next) => {
publicRouter.get('/file/video/:videoId', streamVideoHandler) publicRouter.get('/file/video/:videoId', streamVideoHandler)
publicRouter.get('/api/video/:videoId/file', 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) => { publicRouter.get('/api/video/:videoId', (req, res, next) => {
try { try {

View File

@@ -51,7 +51,10 @@
%> %>
<a class="videoCard" href="<%= shareUrl %>" <a class="videoCard" href="<%= shareUrl %>"
data-video-id="<%= v.id %>" data-share-url="<%= shareUrl %>"> data-video-id="<%= v.id %>" data-share-url="<%= shareUrl %>">
<div class="videoThumb">▶</div> <div class="videoThumb">
<img class="videoThumbImg" src="/file/video/<%= encodeURIComponent(v.id) %>/thumb" alt="" loading="lazy" onerror="this.style.display='none'" />
<span class="videoThumbPlay">▶</span>
</div>
<div class="videoTitle"><%= v.title %></div> <div class="videoTitle"><%= v.title %></div>
</a> </a>
<% }) %> <% }) %>

View File

@@ -62,7 +62,10 @@
data-video-id="<%= v.id %>" data-video-id="<%= v.id %>"
data-title="<%= v.title %>" data-title="<%= v.title %>"
data-share-url="<%= shareUrl %>"> data-share-url="<%= shareUrl %>">
<div class="videoThumb">▶</div> <div class="videoThumb">
<img class="videoThumbImg" src="/file/video/<%= encodeURIComponent(v.id) %>/thumb" alt="" loading="lazy" onerror="this.style.display='none'" />
<span class="videoThumbPlay">▶</span>
</div>
<div class="videoTitle"><%= v.title %></div> <div class="videoTitle"><%= v.title %></div>
<% if (v.sourceType === 'youtube') { %> <% if (v.sourceType === 'youtube') { %>
<div class="muted">YouTube</div> <div class="muted">YouTube</div>