feat(cms): add VDO Frame Scrubber block with isolated sequence library
This commit is contained in:
1 parent
64f7ecf729
commit
0bf544720b
877 files changed
+1632
-6
No files matched your search
@@ -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" };
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user