fix(editor-sync): persist and restore asset-panel media across devices without timeline usage
This commit is contained in:
parent
322f5d3c4f
commit
0ef5b9b1ea
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue