feat: clean up pages, adding e2e, connecting to cloud

This commit is contained in:
Stanley Cheung 2026-02-11 12:34:03 +08:00
parent 229c42aa94
commit 322f5d3c4f
21 changed files with 1414 additions and 24 deletions

View File

@ -333,4 +333,10 @@ Follow `package.json` scripts if different.
- Choose the simplest interpretation.
- Write tests that document the behavior.
- Keep changes minimal and reversible.
- Keep changes minimal and reversible.
---
## 13) Commit message
- Follow commit message convention standard

2
apps/web/.gitignore vendored
View File

@ -8,3 +8,5 @@
.next/
coverage/
storybook-static/
playwright-report/
test-results/

View File

@ -0,0 +1,93 @@
import { expect, test } from "@playwright/test";
test("login loads backend projects list on /projects", async ({ context, page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("opencut_workspace_id", "ws-1");
});
await context.addCookies([
{
name: "refresh_token",
value: "e2e-refresh-token",
domain: "127.0.0.1",
path: "/",
httpOnly: false,
secure: false,
sameSite: "Lax",
},
]);
await page.route("**/api/auth/refresh", async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({
status: "error",
message: "Invalid refresh token",
statusCode: 401,
}),
});
});
await page.route("**/api/auth/user/login", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
token: "backend-jwt-token",
user: {
id: "u-1",
username: "john",
email: "john@example.com",
},
}),
});
});
await page.route("**/api/editor/projects**", async (route) => {
const workspaceHeader = route.request().headers()["x-workspace-id"];
if (!workspaceHeader) {
await route.fulfill({
status: 400,
contentType: "application/json",
body: JSON.stringify({
message: "x-workspace-id header is required",
error: "Bad Request",
statusCode: 400,
}),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
id: "proj-remote-1",
workspaceId: "ws-1",
ownerId: 1,
name: "Backend Project Alpha",
version: 4,
updatedAt: "2026-02-09T12:00:00.000Z",
createdAt: "2026-02-08T12:00:00.000Z",
},
],
total: 1,
}),
});
});
await page.goto("/auth/login");
await page.getByLabel("Username").fill("john");
await page.getByLabel("Password").fill("12345678");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL(/\/projects/);
await page.waitForResponse((response) =>
response.url().includes("/api/editor/projects"),
);
await expect(page.getByText("Backend Project Alpha")).toBeVisible();
});

View File

@ -0,0 +1,174 @@
import { expect, test, type Page, type Route } from "@playwright/test";
const PROJECT_ID = "5e9bd215-7891-4c94-90c5-b87a6f3ddc50";
const CLOUD_PROJECT_RESPONSE = {
id: PROJECT_ID,
workspaceId: "ws-1",
ownerId: 1,
name: "Cloud Synced Project",
version: 3,
updatedAt: "2026-02-09T12:00:00.000Z",
createdAt: "2026-02-08T12:00:00.000Z",
state: {
schemaVersion: 3,
currentSceneId: "scene-main",
metadata: {
id: PROJECT_ID,
name: "Cloud Synced Project",
duration: 30,
updatedAt: "2026-02-09T12:00:00.000Z",
},
settings: {
fps: 30,
canvasSize: { width: 1920, height: 1080 },
background: { type: "color", color: "#000000" },
},
scenes: [
{
id: "scene-main",
name: "Main scene",
isMain: true,
bookmarks: [],
createdAt: "2026-02-08T12:00:00.000Z",
updatedAt: "2026-02-09T12:00:00.000Z",
tracks: [
{
id: "track-video-main",
name: "Main Track",
type: "video",
elements: [],
muted: false,
hidden: false,
isMain: true,
},
],
},
],
},
};
async function mockAuthAndCloudEditorApis({
page,
withPutRoute = false,
}: {
page: Page;
withPutRoute?: boolean;
}) {
await page.route("**/api/auth/refresh", async (route: Route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
token: "backend-jwt-token",
user: {
id: "u-1",
username: "john",
email: "john@example.com",
},
}),
});
});
await page.route("**/api/editor/projects/*", async (route: Route) => {
const workspaceHeader = route.request().headers()["x-workspace-id"];
if (!workspaceHeader) {
await route.fulfill({
status: 400,
contentType: "application/json",
body: JSON.stringify({
message: "x-workspace-id header is required",
error: "Bad Request",
statusCode: 400,
}),
});
return;
}
if (route.request().method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(CLOUD_PROJECT_RESPONSE),
});
return;
}
if (route.request().method() === "PUT" && withPutRoute) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
id: PROJECT_ID,
version: 4,
updatedAt: new Date().toISOString(),
}),
});
return;
}
await route.fulfill({ status: 405 });
});
}
test.beforeEach(async ({ context, page }) => {
await context.addCookies([
{
name: "refresh_token",
value: "e2e-refresh-token",
domain: "127.0.0.1",
path: "/",
httpOnly: false,
secure: false,
sameSite: "Lax",
},
]);
await page.addInitScript(() => {
window.localStorage.setItem("opencut_workspace_id", "ws-1");
});
});
test("editor loads project from cloud when local project is missing", async ({
page,
}) => {
await mockAuthAndCloudEditorApis({ page });
await page.goto(`/editor/${PROJECT_ID}`);
await expect(page.getByText("Cloud Synced Project")).toBeVisible();
});
test("adding media to timeline triggers cloud sync PUT", async ({ page }) => {
await mockAuthAndCloudEditorApis({ page, withPutRoute: true });
const putRequestPromise = page.waitForRequest(
(request) =>
request.method() === "PUT" &&
request.url().includes(`/api/editor/projects/${PROJECT_ID}`),
);
await page.goto(`/editor/${PROJECT_ID}`);
await expect(page.getByText("Cloud Synced Project")).toBeVisible();
const pngBuffer = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9WlqvNQAAAAASUVORK5CYII=",
"base64",
);
await page.locator('input[type="file"]').setInputFiles({
name: "test.png",
mimeType: "image/png",
buffer: pngBuffer,
});
const uploadedMediaCard = page.locator("div", { hasText: "test.png" }).first();
await expect(uploadedMediaCard).toBeVisible();
await uploadedMediaCard.hover();
await uploadedMediaCard.locator("button").first().click({ force: true });
const putRequest = await putRequestPromise;
const putPayload = putRequest.postDataJSON() as Record<string, unknown>;
expect(putPayload).toHaveProperty("state");
expect(putPayload).toHaveProperty("assetFileIds");
});

View File

@ -11,6 +11,11 @@
"build:storybook": "storybook build",
"test": "bun test",
"test:coverage": "bun test --coverage --coverage-reporter=text --coverage-reporter=lcov --coverage-dir=coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:e2e:debug": "playwright test --debug",
"test:e2e:install": "playwright install",
"lint": "biome check src/",
"lint:fix": "biome check src/ --write",
"format": "biome format src/ --write",
@ -84,6 +89,7 @@
"zustand": "^5.0.2"
},
"devDependencies": {
"@playwright/test": "^1.54.2",
"@storybook/addon-a11y": "^8.6.14",
"@storybook/addon-essentials": "^8.6.15",
"@storybook/react": "^8.6.15",

View File

@ -0,0 +1,20 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: [["list"], ["html", { open: "never" }]],
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000",
trace: "on-first-retry",
},
webServer: process.env.PLAYWRIGHT_BASE_URL
? undefined
: {
command: "next dev --turbopack --hostname 127.0.0.1 --port 3000",
url: "http://127.0.0.1:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});

View File

@ -12,7 +12,6 @@ import { Timeline } from "@/components/editor/timeline";
import { PreviewPanel } from "@/components/editor/panels/preview";
import { EditorHeader } from "@/components/editor/editor-header";
import { EditorProvider } from "@/components/providers/editor-provider";
import { Onboarding } from "@/components/editor/onboarding";
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
import { usePanelStore } from "@/stores/panel-store";
@ -27,7 +26,6 @@ export default function Editor() {
<div className="min-h-0 min-w-0 flex-1">
<EditorLayout />
</div>
<Onboarding />
<MigrationDialog />
</div>
</EditorProvider>

View File

@ -49,6 +49,10 @@ import { Label } from "@/components/ui/label";
import { useAuthStore } from "@/stores/auth-store";
import { backendAuthApi } from "@/lib/auth/api-client";
import { authSession } from "@/lib/auth/session";
import {
getSelectedWorkspaceId,
setSelectedWorkspaceId,
} from "@/lib/backend-api";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@ -77,12 +81,17 @@ const formatProjectDuration = ({
export default function ProjectsPage() {
const { searchQuery, sortKey, sortOrder, viewMode } = useProjectsStore();
const editor = useEditor();
const authStatus = useAuthStore((state) => state.status);
useEffect(() => {
if (authStatus === "authenticated") {
editor.project.loadAllProjects();
return;
}
if (!editor.project.getIsInitialized()) {
editor.project.loadAllProjects();
}
}, [editor.project]);
}, [authStatus, editor.project]);
const sortOption: TProjectSortOption = `${sortKey}-${sortOrder}`;
const projectsToDisplay = editor.project.getFilteredAndSortedProjects({
@ -146,6 +155,9 @@ function ProjectsHeader() {
.getWorkspaces({ token })
.then((workspaces) => {
if (workspaces.length > 0) {
if (!getSelectedWorkspaceId()) {
setSelectedWorkspaceId({ workspaceId: workspaces[0].id });
}
setCurrentWorkspaceName(workspaces[0].name);
} else {
setCurrentWorkspaceName("No workspace");

View File

@ -6,9 +6,14 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { backendAuthApi, type TWorkspace } from "@/lib/auth/api-client";
import { authSession } from "@/lib/auth/session";
import { getSelectedWorkspaceId, setSelectedWorkspaceId } from "@/lib/backend-api";
import { toast } from "sonner";
function WorkspacesContent() {
const [workspaces, setWorkspaces] = useState<TWorkspace[]>([]);
const [selectedWorkspaceId, setSelectedWorkspaceIdState] = useState<string | null>(
null,
);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -24,6 +29,13 @@ function WorkspacesContent() {
.getWorkspaces({ token })
.then((data) => {
setWorkspaces(data);
const current = getSelectedWorkspaceId();
if (current) {
setSelectedWorkspaceIdState(current);
} else if (data.length > 0) {
setSelectedWorkspaceId({ workspaceId: data[0].id });
setSelectedWorkspaceIdState(data[0].id);
}
})
.catch((requestError) => {
setError(
@ -51,9 +63,31 @@ function WorkspacesContent() {
<div className="text-muted-foreground">No workspaces available.</div>
) : (
workspaces.map((workspace) => (
<Card key={workspace.id}>
<Card
key={workspace.id}
className={
selectedWorkspaceId === workspace.id ? "border-primary" : undefined
}
>
<CardHeader>
<CardTitle className="text-base">{workspace.name}</CardTitle>
<div className="flex items-center justify-between gap-3">
<CardTitle className="text-base">{workspace.name}</CardTitle>
<Button
variant={
selectedWorkspaceId === workspace.id
? "secondary"
: "outline"
}
size="sm"
onClick={() => {
setSelectedWorkspaceId({ workspaceId: workspace.id });
setSelectedWorkspaceIdState(workspace.id);
toast.success("Workspace selected");
}}
>
{selectedWorkspaceId === workspace.id ? "Selected" : "Use"}
</Button>
</div>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<div>ID: {workspace.id}</div>

View File

@ -30,12 +30,19 @@ import { HugeiconsIcon } from "@hugeicons/react";
import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
export function EditorHeader() {
const editor = useEditor();
const cloudSync = editor.project.getCloudSyncState();
return (
<header className="bg-background flex h-[3.2rem] items-center justify-between px-3 pt-0.5">
<div className="flex items-center gap-2">
<ProjectDropdown />
</div>
<nav className="flex items-center gap-2">
<CloudSyncBadge
status={cloudSync.status}
message={cloudSync.message}
/>
<ExportButton />
<ThemeToggle />
</nav>
@ -43,6 +50,47 @@ export function EditorHeader() {
);
}
function CloudSyncBadge({
status,
message,
}: {
status:
| "idle"
| "disabled"
| "syncing"
| "synced"
| "error"
| "conflict";
message: string | null;
}) {
const labelByStatus: Record<typeof status, string> = {
idle: "Cloud idle",
disabled: "Cloud off",
syncing: "Syncing...",
synced: "Cloud synced",
error: "Sync error",
conflict: "Sync conflict",
};
const classNameByStatus: Record<typeof status, string> = {
idle: "text-muted-foreground",
disabled: "text-muted-foreground",
syncing: "text-foreground",
synced: "text-constructive",
error: "text-destructive",
conflict: "text-amber-500",
};
return (
<div
className={`hidden md:block text-xs ${classNameByStatus[status]}`}
title={message ?? undefined}
>
{labelByStatus[status]}
</div>
);
}
function ProjectDropdown() {
const [openDialog, setOpenDialog] = useState<
"delete" | "rename" | "shortcuts" | null

View File

@ -27,6 +27,17 @@ import {
type MigrationProgress,
} from "@/services/storage/migrations";
import { DEFAULT_TIMELINE_VIEW_STATE } from "@/constants/timeline-constants";
import { authSession } from "@/lib/auth/session";
import {
EditorSyncError,
editorCloudApi,
EditorVersionConflictError,
} from "@/lib/cloud-sync/editor-api";
import {
buildEditorProjectState,
buildLocalProjectFromCloudState,
extractAssetFileIds,
} from "@/lib/cloud-sync/editor-mapper";
export interface MigrationState {
isMigrating: boolean;
@ -35,6 +46,20 @@ export interface MigrationState {
projectName: string | null;
}
export type CloudSyncStatus =
| "idle"
| "disabled"
| "syncing"
| "synced"
| "error"
| "conflict";
export interface CloudSyncState {
status: CloudSyncStatus;
message: string | null;
updatedAt: Date | null;
}
export class ProjectManager {
private active: TProject | null = null;
private savedProjects: TProjectMetadata[] = [];
@ -43,6 +68,12 @@ export class ProjectManager {
private invalidProjectIds = new Set<string>();
private storageMigrationPromise: Promise<void> | null = null;
private listeners = new Set<() => void>();
private remoteVersionByProjectId = new Map<string, number>();
private cloudSyncState: CloudSyncState = {
status: "idle",
message: null,
updatedAt: null,
};
private migrationState: MigrationState = {
isMigrating: false,
fromVersion: null,
@ -106,6 +137,7 @@ export class ProjectManager {
try {
await storageService.saveProject({ project: newProject });
this.remoteVersionByProjectId.set(newProject.metadata.id, 0);
this.updateMetadata(newProject);
return newProject.metadata.id;
@ -127,7 +159,38 @@ export class ProjectManager {
this.editor.scenes.clearScenes();
try {
const result = await storageService.loadProject({ id });
let result = await storageService.loadProject({ id });
if (!result) {
const token = authSession.getToken();
if (token) {
try {
const remoteProject = await editorCloudApi.getProject({
projectId: id,
token,
});
const hydratedProject = buildLocalProjectFromCloudState({
projectId: remoteProject.id,
version: remoteProject.version,
state: remoteProject.state,
updatedAt: remoteProject.updatedAt,
createdAt: remoteProject.createdAt,
});
await storageService.saveProject({ project: hydratedProject });
result = { project: hydratedProject };
this.remoteVersionByProjectId.set(id, remoteProject.version);
} catch (cloudError) {
if (
cloudError instanceof EditorSyncError &&
cloudError.statusCode === 404
) {
throw new Error(`Project with id ${id} not found`);
}
throw cloudError;
}
}
}
if (!result) {
throw new Error(`Project with id ${id} not found`);
}
@ -145,6 +208,7 @@ export class ProjectManager {
}
await this.editor.media.loadProjectMedia({ projectId: id });
void this.initializeRemoteVersionFromCloud({ projectId: id });
if (!project.metadata.thumbnail) {
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
@ -180,6 +244,7 @@ export class ProjectManager {
await storageService.saveProject({ project: updatedProject });
this.active = updatedProject;
this.updateMetadata(updatedProject);
void this.syncProjectToCloud({ project: updatedProject });
} catch (error) {
console.error("Failed to save project:", error);
}
@ -197,11 +262,31 @@ export class ProjectManager {
await this.ensureStorageMigrations();
try {
const metadata = await storageService.loadAllProjectsMetadata();
this.savedProjects = metadata;
const token = authSession.getToken();
if (token) {
const remoteProjects = await editorCloudApi.listProjects({ token });
this.savedProjects = remoteProjects.items.map((project) => ({
id: project.id,
name: project.name,
duration: 0,
createdAt: new Date(project.createdAt),
updatedAt: new Date(project.updatedAt),
}));
} else {
const metadata = await storageService.loadAllProjectsMetadata();
this.savedProjects = metadata;
}
this.notify();
} catch (error) {
console.error("Failed to load projects:", error);
// Fallback to local metadata when cloud list fails.
try {
const metadata = await storageService.loadAllProjectsMetadata();
this.savedProjects = metadata;
this.notify();
} catch (fallbackError) {
console.error("Failed to load local projects fallback:", fallbackError);
}
} finally {
this.isLoading = false;
this.isInitialized = true;
@ -555,6 +640,10 @@ export class ProjectManager {
return this.migrationState;
}
getCloudSyncState(): CloudSyncState {
return this.cloudSyncState;
}
setActiveProject({ project }: { project: TProject }): void {
this.active = project;
this.notify();
@ -623,4 +712,92 @@ export class ProjectManager {
private notify(): void {
this.listeners.forEach((fn) => fn());
}
private setCloudSyncState({
status,
message = null,
}: {
status: CloudSyncStatus;
message?: string | null;
}): void {
this.cloudSyncState = {
status,
message,
updatedAt: new Date(),
};
this.notify();
}
private async syncProjectToCloud({ project }: { project: TProject }): Promise<void> {
const token = authSession.getToken();
if (!token) {
this.setCloudSyncState({
status: "disabled",
message: "Login required for cloud sync",
});
return;
}
this.setCloudSyncState({ status: "syncing", message: null });
const projectId = project.metadata.id;
const baseVersion = this.remoteVersionByProjectId.get(projectId) ?? 0;
try {
const response = await editorCloudApi.putProject({
projectId,
token,
payload: {
name: project.metadata.name,
baseVersion,
state: buildEditorProjectState({ project }),
assetFileIds: extractAssetFileIds({ project }),
clientRequestId: `${projectId}:${project.metadata.updatedAt.toISOString()}`,
},
});
this.remoteVersionByProjectId.set(projectId, response.version);
this.setCloudSyncState({ status: "synced", message: null });
} catch (error) {
if (error instanceof EditorVersionConflictError) {
this.remoteVersionByProjectId.set(projectId, error.serverVersion);
this.setCloudSyncState({
status: "conflict",
message: "Cloud version conflict. Save again to overwrite with local state.",
});
return;
}
if (error instanceof EditorSyncError) {
this.setCloudSyncState({
status: "error",
message: error.message,
});
return;
}
this.setCloudSyncState({
status: "error",
message: "Cloud sync failed",
});
}
}
private async initializeRemoteVersionFromCloud({
projectId,
}: {
projectId: string;
}): Promise<void> {
const token = authSession.getToken();
if (!token) return;
try {
const response = await editorCloudApi.getProject({ projectId, token });
this.remoteVersionByProjectId.set(projectId, response.version);
} catch (error) {
if (error instanceof EditorSyncError && error.statusCode === 404) {
this.remoteVersionByProjectId.set(projectId, 0);
}
}
}
}

View File

@ -1,3 +1,5 @@
import { getBackendApiBaseUrl } from "@/lib/backend-api";
export type TBackendAuthError = {
status?: string;
message?: string;
@ -42,14 +44,6 @@ export class BackendAuthError extends Error {
}
}
const getAuthApiBaseUrl = () => {
const configuredBase = process.env.NEXT_PUBLIC_AUTH_API_BASE_URL;
return (configuredBase && configuredBase.length > 0 ? configuredBase : "/api").replace(
/\/$/,
"",
);
};
const unwrapSuccessPayload = <T,>(payload: TAuthSuccessPayload<T>): T => {
if (payload && typeof payload === "object" && "data" in payload) {
return payload.data;
@ -88,7 +82,7 @@ async function request<T>({
body?: unknown;
token?: string | null;
}): Promise<T> {
const url = `${getAuthApiBaseUrl()}${path}`;
const url = `${getBackendApiBaseUrl()}${path}`;
const response = await fetch(url, {
method,
credentials: "include",

View File

@ -0,0 +1,21 @@
export const getBackendApiBaseUrl = () => {
const configuredBase = process.env.NEXT_PUBLIC_AUTH_API_BASE_URL;
return (configuredBase && configuredBase.length > 0 ? configuredBase : "/api").replace(
/\/$/,
"",
);
};
const WORKSPACE_STORAGE_KEY = "opencut_workspace_id";
export const getSelectedWorkspaceId = (): string | null => {
if (typeof window === "undefined") return null;
const fromStorage = window.localStorage.getItem(WORKSPACE_STORAGE_KEY);
return fromStorage || null;
};
export const setSelectedWorkspaceId = ({ workspaceId }: { workspaceId: string }) => {
if (typeof window === "undefined") return;
window.localStorage.setItem(WORKSPACE_STORAGE_KEY, workspaceId);
};

View File

@ -0,0 +1,246 @@
import {
getBackendApiBaseUrl,
getSelectedWorkspaceId,
setSelectedWorkspaceId,
} from "@/lib/backend-api";
import { backendAuthApi } from "@/lib/auth/api-client";
import { authSession } from "@/lib/auth/session";
export type ISODateString = string;
export type UUID = string;
export interface EditorProjectState {
schemaVersion: number;
currentSceneId: string;
metadata: {
id: string;
name: string;
duration: number;
updatedAt: ISODateString;
};
settings: Record<string, unknown>;
scenes: Array<Record<string, unknown>>;
}
export interface PutEditorProjectRequest {
name: string;
baseVersion: number;
state: EditorProjectState;
assetFileIds: UUID[];
clientRequestId?: string;
}
export interface PutEditorProjectResponse {
id: UUID;
version: number;
updatedAt: ISODateString;
}
export interface EditorProjectListItem {
id: UUID;
workspaceId: string;
ownerId: number;
name: string;
version: number;
updatedAt: ISODateString;
createdAt: ISODateString;
}
export interface ListEditorProjectsResponse {
items: EditorProjectListItem[];
total: number;
}
export interface GetEditorProjectResponse {
id: UUID;
workspaceId: string;
ownerId: number;
name: string;
version: number;
state: EditorProjectState;
updatedAt: ISODateString;
createdAt: ISODateString;
}
export class EditorSyncError extends Error {
public readonly statusCode: number;
constructor({ message, statusCode }: { message: string; statusCode: number }) {
super(message);
this.name = "EditorSyncError";
this.statusCode = statusCode;
}
}
export class EditorVersionConflictError extends Error {
public readonly serverVersion: number;
public readonly serverUpdatedAt: string;
constructor({
message,
serverVersion,
serverUpdatedAt,
}: {
message: string;
serverVersion: number;
serverUpdatedAt: string;
}) {
super(message);
this.name = "EditorVersionConflictError";
this.serverVersion = serverVersion;
this.serverUpdatedAt = serverUpdatedAt;
}
}
const requestWithAuthRetry = async <T>({
path,
method,
token,
body,
}: {
path: string;
method: "GET" | "PUT";
token: string;
body?: unknown;
}): Promise<T> => {
const resolveWorkspaceId = async ({
currentToken,
}: {
currentToken: string;
}): Promise<string> => {
const selectedWorkspaceId = getSelectedWorkspaceId();
if (selectedWorkspaceId) {
return selectedWorkspaceId;
}
const workspaces = await backendAuthApi.getWorkspaces({ token: currentToken });
const firstWorkspace = workspaces[0];
if (!firstWorkspace) {
throw new EditorSyncError({
message: "No workspace available for this account.",
statusCode: 400,
});
}
setSelectedWorkspaceId({ workspaceId: firstWorkspace.id });
return firstWorkspace.id;
};
const runRequest = async ({
currentToken,
}: {
currentToken: string;
}): Promise<Response> => {
const workspaceId = await resolveWorkspaceId({ currentToken });
return fetch(`${getBackendApiBaseUrl()}${path}`, {
method,
credentials: "include",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${currentToken}`,
"x-workspace-id": workspaceId,
},
body: body ? JSON.stringify(body) : undefined,
});
};
let response = await runRequest({ currentToken: token });
if (response.status === 401) {
try {
const refreshed = await backendAuthApi.refresh();
authSession.setToken({ token: refreshed.token });
response = await runRequest({ currentToken: refreshed.token });
} catch {
throw new EditorSyncError({
message: "Session expired",
statusCode: 401,
});
}
}
const responseJson = (await response.json().catch(() => null)) as
| Record<string, unknown>
| null;
if (!response.ok) {
if (response.status === 409 && responseJson?.error === "VERSION_CONFLICT") {
throw new EditorVersionConflictError({
message:
typeof responseJson.message === "string"
? responseJson.message
: "Project has a newer version on server",
serverVersion:
typeof responseJson.serverVersion === "number"
? responseJson.serverVersion
: 0,
serverUpdatedAt:
typeof responseJson.serverUpdatedAt === "string"
? responseJson.serverUpdatedAt
: "",
});
}
throw new EditorSyncError({
message:
typeof responseJson?.message === "string"
? responseJson.message
: `Editor API request failed (${response.status})`,
statusCode: response.status,
});
}
return responseJson as T;
};
export const editorCloudApi = {
listProjects: ({
token,
offset = 0,
limit = 50,
search,
}: {
token: string;
offset?: number;
limit?: number;
search?: string;
}) => {
const params = new URLSearchParams({
offset: String(offset),
limit: String(limit),
});
if (search) {
params.set("search", search);
}
return requestWithAuthRetry<ListEditorProjectsResponse>({
path: `/editor/projects?${params.toString()}`,
method: "GET",
token,
});
},
getProject: ({
projectId,
token,
}: {
projectId: string;
token: string;
}) =>
requestWithAuthRetry<GetEditorProjectResponse>({
path: `/editor/projects/${projectId}`,
method: "GET",
token,
}),
putProject: ({
projectId,
token,
payload,
}: {
projectId: string;
token: string;
payload: PutEditorProjectRequest;
}) =>
requestWithAuthRetry<PutEditorProjectResponse>({
path: `/editor/projects/${projectId}`,
method: "PUT",
token,
body: payload,
}),
};

View File

@ -0,0 +1,109 @@
import type { TProject } from "@/types/project";
import type { TimelineTrack } from "@/types/timeline";
import { hasMediaId } from "@/lib/timeline/element-utils";
import type { EditorProjectState } from "./editor-api";
import type { TScene } from "@/types/timeline";
const stripAudioBuffers = ({ tracks }: { tracks: TimelineTrack[] }): TimelineTrack[] =>
tracks.map((track) => {
if (track.type !== "audio") return track;
return {
...track,
elements: track.elements.map((element) => {
const { buffer: _buffer, ...rest } = element;
return rest;
}),
};
});
const toPlainJson = <T,>(value: T): T =>
JSON.parse(
JSON.stringify(value, (_key, item) => {
if (item instanceof Date) {
return item.toISOString();
}
return item;
}),
) as T;
export const buildEditorProjectState = ({
project,
}: {
project: TProject;
}): EditorProjectState => {
const scenes = project.scenes.map((scene) => ({
...scene,
tracks: stripAudioBuffers({ tracks: scene.tracks }),
}));
return toPlainJson({
schemaVersion: project.version,
currentSceneId: project.currentSceneId,
metadata: {
id: project.metadata.id,
name: project.metadata.name,
duration: project.metadata.duration,
updatedAt: project.metadata.updatedAt.toISOString(),
},
settings: project.settings as unknown as Record<string, unknown>,
scenes: scenes as unknown as Array<Record<string, unknown>>,
}) as EditorProjectState;
};
export const extractAssetFileIds = ({ project }: { project: TProject }): string[] => {
const ids = new Set<string>();
for (const scene of project.scenes) {
for (const track of scene.tracks) {
for (const element of track.elements) {
if (hasMediaId(element)) {
ids.add(element.mediaId);
}
}
}
}
return Array.from(ids);
};
export const buildLocalProjectFromCloudState = ({
projectId,
version,
state,
updatedAt,
createdAt,
}: {
projectId: string;
version: number;
state: EditorProjectState;
updatedAt: string;
createdAt: string;
}): TProject => {
const now = new Date();
const scenes = (state.scenes as unknown as TScene[]).map((scene) => ({
...scene,
createdAt:
scene.createdAt instanceof Date
? scene.createdAt
: new Date((scene.createdAt as unknown as string) ?? now),
updatedAt:
scene.updatedAt instanceof Date
? scene.updatedAt
: new Date((scene.updatedAt as unknown as string) ?? now),
bookmarks: Array.isArray(scene.bookmarks) ? scene.bookmarks : [],
}));
return {
metadata: {
id: projectId,
name: state.metadata.name,
duration: state.metadata.duration,
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
},
currentSceneId: state.currentSceneId,
settings: state.settings as unknown as TProject["settings"],
scenes,
version: Math.max(1, version),
};
};

File diff suppressed because one or more lines are too long

View File

@ -83,6 +83,7 @@
"zustand": "^5.0.2",
},
"devDependencies": {
"@playwright/test": "^1.54.2",
"@storybook/addon-a11y": "^8.6.14",
"@storybook/addon-essentials": "^8.6.15",
"@storybook/react": "^8.6.15",
@ -373,6 +374,8 @@
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
@ -1301,6 +1304,10 @@
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
"polished": ["polished@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.17.8" } }, "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
@ -1753,6 +1760,8 @@
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],

View File

@ -18,7 +18,9 @@
"format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000",
"test": "bun test",
"test:web": "turbo run test --filter=@opencut/web",
"test:web:coverage": "turbo run test:coverage --filter=@opencut/web"
"test:web:coverage": "turbo run test:coverage --filter=@opencut/web",
"test:e2e:web": "bun run --cwd apps/web test:e2e",
"test:e2e:web:ui": "bun run --cwd apps/web test:e2e:ui"
},
"dependencies": {
"@types/react": "^19.2.10",

View File

@ -20,10 +20,14 @@ Provide reliable sync of project state and media assets across devices/accounts.
## Technical Plan
1. Backend API
- `GET /api/projects`
- `GET /api/projects/:id`
- `PUT /api/projects/:id` (optimistic concurrency)
- `POST /api/projects/:id/media/presign`
- Editor state endpoints (timeline, config, and project snapshot data):
- `GET /api/editor/projects`
- `GET /api/editor/projects/:id`
- `PUT /api/editor/projects/:id` (optimistic concurrency)
- File asset endpoints (reuse existing file service):
- Base path: `/api/files`
- Use existing endpoints for upload/list/download/metadata (`/api/files`, `/api/files/:id`, `/api/files/:id/download`, etc.)
- Keep media upload/download concerns in file service; editor project payload stores file references (`fileId`/`key`) instead of raw binaries.
2. Client Sync Service
- Hook into existing autosave flow after local save success.

View File

@ -0,0 +1,188 @@
# Editor Backend API Spec (`/api/editor`)
## Purpose
Provide a backend contract for storing and syncing editor project state (timeline, scenes, settings, metadata).
## Scope
- Persist editor state as JSON snapshots.
- Support optimistic concurrency for autosave sync.
- Support project listing and retrieval across devices.
## Out of Scope (v1)
- Real-time collaborative editing.
- Server-side timeline merge.
- Asset transcoding.
## Auth and Tenancy
- All endpoints require `Authorization: Bearer <JWT>`.
- All reads/writes are scoped to the authenticated user and workspace context.
- Return `401` when JWT/context is invalid.
- Return `404` when project does not exist in the caller workspace.
## Data Model
### `editor_projects`
- `id` UUID PK
- `workspace_id` string not null
- `owner_id` bigint/int not null
- `name` string(255) not null
- `state_json` JSONB not null
- `version` bigint not null default `1`
- `created_at` timestamptz not null
- `updated_at` timestamptz not null
- `deleted_at` timestamptz nullable
Indexes:
- `(workspace_id, updated_at desc)`
- `(workspace_id, owner_id, updated_at desc)`
- Optional GIN on `state_json` if querying inside JSON is needed later.
## Project State Envelope (stored in `state_json`)
Use a versioned envelope so backend can validate shape at a high level while keeping timeline details client-owned.
```json
{
"schemaVersion": 3,
"currentSceneId": "scene_main",
"metadata": {
"id": "proj_123",
"name": "My Project",
"duration": 42.5,
"updatedAt": "2026-02-09T16:00:00.000Z"
},
"settings": {},
"scenes": []
}
```
Validation (v1):
- `schemaVersion` required integer.
- `scenes` required array.
- JSON payload size limit: recommend `<= 5 MB` (return `413` if exceeded).
- Backend should not mutate timeline internals except metadata timestamps/version fields it owns.
## API Contract
Base path: `/api/editor`
### 1) List Projects
`GET /api/editor/projects?offset=0&limit=20&search=my`
Query:
- `offset` optional int `>= 0`, default `0`
- `limit` optional int `1..100`, default `20`
- `search` optional string (name contains)
Response `200`:
```json
{
"items": [
{
"id": "uuid",
"workspaceId": "ws_123",
"ownerId": 1,
"name": "My Project",
"version": 8,
"updatedAt": "2026-02-09T16:00:00.000Z",
"createdAt": "2026-02-08T16:00:00.000Z"
}
],
"total": 1
}
```
### 2) Get Project
`GET /api/editor/projects/:id`
Response `200`:
```json
{
"id": "uuid",
"workspaceId": "ws_123",
"ownerId": 1,
"name": "My Project",
"version": 8,
"state": {},
"updatedAt": "2026-02-09T16:00:00.000Z",
"createdAt": "2026-02-08T16:00:00.000Z"
}
```
Errors:
- `404` project not found in workspace
### 3) Upsert Project State (Optimistic Concurrency)
`PUT /api/editor/projects/:id`
Request:
```json
{
"name": "My Project",
"baseVersion": 8,
"state": {},
"clientRequestId": "optional-idempotency-key"
}
```
Rules:
- If project does not exist, allow create when caller has write access. New record starts at `version = 1`.
- If project exists, `baseVersion` must equal current `version`.
- On success, increment version by 1 (or set to 1 for create), set `updated_at = now()`.
- If `baseVersion` mismatch, return conflict.
Success `200`:
```json
{
"id": "uuid",
"version": 9,
"updatedAt": "2026-02-09T16:00:00.000Z"
}
```
Conflict `409`:
```json
{
"error": "VERSION_CONFLICT",
"message": "Project has a newer version on server",
"serverVersion": 10,
"serverUpdatedAt": "2026-02-09T16:02:00.000Z"
}
```
Validation errors:
- `400` invalid payload
- `413` payload too large
## Sync and Conflict Behavior (Client Contract)
- Client reads project `version` from `GET /api/editor/projects/:id`.
- Autosave sends `PUT` with `baseVersion`.
- On `409`, client fetches latest server project and prompts user: keep local or server (v1 policy).
- Do not silently overwrite newer server versions.
## Security and Limits
- Enforce workspace isolation on every query.
- Validate project ownership or workspace role before writes.
- Rate limit write endpoint (`PUT`) to protect autosave storms.
- Sanitize/validate `name` length and UTF-8 validity.
## Observability
- Log structured fields: `workspaceId`, `projectId`, `userId`, `version`, `status`, latency.
- Emit metrics:
- `editor_api_put_success_total`
- `editor_api_put_conflict_total`
- `editor_api_put_validation_error_total`
- `editor_api_payload_bytes`
## Suggested Backend Tasks
1. Add DB migration for `editor_projects`.
2. Implement `GET /api/editor/projects`.
3. Implement `GET /api/editor/projects/:id`.
4. Implement `PUT /api/editor/projects/:id` with optimistic concurrency transaction.
5. Add request/response validation schema and shared error format.
6. Add integration tests for auth, tenancy, conflict, payload size, and create/update flows.
## Acceptance Criteria
- Project created on device A appears in list on device B.
- Autosave updates increase `version` monotonically.
- Concurrent writes produce deterministic `409` conflict responses.
- Unauthorized cross-workspace access is blocked.

View File

@ -0,0 +1,247 @@
# Frontend-Backend Contract for Cloud Editor Sync
## Audience
- Backend engineers who are not familiar with this frontend codebase.
- Frontend engineers integrating cloud sync with `EditorCore`.
## Context: How the Frontend Editor Works
- Frontend editor state is managed by a singleton `EditorCore`.
- React components use `useEditor()` and read/write through editor managers.
- Local save is offline-first and remains the immediate source of truth.
- Cloud sync runs after local save and mirrors the same state to backend.
## What Backend Must Treat as Source of Truth
- `state` payload sent by frontend is the canonical editor snapshot.
- Backend stores it as opaque JSON (`state_json`) with light validation only.
- Backend must not rewrite timeline internals (track layout, element ordering, etc).
## API Base Paths
- Editor state API: `/api/editor`
- File binary API: `/api/files` (already exists)
## TypeScript Contract (Shared Shapes)
```ts
export type ISODateString = string;
export type UUID = string;
export interface EditorProjectState {
schemaVersion: number;
currentSceneId: string;
metadata: {
id: string;
name: string;
duration: number;
updatedAt: ISODateString;
};
settings: Record<string, unknown>;
scenes: Array<Record<string, unknown>>;
}
export interface EditorProjectListItem {
id: UUID;
workspaceId: string;
ownerId: number;
name: string;
version: number;
updatedAt: ISODateString;
createdAt: ISODateString;
}
export interface ListEditorProjectsResponse {
items: EditorProjectListItem[];
total: number;
}
export interface GetEditorProjectResponse {
id: UUID;
workspaceId: string;
ownerId: number;
name: string;
version: number;
state: EditorProjectState;
updatedAt: ISODateString;
createdAt: ISODateString;
}
export interface PutEditorProjectRequest {
name: string;
baseVersion: number;
state: EditorProjectState;
assetFileIds: UUID[];
clientRequestId?: string;
}
export interface PutEditorProjectResponse {
id: UUID;
version: number;
updatedAt: ISODateString;
}
export interface VersionConflictResponse {
error: "VERSION_CONFLICT";
message: string;
serverVersion: number;
serverUpdatedAt: ISODateString;
}
```
## Endpoint Details
### `GET /api/editor/projects`
Purpose:
- Load project picker and recent projects list.
Query:
- `offset` default `0`
- `limit` default `20`, max `100`
- `search` optional
Returns:
- `200` with `ListEditorProjectsResponse`
### `GET /api/editor/projects/:id`
Purpose:
- Open project on app load or after conflict.
Returns:
- `200` with `GetEditorProjectResponse`
- `404` if not in workspace
### `PUT /api/editor/projects/:id`
Purpose:
- Autosave cloud snapshot with optimistic concurrency.
Request:
- `PutEditorProjectRequest`
Returns:
- `200` with `PutEditorProjectResponse`
- `409` with `VersionConflictResponse` when `baseVersion` mismatches current
- `400` invalid body
- `401` invalid auth
- `404` project/workspace not found
- `413` payload too large
## Required Backend Semantics
### Optimistic concurrency
- Compare `baseVersion` with current server `version`.
- If equal: write state and increment version atomically.
- If different: reject with `409 VERSION_CONFLICT`.
### Idempotency (recommended)
- Respect `clientRequestId` for repeated retries of the same autosave.
- If same `(projectId, clientRequestId)` is seen again, return prior success response.
### Transaction behavior
- `PUT` should run in one transaction:
1. Read current row with lock.
2. Validate `baseVersion`.
3. Update `name`, `state_json`, `version`, `updated_at`.
4. Upsert `editor_project_assets` from `assetFileIds` (if implemented).
## File Service Integration (`/api/files`)
### Frontend upload sequence
1. User adds media file in editor.
2. Frontend calls existing `POST /api/files` with filename/contentType/size.
3. Frontend uploads binary to returned `uploadUrl`.
4. Frontend stores returned `file.id` in timeline element (`fileId`).
5. Next autosave sends `assetFileIds` including that `fileId`.
### Backend expectations
- Do not add editor-specific binary upload endpoint.
- Validate `assetFileIds` belong to same workspace (strongly recommended).
- Keep editor API and file API responsibilities separate.
## Frontend Sync Lifecycle (Backend Should Support)
### A) Open project
1. Frontend requests `GET /api/editor/projects/:id`.
2. Frontend hydrates `EditorCore` from `state`.
3. Frontend stores returned `version` as `remoteVersion`.
### B) Autosave success path
1. Local save completes.
2. Frontend builds snapshot from `EditorCore`.
3. Frontend calls `PUT` with `baseVersion = remoteVersion`.
4. Backend returns new `version`.
5. Frontend updates `remoteVersion`.
### C) Autosave conflict path
1. Frontend sends `PUT`.
2. Backend returns `409 VERSION_CONFLICT`.
3. Frontend fetches latest `GET /api/editor/projects/:id`.
4. Frontend prompts user: keep local or use server.
5. Frontend resolves by re-saving chosen state with latest `baseVersion`.
## Non-Functional Requirements
- P95 `PUT` latency target: under 300ms for normal payloads.
- Support bursty autosave traffic (same project every few seconds).
- Enforce per-user/workspace rate limiting without breaking normal autosave.
## Validation Rules (Minimum)
- `name`: required, string, max 255
- `baseVersion`: required, integer, `>= 0`
- `state.schemaVersion`: required integer
- `state.scenes`: required array
- `assetFileIds`: required array (can be empty), each UUID format
## Error Format (Consistent Across Endpoints)
```json
{
"error": "SOME_ERROR_CODE",
"message": "Human-readable message"
}
```
Conflict is the only error that must include extra fields:
```json
{
"error": "VERSION_CONFLICT",
"message": "Project has a newer version on server",
"serverVersion": 10,
"serverUpdatedAt": "2026-02-09T16:02:00.000Z"
}
```
## Backend Test Cases (Must Implement)
1. Auth required: all `/api/editor/*` return `401` without valid JWT.
2. Workspace isolation: project from another workspace returns `404`.
3. Create/update path: `PUT` creates new and increments version on subsequent saves.
4. Conflict path: stale `baseVersion` returns deterministic `409`.
5. Payload validation: malformed `state` returns `400`.
6. Payload size limit: oversized request returns `413`.
7. File reference check: invalid cross-workspace `assetFileId` rejected.
8. Idempotent retry (if enabled): duplicate `clientRequestId` returns same result.
## Copy/Paste Prompt for Backend Agent
```text
Use these two specs as the source of truth:
- project-management/04a-editor-backend-spec.md
- project-management/04b-editor-frontend-backend-contract.md
Implement `/api/editor` for cloud project sync with optimistic concurrency.
Important: frontend editor state is an opaque JSON snapshot from EditorCore; do not rewrite timeline internals.
Build:
1) Migrations:
- editor_projects
- editor_project_assets (recommended)
2) Endpoints:
- GET /api/editor/projects
- GET /api/editor/projects/:id
- PUT /api/editor/projects/:id
3) Validation and error shapes exactly as documented.
4) Workspace isolation + JWT auth on every endpoint.
5) Integration with existing /api/files via file references only.
6) Integration tests for auth, tenancy, concurrency conflict, validation, and size limits.
Return:
- Migration files
- Route handlers
- Validation schemas
- Tests
- Short implementation notes for frontend consumers
```