mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-29 04:27:47 -06:00
322 lines
20 KiB
Markdown
322 lines
20 KiB
Markdown
# Architecture
|
||
|
||
FilmCraft is a Cargo workspace of small crates with strictly enforced layering. The engine is
|
||
headless: every feature can be reached without a window, and the egui UI is one client among the
|
||
CLI, the JSON control channel and the MCP server.
|
||
|
||
Design principles:
|
||
|
||
1. **Engine-first.** Project-changing actions go through `Session::execute(id, params)`.
|
||
2. **Everything is a command.** Stable id, label, menu path, shortcut, parameter doc, `enabled()`
|
||
predicate with a human-readable reason, and `run()`.
|
||
3. **Exact time.** Integer ticks, rational frame rates. No `f64` seconds in edit math.
|
||
4. **Copy-on-write snapshots.** The project is an `Arc<Project>`. Undo is a stack of snapshots, and
|
||
background readers (playback, export) hold a snapshot without locking.
|
||
5. **CPU reference, GPU fast path.** The CPU compositor is the oracle. The GPU path is tested
|
||
against it.
|
||
6. **Pure Rust, clean-room.** Codecs and containers are written from public specifications
|
||
(see [AGENTS.md](../AGENTS.md)).
|
||
|
||
## 1. Layers
|
||
|
||
```text
|
||
L6 apps/filmcraft · apps/filmcraft-cli
|
||
L5 ui-egui · automation
|
||
L4 engine
|
||
L3 render · gpu · export · golden (test-only)
|
||
L2 edit · codecs · interchange · captions
|
||
L1 frame · media · project · audio-dsp · text
|
||
L0 foundation: time · geom · color · bitstream · testkit (dev-dependency only)
|
||
codecs/containers: isobmff · matroska · h264 · h264enc · hevc · prores · aac
|
||
```
|
||
|
||
Crates are named `filmcraft-<dir>` (`crates/time` is `filmcraft-time`). The apps are `filmcraft`
|
||
and `filmcraft-cli`.
|
||
|
||
| Crate | Layer | Purpose |
|
||
|---|---|---|
|
||
| `time` | L0 | `Tick`, `FrameRate`, `TimeRange`, timecode parse/format (NDF/DF, frames, feet+frames, samples) |
|
||
| `geom` | L0 | `Vec2`, `Rect`, `Affine`, Motion-transform composition |
|
||
| `color` | L0 | colour spaces, transfer functions, YUV↔RGB matrices, LUTs |
|
||
| `bitstream` | L0 | bit reader/writer, Exp-Golomb, emulation prevention |
|
||
| `isobmff` | L0 | MP4/MOV demux and mux |
|
||
| `matroska` | L0 | MKV/WebM demux |
|
||
| `h264`, `h264enc` | L0 | H.264 decoder; H.264 encoder |
|
||
| `hevc` | L0 | H.265 Main/Main 10 decoder |
|
||
| `prores` | L0 | ProRes decoder and encoder |
|
||
| `aac` | L0 | AAC-LC decoder and encoder |
|
||
| `testkit` | L0 | test-only helpers, used only as a dev-dependency: ffmpeg/ffprobe discovery, fixture dirs, golden images ([testing.md](testing.md)) |
|
||
| `frame` | L1 | `VideoFrame` (planar YUV / RGBA8 / linear RGBA f32, colour metadata), `AudioBuffer` |
|
||
| `media` | L1 | `MediaSource` trait, probing/openers, frame cache, generators, stills, WAV |
|
||
| `project` | L1 | document model, effect definitions, keyframes |
|
||
| `audio-dsp` | L1 | loudness metering (BS.1770 / R128) and audio effects; no dependencies |
|
||
| `text` | L1 | text engine: font database (bundled OFL fonts + system fonts), shaping (harfrust), bidi, line breaking, paragraph layout, glyph/path rasteriser, strokes ([crates/text/README.md](../crates/text/README.md)) |
|
||
| `edit` | L2 | pure edit algebra (insert, overwrite, razor, ripple, roll, slip, slide, rate stretch…) |
|
||
| `codecs` | L2 | container + codec hub: MP4/MOV and MKV sources, GOP-aware seeking, decoder registry, audio decoding |
|
||
| `interchange` | L2 | EDL, FCP7 XML, FCPXML and OTIO import/export (no file I/O) |
|
||
| `render` | L3 | sequence evaluation, CPU compositor, video effects, transitions, audio mix |
|
||
| `gpu` | L3 | wgpu compositor (WGSL) |
|
||
| `golden` | L3 | test-only: golden-image tests of the CPU renderer and GPU-vs-CPU parity; empty library, dev-dependencies only |
|
||
| `export` | L3 | render → encode → mux pipeline, progress/cancel |
|
||
| `engine` | L4 | `Session`, command registry, undo history, media pool, jobs, interchange glue |
|
||
| `ui-egui` | L5 | the egui frontend: docking, panels, timeline, monitors, playback, control-channel handlers |
|
||
| `automation` | L5 | MCP server (`rmcp`, stdio), headless or bridged to the running app |
|
||
| `filmcraft` | L6 | desktop binary: eframe/wgpu window, cpal audio output, file dialogs, native macOS menu, TCP control server |
|
||
| `filmcraft-cli` | L6 | headless CLI: `probe`, `commands`, `render`, `run`, `mcp` |
|
||
|
||
### What `cargo xtask layers` enforces
|
||
|
||
The table of layers lives in `xtask/src/main.rs` (`LAYERS`). It also reserves names for planned
|
||
crates. The check reads `cargo metadata` and looks at normal and build dependencies (dev-dependencies
|
||
are exempt):
|
||
|
||
| Rule | Detail |
|
||
|---|---|
|
||
| Every crate has a layer | A new crate fails the check until it is added to `LAYERS`. |
|
||
| Only downward edges | A crate may not depend on a crate in a higher layer. |
|
||
| Same-layer edges are listed | From L1 up, a same-layer edge must be in `SAME_LAYER`: `media→frame`, `project→media`, `project→frame`, `gpu→render`, `export→render`, `cli→filmcraft`, plus a few reserved for planned crates. |
|
||
| L0 codecs stay standalone | L0 crates other than `time`, `geom`, `color`, `bitstream`, `testkit` may depend on no workspace crate except `filmcraft-bitstream`. External crates such as `thiserror` and `rayon` are allowed. |
|
||
| No UI/OS crates below L5 | `egui`, `eframe`, `egui-wgpu`, `winit`, `rfd`, `cpal`, `muda` are allowed only in L5 and L6. |
|
||
|
||
`cargo xtask wasm` runs `cargo check --target wasm32-unknown-unknown` on every L0–L4 crate, so
|
||
everything up to the engine stays web-portable. `unsafe_code = "deny"` applies workspace-wide.
|
||
|
||
## 2. Time base
|
||
|
||
All time is `filmcraft_time::Tick(i64)` at `TICKS_PER_SECOND = 254_016_000_000`.
|
||
|
||
That number divides evenly into the frame duration of every broadcast rate (23.976, 24, 25, 29.97,
|
||
30, 48, 50, 59.94, 60, 120…) and the sample duration of every common audio rate (8 kHz to 192 kHz,
|
||
including the 44.1 kHz family). So frame and sample positions are exact integers, edits never drift
|
||
at 29.97, and audio and video line up to the sample.
|
||
|
||
| Type | Use |
|
||
|---|---|
|
||
| `Tick` | timeline and media positions and durations |
|
||
| `FrameRate { num, den }` | `frame_duration()`, `tick_of(frame)`, `frame_at(tick)`, `snap(tick)` |
|
||
| `TimeRange` | half-open `start + duration` |
|
||
| Timecode | display only (SMPTE NDF/DF, frames, feet+frames, samples); `parse_timecode` and `format_time` |
|
||
|
||
Commands take time as `time` (ticks), `frame`, `seconds` or `timecode`; the engine converts once at
|
||
the boundary.
|
||
|
||
## 3. Data model (`filmcraft-project`)
|
||
|
||
```text
|
||
Project
|
||
├─ root: Bin tree of bins
|
||
├─ items: map ItemId → ProjectItem flat
|
||
│ kind: Media(MediaClip) | Sequence(Sequence) | Subclip{..} | AdjustmentLayer{..} | Graphic{..}
|
||
└─ next_id
|
||
|
||
Sequence
|
||
├─ settings: frame rate, size, sample rate, …
|
||
├─ video_tracks / audio_tracks: Vec<Track>
|
||
├─ markers, mark_in / mark_out
|
||
Track
|
||
├─ locked, sync lock, targeting, mute/solo/visibility
|
||
├─ items: Vec<TrackItem> sorted, never overlapping
|
||
├─ transitions: Vec<Transition>
|
||
└─ audio: volume_db, pan, effects (mixer inserts), mixer: MixerStrip
|
||
(automation mode + lanes, sends, output, record arm, solo safe, input map)
|
||
Sequence (audio) ─ submix_tracks: Vec<Track>, master_volume_db / master_effects / master_mixer
|
||
TrackItem (a clip instance)
|
||
├─ item: ItemId, start (timeline ticks), source_in (media ticks), duration, speed
|
||
├─ link group, label, enabled
|
||
└─ effects: Vec<EffectInstance> intrinsic Motion/Opacity/Volume… first, then standard effects
|
||
EffectInstance
|
||
└─ effect id, enabled, params: id → constant value or keyframe track
|
||
```
|
||
|
||
- **Graphic clips** ([graphics.md](graphics.md)) reference a `Graphic` canvas item; their text and
|
||
shape layers are hidden `graphic_text` / `graphic_shape` effect instances on the track item.
|
||
- Everything is plain serde data. `Sequence::check()` validates the invariants (no overlaps, unique
|
||
ids), and the engine runs it after every sequence edit.
|
||
- Timeline positions are sequence ticks; `source_in` and keyframes are in media time, so trims and
|
||
splits never move keyframes.
|
||
- **Effect definitions are data.** `project::effect::effect_defs()` lists every video effect,
|
||
audio effect and transition with its parameter schema. The Effects panel tree, the Effect
|
||
Controls rows and the parameter docs agents see are all generated from it.
|
||
- **Project files** (`.fcproj`) are the project serialised as JSON. Saves are atomic: write a
|
||
temporary sibling file, then rename it over the target.
|
||
|
||
## 4. Command system (`filmcraft-engine`)
|
||
|
||
```rust
|
||
pub struct CommandSpec {
|
||
pub id: &'static str, // "sequence.addEdit"
|
||
pub label: &'static str, // "Add Edit"
|
||
pub menu: &'static [&'static str], // ["Sequence"]; empty = not in menus
|
||
pub shortcut: Option<&'static str>, // "Cmd+K" (Cmd = ⌘ on macOS, Ctrl elsewhere)
|
||
pub params: &'static str, // r#"{"time":ticks?}"#, shown to agents
|
||
pub enabled: fn(&Session) -> Result<(), String>, // Err carries the reason
|
||
pub run: fn(&mut Session, &Value) -> Result<Value>,
|
||
pub journal: bool, // false for read-only queries
|
||
}
|
||
```
|
||
|
||
- All commands are in `crates/engine/src/commands.rs` (`cmd!` for actions, `query!` for read-only
|
||
queries such as `project.inspect`, `sequence.inspect`, `effects.list`, `jobs.list`). Ids follow
|
||
the menu structure: `file.*`, `edit.*`, `clip.*`, `sequence.*`, `markers.*`, `timeline.*`,
|
||
`effects.*`…
|
||
- `Session::execute(id, params)` finds the spec, checks `enabled`, runs it and appends it to the
|
||
journal.
|
||
- **Undo.** Edits go through `Session::edit(label, |project, state| …)` or
|
||
`Session::edit_sequence(label, |seq, ctx, state| …)`. These clone the project, apply the closure,
|
||
and on success push the old `Arc<Project>` with the label onto the undo stack (200 entries).
|
||
On error nothing changes. `edit.undo` and `edit.redo` swap snapshots. Thanks to structural sharing
|
||
a snapshot costs little.
|
||
- **Editor state** (`EditorState`: active sequence, playheads, selection, targeting, edit points…)
|
||
is serde, so agents can read it with `state.inspect`.
|
||
- **Events** (`ProjectChanged`, `Toast`, `OpenSequence`, `OpenSource`) are drained by frontends
|
||
each frame.
|
||
- **UI-only commands** (tools, playback, zoom, panels, workspaces) live in
|
||
`crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The menu bar is built from the engine registry plus
|
||
this table, and `menus::invoke` is the single entry point for menus, shortcuts and the control
|
||
channel.
|
||
|
||
## 5. Media, render and playback pipeline
|
||
|
||
```text
|
||
file ──► codecs (MP4/MOV, MKV, audio) demux + decode, GOP-aware seek
|
||
│ decoder registry: h264, hevc, prores, mjpeg (+ any registered first)
|
||
▼
|
||
media::MediaSource ──► frame cache (byte-budgeted LRU, shared)
|
||
▼
|
||
render::render_sequence(project, seq, t, scale) CPU reference
|
||
per track bottom→top: map timeline t → media t (speed), fetch frame,
|
||
standard effects → Motion → Opacity/blend, transitions, composite
|
||
in linear-light premultiplied f32
|
||
│
|
||
└─ render::plan::plan_frame → gpu::GpuCompositor GPU path
|
||
layers = decoded YUV/RGBA frames + matrix + opacity;
|
||
anything the shaders don't cover is pre-rendered on the CPU
|
||
▼
|
||
ui-egui frames.rs worker pool ──► monitors (program/source), thumbnails, prefetch
|
||
```
|
||
|
||
- **Sources.** `media::MediaSource` yields `video_frame(FrameRequest)` and
|
||
`audio(start, frames, rate)` in media time. Sources are `Send + Sync` and shared by monitors,
|
||
thumbnails, playback and export. The engine's `MediaPool` creates one per project item, lazily,
|
||
through registered openers (`codecs::openers()`: MP4/MOV, MKV/WebM, audio files).
|
||
- **Seeking.** `codecs::Mp4Source` seeks to the preceding sync sample and decodes forward, caching
|
||
every frame of the GOP. Sequential playback reuses the decoder. Decoders implement
|
||
`codecs::VideoDecoder`. `register_video_decoder` puts a factory in front of the built-in ones, so a
|
||
hardware decoder can take precedence.
|
||
- **Compositor.** `render` is the reference for monitors, thumbnails and export. `render::plan`
|
||
turns a frame into GPU layers. Non-Normal blend modes, standard effects, adjustment layers, nested
|
||
sequences and non-dissolve transitions are rendered on the CPU for that layer or frame and handed to
|
||
the GPU as an image, so both paths give the same picture. Setting `FILMCRAFT_CPU_COMPOSITE=1`
|
||
forces the CPU path in the desktop app.
|
||
- **Frame scheduling.** `crates/ui-egui/src/frames.rs` runs a small pool of worker threads with
|
||
prioritised jobs: the frame on screen first, then playback prefetch, then thumbnails. The UI never
|
||
decodes. It shows the exact frame when it is ready and holds the nearest cached frame meanwhile.
|
||
Play waits for the first frames (`PREROLL_FRAMES`, at most 0.5 s) before starting the clock.
|
||
Every refresh, `schedule_playback` asks for the next frames and drops (or cancels, through
|
||
`filmcraft_media::cancel`) jobs for frames the playhead has passed; a new on-screen frame
|
||
replaces the one asked for before (scrubbing); Stop cancels the prefetch. When frames cost more
|
||
than the workers can render in real time (CPU effects), it starts only frames that can still be
|
||
on time and spaces them evenly (`playback_plan`). GPU plans carry their texels already
|
||
converted for upload (`filmcraft_gpu::prepare`), so the UI thread only copies them.
|
||
- **Audio clock.** The desktop app passes a cpal output (`apps/filmcraft/src/audio.rs`) to the UI as
|
||
`AudioOut`. While playing, the samples played by the sound card drive the playhead and video follows.
|
||
Without an audio device, playback falls back to the wall clock. `PlaybackMeter` counts timeline
|
||
frames: shown when the exact picture was on screen while due, dropped otherwise (including frames
|
||
passed over without a refresh, not while the window is hidden). `cargo xtask bench-playback`
|
||
measures the whole path headlessly ([testing.md](testing.md) §5).
|
||
Sequence audio goes through the mixer graph (`render::mixer`, §5.1); clip audio effects run on
|
||
`audio-dsp` via `render::audio_fx`.
|
||
|
||
### 5.1 Audio mixer
|
||
|
||
```text
|
||
clip: gain → clip effects → Volume / Channel Volume / Panner (clip keyframes, media time)
|
||
→ audio transitions → summed per track render::audio::track_input
|
||
track / submix strip: input map, mono fold → pre-fader inserts → pre-fader sends → mute
|
||
→ fader (volume) → meter → post-fader inserts → post-fader sends → pan / balance → output
|
||
Mix: bus sum → pre-fader inserts → fader → meter → post-fader inserts → out render::mixer
|
||
```
|
||
|
||
- **Model** (`project::mixer`). Every audio track, submix and the Mix has a `MixerStrip`. Static
|
||
values stay in `Track::volume_db`, `pan`, `muted` and the send/effect parameters; automation is
|
||
keyframes in sequence ticks: lanes `volume`, `pan`, `mute` (hold), `send.<i>.level` in
|
||
`MixerStrip::lanes`, and insert parameters (`fx.<slot>.<param>`) in the effect's own keyframes.
|
||
Up to 5 inserts (`EffectInstance::post_fader` picks the side) and 5 sends per strip. Submixes feed
|
||
the Mix or a submix after them (no feedback). All fields have serde defaults, so older projects
|
||
load unchanged.
|
||
- **Graph** (`render::mixer::mix_graph`). Lanes are evaluated per sample; effect parameters update
|
||
on an absolute 64-sample grid, so the output does not depend on how callers cut the timeline into
|
||
requests (export batches and device callbacks give identical samples). Inserts that report
|
||
latency delay their strip; each route into a bus gets a compensation delay and the graph is read
|
||
ahead by its total latency. Graph state (DSP, delay lines) is cached per structure and continued by
|
||
sequential readers; other requests start fresh with a pre-roll (effect tails, ≤ 3 s). Tracks run
|
||
in parallel (rayon), buses in order. Mono tracks pan with the −3 dB constant-power law; stereo
|
||
tracks and sends use balance. Solo keeps soloed and solo-safe strips plus everything feeding them
|
||
or fed by them. 24 tracks × (EQ + Dynamics + Studio Reverb) + a compressed submix renders at
|
||
~6× realtime on one core (release).
|
||
- **Automation modes** (Premiere semantics). Off ignores lanes; Read plays them; Latch records from
|
||
the first touch and holds the last value until playback stops; Touch records while held and ramps
|
||
back to the existing automation over the **automatch time** (Preferences ▸ Audio, 1 s); Write
|
||
records every control from playback start (then switches to Touch unless "Switch to Touch after
|
||
Write" is off).
|
||
- **Recording** (`engine::mixer`). Playback start runs `mixer.recordStart`, stop runs
|
||
`mixer.recordStop`. Fader and knob drags send `mixer.touch` (value, playhead) while held and
|
||
`mixer.release` when let go. Held values go to `render::mixer::LiveMix`, which the playing mix
|
||
reads, so moves are heard at once. At stop each gesture stream is thinned (linear keyframe
|
||
thinning, optional minimum time interval) and written over its time range as one undo step,
|
||
with boundary keyframes that keep the automation outside the range unchanged.
|
||
- **Live state.** `PreviewStore::live` (`LiveMix`) also carries per-strip meter peaks posted by the
|
||
mix (Track Mixer and Audio Meters read them) and the newest project snapshot, which the audio
|
||
callback uses, so edits made during playback are heard.
|
||
|
||
## 6. Export jobs (`filmcraft-export`)
|
||
|
||
```text
|
||
file.exportMedia {path, format, scale, audio, quality}
|
||
→ engine creates a Job {id, label, progress, result} and runs it on a background thread
|
||
→ export: render frames in parallel batches → encode in order → mux; audio mixed per batch
|
||
→ jobs.list shows progress; jobs.cancel sets the shared cancel flag
|
||
```
|
||
|
||
| Format | Encoder | Container |
|
||
|---|---|---|
|
||
| `h264` | `filmcraft-h264enc` + `filmcraft-aac` | MP4 (`isobmff`) |
|
||
| `prores` | `filmcraft-prores` | MOV |
|
||
| `mjpeg` | built in | MOV |
|
||
| `png`, `gif`, `wav` | built in | image sequence / GIF / WAV |
|
||
|
||
Video encoders implement `export::VideoEncoder`. Codec crates plug in with `register_encoder` and
|
||
`register_audio_encoder`.
|
||
|
||
Timelines can be exchanged as EDL, FCP7 XML, FCPXML or OTIO. `file.import` detects these formats and
|
||
merges the result into the project as one undoable step, and `file.exportInterchange`,
|
||
`file.exportEdl`, `file.exportFcpxml` and `file.exportOtio` write them.
|
||
|
||
## 7. Automation surfaces
|
||
|
||
All of these dispatch the same command ids.
|
||
|
||
| Surface | Where | Scope |
|
||
|---|---|---|
|
||
| UI | `ui-egui` menus, shortcuts, panels | `menus::invoke` → engine or UI command |
|
||
| CLI | `filmcraft-cli` | `commands`, `run script.jsonl` (one `{"id","params"}` per line), `render`, `probe` |
|
||
| Control channel | `filmcraft --control <port>` | JSON lines on loopback TCP: engine commands plus synthetic input, inspection and screenshots of the live UI |
|
||
| MCP | `filmcraft-cli mcp` | stdio MCP server: headless in-process session, or `--bridge` to the control channel |
|
||
|
||
- **Automation ids.** Every interactive widget calls `app.auto.add(id, rect, label)` each frame
|
||
(`crates/ui-egui/src/automation.rs`). Agents click by id, e.g. `tools.Razor`,
|
||
`timeline.clip.<id>`, `effects.item.gaussian_blur`, `panel.Timeline`.
|
||
- **UI state** that is not project data (tool, workspace, dock layout, zoom, scroll, monitor
|
||
settings) is in `crates/ui-egui/src/state.rs` as serde structs, so `ui.inspect` and `ui.set` can
|
||
read and write it.
|
||
|
||
Protocol reference: [control-protocol.md](control-protocol.md). Agent guide: [agents.md](agents.md).
|
||
|
||
## 8. Not built yet
|
||
|
||
The layer table reserves names for crates that don't exist yet: `riff`, `mjpeg`, `dnx`,
|
||
`keyframe`, `effects`, `audio`, `scopes`, `playback`, `format` and `platform`.
|
||
Until they exist, that work lives elsewhere: keyframes and effect definitions in `project`, effects
|
||
and the audio mix in `render`, scopes and playback in `ui-egui`, and OS integration (cpal, rfd,
|
||
native menus) in `apps/filmcraft`. [ROADMAP.md](../ROADMAP.md) has the milestone status.
|