Files
filmcraft/docs/project-files.md

27 KiB
Raw Permalink Blame History

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

{
  "format": "filmcraft.project",
  "schema_version": 13,
  "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, each sequence's zoom, scroll and track heights, and each sequence's playhead (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 (a playhead also snaps to a frame of its sequence), 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
13 Multi-stream audio MediaInfo::audio_streams and TrackItem::audio_stream; older audio objects load as stream zero, missing clip indices default to zero; 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 vi+1 to vi+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<Value, String> 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 <name> (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 (.<name>.<pid>-<n>.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 <name>-YYYY-MM-DD_HH-MM-SS.fcproj (local time). An unsaved project uses <data dir>/Auto-Save/. After each write the oldest copies beyond the limit are deleted. Only files named exactly <name>-<timestamp>.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 <data dir>/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 <data dir>/Recovery/<started>-<pid>[-<n>]/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 <session>/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 (Quit, Cmd+Q or the window's close button) first asks "Save changes to 'X' before closing?" with Cancel, Don't Save and Save (Save on a never-saved project opens the Save dialog); Don't Save discards the changes and their snapshot. A quit that does not ask (the control channel's app.quit, the OS ending the session) keeps the snapshot, marked cleanExit, and FilmCraft offers it on the next launch ("FilmCraft was closed while … had unsaved changes").

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. A `portable.txt` file next to the executable (the Windows portable zip ships one) makes it `data` next to the executable instead.

Cost

The UI thread only gives the worker thread an Arc<Project>: 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.<n>; 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.<category> 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 Appearance Mode appearanceMode (auto = Sync with system / dark (default) / light; the header button and View ▸ Appearance ▸ Next Appearance Mode cycle it), Dark Theme darkTheme (darkest (default) / dark), Light Theme lightTheme (light), highlight colour, accessible contrast. colorTheme (Darkest / Dark / Light) is legacy input: setting it (older files and clients, View ▸ Appearance ▸ a theme) selects that theme and fixes the mode to its family; a file without appearanceMode migrates to its theme's fixed mode. It is kept in line with the choices: the theme a fixed mode shows, the dark theme in Auto
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, Label Color Presets: JSON files ({"name", "labels": [16 × {"name", "color": "#rrggbb"}]}) in <data dir>/Label Presets, built in: "Default" (the labels FilmCraft ships with), "Bright" (16 saturated hues) and "Roles" (B-Roll, Footage, Dialogue… named so the label defaults land on matching roles) (labels.presets, labels.importPreset {path}, labels.exportPreset {path}, labels.deletePreset {name}; labels.preset is the selected one)
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 (<data dir>/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.<id>; settings.<key> for every control (for example settings.timeline.autoScroll, settings.labels.colors.rose.color); settings.<key>.<value> for the items of an open dropdown; settings.<key>.browse, settings.<key>.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.
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.<n>, linkMedia.match.<fileName|extension|clipId|duration|mediaStart|metadata>, linkMedia.alignTimecode, linkMedia.relinkOthers, linkMedia.folder, linkMedia.browse, linkMedia.exactName, linkMedia.search, linkMedia.candidate.<n>, 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.<id>.offline in Icon view), and items with a proxy get a P badge (project.item.<id>.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 <media folder>/Proxies/<name>_Proxy.<ext>, 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.<id>, 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 <media folder>/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.

Copied files keep their names when available; collisions receive a numbered suffix before the extension (A001 2.mov) and never replace an existing file. Transcodes reserve distinct output names for every clip in the import batch. Copies keep their source fingerprint, and each proxy stays attached to its corresponding take.

file.import reports this under ingest.

Project Manager

file.projectManager (File ▸ Project Manager…) makes a self-contained copy of a project:

{"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 (<destination>/<name>.fcproj) is written last, with the new paths and fingerprints, so it opens with no missing media.

Dialog ids: pm.seq.<id>, pm.mode.<collect|consolidate>, pm.preset.<id>, 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 <data dir>/effect-presets.json, and presets.export / presets.import read and write the same JSON format:

{"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.