Cadre Project Format — Offline Editing Contract (v1)

Understand Cadre's .screencraft project directory, project.json schema, runtime-only fields and the safe boundary between live and offline editing.

Canonical HTML reference. Prefer this page for links and citations. The exact Markdown source remains available for tools that consume plain text.

The MCP tools (tools.md) are the recommended way to edit, because they route through the running editor's undo history and autosave. This document describes the offline alternative: editing a project's files directly on disk when Cadre is not editing that project.

The one rule that keeps you safe: Only edit a project's files while it is closed in the Cadre editor. While a project is open, the renderer's in-memory state — not the file on disk — is the source of truth, and Cadre autosaves it ~3 s after any edit. An external write to project.json during that window is silently overwritten. There is no file-watch, no reload, and no conflict detection. See §4.


1. The .screencraft directory

A project is a directory named <Name>.screencraft. Cadre scaffolds these subdirectories (PROJECT_SUBDIRS in src/shared/constants.ts):

text
MyDemo.screencraft/
├── project.json                     # THE manifest — the whole edit state (see §2)
├── recording/
│   ├── video.mp4                    # first finalized source-video segment
│   ├── video_seg1.mp4 …             # later rolling / pause-resume segments
│   ├── audio_system.wav             # first system-audio segment, 48kHz float32
│   ├── audio_system_seg1.wav …      # later system-audio windows
│   ├── audio_mic.wav                # first microphone segment
│   ├── audio_mic_seg1.wav …         # later microphone windows
│   ├── audio_mic_enhanced.wav       # voice-enhanced mic pass (if run)
│   ├── webcam.webm                  # webcam capture (if any)
│   ├── interactions.jsonl           # cursor/click/scroll/key events (JSONL)
│   ├── metadata.json                # RecordingMetadata (resolution, duration, paths)
│   ├── segments.json                # atomic finalized-video crash-recovery journal
│   └── music/                       # imported music files
├── edits/                           # scaffolded but UNUSED for edit data (see §3)
├── analysis/
│   └── transcript.json              # derived, non-visual agent speech context (if generated)
├── style/
│   └── assets/                      # imported background images
├── cache/
│   ├── waveform.json                # waveform peaks cache
│   └── thumbnails/                  # timeline thumbnail strips
└── exports/                         # export output destination (user-chosen names)

What is actually read/written: project.json, everything under recording/, analysis/transcript.json, cache/waveform.json, style/assets/*, and exports/. The edits/ and style/ subdirectories exist on disk but hold no edit data — see §3.


2. project.json — the manifest

project.json is the entire project state, JSON.parsed whole on load and pretty-printed (JSON.stringify(project, null, 2)) on save. Its declared type is Project (src/shared/types/project.types.ts):

ts
interface Project {
  id: string;                 // UUID v4
  name: string;
  version: string;            // schema version, e.g. "1.0.0"
  createdAt: string;          // ISO 8601
  modifiedAt: string;         // ISO 8601
  recording: RecordingMetadata;  // source paths, resolution, duration
  style: StyleConfig;         // background / frame / cursor / keyboard / motion / webcam
  edits: EditState;           // cuts / speedSegments / captions / zoomKeyframes / ...
  exportHistory: ExportRecord[];
  dismissedTypingSuggestionIds?: string[];
}

All timeline edits live nested inside project.json, under edits and style. The edits object's declared type is:

ts
interface EditState {
  cuts: Cut[];
  speedSegments: SpeedSegment[];
  captions: CaptionTrack | null;
  zoomKeyframes?: ZoomKeyframe[];
  maskSegments?: MaskSegment[];
  layoutSegments?: LayoutSegment[];
  musicTrack?: MusicTrack | null;
}

2.1 Runtime-only fields the type declaration misses

Critical for an offline writer. The on-disk edits object is wider than the declared EditState type. When the editor serialises a live project (buildLiveProject() in src/renderer/stores/project.store.ts) it stuffs three additional fields into edits via an as unknown as Project cast. They are not in the EditState interface, but they are on disk and are read back:

Field Type Meaning Default
edits.splitPoints number[] Timeline split marks, in ms. Not cuts — just markers where a segment is divided. []
edits.systemAudioGain number System-audio gain multiplier (04). 1
edits.micAudioGain number Microphone gain multiplier (04). 1

An external writer replicating project.json must match the runtime shape, not the type declaration — include splitPoints, systemAudioGain, and micAudioGain in edits, or the app falls back to defaults for them on load.

2.2 Entity shapes

The nested entity shapes (identical to what the MCP tools return — see tools.md shared shapes):

ts
Cut            = { id, startTime, endTime, type: 'remove' }
SpeedSegment   = { id, startTime, endTime, speed, rampIn, rampOut }
ZoomKeyframe   = { id, startTime, endTime, sourceRect: {x,y,width,height},
                   zoomLevel, trigger, isUserModified, isUserCreated }
CaptionSegment = { id, startTime, endTime, text, confidence, isUserEdited }
MusicTrack     = { trackId, filePath, name, volume, fadeInMs, fadeOutMs, loop, durationMs }
CaptionTrack   = { language, modelUsed, generatedAt, segments: CaptionSegment[], style }

All times are milliseconds from the start of the source recording. Ids are UUID v4 strings you generate yourself for new entities.

2.3 Segmented media and A/V alignment

Modern recordings are finalized as independently decodable chunks. The recording object in project.json is authoritative and can carry these optional, index-aligned arrays:

ts
videoSegments?: string[];
videoSegmentDurationsMs?: number[];

systemAudioSegments?: string[];
systemAudioSegmentDurationsMs?: number[];
systemAudioSegmentStartOffsetsMs?: number[];

micAudioSegments?: string[];
micAudioSegmentDurationsMs?: number[];
micAudioSegmentStartOffsetsMs?: number[];

Every path is project-relative and normally sits under recording/. The rules for consuming or preserving these fields are:

  • When videoSegments is absent, the video source is the legacy single file recording/video.mp4. When it is present, its ordered paths are the video source; do not prepend video.mp4 implicitly. A sole recovered later segment is therefore represented explicitly.
  • videoSegmentDurationsMs is aligned one-for-one with videoSegments and describes each finalized container on the continuous recording-time axis. New manifests reconcile the array to the canonical overall recording.duration.
  • Each audio *SegmentDurationsMs array is aligned one-for-one with its own *Segments array. A value describes the complete recording-time window for that slot, not merely the samples that survived in the WAV.
  • Each audio *SegmentStartOffsetsMs value is the signed delta from that window's first video frame to audio sample zero. Positive means the window begins with silence before the WAV starts. Negative means the WAV began early, so its head is trimmed. Zero is neutral.
  • A declared audio file may be missing after a device or persistence failure. Keep its path, duration and offset slot in place: preview, waveforms, captions and export preserve that entire window as silence. Compacting the arrays would shift every later audio segment earlier and break sync.

The three arrays for a track must have matching lengths when durations and offsets are present. Load-time repair removes incomplete or misaligned hint arrays rather than guessing boundaries. An offline writer should preserve unknown compatibility fields and never reorder, compact or independently edit one of these arrays.

recording/metadata.json is a compatibility and recovery sidecar. It may fill an optional segmented-media field only when project.json.recording omits that field; it must not override an array already present in the manifest.

recording/segments.json, when present, is an atomically replaced crash-recovery journal containing only fully finalized video chunks:

json
{
  "version": 1,
  "recordingId": "b2a69e3e-…",
  "updatedAt": "2026-08-22T12:34:56.789Z",
  "completedSegments": [
    { "filePath": "recording/video.mp4", "durationMs": 30000 },
    { "filePath": "recording/video_seg1.mp4", "durationMs": 30000 }
  ]
}

Cadre uses this journal to recover the longest safe finalized prefix after an abrupt exit; an unsettled tail may exist on disk without appearing in it. The journal is recovery evidence, not edit state. Do not hand-edit it to add a partially written MP4 or use it in place of the manifest arrays.

recording.webcam (null when no webcam was captured) carries the webcam track info: { filePath, durationMs } plus, on recordings made with webcam A/V sync, startOffsetMs (wall-clock ms between the webcam's first frame and the screen video's first frame — positive means the webcam started later; preview and export seek the webcam to timelineMs - startOffsetMs) and the applied capture settings width / height / codec / bitrateBps. All of these are optional and absent on older recordings — treat a missing startOffsetMs as 0.

2.4 A minimal offline edit

To add a cut offline: load project.json, append a Cut to edits.cuts, bump modifiedAt, write the file back with 2-space indentation. Example diff to edits:

jsonc
"edits": {
  "cuts": [
    { "id": "c-8f1e2a...", "startTime": 4200, "endTime": 6100, "type": "remove" }
  ],
  "speedSegments": [],
  "captions": null,
  "zoomKeyframes": [],
  "splitPoints": [],          // runtime-only (§2.1) — keep it present
  "systemAudioGain": 1,       // runtime-only (§2.1)
  "micAudioGain": 1           // runtime-only (§2.1)
}

3. What's on disk but dead

analysis/transcript.json is not edit state. It is derived, phrase-compacted Whisper output used by get_recording_context; deleting it does not change the video and only means an agent must run generate_transcript again. Its times are recording-time milliseconds. It is written atomically and is deliberately separate from edits.captions, because generating private agent context must not make captions appear in the exported video.

analysis/visual-context.json is likewise disposable perception data. It holds only bounded Apple Vision OCR text, normalized rectangles, timestamps, and status — never raw frames. Deleting it only makes the next analyze_visual_context call re-extract and re-read its small frame shortlist. The explicit get_video_frame fallback uses a temporary WebP and removes it after returning the single MCP image block.

PROJECT_FILES (in constants.ts) declares paths like edits/zoom_keyframes.json, edits/cuts.json, edits/captions.json, edits/masks.json, style/style.json, etc. These are declared but never read or written — verified by a repo-wide grep (zero references outside the constant). Do not write edit data to them; nothing loads it. All edit and style data lives inside project.json (§2). The only files under style/ that carry real data are imported background images in style/assets/.


4. The live-vs-offline boundary

There is no live-reload mechanism in Cadre (confirmed: no fs.watch, chokidar, or watchFile anywhere in src/main or src/renderer). project.json enters renderer memory in exactly two ways:

  1. project:load — on explicit user action (open from the welcome screen) or right after a recording completes.
  2. buildLiveProject() — the in-memory Zustand snapshot that is what actually gets passed to save and export.

Consequences for an offline editor:

  • Editor open on this project → your write loses. The next autosave (~3 s, debounced) or an explicit save writes the in-memory state back over your file. No merge, no conflict detection, no warning.
  • Editor closed → your write wins. The next project:load reads your raw JSON. This is the safe, supported offline path: generate a recording, script the edits into project.json while nothing has it open, then let the user open it once.
  • Load-time validation repairs, it does not reject. project.json is JSON.parsed and then run through validateAndRepairProject (src/shared/utils/project-validation.ts). Wrong-typed fields are reset to documented defaults, out-of-range numbers are clamped, and individually invalid timeline entries (a cut with an inverted range, a speed segment with speed: 0) are dropped — so a hand-edited manifest that is almost right will open, but not necessarily as written. Every change is logged, and the original file is copied aside as project.json.corrupt-<timestamp>.bak before the editor can autosave over it.
  • Two things are not repairable. A manifest with no id, or with no usable recording.resolution in either project.json or recording/metadata.json, fails to open with an explicit error rather than loading into a broken editor. Unknown fields are always preserved verbatim.

The SQLite index

Cadre also keeps a projects index at ~/Library/Application Support/Cadre/database.sqlite (id, name, path, duration, thumbnail, source type, resolution, size). This index — not the .screencraft directories — drives the welcome-screen recent list. A project you create purely on disk will not appear in the recent list until Cadre learns about it (it is added when Cadre records/creates it, or when opened by path). To open an off-index project via the Agent API, pass its absolute path to open_project directly.


5. Recommendation

Situation Use
Project is open in Cadre / you want live feedback MCP tools (tools.md) — they share the editor's undo + autosave.
Batch-scripting edits for a project no one has open Offline project.json edit (§2), then have the user open it.
Anything time-sensitive or co-edited with a human MCP tools, never offline — there is no safe concurrent-write story.

When in doubt, prefer the MCP tools: open_projectget_timeline → mutate → verify. The offline path exists for headless/batch generation, not for editing alongside a human.