feat(learning): 업로드 동영상 카드 썸네일(ffmpeg 프레임 추출)

- 동영상 업로드 후 PPT와 동일한 썸네일 큐로 PNG 생성
- ENABLE_VIDEO_THUMBNAIL, VIDEO_THUMB_SEEK_SEC 환경 변수 지원
- 관리자: 동영상도 썸네일 재생성·삭제 시 썸네일 파일 정리
- 카드/스타일: 썸네일 이미지 표시 시 불투명도 1

Made-with: Cursor
This commit is contained in:
2026-04-21 17:02:21 +09:00
parent 8f441e8ef2
commit 7bee72f287
7 changed files with 173 additions and 38 deletions

View File

@@ -19,6 +19,8 @@ DB_PASSWORD=wkqltm@@00492
MEETING_AUDIO_MAX_MB=300
ENABLE_PPT_THUMBNAIL=1
ENABLE_VIDEO_THUMBNAIL=1
# VIDEO_THUMB_SEEK_SEC=0.5
THUMBNAIL_WIDTH=1000
THUMBNAIL_MAX_RETRY=2
THUMBNAIL_RETRY_DELAY_MS=5000

View File

@@ -61,6 +61,7 @@
- PPT/PDF(`.pdf`) 상세: **1단·2단·3단** 보기 전환(그리드), 선택값은 브라우저 `localStorage`에 저장
- 목록 카드에 PPT 프리뷰(첫 슬라이드 제목 + 장수) 표시
- macOS 환경에서는 `qlmanage` 기반 실제 썸네일(첫 장 이미지) 자동 생성
- **업로드 동영상**(mp4/webm/mov 등): 목록 카드에 **ffmpeg**로 뽑은 대표 프레임 썸네일 표시(기본 약 0.5초 지점). 서버에 `ffmpeg`가 있어야 하며, PPT 썸네일과 동일한 백그라운드 큐·재시도 정책을 사용합니다.
- 썸네일 백그라운드 큐
- 썸네일 생성은 비동기 큐에서 처리
- 상태값: `pending` / `processing` / `ready` / `failed`
@@ -432,6 +433,13 @@ ENABLE_PPT_THUMBNAIL=1 npm start
- **PDF**: `pdftoppm`만 있으면 동작 (poppler 패키지)
- **한글 깨짐(이미지 안만 □)**: 서버에 PPT가 쓰는 글꼴·한글 폰트가 없을 때 발생. Linux: `fonts-nanum`, `fonts-noto-cjk` 등 설치 후 `fc-cache -fv`, 슬라이드 이미지 재생성.
### 업로드 동영상 카드 썸네일(선택)
- 기본값: `ENABLE_VIDEO_THUMBNAIL`이 `0`이 아니면 활성(미설정 시 켜짐).
- 서버 **PATH**에 `ffmpeg`가 있어야 합니다. macOS: `brew install ffmpeg`, Ubuntu: `sudo apt install -y ffmpeg`.
- 추출 시각은 `VIDEO_THUMB_SEEK_SEC`(초, 기본 `0.5`)로 조정할 수 있습니다. 영상 앞부분이 검은 화면이면 값을 키워 보세요.
- `ENABLE_VIDEO_THUMBNAIL=0`이면 업로드 직후 썸네일은 생성하지 않으며, 카드는 기존처럼 텍스트 폴백만 표시합니다.
### `.env` 예시
```env
@@ -446,6 +454,8 @@ DB_DATABASE=your_database
DB_USERNAME=your_user
DB_PASSWORD=your_password
ENABLE_PPT_THUMBNAIL=1
ENABLE_VIDEO_THUMBNAIL=1
# VIDEO_THUMB_SEEK_SEC=0.5
THUMBNAIL_WIDTH=1000
THUMBNAIL_MAX_RETRY=2
THUMBNAIL_RETRY_DELAY_MS=5000
@@ -465,6 +475,7 @@ OPENAI_API_KEY=
1. 메인 페이지에서 강의 등록
- **유튜브 강의 등록**: 제목 + 유튜브 링크 (+설명)
- **PowerPoint 강의 등록**: 제목 + `.pptx` 파일 (+설명)
- **동영상 파일 등록**: 제목 + 영상 파일 (`ffmpeg`로 카드 썸네일 생성)
- 두 등록 폼 모두 **태그(쉼표 구분)** 입력 가능
2. 하단 **등록된 강의** 카드에서 항목 클릭
3. 강의 상세 화면에서 시청
@@ -484,7 +495,7 @@ OPENAI_API_KEY=
### 썸네일 재생성(관리자)
- 관리자 모드에서 PPT 카드의 `썸네일 재생성` 버튼으로 수동 재생성 가능
- 관리자 모드에서 PPT·업로드 동영상 카드의 `썸네일 재생성` 버튼으로 수동 재생성 가능
- `실패 썸네일 일괄 재시도` 버튼으로 실패 건을 큐에 일괄 재등록 가능
- `이벤트 로그 페이지`에서 기간/유형/강의ID/사유 필터 조회 가능
- 이벤트 로그는 CSV 다운로드 지원
@@ -531,7 +542,7 @@ OPENAI_API_KEY=
관리자 토큰 기반 강의 삭제
- `POST /lectures/:id/thumbnail/regenerate`
관리자 토큰 기반 PPT 썸네일 재생성
관리자 토큰 기반 PDF/PPT·업로드 동영상 썸네일 재생성
- `POST /thumbnails/retry-failed`
관리자 토큰 기반 실패 썸네일 일괄 재시도 큐 등록
@@ -574,7 +585,7 @@ OPENAI_API_KEY=
## 구현 참고/제약
- PPT 뷰어는 **원본 슬라이드 디자인 렌더링**이 아닌, `.pptx` 내부 텍스트를 추출해 보여주는 방식입니다.
- PPT 썸네일은 시스템 도구 상태에 따라 생성 실패할 수 있으며, 이 경우 이미지 없이 텍스트 프리뷰만 표시됩니다.
- PPT·동영상 썸네일은 시스템 도구(`qlmanage`/LibreOffice/`ffmpeg` 등) 상태에 따라 생성 실패할 수 있으며, 이 경우 이미지 없이 텍스트 프리뷰만 표시됩니다.
- 썸네일 큐는 단일 프로세스 워커 기준으로 동작합니다(다중 인스턴스 분산 락은 미구현).
- 폰트/도형/애니메이션까지 완전 동일 렌더링이 필요하면 별도 문서 렌더러(예: LibreOffice/PDF 변환 파이프라인) 연동이 필요합니다.
- 이벤트 로그 페이지의 실시간 그래프는 클라이언트 폴링 기반이며, 다수 접속 시 폴링 주기 조정이 필요할 수 있습니다.

View File

@@ -776,10 +776,8 @@ button.ai-case-inline-link:hover {
color: #fff;
}
.thumb-fallback-video {
font-size: 28px;
line-height: 1;
opacity: 0.95;
.thumb.video .thumb-image {
opacity: 1;
}
.lecture-video-wrap {

158
server.js
View File

@@ -37,6 +37,13 @@ const HOST = process.env.HOST || "0.0.0.0";
const PAGE_SIZE = Number(process.env.PAGE_SIZE || 9);
const ADMIN_TOKEN = (process.env.ADMIN_TOKEN || "xavis-admin").trim();
const ENABLE_PPT_THUMBNAIL = process.env.ENABLE_PPT_THUMBNAIL !== "0";
/** 업로드 동영상 카드 썸네일(ffmpeg 1프레임). `ENABLE_VIDEO_THUMBNAIL=0`이면 비활성화 */
const ENABLE_VIDEO_THUMBNAIL = process.env.ENABLE_VIDEO_THUMBNAIL !== "0";
/** 동영상 썸네일 추출 시점(초). 0에 가까우면 검은 프레임일 수 있어 기본 0.5 */
const VIDEO_THUMB_SEEK_SEC = (() => {
const n = Number(process.env.VIDEO_THUMB_SEEK_SEC);
return Number.isFinite(n) && n >= 0 ? Math.min(120, n) : 0.5;
})();
const THUMBNAIL_WIDTH = Number(process.env.THUMBNAIL_WIDTH || 1000);
const THUMBNAIL_MAX_RETRY = Number(process.env.THUMBNAIL_MAX_RETRY || 2);
const THUMBNAIL_RETRY_DELAY_MS = Number(process.env.THUMBNAIL_RETRY_DELAY_MS || 5000);
@@ -2954,6 +2961,38 @@ const generatePptThumbnail = async (filePath, targetKey) => {
return null;
};
/** 업로드 영상: ffmpeg로 지정 시각의 1프레임을 PNG로 저장 (서버에 ffmpeg 필요) */
const generateVideoThumbnail = async (filePath, targetKey) => {
if (!ENABLE_VIDEO_THUMBNAIL) return null;
if (!filePath || !targetKey || !fsSync.existsSync(filePath)) return null;
await fs.mkdir(THUMBNAIL_DIR, { recursive: true });
const finalName = `${targetKey}.png`;
const finalPath = path.join(THUMBNAIL_DIR, finalName);
const seek = String(VIDEO_THUMB_SEEK_SEC);
const ok = await tryExec(
"ffmpeg",
[
"-hide_banner",
"-loglevel",
"error",
"-y",
"-ss",
seek,
"-i",
filePath,
"-frames:v",
"1",
"-vf",
`scale=${THUMBNAIL_WIDTH}:-1`,
finalPath,
],
90000
);
if (!ok || !fsSync.existsSync(finalPath)) return null;
return `/uploads/thumbnails/${finalName}`;
};
const generateSlideImages = async (filePath, lectureId) => {
const outDir = path.join(SLIDES_DIR, lectureId);
await fs.mkdir(outDir, { recursive: true });
@@ -3198,6 +3237,28 @@ const ensurePptThumbnailFields = (lecture) => {
return changed;
};
const ensureVideoThumbnailFields = (lecture) => {
if (!lecture || lecture.type !== "video") return false;
let changed = false;
if (!lecture.thumbnailStatus) {
lecture.thumbnailStatus = lecture.thumbnailUrl ? "ready" : "pending";
changed = true;
}
if (typeof lecture.thumbnailRetryCount !== "number") {
lecture.thumbnailRetryCount = 0;
changed = true;
}
if (!lecture.thumbnailError) {
lecture.thumbnailError = null;
changed = true;
}
if (!lecture.thumbnailUpdatedAt) {
lecture.thumbnailUpdatedAt = lecture.createdAt || new Date().toISOString();
changed = true;
}
return changed;
};
const enqueueThumbnailJob = (lectureId, options = {}) => {
const force = options.force === true;
const reason = options.reason || "manual";
@@ -3237,9 +3298,17 @@ const processThumbnailQueue = async () => {
const startedAt = Date.now();
const lectures = await readLectureDb();
const lecture = lectures.find((item) => item.id === job.lectureId);
if (!lecture || lecture.type !== "ppt" || !lecture.fileName) continue;
if (!lecture || !lecture.fileName) continue;
ensurePptThumbnailFields(lecture);
const isPpt = lecture.type === "ppt";
const isVideo = lecture.type === "video";
if (!isPpt && !isVideo) continue;
if (isPpt) {
ensurePptThumbnailFields(lecture);
} else {
ensureVideoThumbnailFields(lecture);
}
if (!job.force && lecture.thumbnailStatus === "ready" && lecture.thumbnailUrl) {
continue;
}
@@ -3260,7 +3329,9 @@ const processThumbnailQueue = async () => {
});
const filePath = path.join(UPLOAD_DIR, lecture.fileName);
const newThumb = await generatePptThumbnail(filePath, lecture.id);
const newThumb = isPpt
? await generatePptThumbnail(filePath, lecture.id)
: await generateVideoThumbnail(filePath, lecture.id);
if (newThumb) {
lecture.thumbnailUrl = newThumb;
@@ -3268,7 +3339,9 @@ const processThumbnailQueue = async () => {
lecture.thumbnailError = null;
} else {
lecture.thumbnailStatus = "failed";
lecture.thumbnailError = "썸네일 생성 도구 실행 실패 또는 미설치";
lecture.thumbnailError = isVideo
? "동영상 썸네일 생성 실패(ffmpeg 미설치 또는 코덱/파일 문제)"
: "썸네일 생성 도구 실행 실패 또는 미설치";
lecture.thumbnailRetryCount = (lecture.thumbnailRetryCount || 0) + 1;
}
lecture.thumbnailUpdatedAt = new Date().toISOString();
@@ -3286,11 +3359,11 @@ const processThumbnailQueue = async () => {
// Ignore event logging failure.
});
if (
const allowRetry =
lecture.thumbnailStatus === "failed" &&
lecture.thumbnailRetryCount <= THUMBNAIL_MAX_RETRY &&
ENABLE_PPT_THUMBNAIL
) {
((isPpt && ENABLE_PPT_THUMBNAIL) || (isVideo && ENABLE_VIDEO_THUMBNAIL));
if (allowRetry) {
setTimeout(() => {
enqueueThumbnailJob(lecture.id, { force: true, reason: "auto-retry" });
}, THUMBNAIL_RETRY_DELAY_MS);
@@ -3392,20 +3465,33 @@ const ensureBootstrap = async () => {
const lectures = await readLectureDb();
let changed = false;
for (const lecture of lectures) {
if (lecture.type !== "ppt") continue;
if (ensurePptThumbnailFields(lecture)) changed = true;
if (lecture.type === "ppt") {
if (ensurePptThumbnailFields(lecture)) changed = true;
} else if (lecture.type === "video") {
if (ensureVideoThumbnailFields(lecture)) changed = true;
if (lecture.fileName && !lecture.thumbnailUrl && lecture.thumbnailStatus === "ready") {
lecture.thumbnailStatus = "pending";
lecture.thumbnailUpdatedAt = new Date().toISOString();
changed = true;
}
} else {
continue;
}
if (lecture.thumbnailStatus === "processing") {
lecture.thumbnailStatus = "pending";
lecture.thumbnailUpdatedAt = new Date().toISOString();
changed = true;
}
if (lecture.thumbnailStatus === "pending") {
const thumbEnabled = lecture.type === "ppt" ? ENABLE_PPT_THUMBNAIL : ENABLE_VIDEO_THUMBNAIL;
if (lecture.thumbnailStatus === "pending" && lecture.fileName && thumbEnabled) {
enqueueThumbnailJob(lecture.id, { force: false, reason: "bootstrap" });
}
if (
lecture.thumbnailStatus === "failed" &&
lecture.thumbnailRetryCount < THUMBNAIL_MAX_RETRY &&
ENABLE_PPT_THUMBNAIL
thumbEnabled
) {
lecture.thumbnailStatus = "pending";
lecture.thumbnailError = null;
@@ -3519,6 +3605,16 @@ const buildLectureListContext = async (req, options = {}) => {
enqueueThumbnailJob(lecture.id, { force: false, reason: "list-view" });
}
}
if (lecture.type === "video") {
if (ensureVideoThumbnailFields(lecture)) hasMutated = true;
if (
lecture.thumbnailStatus === "pending" &&
lecture.fileName &&
ENABLE_VIDEO_THUMBNAIL
) {
enqueueThumbnailJob(lecture.id, { force: false, reason: "list-view" });
}
}
}
if (hasMutated) await writeLectureDb(lectures);
@@ -3922,13 +4018,16 @@ app.post(
slideCount: 0,
youtubeUrl: null,
thumbnailUrl: null,
thumbnailStatus: "ready",
thumbnailStatus: ENABLE_VIDEO_THUMBNAIL ? "pending" : "failed",
thumbnailRetryCount: 0,
thumbnailError: null,
thumbnailError: ENABLE_VIDEO_THUMBNAIL ? null : "동영상 썸네일 비활성화(ENABLE_VIDEO_THUMBNAIL=0)",
thumbnailUpdatedAt: createdAt,
createdAt,
});
await writeLectureDb(lectures);
if (ENABLE_VIDEO_THUMBNAIL) {
enqueueThumbnailJob(lectureId, { force: false, reason: "upload" });
}
const returnTo = (req.body.returnTo || "").toString().trim();
res.redirect(returnTo.startsWith("/") ? returnTo : "/admin");
@@ -4005,7 +4104,7 @@ app.post("/lectures/:id/delete", async (req, res) => {
} catch {
// Ignore file delete failures to keep metadata consistent.
}
if (target.type === "ppt") {
if (target.type === "ppt" || target.type === "video") {
if (target.thumbnailUrl) {
const thumbPath = path.join(ROOT_DIR, target.thumbnailUrl.replace(/^\//, ""));
try {
@@ -4014,11 +4113,13 @@ app.post("/lectures/:id/delete", async (req, res) => {
// Ignore thumbnail delete failures.
}
}
const slidesDir = path.join(SLIDES_DIR, target.id);
try {
await fs.rm(slidesDir, { recursive: true, force: true });
} catch {
// Ignore slide images delete failures.
if (target.type === "ppt") {
const slidesDir = path.join(SLIDES_DIR, target.id);
try {
await fs.rm(slidesDir, { recursive: true, force: true });
} catch {
// Ignore slide images delete failures.
}
}
}
}
@@ -4051,12 +4152,16 @@ app.post("/lectures/:id/thumbnail/regenerate", async (req, res) => {
res.status(404).send("강의를 찾을 수 없습니다.");
return;
}
if (lecture.type !== "ppt" || !lecture.fileName) {
res.status(400).send("PPT 강의만 썸네일 재생성이 가능합니다.");
if ((lecture.type !== "ppt" && lecture.type !== "video") || !lecture.fileName) {
res.status(400).send("PDF/PPT·업로드 동영상만 썸네일 재생성이 가능합니다.");
return;
}
ensurePptThumbnailFields(lecture);
if (lecture.type === "ppt") {
ensurePptThumbnailFields(lecture);
} else {
ensureVideoThumbnailFields(lecture);
}
lecture.thumbnailStatus = "pending";
lecture.thumbnailError = null;
lecture.thumbnailRetryCount = 0;
@@ -4125,8 +4230,13 @@ app.post("/thumbnails/retry-failed", async (req, res) => {
const lectures = await readLectureDb();
let queued = 0;
for (const lecture of lectures) {
if (lecture.type !== "ppt") continue;
ensurePptThumbnailFields(lecture);
if (lecture.type === "ppt") {
ensurePptThumbnailFields(lecture);
} else if (lecture.type === "video") {
ensureVideoThumbnailFields(lecture);
} else {
continue;
}
if (lecture.thumbnailStatus === "failed") {
lecture.thumbnailStatus = "pending";
lecture.thumbnailError = null;

View File

@@ -261,9 +261,13 @@
<span class="thumb-kicker">웹 링크</span>
<% if (!lecture.thumbnailUrl) { %><strong>외부 페이지</strong><% } %>
<% } else if (lecture.type === "video") { %>
<span class="thumb-fallback thumb-fallback-video">▶</span>
<% if (lecture.thumbnailUrl) { %>
<img src="<%= lecture.thumbnailUrl %>" alt="<%= lecture.title %> 썸네일" class="thumb-image" />
<% } else { %>
<span class="thumb-fallback">썸네일 <%= lecture.thumbnailStatus || "pending" %></span>
<% } %>
<span class="thumb-kicker">동영상 파일</span>
<strong>업로드 영상</strong>
<% if (!lecture.thumbnailUrl) { %><strong>업로드 영상</strong><% } %>
<% } else { %>
<% const ytThumb = typeof getYoutubeThumbnailUrl === 'function' ? getYoutubeThumbnailUrl(lecture.youtubeUrl) : null; %>
<% if (ytThumb) { %>

View File

@@ -210,9 +210,13 @@
<span class="thumb-kicker">웹 링크</span>
<% if (!lecture.thumbnailUrl) { %><strong>외부 페이지</strong><% } %>
<% } else if (lecture.type === "video") { %>
<span class="thumb-fallback thumb-fallback-video">▶</span>
<% if (lecture.thumbnailUrl) { %>
<img src="<%= lecture.thumbnailUrl %>" alt="<%= lecture.title %> 썸네일" class="thumb-image" />
<% } else { %>
<span class="thumb-fallback">썸네일 <%= lecture.thumbnailStatus || "pending" %></span>
<% } %>
<span class="thumb-kicker">동영상 파일</span>
<strong>업로드 영상</strong>
<% if (!lecture.thumbnailUrl) { %><strong>업로드 영상</strong><% } %>
<% } else { %>
<% const ytThumb = typeof getYoutubeThumbnailUrl === 'function' ? getYoutubeThumbnailUrl(lecture.youtubeUrl) : null; %>
<% if (ytThumb) { %>
@@ -228,7 +232,7 @@
<div class="tag-row"><% (lecture.tags || []).forEach((t) => { %><span class="tag-chip">#<%= t %></span><% }) %></div>
<small><%= new Date(lecture.createdAt).toLocaleString("ko-KR") %></small>
</a>
<% if (adminMode && lecture.type === "ppt") { %>
<% if (adminMode && (lecture.type === "ppt" || lecture.type === "video")) { %>
<div class="thumb-state-row">
<span class="state-chip <%= lecture.thumbnailStatus || "pending" %>"><%= lecture.thumbnailStatus || "pending" %></span>
<% if (lecture.thumbnailError) { %><small class="error-text"><%= lecture.thumbnailError %></small><% } %>
@@ -239,11 +243,13 @@
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<button type="submit" class="ghost">썸네일 재생성</button>
</form>
<% if (lecture.type === "ppt") { %>
<form action="/lectures/<%= lecture.id %>/slides/regenerate" method="post" class="delete-form">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<button type="submit" class="ghost">슬라이드 이미지 재생성</button>
</form>
<% } %>
</div>
<% } %>
</article>

View File

@@ -27,9 +27,13 @@
<span class="thumb-kicker">웹 링크</span>
<% if (!lecture.thumbnailUrl) { %><strong>외부 페이지</strong><% } %>
<% } else if (lecture.type === "video") { %>
<span class="thumb-fallback thumb-fallback-video">▶</span>
<% if (lecture.thumbnailUrl) { %>
<img src="<%= lecture.thumbnailUrl %>" alt="<%= lecture.title %> 썸네일" class="thumb-image" />
<% } else { %>
<span class="thumb-fallback">썸네일 <%= lecture.thumbnailStatus || "pending" %></span>
<% } %>
<span class="thumb-kicker">동영상 파일</span>
<strong>업로드 영상</strong>
<% if (!lecture.thumbnailUrl) { %><strong>업로드 영상</strong><% } %>
<% } else { %>
<% const ytThumb = typeof getYoutubeThumbnailUrl === 'function' ? getYoutubeThumbnailUrl(lecture.youtubeUrl) : null; %>
<% if (ytThumb) { %>