# Project files FilmCraft projects are `.fcproj` files: UTF-8 JSON with an explicit schema version. Reading and writing them is `crates/format` (`filmcraft-format`); the engine's `file.*` commands use it. ## Format ```json { "format": "filmcraft.project", "schema_version": 12, "generator": "FilmCraft 0.1.0", "project": { "name": "…", "settings": { … }, "root": { … }, "items": { … }, "next_id": 48 }, "view": { "open_sequences": [21, 50], "active_sequence": 50, "sequences": { "21": { "pps": 40.0, "scroll": 0.0, … } } } } ``` - `schema_version` is the version of the whole document. This build writes `filmcraft_format::SCHEMA_VERSION` and reads every version from 1 up to it. - `project` is the serialized `filmcraft_project::Project` (bins, media clips, sequences, tracks, clips, effects, keyframes, markers). Media is referenced by path, never embedded. - Files are written compact (no indentation). A 2,000-clip project is about 1.4 MB. - `view` (optional) is what was open when the project was saved: the Timeline's sequence tabs in order, the active one, and each sequence's zoom, scroll and track heights (`filmcraft_project::ProjectView`). It is beside the project, not in it: it is not part of the edit, never an undo step, and changing it does not mark the project as changed. Opening a project restores it when the Timeline preference "Restore open sequences when opening projects" is on. It is outside the schema version: older builds ignore it, ids that are not sequences and numbers out of range are dropped or clamped, and a `view` that cannot be read is ignored (the project opens on its first sequence, as it does without one or with no open sequence in it). - A coordinate of an effect's point parameter may be `null`. Point parameters use NaN for "auto" (the frame centre, or the source centre for `anchor`), and JSON writes NaN as `null`; it reads back as "auto". Placing a clip resolves these points at once. An interchange import resolves them too, except the `anchor` of a clip whose media file was not found. That one stays "auto", and the renderer centres it, until Link Media (`media.relink`, `media.autoRelink`) reads the file and with it the real picture size; a file that merely comes back at its old path does not change it. `null` anywhere else is a damaged file. ### Schema history | Version | Since | Shape | |---|---|---| | 1 | M0 | the bare `Project` object with a `"version": 1` field | | 2 | M11.1 | the envelope above; the project's own `version` field is gone | | 3 | M10.2 | graphic clips (`ItemKind::Graphic` with text/shape layers); a no-op step for older data | | 4 | M7.4 | Essential Sound: clip audio types and settings; no-op step | | 5 | M8.8 | colour management: working space, Interpret Footage colour space, LUT library; no-op step | | 6 | M11.7/M11.9 | media identity fingerprints, ingest settings; no-op step | | 7 | M5.5 | effect masks: `masks` on effect instances with keyframable Bézier `Path` values; no-op step | | 8 | M12 | multi-camera source sequences and clips, merged clips; no-op step | | 9 | M10.5 | transcripts of media items (`project.transcripts`); no-op step | | 10 | M3.10 | clip time interpolation, Hold Filters, Field Options, audio source channels, Modify ▸ Audio Channels map, subclip Restrict Trims; no-op step | | 11 | M3.11 | search bins (`project.search_bins`), Flash Cue markers, Project Settings safe areas, capture format and scratch disks; no-op step | | 12 | M10.7 | graphics design data: `TrackItem::graphic` (roll / crawl, responsive time, template link), `EffectInstance::layer` (layer uid, per-character styles, responsive pins), `project.source_graphics`; no-op step | ### Migrations Older files are upgraded on load, in memory, by a chain of single-step functions (`filmcraft_format::MIGRATIONS`, where entry *i* upgrades v*i+1* to v*i+2*). Each step rewrites the JSON document; after the last one the result is deserialized into the current model. To change the schema: 1. add a function `vN_to_vN1(doc: Value) -> Result` and append it to `MIGRATIONS` (`SCHEMA_VERSION` follows automatically); 2. add a small hand-made fixture of the old shape to `crates/format/tests/fixtures/` and a test that it loads; 3. never edit a migration that has shipped. Fields added with `#[serde(default)]` don't need a migration: missing values take their defaults. But when new data would make the file unreadable to an older build (a new enum variant such as `ItemKind::Graphic`, a new required shape), bump the schema with a no-op step so older builds refuse the file with the message below instead of failing with a parse error. Do the same when an older build would load the file but silently drop new fields on its next save. When you open an older file FilmCraft says so ("Upgraded project from schema v1 to v2"). The file on disk is unchanged until you save. The **first save over it** keeps the original next to it as ` (schema v1 backup).fcproj`, so an older FilmCraft can still open your work. ### Newer files A file with a `schema_version` newer than the build supports is **refused**, not half-read: > this project was saved by a newer version of FilmCraft (project schema v12); this build reads up to > v11. Update FilmCraft to open it. Reading it partially and saving it back would silently drop whatever the newer version added. ## Saving is atomic Every write of a project file, auto-save, recovery snapshot or preferences file goes through `filmcraft_format::atomic_write`: 1. write the bytes to a hidden temp file in the **same directory** (`..-.tmp`); 2. `fsync` the temp file; 3. rename it over the target (an atomic replace on every supported OS); 4. `fsync` the directory so the rename itself is durable. A crash, `kill -9` or power cut at any point leaves either the complete old file or the complete new one. If a step fails, the temp file is removed and the old file is untouched. ## Commands | Id | Menu | | |---|---|---| | `file.save {path?}` | File ▸ Save (⌘S) | write to the project's path (or `path`) | | `file.saveAs {path}` | File ▸ Save As… (⇧⌘S) | write and adopt the new path | | `file.saveCopy {path}` | File ▸ Save a Copy… (⌥⌘S) | write a copy; the project keeps its path and unsaved state | | `file.revert` | File ▸ Revert | reload the last saved version, discarding changes | | `file.open {path}` | File ▸ Open Project… (⌘O) | returns `{schemaVersion, migrated}` | In the UI, Save / Save As / Save a Copy without a `path` show a file dialog. ## Auto Save Preferences ▸ Auto Save (Edit ▸ Preferences ▸ Auto Save…, ⌘,; FilmCraft ▸ Settings… on macOS) works like Premiere's: | Setting | Key | Default | |---|---|---| | Automatically save projects | `autoSave.enabled` | on | | Automatically Save Every N minute(s) | `autoSave.intervalMinutes` | 5 (1–1440) | | Maximum Project Versions | `autoSave.maxVersions` | 20 (1–1000) | | Auto Save also saves the current project(s) | `autoSave.saveCurrentProject` | off | | Keep a recovery copy of unsaved changes | `autoSave.recoveryJournal` | on | | Update it at least every N second(s) | `autoSave.recoveryIntervalSeconds` | 5 (1–600) | At each interval, if the project changed since the last auto-save, a copy is written to the `Auto-Save` folder next to the project as `-YYYY-MM-DD_HH-MM-SS.fcproj` (local time). An unsaved project uses `/Auto-Save/`. After each write the oldest copies beyond the limit are deleted. Only files named exactly `-.fcproj` are ever deleted. An auto-save is an ordinary project file: open it with File ▸ Open Project. `file.listAutoSaves` lists them, newest first. Preferences are stored in `/preferences.json`. The `prefs.*` commands read and change them. ## Crash recovery Premiere's auto-save can lose up to one interval of work. FilmCraft also keeps a **recovery journal**: - Whenever the project has unsaved changes, a snapshot is written to `/Recovery/-[-]/snapshot.fcproj`, plus `session.json` (time, revision, project name and path). It is written about 1 s after the last edit, or after at most `recoveryIntervalSeconds` while edits keep coming. Saving the project deletes the snapshot. - Each running FilmCraft holds an OS file lock on `/lock`. When the process dies (crash, `kill -9`, power loss) the OS releases the lock. - At startup, every other session directory whose lock can be taken and that has a snapshot is a recovery candidate. FilmCraft asks: *"FilmCraft quit unexpectedly while 'X' had unsaved changes. Recover unsaved changes from 2026-10-01 00:02:09?"*, with the buttons **Recover**, **Not Now** and **Discard**. Stale directories without a snapshot are removed. Journals of running sessions are never touched. - Recovering loads the snapshot as an **unsaved** project with its original path, so Save writes over the original file. The recovered state goes into the new session's own journal before the old journal is deleted. - A normal quit with nothing unsaved removes the session directory. Quitting with unsaved changes keeps the snapshot, marked `cleanExit`, and FilmCraft offers it on the next launch ("FilmCraft was closed while … had unsaved changes"). There is no "Save changes?" prompt on quit yet, so this keeps the work. Desktop flags: `--recover` recovers the newest candidate without asking. `--no-recover` starts without asking; the changes stay available through File ▸ Recover Unsaved Changes…. `--data-dir ` (or `FILMCRAFT_DATA_DIR`) moves the data directory. The default is `~/Library/Application Support/FilmCraft` on macOS, `%APPDATA%\FilmCraft` on Windows, and `$XDG_DATA_HOME/filmcraft` or `~/.local/share/filmcraft` elsewhere. ### Cost The UI thread only gives the worker thread an `Arc`: the immutable snapshot the engine already keeps for undo, passed through a channel. JSON encoding and all file I/O run on the `filmcraft-autosave` thread. Measured on the development Mac (release build) with a 2,000-clip project (`cargo test --release -p filmcraft-format --test fixtures -- --nocapture`): | Step | Time | Size | |---|---|---| | encode, compact (what is written) | 2.5 ms | 1.4 MB | | encode, pretty (not used) | 7.1 ms | 5.6 MB | | decode | 4.6 ms | | | atomic write + fsync (APFS SSD) | 30 ms | | In the running app with the demo project, a recovery write measured 0.1 ms to encode and 27 ms to write (two fsynced files). ## Commands | Id | | |---|---| | `file.recoveryList` | candidates: `id`, `projectName`, `projectPath`, `savedAt`, `revision`, `cleanExit`… | | `file.recover {id?}` | recover (newest when no `id`). From the UI, `{}` opens the recovery dialog | | `file.discardRecovery {id?, all?}` | delete a candidate's journal | | `file.autoSaveNow` | write an auto-save now (if anything is unsaved) | | `file.autoSaveStatus` | prefs, folders, last auto-save / journal times, last encode/write ms and bytes | | `file.listAutoSaves` | auto-save files of the current project, newest first | | `prefs.get {key?}` / `prefs.set {key, value}` or `{values:{…}}` / `prefs.reset {category?}` | settings (below) | UI automation ids: `recovery.recover`, `recovery.later`, `recovery.discard`, `recovery.item.`; `revert.yes`, `revert.no` (File ▸ Revert asks first when invoked without params). ## Settings Premiere's Settings dialog (app menu ▸ Settings ▸ on macOS, Edit ▸ Preferences elsewhere; General is Cmd+,; the window is titled "Preferences"). The values are user preferences, not project data: they live in `preferences.json` in the per-user data directory (written atomically on every change) and never touch the `.fcproj` schema. The file carries a `version` (currently 2); older files are migrated on load, unknown dropdown values, bad colours and out-of-range numbers are repaired to defaults/limits, and an unreadable file falls back to the defaults. Every value is a dotted key whose first segment is its category: `timeline.stillImageDuration`, `labels.colors.rose.name`. `prefs.get` / `prefs.set` / `prefs.reset {category?}` reach them from the CLI, the control channel and MCP; `prefs.set` rejects values that are not one of a dropdown's choices. `prefs.schema {category?}` lists every category with its fields (label, kind, choices, range, unit, current value, and `wired`: whether FilmCraft acts on it yet). `app.settings.` opens the dialog on a page. | Category | Takes effect | |---|---| | General | At Startup (Show Home = demo project, Open Most Recent, empty project; recent projects are remembered on open/save), Show Tool Tips | | Appearance | Color Theme (Darkest / Dark / Light; View ▸ Appearance writes it too), highlight colour, accessible contrast | | Audio | Automatch Time, Large Volume Adjustment, automation keyframe thinning (linear, minimum time) | | Audio Hardware | device class (cpal host), output device, I/O buffer size, sample rate, force document rate, Output Mapping (programme L/R → device channels) | | Auto Save | the auto-save ring and the crash-recovery journal | | Color | stored only (display colour management, EDR monitoring, HDR graphics white) | | Graphics | new text layers: smart quotes, ligatures, default font | | Labels | the 16 label names and colours (timeline, Project panel, Edit ▸ Label), label defaults for imported movies / video / audio / stills and new sequences | | Media | Indeterminate Media Timebase (frame rate of stills), Default Media Scaling (Scale to / Set to frame size when a clip of another size is edited in), Enable proxies | | Media Analysis & Transcription | auto-transcribe imported clips (scope "all imported"), speech model, default language / auto-detect, speaker labelling (defaults of `transcript.generate`) | | Media Cache | location (`/Media Cache` by default; unsaved projects' render previews live there), automatic deletion (older than N days / beyond N GB), Delete… (`mediaCache.clean {all?}`, `mediaCache.info`) | | Memory | frame cache budget of the monitors and thumbnails | | Playback | preroll / postroll (Play Around, trim loop), Step forward/back many | | Plugins | stub (no plugins) | | Timeline | video / audio transition and still image default durations (frames or seconds), playback auto-scrolling (none / page / smooth), snap playhead, return to beginning at playback end, play after rendering previews | | Trim | Large Trim Offset, Selection tool picks roll (on the cut) / ripple (on the edge) without a modifier, playhead determines trim loop | The remaining fields are kept for parity and shown, but have no effect yet (`wired: false`). UI automation ids: `settings.category.`; `settings.` for every control (for example `settings.timeline.autoScroll`, `settings.labels.colors.rose.color`); `settings..` for the items of an open dropdown; `settings..browse`, `settings..hex`; `settings.mediaCache.clean`; `settings.help`, `settings.reset` (this page back to its defaults), `settings.cancel`, `settings.ok`. The open dialog's page and draft are `ui.settings` in `ui.inspect`; `ui.set {"settings": {"page": id, "values": {key: value}}}` edits the draft (OK applies it). ## Media: offline, relinking, proxies, ingest Media is referenced by path. Each imported file also records its **identity** (`MediaClip::identity`): the size and a 64-bit fingerprint of the size, the first MiB and the last MiB. Reading 2 MiB is cheap on any file, and it tells a moved original from a different take with the same name. The field is optional (`skip_serializing_if`); it and `ProjectSettings::ingest` came with schema v6, so builds that would drop them on save refuse the file instead. Projects from older builds have no identity, so relinking them skips the fingerprint check. ### Offline media - **Opening a project** checks every file (`media.findMissing`). Missing items don't stop the open. They render the **offline slate** (`crates/render/src/offline.rs`, our own design: a red striped field, a warning triangle, "MEDIA NOT FOUND", the file name and a hint), their audio is silent, and the app opens the **Link Media** dialog. `file.open` returns `missingMedia`. - **Make Offline** (`media.makeOffline {items?, deleteFiles?}`) sets `MediaClip::offline`. The clip then shows "MEDIA SET OFFLINE" even though the file exists, until it is linked again. Files are deleted only with `deleteFiles: true`. - The media pool caches sources per item **and** per reference (path, offline flag). So relinking, Make Offline and undoing either take effect on the next frame. ### Link Media | Command | | |---|---| | `media.findMissing` | `{missing: [{item, name, fileName, path, status: missing\|offline\|proxyMissing, duration, startTimecode}]}` | | `media.status {item?}` | per item: `online`, `missing`, `offline`, `unreadable` or `generated`, plus identity and proxy | | `media.linkMedia` | File ▸ Link Media… (rescans; the UI opens the dialog) | | `media.relink {item, path, force?, relinkOthers?=true, alignTimecode?, match?}` | check, link, then the others | | `media.autoRelink {from, to}` / `{folder}` | batch relink: a folder-prefix remap, or a search of a folder tree | | `media.search {folder, item?, exactName?=true}` | ranked candidates with `ok`, `identityMatch`, `problems` | | `media.replaceFootage {item, path}` | relink without checks (Replace Footage) | | `media.offlineAll` | close the dialog and leave the rest offline | `match` selects which properties must agree. Each defaults as shown: - `fileName` (true): the file name stem; - `extension` (true); - `clipId` (true): the fingerprint; a mismatch is refused with "not the same file…"; - `duration` (true): within one frame; - `mediaStart` (false): the start timecode; - `metadata` (true): frame size, frame rate and audio channels. `force: true` links regardless. **Relink others automatically** works out the folder remap from the file just linked, by dropping the common trailing path components (`/Volumes/A/shoot/a.mov` → `/Users/me/shoot/a.mov` gives `/Volumes/A` → `/Users/me`). It applies the remap to the other missing files and checks each one. It also looks in the new file's folder. Windows and macOS separators both work. **Align Timecode** keeps clips on the same timecode when the new file starts at a different timecode: the clips' source in-points, keyframes, marks and markers move with it (`Project::shift_media_time`). One relink, together with the files it pulls along, is one undo step. Link Media dialog automation ids: `linkMedia.row.`, `linkMedia.match.`, `linkMedia.alignTimecode`, `linkMedia.relinkOthers`, `linkMedia.folder`, `linkMedia.browse`, `linkMedia.exactName`, `linkMedia.search`, `linkMedia.candidate.`, `linkMedia.preview`, `linkMedia.link`, `linkMedia.locate`, `linkMedia.offline`, `linkMedia.offlineAll`, `linkMedia.cancel`. Make Offline: `makeOffline.keep|delete|ok|cancel`. In the Project panel, offline items get a broken-link badge (`project.item..offline` in Icon view), and items with a proxy get a **P** badge (`project.item..proxy`). ### Proxies | Command | Menu | | |---|---|---| | `media.createProxies {items?, preset?, destination?, attach?=true, wait?}` | Clip ▸ Proxy ▸ Create Proxies… | background job; attaches when done | | `media.attachProxies {item, path}` / `{items, paths}` | Attach Proxies… | duration within one frame and the same frame rate are required; size and aspect may differ | | `media.detachProxies {items?}` | Detach Proxies | | | `media.reconnectFullRes {item, path}` | Reconnect Full Resolution Media… | link the full-resolution file of a clip that has a proxy | | `media.toggleProxies {enabled?}` | View ▸ Toggle Proxies, monitor button | Preferences ▸ Media ▸ `media.enableProxies` | | `media.proxyPresets` | | | The presets use our own encoders: - `prores_proxy_quarter` (default), `prores_proxy_half`, `prores_lt_half`: ProRes 422 Proxy or LT in MOV with PCM audio; - `h264_quarter`, `h264_half`: H.264 + AAC in MP4. Proxies go to `/Proxies/_Proxy.`, or to the chosen folder. Job progress is in `jobs.list`. `Session::poll_persistence`, which the app calls every frame, attaches finished proxies. With proxies enabled, monitors, thumbnails and playback read the proxy. The proxy source reports the original's size. The compositor derives its pixel scale from the frame it gets, so Motion and pixel-size effect parameters (blur radii…) give the same picture at the proxy's resolution. A 320×180 clip with Motion and Gaussian Blur and a ½-size ProRes Proxy measures 44 dB PSNR against full resolution (48 dB at ½ playback resolution). **Export always uses full resolution** (`MediaPool::full_res_provider`). Toggling proxies bumps the revision so caches refresh, but it is not an edit and doesn't mark the project as modified. **Cost.** These numbers are from `proxy_playback_perf_4k`, an ignored test: `RAYON_NUM_THREADS=1 cargo test --release -p filmcraft-engine proxy_playback_perf_4k -- --ignored --nocapture`. It plays a 2 s 3840×2160 H.264 clip (ffmpeg `testsrc2`, libx264) on one core of the development Mac and reports milliseconds per frame: | | decode (GPU path) | CPU composite at ½ | CPU composite at ¼ | proxy creation | |---|---|---|---|---| | full resolution | 9.2 | 277 | 201 | | | ProRes Proxy ¼ | 8.9 | 150 | 35 | 17.8 s | | H.264 ¼ | 0.7 | 131 | 48 | 8.9 s | The test pattern is unusually cheap for H.264 to decode, so real camera footage gains more from proxies. The CPU composite (YUV→linear conversion and resampling) is what proxies cut most: 5.7× at ¼ resolution, where the proxy needs no resampling. Create Proxies dialog ids: `proxies.preset.`, `proxies.destination`, `proxies.browse`, `proxies.ok`, `proxies.cancel`. Monitor button: `program.transport.media.toggleProxies` (and `source.…`). It is lit while proxies are on. ### Ingest settings `project.ingestSettings {enabled, action: copy|transcode|createProxies|copyAndCreateProxies, destination?, preset?}` (Project Settings ▸ Ingest) acts on every `file.import`: - **copy** copies the file to the destination (default `/Ingested Media`), checks the copy's fingerprint against the original, and then uses the copy; - **transcode** writes the preset's format (default ProRes 422 LT) in the background and switches the clip to the result when it is done; - **createProxies** makes proxies in the background and attaches them; - **copyAndCreateProxies** does both. `file.import` reports this under `ingest`. ## Project Manager `file.projectManager` (File ▸ Project Manager…) makes a self-contained copy of a project: ```json {"destination": "/path/Folder", "mode": "collect|consolidate", "sequences": [id], "excludeUnused": true, "handles": 30, "preset": "prores_lt", "includeProxies": true, "includePreviews": false, "projectName": "…", "dryRun": false, "overwrite": false, "wait": false} ``` - **collect** copies every media file that the chosen sequences use (following nested sequences and subclips), plus their proxies and render previews if asked. - **consolidate** writes only the used range of each movie or audio file, plus `handles` frames on each side, with a transcode preset (`prores_lt`, `prores_hq`, `h264`). It then re-bases the clips' media time, so every edit, keyframe and marker stays in place. Stills are copied. - **excludeUnused** drops media items and sequences that the chosen sequences don't use. - **dryRun** returns the plan and the disk-space estimate (`originalBytes`, `resultBytes`, `files`) without writing anything. The copy runs as a job. The project file (`/.fcproj`) is written last, with the new paths and fingerprints, so it opens with no missing media. Dialog ids: `pm.seq.`, `pm.mode.`, `pm.preset.`, `pm.excludeUnused`, `pm.handles`, `pm.includeProxies`, `pm.includePreviews`, `pm.destination`, `pm.browse`, `pm.calculate`, `pm.sizes`, `pm.ok`, `pm.cancel`. ## Effect presets Effect presets (Effects panel ▸ Presets; Effect Controls ▸ right-click an effect ▸ Save Preset…) are not part of the project. Built-in presets are defined in code (`filmcraft_engine::presets`); user presets live in `/effect-presets.json`, and `presets.export` / `presets.import` read and write the same JSON format: ```json {"format": "filmcraft.effect-presets", "version": 1, "presets": [ {"name": "My Vignette", "description": "", "keyframes": "AnchorToIn", "source_duration": 2540160000000, "source_size": [1920, 1080], "effects": [ /* EffectInstance, exactly as in .fcproj: effect, enabled, params, masks */ ]} ]} ``` Keyframe times are relative to the in point of the clip the preset was saved from (`source_duration` is that clip's media length in ticks). Applying re-times them: `Scale` stretches them over the target clip, `AnchorToIn` / `AnchorToOut` keep their distance from the target's in / out point. Point parameters, mask paths, feather and expansion are scaled by the target / source frame-size ratio. Intrinsic effects (Motion, Opacity…) replace the clip's own instance; other effects are added. Applying is one undo step; saving, renaming, deleting and importing change the library, not the project, and are not undoable. Files with another `format`, a newer `version` or unknown effect ids are refused.