# Cadre Project Format — Offline Editing Contract (v1)

The MCP tools ([`tools.md`](./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](#4-the-live-vs-offline-boundary).

---

## 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.parse`d 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 (`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`](./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.parse`d 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`](./tools.md#open_project) directly.

---

## 5. Recommendation

| Situation | Use |
|-----------|-----|
| Project is open in Cadre / you want live feedback | **MCP tools** ([`tools.md`](./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.
