NC Kinema · Docs

Architecture

Last reviewed: 2026-10-10 · Last updated: 2026-10-10 · Change: trivial (status line, revision history and roadmap link added; content checked against the crate list) · Target: Adobe Premiere Pro 2026 (26.5.2)

Kinema 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).

1. Layers

 L6  apps/kinema · apps/kinema-cli · apps/kinema-web
 L5  ui-egui · automation · platform
 L4  engine
 L3  render · gpu · export · golden (test-only)
 L2  edit · codecs · interchange · captions · speech · tts · tts-text
 L1  frame · media · project · audio-dsp · text
 L0  foundation: time · geom · color · bitstream · testkit (dev-dependency only)
     codecs/containers: isobmff · matroska · mxf · cfb · mpegts · ogg · h264 · h264enc · hevc · vp9 · av1 · mpeg2v · prores · dnx · apv · dng · aac · ac3 · mpa · opus

Crates are named kinema-<dir> (crates/time is kinema-time). The apps are kinema and kinema-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
mxf L0 MXF demux and mux (SMPTE ST 377-1): OP1a / OP-Atom, index tables, AVC / VC-3 / ProRes / MPEG-2 identification, PCM / AES3 sound, timecode; writer for OP1a / OP-Atom with VC-3, ProRes, AVC and PCM (README)
cfb L0 Compound File Binary ([MS-CFB] structured storage) reader and writer, the container of AAF (README)
mpegts L0 MPEG-2 Systems demux (H.222.0): transport streams (188 / 192-byte BDAV / 204), program streams (MPEG-2 / MPEG-1, VOB, MOD), PSI, PES, PTS / PCR, access-unit index built on open (README)
ogg L0 Ogg demux (RFC 3533), Ogg Opus timing (RFC 7845 granules, pre-skip, end trimming), Vorbis headers
h264, h264enc L0 H.264 decoder; H.264 encoder
hevc L0 H.265 Main/Main 10 decoder
prores L0 ProRes decoder and encoder
dnx L0 DNxHD / DNxHR (SMPTE ST 2019-1 VC-3) decoder and DNxHR encoder
apv L0 Advanced Professional Video (IETF RFC 9924) decoder and encoder (README)
dng L0 DNG / CinemaDNG camera RAW decoder: TIFF container, uncompressed and lossless JPEG raw data, DNG processing to linear Rec. 709 (README)
av1 L0 AV1 decoder (Main profile; bit-exact with libdav1d; see its README for the stage table)
mpeg2v L0 MPEG-2 video (Main / 4:2:2 profile; frame and field pictures, dual prime) and MPEG-1 video decoder (README)
aac L0 AAC-LC decoder and encoder
ac3 L0 AC-3 (ATSC A/52) decoder (README)
mpa L0 MPEG-1/2 Audio Layer I and II (MP1 / MP2) decoder (README)
testkit L0 test-only helpers, used only as a dev-dependency: ffmpeg/ffprobe discovery, fixture dirs, golden images (testing.md)
frame L1 VideoFrame (planar YUV / RGBA8 / linear RGBA f32, colour metadata), AudioBuffer
media L1 MediaSource trait, probing/openers, frame cache, generators, stills, image sequences, WAV / Broadcast WAV
project L1 document model, effect definitions, keyframes
audio-dsp L1 loudness metering (BS.1770 / R128), audio effects, channel layouts (BS.775 up/downmix, 5.1 panner), music remix analysis; 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)
edit L2 pure edit algebra (insert, overwrite, razor, ripple, roll, slip, slide, rate stretch…; text-based editing: edit::transcript)
speech L2 speech-to-text: Transcriber trait, Whisper model catalogue + verified downloader (feature download), pure-Rust Whisper inference on candle with word timestamps (feature whisper), speaker labelling (transcripts.md)
tts L2 text to speech: Voice trait, voice catalogue, pause markers, built-in formant voices; Kokoro-82M natural voices on candle and their pinned package catalogue (feature kokoro). Same-layer edge to tts-text
tts-text L2 text front end for narration: normalizer (numbers, money, dates, acronyms, pauses) and English pronunciation (CMUdict + letter-to-sound) to Kokoro phonemes
codecs L2 container + codec hub: MP4/MOV, MKV, MXF, Ogg and MPEG TS / PS / video elementary stream sources, GOP-aware seeking, decoder registry, audio decoding
interchange L2 EDL, FCP7 XML, FCPXML, OTIO, AAF (on cfb) and OMF 2.0 import/export (no file I/O; the engine supplies rendered audio essence) (README)
render L3 sequence evaluation, CPU compositor, video effects (effects, vfx; effects needing other frames or tracks read them through vfx::FxEnv), 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
scopes L3 video scope maths: waveform, parade, histogram, YUV / HLS vectorscopes, numeric summaries (crates/scopes/README.md)
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
platform L5 OS media FFI only: hardware video decoding (VideoToolbox H.264 / HEVC on macOS; Media Foundation / Direct3D 11 H.264 / HEVC / VP9 / AV1 on Windows; VA-API H.264 / HEVC on Linux, with our own parsers and DPBs driving the stateless hardware; a no-op elsewhere) behind codecs::VideoDecoder, with transparent fallback to our decoders, and hardware H.264 encoding (VideoToolbox, opt-in) and H.265 encoding (VideoToolbox, the only H.265 encoder; the format exists only where a hardware encoder does) and NVIDIA NVENC H.264 (Windows and 64-bit Linux, opt-in) and H.265 (Windows, Kinema's only H.265 encoder there) encoding behind export::VideoEncoder; H.264 declines to the built-in encoder for what the hardware does not take. The one crate allowed unsafe (ADR 0001, README)
kinema L6 desktop binary: eframe/wgpu window, cpal audio output, file dialogs, native macOS menu, TCP control server
kinema-cli L6 headless CLI: exec, run, inspect, describe, commands, import, export, render, probe, mcp; --bridge targets the running app
kinema-web L6 the browser app (wasm32): eframe web runner on WebGPU/WebGL2, Blob-backed services, OPFS recovery, WebAudio, WebCodecs, window.kinema API (web.md)

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→kinema, 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 kinema-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, the egui UI and the web app, so everything up to the engine stays web-portable and the web app builds (web.md). unsafe_code = "forbid" applies workspace-wide, except in platform (deny, allowed only on its FFI modules; ADR 0001).

2. Time base

All time is kinema_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 (kinema-project)

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
└─ masks: Vec<Mask>                  path (keyframable Bézier), feather, opacity, expansion, inverted, mode

4. Command system (kinema-engine)

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
}

5. Media, render and playback pipeline

file ──► codecs (MP4/MOV, MKV, audio)        demux + decode, GOP-aware seek
          │   decoder registry: h264, hevc, vp9, av1, prores, dnx, mjpeg (+ any registered first:
          │   platform's VideoToolbox (macOS) or Media Foundation (Windows) H.264 / HEVC, falling back to ours)
          ▼
        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 + blend mode;
               anything the shaders don't cover is pre-rendered on the CPU
          ▼
        ui-egui frames.rs worker pool ──► monitors (program/source), thumbnails, prefetch

5.1 Audio mixer

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

5.1.1 Multichannel and 5.1

5.1 track (6 ch: L R C LFE Ls Rs) ─┐                         ┌─► 5.1 Mix ─► 6-ch export / 5.1 device
stereo / mono track ──5.1 panner───┼─► 5.1 submix / 5.1 Mix ─┤
5.1 strip ──BS.775 downmix─────────┴─► stereo bus            └─► stereo device: 5.1 Mixdown Type fold

5.1.2 Voice-over recording

Voice-over recording (engine::voiceover, audio.voiceover.*). The record point R is the playhead, or the In point when In/Out are set (punch-in; punch-out at Out). Playback starts the pre-roll before it (C = max(0, R − pre-roll)) and the input is captured from C. When the UI's audio clock really starts, audio.voiceover.sync restarts the capture there. audio.voiceover.stop keeps the audio from R to min(stop, Out), writes a mono 32-bit float WAV <Name> <n>.wav (Scratch Disks ▸ Captured, else next to the project, else the data or temporary directory), imports it and overwrites it onto the record track at R as one undo step. The record track is the given one, else the record-armed one, else the first targeted one. Input goes through the AudioInput trait: cpal in the desktop app (apps/kinema/src/audio_in.rs, on its own thread), and SyntheticInput headless, which produces exactly the samples the timeline asks for so recordings land sample-accurately. Voice-Over Record Settings (Source, Input channel, Name, Countdown Sound Cues, Pre-/Post-roll) are preferences (voiceOver). The track header's microphone records or stops, and a right-click opens the settings dialog. Countdown beeps (1 kHz, 100 ms, each whole second of pre-roll and at R) are mixed into playback, an overlay counts down, and playback stops at Out + post-roll. A take records anywhere, also on the empty timeline past the end of the sequence: starting it never returns the playhead to the beginning, and playback keeps going past the end until the take is stopped.

5.1.2a Text to Speech (narrations)

Narrations (engine::narration, tts.*; voices in kinema-tts). The Text to Speech panel (Window ▸ Text to Speech) writes a script with pause markers ([pause 1s]), a voice, a vocal pitch and a pace; tts.create synthesizes it, writes Narration <n>.wav (mono 32-bit float, 24 kHz, where voice-over recordings go), imports it and places it at the playhead on the first free targeted audio track, else the first free audio track, else a new one, as one undo step. The settings are kept per generated item in Project::narrations (schema v14). Selecting a narration clip loads it into the panel; tts.edit writes a new file (beside the previous one) and swaps the clip to it, keeping the clip's start, duration and speed: longer speech is kept whole in the file but plays only to the clip's end; shorter speech is padded with silence to the clip's length. The old item and narration stay, so undo is exact. tts.preview synthesizes without changing the project and the UI plays Session::tts_preview on the audio output. Built-in voices are an original formant synthesizer (robotic, no download). The natural voices (feature neural-voices of the desktop app, off by default like whisper) are Kokoro-82M on candle (kinema-tts) fed by kinema-tts-text (normalizer, CMUdict + letter-to-sound); their 336 MB package is downloaded once after the user confirms (tts.downloadVoices, a job). The UI synthesizes with tts.render (a job filling Session::tts_cache) and then runs create / edit / preview, which use the cache.

5.1.3 Remix

clip media (source In … +duration) ─► audio_dsp::remix::analyze: spectral-flux onsets → tempo (autocorrelation, 50–200 BPM)
  → DP beat tracker → per-beat chroma + mel cepstra → beat self-similarity
  ─► plan(target, Segments, Variations): intro … joints at beat boundaries … outro, length within one beat
  ─► hidden clip effect `remix` (target, sliders, original duration, plan in ticks) ─► render::remix::read (20 ms equal-power crossfades)

Clip ▸ Remix ▸ Enable Remix / Remix Properties… / Revert Remix (clip.remix.*; agents can use clip.remix {duration|seconds…}) and the Remix tool in the Rate Stretch group (drag a music clip's Out edge) retime a music clip to a target duration. The analysis finds the beat and compares every pair of beats by harmony (chroma) and timbre (cepstra). Each joint leaves the music at one beat boundary and continues at the beat boundary whose four beats on either side sound most alike. The intro and outro are kept, and the result lands within one beat of the target. Segments (0–100) sets the number of joints (1–3, more for long extensions). Variations (0–100) sets how far a joint may move from its evenly spaced position (±1–8 beats). The state is a hidden effect instance with no definition, so panels, the effect chain and Paste Attributes ignore it. The plan is stored in ticks, so playback and export read it without re-analysing. A remix never moves other clips: one that would overlap the next clip is refused, and Revert restores the original duration. Results are deterministic and the analysis is cached per media range.

5.2 Essential Sound

clip.essential (type + settings)  ──apply()──►  clip effects marked `essential`, clip gain, Volume, Panner
essentialSound.autoMatch   BS.1770 integrated loudness of render::audio::clip_signal (mono clips as one channel) → match gain (clip gain)
essentialSound.generateDucking   trigger clips' summed level (10 ms hops) → activity regions → Volume keyframes

5.3 Masks

Every video effect and the intrinsic Opacity carry EffectInstance::masks (project::mask). A mask is a closed cubic Bézier MaskPath in clip pixels (ellipse = four smooth vertices with circular tangents, 4-point polygon = corner vertices, the pen draws arbitrary vertices), stored as a ParamValue::Path so the ordinary keyframe engine animates it (vertex-wise interpolation; paths with different vertex counts hold). Feather, Opacity and Expansion are ordinary float parameters.

5.4 Colour management

frame (Y'CbCr/RGB + metadata) ─► source colour space: Interpret Footage override, else VUI/colr/MKV Colour
  ─► decode table (sRGB/BT.709, PQ, HLG scene light, camera log) ─► HLG OOTF ─► 3×3 to working gamut
  ─► BT.2390 tone map (HDR/log into an SDR sequence, Auto Tone Map Media) ─► gamut compression
  ─► effects + compositing in working linear (1.0 = reference white = SDR white = 203 cd/m²)
  ─► monitors: working → SDR BT.709 (tone map from HDR, gamut map from BT.2020)
  ─► HDR export: working → PQ/HLG BT.2020 R'G'B' → Y'CbCr (BT.2020 NCL) + VUI/colr/mdcv/clli/SEI

5.4 Multi-camera and synchronisation

clips ──sync (in | out | timecode[±hours] | marker | audio)──► anchors (media time ↔ common instant)
  ├─ clip.synchronize        moves selected timeline clips (link groups together) onto the reference
  ├─ clip.mergeClips         video + ≤16 audio clips → a merged-clip sequence
  └─ clip.createMulticam     cameras → a multi-camera source sequence (one video track per angle)
multi-camera clip = nested source + TrackItem::multicam {enabled, angle}
  render: only the angle's video track · audio: camera 1 | all | the angle (Switch Audio)

6. Export jobs (kinema-export)

file.exportMedia {path, preset?, settings?, format?, range?, …}     export.quick {preset?, path?}
  → engine::export_tools: preset ⊕ settings JSON ⊕ flat overrides → ExportSettings; range resolved
  → a Job {id, label, progress, result} on a background thread (export.queue.* runs them in order)
  → export: per output frame render (output rate) → fit into the output frame → overlays → limiter
            → encode in order → mux; audio mixed at the output rate, folded to mono/stereo,
            loudness-normalized (measuring pass + gain + true-peak limiter) per batch
  → jobs.list shows progress; jobs.cancel sets the shared cancel flag
Format Encoder Container
h264 kinema-h264enc (profile, level, CBR / VBR 1-pass / VBR 2-pass, keyframe distance) + kinema-aac MP4 or QuickTime (isobmff, the Multiplexer setting)
prores kinema-prores (Proxy / LT / 422 / HQ) MOV
dnxhr kinema-dnx (LB / SQ / HQ / HQX) MOV (AVdh)
mjpeg built in MOV
aac kinema-aac (AAC-LC, the audio settings' bitrate) audio only: MPEG-4 audio .m4a (isobmff, priming in an edit list), or raw ADTS when the path ends in .aac
mxf-op1a DNxHR (default), ProRes or H.264 (Annex B, long GOP), mxfVideoCodec; PCM 16/24/32-bit integer MXF OP1a (kinema-mxf writer: frame-wrapped, index with temporal offsets, start timecode)
mxf-opatom as mxf-op1a Avid-style MXF OP-Atom: clip-wrapped picture at path, one mono PCM file per channel (<stem>_A1.mxf …, Report::extra_files)
png, tiff, bmp, jpg image numbered stills <name>000.<ext>, <name>001.<ext> …
tga, dpx, exr built in (raster.rs: uncompressed Targa 24/32-bit; DPX 10-bit RGB, SMPTE ST 268; OpenEXR half-float linear RGB(A), uncompressed scan lines) numbered stills, as above
gif, wav, aiff built in / image GIF / RIFF WAVE (WAVE_FORMAT_EXTENSIBLE for 5.1; WAVE_FORMAT_IEEE_FLOAT + fact for float) / AIFF (16-, 24- or 32-bit PCM; 32-bit float as AIFF-C fl32)

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, OTIO, AAF or OMF. file.import detects these formats and merges the result into the project as one undoable step (AAF / OMF embedded audio is written next to the document first). file.import {replace: true} starts from an empty project with the same name (file.newProject) so a headless import→export round-trip contains only the imported timeline. file.exportInterchange, file.exportEdl, file.exportFcpxml, file.exportOtio, file.exportAaf and file.exportOmf write them. file.exportInterchange {format} takes edl, xml (the default), fcpxml, otio, aaf or omf; any other value is a parameter error and nothing is written. For AAF and OMF the engine (engine::aaf_omf) first prepares the audio the document references: it lists the used ranges (interchange::essence::audio_needs), decodes or renders them (clip effects through the export audio pipeline), embeds them or writes WAV / AIFF files, and optionally renders a video mixdown. A nested sequence is exported the way Premiere Pro does it: a composition of its own in AAF (the media of the clips inside it is prepared with the rest), its sound mixed into the document in OMF, a nested sequence in FCP7 XML, FCPXML and OTIO, and one AX event in an EDL (table in the interchange README).

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 kinema-cli exec <id> key=value…, run script.jsonl (one {"id","params"} per line), inspect, import, export, render, probe; --save, --bridge
Control channel kinema --control <port> JSON lines on loopback TCP: engine commands plus synthetic input, inspection and screenshots of the live UI
MCP kinema-cli mcp stdio MCP server: headless in-process session, or --bridge to the control channel

Protocol reference: control-protocol.md. Agent guide: agents.md.

8. Not built yet

The layer table reserves names for crates that don't exist yet: riff, mjpeg, keyframe, effects, audio and playback. (platform exists but holds only OS media FFI; other OS integration stays in apps/kinema.) Until they exist, that work lives elsewhere: keyframes and effect definitions in project, effects and the audio mix in render, playback in ui-egui, and OS integration (cpal, rfd, native menus) in apps/kinema. roadmap.md has the milestone status and gaps.md what is missing.

Revision history

Date Change Summary
2026-10-10 trivial Status line and revision history added (craftrules progress-docs standard); milestone link points at docs/roadmap.md