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) { %> + <%= lecture.title %> 썸네일 + <% } 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) { %> + <%= lecture.title %> 썸네일 + <% } else { %> + 썸네일 <%= lecture.thumbnailStatus || "pending" %> + <% } %> 동영상 파일 - 업로드 영상 + <% if (!lecture.thumbnailUrl) { %>업로드 영상<% } %> <% } else { %> <% const ytThumb = typeof getYoutubeThumbnailUrl === 'function' ? getYoutubeThumbnailUrl(lecture.youtubeUrl) : null; %> <% if (ytThumb) { %> @@ -228,7 +232,7 @@
<% (lecture.tags || []).forEach((t) => { %>#<%= t %><% }) %>
<%= new Date(lecture.createdAt).toLocaleString("ko-KR") %> - <% if (adminMode && lecture.type === "ppt") { %> + <% if (adminMode && (lecture.type === "ppt" || lecture.type === "video")) { %>
"><%= lecture.thumbnailStatus || "pending" %> <% if (lecture.thumbnailError) { %><%= lecture.thumbnailError %><% } %> @@ -239,11 +243,13 @@ + <% if (lecture.type === "ppt") { %>
+ <% } %>
<% } %> diff --git a/views/partials/lecture-card.ejs b/views/partials/lecture-card.ejs index 707636f..93dce2c 100644 --- a/views/partials/lecture-card.ejs +++ b/views/partials/lecture-card.ejs @@ -27,9 +27,13 @@ 웹 링크 <% if (!lecture.thumbnailUrl) { %>외부 페이지<% } %> <% } else if (lecture.type === "video") { %> - + <% if (lecture.thumbnailUrl) { %> + <%= lecture.title %> 썸네일 + <% } else { %> + 썸네일 <%= lecture.thumbnailStatus || "pending" %> + <% } %> 동영상 파일 - 업로드 영상 + <% if (!lecture.thumbnailUrl) { %>업로드 영상<% } %> <% } else { %> <% const ytThumb = typeof getYoutubeThumbnailUrl === 'function' ? getYoutubeThumbnailUrl(lecture.youtubeUrl) : null; %> <% if (ytThumb) { %>