feat(cms): add VDO Frame Scrubber block with isolated sequence library

This commit is contained in:
Your Name committed 2026-10-17 11:23:08 +07:00
1 parent 64f7ecf729
commit 0bf544720b
877 files changed
+1632 -6

No files matched your search

+10 -1
View File
@@ -35,6 +35,7 @@ import { crmRoute } from "./routes/crm";
import { brandKnowledgeRoute } from "./routes/brandKnowledge";
import { categorySectionsRoute } from "./routes/categorySections";
import { llmPromptsRoute } from "./routes/llmPrompts";
import { frameSequencesRoute } from "./routes/frame-sequences";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
@@ -141,8 +142,16 @@ const app = new Elysia()
}
return file;
})
.get("/frame-sequences/:sequence/:filename", ({ params: { sequence, filename }, set }) => {
const file = Bun.file(`public/frame-sequences/${sequence}/${filename}`);
if (file.size === 0) {
set.status = 404;
return "File not found";
}
return file;
})
.group("/api", (app) =>
app.use(authRoute).use(categoriesRoute).use(categorySectionsRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute).use(brandKnowledgeRoute).use(llmPromptsRoute)
app.use(authRoute).use(categoriesRoute).use(categorySectionsRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute).use(brandKnowledgeRoute).use(llmPromptsRoute).use(frameSequencesRoute)
)
.listen({ port: PORT, hostname: "0.0.0.0" });
+215
View File
@@ -0,0 +1,215 @@
import { Elysia, t } from "elysia";
import { join } from "path";
import { readdir, readFile, writeFile, rm, mkdir } from "fs/promises";
import { existsSync } from "fs";
export interface FrameSequenceManifest {
id: string;
name: string;
slug: string;
folderPath: string;
pattern: string;
padding: number;
totalFrames: number;
fps: number;
durationSeconds: number;
posterUrl: string;
createdAt: string;
}
const getBaseDir = () => join(process.cwd(), "public", "frame-sequences");
export const frameSequencesRoute = new Elysia({ prefix: "/frame-sequences" })
// 1. List all Frame Sequences (Reads manifests from public/frame-sequences/*)
.get("/", async () => {
const baseDir = getBaseDir();
if (!existsSync(baseDir)) {
await mkdir(baseDir, { recursive: true });
return { sequences: [] };
}
const entries = await readdir(baseDir, { withFileTypes: true });
const sequences: FrameSequenceManifest[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const folderPath = join(baseDir, entry.name);
const manifestPath = join(folderPath, "manifest.json");
if (existsSync(manifestPath)) {
try {
const raw = await readFile(manifestPath, "utf8");
const manifest: FrameSequenceManifest = JSON.parse(raw);
sequences.push(manifest);
continue;
} catch (e) {
console.error(`Failed to parse manifest in ${entry.name}`, e);
}
}
// Auto-detect frames if no manifest exists
try {
const files = await readdir(folderPath);
const imageFiles = files
.filter((f) => /\.(webp|png|jpe?g)$/i.test(f) && !f.startsWith("."))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
if (imageFiles.length > 0) {
const autoManifest: FrameSequenceManifest = {
id: `seq_${entry.name}`,
name: entry.name.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
slug: entry.name,
folderPath: `/frame-sequences/${entry.name}`,
pattern: "frame-{index}.webp",
padding: 3,
totalFrames: imageFiles.length,
fps: 24,
durationSeconds: parseFloat((imageFiles.length / 24).toFixed(1)),
posterUrl: `/frame-sequences/${entry.name}/${imageFiles[0]}`,
createdAt: new Date().toISOString(),
};
await writeFile(manifestPath, JSON.stringify(autoManifest, null, 2), "utf8");
sequences.push(autoManifest);
}
} catch (err) {
console.error(`Error scanning sequence folder ${entry.name}`, err);
}
}
return { sequences };
})
// 2. Upload a ZIP file containing video frames
.post(
"/upload",
async ({ body, set }) => {
const { file, name, slug: customSlug, fps: rawFps } = body as {
file: File;
name?: string;
slug?: string;
fps?: string | number;
};
if (!file) {
set.status = 400;
return { error: "No zip file uploaded" };
}
const seqName = name?.trim() || file.name.replace(/\.zip$/i, "");
const slug =
customSlug?.trim() ||
seqName
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") ||
`seq-${Date.now()}`;
const fps = Number(rawFps) || 24;
const baseDir = getBaseDir();
const targetDir = join(baseDir, slug);
try {
await mkdir(targetDir, { recursive: true });
// Save uploaded zip to a temporary file
const tmpZip = join(targetDir, "_temp_upload.zip");
await Bun.write(tmpZip, await file.arrayBuffer());
// Extract zip
if (process.platform === "win32") {
const proc = Bun.spawn([
"powershell",
"-NoProfile",
"-Command",
`Expand-Archive -LiteralPath '${tmpZip}' -DestinationPath '${targetDir}' -Force`,
]);
await proc.exited;
} else {
const proc = Bun.spawn(["unzip", "-o", tmpZip, "-d", targetDir]);
await proc.exited;
}
// Clean up temp zip
if (existsSync(tmpZip)) {
await rm(tmpZip, { force: true });
}
// Scan extracted files
const allFiles = await readdir(targetDir);
const imageFiles = allFiles
.filter((f) => /\.(webp|png|jpe?g)$/i.test(f) && !f.startsWith("."))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
if (imageFiles.length === 0) {
await rm(targetDir, { recursive: true, force: true });
set.status = 400;
return { error: "No image frames found in uploaded ZIP file" };
}
const durationSeconds = parseFloat((imageFiles.length / fps).toFixed(1));
const manifest: FrameSequenceManifest = {
id: `seq_${slug}`,
name: seqName,
slug,
folderPath: `/frame-sequences/${slug}`,
pattern: "frame-{index}.webp",
padding: 3,
totalFrames: imageFiles.length,
fps,
durationSeconds,
posterUrl: `/frame-sequences/${slug}/${imageFiles[0]}`,
createdAt: new Date().toISOString(),
};
await writeFile(join(targetDir, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8");
// Sync to website public folder if monorepo
const websiteTargetDir = join(process.cwd(), "..", "website", "public", "frame-sequences", slug);
if (existsSync(join(process.cwd(), "..", "website"))) {
try {
await mkdir(websiteTargetDir, { recursive: true });
for (const f of imageFiles) {
await Bun.write(join(websiteTargetDir, f), await Bun.file(join(targetDir, f)).arrayBuffer());
}
await Bun.write(join(websiteTargetDir, "manifest.json"), JSON.stringify(manifest, null, 2));
} catch (syncErr) {
console.warn("[FrameSequence] Warning: Could not sync to website public dir", syncErr);
}
}
return {
success: true,
sequence: manifest,
};
} catch (err: any) {
console.error("[FrameSequence] Upload error:", err);
set.status = 500;
return { error: err.message || "Failed to process zip file" };
}
}
)
// 3. Delete a Frame Sequence folder
.delete("/:slug", async ({ params: { slug }, set }) => {
const targetDir = join(getBaseDir(), slug);
if (!existsSync(targetDir)) {
set.status = 404;
return { error: "Sequence not found" };
}
try {
await rm(targetDir, { recursive: true, force: true });
// Also clean up from website if exists
const websiteTargetDir = join(process.cwd(), "..", "website", "public", "frame-sequences", slug);
if (existsSync(websiteTargetDir)) {
await rm(websiteTargetDir, { recursive: true, force: true });
}
return { success: true };
} catch (err: any) {
set.status = 500;
return { error: err.message || "Failed to delete sequence" };
}
});
+6 -1
View File
@@ -524,7 +524,12 @@ function validateSectionPayload(type, payload) {
case "STORY_SCROLL":
schema = CinematicStoryScrollSectionPayloadSchema;
break;
case "INTERACTIVE_360_SHOWCASE":
case "VDO_FRAME_SCRUBBER":
case "INTERACTIVE_VDO_CANVAS":
case "VIDEO_SCRUBBER":
schema = VdoFrameScrubberSectionPayloadSchema;
break;
case "INTERACTIVE_360_SHOWCASE":
case "TURNTABLE_3D_SHOWCASE":
case "TURNTABLE_360":
schema = Interactive360ShowcaseSectionPayloadSchema;
+6
View File
@@ -46,6 +46,7 @@ import {
CinematicStoryScrollSectionPayloadSchema,
AppleShowcaseSectionPayloadSchema,
Interactive360ShowcaseSectionPayloadSchema,
VdoFrameScrubberSectionPayloadSchema,
SectionSpacingPayloadSchema,
PAGE_SECTION_LOCALIZED_PATHS,
type SectionType,
@@ -1435,6 +1436,11 @@ export function validateSectionPayload(
case "STORY_SCROLL":
schema = CinematicStoryScrollSectionPayloadSchema;
break;
case "VDO_FRAME_SCRUBBER":
case "INTERACTIVE_VDO_CANVAS":
case "VIDEO_SCRUBBER":
schema = VdoFrameScrubberSectionPayloadSchema;
break;
case "INTERACTIVE_360_SHOWCASE":
case "TURNTABLE_3D_SHOWCASE":
case "TURNTABLE_360":