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