diff --git a/.env.example b/.env.example
index 7e936b6..f60417b 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/README.md b/README.md
index 7df63ed..5b5b47c 100644
--- a/README.md
+++ b/README.md
@@ -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 변환 파이프라인) 연동이 필요합니다.
- 이벤트 로그 페이지의 실시간 그래프는 클라이언트 폴링 기반이며, 다수 접속 시 폴링 주기 조정이 필요할 수 있습니다.
diff --git a/public/styles.css b/public/styles.css
index e208fb1..dcd8d21 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -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 {
diff --git a/server.js b/server.js
index 447a5de..f6ad17a 100644
--- a/server.js
+++ b/server.js
@@ -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;
diff --git a/views/index.ejs b/views/index.ejs
index e0a5f0f..f499d55 100644
--- a/views/index.ejs
+++ b/views/index.ejs
@@ -261,9 +261,13 @@
웹 링크
<% if (!lecture.thumbnailUrl) { %>외부 페이지<% } %>
<% } else if (lecture.type === "video") { %>
- ▶
+ <% if (lecture.thumbnailUrl) { %>
+
+ <% } else { %>
+ 썸네일 <%= lecture.thumbnailStatus || "pending" %>
+ <% } %>
동영상 파일
- 업로드 영상
+ <% if (!lecture.thumbnailUrl) { %>업로드 영상<% } %>
<% } else { %>
<% const ytThumb = typeof getYoutubeThumbnailUrl === 'function' ? getYoutubeThumbnailUrl(lecture.youtubeUrl) : null; %>
<% if (ytThumb) { %>
diff --git a/views/learning-admin.ejs b/views/learning-admin.ejs
index 8093848..b4fe083 100644
--- a/views/learning-admin.ejs
+++ b/views/learning-admin.ejs
@@ -210,9 +210,13 @@
웹 링크
<% if (!lecture.thumbnailUrl) { %>외부 페이지<% } %>
<% } else if (lecture.type === "video") { %>
- ▶
+ <% if (lecture.thumbnailUrl) { %>
+
+ <% } else { %>
+ 썸네일 <%= lecture.thumbnailStatus || "pending" %>
+ <% } %>
동영상 파일
- 업로드 영상
+ <% if (!lecture.thumbnailUrl) { %>업로드 영상<% } %>
<% } else { %>
<% const ytThumb = typeof getYoutubeThumbnailUrl === 'function' ? getYoutubeThumbnailUrl(lecture.youtubeUrl) : null; %>
<% if (ytThumb) { %>
@@ -228,7 +232,7 @@