Overview
The editor keeps its entire document in one reactive store, createEditorStore()
in packages/editor/src/stores/editor-store.svelte.ts, built on Svelte 5
runes. $state/$state.raw fields hold the raw document; $derived/$derived.by
memos compute everything downstream of it (kept segments, the output time-map, the
caption rescale, the current selection). Nothing outside the store writes those
fields except through the store's own methods.
State flows one way. The store is the single source of truth; the preview and
export engines read a snapshot of it (FrameInput params or an
EditorRenderState) and composite frames, but never write back into the store.
The document model itself is runes-free and Tauri-free; it lives in
packages/editor/src/lib/editor/render-state.ts (EditorRenderState, all its
types, defaults, and pure helpers) so the wire/IPC layer and unit tests can import
the shape without pulling in reactivity (render-state.ts).
Persistence crosses into Rust. store.toRenderState() serializes the document to a
flat camelCase JSON blob; Rust splits it into per-concern, versioned sections inside
a .recast ZIP (format v2: a project.json manifest + edits/<section>.json
files + assets/ media). Reads fan the sections back into one edits.json;
store.loadRenderState() rehydrates the store. Legacy v1 bundles (single root
edits.json) are migrated in place behind a dialog. All writes are atomic
(temp-file + rename, never remove-before-rename).
Diagram
flowchart TB
subgraph store["Editor store (runes)"]
raw["$state fields<br/>cuts, splitPoints, zoomRegions,<br/>trim, annotations, cameraOverlay…"]
memo["$derived memos<br/>cutsMemo · segmentsMemo · timeMapMemo<br/>captionTranscriptMemo · selection"]
raw -->|auto-track| memo
end
subgraph engine["Render engines (read-only)"]
preview["VideoPreview<br/>computeFrameParams → FrameInput"]
export["export-scene / buildExportBase"]
core["RenderCore + WebGL2Backend<br/>(preview + export, shared)"]
end
raw -->|getters| preview
memo -->|getters| preview
raw -->|toRenderState| export
preview --> core
export --> core
core -. "never writes back" .-> store
classDef oneway stroke-dasharray: 4 4;
class core onewayflowchart LR
subgraph disk[".recast v2 (ZIP)"]
man["project.json<br/>manifest"]
meta["metadata.json"]
sec["edits/frame·cursor·zoom·<br/>annotations·timeline·audio·overlays.json<br/>(each versioned)"]
assets["assets/<br/>recording.mp4 · audio.wav · cursor.track.json"]
end
store["Editor store"]
store -->|"toRenderState() → flat JSON"| split["split_edits + canonicalize"]
split -->|"save_project_edits<br/>update_project_edits (atomic)"| sec
sec -->|"open_project → merge_sections"| merged["edits.json (cache)"]
merged -->|"loadEditorDocument"| load["loadRenderState()"]
load --> store
man -. "is_v2? no → migrate_project<br/>(.recast.bak backup)" .-> loadKey components
| Component | Location | Role |
|---|---|---|
createEditorStore() | editor-store.svelte.ts | The reactive document store; returns getters/setters + methods |
$state/$state.raw fields | editor-store.svelte.ts-373 | Raw document state; .raw for replace-only large arrays (transcript, thumbnailStrip, cursorSamplesRaw, undo stacks ) |
captureSettings() | editor-store.svelte.ts | The exact set of undoable fields; must stay in sync with applySnapshot |
pushUndoState / withoutUndo / pushUndoStateCoalesced | , , | Undo history ($state.raw stacks, $state.snapshot clones, bound to 50); suppression + coalescing |
cutsMemo / segmentsMemo / timeMapMemo / renderMap | , , , | Memoized cut→segment→time-map chain; exposed via effectiveCuts/segments/timeMap getters |
captionTranscriptMemo | Transcript rescaled onto the video/time axis; every caption surface reads this | |
annotationsByZOrdered / selection | , | Memoized z-sorted overlay list; single exclusive selection |
toRenderState() | editor-store.svelte.ts | Serialize store → EditorRenderState (de-proxied, orphan anchors pruned) |
loadRenderState() | editor-store.svelte.ts | Rehydrate store from a (partial) EditorRenderState, applying ?? back-compat defaults; clears isDirty, sets savedSnapshot |
markSaved / revertToSaved / savedSnapshot | , , | Dirty tracking + revert-to-disk baseline |
EditorRenderState | render-state.ts | The persisted document shape (runes-free, Tauri-free) |
handleSave / load / migration | +page.svelte, , | Desktop wiring: serialize→IPC→markSaved; load→loadRenderState; v1→migration dialog |
IPC: saveProjectEdits/autosaveProject/migrateProject | apps/desktop/src/lib/ipc.ts, , | Tauri command wrappers; save returns saved-at unix ms |
format.rs (sections, split/merge, canonicalize) | apps/desktop/src-tauri/src/project/format.rs | v2 layout, section_for_key, split_edits, merge_sections, canonicalize, is_v2 |
writer.rs (write_project, update_project_edits) | project/writer.rs, | Atomic ZIP writes; edits-only rewrite raw-copies media |
reader.rs (open_project) | project/reader.rs | Extract to temp cache, fan sections → edits.json |
mod.rs (is_legacy_project, migrate_project) | project/mod.rs, | v1 detection + in-place re-pack with .recast.bak |
Control / data flow
An edit updates the preview
- A UI action calls a store method (e.g.
addCut,splitAt,updateZoomRegion) or a setter. Mutating methods callpushUndoState()(or a coalesced/withoutUndovariant) and reassign a$statefield with a fresh array/object, never index-mutate (editor-store.svelte.ts). - Reassigning a
$statefield invalidates every$derivedmemo that read it. Thecuts → cutsMemo → segmentsMemo → timeMapMemochain recomputes lazily on next read; the pure math (deriveSegments,timeMapFromSegments) is unchanged, only re-run when an input actually changed. VideoPreview.sveltereads store getters (timeMap,zoomRegions,cameraOverlay,captionTranscript, …) inside its draw effect, builds aFrameInputviacomputeFrameParams, and hands it toRenderCore(components/render-core.ts, driven by the render worker). The scene evaluators (lib/scenes/eval.ts) are pure functions of that input, they hold no store reference, so the engine cannot mutate state. This is the one-way boundary.- Export takes the same path:
store.toRenderState()→buildExportBase/export-scene.ts→ the sameRenderCore, so preview and export composite identically (lib/export/export-scene.ts).
Loading a project
loadEditorDocument(path)(IPC) → Rustopen_projectextracts the ZIP to a per-path temp cache and, for v2, mergesedits/*.jsonback into one flatedits.json(reader.rs). v1 bundles returnneeds_migration=true.- The desktop route resets the store, and if
document.needsMigrationis set it stops and shows the migration dialog instead of loading (+page.svelte). Otherwise it setsmetadatathen callsstore.loadRenderState(document.renderState). loadRenderStatecopies each field into fresh state with??defaults for fields absent in older projects, setsisDirty=false, and snapshotssavedSnapshotas the revert baseline.
Saving a project
handleSaveserializesstore.toRenderState()to JSON and callssaveProjectEdits(documentPath, editsJson).- Rust
save_project_editsrunsupdate_project_editson aspawn_blockingthread, clears the autosave shadow, and returns the save timestamp (commands/editor.rs). update_project_editsopens the existing v2 archive, raw-copies every non-edits/entry (manifest, metadata, media, no decode/re-encode), rewrites only theedits/sections (split_edits+canonicalize), writes to a.recast.tmp, and atomically renames over the original (writer.rs).- Back in JS,
store.markSaved(savedAt)clearsisDirty, recordslastSavedAt, and refreshessavedSnapshot.
Autosave (autosaveProject, analysis.ts) writes the same toRenderState()
JSON to a separate recovery shadow, gated on isDirty.
Invariants & gotchas
- Only
$statethat affects output belongs in the reactive graph. Fields read by the renderer are$state; transient/UI-only fields (timeMode,isTrimming, selection ids) are still$statebut are deliberately excluded fromcaptureSettings/toRenderStateso they neither undo nor persist. Large replace-only arrays use$state.raw(transcript,thumbnailStrip,cursorSamplesRaw, undo stacks): deep-proxying tens of thousands of entries is pure overhead; only array identity needs reactivity. - One-way flow: the engine never mutates the store.
RenderCore/ the WebGL2 backend / scene evaluators are pure overFrameInput/EditorRenderStateand hold no store reference. External seeks must go throughstore.seek()(moves playhead and transport), neverstore.currentTime =alone, which the next playback publish overwrites. $effectthat writes store state mustuntrack+withoutUndo. A live-preview effect (e.g. previewing a preset as the cursor moves) writes throughwithoutUndoso it records no undo entry and does not flipisDirty; the committed change is made outside that scope (withoutUndo; live setterssetBackgroundLive,updateCameraOverlayLive). Continuous gestures coalesce withpushUndoStateCoalescedso one drag is one undo.captureSettingsandapplySnapshotmust stay in lockstep. Any undoable field left out ofcaptureSettingssilently survives an undo, the user sees unrelated edits revert while their tweak stays put. Camera overlay was once captured but not restored, which destroyed camera edits on undo (fixed at ).- Serialization is a boundary, not the store.
toRenderStatede-proxies via spreads/maps and prunes orphaned segment-speed/anim anchors so sections diff cleanly. Export-only fields (cursorSprite*) are populated right beforeenqueue_exportand are never persisted or read back byloadRenderState(render-state.ts).loadRenderStatemust default every optional field with??or an older project fails to load. .recastv2 is sectioned + independently versioned.section_for_key(format.rs) is a grouping table, not a type mirror; unrecognised keys fall back toframe, so a future editor toggle round-trips losslessly even before the table learns it (RenderStatepassthrough + thefutureKeyround-trip tests). Eachedits/<section>.jsoncarries its ownversionfor per-section migration. Output is canonicalized (sorted keys, id-sorted arrays) so git diffs are minimal (canonicalize).- Atomic writes; never remove-before-rename. Both
write_projectandupdate_project_editswrite a.recast.tmp,sync_all(), thenfs::renameover the original, which already replaces atomically. Deleting the original first opens a window where a crash loses the project outright (writer.rs, reader mirrors this for extracted assets atreader.rs). - Migration is dialog-gated and backed up.
is_legacy_projectcheaply probes only the ZIP central directory for the absence ofproject.json(mod.rs). Loading a v1 project stops and prompts; the user confirms, thenmigrate_projectre-packs to v2 in place after copying a one-time.recast.bak(recordings can be irreplaceable). A save refuses to run on a non-v2 archive, so it can never produce a hybrid v1/v2 bundle (writer.rs).
Related
03-preview-and-rendercore.md, the read-only consumer of store state (FrameInput→RenderCore).05-timeline-model.md, the cut/segment/time-map math the memos wrap.06-export-pipeline.md,toRenderState→ sharedRenderCore→ encoder.