Skip to main content

project.json

Designing the project file for a web video editor: schema, versions and autosave

Design the JSON document behind a web video editor: what a project stores, how to version and migrate it, and how to autosave without losing work.

15 min readEngineering

Part 2 of 15Build a browser video editor

project.jsonazelify
Engineering

Designing the project file for a web video editor: schema, versions and autosave

Key takeaways

  • Store intent, not pixels: a project file records clips, times and settings, and points at media instead of embedding it.
  • Give the document an explicit version and write a migration for every change to its shape, so old projects still open.
  • Autosave is a debounced write of a validated snapshot to durable storage, with a clear rule for what wins when two copies disagree.

A video editor project file format is the agreed shape of the data that describes one edit: which clips exist, where they sit on the timeline, which part of each source they play and which settings apply. If you are designing one, the hard questions are less about JSON syntax and more about meaning. What does each number measure? How does the file change over time? What happens when two copies of the same project disagree?

This guide answers those questions with a teaching schema, a few short code blocks and the design choices behind the Azelify editor. It assumes you know basic JavaScript and have heard of JSON, but nothing about how editors save work. Along the way we define every term we use, including schema, serialization, debounce and migration, so you can follow the reasoning even if you are new to all of them. It is one part of our guide to building a browser video editor, which maps how the pieces connect.

What a video editor project file format stores, and why it is not the video#

A project file is the saved description of an edit. It is usually plain text in JSON, a syntax for nested objects, arrays, strings, numbers and booleans. Every browser can parse it, and every server language has a reader for it, which is why it is the default choice for this job.

The project file is not the video. A ten-minute edit might describe a few hundred clips that point at four source files totalling several gigabytes, while the project file itself may be only a few kilobytes. Keeping those two things apart is the first design decision. The project records what the editor should do, and the media stays in its own storage.

Two terms appear throughout this guide. A schema is the written rule for what a valid file looks like: which fields exist, what type each one holds, and which ranges are legal. Serialization is the step that turns the live in-memory object into text or bytes that can be stored or sent, and deserialization is the reverse. A round trip means serializing and loading again. If the result is not identical to the original, the format is losing information, and you have found the first bug to fix.

Store intent, not pixels#

A good project file records intent. It says that this clip plays from second four of its source, starts at second twelve on the timeline, and uses a warm colour filter at sixty per cent strength. It does not record pixels. Pixels are the output of rendering, and rendering can always be repeated from intent. If the file stored rendered frames, every edit would mean replacing a huge block of binary data, and undo would have to keep those blocks too.

The same reasoning applies to the media. The file should name each media item and keep its metadata, but the bytes belong in storage built for large binary data. The table below sums up the split we recommend.

Belongs in the project fileBelongs somewhere else
Clip start, duration, in point and out pointThe video and audio bytes themselves
Track order and track typeDecoded frames and waveform caches
Transform values such as position and scaleRendered preview or export output
A media id and the original file nameThumbnails generated from the footage
Project settings such as width, height and frame rateTemporary files created during export

Some systems go further and store the sequence of changes instead of only the final state. That idea is called event sourcing: you save the events that produced the state, which gives you the full history as a by-product.[1] Most browser editors keep undo history in memory and save only the current state. The lesson still carries over. Record the decisions a person made, because you can always replay them.

A timeline JSON schema, field by field#

A timeline JSON schema is simply the set of fields that describe a timeline, written so that a program can check them. Here is a small teaching schema. It is not our production schema. It is written so that every field is easy to read.

// A clip places a slice of one source file on a track.
interface Clip {
  id: string;        // stable identity, never an array index
  mediaId: string;   // points into the media list, not at bytes
  startTime: number; // where the clip begins on the timeline, in seconds
  inPoint: number;   // where playback starts inside the source, in seconds
  outPoint: number;  // where playback stops inside the source, in seconds
}

interface Track {
  id: string;
  type: "video" | "audio" | "text";
  clips: Clip[];
}

// The whole project: plain data, with no functions and no DOM objects.
interface ProjectDocument {
  version: string; // the schema version this file was written with
  settings: { width: number; height: number; frameRate: number };
  media: Record<string, { id: string; fileName: string; durationSeconds: number }>;
  tracks: Track[];
}

Read the times first. startTime says where the clip begins on the timeline. inPoint and outPoint say where playback starts and stops inside the source file. The clip's length on the timeline is outPoint - inPoint, unless the clip is played at a different speed. A schema can either store a duration or derive one. Deriving it removes one way for two numbers to disagree.

The figure below shows three clips on one track. Each beat states its source range, which makes the difference between timeline position and source position visible.

Fig. 01
Intro0–3s
Hook3–7s
Outro7–12s
0s5s10s
7s · Cut
Three clips on one track, each with its own source rangeIllustrative twelve-second track. The first clip sits at zero to three seconds and plays source seconds zero to three. The second sits at three to seven seconds and plays source seconds four to eight. The third sits at seven to twelve seconds and plays source seconds twenty to twenty-five. A cut marker sits at seven seconds, where the second and third clips meet.123
  1. 1Intro 0–3sin 0s · out 3s
  2. 2Hook 3–7sin 4s · out 8s
  3. 3Outro 7–12sin 20s · out 25s
Three clips on one track, each with its own source range
Illustrative, not data

The order of tracks and clips decides what is visible when they overlap. The timeline data model article covers overlaps, trims and ripple edits in depth.

Times, ids and media references#

Three design choices decide whether a project file survives real editing. Each one looks trivial until it causes a bug.

Seconds, frames or rationals?#

Times on a timeline can be stored three ways. Seconds as floating-point numbers are easy to read, but binary floating point cannot represent every decimal exactly. In JavaScript, 0.1 + 0.2 is not 0.3, so repeated arithmetic on seconds drifts. Frame counts are integers, which is exact, but they only make sense at one frame rate. Change the rate and every stored frame number changes meaning. Rationals store a numerator and a denominator, such as 30000 over 1001 for a common NTSC rate of 29.97 frames per second. They stay exact at any rate, at the cost of more code to read them.

Whichever you choose, write the unit into the field name, such as startSeconds or startFrame. A reader should never have to guess.

Ids, not array positions#

Clips in a track are stored in a list, so a clip's position in that list changes whenever a clip before it is deleted or moved. If a keyframe or a link stores the index three, it will point at the wrong clip after the next edit. Store an id instead: a string that never changes for the life of the clip. Generate it once, when the clip is created, with a random value such as the one returned by crypto.randomUUID().

References to media, not media bytes#

A clip refers to a media item by id, and the media item records the file name, the duration and a key into browser storage. This keeps the project small and lets one source appear in many clips without copying it. The bytes sit in a database for binary data or in the origin private file system, which the storage section below describes.

The rule has a practical consequence. If you copy a project, you must also copy or re-link its media, because the document alone does not contain the video. A JSON file also cannot hold binary data directly. A naive serializer either drops the file or turns it into a huge array of numbers, so keep the document free of blobs from the start.

Validating a project file on load#

Validation is the check that a loaded file matches the schema before the editor trusts it. It matters because a file can come from an older build of your editor, a hand-edited copy, or a save that was only half written. A TypeScript type disappears when the code runs, so you need a runtime check as well.

The zod library (opens in a new tab) lets you write the schema once and get both a parser and a type from it.[2] JSON Schema (opens in a new tab) describes the same kind of rules in a language-neutral way, which helps when a server written in another language must check the file too.[3] The example below uses zod.

import { z } from "zod";

// A runtime schema checks the data we actually received, not just its TypeScript type.
const ClipSchema = z
  .object({
    id: z.string().min(1),
    mediaId: z.string().min(1),
    startTime: z.number().nonnegative(),
    inPoint: z.number().nonnegative(),
    outPoint: z.number().positive(),
  })
  .refine((clip) => clip.outPoint > clip.inPoint, "outPoint must be after inPoint");

const ProjectSchema = z.object({
  version: z.string(),
  settings: z.object({
    width: z.number().int().positive(),
    height: z.number().int().positive(),
    frameRate: z.number().positive(),
  }),
  media: z.record(z.string(), z.object({
    id: z.string(),
    fileName: z.string(),
    durationSeconds: z.number().positive(),
  })),
  tracks: z.array(z.object({
    id: z.string(),
    type: z.enum(["video", "audio", "text"]),
    clips: z.array(ClipSchema),
  })),
});

// Returns a result object instead of throwing, so the caller decides what to show.
export function loadProject(raw: unknown) {
  const result = ProjectSchema.safeParse(raw);
  if (!result.success) {
    return { ok: false as const, message: result.error.issues[0]?.message ?? "invalid project" };
  }
  return { ok: true as const, project: result.data };
}

The zod documentation describes .parse() as throwing a ZodError on invalid input, while .safeParse() returns a plain object that contains either the parsed data or the error.[2] Returning a result keeps the loader's control flow in one place, so the calling code does not need a try block at every call site.

The decision you make after validation is the important one. The figure below shows the logic in plain words.

Fig. 02
Load the document only when its version matches this editorDecision tree for loading a project file. If the file has no version stamp, it is refused. If it has a stamp that matches the editor's current version, the document loads. If the stamp is older or newer, the file is refused and left untouched on disk.yesyesnono
Has a version stamp?
Matches this editor?
Load the document
Refuse, leave file as is
Refuse, leave file as is
  1. Has a version stamp?
    1. yes → Matches this editor?
      1. yes → Load the document
      2. no → Refuse, leave file as is
    2. no → Refuse, leave file as is
Load the document only when its version matches this editor
Illustrative, not data

Refusing is safer than guessing. A guess can silently drop clips, and the person may not notice until the export is wrong.

Schema versioning and migrations#

A version field is a label written into the file, such as 1.0.0, that says which schema produced it. Semantic versioning (opens in a new tab) gives the label a meaning. A MAJOR change breaks compatibility, a MINOR change adds features that stay compatible, and a PATCH change fixes bugs without changing the shape.[4] For project files, a MAJOR bump means some old files need a migration before they can load.

A migration is a function that turns a document from one version into the next. Keep one small migration per step and run them in order. A version 1 file then becomes version 2, and version 3 after that, without a special case for every possible pair of versions. The code below shows a chain for a made-up change. Version 1 stored clips at the top level, and version 2 moved them into a track.

const CURRENT_VERSION = "2.0.0"; // hard-coded constant: bump it when the shape changes

type Doc = Record<string, unknown> & { version: string };
type Migration = { to: string; run: (doc: Doc) => Doc };

// One step per version. Version 1 kept clips at the top level.
const migrations: Record<string, Migration> = {
  "1.0.0": {
    to: "2.0.0",
    run: (doc) => ({
      ...doc,
      version: "2.0.0",
      tracks: [{ id: "video-1", type: "video", clips: doc.clips ?? [] }],
    }),
  },
};

export function upgrade(input: Doc): Doc {
  let current = input;
  while (current.version !== CURRENT_VERSION) {
    const step = migrations[current.version];
    if (!step) throw new Error(`No migration from version ${current.version}`);
    current = step.run(current);
  }
  return current;
}

The chain has two useful properties. Each step is small enough to test on its own, and the loop stops as soon as the file reaches the current version. The loop also throws when it meets a version with no step. That is the behaviour you want, because refusing is better than guessing.

Migrations carry a cost. Every shape change adds a step that must be kept for as long as old files might exist. Teams therefore prefer to change shapes rarely and in batches.

How the Azelify editor handles schema versions#

The Azelify editor applies one strict rule: it never guesses. Every project document carries a version stamp, and the loader checks that stamp before it reads anything else. A document with no stamp, or a stamp the running code does not understand, is refused with a clear error, and the stored copy is left exactly as it was. Nothing is half-loaded, silently patched or overwritten by the next autosave.

The reason is that a wrong guess is worse than a refusal. If a loader quietly fills in missing fields with defaults, the project opens, looks almost right, and then autosave writes the damaged version over the good one. A refusal keeps the original intact, so a later build with the right migration can still open it. The cost is that a shape change has to ship together with the code that understands it, which is exactly the discipline the migration section above describes.

Pair the strict check with migrations and you get both guarantees: old files open through a tested upgrade path, and anything the code cannot explain is stopped at the door instead of being rewritten.

Autosave: a debounced write to IndexedDB and the server#

Autosave saves the project on its own, so the person never has to press a save button. Two ideas make it work. The first is debounce: wait until edits pause, then save once. Ten quick edits in one second should cause one write, not ten. The second is a local draft: write the current document into browser storage right away, so that a crash or a closed tab does not lose the last few seconds of work.

Fig. 03
Autosave writes a local draft, waits for a pause, then savesIllustrative autosave pipeline in five steps. An edit changes the document in memory. A local draft is written to browser storage. The editor waits for a pause in editing. It then sends a snapshot of the document to the server. Finally it checks that the saved copy matches what was sent.
EditChange in memory
DraftLocal IndexedDB copy
DebounceWait for a pause
SaveSend snapshot to server
ConfirmCheck the saved copy
  1. 01 → EditChange in memory
  2. 02 → DraftLocal IndexedDB copy
  3. 03 → DebounceWait for a pause
  4. 04 → SaveSend snapshot to server
  5. 05 → ConfirmCheck the saved copy
Autosave writes a local draft, waits for a pause, then saves
Illustrative, not data[mdn-indexeddb]

The local draft uses IndexedDB. The MDN guide (opens in a new tab) describes it as a way to store data persistently inside the browser, and its API is asynchronous, so every operation returns a promise that you must await.[5] The code below opens a database and writes one draft per project. The explicit version number in indexedDB.open is what lets you run a schema upgrade step later.

// Open (or create) the draft database. The version number lets us
// run onupgradeneeded whenever we change the database layout.
function openDraftDb() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open("project-drafts", 1);
    request.onupgradeneeded = () => {
      request.result.createObjectStore("drafts", { keyPath: "projectId" });
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

// Replace the whole draft for one project. The put call creates or overwrites.
async function saveDraft(projectId, document) {
  const db = await openDraftDb();
  await new Promise((resolve, reject) => {
    const tx = db.transaction("drafts", "readwrite");
    tx.objectStore("drafts").put({ projectId, document, savedAt: Date.now() });
    tx.oncomplete = resolve;
    tx.onerror = () => reject(tx.error);
  });
  db.close();
}

// Storage is finite. A write that exceeds the quota fails, so catch it.
try {
  await saveDraft("project-42", { version: "2.0.0", tracks: [] });
} catch (error) {
  console.warn("Draft not saved:", error.name); // QuotaExceededError is one possible name
}

MDN's storage guide explains that a write which would exceed an origin's quota fails with a QuotaExceededError, and it recommends wrapping storage writes in try...catch blocks.[6] The example does exactly that. The debounce is the other half. It delays the save and passes the most recent copy of the document.

// Wait for a pause in edits, then run the function once with the latest arguments.
function debounce(fn, waitMs) {
  let timer = null;
  return (...args) => {
    clearTimeout(timer); // forget the previous pending call
    timer = setTimeout(() => fn(...args), waitMs);
  };
}

const autosave = debounce((projectId, snapshot) => {
  saveDraft(projectId, snapshot); // local copy first; the server call would follow
}, 2000);

// Called after every edit. structuredClone copies the document now, so the
// saved snapshot reflects this edit even if more edits happen before the timer fires.
autosave("project-42", structuredClone(documentInMemory));

The structuredClone call matters. MDN documents it as the function that copies complex objects, and web.dev (opens in a new tab) explains the same algorithm as a way to deep-copy data safely.[7][8] Without it, the debounced call would hold a reference to an object that keeps changing.

Autosave is continuous and unnamed. A checkpoint, by contrast, is a named save point that a person chooses on purpose. Our editor keeps both: drafts protect the last few seconds, and checkpoints let someone return to a version they chose to keep.

Choosing where each piece of data lives#

Browsers offer several places to store data, and each one suits a different kind of data. The table below compares the four you are most likely to choose between.

StoreGood forWatch out for
localStorageSmall string preferencesStrings only, and limited in size
IndexedDBProject drafts and structured recordsAsynchronous, and subject to the origin's quota
Origin private file systemLarge file-like media bytesCleared with the site's data and not visible to users
Server databaseShared, durable copies of a projectNeeds a network connection and a version check

The Web Storage API (opens in a new tab) stores string-only key and value pairs, which makes it a poor home for a whole project.[9] IndexedDB handles structured records well, and the origin private file system suits large byte streams, although MDN notes that clearing a site's data deletes it.[10] The server is the only store other devices can read. It is also the only one that can enforce a rule across every copy, which is why the conflict rules below depend on it.

Conflicts: which copy wins?#

A conflict happens when two copies of one project both changed. A common case looks like this. You edit a project on a laptop, the save reaches the server, and then you edit the same project in a second tab that still holds the old copy. Three rules keep that situation from becoming a data-loss story.

The first rule is to give each save a revision number and send the revision your edit was based on. The server accepts the save only if its own revision still matches. Otherwise it reports a conflict instead of overwriting newer work.

The second rule is to be careful about retries. A write whose response was lost may already have succeeded, so a blind retry can create a duplicate. In our editor the network layer retries reads but never writes. After a lost write, the editor reads the project back and compares the result with what it sent.

The read-back comparison needs care. A server may issue fresh signed links every time it returns a project, so a naive comparison would call a successful save a conflict. The comparison has to match linked files by the object they point to, not by the link text.

The third rule is that a recovery draft and a server copy that disagree must not be merged silently. Ask the person which to keep, or keep both and let them choose. A silent merge is how work quietly disappears.

Designing a video editor project file format, step by step#

The steps below take you from an empty folder to a working save path. Each one leaves you with something you can run and test.

Step 1: list what a person can change#

Write a plain list of every action in your editor, such as adding a clip, trimming it, moving it or changing a setting. Each action should map to one field or one small group of fields in the schema. If an action has no field, decide whether it is intent that needs saving, or view state that does not.

Step 2: give every entity a stable id#

Every clip, track and media item needs an id that is generated once and never changes. Add a test that saves a project, reloads it and checks that the ids are identical. This single test catches most reference bugs before they reach a user.

Step 3: write the schema and a validator#

Write the types from the teaching schema above, then write a runtime validator that mirrors them. Keep the validator next to the types so a change to one forces a change to the other. Test the validator with at least one invalid file for each rule.

Step 4: stamp a version and write the loader#

Add a version field with one value, and write a loader that refuses any other value. Stamping the version now costs nothing, and it makes every later migration possible. Write the loader so it returns a result object and never leaves the stored copy in a half-changed state.

Step 5: add autosave and a conflict rule#

Add a debounced save with a local draft, using the code above. Then decide what happens when the draft and the server disagree, and write that rule down before you write the code. Test it by saving from two tabs and checking that neither copy is lost.

Frequently asked questions#

Can I store the video inside the project JSON?#

You can, but you should not. JSON cannot hold binary data directly, so the bytes would have to be encoded as text, which makes the file much larger. Every autosave would then upload the whole video again. Store the media separately and refer to it by id.

How often should autosave run?#

A debounce of a couple of seconds is a common starting point. It collapses a burst of edits into one write while keeping the window of possible loss short. Tune the number by measuring how long a save takes on a slow connection.

What is the difference between a schema and a type?#

A TypeScript type is checked when you compile the code and disappears when it runs. A schema is a rule the running program can check against data that arrives from outside, such as a file or a network response. You usually need both, and a library such as zod can produce both from one definition.

Do I need a migration for every change?#

No. Adding an optional field usually needs no migration, because old files simply lack it and your loader can supply a default. A migration is needed when an old file would be read with the wrong meaning, such as when a field moves or changes units.

Why not keep the whole project in localStorage?#

localStorage stores strings only and has a small size limit, so a project with many clips and metadata would crowd it out quickly. IndexedDB handles structured records far better, and it can run asynchronously without freezing the page.

Is JSON fast enough for large projects?#

For a project file of a few megabytes, yes. Parsing and saving are fast enough that the bigger cost is usually the amount of data you send over the network. Keep the document small, keep the media out of it, and measure before you optimise further.

Where to go next#

The undo and redo guide explains how edits made to this document can be reversed without storing a full copy of the project for every change. The timeline data model article shows what the clip fields mean once trims, overlaps and ripple edits enter the picture.

Sources

  1. Reactive Design Patterns, “Event sourcing,” reactivedesignpatterns.com (opens in a new tab) (accessed 2026-10-13). ↩

  2. Zod, “Basics,” zod.dev (opens in a new tab) (accessed 2026-10-13). ↩ ↩[2]

  3. JSON Schema, “JSON Schema,” json-schema.org (opens in a new tab) (accessed 2026-10-13). ↩

  4. Semantic Versioning, “Semantic Versioning 2.0.0,” semver.org (opens in a new tab) (accessed 2026-10-13). ↩

  5. MDN Web Docs, “Using IndexedDB,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  6. MDN Web Docs, “Storage quotas and eviction criteria,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  7. MDN Web Docs, “Window: structuredClone() method,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  8. web.dev, “Deep-copying in JavaScript using structuredClone,” web.dev (opens in a new tab) (accessed 2026-10-13). ↩

  9. MDN Web Docs, “Web Storage API,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

  10. MDN Web Docs, “Origin private file system,” developer.mozilla.org (opens in a new tab) (accessed 2026-10-13). ↩

Build a browser video editor

In this series

  1. 01How to build a video editor in the browser: the complete architecture
  2. 02Designing the project file for a web video editor: schema, versions and autosave
  3. 03Undo and redo in a web editor: the command pattern, explained from zero
  4. 04How a video editor timeline works: tracks, clips, trims and overlaps
  5. 05Importing video files in the browser: probing, codecs, proxies and storage
  6. 06WebCodecs tutorial: decoding video frames in the browser without a video tag
  7. 07Frame-accurate playback in JavaScript: clocks, requestAnimationFrame and A/V sync
  8. 08Compositing video layers with Canvas and WebGL: transforms, blends and effects
  9. 09Text and animated captions in a browser video editor: let CSS do the drawing
  10. 10Drag, resize and rotate handles for a canvas editor: building the transform gizmo
  11. 11Web Audio API for video editors: mixing tracks, drawing waveforms and loudness
  12. 12Exporting MP4 from the browser: WebCodecs encoding, muxing and server fallback
  13. 13Keyframe animation in a web editor: interpolation, easing curves and motion tracking
  14. 14How to test a video renderer: golden images, oracles and property-based tests
  15. 15FFmpeg.wasm vs WebCodecs vs MediaRecorder: choosing a browser video stack

Start a project and watch it save

Make an edit, close the tab and come back to the same timeline.

Open the studio

Keep reading

More in Browser video editor

All guides