mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-31 13:38:39 -06:00
172 lines
8.8 KiB
Markdown
172 lines
8.8 KiB
Markdown
# 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": 3,
|
||
"generator": "FilmCraft 0.1.0",
|
||
"project": { "name": "…", "settings": { … }, "root": { … }, "items": { … }, "next_id": 48 }
|
||
}
|
||
```
|
||
|
||
- `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.
|
||
|
||
### 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 |
|
||
|
||
### 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<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.
|
||
|
||
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 v4); this build reads up to
|
||
> v3. 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
|
||
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
|
||
<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<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` | preferences |
|
||
|
||
UI automation ids: `prefs.autoSave.enabled`, `prefs.autoSave.intervalMinutes`,
|
||
`prefs.autoSave.maxVersions`, `prefs.autoSave.saveCurrentProject`, `prefs.autoSave.recoveryJournal`,
|
||
`prefs.autoSave.recoveryIntervalSeconds`, `prefs.ok`, `prefs.cancel`, `prefs.reset`;
|
||
`recovery.recover`, `recovery.later`, `recovery.discard`, `recovery.item.<n>`; `revert.yes`,
|
||
`revert.no` (File ▸ Revert asks first when invoked without params).
|