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

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;