mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Frontend: video editor persistence (#1792)
Co-authored-by: bflatastic <bombayapirat@gmail.com>
This commit is contained in:
co-authored by
bflatastic
parent
eb59e7fcd7
commit
cb4374ea70
@@ -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>
|
||||
);
|
||||
}
|
||||
+221
@@ -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;
|
||||
}
|
||||
}
|
||||
+142
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user