fix(editor-sync): persist and restore asset-panel media across devices without timeline usage

This commit is contained in:
Stanley Cheung 2026-02-11 14:52:45 +08:00
parent 322f5d3c4f
commit 0ef5b9b1ea
9 changed files with 562 additions and 6 deletions

View File

@ -48,6 +48,8 @@ const CLOUD_PROJECT_RESPONSE = {
},
};
const CLOUD_UPLOAD_URL = "https://presigned-upload.example.com/upload";
async function mockAuthAndCloudEditorApis({
page,
withPutRoute = false,
@ -109,6 +111,55 @@ async function mockAuthAndCloudEditorApis({
await route.fulfill({ status: 405 });
});
await page.route("**/api/files/upload", async (route: Route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
id: "file-1",
workspaceId: "ws-1",
ownerId: 1,
key: `workspaces/ws-1/files/${PROJECT_ID}__media-1__test.png`,
bucket: "bucket",
region: "us-east-1",
fileName: `${PROJECT_ID}__media-1__test.png`,
contentType: "image/png",
size: 100,
createdAt: "2026-02-11T00:00:00.000Z",
deletedAt: null,
uploadUrl: CLOUD_UPLOAD_URL,
}),
});
});
await page.route("**/api/files?**", async (route: Route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [],
total: 0,
}),
});
});
await page.route("**/api/files/sign?**", async (route: Route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
url: "https://download.example.com/file.png",
}),
});
});
await page.route("https://presigned-upload.example.com/**", async (route: Route) => {
await route.fulfill({
status: 200,
body: "",
});
});
}
test.beforeEach(async ({ context, page }) => {
@ -139,7 +190,57 @@ test("editor loads project from cloud when local project is missing", async ({
await expect(page.getByText("Cloud Synced Project")).toBeVisible();
});
test("adding media to timeline triggers cloud sync PUT", async ({ page }) => {
test("editor restores cloud assets even when not used on timeline", async ({
page,
}) => {
await mockAuthAndCloudEditorApis({ page });
await page.route("**/api/files?**", async (route: Route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
id: "file-asset-1",
workspaceId: "ws-1",
ownerId: 1,
key: `workspaces/ws-1/files/${PROJECT_ID}__media-asset-1__asset-only.png`,
bucket: "bucket",
region: "ap-southeast-1",
fileName: `${PROJECT_ID}__media-asset-1__asset-only.png`,
contentType: "image/png",
size: 95,
createdAt: "2026-02-11T00:00:00.000Z",
deletedAt: null,
},
],
total: 1,
}),
});
});
await page.route("https://download.example.com/file.png", async (route: Route) => {
const pngBuffer = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9WlqvNQAAAAASUVORK5CYII=",
"base64",
);
await route.fulfill({
status: 200,
contentType: "image/png",
body: pngBuffer,
});
});
await page.goto(`/editor/${PROJECT_ID}`);
await expect(page.getByText("Cloud Synced Project")).toBeVisible();
await expect(
page.locator("div", { hasText: "asset-only.png" }).first(),
).toBeVisible();
});
test("adding media to assets triggers cloud sync PUT", async ({ page }) => {
await mockAuthAndCloudEditorApis({ page, withPutRoute: true });
const putRequestPromise = page.waitForRequest(
@ -164,11 +265,11 @@ test("adding media to timeline triggers cloud sync PUT", async ({ page }) => {
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");
expect(Array.isArray(putPayload.assetFileIds)).toBe(true);
expect(putPayload.assetFileIds).toContain("file-1");
});

View File

@ -4,6 +4,8 @@ import { storageService } from "@/services/storage/service";
import { generateUUID } from "@/utils/id";
import { videoCache } from "@/services/video-cache/service";
import { hasMediaId } from "@/lib/timeline/element-utils";
import { authSession } from "@/lib/auth/session";
import { editorCloudApi } from "@/lib/cloud-sync/editor-api";
export class MediaManager {
private assets: MediaAsset[] = [];
@ -29,6 +31,7 @@ export class MediaManager {
try {
await storageService.saveMediaAsset({ projectId, mediaAsset: newAsset });
void this.syncMediaAssetToCloud({ projectId, mediaId: newAsset.id });
} catch (error) {
console.error("Failed to save media asset:", error);
this.assets = this.assets.filter((asset) => asset.id !== newAsset.id);
@ -56,6 +59,7 @@ export class MediaManager {
this.assets = this.assets.filter((asset) => asset.id !== id);
this.notify();
this.editor.save.markDirty();
const tracks = this.editor.timeline.getTracks();
const elementsToRemove: Array<{ trackId: string; elementId: string }> = [];
@ -159,4 +163,75 @@ export class MediaManager {
private notify(): void {
this.listeners.forEach((fn) => fn());
}
private async syncMediaAssetToCloud({
projectId,
mediaId,
}: {
projectId: string;
mediaId: string;
}): Promise<void> {
const token = authSession.getToken();
if (!token) return;
const asset = this.assets.find((item) => item.id === mediaId);
if (!asset || asset.cloudFileId || asset.cloudFileKey) {
return;
}
const uploadFileName = `${projectId}__${mediaId}__${asset.name}`;
try {
const uploadResponse = await editorCloudApi.createFileUpload({
token,
payload: {
fileName: uploadFileName,
contentType: asset.file.type || "application/octet-stream",
size: asset.file.size,
},
});
await editorCloudApi.uploadFileToPresignedUrl({
uploadUrl: uploadResponse.uploadUrl,
file: asset.file,
contentType: asset.file.type || "application/octet-stream",
});
let cloudFileId = uploadResponse.id;
if (!cloudFileId) {
const listResponse = await editorCloudApi.listFiles({
token,
limit: 100,
search: uploadFileName,
});
const matchedFile = listResponse.items.find(
(file) => file.key === uploadResponse.key,
);
cloudFileId = matchedFile?.id ?? "";
}
const latestAsset = this.assets.find((item) => item.id === mediaId);
if (!latestAsset) return;
const syncedAsset: MediaAsset = {
...latestAsset,
cloudFileId: cloudFileId || undefined,
cloudFileKey: uploadResponse.key,
cloudSyncedAt: new Date().toISOString(),
};
this.assets = this.assets.map((item) =>
item.id === mediaId ? syncedAsset : item,
);
this.notify();
this.editor.save.markDirty();
await storageService.saveMediaAsset({
projectId,
mediaAsset: syncedAsset,
});
} catch (error) {
console.error("Failed to sync media asset to cloud:", error);
}
}
}

View File

@ -32,12 +32,15 @@ import {
EditorSyncError,
editorCloudApi,
EditorVersionConflictError,
type FileAsset,
} from "@/lib/cloud-sync/editor-api";
import {
buildEditorProjectState,
buildLocalProjectFromCloudState,
extractCloudAssetFileIds,
extractAssetFileIds,
} from "@/lib/cloud-sync/editor-mapper";
import type { MediaAsset } from "@/types/assets";
export interface MigrationState {
isMigrating: boolean;
@ -207,6 +210,11 @@ export class ProjectManager {
});
}
await this.syncMissingLocalMediaFromCloud({
project,
token: authSession.getToken(),
});
await this.editor.media.loadProjectMedia({ projectId: id });
void this.initializeRemoteVersionFromCloud({ projectId: id });
@ -744,6 +752,8 @@ export class ProjectManager {
const baseVersion = this.remoteVersionByProjectId.get(projectId) ?? 0;
try {
const mediaAssets = this.editor.media.getAssets();
const response = await editorCloudApi.putProject({
projectId,
token,
@ -751,7 +761,7 @@ export class ProjectManager {
name: project.metadata.name,
baseVersion,
state: buildEditorProjectState({ project }),
assetFileIds: extractAssetFileIds({ project }),
assetFileIds: extractCloudAssetFileIds({ mediaAssets }),
clientRequestId: `${projectId}:${project.metadata.updatedAt.toISOString()}`,
},
});
@ -800,4 +810,111 @@ export class ProjectManager {
}
}
}
private async syncMissingLocalMediaFromCloud({
project,
token,
}: {
project: TProject;
token: string | null;
}): Promise<void> {
if (!token) return;
const existingLocalMedia = await storageService.loadAllMediaAssets({
projectId: project.metadata.id,
});
const existingMediaIds = new Set(existingLocalMedia.map((asset) => asset.id));
const remoteFiles = await editorCloudApi.listFiles({
token,
limit: 100,
search: project.metadata.id,
});
const remoteFileByMediaId = new Map<string, FileAsset>();
for (const remoteFile of remoteFiles.items) {
const parsed = this.parseCloudFileName({
fileName: remoteFile.fileName,
});
if (!parsed) continue;
if (parsed.projectId !== project.metadata.id) continue;
remoteFileByMediaId.set(parsed.mediaId, remoteFile);
}
for (const [mediaId, remoteFile] of remoteFileByMediaId) {
if (existingMediaIds.has(mediaId)) continue;
if (!remoteFile) continue;
try {
const signed = await editorCloudApi.signFileByKey({
token,
key: remoteFile.key,
});
const blobResponse = await fetch(signed.url);
if (!blobResponse.ok) continue;
const blob = await blobResponse.blob();
const file = new File([blob], remoteFile.fileName, {
type: remoteFile.contentType || blob.type,
lastModified: Date.now(),
});
const fileNameParts = this.parseCloudFileName({
fileName: remoteFile.fileName,
});
await storageService.saveMediaAsset({
projectId: project.metadata.id,
mediaAsset: {
id: mediaId,
name: fileNameParts?.originalFileName ?? remoteFile.fileName,
type: this.getMediaTypeFromContentType({
contentType: remoteFile.contentType,
}),
file,
cloudFileId: remoteFile.id,
cloudFileKey: remoteFile.key,
cloudSyncedAt: new Date().toISOString(),
},
});
} catch (error) {
console.warn("Failed to hydrate remote media file", error);
}
}
}
private parseCloudFileName({
fileName,
}: {
fileName: string;
}):
| {
projectId: string;
mediaId: string;
originalFileName: string;
}
| null {
const firstSeparator = fileName.indexOf("__");
if (firstSeparator <= 0) return null;
const secondSeparator = fileName.indexOf("__", firstSeparator + 2);
if (secondSeparator <= firstSeparator + 2) return null;
const projectId = fileName.slice(0, firstSeparator);
const mediaId = fileName.slice(firstSeparator + 2, secondSeparator);
const originalFileName = fileName.slice(secondSeparator + 2);
if (!projectId || !mediaId || !originalFileName) return null;
return { projectId, mediaId, originalFileName };
}
private getMediaTypeFromContentType({
contentType,
}: {
contentType: string;
}): MediaAsset["type"] {
if (contentType.startsWith("image/")) return "image";
if (contentType.startsWith("video/")) return "video";
if (contentType.startsWith("audio/")) return "audio";
return "video";
}
}

View File

@ -36,6 +36,40 @@ export interface PutEditorProjectResponse {
updatedAt: ISODateString;
}
export interface CreateFileUploadRequest {
fileName: string;
contentType: string;
size?: number;
}
export interface FileAsset {
id: UUID;
workspaceId: string;
ownerId: number;
key: string;
bucket: string;
region: string;
fileName: string;
contentType: string;
size: number;
createdAt: ISODateString;
updatedAt?: ISODateString;
deletedAt: ISODateString | null;
}
export interface CreateFileUploadResponse extends FileAsset {
uploadUrl: string;
}
export interface ListFilesResponse {
items: FileAsset[];
total: number;
}
export interface SignedFileResponse {
url: string;
}
export interface EditorProjectListItem {
id: UUID;
workspaceId: string;
@ -99,7 +133,7 @@ const requestWithAuthRetry = async <T>({
body,
}: {
path: string;
method: "GET" | "PUT";
method: "GET" | "PUT" | "POST";
token: string;
body?: unknown;
}): Promise<T> => {
@ -243,4 +277,81 @@ export const editorCloudApi = {
token,
body: payload,
}),
createFileUpload: ({
token,
payload,
}: {
token: string;
payload: CreateFileUploadRequest;
}) =>
requestWithAuthRetry<CreateFileUploadResponse>({
path: "/files/upload",
method: "POST",
token,
body: payload,
}),
uploadFileToPresignedUrl: async ({
uploadUrl,
file,
contentType,
}: {
uploadUrl: string;
file: File;
contentType: string;
}): Promise<void> => {
const response = await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Type": contentType,
},
body: file,
});
if (!response.ok) {
throw new EditorSyncError({
message: `File upload failed (${response.status})`,
statusCode: response.status,
});
}
},
listFiles: ({
token,
limit = 100,
search,
}: {
token: string;
limit?: number;
search?: string;
}) => {
const params = new URLSearchParams({
limit: String(limit),
});
if (search) {
params.set("search", search);
}
return requestWithAuthRetry<ListFilesResponse>({
path: `/files?${params.toString()}`,
method: "GET",
token,
});
},
signFileByKey: ({
token,
key,
redirect = false,
}: {
token: string;
key: string;
redirect?: boolean;
}) => {
const params = new URLSearchParams({
key,
redirect: String(redirect),
});
return requestWithAuthRetry<SignedFileResponse>({
path: `/files/sign?${params.toString()}`,
method: "GET",
token,
});
},
};

View File

@ -0,0 +1,104 @@
import { describe, expect, test } from "bun:test";
import {
extractAssetFileIds,
extractCloudAssetFileIds,
extractCloudAssetFileIdsForTimeline,
} from "@/lib/cloud-sync/editor-mapper";
import type { TProject } from "@/types/project";
function buildProjectWithMediaElements(): TProject {
return {
metadata: {
id: "project-1",
name: "Project",
duration: 12,
createdAt: new Date("2026-02-10T00:00:00.000Z"),
updatedAt: new Date("2026-02-10T00:00:00.000Z"),
},
currentSceneId: "scene-1",
version: 3,
settings: {
fps: 30,
canvasSize: { width: 1920, height: 1080 },
background: { type: "color", color: "#000000" },
originalCanvasSize: null,
},
scenes: [
{
id: "scene-1",
name: "Main",
isMain: true,
bookmarks: [],
createdAt: new Date("2026-02-10T00:00:00.000Z"),
updatedAt: new Date("2026-02-10T00:00:00.000Z"),
tracks: [
{
id: "track-1",
name: "Video",
type: "video",
isMain: true,
muted: false,
hidden: false,
elements: [
{
id: "el-1",
type: "video",
name: "Clip",
startTime: 0,
duration: 5,
trimStart: 0,
trimEnd: 0,
mediaId: "media-a",
transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
opacity: 1,
},
{
id: "el-2",
type: "image",
name: "Image",
startTime: 6,
duration: 4,
trimStart: 0,
trimEnd: 0,
mediaId: "media-b",
transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
opacity: 1,
},
],
},
],
},
],
};
}
describe("extractCloudAssetFileIds", () => {
test("returns cloud file ids for all assets by default", () => {
const project = buildProjectWithMediaElements();
const mediaAssets = [
{ id: "media-a", cloudFileId: "file-1" },
{ id: "media-b", cloudFileId: undefined },
{ id: "media-c", cloudFileId: "file-3" },
];
expect(
extractCloudAssetFileIds({
mediaAssets,
}),
).toEqual(["file-1", "file-3"]);
expect(
extractCloudAssetFileIdsForTimeline({
project,
mediaAssets,
}),
).toEqual(["file-1"]);
});
test("keeps extractAssetFileIds behavior unchanged", () => {
const project = buildProjectWithMediaElements();
expect(extractAssetFileIds({ project }).sort()).toEqual([
"media-a",
"media-b",
]);
});
});

View File

@ -66,6 +66,45 @@ export const extractAssetFileIds = ({ project }: { project: TProject }): string[
return Array.from(ids);
};
export const extractCloudAssetFileIds = ({
mediaAssets,
includeEphemeral = false,
}: {
mediaAssets: Array<{ id: string; cloudFileId?: string }>;
includeEphemeral?: boolean;
}): string[] => {
const fileIds = new Set<string>();
for (const asset of mediaAssets) {
if (!asset.cloudFileId) continue;
if (!includeEphemeral && "ephemeral" in asset && asset.ephemeral) continue;
fileIds.add(asset.cloudFileId);
}
return Array.from(fileIds);
};
export const extractCloudAssetFileIdsForTimeline = ({
project,
mediaAssets,
}: {
project: TProject;
mediaAssets: Array<{ id: string; cloudFileId?: string }>;
}): string[] => {
const referencedMediaIds = extractAssetFileIds({ project });
const mediaById = new Map(mediaAssets.map((asset) => [asset.id, asset]));
const fileIds = new Set<string>();
for (const mediaId of referencedMediaIds) {
const asset = mediaById.get(mediaId);
if (asset?.cloudFileId) {
fileIds.add(asset.cloudFileId);
}
}
return Array.from(fileIds);
};
export const buildLocalProjectFromCloudState = ({
projectId,
version,

View File

@ -228,6 +228,9 @@ class StorageService {
duration: mediaAsset.duration,
thumbnailUrl: mediaAsset.thumbnailUrl,
ephemeral: mediaAsset.ephemeral,
cloudFileId: mediaAsset.cloudFileId,
cloudFileKey: mediaAsset.cloudFileKey,
cloudSyncedAt: mediaAsset.cloudSyncedAt,
};
await mediaMetadataAdapter.set(mediaAsset.id, metadata);
@ -278,6 +281,9 @@ class StorageService {
duration: metadata.duration,
thumbnailUrl: metadata.thumbnailUrl,
ephemeral: metadata.ephemeral,
cloudFileId: metadata.cloudFileId,
cloudFileKey: metadata.cloudFileKey,
cloudSyncedAt: metadata.cloudSyncedAt,
};
}

View File

@ -26,6 +26,9 @@ export interface MediaAssetData {
fps?: number;
ephemeral?: boolean;
thumbnailUrl?: string;
cloudFileId?: string;
cloudFileKey?: string;
cloudSyncedAt?: string;
}
export type SerializedScene = Omit<TScene, "createdAt" | "updatedAt"> & {

File diff suppressed because one or more lines are too long