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

Composition compatibility

The optional style.frame.layout object stores the whole-video arrangement: { mode, speakerSide, speakerSize, gap, supportHeight }. Modes are default (camera overlay), fullscreen (camera only), hide (screen only) and split (side by side, stacked in portrait). Side is left or right; speaker size is 0.18–0.45; gap is 0–80 reference pixels; supporting height is 0–0.35 of the available height, with zero disabled. Preview and export resolve the same geometry.

edits.layoutSegments[].layout optionally overrides those settings for that recording-time span. The segment's existing mode, position, customSize and enabled fields retain their meaning. Segment mode wins over nested layout mode. The whole-video arrangement resumes in gaps. Disabled segments reserve their span but do not change the rendered layout. Cuts and speeds do not rewrite these recording-time ranges.

style.webcam.reframe optionally stores { x, y, zoom }, with normalized crop positions 0–1 and zoom 1–3. Its default is { x: 0.5, y: 0.5, zoom: 1 }. edits.overlaySegments[].region: "support" attaches a text overlay to the current supporting section while retaining the text block's independent timing. Omit region for ordinary canvas placement. Unsupported region values are removed during manifest repair. Non-text overlays use ordinary canvas placement.

Old manifests need no migration: an absent frame layout preserves legacy webcam flags and rendering, and absent camera reframing preserves the original crop. New fields round-trip in the live project manifest and through undo/autosave. Full field validation is in composition settings.

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/
│       ├── <background-image>        # imported background images
│       └── animations/<uuid>/       # managed 8-bit RGBA PNG animation frames
├── 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 {
  splitPoints?: number[];
  systemAudioGain?: number;
  micAudioGain?: number;
  micNoiseReduction?: boolean;
  cuts: Cut[];
  speedSegments: SpeedSegment[];
  captions: CaptionTrack | null;
  zoomKeyframes?: ZoomKeyframe[];
  maskSegments?: MaskSegment[];
  overlaySegments?: OverlaySegment[];
  layoutSegments?: LayoutSegment[];
  musicTrack?: MusicTrack | null;
}

2.1 Section markers and audio settings

EditState declares these persisted fields. Save and export assemble their current values through buildLiveProject() in the renderer project store.

Field Type Meaning Default
edits.splitPoints number[] Section boundaries in recording-time ms. Joining a boundary keeps the footage. []
edits.systemAudioGain number System-audio gain multiplier (0–4). 1
edits.micAudioGain number Microphone gain multiplier (0–4). 1
edits.micNoiseReduction boolean Local microphone noise reduction, including keyboard clicks, in preview and export. Original audio is preserved. true

Older projects that omit these fields use the defaults above. Explicit saved gains are preserved. The Audio panel changes these settings through undoable store actions. Derived clean microphone WAV files live under cache/audio/ and can be regenerated; the recording remains the source of truth. A requested noise reduction failure is visible, with a choice to retry or use original audio.

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.

Native and rendered motion graphics persist in the same edits.overlaySegments array as text and SVG overlays. The timeline snapshot, buildLiveProject(), autosave, and export copy that array as a whole, so the new variants need no manifest migration:

ts
OverlaySegment = {
  id, type: 'text' | 'svg' | 'graphic' | 'animation', startTime, endTime,
  position: { x, y }, width, rotationDeg, opacity, animation, keyframes?,
  text?, textStyle?,     // text only
  svg?,                  // SVG only
  graphic?,              // graphic only; MotionGraphicSpec below
  animationSource?       // animation only; AnimationOverlaySource below
}

MotionGraphicSpec =
  | { kind: 'counter'; title; subtitle; theme; accentColor; delayMs; durationMs;
      easing; from; to; decimals; prefix; suffix }
  | { kind: 'bar-chart'; title; subtitle; theme; accentColor; delayMs; durationMs;
      easing; bars: Array<{ label; value }>; maxValue?; suffix }
  | { kind: 'progress-ring'; title; subtitle; theme; accentColor; delayMs;
      durationMs; easing; value; decimals }

AnimationOverlaySource = {
  assetId; frameCount; fps; width; height; loop
}

Stored graphic objects are canonical: the common presentation and timing fields are present even when the Agent call omitted them. delayMs and durationMs are offsets in recording-time milliseconds from the overlay's startTime; the final visual state holds after delayMs + durationMs until the segment ends. Cuts and speeds map the recording clock onto output time. Shortening endTime can clip an animation, but never rescales its internal timing. See add_graphic_overlay for exact bounds and defaults.

A rendered animation owns canonical 8-bit RGBA PNGs at style/assets/animations/<assetId>/frame-000000.png through its declared frameCount. fps selects frames using local recording time; loop: false holds the last frame and true wraps. These are managed project assets: copy the directory with the project and never replace assetId with an absolute source path. add_animation_overlay is the safe importer for PNG directories and alpha video; it validates, normalises, and atomically publishes the sequence. Offline writers must supply an existing contained asset directory matching the descriptor exactly.

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": [],
  "systemAudioGain": 1,
  "micAudioGain": 1,
  "micNoiseReduction": true
}

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/.

Imported background images retain their original encoded bytes. The manifest stores style.background.imagePath relative to the project, for example style/assets/<id>-wallpaper.png, so copying or moving the project preserves the image. Legacy absolute paths to the same managed asset normalize on load and save. Export requires the selected image to load successfully.


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_project → get_timeline → mutate → verify. The offline path exists for headless/batch generation, not for editing alongside a human.