VS

Save and load

A document is the project as plain JSON (JavaScript Object Notation): version, project, mediaAssets, tracks, clips, effects, keyframes and transitions. The media files are not in it. saveDocument returns an array next to the document; this page calls it "the media list". One entry of that list is "a media handle".

Save

saveDocument returns the document and the media list, one media handle per stored file.

import type { EditorBackend } from "@vidstudio/engine";

export async function inspect(backend: EditorBackend): Promise<void> {
  const { document, media } = await backend.saveDocument();
  console.log(document.project.name, document.clips.length, "clips");
  for (const item of media) console.log(item.assetId, item.name, item.type, item.bytes, item.hash);
}
Field Meaning
assetId The asset's id inside the document
name The file name the user imported
type "video", "audio" or "image"
bytes The file size
hash "<bytes>:<hex>": the size plus a SHA-256 of the first and last 64 KB
read() A File handle to the stored file; no bytes are read until you upload or stream it

hash is cheap on purpose. It is enough to skip an upload your server already has. It is not a checksum of the whole file. Two files of the same size that differ only in the middle share a hash. Compute your own checksum on the server when that matters. An asset marked missing (see below) has no media handle.

Load

loadDocument(document, resolve) replaces the project's content with the document. It calls resolve(asset) once per asset in document.mediaAssets. Return a File, or a web address the engine downloads. Either one is written to file storage as a stream, and proxies are made again in the background. The document's name and settings replace the project's. Every id is minted again. Undo and redo are cleared.

A web address on another origin needs the headers in Media, fonts and models.

Missing media

When resolve throws, or the download fails, the asset loads with missing: true. Its clips show a flat grey frame. exportProject rejects with the code missing-media while a clip still uses it. The editor opens the project and shows a warning with the count. To repair it, load the document again with a resolver that finds the file, or remove the clips.

Any other failure, such as an unknown effect type, a wrong version, or a full storage, rejects. The files written so far are deleted, and the project stays exactly as it was.

Document versions

version is 1 today. migrateDocument(document) brings an older document up to the current format and returns it; loadDocument calls it for you. A document with another version is refused.

The built-in zip

Without handlers, the editor's "Save project" writes one zip and "Open project" reads one. writeProjectZip(document, media) returns a Blob. The zip is stored uncompressed. It points at the media files instead of copying them, and reads each file once for its checksum. Files and archives past 4 GB use ZIP64. readProjectZip(blob) returns { document, resolve }; each media file comes back as a slice of the zip. A zip re-packed with compression is refused.

import { readProjectZip, writeProjectZip } from "@vidstudio/editor";
import type { EditorBackend } from "@vidstudio/engine";

export async function roundTrip(backend: EditorBackend): Promise<Blob> {
  const { document, media } = await backend.saveDocument();
  const zip = await writeProjectZip(document, media);
  const opened = await readProjectZip(zip);
  await backend.loadDocument(opened.document, opened.resolve);
  return zip;
}

The editor's handlers

onSaveDocument(document, media) replaces the zip download. onOpenDocument() replaces the zip picker and returns { document, resolve }, or null when the user cancelled. An error thrown by either becomes an error toast. features={{ projectFiles: false }} hides both menu entries.

Save to a server

This example uploads each file once, keyed by its hash, then stores the document with the asset-to-hash map. Save it as upload-project.ts.

import type { MediaManifest, ProjectDocument } from "@vidstudio/engine";

async function serverHas(hash: string): Promise<boolean> {
  const response = await fetch(`/api/media/${encodeURIComponent(hash)}`, { method: "HEAD" });
  return response.ok;
}

export async function uploadProject(key: string, document: ProjectDocument, media: MediaManifest): Promise<void> {
  for (const item of media) {
    if (await serverHas(item.hash)) continue;
    // read() is a handle; fetch streams the file from disk.
    const response = await fetch(`/api/media/${encodeURIComponent(item.hash)}`, { method: "PUT", body: await item.read() });
    if (!response.ok) throw new Error(`Uploading ${item.name} failed: ${response.status}`);
  }
  const hashes = Object.fromEntries(media.map((item) => [item.assetId, item.hash]));
  const response = await fetch(`/api/projects/${encodeURIComponent(key)}`, {
    method: "PUT",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ document, hashes }),
  });
  if (!response.ok) throw new Error(`Saving the project failed: ${response.status}`);
}

The server keeps { document, hashes } per project and one file per hash.

Load from a server

This example fetches the document and resolves each asset to its file's web address. Save it as fetch-project.ts.

import type { MediaResolver, ProjectDocument } from "@vidstudio/engine";

export async function fetchProject(key: string): Promise<{ document: ProjectDocument; resolve: MediaResolver }> {
  const response = await fetch(`/api/projects/${encodeURIComponent(key)}`);
  if (!response.ok) throw new Error(`Loading the project failed: ${response.status}`);
  const saved = (await response.json()) as { document: ProjectDocument; hashes: Record<string, string> };
  const resolve: MediaResolver = (asset) => {
    const hash = saved.hashes[asset.id];
    // Throwing marks this one asset missing; the rest of the project still loads.
    if (!hash) throw new Error(`${asset.filename} was never uploaded`);
    return `/api/media/${encodeURIComponent(hash)}`;
  };
  return { document: saved.document, resolve };
}

Wire both into the editor, or call them around the backend.

import { VidstudioEditor, createStore } from "@vidstudio/editor";
import "@vidstudio/editor/editor.css";
import { uploadProject } from "./upload-project";
import { fetchProject } from "./fetch-project";

type Store = Awaited<ReturnType<typeof createStore>>;

export function ServerBackedEditor({ store, projectId }: { store: Store; projectId: string }) {
  return (
    <div style={{ height: "100vh" }}>
      <VidstudioEditor
        store={store}
        projectId={projectId}
        onSaveDocument={(document, media) => uploadProject(projectId, document, media)}
        onOpenDocument={() => fetchProject(projectId)}
      />
    </div>
  );
}

With the backend alone: const saved = await backend.saveDocument(); await uploadProject(key, saved.document, saved.media); to save, and const opened = await fetchProject(key); await backend.loadDocument(opened.document, opened.resolve); to load.