From 066ae6b11262a93bd46cf32d4fe5c572ef4aa6d1 Mon Sep 17 00:00:00 2001 From: "Claude (owner)" Date: Tue, 2 Jun 2026 00:41:06 +0900 Subject: [PATCH] feat(thumbnails): generate and serve video thumbnails for folder cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ffmpeg로 영상 첫 프레임을 즉석 추출해 thumb.jpg로 캐시하고 공개/관리자 폴더 카드에서 실제 썸네일을 표시한다. ffmpeg가 없거나 생성 불가하면 404로 떨어져 ▶ placeholder로 폴백한다. Co-Authored-By: Claude Opus 4 --- public/styles.css | 6 +++++ src/editor.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++ src/routes/public.ts | 19 +++++++++++++ views/folder.ejs | 5 +++- views/op/folder.ejs | 5 +++- 5 files changed, 97 insertions(+), 2 deletions(-) diff --git a/public/styles.css b/public/styles.css index 5c30020..37d1ee2 100644 --- a/public/styles.css +++ b/public/styles.css @@ -115,10 +115,16 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content } .videoCard:hover { border-color: var(--accent); } .videoThumb { + position: relative; aspect-ratio: 16/9; background: linear-gradient(135deg, #1f242c, #0d1117); display: flex; align-items: center; justify-content: center; 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; } /* login */ diff --git a/src/editor.ts b/src/editor.ts index 9ffc8aa..b252c9a 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -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 { + 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 로 재시도. * diff --git a/src/routes/public.ts b/src/routes/public.ts index c231802..1bc36f5 100644 --- a/src/routes/public.ts +++ b/src/routes/public.ts @@ -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 { diff --git a/views/folder.ejs b/views/folder.ejs index 36a5a8f..bad8631 100644 --- a/views/folder.ejs +++ b/views/folder.ejs @@ -51,7 +51,10 @@ %> -
+
+ + +
<%= v.title %>
<% }) %> diff --git a/views/op/folder.ejs b/views/op/folder.ejs index fac1c64..86eba98 100644 --- a/views/op/folder.ejs +++ b/views/op/folder.ejs @@ -62,7 +62,10 @@ data-video-id="<%= v.id %>" data-title="<%= v.title %>" data-share-url="<%= shareUrl %>"> -
+
+ + +
<%= v.title %>
<% if (v.sourceType === 'youtube') { %>
YouTube