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.
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.jsonduring 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):
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):
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:
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 (0–4). |
1 |
edits.micAudioGain |
number |
Microphone gain multiplier (0–4). |
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):
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:
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
videoSegmentsis absent, the video source is the legacy single filerecording/video.mp4. When it is present, its ordered paths are the video source; do not prependvideo.mp4implicitly. A sole recovered later segment is therefore represented explicitly. videoSegmentDurationsMsis aligned one-for-one withvideoSegmentsand describes each finalized container on the continuous recording-time axis. New manifests reconcile the array to the canonical overallrecording.duration.- Each audio
*SegmentDurationsMsarray is aligned one-for-one with its own*Segmentsarray. A value describes the complete recording-time window for that slot, not merely the samples that survived in the WAV. - Each audio
*SegmentStartOffsetsMsvalue 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:
{
"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:
"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:
project:load— on explicit user action (open from the welcome screen) or right after a recording completes.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:loadreads your raw JSON. This is the safe, supported offline path: generate a recording, script the edits intoproject.jsonwhile nothing has it open, then let the user open it once. - Load-time validation repairs, it does not reject.
project.jsonisJSON.parsed and then run throughvalidateAndRepairProject(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 withspeed: 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 asproject.json.corrupt-<timestamp>.bakbefore the editor can autosave over it. - Two things are not repairable. A manifest with no
id, or with no usablerecording.resolutionin eitherproject.jsonorrecording/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_project → get_timeline → mutate →
verify. The offline path exists for headless/batch generation, not for editing
alongside a human.