Skip to content
Docs / Overview
Engine & data

Project Documents

A project that has been through JSON.stringify and back is not a Project. It is unknown. 0.6.0 adds the primitives for turning a stored document back into one safely, re-attaching its media, and carrying the media library across a page load.

Reading a Document

readProjectDocument(document, options?) takes anything you read back from storage and returns a Project every consumer can dereference, or throws ProjectDocumentError. It is total: it never returns half a project. The only option is defaultTrackHeight (default 64), used for a track whose stored height is missing or nonsense.

typescript
import {
  readProjectDocument,
  isReadableProjectDocument,
  ProjectDocumentError,
} from '@elah/editor'

try {
  const project = readProjectDocument(JSON.parse(stored))
  engine.loadProject(project)
} catch (err) {
  if (err instanceof ProjectDocumentError) {
    // err.code is 'unreadable' | 'unsupported-version'
    // err.documentVersion is the stamped version when it had a readable one
    showMessage(err.code)   // decide what to say from the code, not message text
  } else {
    throw err               // e.g. JSON.parse failed
  }
}

// Boolean form, for deciding what to show. Restore with readProjectDocument itself.
isReadableProjectDocument(stored)

The split of responsibility is deliberate. Missing things are repaired, because defaulting them loses nothing and opens the project. A missing stage becomes 1080 by 1920, missing transitions become an empty list, a track without a volume gets 1, and positions and lengths are rounded to integer frames. Wrong things are refused, because opening them as empty would be a lie that the first autosave makes permanent. That covers a document that is not an object, has no frame rate, whose tracks is not a list, whose clips is not indexed by track id, that has two tracks with the same id, a track of unknown kind, or a clip with an unknown type, no position or no length.

  • Clips are never dropped.A clip whose media has gone missing is still the user's work. See relinking below.
  • A transition is dropped, not refused, when it no longer makes sense (an unknown kind, or either clip gone). Nothing of the user's is lost, because the clips themselves are untouched.
  • Clip buckets keyed by a track that no longer exists are dropped, because they would stretch the timeline to a length with nothing in it.

Versioning

PROJECT_VERSION is the schema version this build writes and reads, currently 1. It is a promise to every build that reads the document, not the version of your app. The project's own version field carries the stamp.

  • A document with no version predates the stamp and is read as version 1. Refusing it would strand the earliest saved projects.
  • A document from a newer build throws ProjectDocumentError with code: 'unsupported-version'. The fields this build does not know about are exactly the ones it would drop on the next autosave, so it refuses instead.
  • A version that is not a whole number of at least 1 is 'unreadable'.

serializeProject(engine) and deserializeProject(engine, json) are still the simple save and restore pair. They are now a facade over readProjectDocument. deserializeProject throws Not valid project JSON: ... for bad JSON and ProjectDocumentError for an unreadable or too-new document, and leaves the engine untouched in either case. The one behavioural difference from before is the unversioned-document rule above.

Relinking Media

A clip carries both a src (what the renderer plays) and an assetId (what the timeline reads its filmstrip and intrinsic size from). Only src survives a save. The media library is rebuilt on every page load and hands out fresh ids, so a restored clip's assetId names an asset that no longer exists. The composition still plays, because the resolver reads src, but the clip has no filmstrip and its selection box falls back to the whole stage.

relinkProjectMedia(project, assets) repairs that by matching on src against whatever the library holds. It is pure, so you decide when to apply it, which matters because the library fills asynchronously after the composition is already on screen.

typescript
import {
  relinkProjectMedia,
  missingMediaSummary,
  isRecoverableMediaSrc,
  mediaLibraryStore,
} from '@elah/editor'

const assets = Object.values(mediaLibraryStore.getState().assets)
const { project, relinked, missing } = relinkProjectMedia(engine.getProject(), assets)

// project is the SAME object reference as the input when nothing changed.
if (relinked > 0) {
  engine.loadProject(project, { transport: 'keep', history: 'keep' })
}

// missing: { clipId, clipName, kind: 'video' | 'audio' | 'image' }[]
const names = missingMediaSummary(missing) // null | 'A' | 'A and B' | 'A, B and 3 more'
if (names) showToast(`Re-add the files for: ${names}`)

isRecoverableMediaSrc('blob:https://example.com/abc') // false
isRecoverableMediaSrc('https://cdn.example.com/a.mp4') // true
  • A blob: or data: source cannot be fetched again in a later session. It was minted by URL.createObjectURL when the user imported a file from their device and dies with the page that created it. isRecoverableMediaSrc(src) names that case.
  • Clips whose source cannot be recovered are reported in missing and left exactly where they are, with name, position and length intact. They are never dropped, because that is the one outcome the user can neither see nor undo.
  • missingMediaSummary encodes the rule that keeps the message readable on a project with thirty broken clips: name up to two, count the rest. It returns null for an empty list.

loadProject & project:loaded

engine.loadProject(project, options?) replaces the whole composition. It takes a valid Project, so anything that arrived as JSON goes through readProjectDocumentfirst. That is where an unreadable or too-new document is refused, and it is separate so the refusal can be shown before the editor's contents are thrown away.

typescript
type LoadProjectTransport = 'rewind' | 'keep'
type LoadProjectHistory = 'reset' | 'keep'

engine.loadProject(project, {
  transport: 'rewind', // default. 'keep' leaves the playhead where it is
  history: 'reset',    // default. 'keep' leaves undo/redo and any open drag intact
})

// Emitted after 'change' and 'history:change', so stores are already synced.
engine.on('project:loaded', ({ project, transport }) => {
  // ProjectLoadedEvent = { project: Project; transport: LoadProjectTransport }
})

Opening or reloading a document uses the defaults. The playhead belonged to a composition that is gone, so it should stop and return to frame 0, and the document that just arrived is the history's starting point, so ctrl+Z must not walk back into the empty timeline that existed before it. PlaybackEngine is a separate object that the engine deliberately does not know about, which is why project:loaded carries the transport instruction. EditorProvider subscribes to it and does the rewind for you. If you build your own composition layer, do the same.

Why the relink pass uses history: 'keep'

The relink pass does not bring a new composition. It hands back the one already on screen with internal library references repaired, and it lands whenever the project's assets finish importing, which can be long after the open. By then the user may have trimmed a clip, added a title, or be mid-drag. Resetting history would throw away real undo steps to swap an internal reference, and an open drag still has a commitInteraction() coming. So pass { transport: 'keep', history: 'keep' }: the playhead does not jump, and the undo stacks, the interaction snapshot and any open batch survive. Entries already on the stacks still hold the pre-repair references. Undoing past the repair costs a filmstrip, not work, and those ids are session-scoped anyway.

Media Library Snapshots

The media library is module-scoped and starts empty on every load. A clip plays from its own src and never asks the library for anything, so what does not survive is everything only the library held: the filmstrip, the waveform, the real duration and dimensions. A restored composition comes back with grey boxes where the thumbnails were. Two functions carry the library across, and storing the result is up to you.

typescript
import {
  mediaLibraryStore,
  snapshotMediaLibrary,
  hydrateMediaLibrary,
  refreshMissingThumbnails,
} from '@elah/editor'

// Save: something storable. Assets still being probed (status 'pending') are skipped,
// because their duration and size are provisional guesses.
const entries = snapshotMediaLibrary(mediaLibraryStore.getState())
await myStore.put(entries)

// Restore: keeps the stored ids, so restored clips' assetId references resolve directly.
const referencedSrcs = new Set(
  Object.values(project.clips).flat().flatMap((c) => (c.src ? [c.src] : [])),
)
const { hydrated, needsThumbnail } = hydrateMediaLibrary(await myStore.get(), {
  referencedSrcs,
})

// Fire-and-forget: decode thumbnails for entries that came back without them.
refreshMissingThumbnails(needsThumbnail)
  • hydrateMediaLibrary never overwrites. An asset already registered under the same id or the same src came from this session and is fresher than anything stored.
  • An entry whose source is dead (blob: or data:) is restored only when a clip still points at it, which is what referencedSrcs is for. Its stored filmstrip is the one thing that keeps that clip from being an anonymous grey rectangle.
  • needsThumbnail lists hydrated ids that came back without thumbnails and whose source can still be fetched. refreshMissingThumbnails(ids, schedule?) asks for a decode of each. It is cosmetic: the composition plays whether or not they land. scheduleThumbnailById(assetId) is the single-asset call it uses.
  • relinkProjectMedia stays the fallback for anything that comes back by src rather than by id, such as a re-import or a snapshot that predates the clip.

Related media-library helpers added in 0.6.0, covered with the clip workflow on the Clips page: beginImportUrl(url, opts?) registers a remote URL as a status: 'pending' asset immediately and resolves it later. probeHasAudio(src) reads the container header (range requests for http sources, so not the media payload) and answers whether a video carries an audio track. determineAssetHasAudio(assetId) memoizes that per asset and writes hasAudio back to the library, and hasAudioDetermined(assetId) says whether the container probe has already answered, so weaker signals do not overwrite it (it is exported from @elah/core but not re-exported by the @elah/editor barrel). beginImportUrl starts the determination itself for video, so a caller that imports and inserts in one click gets a real answer rather than the placeholder.

Browser Persistence

Not part of the packages

The IndexedDB persistence on elah.dev itself is application code, not part of @elah/*. The packages give you the snapshot and relink primitives above. They deliberately know nothing about IndexedDB, tenants or projects. You build the storage adapter.

For reference, the playground here wires them together in the web app (see the repository under apps/web):

  • lib/local-project.ts keeps the composition in localStorage. The document is the raw Project, because it already carries version and readProjectDocument already owns versioning, repair and refusal. A document this build cannot read is copied to a backup key before the editor carries on, never written over.
  • lib/media-file-storage.ts keeps imported files' bytes in IndexedDB, so a refresh can mint a fresh object URL for each. This is what makes a locally imported file recoverable at all, since the original blob: URL is dead.
  • lib/media-library-snapshot.ts stores the output of snapshotMediaLibrary in a separate IndexedDB database. It is IndexedDB rather than localStorage because thumbnails are base64 strings of roughly 40 KB per video asset, and a library of any size would push the origin toward its storage budget and risk losing the timeline to save its thumbnails.

All of it fails soft: private mode, blocked storage or a full quota loses filmstrips and nothing else.

On this page