1## What this is
2
3Sequencer ("Clover Sequencer") is a native macOS app, not a video editor. It helps storyboard videos, sync multicam clips in time, and review multicam recordings. It's built to feed Blackmagic Fusion Studio: the workflow is arrange/trim media in Sequencer, then copy-paste the selected clips into Fusion as Loader nodes. It is not useful standalone from Fusion's perspective.
4
5## Build & run
6
7```sh
8swift build # compile (debug — for the CLI harnesses below)
9./run.sh # build RELEASE, kill running instance, copy into Sequencer.app, relaunch
10./run.sh --debug # same but ship the debug build (crash-chasing only)
11```
12
13`run.sh` is the standard dev loop — it copies the built binary into `Sequencer.app/Contents/MacOS/Sequencer` and reopens the app bundle (needed for a proper app identity/menu bar, not just a bare executable). **It ships the `-c release` build by default**: timeline drawing is ~4× slower under `-Onone`, and shipping debug binaries is how the app spent months feeling slower than it was. Judge any perf perception against release.
14
15Launching the binary directly with `SEQ_DRAWPROF=1` in the environment (stderr to a file — NSLog is invisible under `open`) prints one `[drawprof]` line per second with draws/sec and mean/max ms per `TimelineView.draw` — the GUI-side counterpart to `--perftest`.
16
17There is no test target in Package.swift. Verification instead happens through two CLI-flag-driven harnesses baked into `main.swift`:
18
19```sh
20swift run Sequencer --selftest <mediafile> # headless pipeline check (see Selftest.swift)
21swift run Sequencer --uitest # offscreen TimelineView harness (see UITest.swift)
22swift run Sequencer --perftest <file.sq> # offscreen draw-timing harness (see PerfTest.swift)
23swift run Sequencer --cachetest <file.sq> [capGB] # cache-budget invariant check (see Cachetest.swift)
24```
25
26- `--selftest` exercises the real media pipeline against a file you pass in: ffprobe, filmstrip/waveform generation, chunk-proxy building, AVFoundation playability of the built proxy, and prints sample Fusion Lua output. Useful when touching `MediaPipeline.swift` or `ChunkedProxy.swift`.
27- `--uitest` hosts the real `TimelineView` in an offscreen window and drives it with synthetic `NSEvent`s (move, trim, slip, stretch, box select, split, links, storyboard split, comp parsing, fades, plus file-format migration/round-trip — ~100 assertions, PASS/FAIL printer). Useful when touching timeline gesture code.
28- `--perftest` loads a real `.sq` into an offscreen `TimelineView` and times `draw(_:)` at several zoom levels, printing **median and p90** ms/frame (medians resist the I/O spikes async thumbnail loads inject) plus a per-section `DrawProf` breakdown and tile blit/render counts. Each zoom is measured **direct vs tiled, in the same process** (thermal drift between runs otherwise swamps real deltas — this machine varies 5× with battery/heat), under two pan patterns: `scroll` (8 px/frame, a real gesture) and `jump` (half-project teleports, cache-hostile). Use it when touching timeline **drawing**. Lessons baked into `TimelineView`: never build/tint an `NSImage(systemSymbolName:)` per clip per frame — bake once via `bakedSymbol(...)`; never draw geometry millions of points wide (clamp to the cull window; CG "clips" it but pays anyway); and detail is a function of **on-screen clip width** (`lodMinWidth`), never of whether the user is scrolling.
29- `--cachetest` loads a real `.sq` (headless), jumps the playhead across the timeline, and continuously asserts the cache-budget invariant (`ledger ≤ cap`, disk ≤ cap + slack) while demand builds and evictions run — plus coverage of the chunk under the playhead at each stop. Use it when touching the budget/eviction logic in `MediaPipeline.swift` or `ChunkedProxy.swift`. Run it against a project whose full proxy set exceeds the cap (pass a small `capGB`) — that's the regime it exists to test. Don't run it while the GUI app is open (two processes would fight over one cache).
30
31Cache/proxy debugging levers: `SEQ_BUILDLOG=1` logs every chunk-build START and admission failure (`[cache] start/admission blocked` — success completions are otherwise silent, so this is how you chase "why isn't chunk X building"). The viewer logs every "Loading Media…" spinner occurrence as a `[miss]` line with a cause diagnosis from `ChunkManager.missDiagnosis` (chunk missing/building/queued-where, composition-lag, player-late, budget-starved) and, on recovery, its duration. **All `[miss]` lines and important `[cache]` events (rescues, build/stitch failures, admission blocks, orphan reaping) also append to `~/Library/Logs/Sequencer.log`** (`SeqLog`, 5 MB rotation) — no Console attach or special launch needed; a user-reported spinner sighting is answerable from that file. `sequencer --layertest <cacheKey:origPath> […]` opens bare AVPlayerLayer tiles playing the real stitched composition(s) (`SEQ_LAYERTEST_T=<sec>` sets the seek) — the isolation tool that cracked the black-viewer bug.
32
33Two hard-won playback failure modes (2026-07-11): (1) **an `AVAssetTrack` does not retain its `AVURLAsset`** — stitching a track whose asset has been deallocated fails with -11800/-12780, and under `try?` that silently left a black GAP in the composition for a chunk that was healthy on disk (`LoadedPart.asset` exists precisely to prevent this; `comp insert FAILED` in the log means it's back). (2) **Orphaned ffmpeg encoders** (app killed mid-build — run.sh pkills on every rebuild) hold VideoToolbox decode sessions from a machine-wide pool; a dozen accumulated orphans make every AVPlayer render black with zero errors anywhere (items ready, seeks land, `isReadyForDisplay` true). Defenses: `MediaPipeline.reapOrphans` kills stale ffmpegs referencing our cache at launch, `terminateChildren` runs on quit and SIGTERM/SIGINT, and run.sh sweeps strays.
34
35Bundle id: `net.paperclover.Sequencer` (defaults domain too; renamed from com.clover.Sequencer 2026-07-12). Document UTI: `net.paperclover.sequencer.project` — keep `ProjectDocumentController.projectType` and build.sh's Info.plist in sync.
36
37`ProjectModel.laneRefs` (and `hasStoryboard`) scan every clip — O(clips). Never call them per-clip in a draw or per-frame path: `laneRect→laneRefs` per drawn clip was quadratic and pegged a core at 98% on big projects. `TimelineView` uses its `laneRows` cache (invalidated in `redraw()`/`ctx.didSet`); route new hot paths through that.
38
39Two hard-won main-thread rules (2026-07-12): (1) **`URL(fileURLWithPath:)` without `isDirectory:` stats the path** — `MediaItem.url`/`displayName` did this per clip label per timeline draw against NAS paths, freezing the UI whenever the volume was cold (292/337 draw samples in `stat()`). Always pass `isDirectory:` for known-file paths; `displayName` uses `(path as NSString).lastPathComponent` (no I/O). (2) **Nothing between `thread_suspend` and `thread_resume` may allocate or lock** — the hang sampler's `Array.append` needed the malloc lock the suspended main thread held → permanent app freeze (the "unusably laggy on startup" report). `sampleMainStack` uses preallocated buffers; keep it allocation-free. Related: the SIGTERM/SIGINT DispatchSources live on a background queue, not main — a wedged main thread must not make the app unkillable.
40
41Microhangs/stutter: `HangMonitor` (a watchdog thread pinging the main queue every 20 ms) logs every main-thread stall >100 ms as a `[hang]` line in `~/Library/Logs/Sequencer.log` — duration, whether playback was running (`during playback (rate …)`), and a **symbolicated stack of the main thread sampled mid-stall** (mach `thread_suspend` + arm64 frame-pointer walk + `swift_demangle`), so the culprit is named without Instruments. `SEQ_NOHANGWATCH=1` disables it; `SEQ_HANGTEST=1` injects a deliberate 0.4 s spin ~2 s after launch to verify the pipeline end-to-end. User-reported stutter during long sessions is answerable from that file.
42
43RAM: the `maxRAMGB` default (Settings → Global, default 2) budgets `FrameCache` — decoded exact stand-in frames for cut boundaries and not-yet-presentable moments (warmed ahead by `VideoTrackPlayer.warmBoundaryFrames`, on demand by the viewer; `SEQ_NOWARM=1` disables decodes) — and scales the video players' forward buffer (`ramGB/2` seconds, capped at 8).
44
45Requires `ffmpeg`/`ffprobe` on PATH for anything touching media (probing, filmstrips, proxies).
46
47## Architecture
48
49**AppKit only — no SwiftUI, no Combine.** Views are `NSView` subclasses that redraw imperatively: state changes post to `NotificationCenter`, views observe and set `needsDisplay = true`, AppKit calls `draw(_:)` on the next pass. There is no data-binding layer to reach for; if you're adding reactive UI, follow this same notify-and-redraw pattern.
50
51### Data model & mutation (`Model.swift`, `Store.swift`)
52
53`ProjectModel` is a plain value type (`Codable`, `Equatable`): media items, tracks, clips, markers, storyboard boards. All custom `Codable` inits decode tolerantly (`decodeIfPresent` with defaults) so older saved projects keep opening — preserve this pattern when adding fields.
54
55**Tracks are numbered, not identified.** A `Track` is *just a hue*; its index in `ProjectModel.tracks` is its number (displayed as index+1). A clip references its lane via `Clip.track: TrackRef` — `.video(Int)`, `.storyboard`, or `.fusion`. The storyboard lane isn't stored in `tracks` (it's implied by the presence of `.storyboard` clips, `hasStoryboard`); the Fusion band holds no clips. `laneRefs` yields the display order (storyboard on top, then video tracks). Deleting a video track (`removeTrack(at:)`) **renumbers** the clips on higher lanes down — the work UUIDs used to make free.
56
57**The `.sq` file is a document package** (a bundle Finder shows as one file): `project.json` (a `SequencerDocument` envelope `{ formatVersion, project, view }`) plus a `Storyboard/NN.png` per storyboard panel. `ProjectDocument` (`Document.swift`, an `NSDocument`) reads/writes it via `read(from url:)` / `fileWrapper(ofType:)`. Legacy **flat** `.sq` JSON files still open (`read` detects file-vs-directory and pulls rasters from a sibling `Storyboard/` folder); the first save rewrites them as a package. Legacy bare-`ProjectModel` files (v1, UUID-keyed tracks) are also detected and migrated on read — keep both paths working. Media `cacheKey`s are self-healed on load and sanitized at the cache-path boundary (`MediaPipeline.isValidCacheKey`/`normalizedCacheKey`) so a blank or malformed key can never collide or escape the cache root.
58
59**This is a multi-document app** (see "Per-document architecture" below): every per-project service is an instance on a `DocumentContext`, not a global singleton. `Store` (one per open document, reached as `ctx.store`) owns the model, selection, and undo/redo; persistence and autosave belong to the owning `NSDocument` (`Store.changed()` bridges dirty state via `ctx.document?.updateChangeCount`). Undo is a snapshot stack of the whole (small) model — no diffing. Three mutation modes matter:
60- `mutate { }` — one discrete undoable edit.
61- `beginGesture()` / `updateGesture { }` / `endGesture()` — continuous drags collapse into a single undo step; `updateGesture` recomputes from the gesture-start snapshot each call so there's no drift.
62- `preview { }` / `commitPreview(from:)` — live non-undoable edits (e.g. the color picker) that commit as one step when done.
63
64Every mutation runs `normalizeStoryboards()` before committing — storyboard panels are "start-only" (duration is implicit, derived from the next panel's start), so this keeps stored durations consistent. Notifications posted after mutation: `.projectChanged`, `.selectionChanged`, `.documentStateChanged`, `.mediaStatusChanged`, `.viewerNeedsRefresh`.
65
66Non-undoable session/UI state (track hide/focus, pane heights, laneScale, snapping, current tool, draw color) lives on the per-window `SessionState` (`ctx.session`), outside `Store` — do not put it in `ProjectModel` (undo must never toggle visibility). `UI` (in `AppDelegate.swift`) now holds only constants (SF Symbols, extension sets). The *portable* subset (hide/focus/heights keyed by `TrackRef`, zoom, snapping, previews-on-left, priority pane, Fusion band) is serialized into the `.sq` envelope's `view` block via `SessionState.captureViewState()` / `apply(_:)` — so a project reopens looking the way it was left, without entering the undo model.
67
68### Per-document architecture (`DocumentContext.swift`, `Document.swift`, `WindowController.swift`)
69
70Each open project is a `ProjectDocument: NSDocument` owning a `DocumentContext` — the per-document service bag. Everything per-project is an instance on it: `ctx.store`, `ctx.playback`, `ctx.players`, `ctx.chunks`, `ctx.comps`, `ctx.boards`, `ctx.session`. Each service holds an `unowned var ctx` back-reference, so service-to-service calls go through `ctx.*`. **Because `ctx` is `unowned`, any service that lands an async callback (off-main chunk builds, the 60 Hz clock) must stop touching `ctx` once the document is closing** — `ProjectDocument.close()` calls `DocumentContext.shutdown()`, which flips `ChunkManager.stop()` / `PlaybackController.stop()` (both guard their ctx-touching entry points) *before* the context deallocs, so a build finishing after close can't trap on a dangling reference. Add the same guard to any new service that schedules deferred work. Views reach their state through a stored `var ctx` injected at construction (`SequencerWindowController` sets `timeline/viewer/transport`'s `ctx`; the grid injects its cells; the storyboard editor is re-targeted per `open(clipId:ctx:)`). **Do not reach for a global `.shared` for per-project state** — only `MediaPipeline` (content-addressed media cache) and `Theme` are genuinely global. `DocumentContext.current` resolves the front document's context for app-level actions (Settings/Export/cache eviction); `DocumentContext.headless` backs the `--uitest`/`--selftest` harnesses.
71
72`SequencerWindowController` (one per window) owns the split layout, previews pop-out, and every per-document menu action (`@objc func`s targeting the first responder). `AppDelegate` is now slim: it builds the menu bar and handles app-level actions only (New/Open route to `NSDocumentController`; Save/Save As/Close to `NSDocument`).
73
74**Notifications:** the 60 Hz `.playheadChanged` runs on a per-document bus (`ctx.notify`) so a playing window only redraws itself. Every other notification is posted app-wide on `.default` — deliberately: each handler reads its own `ctx`, so a broadcast is correct (never cross-contaminates state) and the extra redraw is cheap. When adding a hot-path (high-frequency) signal, put it on `ctx.notify` and bind the observer against the injected `ctx` (see the `ctx.didSet` re-bind in the three main views); everything else can stay on `.default`. `MediaPipeline` (global) protects and evicts across **all** open documents via `DocumentContext.allLive`.
75
76### Media & proxy playback pipeline
77
78This is the most complex subsystem — a three-stage pipeline. `ChunkManager` and `PlaybackController`/`PlayerManager` are per-document (`ctx.chunks`, `ctx.playback`, `ctx.players`); `MediaPipeline` is the one global (a shared content-addressed cache):
79
801. **`MediaPipeline`** (`MediaPipeline.swift`) — wraps ffprobe/ffmpeg. Probes media, generates filmstrips (thumbnail strips) and waveforms async, LRU content-addressed cache (default 50GB). Cache key = `SHA256(path|size|mtime)`, so moved/remounted files with identical content still hit cache.
812. **`ChunkManager`** (`ChunkedProxy.swift`) — demand-driven **30-second ProRes Proxy chunks** instead of whole-file transcodes. **Build order is the whole game** (`updateDemand` → `nextJob` → `pump`): every playback tick recomputes a best-first `demand` list from the playhead, playback direction, and visibility — for each video clip in a look-ahead window it scores the proxy chunks its source range needs (coverage before sharpening, visible/focused tracks before hidden, nearer the playhead in the playback direction before farther) so the frame you're about to see is always built first. Chunks outside the window fall to the whole-project background fill (`ensure`). Adaptive quality (4 resolution/fps tiers) steps down/up based on measured encode wall-time vs. realtime ratio and distinguishes network vs. encode bottlenecks; **imminent** coverage (uncovered, just ahead, visible) builds at that adaptive realtime quality so it lands in time, everything else at the full preview-quality target. **Rescue slices**: when the playhead lands INSIDE an uncovered chunk, a short (~10 s) slice starting at the playhead builds first (`rNNNNNN.mov`, one quality rung lower) — read-bound NAS sources scale with encoded seconds, so this lands in a few seconds where quality drops can't help — then the full chunk builds right behind it and deletes the slice. Slice state (`MediaState.partial`) is session-only; stale slice files are purged by the launch reconcile. In queries: `isCovered` is true inside the slice's range, `builtWidths` reports a slice as width 1 (so the full build reads as a strict upgrade and the player adopts it), demand scoring still counts the chunk uncovered (so the full build stays queued). Trim/slip/ripple drags call `noteGestureExposure` mid-gesture so newly exposed source ranges build before mouse-up; committed edits warm the *edited edge* (`Store.noteEditLocus`). Composes ready chunks + rescue slices + original-file fallback into a **per-media** `AVComposition` (`composition(for:)`, chunk assets loaded concurrently), versioned so playback only swaps on a strict upgrade. Per-media (not per-track) is deliberate: only the clip under the playhead needs building, so the current frame is ready fast — a whole-track composition would have to assemble the entire timeline before it could show anything, which is far too slow for a big multicut project.
823. **`PlaybackController`** + `PlayerManager` (`PlaybackController.swift`) — master clock anchored to `CACurrentMediaTime()`; all players chase this one authoritative time at 60Hz. Supports reverse and J/K/L shuttle speeds (±1/2/4/8/16/32/64). Each video track is a **`VideoTrackPlayer`: two `AVPlayer`s (front/back) for gapless cuts.** The front shows the clip under the playhead (`currentTime` == source time, chasing at `rate × clip.speed`); as a cut to a *different media* approaches (~1.5 s out) the back is prerolled to the next clip's first frame — loaded, decoded, parked, muted — and at the cut the roles flip. The viewer's cell holds two `AVPlayerLayer`s (one bound to each sub-player, stable) and just toggles which is visible, so the flip is a layer swap with no `replaceCurrentItem` black-frame gap. Same-media continuations don't buffer (the front keeps its item and seeks). `tick()` runs `sync()` (which does the flip) *before* posting `.playheadChanged`, so the viewer reflects the flip on the same frame. Standalone `.audio` clips get one `AVPlayer` per clip (looser sync tolerance since originals live on NAS); a clip's player is **prerolled ~1 s before its cut and kept running (silent — its fade gain is 0 until reached)**, so crossing an audio cut is a seamless volume handover rather than a cold start — see `audioLookahead`/`audioLinger` in `syncAudio`. Item swaps hold the fresh item silent (`rate = 0`) until the immediately-following `syncTime` seeks it to the live position, so a swap never blips wrong content from t=0. `.projectChanged` forces a hard resync only while **paused** — during playback the 60 Hz tick already tracks, so edits away from the playhead don't stutter. Seeks coalesce while one is in-flight.
83
84When working on playback bugs, the mental model is: `MediaPipeline` produces cached derived assets → `ChunkManager` decides what to build (playhead/visibility-ordered) and assembles per-media compositions → `PlaybackController` drives the players (a double-buffered pair per video track, one per audio clip) against those compositions on a shared clock. Two levers make cuts gapless: the **build order** gets the next clip's proxy ready ahead of time, and the **double buffer** prerolls it and flips without an item swap.
85
86### Fusion integration (`FusionExport.swift`, `FusionComps.swift`)
87
88- **Export direction (Sequencer → Fusion):** `FusionExport.copySelectedClips()` generates a Lua table describing one Loader node per clip (original media path — never the proxy — with source in/out trims and timeline position), pasted directly into Fusion Studio's Flow view.
89- **Import direction (Fusion → Sequencer):** `FusionComps` scans a comps folder for `.comp` files, parsing the filename prefix for frame range/title (e.g. `0200-0681_intro.comp`) and parsing the `.comp` Lua text for Saver nodes to find the rendered image sequence. These render as a band in the timeline/viewer, like an extra track, with the same hide/focus (F/H) controls as regular tracks.
90
91### Storyboard (`Storyboard.swift`, `StoryboardEditor.swift`)
92
93Each `Board` has two layers: a shape (vector) layer stored in the model (`BoardShape` — rect/oval/triangle/star/n-gon/text/image-ref) and a raster (drawing) layer stored as a PNG on disk keyed by board ID. `revision`/version counters invalidate the composite cache independent of model-mutation equality (raster pixels live in `BoardStore`, out of the value-type model). **Drawing IS undoable and shares ONE timeline with model edits:** `BoardStore.beginStroke`/`endStroke` capture the pre-stroke image and register it via `Store.recordRasterEdit`, so the `Store` undo stack holds both `.model(Snapshot)` and `.raster(boardId, image)` entries in the order they happened — ⌘Z/⌘⇧Z step through drawing and model edits together, from any window, and drawing redoes like everything else (undo/redo of a raster entry swaps the current drawing for the stored one via `BoardStore.rasterSnapshot`/`applyRaster`). Panels are "start-only" in the model — see `normalizeStoryboards()` above.
94
95### Views
96
97- `TimelineView.swift` — largest file (~3500 lines); all editing gestures (move/trim/slip/stretch/box-select/split/blade) live here, each gesture wrapped in one `Store` undo step.
98
99 **Timeline rendering** is built around one rule: frame cost is proportional to what *changed*, not what's visible, and the look never degrades — a 4,740-clip project draws pixel-identically to a 10-clip one. Three mechanisms:
100 1. **Pixel-width LOD** (`drawClip`, `lodMinWidth`): a clip narrower than ~3 px physically can't show corners/labels/filmstrips, so it draws as flat rects (body + strip + audio center line + fade-handle slivers) — no bezier/clip-state chrome. This replaced the old `lightScroll` shed-detail-mid-scroll mode, which visibly dropped thumbnails yet saved almost nothing.
101 2. **Scene tile cache** (`blitLaneTiles`/`tileImage`): lane clip content rasterizes into per-(lane × 512 pt slice) `CGImage` tiles in *timeline space* (origin-independent), rendered by the same `drawLaneClips` code as the direct path — pixel-equivalent by construction. Pan/playback frames are blits + chrome (~15 ms at fit-all on a throttled M4; was ~500 ms). Pin-to-view-edge labels are an overlay (`drawPinnedLabels`), never baked. Origin/scrollY/lane heights are quantized to the device-pixel grid so blits never resample. Tiles are BGRA in the window's colorspace (an `NSBitmapImageRep` source costs a per-blit swizzle+conversion). Renders are budgeted (`tileRendersPerFrame`) with pixel-identical direct fallback + a scheduled fill pass; big origin jumps render zero tiles that frame. Edit gestures bypass tiles entirely. `SEQ_NOTILES=1` forces the direct path.
102 3. **Targeted invalidation**: `.projectChanged`/`.viewOptionsChanged` → scene regroup + tile flush; `.selectionChanged` → link-mate cache + tile flush only; `.mediaStatusChanged` flushes tiles **only when posted with `userInfo["scene"]`** (filmstrip/waveform/board raster landed — `MediaPipeline`/`BoardStore` posts carry it) — proxy-chunk churn during playback repaints without re-rendering. Keep that convention when adding posts.
103 4. **Parallel cold frames** (`drawLanesDirectParallel`): zoom-in-flight, teleport jumps, and live edit gestures can't use tiles, so those frames rasterize every lane concurrently into persistent per-lane buffers (same `drawLaneClips` code, composited on main; the storyboard lane stays on main — `BoardStore` isn't locked). Anything `drawLaneClips` touches must therefore be **thread-safe under concurrent reads**: the scene caches are read-only during a draw; `MediaPipeline`'s thumb/waveform/strip-info bookkeeping, `bakedSymbol`, and the CTLine title cache are lock-guarded; `DrawProf` only records on the main thread. `NSGraphicsContext.current` is thread-local, but `concurrentPerform` runs one iteration on the calling thread — save/restore it, never nil it.
104
105 Image assets that the timeline blits per frame (filmstrip thumbs, waveforms) are decoded once into the display's raster format via `MediaPipeline.displayImage` (BGRA premultiplied, screen colorspace) and returned as `CGImage` — a file-format NSImage costs a per-draw swizzle + colorspace conversion. Misses are negatively cached (`thumbMissing`/`waveformMissing`) so a dense timeline doesn't re-dispatch hundreds of no-op loads per frame; the miss sets clear when fresh strips/waveforms land.
106
107 Model-side lookups that draw or hit-test against clips go through the scene caches (`clipsByLane` sorted per lane + binary-searched `visibleIndexRange`, `overlapsByLane`, `overlapIdsByLane`, `cachedTimelineDuration`) — never `project.clips.filter(...)` per frame/event; `Model.overlaps()` relies on its sorted early-`break` to stay near-linear.
108- `ViewerGridView.swift` — multicam grid, one cell per visible track plus a Fusion comps cell.
109- `TransportBar.swift`, `ExportDialog.swift`, `ColorPicker.swift`, `Theme.swift` (light/dark, follows system appearance, no manual toggle), `Tools.swift` (tool enum + radial quick-picker).
110
111### Cross-cutting conventions
112
113- Per-project services (`Store`, `ChunkManager`, `PlaybackController`, `PlayerManager`, `FusionComps`, `BoardStore`, `SessionState`) are **instances on `DocumentContext`**, reached as `ctx.*` — one set per open document. Only `MediaPipeline` and `Theme` are global (`.shared` / static enum). Reach per-project state through the view's injected `ctx`, or `DocumentContext.current` at an app-level entry point — do not add a new global singleton.
114- All I/O (ffmpeg/ffprobe, directory scans, chunk building, comp scanning) runs off-main and lands back via `DispatchQueue.main.async`; don't block the main thread with media work.
115- Time units: everywhere in the model is **seconds (Double)**; frame numbers only appear at the Fusion export boundary and timecode display, converted via the project's `fps`.