Frontend: video editor persistence (#1792)

Co-authored-by: bflatastic <bombayapirat@gmail.com>
This commit is contained in:
Tanish Manku
2026-07-20 18:01:24 -07:00
committed by GitHub
co-authored by bflatastic
parent eb59e7fcd7
commit cb4374ea70
13 changed files with 1432 additions and 47 deletions
@@ -164,11 +164,6 @@ export function App() {
<Route path="/edit-3d" element={<PageScene />} />
<Route path="/edit-3d/:sceneToken" element={<PageScene />} />
<Route path="/edit-image" element={<PageDraw />} />
<Route path="/video-editor" element={<VideoEditorPage />} />
<Route
path="/video-editor/:projectId"
element={<VideoEditorPage />}
/>
<Route path="/support" element={<Support />} />
<Route path="/pricing" element={<Pricing />} />
{/* Welcome is public so it stays reachable right after signup
@@ -179,6 +174,14 @@ export function App() {
{/* Protected — sign-in required (user-owned content / billing flows) */}
<Route element={<RequireAuth />}>
{/* Video projects persist server-side, so the whole editor is
sign-in gated: logged-out visits bounce to /login?from=...
and return here after auth. */}
<Route path="/video-editor" element={<VideoEditorPage />} />
<Route
path="/video-editor/:projectId"
element={<VideoEditorPage />}
/>
<Route path="/media" element={<Media />} />
<Route path="/media/:id" element={<Media />} />
<Route path="/library" element={<Library />} />
@@ -0,0 +1,238 @@
import { useCallback, useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faSparkles,
faTriangleExclamation,
} from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { FolderNameDialog } from "@storyteller/ui-gallery-modal";
import {
isActionReminderOpen,
showActionReminder,
} from "@storyteller/ui-action-reminder-modal";
import type { ProjectMeta } from "@storyteller/ui-video-editor";
import { TopBarActions } from "../../components/topbar/TopBarActions";
import { webappToastAdapter } from "./adapters/toast-adapter";
import type { WebappProjectStorage } from "./adapters/project-storage-adapter";
import {
NewProjectTile,
VideoProjectCard,
VideoProjectCardSkeleton,
} from "./components/VideoProjectCard";
// Landing view for /video-editor (no project in the URL): the user's saved
// video projects plus a "New project" tile. Styled to match the create
// pages and library: #101014 canvas, glow orbs, eyebrow + display heading,
// ui-controls cards with primary hover rings, kebab menus, and the app's
// standard confirm dialog.
const GRID_CLASSES =
"mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5";
const SKELETON_COUNT = 8;
interface VideoEditorProjectsLandingProps {
projectStorage: WebappProjectStorage;
onNewProject: () => void;
onOpenProject: (token: string) => void;
}
export function VideoEditorProjectsLanding({
projectStorage,
onNewProject,
onOpenProject,
}: VideoEditorProjectsLandingProps) {
const [status, setStatus] = useState<"loading" | "ready" | "error">(
"loading",
);
const [projects, setProjects] = useState<ProjectMeta[]>([]);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [renameTarget, setRenameTarget] = useState<ProjectMeta | null>(null);
const refresh = useCallback(async () => {
setStatus("loading");
try {
const listed = await projectStorage.listProjects();
setProjects(listed);
setStatus("ready");
} catch (error) {
console.error("Failed to list video projects:", error);
setProjects([]);
setStatus("error");
}
}, [projectStorage]);
useEffect(() => {
void refresh();
}, [refresh]);
const handleDeleteRequest = (project: ProjectMeta) => {
if (deletingId) return;
showActionReminder({
reminderType: "default",
title: `Delete "${project.name}"?`,
message: (
<p className="text-sm text-white/70">
This will permanently delete the project. This action cannot be
undone.
</p>
),
primaryActionText: "Delete",
secondaryActionText: "Cancel",
primaryActionBtnClassName: "bg-red text-white hover:bg-red/90",
onPrimaryAction: async () => {
try {
await deleteProject(project);
} finally {
isActionReminderOpen.value = false;
}
},
});
};
const deleteProject = async (project: ProjectMeta) => {
setDeletingId(project.id);
try {
await projectStorage.deleteProject(project.id);
setProjects((current) => current.filter((p) => p.id !== project.id));
} catch (error) {
console.error("Failed to delete video project:", error);
webappToastAdapter.error("Couldn't delete project", {
description:
error instanceof Error ? error.message : "Please try again",
});
} finally {
setDeletingId(null);
}
};
const submitRename = async (name: string) => {
const target = renameTarget;
setRenameTarget(null);
if (!target) return;
const trimmed = name.trim();
if (!trimmed || trimmed === target.name) return;
setProjects((current) =>
current.map((p) => (p.id === target.id ? { ...p, name: trimmed } : p)),
);
try {
await projectStorage.renameProject(target.id, trimmed);
} catch (error) {
console.error("Failed to rename video project:", error);
setProjects((current) =>
current.map((p) =>
p.id === target.id ? { ...p, name: target.name } : p,
),
);
webappToastAdapter.error("Couldn't rename project", {
description:
error instanceof Error ? error.message : "Please try again",
});
}
};
return (
<div className="relative h-full w-full overflow-y-auto bg-[#101014] text-white">
{/* Video-themed glow orbs (create-video recipe; absolute, not fixed,
since this page scrolls inside SidebarInset). */}
<div className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
<div className="absolute left-1/2 top-[-10%] h-[700px] w-[700px] -translate-x-1/2 transform-gpu rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-[0.10] blur-[120px]" />
<div className="absolute bottom-[-15%] left-[-10%] h-[500px] w-[500px] transform-gpu rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-purple-600 opacity-[0.07] blur-[120px]" />
</div>
{/* The global topbar is hidden on /video-editor routes; surface its
actions here like the editor header does. */}
<div className="absolute right-4 top-3 z-20">
<TopBarActions />
</div>
<div className="relative z-10 mx-auto w-full max-w-[1600px] px-3 pb-16 pt-10 sm:px-4 md:px-8 lg:px-12">
<header className="animate-fade-in-up">
<p className="text-[11px] font-semibold uppercase tracking-wider text-white/40">
Video editor
</p>
<h1 className="mt-1.5 font-display text-3xl font-semibold md:text-4xl">
Your <span className="font-serif-italic font-normal">projects</span>
</h1>
<p className="mt-2 text-base text-white/60">
Pick up where you left off, or start something new.
</p>
</header>
{status === "loading" && (
<div className={GRID_CLASSES}>
{Array.from({ length: SKELETON_COUNT }, (_, i) => (
<VideoProjectCardSkeleton key={i} />
))}
</div>
)}
{status === "ready" && projects.length > 0 && (
<div className={GRID_CLASSES}>
<NewProjectTile onClick={onNewProject} />
{projects.map((project, index) => (
<VideoProjectCard
key={project.id}
project={project}
index={index}
isDeleting={deletingId === project.id}
onOpen={() => onOpenProject(project.id)}
onRename={() => setRenameTarget(project)}
onDelete={() => handleDeleteRequest(project)}
/>
))}
</div>
)}
{status === "ready" && projects.length === 0 && (
<div className="animate-fade-in-up flex flex-col items-center justify-center py-28 text-center">
<h2 className="text-5xl font-semibold text-white md:text-6xl">
Make your first{" "}
<span className="font-serif-italic font-normal">edit</span>
</h2>
<p className="mt-3 text-lg text-white/80">
Arrange clips, add text and audio, export in minutes.
</p>
<Button
variant="primary"
icon={faSparkles}
onClick={onNewProject}
className="mt-8 h-12 rounded-full px-6 text-base font-semibold"
>
New project
</Button>
</div>
)}
{status === "error" && (
<div className="flex flex-col items-center justify-center gap-4 py-28 text-center">
<FontAwesomeIcon
icon={faTriangleExclamation}
className="text-3xl text-white/30"
/>
<div>
<div className="text-lg font-medium">
Couldn't load your projects
</div>
<p className="mt-1 text-sm text-white/50">
Something went wrong while fetching your saved edits.
</p>
</div>
<Button variant="secondary" onClick={() => void refresh()}>
Try again
</Button>
</div>
)}
</div>
<FolderNameDialog
isOpen={!!renameTarget}
title="Rename project"
initialValue={renameTarget?.name ?? ""}
confirmLabel="Rename"
onConfirm={(name) => void submitRename(name)}
onClose={() => setRenameTarget(null)}
/>
</div>
);
}
@@ -0,0 +1,221 @@
import { MediaFilesApi, ProjectsApi } from "@storyteller/api";
import { getThumbnailUrl } from "@storyteller/common";
import type {
EditorProject,
ProjectMeta,
ProjectStorageAdapter,
} from "@storyteller/ui-video-editor";
import { webappToastAdapter } from "./toast-adapter";
import { captureAndUploadProjectCover } from "./thumbnail-capture";
// Server-backed ProjectStorageAdapter over the project-document endpoints
// (`/v1/media_files/upload/project/video_timeline/{new,update/{token}}` +
// `/v1/media_files/project/list`). Replaces the lib's IndexedDB default in
// the webapp.
//
// Project ids: the host bootstraps fresh sessions with a transient
// `local-{uuid}` id; the first successful save creates the server row and
// every id after that is the media_file_token. The local→token mapping is
// kept here so debounced autosaves that still carry the local id route to
// the update endpoint, and `onProjectCreated` lets the host re-key the
// active project + URL.
const LOCAL_ID_PREFIX = "local-";
const UNTITLED_PROJECT_NAME = "Untitled project";
const COVER_CAPTURE_THROTTLE_MS = 60_000;
// The lib's ProjectStorageAdapter plus webapp-only operations the projects
// landing needs (rename without opening the editor).
export type WebappProjectStorage = ProjectStorageAdapter & {
renameProject(id: string, name: string): Promise<void>;
};
export function isLocalProjectId(id: string): boolean {
return id.startsWith(LOCAL_ID_PREFIX);
}
export function makeLocalProjectId(): string {
return `${LOCAL_ID_PREFIX}${crypto.randomUUID()}`;
}
export function createWebappProjectStorage({
onProjectCreated,
}: {
onProjectCreated?: (args: { localId: string; token: string }) => void;
} = {}): WebappProjectStorage {
const projectsApi = new ProjectsApi();
const filesApi = new MediaFilesApi();
// local-{uuid} → media_file_token, filled by the first create.
const localToRemote = new Map<string, string>();
// Per-project save chain: SaveManager already serializes autosaves, but
// renames call saveCurrentProject directly — the chain guarantees a
// create can never race a second create for the same project.
const saveQueues = new Map<string, Promise<void>>();
// Last cover capture per token: covers refresh on the first save of a
// session and then at most once a minute.
const coverCapturedAt = new Map<string, number>();
const resolveId = (id: string): string => localToRemote.get(id) ?? id;
const maybeCaptureCover = (id: string): void => {
const token = resolveId(id);
if (isLocalProjectId(token)) return;
const last = coverCapturedAt.get(token) ?? 0;
if (Date.now() - last < COVER_CAPTURE_THROTTLE_MS) return;
coverCapturedAt.set(token, Date.now());
void captureAndUploadProjectCover({ token });
};
const uploadDocument = async (envelope: EditorProject): Promise<void> => {
const name = envelope.name || UNTITLED_PROJECT_NAME;
const blob = new Blob([JSON.stringify(envelope.data)], {
type: "application/json",
});
const fileName = `${name}.timeline.json`;
const resolved = resolveId(envelope.id);
if (isLocalProjectId(resolved)) {
const response = await projectsApi.UploadNewProject({
projectType: "video_timeline",
blob,
fileName,
uuid: crypto.randomUUID(),
maybe_title: name,
});
if (!response.success || !response.data) {
throw new Error(response.errorMessage || "Upload failed");
}
localToRemote.set(envelope.id, response.data);
onProjectCreated?.({ localId: envelope.id, token: response.data });
return;
}
const response = await projectsApi.UpdateProject({
projectType: "video_timeline",
token: resolved,
blob,
fileName,
uuid: crypto.randomUUID(),
maybe_title: name,
});
if (!response.success) {
throw new Error(response.errorMessage || "Update failed");
}
};
return {
async saveProject(envelope) {
const queueKey = envelope.id;
const task = async () => {
try {
await uploadDocument(envelope);
// Fire-and-forget: by now a first save has filled localToRemote,
// so brand-new projects get a cover immediately.
maybeCaptureCover(envelope.id);
} catch (error) {
// Saves ship the full document, so a failed save is fully
// recovered by the next successful one — surface it and move
// on instead of leaving an unhandled rejection in the
// autosave path.
console.error("Project save failed:", error);
webappToastAdapter.error("Couldn't save project", {
description:
error instanceof Error ? error.message : "Please try again",
});
}
};
const prior = saveQueues.get(queueKey) ?? Promise.resolve();
const next = prior.then(task, task);
saveQueues.set(queueKey, next);
// Drop the chain entry once it settles with no successor queued, so
// ids (especially one-shot local-{uuid}s) don't accumulate forever.
void next.finally(() => {
if (saveQueues.get(queueKey) === next) saveQueues.delete(queueKey);
});
await next;
},
async loadProject(id) {
const token = resolveId(id);
if (isLocalProjectId(token)) return null;
const response = await filesApi.GetMediaFileByToken({
mediaFileToken: token,
});
const media = response.success ? response.data : undefined;
const cdnUrl = media?.media_links?.cdn_url;
if (!media || !cdnUrl) return null;
const documentResponse = await fetch(cdnUrl);
if (!documentResponse.ok) return null;
const data: unknown = await documentResponse.json();
return {
id: token,
name: media.maybe_title ?? UNTITLED_PROJECT_NAME,
updatedAt: new Date(media.updated_at).getTime(),
data,
};
},
async listProjects(): Promise<ProjectMeta[]> {
const response = await projectsApi.ListSessionProjects({
filter_project_type: "video_timeline",
page_size: 100,
});
if (!response.success || !response.data) {
throw new Error(response.errorMessage || "Couldn't list projects");
}
return response.data.map((row) => ({
id: row.token,
name: row.maybe_title ?? UNTITLED_PROJECT_NAME,
updatedAt: new Date(row.updated_at).getTime(),
thumbnailUrl:
getThumbnailUrl(row.cover_image?.maybe_links?.thumbnail_template, {
width: 600,
}) ??
row.cover_image?.maybe_links?.cdn_url ??
undefined,
}));
},
async deleteProject(id) {
const token = resolveId(id);
if (isLocalProjectId(token)) return;
const response = await filesApi.DeleteMediaFileByToken({
mediaFileToken: token,
asMod: false,
});
if (!response.success) {
throw new Error(response.errorMessage || "Delete failed");
}
},
async renameProject(id, name) {
const token = resolveId(id);
if (isLocalProjectId(token)) return;
// Title-only endpoint — no document re-upload. Known edge: an editor
// session open in another tab still autosaves its stale in-memory
// title and can overwrite this rename on its next save.
const response = await filesApi.RenameMediaFileByToken({
mediaToken: token,
name,
});
if (!response.success) {
throw new Error(response.errorMessage || "Rename failed");
}
},
async createProject(name) {
// The webapp defers server-row creation to the first save (see
// saveProject), so "creating" a project is purely local.
return {
id: makeLocalProjectId(),
name: name || UNTITLED_PROJECT_NAME,
updatedAt: Date.now(),
data: null,
};
},
};
}
@@ -0,0 +1,91 @@
import { MediaFilesApi, MediaUploadApi } from "@storyteller/api";
import { CanvasRenderer, EditorCore } from "@storyteller/ui-video-editor";
// Best-effort project cover capture: renders the active project's current
// frame and attaches it as the media file's cover image so the projects
// landing gets a real thumbnail. Covers are cosmetic — every failure path
// is a silent no-op so the save flow can never be affected.
//
// Mirrors the lib's own snapshot feature (RendererManager.createSnapshot):
// same CanvasRenderer + render tree from the same thread, so it doesn't
// contend with the live preview any more than the built-in snapshot does.
const COVER_WIDTH = 640;
const COVER_JPEG_QUALITY = 0.85;
export async function captureAndUploadProjectCover({
token,
}: {
token: string;
}): Promise<void> {
try {
const editor = EditorCore.getInstance();
const renderTree = editor.renderer.getRenderTree();
const activeProject = editor.project.getActive();
if (!renderTree || !activeProject) return;
// The save queue can outlive a project switch; only capture when the
// editor is still showing the project this save belongs to.
if (activeProject.metadata.id !== token) return;
if (editor.timeline.getTotalDuration() === 0) return;
const { canvasSize, fps } = activeProject.settings;
const renderTime = Math.min(
editor.playback.getCurrentTime(),
editor.timeline.getLastFrameTime(),
);
const renderer = new CanvasRenderer({
width: canvasSize.width,
height: canvasSize.height,
fps,
});
const fullCanvas = document.createElement("canvas");
fullCanvas.width = canvasSize.width;
fullCanvas.height = canvasSize.height;
await renderer.renderToCanvas({
node: renderTree,
time: renderTime,
targetCanvas: fullCanvas,
});
const coverHeight = Math.max(
1,
Math.round((COVER_WIDTH * canvasSize.height) / canvasSize.width),
);
const coverCanvas = document.createElement("canvas");
coverCanvas.width = COVER_WIDTH;
coverCanvas.height = coverHeight;
const context = coverCanvas.getContext("2d");
if (!context) return;
context.drawImage(fullCanvas, 0, 0, COVER_WIDTH, coverHeight);
const blob = await new Promise<Blob | null>((resolve) => {
coverCanvas.toBlob(
(result) => resolve(result),
"image/jpeg",
COVER_JPEG_QUALITY,
);
});
if (!blob) return;
const upload = await new MediaUploadApi().UploadImage({
blob,
fileName: "cover.jpg",
uuid: crypto.randomUUID(),
});
if (!upload.success || !upload.data) {
console.warn("Project cover upload failed:", upload.errorMessage);
return;
}
const attach = await new MediaFilesApi().UpdateCoverImage({
mediaFileToken: token,
imageToken: upload.data,
});
if (!attach.success) {
console.warn("Project cover attach failed:", attach.errorMessage);
}
} catch (error) {
console.warn("Project cover capture failed:", error);
}
}
@@ -0,0 +1,191 @@
import { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faClapperboardPlay,
faEllipsis,
faPen,
faPlus,
faTrashCan,
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { PopoverMenu } from "@storyteller/ui-popover";
import type { ProjectMeta } from "@storyteller/ui-video-editor";
import { formatTimeAgo } from "../../../lib/format-time-ago";
// Card, skeleton, and "new project" tile for the video projects landing.
// Visual language follows the library/create-page cards: ui-controls
// surface, primary hover ring, hover-revealed kebab menu, and the house
// dashed recipe for the new-item tile.
// Deterministic per-project placeholder art so thumbnail-less cards don't
// all look identical (blue/teal/purple/pink families, matching the
// create-page glow orbs).
const PLACEHOLDER_GRADIENTS = [
"from-blue-600/30 via-blue-500/15 to-[#00AABA]/20",
"from-purple-600/30 via-blue-500/15 to-pink-500/20",
"from-[#00AABA]/30 via-blue-600/15 to-purple-500/20",
"from-pink-500/25 via-purple-500/15 to-blue-600/20",
"from-indigo-500/30 via-blue-500/15 to-teal-500/20",
"from-sky-500/25 via-indigo-500/15 to-purple-600/20",
];
const STAGGER_STEP_MS = 30;
const STAGGER_MAX_STEPS = 8;
interface VideoProjectCardProps {
project: ProjectMeta;
index: number;
isDeleting: boolean;
onOpen: () => void;
onRename: () => void;
onDelete: () => void;
}
export function VideoProjectCard({
project,
index,
isDeleting,
onOpen,
onRename,
onDelete,
}: VideoProjectCardProps) {
const [thumbnailError, setThumbnailError] = useState(false);
const showThumbnail = !!project.thumbnailUrl && !thumbnailError;
return (
<div
className={twMerge(
"animate-fade-in-up group relative flex flex-col overflow-hidden rounded-xl bg-ui-controls/40 ring-1 ring-white/5 transition-all duration-200 hover:ring-2 hover:ring-primary-400/60",
isDeleting && "pointer-events-none opacity-50",
)}
style={{
animationDelay: `${Math.min(index, STAGGER_MAX_STEPS) * STAGGER_STEP_MS}ms`,
}}
>
<button
type="button"
onClick={onOpen}
aria-label={`Open ${project.name}`}
className="relative block aspect-video w-full cursor-pointer overflow-hidden text-start"
>
{showThumbnail ? (
<img
src={project.thumbnailUrl}
alt={project.name}
crossOrigin="anonymous"
loading="lazy"
onError={() => setThumbnailError(true)}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
/>
) : (
<div
className={twMerge(
"flex h-full w-full items-center justify-center bg-gradient-to-br",
gradientForProject(project.id),
)}
>
<FontAwesomeIcon
icon={faClapperboardPlay}
className="text-3xl text-white/25 transition-colors group-hover:text-white/40"
/>
</div>
)}
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/70 to-transparent p-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<span className="rounded-full bg-white/15 px-3 py-1 text-xs font-semibold text-white backdrop-blur-sm">
Open project
</span>
</div>
</button>
<div className="min-w-0 px-3 py-2.5">
<div className="truncate text-sm font-medium text-white">
{project.name}
</div>
<div className="text-xs text-white/45">
Edited {formatTimeAgo(project.updatedAt)}
</div>
</div>
<div
className="absolute right-2 top-2 z-10 opacity-0 transition-opacity duration-75 focus-within:opacity-100 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<PopoverMenu
position="bottom"
align="end"
mode="default"
triggerIcon={
<FontAwesomeIcon icon={faEllipsis} className="text-base-fg" />
}
buttonClassName="h-7 w-7 p-0 rounded-full bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg border border-ui-controls-border"
panelClassName="w-max min-w-44 p-1"
closeOnUnhover
>
{(close) => (
<div className="flex flex-col">
<button
type="button"
className="flex w-full items-center gap-2 whitespace-nowrap rounded-md px-2 py-2 text-sm text-base-fg hover:bg-ui-controls/60"
onClick={(e) => {
e.stopPropagation();
close();
onRename();
}}
>
<FontAwesomeIcon icon={faPen} className="w-3.5" />
Rename
</button>
<button
type="button"
className="flex w-full items-center gap-2 whitespace-nowrap rounded-md px-2 py-2 text-sm text-red hover:bg-red/10"
onClick={(e) => {
e.stopPropagation();
close();
onDelete();
}}
>
<FontAwesomeIcon icon={faTrashCan} className="w-3.5" />
Delete
</button>
</div>
)}
</PopoverMenu>
</div>
</div>
);
}
export function VideoProjectCardSkeleton() {
return (
<div className="flex flex-col overflow-hidden rounded-xl ring-1 ring-white/5">
<div className="animate-shimmer aspect-video w-full bg-white/[0.04]" />
<div className="space-y-2 px-3 py-3">
<div className="animate-shimmer h-3.5 w-2/3 rounded bg-white/[0.06]" />
<div className="animate-shimmer h-3 w-1/3 rounded bg-white/[0.05]" />
</div>
</div>
);
}
export function NewProjectTile({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="group flex h-full w-full cursor-pointer flex-col items-center justify-center gap-2.5 rounded-xl border border-dashed border-white/20 bg-white/[0.02] py-10 transition-colors hover:border-white/40 hover:bg-white/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
>
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-white/5 text-white/55 transition-colors group-hover:bg-primary/15 group-hover:text-primary">
<FontAwesomeIcon icon={faPlus} />
</span>
<span className="text-sm font-medium text-white/70">New project</span>
</button>
);
}
function gradientForProject(id: string): string {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
return PLACEHOLDER_GRADIENTS[hash % PLACEHOLDER_GRADIENTS.length];
}
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import {
VideoEditor,
EditorCore,
@@ -14,15 +15,26 @@ import { webappAuthUserAdapter } from "./adapters/auth-user-adapter";
import { webappMediaSourceAdapter } from "./adapters/media-source-adapter";
import { webappExportSinkAdapter } from "./adapters/export-sink-adapter";
import { useWebappAssetGalleryAdapter } from "./adapters/asset-gallery-adapter";
import {
createWebappProjectStorage,
isLocalProjectId,
makeLocalProjectId,
} from "./adapters/project-storage-adapter";
import { VideoEditorProjectsLanding } from "./VideoEditorProjectsLanding";
import { TopBarActions } from "../../components/topbar/TopBarActions";
// Webapp host for the @storyteller/ui-video-editor lib.
//
// Bootstraps a default in-memory project so the editor shell can mount
// with an active scene (replace with a real load via
// ProjectStorageAdapter once the backend exists), and injects the
// webapp adapters that route through Artcraft's MediaUploadApi /
// MediaFilesApi / gallery modal / session store / toast event bus.
// Routing:
// /video-editor → saved-projects landing (list + new)
// /video-editor/local-{uuid} → fresh in-memory project; the first
// autosave creates the server row and
// rewrites the URL to the token
// /video-editor/{token} → load the project document from the
// server (ProjectsApi / video_timeline)
//
// Persistence goes through createWebappProjectStorage; the remaining
// adapters route uploads/gallery/toasts/session through the webapp.
function buildBootstrapProject({ id }: { id: string }): TProject {
const scene = buildDefaultScene({ name: "Main scene", isMain: true });
@@ -48,11 +60,47 @@ function buildBootstrapProject({ id }: { id: string }): TProject {
export default function VideoEditorPage() {
const { projectId } = useParams<{ projectId?: string }>();
const [ready, setReady] = useState(false);
const navigate = useNavigate();
const navigateRef = useRef(navigate);
navigateRef.current = navigate;
const { adapter: assetGalleryAdapter, modal: galleryModal } =
useWebappAssetGalleryAdapter();
const projectStorage = useMemo(
() =>
createWebappProjectStorage({
onProjectCreated: ({ localId, token }) => {
// First save of a fresh project: re-key the active project to
// the server token, then swap the URL. The session effect's
// "active id already matches the route" check makes the
// navigation a no-op instead of a re-bootstrap.
const editor = EditorCore.getInstance();
const active = editor.project.getActive();
if (
active &&
isLocalProjectId(active.metadata.id) &&
active.metadata.id === localId
) {
editor.project.setActiveProject({
project: {
...active,
metadata: { ...active.metadata, id: token },
},
});
}
// Only rewrite the URL if the user is still on this project's
// route — a teardown flush can finish after they navigated
// elsewhere, and yanking them back into the editor would be
// hostile. The landing lists the project either way.
if (window.location.pathname === `/video-editor/${localId}`) {
navigateRef.current(`/video-editor/${token}`, { replace: true });
}
},
}),
[],
);
const adapters = useMemo<Partial<VideoEditorAdapters>>(
() => ({
toast: webappToastAdapter,
@@ -60,16 +108,46 @@ export default function VideoEditorPage() {
mediaSource: webappMediaSourceAdapter,
assetGallery: assetGalleryAdapter,
exportSink: webappExportSinkAdapter,
projectStorage,
}),
[assetGalleryAdapter],
[assetGalleryAdapter, projectStorage],
);
// Stable transient id for the "no projectId in route" case. Computed
// once on first mount and reused across StrictMode double-mount,
// adapter ref churn, and any other dep change so we never overwrite
// the in-memory project with a fresh bootstrap.
const transientIdRef = useRef<string>(`local-${crypto.randomUUID()}`);
const resolvedProjectId = projectId ?? transientIdRef.current;
if (!projectId) {
return (
<VideoEditorProjectsLanding
projectStorage={projectStorage}
onNewProject={() =>
navigate(`/video-editor/${makeLocalProjectId()}`)
}
onOpenProject={(token) => navigate(`/video-editor/${token}`)}
/>
);
}
return (
<VideoEditorSession
key={projectId}
projectId={projectId}
adapters={adapters}
galleryModal={galleryModal}
/>
);
}
function VideoEditorSession({
projectId,
adapters,
galleryModal,
}: {
projectId: string;
adapters: Partial<VideoEditorAdapters>;
galleryModal: ReactNode;
}) {
const navigate = useNavigate();
const [status, setStatus] = useState<"pending" | "ready" | "missing">(
"pending",
);
useEffect(() => {
// Explicit initialize ensures the webapp adapters are installed
@@ -81,21 +159,78 @@ export default function VideoEditorPage() {
});
const editor = EditorCore.getInstance();
// Bootstrap only when the active project doesn't already match the
// requested id. StrictMode mount → unmount → re-mount with the same
// id is a no-op; a route nav to a different :projectId reloads.
if (editor.project.getActive()?.metadata.id !== resolvedProjectId) {
const project = buildBootstrapProject({ id: resolvedProjectId });
let cancelled = false;
// Already active (StrictMode remount, or the post-first-save URL
// rewrite from local id to token): nothing to do.
if (editor.project.getActive()?.metadata.id === projectId) {
setStatus("ready");
} else if (isLocalProjectId(projectId)) {
// Tear down whatever project was open before bootstrapping — this
// path is reachable without the header's exit flow (Back button,
// "New project"), and a leftover media bin or undo stack would
// bleed into the fresh project and persist on its first save.
editor.project.closeProject();
const project = buildBootstrapProject({ id: projectId });
editor.project.setActiveProject({ project });
editor.scenes.initializeScenes({
scenes: project.scenes,
currentSceneId: project.currentSceneId,
});
// closeProject stopped the autosave subscriptions; re-arm them.
editor.save.start();
setStatus("ready");
} else {
setStatus("pending");
// openProject performs its own full teardown of any prior project.
editor.project
.openProject({ id: projectId })
.then((project) => {
if (!cancelled) setStatus(project ? "ready" : "missing");
})
.catch((error) => {
console.error("Failed to open project:", projectId, error);
if (!cancelled) setStatus("missing");
});
}
setReady(true);
}, [resolvedProjectId, adapters]);
if (!ready) return null;
return () => {
cancelled = true;
};
}, [projectId, adapters]);
// Teardown is keyed on projectId alone so it fires exactly once, on
// true unmount (key={projectId} remounts the component per project) —
// never on an incidental adapter identity change mid-session.
useEffect(() => {
return () => teardownEditorSession(projectId);
}, [projectId]);
if (status === "missing") {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-4">
<div className="text-lg font-medium">Couldn't open this project</div>
<p className="max-w-sm text-center text-sm opacity-60">
It may have been deleted, or it belongs to another account.
</p>
<button
onClick={() => navigate("/video-editor")}
className="rounded-lg border border-white/15 px-4 py-2 text-sm transition-colors hover:bg-white/10"
>
Back to projects
</button>
</div>
);
}
if (status === "pending") {
return (
<div className="flex h-full w-full items-center justify-center gap-3">
<LoadingSpinner />
<span className="font-medium opacity-70">Loading project...</span>
</div>
);
}
return (
<div className="h-full w-full overflow-hidden">
@@ -103,8 +238,35 @@ export default function VideoEditorPage() {
projectId={projectId}
adapters={adapters}
headerEndSlot={<TopBarActions />}
exitTo="/video-editor"
/>
{galleryModal}
</div>
);
}
// Unmount teardown for a session. EditorCore is a singleton, so leaving the
// route (sidebar link, Back button) without this would keep the project
// active with live autosave subscriptions — a stale debounce could fire a
// server write long after the user left, and the next session would open on
// top of leaked state.
//
// Ordering matters: saveCurrentProject serializes the document synchronously
// (before its first await), so pending edits are captured even though
// closeProject clears the editor immediately after — the upload continues in
// the background against the captured snapshot. Skipped when nothing is
// dirty so an untouched bootstrap project never creates an empty server row.
function teardownEditorSession(projectId: string): void {
const editor = EditorCore.getInstance();
if (editor.project.getActive()?.metadata.id !== projectId) {
// Another session (different :projectId key) already took over the
// singleton — it owns teardown now.
return;
}
if (editor.save.getIsDirty()) {
editor.project.saveCurrentProject().catch((error) => {
console.error("Final save on editor exit failed:", error);
});
}
editor.project.closeProject();
}
@@ -53,6 +53,22 @@ export {
// exposed so hosts don't re-implement them inconsistently.
export { kindFromMime } from "./lib/adapters";
// --- Project document serialization ---
// The JSON shape ProjectStorageAdapter envelopes carry in `data`.
// Server-backed adapters (and hosts building bootstrap documents for
// createProject) use these instead of inventing their own format.
export {
serializeProjectDocument,
deserializeProjectDocument,
mediaAssetToData,
} from "./lib/services/storage/serialization";
export type { ProjectDocument } from "./lib/services/storage/serialization";
export type {
MediaAssetData,
SerializedProject,
SerializedScene,
} from "./lib/services/storage/types";
// --- MediaTime + frame math (the wasm boundary) ---
export {
TICKS_PER_SECOND,
@@ -45,6 +45,9 @@ export interface VideoEditorProps {
// the export button. Webapp hosts that hide the global topbar inject
// its actions (pricing/credits/task queue/profile) here.
headerEndSlot?: ReactNode;
// Host route the header's "Exit project" / delete actions navigate to.
// Defaults to "/projects" for hosts that own that route.
exitTo?: string;
}
// Public entry. Wraps the inner shell with EditorProvider so callers
@@ -55,6 +58,7 @@ export function VideoEditor({
projectId: _projectId,
adapters,
headerEndSlot,
exitTo,
}: VideoEditorProps) {
return (
<EditorProvider adapters={adapters}>
@@ -75,7 +79,7 @@ export function VideoEditor({
<EditorErrorBoundary>
<div className="bg-background flex h-full w-full flex-col overflow-hidden">
<DegradedRendererBanner />
<EditorHeader endSlot={headerEndSlot} />
<EditorHeader endSlot={headerEndSlot} exitTo={exitTo} />
<div className="min-h-0 min-w-0 flex-1">
<EditorLayout />
</div>
@@ -26,13 +26,16 @@ export interface EditorHeaderProps {
// the webapp's pricing/credits/task queue/profile cluster when the
// global topbar is hidden).
endSlot?: ReactNode;
// Host route to navigate to when the user exits or deletes the open
// project (the host owns routing; the editor shell has no routes).
exitTo?: string;
}
export function EditorHeader({ endSlot }: EditorHeaderProps) {
export function EditorHeader({ endSlot, exitTo = "/projects" }: EditorHeaderProps) {
return (
<header className="bg-background flex h-[3.4rem] items-center justify-between px-3 pt-0.5">
<div className="flex items-center gap-1">
<ProjectDropdown />
<ProjectDropdown exitTo={exitTo} />
<EditableProjectName />
</div>
<nav className="flex items-center gap-2">
@@ -59,7 +62,7 @@ function CurrentUserBadge() {
);
}
function ProjectDropdown() {
function ProjectDropdown({ exitTo }: { exitTo: string }) {
const [openDialog, setOpenDialog] = useState<
"delete" | "rename" | "shortcuts" | null
>(null);
@@ -79,9 +82,7 @@ function ProjectDropdown() {
} catch (error) {
console.error("Failed to prepare project exit:", error);
} finally {
// The /projects route is host-owned. Hosts can intercept this with
// their own router or override the editor shell.
navigate("/projects");
navigate(exitTo);
}
};
@@ -113,7 +114,7 @@ function ProjectDropdown() {
await editor.project.deleteProjects({
ids: [activeProject.metadata.id],
});
navigate("/projects");
navigate(exitTo);
} catch (error) {
toast.error("Failed to delete project", {
description:
@@ -9,6 +9,12 @@ import type {
} from "../../export";
import { UpdateProjectSettingsCommand } from "../../commands/project";
import { getRaisedProjectFpsForImportedMedia } from "../../fps/utils";
import { rehydrateProjectMedia } from "../../media/rehydrate";
import {
deserializeProjectDocument,
mediaAssetToData,
serializeProjectDocument,
} from "../../services/storage/serialization";
// Thin adapter-delegating ProjectManager. The OpenCut original (707
// LOC) handled IndexedDB CRUD, autosave migrations, project bytes
@@ -86,7 +92,10 @@ export class ProjectManager {
id: this.active.metadata.id,
name: this.active.metadata.name,
updatedAt: this.active.metadata.updatedAt.getTime(),
data: this.active,
data: serializeProjectDocument({
project: this.active,
media: this.editor.media.getAssets().map(mediaAssetToData),
}),
});
}
@@ -96,9 +105,62 @@ export class ProjectManager {
try {
const envelope =
await this.editor.adapters.projectStorage.loadProject(id);
const project = (envelope?.data as TProject | null) ?? null;
const document = envelope
? deserializeProjectDocument(envelope.data)
: null;
this.active = document?.project ?? null;
this.notify();
return this.active;
} finally {
this.isLoading = false;
this.notify();
}
}
// Full open flow for hosts: load the document, activate its scenes,
// and rebuild the media bin from the manifest. Returns null when the
// storage adapter has no (readable) project for the id.
//
// Always begins with a full closeProject(): hosts reach here through
// routes (Back button, project cards, deep links) that never run the
// header's explicit exit flow, so any previously open project's media
// bin, undo stack, caches, and autosave subscriptions must be torn
// down here — otherwise they leak into the newly opened project and
// its next autosave persists the contamination.
async openProject({ id }: { id: string }): Promise<TProject | null> {
this.closeProject();
this.isLoading = true;
this.notify();
try {
const envelope =
await this.editor.adapters.projectStorage.loadProject(id);
const document = envelope
? deserializeProjectDocument(envelope.data)
: null;
if (!document) {
return null;
}
const { project, media } = document;
this.active = project;
this.notify();
this.editor.scenes.initializeScenes({
scenes: project.scenes,
currentSceneId: project.currentSceneId,
});
// closeProject() stopped the autosave subscriptions; re-arm them
// for the newly opened project (start() is idempotent).
this.editor.save.start();
if (media.length > 0) {
const assets = await rehydrateProjectMedia({
manifest: media,
mediaSource: this.editor.adapters.mediaSource,
toast: this.editor.adapters.toast,
});
this.editor.media.loadProjectMedia({ assets });
}
return project;
} finally {
this.isLoading = false;
@@ -190,13 +252,30 @@ export class ProjectManager {
id: string;
name: string;
}): Promise<void> {
// Active project: the in-memory state is authoritative (the stored
// row can lag behind the autosave debounce, and may not exist at all
// before the first save) — rename in place and persist.
if (this.active?.metadata.id === id) {
this.active = {
...this.active,
metadata: { ...this.active.metadata, name, updatedAt: new Date() },
};
this.notify();
await this.saveCurrentProject();
return;
}
// Background project: rewrite its stored document.
const envelope = await this.editor.adapters.projectStorage.loadProject(id);
if (!envelope) return;
const project = envelope.data as TProject;
const document = envelope
? deserializeProjectDocument(envelope.data)
: null;
if (!document) return;
const renamed: TProject = {
...project,
...document.project,
metadata: {
...project.metadata,
...document.project.metadata,
name,
updatedAt: new Date(),
},
@@ -205,12 +284,11 @@ export class ProjectManager {
id: renamed.metadata.id,
name: renamed.metadata.name,
updatedAt: renamed.metadata.updatedAt.getTime(),
data: renamed,
data: serializeProjectDocument({
project: renamed,
media: document.media,
}),
});
if (this.active?.metadata.id === id) {
this.active = renamed;
this.notify();
}
}
async deleteProjects({ ids }: { ids: string[] }): Promise<void> {
@@ -0,0 +1,106 @@
import type { MediaSourceAdapter } from "../adapters/media-source";
import type { ToastAdapter } from "../adapters/toast";
import type { MediaHandle, ResolvedMedia } from "../adapters/types";
import type { MediaAssetData } from "../services/storage/types";
import { processMediaAssets, type ProcessedMediaAsset } from "./processing";
// Rebuilds the in-memory media bin from a persisted document's media
// manifest. Each manifest entry's id is a MediaHandle id (in Artcraft
// hosts, a media_file_token): resolve it to a URL, download the bytes
// into a File, then run the normal import pipeline with the handle and
// resolution pre-supplied so nothing re-uploads. Entries that fail to
// resolve or download are skipped (their timeline elements render as
// missing media) rather than failing the whole project load.
interface FetchedEntry {
file: File;
handle: MediaHandle;
resolved: ResolvedMedia;
}
// Cap on simultaneous asset downloads. An unbounded Promise.all over a
// media-heavy project would open one connection (and buffer one Blob) per
// asset at once — slow to first-usable and a real memory spike.
const MAX_CONCURRENT_FETCHES = 4;
export async function rehydrateProjectMedia({
manifest,
mediaSource,
toast,
}: {
manifest: MediaAssetData[];
mediaSource: MediaSourceAdapter;
toast: ToastAdapter;
}): Promise<ProcessedMediaAsset[]> {
const fetched = await mapWithConcurrency({
items: manifest,
limit: MAX_CONCURRENT_FETCHES,
run: (entry) => fetchEntry({ entry, mediaSource, toast }),
});
const entries = fetched.filter((entry): entry is FetchedEntry => !!entry);
if (entries.length === 0) return [];
return processMediaAssets({
files: entries.map((entry) => entry.file),
toast,
mediaSource,
existingHandles: entries.map((entry) => entry.handle),
existingResolved: entries.map((entry) => entry.resolved),
});
}
// Order-preserving concurrent map: at most `limit` `run` calls in flight.
async function mapWithConcurrency<T, R>({
items,
limit,
run,
}: {
items: T[];
limit: number;
run: (item: T) => Promise<R>;
}): Promise<R[]> {
const results: R[] = new Array(items.length);
let nextIndex = 0;
const workers = Array.from(
{ length: Math.min(limit, items.length) },
async () => {
while (nextIndex < items.length) {
const index = nextIndex++;
results[index] = await run(items[index]);
}
},
);
await Promise.all(workers);
return results;
}
async function fetchEntry({
entry,
mediaSource,
toast,
}: {
entry: MediaAssetData;
mediaSource: MediaSourceAdapter;
toast: ToastAdapter;
}): Promise<FetchedEntry | null> {
try {
const handle: MediaHandle = { id: entry.id, kind: entry.type };
const resolved = await mediaSource.resolveMedia(handle);
const response = await fetch(resolved.url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} fetching ${resolved.url}`);
}
const blob = await response.blob();
const file = new File([blob], entry.name, {
type: resolved.mime || blob.type,
lastModified: entry.lastModified || undefined,
});
return { file, handle, resolved };
} catch (error) {
console.error("Failed to rehydrate media asset:", entry.id, error);
toast.error(`Couldn't restore ${entry.name}`, {
description: error instanceof Error ? error.message : undefined,
});
return null;
}
}
@@ -0,0 +1,142 @@
import { describe, expect, test } from "vitest";
import { buildDefaultScene } from "../../../timeline/scenes";
import type { MediaAsset } from "../../../media/types";
import type { TProject } from "../../../project/types";
import type { AudioTrack } from "../../../timeline/types";
import { ZERO_MEDIA_TIME } from "../../../wasm";
import {
deserializeProjectDocument,
mediaAssetToData,
serializeProjectDocument,
} from "../serialization";
const CREATED_AT = new Date("2026-07-01T10:00:00.000Z");
const UPDATED_AT = new Date("2026-07-02T12:30:00.000Z");
describe("project document serialization", () => {
test("JSON round-trip preserves dates and media manifest", () => {
const project = buildSampleProject();
const media = [
{
id: "m_asset1",
name: "clip.mp4",
type: "video" as const,
size: 1234,
lastModified: 111,
width: 1920,
height: 1080,
duration: 4.2,
fps: 30,
hasAudio: true,
},
];
const wire = JSON.parse(
JSON.stringify(serializeProjectDocument({ project, media })),
);
const restored = deserializeProjectDocument(wire);
expect(restored).not.toBeNull();
expect(restored?.project.metadata.id).toBe(project.metadata.id);
expect(restored?.project.metadata.createdAt).toEqual(CREATED_AT);
expect(restored?.project.metadata.updatedAt).toEqual(UPDATED_AT);
expect(restored?.project.scenes[0].createdAt).toBeInstanceOf(Date);
expect(restored?.project.currentSceneId).toBe(project.currentSceneId);
expect(restored?.media).toEqual(media);
});
test("accepts legacy raw TProject envelopes", () => {
const project = buildSampleProject();
const restored = deserializeProjectDocument(project);
expect(restored).not.toBeNull();
expect(restored?.project.metadata.name).toBe(project.metadata.name);
expect(restored?.project.metadata.createdAt).toEqual(CREATED_AT);
expect(restored?.media).toEqual([]);
});
test("rejects unrecognized payloads", () => {
expect(deserializeProjectDocument(null)).toBeNull();
expect(deserializeProjectDocument("nope")).toBeNull();
expect(deserializeProjectDocument({ something: "else" })).toBeNull();
});
test("strips decoded AudioBuffers from audio elements", () => {
const project = buildSampleProject();
const audioTrack: AudioTrack = {
id: "track-audio",
name: "Audio",
type: "audio",
muted: false,
elements: [
{
id: "elem-audio",
type: "audio",
sourceType: "upload",
mediaId: "m_audio1",
name: "voice.mp3",
startTime: ZERO_MEDIA_TIME,
duration: ZERO_MEDIA_TIME,
trimStart: ZERO_MEDIA_TIME,
trimEnd: ZERO_MEDIA_TIME,
params: {},
buffer: { fake: "audio-buffer" } as never,
},
],
};
project.scenes[0].tracks.audio = [audioTrack];
const document = serializeProjectDocument({ project, media: [] });
const serialized = document.project.scenes[0].tracks.audio[0].elements[0];
expect("buffer" in serialized).toBe(false);
});
test("mediaAssetToData pulls size from the File and drops thumbnails", () => {
const file = new File(["abc"], "pic.png", {
type: "image/png",
lastModified: 42,
});
const asset: MediaAsset = {
id: "m_img1",
name: "pic.png",
type: "image",
file,
url: "blob:runtime",
thumbnailUrl: "data:image/png;base64,xyz",
width: 640,
height: 480,
};
const data = mediaAssetToData(asset);
expect(data.size).toBe(3);
expect(data.lastModified).toBe(42);
expect(data.thumbnailUrl).toBeUndefined();
expect(data).not.toHaveProperty("file");
expect(data).not.toHaveProperty("url");
});
});
function buildSampleProject(): TProject {
const scene = buildDefaultScene({ name: "Main scene", isMain: true });
return {
metadata: {
id: "proj-1",
name: "Round trip",
duration: ZERO_MEDIA_TIME,
createdAt: CREATED_AT,
updatedAt: UPDATED_AT,
},
scenes: [scene],
currentSceneId: scene.id,
settings: {
fps: { numerator: 30, denominator: 1 } as never,
canvasSize: { width: 1920, height: 1080 },
background: { type: "color", color: "#000000" },
},
version: 1,
};
}
@@ -0,0 +1,132 @@
import type { MediaAsset } from "../../media/types";
import type { TProject } from "../../project/types";
import type { AudioTrack, TScene } from "../../timeline/types";
import type {
MediaAssetData,
SerializedProject,
SerializedScene,
} from "./types";
// JSON document shape persisted by ProjectStorageAdapter implementations.
// TProject only references media by `mediaId`, so the document carries a
// manifest of the media bin alongside the project — enough for the editor
// to re-resolve every asset through MediaSourceAdapter on load.
export interface ProjectDocument {
version: 1;
project: SerializedProject;
media: MediaAssetData[];
}
export function serializeProjectDocument({
project,
media,
}: {
project: TProject;
media: MediaAssetData[];
}): ProjectDocument {
return {
version: 1,
project: {
...project,
metadata: {
...project.metadata,
createdAt: project.metadata.createdAt.toISOString(),
updatedAt: project.metadata.updatedAt.toISOString(),
},
scenes: project.scenes.map(serializeScene),
},
media,
};
}
// Accepts both the versioned document shape and legacy envelopes whose
// `data` was a raw TProject (IndexedDB rows structured-cloned before the
// document wrapper existed — their Date fields may still be real Dates).
export function deserializeProjectDocument(
data: unknown,
): { project: TProject; media: MediaAssetData[] } | null {
if (!data || typeof data !== "object") return null;
if ("metadata" in data) {
return {
project: reviveProject(data as SerializedProject),
media: [],
};
}
if ("project" in data) {
const document = data as ProjectDocument;
if (!document.project?.metadata) return null;
return {
project: reviveProject(document.project),
media: document.media ?? [],
};
}
return null;
}
export function mediaAssetToData(asset: MediaAsset): MediaAssetData {
// `thumbnailUrl` is deliberately dropped: runtime thumbnails are data:
// or blob: URLs regenerated during rehydration, and embedding them
// would bloat the persisted document.
return {
id: asset.id,
name: asset.name,
type: asset.type,
size: asset.file?.size ?? 0,
lastModified: asset.file?.lastModified ?? 0,
width: asset.width,
height: asset.height,
duration: asset.duration,
fps: asset.fps,
hasAudio: asset.hasAudio,
ephemeral: asset.ephemeral,
};
}
function serializeScene(scene: TScene): SerializedScene {
return {
...scene,
tracks: {
...scene.tracks,
audio: scene.tracks.audio.map(stripAudioBuffers),
},
createdAt: scene.createdAt.toISOString(),
updatedAt: scene.updatedAt.toISOString(),
};
}
// AudioElement.buffer holds a decoded AudioBuffer — a runtime cache that
// can't survive JSON (or structured clone). It's rebuilt from the media
// source after load.
function stripAudioBuffers(track: AudioTrack): AudioTrack {
return {
...track,
elements: track.elements.map((element) => {
if (!element.buffer) return element;
const { buffer: _buffer, ...rest } = element;
return rest as typeof element;
}),
};
}
function reviveProject(project: SerializedProject | TProject): TProject {
return {
...project,
metadata: {
...project.metadata,
createdAt: toDate(project.metadata.createdAt),
updatedAt: toDate(project.metadata.updatedAt),
},
scenes: project.scenes.map((scene) => ({
...scene,
createdAt: toDate(scene.createdAt),
updatedAt: toDate(scene.updatedAt),
})),
};
}
function toDate(value: string | Date): Date {
return value instanceof Date ? value : new Date(value);
}