feat(learning): 업로드 동영상 카드 썸네일(ffmpeg 프레임 추출)
- 동영상 업로드 후 PPT와 동일한 썸네일 큐로 PNG 생성 - ENABLE_VIDEO_THUMBNAIL, VIDEO_THUMB_SEEK_SEC 환경 변수 지원 - 관리자: 동영상도 썸네일 재생성·삭제 시 썸네일 파일 정리 - 카드/스타일: 썸네일 이미지 표시 시 불투명도 1 Made-with: Cursor
This commit is contained in:
158
server.js
158
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;
|
||||
|
||||
Reference in New Issue
Block a user