From f631fbc1e6ebc0bebf8434d37a2db896bfee8ccc Mon Sep 17 00:00:00 2001 From: Okonai <6979365+Okonai@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:47:56 +0200 Subject: [PATCH] docs: reference split into sections with a table of contents docs/reference.md was one bullet list with bullets of thousands of characters. Each topic now has its own heading, under a table of contents; long bullets are short paragraphs, and a sentence that is a long run of clauses is a list of them. A table lists every nocturne-ctl command the reference documents by its section. The text is the same, word for word, less the internal ticket numbers, which are gone from docs/ too, and the preset header's old name (Built-in Classic is now Built-in). Closes #8 Signed-off-by: Okonai <6979365+Okonai@users.noreply.github.com> --- docs/adr/0012-agpl-licence.md | 16 +- docs/licence-notes.md | 8 +- docs/reference.md | 572 +++++++++++++++++++++++++++++++--- 3 files changed, 538 insertions(+), 58 deletions(-) diff --git a/docs/adr/0012-agpl-licence.md b/docs/adr/0012-agpl-licence.md index fb70d32..9edade1 100644 --- a/docs/adr/0012-agpl-licence.md +++ b/docs/adr/0012-agpl-licence.md @@ -23,15 +23,15 @@ ever make it closed; running it as a network service counts as distribution, so | metal-cpp | third_party/metal-cpp | Apache-2.0 | yes | | nlohmann/json (also our MessagePack encoding) | third_party/nlohmann | MIT | yes | | doctest | third_party/doctest, tests only | MIT | yes | -| Tracy v0.14.1 | optional profiler build (`-DNOCTURNE_TRACY=ON`, ticket 92), FetchContent; not in the default build | BSD-3-Clause | yes | -| glslang vulkan-sdk-1.4.363.0 | engine, FetchContent (ticket 95): ISF GLSL to SPIR-V | BSD-3 / BSD-2 / MIT / Apache-2.0 (GPL-3 with the Bison exception covers only Bison output) | yes | -| SPIRV-Cross vulkan-sdk-1.4.363.0 | engine, FetchContent (ticket 95): SPIR-V to MSL | Apache-2.0 | yes | -| Curated ISF shaders (ISF-Files, gl-transitions) | third_party/isf, built into the program (ticket 95) | MIT, per shader in `docs/licence-notes.md` | yes | -| AMD FidelityFX Super Resolution 1 (v1.20210629, EASU and RCAS) | shaders ported to Metal in src/engine/fsr_upscaler.cpp (ticket 93), AMD's notice kept in that file | MIT | yes | +| Tracy v0.14.1 | optional profiler build (`-DNOCTURNE_TRACY=ON`), FetchContent; not in the default build | BSD-3-Clause | yes | +| glslang vulkan-sdk-1.4.363.0 | engine, FetchContent: ISF GLSL to SPIR-V | BSD-3 / BSD-2 / MIT / Apache-2.0 (GPL-3 with the Bison exception covers only Bison output) | yes | +| SPIRV-Cross vulkan-sdk-1.4.363.0 | engine, FetchContent: SPIR-V to MSL | Apache-2.0 | yes | +| Curated ISF shaders (ISF-Files, gl-transitions) | third_party/isf, built into the program | MIT, per shader in `docs/licence-notes.md` | yes | +| AMD FidelityFX Super Resolution 1 (v1.20210629, EASU and RCAS) | shaders ported to Metal in src/engine/fsr_upscaler.cpp, AMD's notice kept in that file | MIT | yes | | Apple frameworks (Metal, AVFoundation, VideoToolbox, CoreAudio, ...) | system | system libraries | yes (system library exception) | -| stb_dxt.h v1.12 | third_party/stb, the HAP encoder's BC block compressor (ticket 94) | MIT / public domain (dual) | yes | -| HAP frame format (Vidvox) and snappy format (Google) | re-implemented from their specifications in src/media/hap.cpp and src/media/snappy.cpp (ticket 94); no code copied | BSD-2-Clause, BSD-3-Clause (the references) | yes | -| ffmpeg, ffprobe (Homebrew, with libx264) | tools/transcode-media.sh and nocturne-upload (ticket 84) run them as separate programs; nothing links them | GPL-2.0-or-later build | yes (not linked; GPL-2.0+ allows GPL-3.0 anyway) | +| stb_dxt.h v1.12 | third_party/stb, the HAP encoder's BC block compressor | MIT / public domain (dual) | yes | +| HAP frame format (Vidvox) and snappy format (Google) | re-implemented from their specifications in src/media/hap.cpp and src/media/snappy.cpp; no code copied | BSD-2-Clause, BSD-3-Clause (the references) | yes | +| ffmpeg, ffprobe (Homebrew, with libx264) | tools/transcode-media.sh and nocturne-upload run them as separate programs; nothing links them | GPL-2.0-or-later build | yes (not linked; GPL-2.0+ allows GPL-3.0 anyway) | No incompatible dependency was found. diff --git a/docs/licence-notes.md b/docs/licence-notes.md index 1fc697e..60e36c1 100644 --- a/docs/licence-notes.md +++ b/docs/licence-notes.md @@ -12,7 +12,7 @@ shaders, copied snippets), check its licence here and add a row to ADR 0012's ta - **Commits** carry `Signed-off-by:` (DCO); no CLA. - **Unsure:** stop and ask the maintainer before adding it. -## ISF import (ticket 95), checked 2026-10-18 +## ISF import, checked 2026-10-18 | Component | Version | Licence | Use | Compatible | |---|---|---|---|---| @@ -64,14 +64,14 @@ Every other shader in ISF-Files 395072d, and why it is not vendored. The first g - gl-transitions, BSD with its author: licence fine, not picked (three transitions were enough; many sample away from their pixel) (2): Inverted Page Curl, Stereo Viewer. - credited to Vidvox: left out for what the importer cannot run yet (multi-pass, persistent buffers, custom vertex shaders, audio, extra image inputs) or as not needed; each gets its own header check before it is added (134): Apply Alpha, Auto Color Tone, Auto Colors Histogram, Bad TV, Basic Shape, Bloom, Bordered Box, Broken LCD, Checkerboard, Chroma Desaturation Mask, Chroma Mask, Circle Splash Distortion, Circle Warp, Circle Wrap Distortion, Circular Feedback Mask, Circular Screen, City Lights, Collage, Color Bars, Color Blowout, Color History, Color Levels, Color Relookup, Color Replacement, Color Schemes, Color Test Grid, ConstantAlpha, Corner Color Tint, Corner Colors, Cursor Overlay, Cursor, Diagonal Blur, Diagonalize, Dilate-Fast, Dilate, Doodler Overlay, Doodler, Dot Screen (needs its own vertex shader), Double Vision, Duotone From Histogram, Echo Trace, Edge Blowout, Edge Distort, Edge Trace, Edges, Emboss, Erode-Fast, Erode, Etch-a-Sketch, FFT Color Lines, FFT Filled Waveform, FFT Spectrogram, FastMosh, Flip H, Flip V, Flipbook, Freeze Frame, Ghosting, Glitch Shifter, Gloom, Glow-Fast, Glow, Grid Warp, Hatch Blur, Highlighter Overlay, Histogram Viewer, Hyperspace, JPEG Block Corruption, LGG, Life, Line Screen, Lines, Long Exposure, Micro Buffer RGB, Micro Buffer, Mirror Edge, Motion Heat Map, Motion Mask, Multi-Pixellate, MultiFrame 2x2, MultiFrame 3x3, Night Vision, Noise Adapt, Noise Displace, Noise Pixellate, Noise, Pattern Glitch, Pixel Shifter, Poly Glitch, RGB Invert, RGB Strobe, RGBA Swap, Random Checkerboard, Random Freeze, Random Lines, Random Shape Blast, Random Shape, Random Squares Mask, Random Stripes, Resize Glitch, Ridgelines, Rotate, Set Alpha, Shake, Shape Mask, Sharpen Luminance, Sharpen RGB, Shockwave Pulse, Shockwave, Show Alpha, Simplex Noise, Sine Warp Tile, Sketch, Slit Scan Mask, Slit Scan, Soft Blur, Soft Flip, Sorting Smear, Sphere Map, Strobe, Time Glitch RGB, Trail Mask, Trapezoid Distortion, Triangle Warp, Triangles, Triple Rotate, Unsharp Mask, VU Meter, Vertex Manipulator, Vertical Tearing, Video Snake, XYZoom, Zoom. -### User ISF shaders (ticket 116) +### User ISF shaders The engine also reads ISF shaders from its machine's user folder (`/isf-user/`). Those files are the user's own: they are never vendored into this repository, never uploaded or synced by us (the media sync and the media upload do not see the folder), and only go to the engine's own clients so a UI can show their parameters. Their licence is the user's responsibility. -## Pro DJ Link references (tickets 89 and 112, checked 2026-10-18) +## Pro DJ Link references (checked 2026-10-18) | Project | Licence | Compatible | Used how | |---|---|---|---| @@ -82,7 +82,7 @@ user's responsibility. The DJ link receiver and the simulator (`tools/djlink-sim`) are written from the documented layouts; nothing from these projects is vendored, so ADR 0012's dependency tables do not change. -## HAP playback and transcode (ticket 94, checked 2026-10-18) +## HAP playback and transcode (checked 2026-10-18) | Project | Licence | Compatible | Used how | |---|---|---|---| diff --git a/docs/reference.md b/docs/reference.md index c64d92f..e057f9a 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -2,49 +2,529 @@ The engine, `nocturne-ctl`, the UI and the file formats, feature by feature. Start with the README for building. -- On the laptop nothing a test, bench or smoke run starts shows or focuses a window: engines run `--offscreen` (no window, no activation; outputs render into textures and present to nothing, stats still count), the UI `--hidden`. `--virtual` and `--windowed` windows open behind other windows and never activate. The Mini keeps real windows. -- Regenerate goldens after an intended picture change: `NOCTURNE_UPDATE_GOLDEN=1 build/nocturne_tests`, then review the PNGs in `tests/golden/`. -- `nocturne --help` lists the options; it prints render and present rates once per second and quits cleanly on SIGINT/SIGTERM. -- The engine serves its composition over WebSocket on port 7400 (`--port N`). `build/nocturne-ctl` is a debug client: `watch` prints the snapshot and every change, `opacity LAYER VALUE`, `undo` and `redo` edit, `--json` switches from MessagePack to JSON, `--host H` reaches another machine by name (`localhost`, `mini.local`, a Tailscale name) or address, IPv4 or IPv6; a name that does not resolve, or an engine that does not answer, fails naming the host and the addresses it resolved to. -- Pairing: every protocol client opens with `{"type": "hello"}` (with `"watch": false` it only asks questions and gets no document). A client on another machine adds `"token"`, the engine machine's pairing token: 64 hex digits the engine makes once and keeps in its state folder as `token` (`--state DIR`, default `~/Library/Application Support/Nocturne/State`), readable by its user alone. `nocturne-ctl token` on that machine prints it. Without the right token, or with anything but a hello first, the engine answers `{"type": "error"}` and closes the connection. Clients from a loopback address (127.0.0.1, ::1: the local UI, `nocturne-ctl` on the same machine, an ssh tunnel) need none. A handshake with an `Origin` header (a web page's) is refused; a client from another machine that has not paired within 10 s is closed, and at most 32 may wait at once, 4 from one address. Before pairing a message may be 64 KiB at most and is refused unread beyond that. A message over 16 MiB closes the connection; one nested more than 64 levels deep, or MessagePack with a string that is not UTF-8, is answered `undecodable message`. `nocturne-ctl` takes it as `--token T` or `NOCTURNE_TOKEN`, and reads the machine's own file when `--host` names this machine; the UI keeps one per host, typed into the venue menu beside the host; a node (`--follow`) reads `NOCTURNE_TOKEN`, and presents it only to the main engine it was told to follow: a bare `--node` sends it to a main engine on this machine alone, since any machine on the LAN can advertise itself as main. This narrows the token leak to a rogue main rather than closing it: the main is still known by an unauthenticated Bonjour or host name and the token goes over plain ws://, so on an untrusted network follow a host reached over Tailscale or an ssh tunnel. A paired client on another machine names files only inside the shows folder, `~/Nocturne/media`, `--media-root`, the Media Library folder, the recording folder and the state folder: a path outside them in composition `save`/`load`/`shows`, library `add`/`relink`, record `set`/`folders` or preset `import` (also with a `..` part, or through a symbolic link that leads out) is answered with an error before anything reads or writes it. See SECURITY.md, "Network trust". -- Media: `nocturne-ctl add PATH` adds a file to the engine machine's Media Library (an absolute path on that machine), `library` lists it, `play LAYER PATH` adds a file and puts it into the cell the layer plays (the shown deck's first column if it plays nothing) and starts it, `clip LAYER HASH NAME` and `clear LAYER` set or empty that cell. The library index lives in `~/Library/Application Support/Nocturne/MediaLibrary` (`--library DIR`). -- Tempo and transport: `bpm VALUE` sets the composition tempo (an undoable edit); `tap [COUNT SECONDS]`, `nudge PERCENT` (held until `nudge 0`) and `resync` are live beat clock actions outside the undo history; `clock` prints the beat, tempo and every clip's frame and beat count. Per clip: `sync LAYER timeline|bpm`, `playmode LAYER loop|bounce|reverse|randomJump|onceClear|onceHold`, `beats LAYER N|auto`, `jump LAYER INTERVAL DISTANCE [COLUMN]` (Random Jump every INTERVAL beats, 1/8..8, by up to DISTANCE beats either way, landing on the clip's beat grid). `scrub LAYER POSITION [TO SECONDS]` moves a clip to a point (0..1) or sweeps it there, live and outside the undo history. New clips are BPM-synced and pick the power of two beats that puts them between 80 and 160 BPM. -- Decoding: one decode thread per clip fills a frame cache. Playing forward it holds one second ahead, decoding beyond 4 frames ahead at 1.25x real time (a parked start beyond its first frame at 0.5x), so a column starting or the next one preloading never bursts the hardware decoder, which on the Mac mini delays output frames (a playhead with prefetch targets is not paced); playing backward it decodes whole GOPs in one pass and holds the playhead's GOP plus the one below (`--clip-cache MB` bounds that pair, default 2048; longer GOPs are split, at the cost of decoding again from the keyframe). Prefetched targets (the next Random Jump, a bounce's turn, the frame a resync lands on) get one second each. The playhead and the prefetched targets read with separate readers, and a seek reads frame by frame from the keyframe with the playhead's frames first, so reaching a target deep in a long GOP never holds up the playhead (decoded frames/s count the frames on the way too). At 1080p a forward or Random Jump layer holds about 0.2-0.6 GB, a reversing layer on a 5 s GOP file about 2 GB. `--seed N` fixes the Random Jump sequence. The stats line shows decoded frames/s, decode thread busy %, seeks, decode errors, stalls, the slowest read and cache size. Each read runs on a worker thread: one that takes over 500 ms (2 s for a new reader's first read; AVFoundation can block in it for good, seen on the Mac mini) is given up on and the frame is read again with a new reader, which the second held ahead covers; three in a row with no frame between flag the file corrupt. Closing a decoder never waits for a stuck read, and waits at most 500 ms for its decode thread. A decode error mid-stream skips to the next keyframe while the layer holds its last frame; three errors in a row flag the file corrupt. A file that changes or vanishes while it plays (noticed at its next read or seek) breaks its clip at once: the layer holds its last frame without counting misses, and the file is flagged corrupt, or missing when it is gone. Missing files (a removed drive) are looked for every 3 s and play again once back. Rotation metadata (phone videos) is honoured; variable frame rate files play by their timestamps, a gap showing the next frame. -- Start-up: the engine keeps every layer back, its outputs black, until each clip the composition plays or may play next has opened and decoded its start (the second a forward clip plays first) and every effect chain is compiled, at most 5 s; then all layers start together on their first frames. Started cold with many clips, the decoders otherwise share the hardware decoder with a dozen preloads in the first seconds and the playing layers miss frames. A resumed show (`--state`) skips the hold, so a restart stays a blink. The stats JSON shows `render.starting` while it holds. Triggers and composition loads after start-up play as usual. -- Effects: `nocturne-ctl fx LAYER TYPE [NAME=VALUE ...]` adds an effect (`transform`: positionX, positionY, scale, rotation; `colorize`: hue, saturation; `edgeDetection`: multiplier; `stripper`: number, direction, speed, spread, width, feather, invert, red, green, blue, alpha; `trails`: feedback; `slicer`: count, direction (Vertical, Horizontal, Grid, Radial, Boxes, Voronoi), animation, rate (Off, 1/4 to 16 beats), spread, seed, lines, hue, colour, an On/Off switch per style (none: the picture as it is, off by default; thermal, grain, letters, invert, dots, edges, duotone, outline, solid), movement, speed, size, corners, links; `thermal`: hue, colour; `grain`; `letters`: rows; `dots`: rows, hue, tint; `duotone`: hue; `outline`: hue; every effect also has `mix`, its dry/wet) and prints its id; `param EFFECT NAME VALUE`, `fxmove EFFECT INDEX` and `fxrm EFFECT` edit the chain; `animate EFFECT NAME FROM TO SECONDS` sweeps a parameter at 60 Hz. Effects live on clips, layers, groups and the composition: `clipfx LAYER COLUMN TYPE [NAME=VALUE ...]` adds one to the clip in that cell, LAYER 0 is the composition. Effect ids are unique across the composition (a file with repeats gets fresh ones on load), so `param`, `fxmove`, `fxrm`, macro and modulator targets (`EFFECT.NAME`) and presets name an effect by its id alone, wherever it sits; an older command or message that names a layer next to the effect still works. A clip's chain runs before its layer's and travels with the clip, through transitions too; a group's chain runs on the group's picture; the composition's on the final picture, in one more pass, before the outputs. A clip's chain and its layer's are planned as one chain, so they fuse into the same passes, and every chain (each clip's with its layer's) compiles when the composition loads or changes, never on a trigger. Chains compile off the render thread; the stats line shows the average GPU time per frame, the passes and full-resolution intermediates of the last frame, and the pipelines compiled each second with the slowest compile. Trails history and an effect's integrated phases belong to the effect's id: they survive parameter and chain edits and an effect moved to another chain, and Trails history restarts when the drawing layer's clip or group changes. -- ISF shaders (ticket 95): a curated set of Interactive Shader Format files in `third_party/isf/` (licences in `docs/licence-notes.md`) is built in. Effects and generators register under the ISF category (`isfKaleidoscope`, `isfSpiral`, ...; the Effects and Sources tabs list them by their file names) with each ISF input as parameters: float as is, bool as an Off/On choice, long as a choice of its labels, color as NAMERed/Green/Blue/Alpha (a colour group), point2D as NAMEX/NAMEY; so macros, modulators and presets work on them. Three gl-transitions are transition modes (`windowSlice`, `radial`, `randomSquares`) at their default settings. At start the engine translates each shader GLSL -> SPIR-V (glslang) -> MSL (SPIRV-Cross) and caches the namespaced MSL in the state folder's `isf/` by a hash of the shader as written and its namespace, so a start with the cache translates nothing; a shader that reads its input only at its own pixel fuses as a pointwise stage. Multi-pass, persistent, audio and extra-image ISF shaders are refused with an error, and a shader that fails to translate reports its error when a chain uses it. -- Presets: `nocturne-ctl preset save EFFECT NAME` stores an effect's parameters under a name for its type, `preset list [TYPE]` lists them, `preset apply EFFECT NAME` sets them as one undoable edit. They live per machine in `~/Library/Application Support/Nocturne/Presets.json` (`--presets FILE`). The list names each preset's group (`entries`: built-in, imported or the user's own; the built-in ones are our own looks for Transform, Colorize, Edge Detection, Stripper, Trails and Reveal (3 to 5 each, e.g. Reveal's Slow Mosaic, Fast Shutter, Big Blocks, Black Grid) and for the Classic pack, shown under Built-in), `apply` takes a `group`, and `rename` and `delete` act on saved presets only (`src/effects/preset_service.hpp`). A chain that fails to compile is logged and sent to every client as an `error` naming the `chain`; its layers keep their previous chain, or show their source with no effects. -- Layers and groups: `addlayer [PARENT [INDEX]]`, `addgroup [INDEX]`, `rmlayer LAYER` and `movelayer LAYER PARENT INDEX` (PARENT 0 is the composition) edit the stack; `blend LAYER MODE`, `bypass LAYER on|off` and `solo LAYER on|off` work on layers and groups alike. Normal, add, screen and multiply blend in fixed function; the other modes copy the target first, which the stats line counts as a pass. Bypass hides a layer or group (its clip keeps playing). Solo is composition-wide: once anything is soloed, only soloed layers show, plus every layer of a soloed group that has no soloed layers of its own. Groups hold layers only (no nesting) and can carry effects. -- Clip grid and triggers: the composition has decks of columns, and each layer a cell per column (one deck of 4 columns to start). `cell LAYER COLUMN PATH` and `cellclear LAYER COLUMN` fill or empty a cell, `addcolumn DECK [INDEX]`, `rmcolumn COLUMN`, `adddeck [COLUMNS]` and `rmdeck DECK` edit the grid, `quantise off|beat|bar|NBARS|BEATS` sets where triggers land (the next bar by default); all of these are undoable edits. `trigger LAYER COLUMN`, `column COLUMN` (every layer; a layer whose cell there is empty clears, as in Resolume), `deck DECK` (shows a deck; what plays keeps playing) and `arm LAYER COLUMN [off]` are live actions outside the undo history. A trigger lands on the first frame at or after its quantised beat and the clip runs from that beat; triggering the playing clip restarts it. Until the new clip delivers its first frame the layer keeps its picture, never black; a missing, corrupt or unsupported clip keeps it for good and is reported to every client as an `error` naming the layer and hash. The clips of the active and next column, of triggers waiting for their beat, of armed cells and of every deck's first column (where a deck change starts) are opened in the background and decoded at their start (the second they play first, forward, at half real time while they wait, or backward the GOP up to the clip's last frame), within `--preload MB` (default 3072); a layer with no clip at all shows the test card, a layer playing an empty cell nothing. `tests/demo_triggers.sh CTL MEDIA_DIR [PORT [HOST]]` drives an on-screen demo on a fresh engine. -- Autopilot: `autopilot LAYER next|random|off [BARS]` moves a layer on to the next clip of its deck (in column order, wrapping) or a random other one at the end of every step of BARS bars (1..64, default 4); `step LAYER COLUMN BARS|layer` gives one clip its own step. Both are undoable edits. Steps run on the bar from the layer's last trigger. A trigger while Autopilot plays the layer (a click, a column or `nocturne-ctl`, not a Cue or Autopilot itself) holds its clip for the layer's hold, `hold LAYER BARS` (0..64, default 16; 0 just restarts the steps), shown as `manual N` with the bars left on the layer, and Autopilot then moves on from that clip on the bar; a held layer survives a restart. `pace 0.5|1|2|4|8` (an undoable edit, saved with the composition; the Autopilot tab's Pace row) multiplies every step and hold of the composition, rounded to whole bars of at least one; changed mid-step, the running step keeps the fraction of it still to play at the new length, ending on the next bar line at or after that, and the clip plays on. A short clip loops until its step ends and a long one is cut. The next clip is picked when a step starts (a random pick is never the clip playing and repeats exactly with `nocturne --seed N`) and preloaded for the whole step, so the switch lands on the bar's first frame through the same path as a trigger. Clips that fail to open are skipped. Switched off, the layer holds the clip Autopilot played until the next trigger. Autopilot runs in the engine: the document keeps the last trigger, and `clock` shows each layer's column and, under `next`, the column Autopilot plays next. `tests/demo_autopilot.sh CTL MEDIA_DIR [PORT [HOST]]` drives an on-screen demo. The column Autopilot, `columnpilot next|random|off [BARS]` and `columnhold BARS` (`setColumnAutopilot`, undoable, saved with the composition as `columnAutopilot` only when set; the Autopilot tab's Columns row), triggers a whole column of the shown deck on every layer every BARS bars times the pace, on the bar, as a column trigger does (an empty cell clears its layer) but outside the document: the next column after the one it played last (the shown deck's first after a deck switch) or a random other one; columns with nothing playable are skipped. While it runs the layers' Autopilots hold their clips and show `auto paused`; switched off, they step on from the bar they are in. A trigger holds it for its hold (default 16 bars), shown as `manual N` in its row, and a column trigger moves it on from that column. `clock` carries it as `columns` (`column`, `next`, `held`), the shown deck's tab shows `auto next N` or `auto rnd N`, and its step survives a restart and reaches nodes as the pilot of layer 0. -- Transitions: `transition LAYER [alpha|wipe|dissolve|push] [BEATS|cut]` sets how a layer's clips change, over BEATS beats on the beat clock (0..64, fractions too; 0 or `cut` cuts, the default), and `cliptransition LAYER COLUMN BEATS|layer` gives the transition into one clip its own length. Both are undoable edits, and both triggers and Autopilot use them. The transition starts on the trigger's beat and follows the tempo; the old clip plays on, the old clip renders through its own clip chain and the layer's chain into a picture, and the new clip's last pass mixes that picture in on its way into the composition with the layer's opacity and blend mode, so a layer in transition costs one more chain and one more pass. Every last pass is compiled with its transition entry points and the clip to come is preloaded, so nothing compiles and nothing waits when a transition starts. The transition ends once its beats have passed and the new clip shows; a resync ends it at once. `tests/demo_transitions.sh CTL MEDIA_DIR [PORT [HOST]]` drives an on-screen demo. -- Sections and Cues: `section intro|build|drop|breakdown|outro [BAR]` marks a Section of the track playing, on the next bar or ahead on bar BAR of the beat clock (bar 1 starts on beat 0); it lasts until the next mark. `section cancel [BAR]` drops the scheduled marks (or the one on BAR), `cue clear` empties the timeline for a new track, and a resync restarts the Section playing on beat 0 and drops the scheduled ones. These are live, outside the undo history, as are triggers; `cancel [LAYER | column COLUMN]` takes back triggers still waiting for their beat. A Cue (`cue add NAME WHEN [CHANGE ...]`, `cue rm CUE`; undoable edits saved in composition files) fires on bar 1 or bar N of every Section of its kind (`drop`, `drop@3`) or on bar N of the beat clock (`@17`), and holds its changes until the Section it fired in ends: Autopilot mode and step per layer (`autopilot:1=next/2`, `autopilot:1=/1`) and macro values (`macro:1=0.8`), the later Cue winning; then the composition's own values return. `cue cancel CUE` skips a Cue's next firing, and `cue state` shows the Section, the Cues holding and those to come. The timeline is part of the document, and each frame's values follow from it and the beat alone, so every engine on the same beat clock changes on the same beat and nothing is written back into the document; files keep the Cues but not the timeline. `tests/demo_cues.sh CTL MEDIA_DIR [PORT [HOST]]` drives an on-screen demo. -- Show controls (ticket 36): `opacity 0 VALUE` is the composition's master opacity over black (undoable; `0.opacity` takes macros and modulators). `cliptrigger LAYER COLUMN off|beat|bar|NBARS|BEATS|default` gives one clip its own trigger quantisation; `speed LAYER SPEED [COLUMN]` (0..4) and `inout LAYER IN OUT [COLUMN]` (0..1 of its frames) make it play only that part that fast, preloaded at its in point (out point backward). `stop LAYER` is live: the layer plays nothing until its next trigger. `relink LAYER COLUMN [PATH]` finds a cell's missing media again by hash in the library's folders or as the file PATH (protocol `{"type": "library", "op": "relink", "hash"[, "path"]}`); other content at PATH replaces it in every cell as one undo step. Cue changes also take `macro:MACRO=VALUE/BARS` (a ramp over BARS bars from the value the macro held) and `column:COLUMN` (fires that column, of its own deck, on every layer until each layer's next trigger) or `column:#N` (the Nth column of whichever deck is shown). Snapshots list every undo and redo step's op (`"steps"`). `identify [DISPLAY|all [SECONDS]]` shows each output's screen name and output large for a few seconds (`{"type": "outputs", "op": "identify"}`). -- Text clips: a cell can hold a text generator instead of media (`"hash": ""` and a `text` object: text, font, size as a fraction of the composition height, color, align, x, y, lineSpacing, outline, outlineColor). `textcell LAYER COLUMN TEXT [KEY=VALUE ...]` puts one into a cell and `text LAYER COLUMN KEY=VALUE ...` changes it (`setClipText`, one undoable edit of the fields given). It plays on any layer like a video clip: through its own clip chain fused with the layer's chain, transitions and Autopilot. CoreText rasterises each distinct text, font, size, alignment, spacing and outline once, on its own thread, into a small coverage texture (fill and outline); colour, position and modulated size are uniforms, so a colour change or an LFO on `LAYER.textSize`, `textX`, `textY`, `textRed`, `textGreen`, `textBlue` or `textAlpha` never re-rasterises, and the layer keeps showing its last text until a new one is ready, so the render thread never waits for layout. A layer holding a text clip compiles its chains (its own and its clips' fused ones) with a text source too. A font that is not installed falls back to the system font with a warning in the engine log. `tests/demo_text.sh CTL MEDIA_DIR [PORT [HOST]]` drives a 20 s on-screen demo. -- Generator clips: a cell can hold a built-in source instead of media: `"hash": ""`, no `text`, and a first clip effect of a generator type (`blank`, `solid`, `strobe`, `lines`, `checker`, `rings`, `gradient`, `noise`), e.g. `{"hash": "", "name": "Solid", "effects": [{"type": "solid", "params": {"red": 1}}]}` with `setSlot`. The generator draws the clip's picture in place of a video source, in the same fused pass, and its parameters are ordinary effect parameters (`setEffectParam`, macros, modulators, meters). It stays first: adding an effect before it, moving or removing it, or a generator anywhere else is refused. Blank shows nothing (transparent) and is a real step for Autopilot, unlike an empty cell; Strobe is on for `duty` of every `rate` beats of the beat clock, transparent when off; Lines, Checker, Rings and Gradient move at `speed` integrated over time, so a speed change never jumps; Rings are `count` concentric rings out to the top edge around a centre moved by `x` and `y`, spreading outward (inward when negative), a tunnel on the beat. The UI's browser has a Sources tab: drag a generator onto a cell like a file, or double-click it to add it to the selected layer; its settings are in the Clip tab. -- `nocturne --debug-frame-bits` draws a strip along the bottom of every output with the render frame index in 16 bars (most significant left, white = 1). The rightmost bar flips every frame, so a dropped or repeated frame is visible to the eye and on camera. It costs nothing when off. -- Test clips in `tests/media/` are generated by `tests/media/make_media.sh` (needs Homebrew `ffmpeg`). Tests find them through `NOCTURNE_MEDIA_DIR` and goldens through `NOCTURNE_GOLDEN_DIR` when run outside the source tree. -- Screens and outputs: `nocturne --displays` lists the attached displays with their ids (vendor-model-serial, or vendor-model-u when a display reports no serial); screens name displays by id, never by index, so a screen finds its display again after a reconnect. The engine starts with one screen showing the whole composition on `--display ID` (default: the first display). `--virtual N` replaces the real displays with N always-attached displays `window-1`..`window-N`, windows side by side on the main display with 1920x1080 drawables. `--main ID` picks the output that sets the render clock's phase (default: the first attached screen's display); `--slow ID:MS` delays one output's presents to show that a slow output stays local. Below the render line, the stats print one line per output: its measured refresh rate, presents, shown and new frames per second, hitches, dropped frames (rendered frames it never showed), `main`, or `parked` while its display is detached. Every output shows the next frame in order that began at least half a refresh before its vsync, so a display callback running late never makes it skip or repeat. -- `nocturne-ctl assign GROUP DISPLAY` gives a group its own screen with one full slice; `addscreen DISPLAY|none`, `rmscreen`, `screendisplay SCREEN DISPLAY|none` and `testcard SCREEN on|off` edit screens; `screen move SCREEN X Y|grid`, `screen hide SCREEN on|off` and `screen order SCREEN INDEX` arrange the Map (a frame's saved position, hidden from the canvas while its output runs on, the list order; a screen never moved stays in the 3-column grid); `addslice SCREEN [INPUT [X Y W H]]`, `rmslice`, `corners SLICE TLX TLY TRX TRY BRX BRY BLX BLY` (corner pin, 0..1, y down) and `sliceinput SLICE INPUT [X Y W H]` edit slices (INPUT 0 is the composition, otherwise a layer or group id); `outputonly GROUP on|off` keeps a group out of the composition so it shows only on its slices. All are undoable. `displays` lists the engine machine's displays and outputs, `main DISPLAY` changes the main output. A replug keeps screens on their displays: when macOS mirrors displays that carry different screens, the engine turns the mirroring off (`unmirror on|off`, machine-local in `displays.json`, on by default; `{"type": "outputs", "op": "unmirror", "on": false}`), and a screen whose display is gone takes a display newly attached that is the same projector under a new id (same vendor and model, else name, and serial when both report one; a serial-less projector's id names its port, which a USB-C splitter changes). Both are logged and sent to clients as `{"type": "outputs", "op": "notice"}`; the UI shows the notice, flags a screen whose display is missing or mirrored in the status bar, and turns the unmirroring off and on from the Map inspector. -- Advanced mapping (undoable edits, saved with the composition): `warp SLICE linear|bezier [PX PY]` and `warppoints SLICE X Y ...` give a slice a Bezier warp grid of 1..16 patches per axis, applied before its corner pin and tessellated at 21x21 points per patch (linear mode puts the edge handles at thirds; subdividing keeps the shape); `softedge SLICE L R T B [POWER [LUMINANCE [GAMMA]]]` fades its edges (widths as fractions of the slice, Paul Bourke's curve), and `softedge SLICE auto` fades it and every slice of the same input across their overlapping input rects; `screencolor`/`slicecolor ... B C R G BL` add brightness, contrast and RGB (a slice's adds to its screen's); `mask add SCREEN SLICE|0 normal|invert X Y ...`, `mask rm|points|invert` edit polygon masks on a screen (its 0..1 space) or a slice (its input's UV) that black out inside, or outside when inverted. `testcard all on|off` shows the test card on every screen; the card (40 px grid, centre cross, circles, diagonals, colour bars, red border, screen name and output) lives in composition space, and each slice shows its own input area of it through its warp, so overlapping projectors show the same lines. `guides x=POS y=POS ...` places guides in composition UV. Machine-local, not undoable: `showguides on|off` draws them through every slice on the outputs, `target DISPLAY X Y|off` a corner-target crosshair on one output. Nothing compiles on these edits: the outputs rebuild one uniform buffer, and masks are rasterised into coverage textures, only when the mapping changes. `tests/demo_mapping.sh CTL DISPLAY [PORT [HOST]]` drives an on-screen demo on a fresh engine. -- Audio: the engine captures one input device, `--audio-input D` by UID, name or a case-insensitive part of a name (default: the system's default input; `off` captures nothing), mixing `--audio-channels 1,2` (1-based, default all) to mono. While that device is missing, or after it disappears, the default input runs instead and the stats say `fallback`; the chosen device is taken back when it returns, and a device whose callbacks stop for 2 s or whose sample rate changes is restarted. Without any input the analysis runs on silence, so every feature releases to zero. Analysis runs on its own thread in hops of about 2.7 ms, independent of the frame rate: band envelopes (`level`, `sub` 20-60 Hz, `bass` 60-150, `lowMid` 150-500, `mid` 500-2k, `highMid` 2k-6k, `high` 6k-16k) with attack and release per feature, `kick`/`snare`/`hat` onset pulses with onset counts, and a 24-band spectrum from a 2048-point FFT with 75 % overlap. Auto gain (default) normalises each feature by its own recent peak; `--audio-gain DB` sets a fixed gain. Gain and channels are remembered per input device in `audio-devices.json` in the `--state` folder (this run only without one) and come back whenever that device runs again: selected, plugged back in, or after a restart. The state also reports each input channel's level and how much of it is noise. `--audio-latency MS` delays what readers see, to line reactions up with the room. `--audio-log FILE` appends the analysis as JSON lines at 10 Hz plus every input event and settings change (about 12 MB per hour). The stats print an `audio` line: device, state, level, onsets per second, gain, ring overruns, and `digital silence` when the input delivers exact zeros (no microphone permission). -- Process taps (macOS 14.2+, not yet verified to deliver sound): `--audio-input system` captures all system audio, `--audio-input app:NAME` one app's audio by part of its bundle ID or name, or its pid (every matching process, so a browser's helpers too; it follows them as they come and go, and falls back to the default input while none runs). They use a private tap on a private aggregate device clocked by the default output. macOS asks separately for System Audio Recording; without it the tap delivers digital silence. -- `nocturne-ctl audio devices | apps | input | state` query, `audio select DEVICE|default|system|app:NAME|off [CHANNELS]` switches the input, `audio watch [HZ]` shows the live analysis as meters (one line per update when not on a terminal, raw messages with `--json`), `audio gain auto|DB`, `audio latency MS` and `audio envelope FEATURE ATTACK RELEASE` change the analysis. These are machine-local and outside the undo history. -- Modulators and Macros: a modulator is a beat LFO (`sine`, `saw`, `square`, `ramp`, all peaking on the beat, and `random`, holding one value per period that is the same on every engine for the same beat, and `custom`, a curve of up to 16 points over one period, each segment straight or eased (`points=0:1s,0.5:0`, set in the UI's LFO editor too); over 1/16..64 beats with a phase offset, on the beat clock so they follow tempo, nudge and resync) or an audio feature (`level`, the bands, `kick`, `snare`, `hat`) followed through the modulator's own attack and release in ms (`attack=`, `release=`, 0 by default). Each source's sensitivity (`sensitivity kick|snare|hat|bass|lfo VALUE`, 0..4, 1 by default) scales its 0..1 value before its curve; it is part of the show (undoable, saved, replicated), since the input gain already evens out each machine's levels. An audio modulator with Auto range (`"autoRange": true`; on for the audio modulators the UI's Animate and `mod audio` make, off for one saved without it; `autorange=on|off`, the switch under Animate's envelope) maps the running range of its feature, the 10th to 90th percentile over about the last 30 s of music (silence teaches nothing; the first seconds blend from raw 0..1), instead of raw 0..1, so it swings the same at any input level; `mod state` gives each such modulator's `range`. Animate on Reveal's progress, Stripper's width, Colorize's brightness, Transform's scale or a layer's opacity sweeps 0.35..0.65, 0.3..0.7, 0.5..1, 0.9..1.15 and 0.4..1. It maps its 0..1 through a cubic Bezier curve onto MIN..MAX in its target's units and adds that to, or multiplies, a parameter (a layer's opacity or any effect parameter) or a Macro. A Macro is a 0..1 knob on the composition or on a group (8 each) that adds MIN..MAX through its own curve to each of up to 32 parameters. A driven value is its base, plus every offset, times every factor, within its range; the document keeps only the base, so undo and snapshots never see modulated values. `nocturne-ctl macro add|rm|set|target|untarget` and `mod lfo|audio|rm|set` edit them (undoable; `setMacroTarget` replaces one target's parameter, curve and range, and `rename` names a layer, deck, column, screen, slice, mask or macro; `nocturne-ctl rename deck|layer|column|screen|slice|mask|macro ID NAME` sends it, NAME being the rest of the line), `mod state` shows the live values, and `macroset save|load|list` keeps Macro Sets per machine (`--macro-sets FILE`); loading finds targets by layer name and effect type, drops what it cannot find and replaces the macros and the modulators driving macros as one undo step; modulators on parameters stay. The `preview` op lists what a load would bring, item by item, and `load` takes a `drop` list of those items. A modulator whose target parameter does not exist is logged and listed as `dangling` in `mod state`, and the Macros tab names it. The render thread evaluates everything once per frame from a plan built when the document changes, reading the audio analysis once at the frame's time less the latency offset; values reach the shaders as parameter uniforms, so nothing recompiles. `tests/demo_modulators.sh CTL MEDIA_DIR [PORT [HOST]]` drives an on-screen demo. -- App bundle and permissions: one identity, one bundle, one path, one grant. The build makes `Nocturne.app` (bundle id `com.azylume.nocturne`) holding `nocturne`, `nocturne-supervisor` and `nocturne-ctl` in `Contents/MacOS`, with an Info.plist carrying the microphone, system audio and local network usage descriptions and `NSBonjourServices` (`_nocturne._tcp`). `tools/sign-bundle.sh` signs every build with the self-signed identity "Nocturne Dev" from the laptop's login keychain (`tools/make-signing-identity.sh` creates it once; keep it, a new certificate means granting everything again). macOS keys a permission on the bundle id plus that certificate, so it survives rebuilds and redeploys. Without the identity the build signs ad hoc with a loud warning, and every build needs the permissions granted again. On the Mini the app always lives at `~/Nocturne/Nocturne.app` (`tools/deploy-mini.sh`). -- Microphone permission: `nocturne` re-executes itself at start as its own responsible process, so the permission belongs to the app whether it runs from Terminal, SSH, the supervisor or launchd; the bundle alone would not do that for SSH and launchd runs. `nocturne --mic-check` prints the permission (asking for it when it was never decided) and the input devices, and exits 0 when access is granted. One-time step per machine: start it in the desktop session, e.g. over SSH `open -W ~/Nocturne/Nocturne.app --args --mic-check` or in Terminal, and click Allow on the machine's screen; from then on every build signed with Nocturne Dev gets the input, from SSH and launchd too. If it answers `denied`, `tccutil reset Microphone com.azylume.nocturne` and repeat. -- No microphone on laptop runs: `--audio-input off` never asks for the permission, and `NOCTURNE_NO_AUDIO_INPUT=1` in the environment keeps every input off whatever is selected. The test runners set it for themselves and every engine they start (`NOCTURNE_REAL_AUDIO=1` runs the tests that need a real input instead), and `bench/run.sh` and `bench/soak.sh` add `--audio-input off` off the Mini unless `ENGINE_ARGS` has `--audio-input`. -- Supervisor: `nocturne-supervisor [options] [-- engine options]` runs the engine (`nocturne` next to it), restarts it at once when it crashes and kills and restarts it when no new frame was finished for 2 s (`--hang-timeout`), a hang. The engine reports finished frames down a pipe (`NOCTURNE_HEARTBEAT_FD`) and quits when the supervisor is gone. Three crashes or hangs within 60 s (`--crash-limit`, `--crash-window`) start safe mode: black, or `--fallback-image FILE`, with the reason along the bottom of every display, and nothing else of the engine runs, no autoplay. Safe mode lasts until the supervisor restarts (`tools/autostart.sh restart`). The supervisor logs to `~/Library/Logs/Nocturne/supervisor.log` and copies the engine's output, stats lines included, to `engine.log` with a time stamp per line (`--logs DIR`); both rotate by size, and `status.json` in the state folder shows the mode, the engine's pid, the starts and the recent crashes. -- Persistence and resume: the supervisor passes `--state DIR` (default `~/Library/Application Support/Nocturne/State`). The engine then saves the composition to `DIR/composition.json` a quarter second after every change, and where the show is in time (the beat at a wall-clock time, and every layer's Autopilot step) whenever that drifts from what was saved, always off the render thread and as an atomic write-rename. On start it loads the file and continues: the same clips on the same beat grid, Autopilot on the clip it had stepped to; a restart is back on the clip in about 0.2 s after a crash, 2.2 s after a hang. The file is the composition document as JSON with a format version (`nocturne.composition`, version 1, plus a `resume` section only a running engine writes, and `file`: the show file the document was last loaded from or saved to and whether it has edits that file has not, so after a restart the UI names the show and Save writes back to it); a file that cannot be read is moved aside to `composition.json.bad` and the engine starts fresh. A save that fails (a full disk) is logged once (`state: cannot save`), retried every second while the show goes on, and logged again when it works. -- Composition files: `nocturne-ctl save FILE [MEDIA_ROOT]` saves the whole composition (layers, clips, effects, parameters, Autopilot, transitions, macros, modulators, BPM, screens and slices with their warps, soft edges, colour and masks, the test card and guides) as a human-readable file on the engine machine, and `load FILE [MEDIA_ROOT]` replaces the composition with it as one undoable edit. It is the state file's format (`nocturne.composition`, version 1) where every cell also carries its media's `path`, relative to the media root (default: the file's folder) when inside it, and `size`; `mediaRoot` records where the media were when saved, and no beats are kept, so layers start their cells when loaded. Loading finds each clip by its path under the media root, then by its name anywhere under the root, then by content hash (the Media Library, then files of its size under the root), and adds it to the library. A clip not found leaves its cell empty, an unknown effect or parameter is skipped (with the macro targets and modulators on it), and a screen whose display is not attached moves to the first free attached display or stays parked, but only when this machine has never seen that display: every display ever attached while the engine ran is kept in `displays.json` in the `--state` folder (this run only without one), and a screen on a known display that is merely off stays parked and attaches when it powers on (`load FILE [MEDIA_ROOT] --remap-displays`, protocol `"remapDisplays": true`, moves it anyway); each is a warning in the reply and the engine log, never an error. Files without a format version (or a bare composition document), from a newer version, or with values this version does not know (a blend or play mode, a cell without a hash, a layer that does not parse) load what can be read, each dropped value a warning; only a file that is not JSON or has no layers is refused. `nocturne --composition FILE [--media-root DIR]` starts on the file and plays it with no client attached. With `--state`, the state file records the file's path and content hash: while the saved state holds the same show (the same path, or for an older state the same content) it wins even after FILE changes, so edits made on the Mini survive restarts and redeploys; another FILE starts over from it, and `nocturne-ctl load` reloads a changed one. The state is backed up to `DIR/backups` once a minute while it changes (`TIME-minute.json`) and before every load of a show file (`TIME-load.json`), the last 20 of each kept; `tools/state-backup.sh list` and `restore NAME` (on the engine machine; it restarts the autostart agent) bring one back. `tools/sync-show.sh FILE [MEDIA_ROOT]` copies a file to the Mini's `~/Nocturne/shows` and its media to `~/Nocturne/media` (rsync over ssh, under the Mini lock; `nocturne-ctl showmedia FILE` lists what it copies). `tests/demo_composition.sh CTL MEDIA_DIR [PORT [HOST]]` builds a show, saves it, wipes it and loads it back on screen. -- Node Sync (ADR 0005, 0006, 0007; since ADR 0011 there is no main machine at a venue, and Node Sync stays only as the follower the laptop UI uses for local previews): every engine advertises itself over Bonjour as `_nocturne._tcp` on its protocol port with its role in the TXT record (`--name NAME`, default the computer name), and answers clock pings on the same port number over UDP. An engine is the main engine unless started with `--node` (follow the first main engine Bonjour finds, presenting the pairing token only when that engine is on this machine) or `--follow MAIN` (a Bonjour name, or `HOST[:PORT]`, which needs no Bonjour; the node presents `NOCTURNE_TOKEN` to it). A node mirrors the main engine's composition into its own document (snapshot, then every change as the same edit), copies the media its clips use that its library lacks by hash (1 MiB chunks, four in flight, verified by hashing into `/media/`), and makes its beat clock follow the main engine's beat timeline (`{"type": "beat"}`, sent on every change and twice a second) through an NTP-style offset and drift estimate from ten UDP ping-pongs a second. A correction never jumps: the node bends its tempo by at most 2% towards the main engine's beat, and jumps only when the main engine resyncs. A node refuses edits from its own clients. When the main engine or the network goes away (closed connection or 3 s of silence) the node keeps playing its last composition on its own clock and retries every second; when the main engine is back it takes its composition and bends back onto its clock. A restarted main engine never wipes a node's show: when the main engine runs the default composition it started with (no `--state`, no `--composition`) and nothing has changed it, a node whose composition has clips keeps playing its own show on its own clock (`held` in `node status`) until `nocturne-ctl node adopt` on the node, or until the main engine loads a composition. Random Jump targets and random Autopilot picks are a function of the seed, the layer and the beat they happen on, never of a generator's history; a node takes the main engine's seed (and its Autopilot steps when it joins mid-show), so main and nodes choose alike whatever their own `--seed`, and a promoted node keeps that seed. `nocturne-ctl nodes` lists the engines Bonjour finds; `node status` shows the role, the main engine, media still missing, bytes copied, clock offset and round trip; `node promote` makes a node the main engine with the composition, media and beat clock it holds, `node demote` makes a main engine a node, and `node adopt` makes a held node take the main engine's composition. The stats print one `sync` line per second. Bonjour and LAN connections need the one-time macOS Local Network grant for `com.azylume.nocturne` on each machine (the bundle declares `NSLocalNetworkUsageDescription` and `NSBonjourServices`); until it is answered, Bonjour requests stay pending and nothing is found, and a denial prints `bonjour: ... Local Network access denied`. Loopback needs no grant. -- Resolume import (laptop side): `tools/transcode-media.sh COMP.avc DIR` prepares a composition's media once into DIR (DXV, stills and anything above 1080p become H.264 1080p with a keyframe every second as `STEM.mp4`; playable H.264/HEVC/ProRes/HAP files are copied as APFS clones; at nice 15, one file at a time, originals untouched). `build/nocturne-import COMP.avc OUT.json --media DIR [--mapping PRESET.xml] [--displays ID,...] [--start-column N]` writes a portable composition file with DIR as its media root and `OUT.report.md`, which lists what was imported, what was approximated (with the conversion) and every element not imported with where it was. Layers, decks, columns, clips (by name in DIR), blend modes, opacity, FFT-driven opacity (as audio modulators), transport, Random Jump, BPM and Beat Snap map to the document; Transform, Colorize, Edge Detection, Stripper and Trails map with their parameters (Bloom is reported as unsupported). Clip effects land on their clips and the composition's effects on the composition; a conversion every clip of a layer shares is reported once with the number of clips. The mapping imports into Screens and Slices: input rects, Bezier warp grids (point mode and subdivisions), the homography as the corner pin, soft edges (the setup's power, luminance and gamma, widths from where soft-edged slices' input rects overlap), screen and slice colour, and each screen's output device as its display. Nocturne input rects cannot turn, so a turned one becomes its bounding box with the warp grid turned (quarter turns) or an inverted slice mask and a re-solved corner pin (flat slices), with soft edge off; DMX screens, unknown elements and parameters off their defaults are reported with where they were. `build/nocturne-import PRESET.xml|DIR... OUT_DIR` imports Advanced Output files on their own: each becomes `OUT_DIR/NAME.json` (a composition file holding only its screens) and `NAME.report.md`. Then `tools/sync-show.sh OUT.json`, and `tools/demo-show.sh OUT.json [SECONDS]` (default 20 s) plays it on the Mini's display as `--virtual 5` windows (or `DISPLAY_ID=` a real output) with `--debug-frame-bits`, stepping through the decks' columns through the beat clock's quantisation (`QUANTISE=bar` by default, `off` for the worst case; `AHEAD=1` selects the next deck a step early (default off: deck changes are cold), `TRIGGERS=0` gives the no-trigger baseline), and prints the benchmark's measures plus the hitches of the seconds after each trigger against the rest (`bench/trigger_seconds.py`) (it holds the Mini lock; `LFOS=1`, the default, swaps audio modulators on opacity for beat LFOs since the Mini has no audio input). -- Crash causes: before opening a clip file or compiling an effect chain the engine records it in `DIR/inflight.json`. When the engine crashes or hangs, the supervisor adds what was recorded to `DIR/skip.json` and logs it; the engine never opens or compiles what is listed again (the layer keeps its picture and clients get an `error`). `nocturne-supervisor --clear-skips` empties the list. `NOCTURNE_CRASH_ON=clip:NAME` (a file name or hash, or `effect:KEY`) makes the engine kill itself there, for tests. -- Logs keep writing across restarts: `--audio-log` appends and rotates at 64 MB, keeping 4 old files. -- Autostart: `tools/autostart.sh install [--bin DIR] [-- engine options]` writes the LaunchAgent `~/Library/LaunchAgents/ai.azylume.nocturne.plist` (KeepAlive, interactive priority, desktop session only) that starts the supervisor at login, with the audio analysis logged to `~/Library/Logs/Nocturne/audio.jsonl`; `uninstall`, `status`, `restart` and `logs` do what they say. `tests/demo_supervisor.sh CTL MEDIA_DIR [PORT]` kills and hangs a supervised engine on screen and shows it coming back on the same clip and beat. - -- UI: `build/Nocturne UI.app` is the JUCE UI, its own process and bundle (`com.azylume.nocturne.ui`, ADR 0010), signed with Nocturne Dev. JUCE 8.0.9 is fetched by CMake (FetchContent) at configure time. Run it with `open "build/Nocturne UI.app" --args [--host H] [--port N]` (H a name or an address; default the last H given, kept in `UI.settings`, else this machine; port 7400; `--hidden` and `--snapshot` runs neither use nor keep it); while it cannot connect the status area says why (the name does not resolve, or the addresses it resolved to); it reconnects every second while the engine is away, and a UI crash or kill never touches the engine. One window: transport bar, Perform (composition monitor with a view selector for the composition, any group or any screen with its mapping; preview monitor (the selected layer live, or the selected clip while it is not playing: its still, or with an engine run with `--clip-previews`, off by default, its media moving through keyframes sampled off the render thread, drawn through the clip's own effects on the preview thread; see `PreviewService` and `ClipEffectsPreview`); clip grid with deck tabs, the columns of stills (cells never narrower than 86 px: a deck with more columns than fit scrolls sideways by trackpad, Shift+wheel or its scrollbar, the column headers with it, the layer controls pinned; a column that starts playing by itself scrolls into view unless the user is dragging or has just scrolled), and the compact layer control with B/S, opacity, blend and Autopilot; effect chain, Macros/Autopilot/Cues and the library browser below) and Map (below). Clicking a thumbnail triggers, clicking a name selects, clicking a column header triggers the column; media drags from the browser or the Finder (local engine only) onto cells, a clip drags to another cell (Alt copies). A second click on a queued clip takes that layer's trigger back and Esc takes back all of them (`{"type": "trigger", "op": "cancel"[, "layer": L]}`). Cmd+Z / Cmd+Shift+Z and the Undo and Redo buttons (greyed when empty, the step's op as tooltip) follow the engine's history: every change and snapshot carries `"history": {"undo", "redo", "undoOp", "redoOp"}`, and the commands of one mouse drag or compound action carry the same `"gesture"` number, so the engine makes them one undo step (a drag keeps only its first inverse and its last value; moving a clip is two cells in one step). An edit that removes a layer or an effect (or replaces it with its clip) takes the macro targets, modulators and Cue steps on it along in the same step, undone together; the change carries a `"notice"` naming them, which the UI shows in the status area. Below the grid: the effect chain of the composition, the selected layer or the selected clip as cards side by side (the vertical wheel scrolls them sideways); the first card holds the owner's own settings (tempo and quantisation; a layer's opacity, blend, transition and length, group and Autopilot; a clip's sync, play mode, length, step and transition), then one card per effect with a row per parameter and dry/wet last, a Presets box (the effect's presets under collapsible Built-in Classic, Imported and Mine headers with a search field; a click applies one, Save as saves to Mine, right-click renames or deletes, in Mine only; a Classic effect also imports the presets of the VJ app the pack mirrors from the engine machine, which may make macOS ask for access to Documents), a bypass button and a remove button (bypass, `fxbypass EFFECT on|off`, is an undoable effect field: the effect's dry/wet uniform is forced to 0 and its side passes are skipped, so nothing recompiles); a header click collapses a card to a strip, a header drag reorders it, and effects drag from the browser's Effects tab onto the chain, a layer's control, a clip or the Composition header (double-click adds to the chain shown). A parameter row drags (one undo step), types after a double-click on its value, and has a right-click menu: Add to macro (eight fixed macros), Remove from a macro, Animate (a beat LFO with shape sine, saw, square or random over 1, 2, 4, 8 or 16 beats, or Kick, Snare, Hat or Bass with attack and release in ms, both sweeping a from..to range; its settings drop down under the row and the `~` tag folds them), Reset. The Macros pane: Macro Sets (load, save as), eight knobs (drag, click to select, double-click to rename), and the selected macro's editor: Assign (learn mode: click parameter bars to add them; Esc stops), its modulator (Kick, Snare, Hat or Bass with attack and release in ms, or a beat LFO), and its targets with a curve (presets or an editor with two control points) and a from..to range. The Audio panel lists each source with its level, its sensitivity field (drag or type) and the macros and parameters it drives, each with its live value (a beat LFO as its shape over one period, range and curve applied, with a playhead); a click jumps there. The bottom panes resize with the bar above them and the browser hides to a strip; both are remembered (`~/Library/Application Support/Nocturne/UI.settings`). Layers rename by double-clicking their name, decks by double-clicking their tab, columns by double-clicking their header. Clips multi-select (UI-local, never sent to the engine): Cmd-click toggles a clip, Shift-click selects the block from the last one, Shift-click on a layer's name its clips, Cmd+A the deck's, Esc clears. An effect dropped on a selected clip, the Clip tab's fields and effect parameters (values that differ show as mixed), Delete, a block drag (Alt copies) and the menus act on every selected clip as one undo step: a `batch` edit (`{"op": "batch", "edits": [...]}`) applies all its edits or none. Right-click menus, each item with its shortcut and a tooltip: a deck (rename, duplicate, select its clips, delete), a column (fire, rename, select its clips, clear), a layer (rename, group, move up or down, select its clips, add, copy and paste effects, move clip effects to layer: a chain the clips carry alike goes to the layer, per deck where it differs, delete), a cell (trigger, select, select a layer's or column's clips, copy Cmd+C, paste Cmd+V, duplicate Cmd+D, clear, add an effect or a preset, copy Shift+Cmd+C and paste Shift+Cmd+V effects, remove an effect type, transport, step, transition, rename, relink, and repeat last edit Cmd+R: the last clip edit on every selected clip by effect type and parameter name, counting the clips without it) and an effect card (copy, paste, duplicate, bypass, remove); a layer's name drags onto another layer to reorder, media or a whole folder from the browser drop onto a layer's control to fill its empty cells, Delete removes the selected clips, Cmd+Y redoes. The transport bar also has the composition file (name, an unsaved dot, Save and Open on the engine machine, Cmd+S), nudge held on - and +, /2 and x2, and the Section buttons (`cue mark`, lit while that Section plays). The middle pane has Macros, Autopilot (buttons per layer), Cues (by Section, add and remove) and History (the steps as this UI saw them). The browser searches its tabs, groups media by folder and adds a clip to the selected layer on double-click; its Effects tab lists our own effects under Ours and the pack under Classic, each effect opening into Built-in Classic, Imported and Mine headers (only those holding presets), the search opening what it finds, and every header's open state kept in `UI.settings`; a preset drags onto a clip, a layer, a selection, the Composition header or the chain with its settings, and a double-click adds it to the chain shown; the Audio panel picks the engine's input and its channels (1, 2, 1+2, ...), sets Auto gain or a gain in dB with the applied gain shown live, warns about a channel that is silent or noise only, and shows the level, spectrum and onsets after gain. Every number field drags (Shift: ten steps), scrolls, or types after a click (up and down step). Map has Lock (select and look only) and renames slices and masks from the inspector. Styling lives in `src/ui/app/look.*` only. `--jpeg-previews` takes previews as a remote UI does; `--hidden` never shows the window, has no Dock icon and never activates (with `--snapshot`, for evidence on the laptop); `--snapshot FILE` renders the window to a PNG after 8 s (`--snapshot-after S`) and quits, `--snapshots N` takes N half a second apart; `--map` opens Map first (`--map-screen N` opens the Nth screen large, `--map-point K` selects corner K of its first slice, `--map-input` shows the Input view). MIDI: a controller plugs into the machine the UI runs on, and the UI turns its controls into the same edits and actions as a click, so the engine and the protocol know nothing of it. Right-click a parameter, a macro knob, a clip, a column header, a deck tab, Tap or Resync and pick MIDI learn, then move a control: a CC or the pitch bend moves a parameter or a macro (a knob or fader), a note or a CC pressed past half way fires a clip, a column, a deck, Tap or Resync (a key or a pad); Esc cancels, Forget MIDI removes one. A continuous control picks its target up: it moves it only once it comes within 2 % of the value the target has or crosses it, also after something else (a click, an undo, another machine) moved it, so nothing jumps; a modulator or macro driving the parameter adds to that base as before. A turn of a knob is one undo step (it ends 0.4 s after the last message), and of a burst of one control only its last value is sent. Settings > MIDI picks the inputs (all, off, or one) and lists the mappings with Remove; both are kept per machine in `UI.settings` (`midi`), by the show's ids, so a mapping to something another show lacks does nothing. Only an interactive UI opens MIDI inputs, and only once there are any; it follows controllers plugged in and out (`src/ui/app/midi.*`). -- Map tab: every screen is a frame on one canvas with its live picture, its slices (outline through warp and corner pin) and masks (dashed), screen masks and slice masks alike. The selected slice or mask moves by dragging inside, reshapes from its corners or edge handles (an edge moves both points) and rotates from its handle (Shift snaps to 15 degrees); arrow keys nudge the selected point, or the whole shape, by one output pixel (Shift: ten). A slice's Warp is Off (corner pin), Bezier · corners (the 4 corners plus 2 round tangent handles each, 12 points: every edge a cubic Bezier, the interior the Coons patch of the edges, as one engine Bezier patch whose interior handles the engine derives by the Coons rule; a corner drags its two handles along, a handle bends only its own edge), or Grid 3 × 3, 4 × 4 or 5 × 5 points sampled from its current mapping: the grid points are the handles and the surface runs through them (Catmull-Rom edges with reflected end tangents, stored as the engine's Bezier anchors and edge handles under an identity corner pin, so the points and grid lines are exactly what renders); Off keeps the four outer points as the corner pin, Flatten to corners straightens the interior. The wheel only zooms around the cursor, a drag on empty space pans, Fit resets; double-clicking a screen's name (tree or canvas) opens it large, "← All screens", Esc or another double-click goes back; a tree row's right-click menu has Rename (also F2 or Enter on the selected screen; undoable `rename` with kind `screen`) and Open large. The Input view shows the selected slice's input large with every slice of that input as a coloured rectangle (its input rect): drag to move, corners and edges resize, edges snap to 0/100 % and to other rectangles (Alt: no snapping), Shift+drag on empty space draws a new slice on the selected screen, and the selected slice's overlaps are hatched. The inspector has the screen's output, test card and corner-target switches, the guides (composition %), and for a slice its input and input area, relative Move/Scale/Rotate, Warp, the corner pin in output pixels (or the selected grid point), soft edge (L/R/T/B, power, Auto from overlap) and + Mask on slice; for a mask its mode (inverted shows only inside), transform and points (a slice mask's in % of its input). Every number drags, scrolls or types, and every drag or wheel run is one undo step. While Map is open the outputs draw the guides, and a corner target through the selected point (machine-local `outputs` overlays, cleared when Map closes or the UI quits); guide positions, warps, masks, soft edges and test cards are undoable document edits. A group's settings card has a Projector choice that assigns it to a free display, creating its Screen in one step. On the composition monitor (also a group's or a screen's view, through its slice) the selected layer's Transform has handles: drag inside to move, a corner to scale, the round handle to rotate (Shift: 15 degrees); the first drag adds a Transform at the front of the chain. For a text clip (the selected one, or the one the layer plays) the handles move it and change its size. `--select-layer N` selects a layer for a snapshot. -- Recording (machine-local, live, outside the undo history): `nocturne-ctl record start [target=composition|screen:N] [codec=hevc|prores] [path=FILE.mov|FOLDER] [size=WxH] [reserve=MB]`, `record stop`, `record state`, and `record target|codec|path|size|reserve VALUE` to set what the next recording uses (protocol: `{"type": "record", "op": "set"|"start"|"stop"|"state", ...}`, see `src/output/record_service.hpp`). It records the composition, or one screen through its mapping, to a QuickTime movie: HEVC with the hardware encoder where there is one (the Mini's UHD 630 has it), or ProRes 422 (software on the Mini). The render thread only copies, or for a screen paints, each frame into one of 8 pooled IOSurface pixel buffers on the frame's own command buffer; the recorder's thread encodes and writes them. With no free buffer the recorded frame is dropped and counted, never a rendered one. The movie is written as a hidden `.NAME.partial.mov` and renamed into place once finished. When free space falls below the reserve (default 256 MB) the recording finishes cleanly; a write that fails on a full disk ends it and removes the partial file. Either way every client gets one `stopped` message with a notice, and the show goes on. With an audio input running (`record audio on|off`, default on) the movie gets an AAC track of it: the analysis thread, the one reader of the audio callback's ring, hands each chunk it drains to the recorder, and each lands at its capture time on the frames' clock, which starts at the first recorded frame. The stats (`--stats-json` too) carry `record`: recorded fps, dropped frames, codec and whether the encoder is hardware. In the UI, REC in the transport bar starts and stops a recording with the engine's settings (lit while recording, the file in its tooltip); a recording that stops by itself shows its notice in the status area. With `--state`, every setting but the target and a .mov path is kept in `DIR/record.json` and comes back on each start; `--record-path DIR`, `--record-reserve` and `--record-reserve-percent` override what is kept. A chosen folder that is missing or not writable refuses the start with the reason, and the state names it (`folderProblem`); only the default ~/Movies/Nocturne is created. The UI's Settings (top bar, Cmd+,) shows the folder with its free space and the kept settings, and Choose browses the engine machine's folders and volumes (`record folders` op; the native dialog when the engine is local). -- Previews: `{"type": "preview", "op": "watch", "sources": ["composition", "layer:N", "screen:N"], "via": "surface" | "jpeg", "width", "height", "fps"}` (one watch per client, an empty list ends it). A local UI gets a ring of three global IOSurfaces per source, announced once, then the surface index of each new picture; a remote one gets each picture as a JPEG (dropped when the connection is behind). They are painted from the newest frame on their own thread with the screen painter, so a screen preview shows its mapping; a watched layer publishes its picture every frame (one extra full-resolution pass per layer). `{"type": "library", "op": "thumbnail", "hash", "width"}` answers a JPEG still a third into the clip, cached under the library folder in `stills/`. -- UI tests: `build/src/ui/nocturne_ui_tests` (protocol client and panel logic unit tests, the client over a real WebSocket, and headless smoke tests that connect the window to a harness engine, render it to `build/ui-shots/*.png`, trigger a clip, and drive the panels: effects added, reordered and removed by drag and drop, drags as one undo step, learn mode, Animate and the macro editor; the Map canvas: corner, edge, rotate with snap, interpolating warp point on the rendered surface, arrow nudge, slice mask, screen rename by F2, double-click to open large in the tree, zoom and pan, open large, auto soft edge, mask add and invert, test card, guides, corner target while dragging, the Input view, and group to projector; monitor Transform and text handles; presets by effect and group, their open state across a restart, search, and preset drags). +## Contents + +- [Engine options](#engine-options) +- [nocturne-ctl and the protocol](#nocturne-ctl-and-the-protocol) +- [Pairing](#pairing) +- [Media and decoding](#media-and-decoding) +- [Tempo and transport](#tempo-and-transport) +- [Layers and groups](#layers-and-groups) +- [Clip grid and triggers](#clip-grid-and-triggers) +- [Autopilot](#autopilot) +- [Transitions](#transitions) +- [Sections and Cues](#sections-and-cues) +- [Show controls](#show-controls) +- [Effects](#effects) +- [ISF shaders](#isf-shaders) +- [Presets](#presets) +- [Text and generators](#text-and-generators) +- [Screens and mapping](#screens-and-mapping) +- [Audio](#audio) +- [Modulators and Macros](#modulators-and-macros) +- [Recording](#recording) +- [Previews](#previews) +- [App bundle and permissions](#app-bundle-and-permissions) +- [Supervisor](#supervisor) +- [Persistence](#persistence) +- [Node Sync](#node-sync) +- [Resolume import](#resolume-import) +- [UI](#ui) +- [Tests](#tests) + +## Engine options + +`nocturne --help` lists the options; it prints render and present rates once per second and quits cleanly on SIGINT/SIGTERM. + +`nocturne --debug-frame-bits` draws a strip along the bottom of every output with the render frame index in 16 bars (most significant left, white = 1). The rightmost bar flips every frame, so a dropped or repeated frame is visible to the eye and on camera. It costs nothing when off. + +## nocturne-ctl and the protocol + +The engine serves its composition over WebSocket on port 7400 (`--port N`). + +`build/nocturne-ctl` is a debug client: `watch` prints the snapshot and every change, `opacity LAYER VALUE`, `undo` and `redo` edit, `--json` switches from MessagePack to JSON, `--host H` reaches another machine by name (`localhost`, `mini.local`, a Tailscale name) or address, IPv4 or IPv6; a name that does not resolve, or an engine that does not answer, fails naming the host and the addresses it resolved to. + +### Commands by section + +| Section | `nocturne-ctl` commands | +|---|---| +| [nocturne-ctl and the protocol](#nocturne-ctl-and-the-protocol) | `watch`, `undo`, `redo` | +| [Pairing](#pairing) | `token` | +| [Media and decoding](#media-and-decoding) | `add`, `library`, `play`, `clip`, `clear` | +| [Tempo and transport](#tempo-and-transport) | `bpm`, `tap`, `nudge`, `resync`, `clock`, `jump`, `scrub`, `sync`, `playmode` | +| [Layers and groups](#layers-and-groups) | `addlayer`, `addgroup`, `rmlayer`, `movelayer`, `blend`, `bypass`, `solo` | +| [Clip grid and triggers](#clip-grid-and-triggers) | `adddeck`, `rmdeck`, `deck`, `addcolumn`, `rmcolumn`, `column`, `cell`, `cellclear`, `trigger`, `arm`, `quantise` | +| [Autopilot](#autopilot) | `autopilot`, `columnpilot`, `hold`, `columnhold`, `pace`, `step` | +| [Transitions](#transitions) | `transition`, `cliptransition` | +| [Sections and Cues](#sections-and-cues) | `section`, `cue`, `cancel` | +| [Show controls](#show-controls) | `opacity`, `cliptrigger`, `speed`, `inout`, `stop`, `identify`, `relink` | +| [Effects](#effects) | `fx`, `clipfx`, `param`, `fxmove`, `fxrm`, `animate` | +| [Presets](#presets) | `preset` | +| [Text and generators](#text-and-generators) | `text`, `textcell` | +| [Screens and mapping](#screens-and-mapping) | `displays`, `assign`, `addscreen`, `rmscreen`, `screendisplay`, `testcard`, `screen`, `addslice`, `rmslice`, `corners`, `sliceinput`, `outputonly`, `warp`, `warppoints`, `softedge`, `screencolor`, `slicecolor`, `mask`, `showguides`, `target`, `main`, `unmirror` | +| [Audio](#audio) | `audio` | +| [Modulators and Macros](#modulators-and-macros) | `mod`, `macro`, `sensitivity`, `macroset`, `rename` | +| [Recording](#recording) | `record` | +| [Persistence](#persistence) | `save`, `load`, `showmedia` | +| [Node Sync](#node-sync) | `node`, `nodes` | +| [UI](#ui) | `fxbypass` | + +## Pairing + +Every protocol client opens with `{"type": "hello"}` (with `"watch": false` it only asks questions and gets no document). A client on another machine adds `"token"`, the engine machine's pairing token: 64 hex digits the engine makes once and keeps in its state folder as `token` (`--state DIR`, default `~/Library/Application Support/Nocturne/State`), readable by its user alone. `nocturne-ctl token` on that machine prints it. + +Without the right token, or with anything but a hello first, the engine answers `{"type": "error"}` and closes the connection. Clients from a loopback address (127.0.0.1, ::1: the local UI, `nocturne-ctl` on the same machine, an ssh tunnel) need none. A handshake with an `Origin` header (a web page's) is refused; a client from another machine that has not paired within 10 s is closed, and at most 32 may wait at once, 4 from one address. + +Before pairing a message may be 64 KiB at most and is refused unread beyond that. A message over 16 MiB closes the connection; one nested more than 64 levels deep, or MessagePack with a string that is not UTF-8, is answered `undecodable message`. + +`nocturne-ctl` takes it as `--token T` or `NOCTURNE_TOKEN`, and reads the machine's own file when `--host` names this machine; the UI keeps one per host, typed into the venue menu beside the host; a node (`--follow`) reads `NOCTURNE_TOKEN`, and presents it only to the main engine it was told to follow: a bare `--node` sends it to a main engine on this machine alone, since any machine on the LAN can advertise itself as main. + +This narrows the token leak to a rogue main rather than closing it: the main is still known by an unauthenticated Bonjour or host name and the token goes over plain ws://, so on an untrusted network follow a host reached over Tailscale or an ssh tunnel. + +A paired client on another machine names files only inside the shows folder, `~/Nocturne/media`, `--media-root`, the Media Library folder, the recording folder and the state folder: a path outside them in composition `save`/`load`/`shows`, library `add`/`relink`, record `set`/`folders` or preset `import` (also with a `..` part, or through a symbolic link that leads out) is answered with an error before anything reads or writes it. See SECURITY.md, "Network trust". + +## Media and decoding + +### Media Library + +`nocturne-ctl add PATH` adds a file to the engine machine's Media Library (an absolute path on that machine), `library` lists it, `play LAYER PATH` adds a file and puts it into the cell the layer plays (the shown deck's first column if it plays nothing) and starts it, `clip LAYER HASH NAME` and `clear LAYER` set or empty that cell. The library index lives in `~/Library/Application Support/Nocturne/MediaLibrary` (`--library DIR`). + +### Decoding + +One decode thread per clip fills a frame cache. + +Playing forward it holds one second ahead, decoding beyond 4 frames ahead at 1.25x real time (a parked start beyond its first frame at 0.5x), so a column starting or the next one preloading never bursts the hardware decoder, which on the Mac mini delays output frames (a playhead with prefetch targets is not paced); playing backward it decodes whole GOPs in one pass and holds the playhead's GOP plus the one below (`--clip-cache MB` bounds that pair, default 2048; longer GOPs are split, at the cost of decoding again from the keyframe). + +Prefetched targets (the next Random Jump, a bounce's turn, the frame a resync lands on) get one second each. The playhead and the prefetched targets read with separate readers, and a seek reads frame by frame from the keyframe with the playhead's frames first, so reaching a target deep in a long GOP never holds up the playhead (decoded frames/s count the frames on the way too). + +At 1080p a forward or Random Jump layer holds about 0.2-0.6 GB, a reversing layer on a 5 s GOP file about 2 GB. `--seed N` fixes the Random Jump sequence. The stats line shows decoded frames/s, decode thread busy %, seeks, decode errors, stalls, the slowest read and cache size. + +Each read runs on a worker thread: one that takes over 500 ms (2 s for a new reader's first read; AVFoundation can block in it for good, seen on the Mac mini) is given up on and the frame is read again with a new reader, which the second held ahead covers; three in a row with no frame between flag the file corrupt. Closing a decoder never waits for a stuck read, and waits at most 500 ms for its decode thread. + +A decode error mid-stream skips to the next keyframe while the layer holds its last frame; three errors in a row flag the file corrupt. A file that changes or vanishes while it plays (noticed at its next read or seek) breaks its clip at once: the layer holds its last frame without counting misses, and the file is flagged corrupt, or missing when it is gone. Missing files (a removed drive) are looked for every 3 s and play again once back. + +Rotation metadata (phone videos) is honoured; variable frame rate files play by their timestamps, a gap showing the next frame. + +### Start-up + +The engine keeps every layer back, its outputs black, until each clip the composition plays or may play next has opened and decoded its start (the second a forward clip plays first) and every effect chain is compiled, at most 5 s; then all layers start together on their first frames. Started cold with many clips, the decoders otherwise share the hardware decoder with a dozen preloads in the first seconds and the playing layers miss frames. + +A resumed show (`--state`) skips the hold, so a restart stays a blink. The stats JSON shows `render.starting` while it holds. Triggers and composition loads after start-up play as usual. + +## Tempo and transport + +`bpm VALUE` sets the composition tempo (an undoable edit); `tap [COUNT SECONDS]`, `nudge PERCENT` (held until `nudge 0`) and `resync` are live beat clock actions outside the undo history; `clock` prints the beat, tempo and every clip's frame and beat count. + +Per clip: `sync LAYER timeline|bpm`, `playmode LAYER loop|bounce|reverse|randomJump|onceClear|onceHold`, `beats LAYER N|auto`, `jump LAYER INTERVAL DISTANCE [COLUMN]` (Random Jump every INTERVAL beats, 1/8..8, by up to DISTANCE beats either way, landing on the clip's beat grid). `scrub LAYER POSITION [TO SECONDS]` moves a clip to a point (0..1) or sweeps it there, live and outside the undo history. + +New clips are BPM-synced and pick the power of two beats that puts them between 80 and 160 BPM. + +## Layers and groups + +`addlayer [PARENT [INDEX]]`, `addgroup [INDEX]`, `rmlayer LAYER` and `movelayer LAYER PARENT INDEX` (PARENT 0 is the composition) edit the stack; `blend LAYER MODE`, `bypass LAYER on|off` and `solo LAYER on|off` work on layers and groups alike. Normal, add, screen and multiply blend in fixed function; the other modes copy the target first, which the stats line counts as a pass. Bypass hides a layer or group (its clip keeps playing). + +Solo is composition-wide: once anything is soloed, only soloed layers show, plus every layer of a soloed group that has no soloed layers of its own. Groups hold layers only (no nesting) and can carry effects. + +## Clip grid and triggers + +The composition has decks of columns, and each layer a cell per column (one deck of 4 columns to start). `cell LAYER COLUMN PATH` and `cellclear LAYER COLUMN` fill or empty a cell, `addcolumn DECK [INDEX]`, `rmcolumn COLUMN`, `adddeck [COLUMNS]` and `rmdeck DECK` edit the grid, `quantise off|beat|bar|NBARS|BEATS` sets where triggers land (the next bar by default); all of these are undoable edits. + +`trigger LAYER COLUMN`, `column COLUMN` (every layer; a layer whose cell there is empty clears, as in Resolume), `deck DECK` (shows a deck; what plays keeps playing) and `arm LAYER COLUMN [off]` are live actions outside the undo history. A trigger lands on the first frame at or after its quantised beat and the clip runs from that beat; triggering the playing clip restarts it. + +Until the new clip delivers its first frame the layer keeps its picture, never black; a missing, corrupt or unsupported clip keeps it for good and is reported to every client as an `error` naming the layer and hash. + +The clips of the active and next column, of triggers waiting for their beat, of armed cells and of every deck's first column (where a deck change starts) are opened in the background and decoded at their start (the second they play first, forward, at half real time while they wait, or backward the GOP up to the clip's last frame), within `--preload MB` (default 3072); a layer with no clip at all shows the test card, a layer playing an empty cell nothing. + +`tests/demo_triggers.sh CTL MEDIA_DIR [PORT [HOST]]` drives an on-screen demo on a fresh engine. + +## Autopilot + +`autopilot LAYER next|random|off [BARS]` moves a layer on to the next clip of its deck (in column order, wrapping) or a random other one at the end of every step of BARS bars (1..64, default 4); `step LAYER COLUMN BARS|layer` gives one clip its own step. Both are undoable edits. Steps run on the bar from the layer's last trigger. + +A trigger while Autopilot plays the layer (a click, a column or `nocturne-ctl`, not a Cue or Autopilot itself) holds its clip for the layer's hold, `hold LAYER BARS` (0..64, default 16; 0 just restarts the steps), shown as `manual N` with the bars left on the layer, and Autopilot then moves on from that clip on the bar; a held layer survives a restart. + +`pace 0.5|1|2|4|8` (an undoable edit, saved with the composition; the Autopilot tab's Pace row) multiplies every step and hold of the composition, rounded to whole bars of at least one; changed mid-step, the running step keeps the fraction of it still to play at the new length, ending on the next bar line at or after that, and the clip plays on. A short clip loops until its step ends and a long one is cut. + +The next clip is picked when a step starts (a random pick is never the clip playing and repeats exactly with `nocturne --seed N`) and preloaded for the whole step, so the switch lands on the bar's first frame through the same path as a trigger. Clips that fail to open are skipped. Switched off, the layer holds the clip Autopilot played until the next trigger. + +Autopilot runs in the engine: the document keeps the last trigger, and `clock` shows each layer's column and, under `next`, the column Autopilot plays next. `tests/demo_autopilot.sh CTL MEDIA_DIR [PORT [HOST]]` drives an on-screen demo. + +The column Autopilot, `columnpilot next|random|off [BARS]` and `columnhold BARS` (`setColumnAutopilot`, undoable, saved with the composition as `columnAutopilot` only when set; the Autopilot tab's Columns row), triggers a whole column of the shown deck on every layer every BARS bars times the pace, on the bar, as a column trigger does (an empty cell clears its layer) but outside the document: the next column after the one it played last (the shown deck's first after a deck switch) or a random other one; columns with nothing playable are skipped. + +While it runs the layers' Autopilots hold their clips and show `auto paused`; switched off, they step on from the bar they are in. A trigger holds it for its hold (default 16 bars), shown as `manual N` in its row, and a column trigger moves it on from that column. `clock` carries it as `columns` (`column`, `next`, `held`), the shown deck's tab shows `auto next N` or `auto rnd N`, and its step survives a restart and reaches nodes as the pilot of layer 0. + +## Transitions + +`transition LAYER [alpha|wipe|dissolve|push] [BEATS|cut]` sets how a layer's clips change, over BEATS beats on the beat clock (0..64, fractions too; 0 or `cut` cuts, the default), and `cliptransition LAYER COLUMN BEATS|layer` gives the transition into one clip its own length. Both are undoable edits, and both triggers and Autopilot use them. + +The transition starts on the trigger's beat and follows the tempo; the old clip plays on, the old clip renders through its own clip chain and the layer's chain into a picture, and the new clip's last pass mixes that picture in on its way into the composition with the layer's opacity and blend mode, so a layer in transition costs one more chain and one more pass. + +Every last pass is compiled with its transition entry points and the clip to come is preloaded, so nothing compiles and nothing waits when a transition starts. The transition ends once its beats have passed and the new clip shows; a resync ends it at once. `tests/demo_transitions.sh CTL MEDIA_DIR [PORT [HOST]]` drives an on-screen demo. + +## Sections and Cues + +`section intro|build|drop|breakdown|outro [BAR]` marks a Section of the track playing, on the next bar or ahead on bar BAR of the beat clock (bar 1 starts on beat 0); it lasts until the next mark. `section cancel [BAR]` drops the scheduled marks (or the one on BAR), `cue clear` empties the timeline for a new track, and a resync restarts the Section playing on beat 0 and drops the scheduled ones. + +These are live, outside the undo history, as are triggers; `cancel [LAYER | column COLUMN]` takes back triggers still waiting for their beat. + +A Cue (`cue add NAME WHEN [CHANGE ...]`, `cue rm CUE`; undoable edits saved in composition files) fires on bar 1 or bar N of every Section of its kind (`drop`, `drop@3`) or on bar N of the beat clock (`@17`), and holds its changes until the Section it fired in ends: Autopilot mode and step per layer (`autopilot:1=next/2`, `autopilot:1=/1`) and macro values (`macro:1=0.8`), the later Cue winning; then the composition's own values return. + +`cue cancel CUE` skips a Cue's next firing, and `cue state` shows the Section, the Cues holding and those to come. The timeline is part of the document, and each frame's values follow from it and the beat alone, so every engine on the same beat clock changes on the same beat and nothing is written back into the document; files keep the Cues but not the timeline. `tests/demo_cues.sh CTL MEDIA_DIR [PORT [HOST]]` drives an on-screen demo. + +## Show controls + +`opacity 0 VALUE` is the composition's master opacity over black (undoable; `0.opacity` takes macros and modulators). `cliptrigger LAYER COLUMN off|beat|bar|NBARS|BEATS|default` gives one clip its own trigger quantisation; `speed LAYER SPEED [COLUMN]` (0..4) and `inout LAYER IN OUT [COLUMN]` (0..1 of its frames) make it play only that part that fast, preloaded at its in point (out point backward). `stop LAYER` is live: the layer plays nothing until its next trigger. + +`relink LAYER COLUMN [PATH]` finds a cell's missing media again by hash in the library's folders or as the file PATH (protocol `{"type": "library", "op": "relink", "hash"[, "path"]}`); other content at PATH replaces it in every cell as one undo step. + +Cue changes also take `macro:MACRO=VALUE/BARS` (a ramp over BARS bars from the value the macro held) and `column:COLUMN` (fires that column, of its own deck, on every layer until each layer's next trigger) or `column:#N` (the Nth column of whichever deck is shown). Snapshots list every undo and redo step's op (`"steps"`). `identify [DISPLAY|all [SECONDS]]` shows each output's screen name and output large for a few seconds (`{"type": "outputs", "op": "identify"}`). + +## Effects + +- `nocturne-ctl fx LAYER TYPE [NAME=VALUE ...]` adds an effect (`transform`: positionX, positionY, scale, rotation; `colorize`: hue, saturation; `edgeDetection`: multiplier; `stripper`: number, direction, speed, spread, width, feather, invert, red, green, blue, alpha; `trails`: feedback; `slicer`: count, direction (Vertical, Horizontal, Grid, Radial, Boxes, Voronoi), animation, rate (Off, 1/4 to 16 beats), spread, seed, lines, hue, colour, an On/Off switch per style (none: the picture as it is, off by default; thermal, grain, letters, invert, dots, edges, duotone, outline, solid), movement, speed, size, corners, links; `thermal`: hue, colour; `grain`; `letters`: rows; `dots`: rows, hue, tint; `duotone`: hue; `outline`: hue; every effect also has `mix`, its dry/wet) and prints its id +- `param EFFECT NAME VALUE`, `fxmove EFFECT INDEX` and `fxrm EFFECT` edit the chain +- `animate EFFECT NAME FROM TO SECONDS` sweeps a parameter at 60 Hz. + +Effects live on clips, layers, groups and the composition: `clipfx LAYER COLUMN TYPE [NAME=VALUE ...]` adds one to the clip in that cell, LAYER 0 is the composition. Effect ids are unique across the composition (a file with repeats gets fresh ones on load), so `param`, `fxmove`, `fxrm`, macro and modulator targets (`EFFECT.NAME`) and presets name an effect by its id alone, wherever it sits; an older command or message that names a layer next to the effect still works. + +A clip's chain runs before its layer's and travels with the clip, through transitions too; a group's chain runs on the group's picture; the composition's on the final picture, in one more pass, before the outputs. A clip's chain and its layer's are planned as one chain, so they fuse into the same passes, and every chain (each clip's with its layer's) compiles when the composition loads or changes, never on a trigger. + +Chains compile off the render thread; the stats line shows the average GPU time per frame, the passes and full-resolution intermediates of the last frame, and the pipelines compiled each second with the slowest compile. Trails history and an effect's integrated phases belong to the effect's id: they survive parameter and chain edits and an effect moved to another chain, and Trails history restarts when the drawing layer's clip or group changes. + +## ISF shaders + +A curated set of Interactive Shader Format files in `third_party/isf/` (licences in `docs/licence-notes.md`) is built in. + +Effects and generators register under the ISF category (`isfKaleidoscope`, `isfSpiral`, ...; the Effects and Sources tabs list them by their file names) with each ISF input as parameters: float as is, bool as an Off/On choice, long as a choice of its labels, color as NAMERed/Green/Blue/Alpha (a colour group), point2D as NAMEX/NAMEY; so macros, modulators and presets work on them. + +Three gl-transitions are transition modes (`windowSlice`, `radial`, `randomSquares`) at their default settings. At start the engine translates each shader GLSL -> SPIR-V (glslang) -> MSL (SPIRV-Cross) and caches the namespaced MSL in the state folder's `isf/` by a hash of the shader as written and its namespace, so a start with the cache translates nothing; a shader that reads its input only at its own pixel fuses as a pointwise stage. + +Multi-pass, persistent, audio and extra-image ISF shaders are refused with an error, and a shader that fails to translate reports its error when a chain uses it. + +## Presets + +`nocturne-ctl preset save EFFECT NAME` stores an effect's parameters under a name for its type, `preset list [TYPE]` lists them, `preset apply EFFECT NAME` sets them as one undoable edit. They live per machine in `~/Library/Application Support/Nocturne/Presets.json` (`--presets FILE`). + +The list names each preset's group (`entries`: built-in, imported or the user's own; the built-in ones are our own looks for Transform, Colorize, Edge Detection, Stripper, Trails and Reveal (3 to 5 each, e.g. Reveal's Slow Mosaic, Fast Shutter, Big Blocks, Black Grid) and for the Classic pack, shown under Built-in), `apply` takes a `group`, and `rename` and `delete` act on saved presets only (`src/effects/preset_service.hpp`). + +A chain that fails to compile is logged and sent to every client as an `error` naming the `chain`; its layers keep their previous chain, or show their source with no effects. + +## Text and generators + +### Text clips + +A cell can hold a text generator instead of media (`"hash": ""` and a `text` object: text, font, size as a fraction of the composition height, color, align, x, y, lineSpacing, outline, outlineColor). `textcell LAYER COLUMN TEXT [KEY=VALUE ...]` puts one into a cell and `text LAYER COLUMN KEY=VALUE ...` changes it (`setClipText`, one undoable edit of the fields given). + +It plays on any layer like a video clip: through its own clip chain fused with the layer's chain, transitions and Autopilot. + +CoreText rasterises each distinct text, font, size, alignment, spacing and outline once, on its own thread, into a small coverage texture (fill and outline); colour, position and modulated size are uniforms, so a colour change or an LFO on `LAYER.textSize`, `textX`, `textY`, `textRed`, `textGreen`, `textBlue` or `textAlpha` never re-rasterises, and the layer keeps showing its last text until a new one is ready, so the render thread never waits for layout. + +A layer holding a text clip compiles its chains (its own and its clips' fused ones) with a text source too. A font that is not installed falls back to the system font with a warning in the engine log. `tests/demo_text.sh CTL MEDIA_DIR [PORT [HOST]]` drives a 20 s on-screen demo. + +### Generator clips + +A cell can hold a built-in source instead of media: `"hash": ""`, no `text`, and a first clip effect of a generator type (`blank`, `solid`, `strobe`, `lines`, `checker`, `rings`, `gradient`, `noise`), e.g. `{"hash": "", "name": "Solid", "effects": [{"type": "solid", "params": {"red": 1}}]}` with `setSlot`. + +The generator draws the clip's picture in place of a video source, in the same fused pass, and its parameters are ordinary effect parameters (`setEffectParam`, macros, modulators, meters). It stays first: adding an effect before it, moving or removing it, or a generator anywhere else is refused. + +Blank shows nothing (transparent) and is a real step for Autopilot, unlike an empty cell; Strobe is on for `duty` of every `rate` beats of the beat clock, transparent when off; Lines, Checker, Rings and Gradient move at `speed` integrated over time, so a speed change never jumps; Rings are `count` concentric rings out to the top edge around a centre moved by `x` and `y`, spreading outward (inward when negative), a tunnel on the beat. + +The UI's browser has a Sources tab: drag a generator onto a cell like a file, or double-click it to add it to the selected layer; its settings are in the Clip tab. + +## Screens and mapping + +### Screens and outputs + +`nocturne --displays` lists the attached displays with their ids (vendor-model-serial, or vendor-model-u when a display reports no serial); screens name displays by id, never by index, so a screen finds its display again after a reconnect. The engine starts with one screen showing the whole composition on `--display ID` (default: the first display). + +`--virtual N` replaces the real displays with N always-attached displays `window-1`..`window-N`, windows side by side on the main display with 1920x1080 drawables. `--main ID` picks the output that sets the render clock's phase (default: the first attached screen's display); `--slow ID:MS` delays one output's presents to show that a slow output stays local. + +Below the render line, the stats print one line per output: its measured refresh rate, presents, shown and new frames per second, hitches, dropped frames (rendered frames it never showed), `main`, or `parked` while its display is detached. Every output shows the next frame in order that began at least half a refresh before its vsync, so a display callback running late never makes it skip or repeat. + +### Groups on screens + +- `nocturne-ctl assign GROUP DISPLAY` gives a group its own screen with one full slice +- `addscreen DISPLAY|none`, `rmscreen`, `screendisplay SCREEN DISPLAY|none` and `testcard SCREEN on|off` edit screens +- `screen move SCREEN X Y|grid`, `screen hide SCREEN on|off` and `screen order SCREEN INDEX` arrange the Map (a frame's saved position, hidden from the canvas while its output runs on, the list order; a screen never moved stays in the 3-column grid) +- `addslice SCREEN [INPUT [X Y W H]]`, `rmslice`, `corners SLICE TLX TLY TRX TRY BRX BRY BLX BLY` (corner pin, 0..1, y down) and `sliceinput SLICE INPUT [X Y W H]` edit slices (INPUT 0 is the composition, otherwise a layer or group id) +- `outputonly GROUP on|off` keeps a group out of the composition so it shows only on its slices. + +All are undoable. `displays` lists the engine machine's displays and outputs, `main DISPLAY` changes the main output. + +A replug keeps screens on their displays: when macOS mirrors displays that carry different screens, the engine turns the mirroring off (`unmirror on|off`, machine-local in `displays.json`, on by default; `{"type": "outputs", "op": "unmirror", "on": false}`), and a screen whose display is gone takes a display newly attached that is the same projector under a new id (same vendor and model, else name, and serial when both report one; a serial-less projector's id names its port, which a USB-C splitter changes). + +Both are logged and sent to clients as `{"type": "outputs", "op": "notice"}`; the UI shows the notice, flags a screen whose display is missing or mirrored in the status bar, and turns the unmirroring off and on from the Map inspector. + +### Advanced mapping + +- Advanced mapping (undoable edits, saved with the composition): `warp SLICE linear|bezier [PX PY]` and `warppoints SLICE X Y ...` give a slice a Bezier warp grid of 1..16 patches per axis, applied before its corner pin and tessellated at 21x21 points per patch (linear mode puts the edge handles at thirds; subdividing keeps the shape) +- `softedge SLICE L R T B [POWER [LUMINANCE [GAMMA]]]` fades its edges (widths as fractions of the slice, Paul Bourke's curve), and `softedge SLICE auto` fades it and every slice of the same input across their overlapping input rects +- `screencolor`/`slicecolor ... B C R G BL` add brightness, contrast and RGB (a slice's adds to its screen's) +- `mask add SCREEN SLICE|0 normal|invert X Y ...`, `mask rm|points|invert` edit polygon masks on a screen (its 0..1 space) or a slice (its input's UV) that black out inside, or outside when inverted. + +`testcard all on|off` shows the test card on every screen; the card (40 px grid, centre cross, circles, diagonals, colour bars, red border, screen name and output) lives in composition space, and each slice shows its own input area of it through its warp, so overlapping projectors show the same lines. `guides x=POS y=POS ...` places guides in composition UV. + +Machine-local, not undoable: `showguides on|off` draws them through every slice on the outputs, `target DISPLAY X Y|off` a corner-target crosshair on one output. Nothing compiles on these edits: the outputs rebuild one uniform buffer, and masks are rasterised into coverage textures, only when the mapping changes. `tests/demo_mapping.sh CTL DISPLAY [PORT [HOST]]` drives an on-screen demo on a fresh engine. + +## Audio + +### Input and analysis + +The engine captures one input device, `--audio-input D` by UID, name or a case-insensitive part of a name (default: the system's default input; `off` captures nothing), mixing `--audio-channels 1,2` (1-based, default all) to mono. While that device is missing, or after it disappears, the default input runs instead and the stats say `fallback`; the chosen device is taken back when it returns, and a device whose callbacks stop for 2 s or whose sample rate changes is restarted. + +Without any input the analysis runs on silence, so every feature releases to zero. Analysis runs on its own thread in hops of about 2.7 ms, independent of the frame rate: band envelopes (`level`, `sub` 20-60 Hz, `bass` 60-150, `lowMid` 150-500, `mid` 500-2k, `highMid` 2k-6k, `high` 6k-16k) with attack and release per feature, `kick`/`snare`/`hat` onset pulses with onset counts, and a 24-band spectrum from a 2048-point FFT with 75 % overlap. + +Auto gain (default) normalises each feature by its own recent peak; `--audio-gain DB` sets a fixed gain. Gain and channels are remembered per input device in `audio-devices.json` in the `--state` folder (this run only without one) and come back whenever that device runs again: selected, plugged back in, or after a restart. The state also reports each input channel's level and how much of it is noise. + +`--audio-latency MS` delays what readers see, to line reactions up with the room. `--audio-log FILE` appends the analysis as JSON lines at 10 Hz plus every input event and settings change (about 12 MB per hour). The stats print an `audio` line: device, state, level, onsets per second, gain, ring overruns, and `digital silence` when the input delivers exact zeros (no microphone permission). + +### Process taps + +Process taps (macOS 14.2+, not yet verified to deliver sound): `--audio-input system` captures all system audio, `--audio-input app:NAME` one app's audio by part of its bundle ID or name, or its pid (every matching process, so a browser's helpers too; it follows them as they come and go, and falls back to the default input while none runs). + +They use a private tap on a private aggregate device clocked by the default output. macOS asks separately for System Audio Recording; without it the tap delivers digital silence. + +### nocturne-ctl audio + +`nocturne-ctl audio devices | apps | input | state` query, `audio select DEVICE|default|system|app:NAME|off [CHANNELS]` switches the input, `audio watch [HZ]` shows the live analysis as meters (one line per update when not on a terminal, raw messages with `--json`), `audio gain auto|DB`, `audio latency MS` and `audio envelope FEATURE ATTACK RELEASE` change the analysis. These are machine-local and outside the undo history. + +## Modulators and Macros + +A modulator is a beat LFO (`sine`, `saw`, `square`, `ramp`, all peaking on the beat, and `random`, holding one value per period that is the same on every engine for the same beat, and `custom`, a curve of up to 16 points over one period, each segment straight or eased (`points=0:1s,0.5:0`, set in the UI's LFO editor too); over 1/16..64 beats with a phase offset, on the beat clock so they follow tempo, nudge and resync) or an audio feature (`level`, the bands, `kick`, `snare`, `hat`) followed through the modulator's own attack and release in ms (`attack=`, `release=`, 0 by default). + +Each source's sensitivity (`sensitivity kick|snare|hat|bass|lfo VALUE`, 0..4, 1 by default) scales its 0..1 value before its curve; it is part of the show (undoable, saved, replicated), since the input gain already evens out each machine's levels. + +An audio modulator with Auto range (`"autoRange": true`; on for the audio modulators the UI's Animate and `mod audio` make, off for one saved without it; `autorange=on|off`, the switch under Animate's envelope) maps the running range of its feature, the 10th to 90th percentile over about the last 30 s of music (silence teaches nothing; the first seconds blend from raw 0..1), instead of raw 0..1, so it swings the same at any input level; `mod state` gives each such modulator's `range`. + +Animate on Reveal's progress, Stripper's width, Colorize's brightness, Transform's scale or a layer's opacity sweeps 0.35..0.65, 0.3..0.7, 0.5..1, 0.9..1.15 and 0.4..1. It maps its 0..1 through a cubic Bezier curve onto MIN..MAX in its target's units and adds that to, or multiplies, a parameter (a layer's opacity or any effect parameter) or a Macro. + +A Macro is a 0..1 knob on the composition or on a group (8 each) that adds MIN..MAX through its own curve to each of up to 32 parameters. A driven value is its base, plus every offset, times every factor, within its range; the document keeps only the base, so undo and snapshots never see modulated values. + +- `nocturne-ctl macro add|rm|set|target|untarget` and `mod lfo|audio|rm|set` edit them (undoable; `setMacroTarget` replaces one target's parameter, curve and range, and `rename` names a layer, deck, column, screen, slice, mask or macro; `nocturne-ctl rename deck|layer|column|screen|slice|mask|macro ID NAME` sends it, NAME being the rest of the line), `mod state` shows the live values, and `macroset save|load|list` keeps Macro Sets per machine (`--macro-sets FILE`) +- loading finds targets by layer name and effect type, drops what it cannot find and replaces the macros and the modulators driving macros as one undo step +- modulators on parameters stay. + +The `preview` op lists what a load would bring, item by item, and `load` takes a `drop` list of those items. A modulator whose target parameter does not exist is logged and listed as `dangling` in `mod state`, and the Macros tab names it. + +The render thread evaluates everything once per frame from a plan built when the document changes, reading the audio analysis once at the frame's time less the latency offset; values reach the shaders as parameter uniforms, so nothing recompiles. `tests/demo_modulators.sh CTL MEDIA_DIR [PORT [HOST]]` drives an on-screen demo. + +## Recording + +Recording (machine-local, live, outside the undo history): `nocturne-ctl record start [target=composition|screen:N] [codec=hevc|prores] [path=FILE.mov|FOLDER] [size=WxH] [reserve=MB]`, `record stop`, `record state`, and `record target|codec|path|size|reserve VALUE` to set what the next recording uses (protocol: `{"type": "record", "op": "set"|"start"|"stop"|"state", ...}`, see `src/output/record_service.hpp`). + +It records the composition, or one screen through its mapping, to a QuickTime movie: HEVC with the hardware encoder where there is one (the Mini's UHD 630 has it), or ProRes 422 (software on the Mini). The render thread only copies, or for a screen paints, each frame into one of 8 pooled IOSurface pixel buffers on the frame's own command buffer; the recorder's thread encodes and writes them. With no free buffer the recorded frame is dropped and counted, never a rendered one. + +The movie is written as a hidden `.NAME.partial.mov` and renamed into place once finished. When free space falls below the reserve (default 256 MB) the recording finishes cleanly; a write that fails on a full disk ends it and removes the partial file. Either way every client gets one `stopped` message with a notice, and the show goes on. + +With an audio input running (`record audio on|off`, default on) the movie gets an AAC track of it: the analysis thread, the one reader of the audio callback's ring, hands each chunk it drains to the recorder, and each lands at its capture time on the frames' clock, which starts at the first recorded frame. The stats (`--stats-json` too) carry `record`: recorded fps, dropped frames, codec and whether the encoder is hardware. + +In the UI, REC in the transport bar starts and stops a recording with the engine's settings (lit while recording, the file in its tooltip); a recording that stops by itself shows its notice in the status area. With `--state`, every setting but the target and a .mov path is kept in `DIR/record.json` and comes back on each start; `--record-path DIR`, `--record-reserve` and `--record-reserve-percent` override what is kept. + +A chosen folder that is missing or not writable refuses the start with the reason, and the state names it (`folderProblem`); only the default ~/Movies/Nocturne is created. The UI's Settings (top bar, Cmd+,) shows the folder with its free space and the kept settings, and Choose browses the engine machine's folders and volumes (`record folders` op; the native dialog when the engine is local). + +## Previews + +`{"type": "preview", "op": "watch", "sources": ["composition", "layer:N", "screen:N"], "via": "surface" | "jpeg", "width", "height", "fps"}` (one watch per client, an empty list ends it). A local UI gets a ring of three global IOSurfaces per source, announced once, then the surface index of each new picture; a remote one gets each picture as a JPEG (dropped when the connection is behind). + +They are painted from the newest frame on their own thread with the screen painter, so a screen preview shows its mapping; a watched layer publishes its picture every frame (one extra full-resolution pass per layer). `{"type": "library", "op": "thumbnail", "hash", "width"}` answers a JPEG still a third into the clip, cached under the library folder in `stills/`. + +## App bundle and permissions + +### Bundle + +One identity, one bundle, one path, one grant. The build makes `Nocturne.app` (bundle id `com.azylume.nocturne`) holding `nocturne`, `nocturne-supervisor` and `nocturne-ctl` in `Contents/MacOS`, with an Info.plist carrying the microphone, system audio and local network usage descriptions and `NSBonjourServices` (`_nocturne._tcp`). + +`tools/sign-bundle.sh` signs every build with the self-signed identity "Nocturne Dev" from the laptop's login keychain (`tools/make-signing-identity.sh` creates it once; keep it, a new certificate means granting everything again). macOS keys a permission on the bundle id plus that certificate, so it survives rebuilds and redeploys. Without the identity the build signs ad hoc with a loud warning, and every build needs the permissions granted again. + +On the Mini the app always lives at `~/Nocturne/Nocturne.app` (`tools/deploy-mini.sh`). + +### Microphone permission + +`nocturne` re-executes itself at start as its own responsible process, so the permission belongs to the app whether it runs from Terminal, SSH, the supervisor or launchd; the bundle alone would not do that for SSH and launchd runs. `nocturne --mic-check` prints the permission (asking for it when it was never decided) and the input devices, and exits 0 when access is granted. + +One-time step per machine: start it in the desktop session, e.g. over SSH `open -W ~/Nocturne/Nocturne.app --args --mic-check` or in Terminal, and click Allow on the machine's screen; from then on every build signed with Nocturne Dev gets the input, from SSH and launchd too. If it answers `denied`, `tccutil reset Microphone com.azylume.nocturne` and repeat. + +### No microphone on laptop runs + +No microphone on laptop runs: `--audio-input off` never asks for the permission, and `NOCTURNE_NO_AUDIO_INPUT=1` in the environment keeps every input off whatever is selected. The test runners set it for themselves and every engine they start (`NOCTURNE_REAL_AUDIO=1` runs the tests that need a real input instead), and `bench/run.sh` and `bench/soak.sh` add `--audio-input off` off the Mini unless `ENGINE_ARGS` has `--audio-input`. + +## Supervisor + +`nocturne-supervisor [options] [-- engine options]` runs the engine (`nocturne` next to it), restarts it at once when it crashes and kills and restarts it when no new frame was finished for 2 s (`--hang-timeout`), a hang. The engine reports finished frames down a pipe (`NOCTURNE_HEARTBEAT_FD`) and quits when the supervisor is gone. + +Three crashes or hangs within 60 s (`--crash-limit`, `--crash-window`) start safe mode: black, or `--fallback-image FILE`, with the reason along the bottom of every display, and nothing else of the engine runs, no autoplay. Safe mode lasts until the supervisor restarts (`tools/autostart.sh restart`). + +The supervisor logs to `~/Library/Logs/Nocturne/supervisor.log` and copies the engine's output, stats lines included, to `engine.log` with a time stamp per line (`--logs DIR`); both rotate by size, and `status.json` in the state folder shows the mode, the engine's pid, the starts and the recent crashes. + +### Crash causes + +Before opening a clip file or compiling an effect chain the engine records it in `DIR/inflight.json`. When the engine crashes or hangs, the supervisor adds what was recorded to `DIR/skip.json` and logs it; the engine never opens or compiles what is listed again (the layer keeps its picture and clients get an `error`). `nocturne-supervisor --clear-skips` empties the list. + +`NOCTURNE_CRASH_ON=clip:NAME` (a file name or hash, or `effect:KEY`) makes the engine kill itself there, for tests. + +### Logs + +Logs keep writing across restarts: `--audio-log` appends and rotates at 64 MB, keeping 4 old files. + +### Autostart + +`tools/autostart.sh install [--bin DIR] [-- engine options]` writes the LaunchAgent `~/Library/LaunchAgents/ai.azylume.nocturne.plist` (KeepAlive, interactive priority, desktop session only) that starts the supervisor at login, with the audio analysis logged to `~/Library/Logs/Nocturne/audio.jsonl`; `uninstall`, `status`, `restart` and `logs` do what they say. + +`tests/demo_supervisor.sh CTL MEDIA_DIR [PORT]` kills and hangs a supervised engine on screen and shows it coming back on the same clip and beat. + +## Persistence + +### Resume + +Persistence and resume: the supervisor passes `--state DIR` (default `~/Library/Application Support/Nocturne/State`). The engine then saves the composition to `DIR/composition.json` a quarter second after every change, and where the show is in time (the beat at a wall-clock time, and every layer's Autopilot step) whenever that drifts from what was saved, always off the render thread and as an atomic write-rename. + +On start it loads the file and continues: the same clips on the same beat grid, Autopilot on the clip it had stepped to; a restart is back on the clip in about 0.2 s after a crash, 2.2 s after a hang. + +The file is the composition document as JSON with a format version (`nocturne.composition`, version 1, plus a `resume` section only a running engine writes, and `file`: the show file the document was last loaded from or saved to and whether it has edits that file has not, so after a restart the UI names the show and Save writes back to it); a file that cannot be read is moved aside to `composition.json.bad` and the engine starts fresh. + +A save that fails (a full disk) is logged once (`state: cannot save`), retried every second while the show goes on, and logged again when it works. + +### Composition files + +`nocturne-ctl save FILE [MEDIA_ROOT]` saves the whole composition (layers, clips, effects, parameters, Autopilot, transitions, macros, modulators, BPM, screens and slices with their warps, soft edges, colour and masks, the test card and guides) as a human-readable file on the engine machine, and `load FILE [MEDIA_ROOT]` replaces the composition with it as one undoable edit. + +It is the state file's format (`nocturne.composition`, version 1) where every cell also carries its media's `path`, relative to the media root (default: the file's folder) when inside it, and `size`; `mediaRoot` records where the media were when saved, and no beats are kept, so layers start their cells when loaded. + +Loading finds each clip by its path under the media root, then by its name anywhere under the root, then by content hash (the Media Library, then files of its size under the root), and adds it to the library. + +- A clip not found leaves its cell empty, an unknown effect or parameter is skipped (with the macro targets and modulators on it), and a screen whose display is not attached moves to the first free attached display or stays parked, but only when this machine has never seen that display: + - every display ever attached while the engine ran is kept in `displays.json` in the `--state` folder (this run only without one) + - and a screen on a known display that is merely off stays parked and attaches when it powers on (`load FILE [MEDIA_ROOT] --remap-displays`, protocol `"remapDisplays": true`, moves it anyway); each is a warning in the reply and the engine log + - never an error. + +Files without a format version (or a bare composition document), from a newer version, or with values this version does not know (a blend or play mode, a cell without a hash, a layer that does not parse) load what can be read, each dropped value a warning; only a file that is not JSON or has no layers is refused. `nocturne --composition FILE [--media-root DIR]` starts on the file and plays it with no client attached. + +With `--state`, the state file records the file's path and content hash: while the saved state holds the same show (the same path, or for an older state the same content) it wins even after FILE changes, so edits made on the Mini survive restarts and redeploys; another FILE starts over from it, and `nocturne-ctl load` reloads a changed one. + +The state is backed up to `DIR/backups` once a minute while it changes (`TIME-minute.json`) and before every load of a show file (`TIME-load.json`), the last 20 of each kept; `tools/state-backup.sh list` and `restore NAME` (on the engine machine; it restarts the autostart agent) bring one back. + +`tools/sync-show.sh FILE [MEDIA_ROOT]` copies a file to the Mini's `~/Nocturne/shows` and its media to `~/Nocturne/media` (rsync over ssh, under the Mini lock; `nocturne-ctl showmedia FILE` lists what it copies). `tests/demo_composition.sh CTL MEDIA_DIR [PORT [HOST]]` builds a show, saves it, wipes it and loads it back on screen. + +## Node Sync + +Node Sync (ADR 0005, 0006, 0007; since ADR 0011 there is no main machine at a venue, and Node Sync stays only as the follower the laptop UI uses for local previews): every engine advertises itself over Bonjour as `_nocturne._tcp` on its protocol port with its role in the TXT record (`--name NAME`, default the computer name), and answers clock pings on the same port number over UDP. + +An engine is the main engine unless started with `--node` (follow the first main engine Bonjour finds, presenting the pairing token only when that engine is on this machine) or `--follow MAIN` (a Bonjour name, or `HOST[:PORT]`, which needs no Bonjour; the node presents `NOCTURNE_TOKEN` to it). + +A node mirrors the main engine's composition into its own document (snapshot, then every change as the same edit), copies the media its clips use that its library lacks by hash (1 MiB chunks, four in flight, verified by hashing into `/media/`), and makes its beat clock follow the main engine's beat timeline (`{"type": "beat"}`, sent on every change and twice a second) through an NTP-style offset and drift estimate from ten UDP ping-pongs a second. + +A correction never jumps: the node bends its tempo by at most 2% towards the main engine's beat, and jumps only when the main engine resyncs. A node refuses edits from its own clients. When the main engine or the network goes away (closed connection or 3 s of silence) the node keeps playing its last composition on its own clock and retries every second; when the main engine is back it takes its composition and bends back onto its clock. + +A restarted main engine never wipes a node's show: when the main engine runs the default composition it started with (no `--state`, no `--composition`) and nothing has changed it, a node whose composition has clips keeps playing its own show on its own clock (`held` in `node status`) until `nocturne-ctl node adopt` on the node, or until the main engine loads a composition. + +Random Jump targets and random Autopilot picks are a function of the seed, the layer and the beat they happen on, never of a generator's history; a node takes the main engine's seed (and its Autopilot steps when it joins mid-show), so main and nodes choose alike whatever their own `--seed`, and a promoted node keeps that seed. + +`nocturne-ctl nodes` lists the engines Bonjour finds; `node status` shows the role, the main engine, media still missing, bytes copied, clock offset and round trip; `node promote` makes a node the main engine with the composition, media and beat clock it holds, `node demote` makes a main engine a node, and `node adopt` makes a held node take the main engine's composition. The stats print one `sync` line per second. + +Bonjour and LAN connections need the one-time macOS Local Network grant for `com.azylume.nocturne` on each machine (the bundle declares `NSLocalNetworkUsageDescription` and `NSBonjourServices`); until it is answered, Bonjour requests stay pending and nothing is found, and a denial prints `bonjour: ... Local Network access denied`. Loopback needs no grant. + +## Resolume import + +Resolume import (laptop side): `tools/transcode-media.sh COMP.avc DIR` prepares a composition's media once into DIR (DXV, stills and anything above 1080p become H.264 1080p with a keyframe every second as `STEM.mp4`; playable H.264/HEVC/ProRes/HAP files are copied as APFS clones; at nice 15, one file at a time, originals untouched). + +`build/nocturne-import COMP.avc OUT.json --media DIR [--mapping PRESET.xml] [--displays ID,...] [--start-column N]` writes a portable composition file with DIR as its media root and `OUT.report.md`, which lists what was imported, what was approximated (with the conversion) and every element not imported with where it was. + +Layers, decks, columns, clips (by name in DIR), blend modes, opacity, FFT-driven opacity (as audio modulators), transport, Random Jump, BPM and Beat Snap map to the document; Transform, Colorize, Edge Detection, Stripper and Trails map with their parameters (Bloom is reported as unsupported). Clip effects land on their clips and the composition's effects on the composition; a conversion every clip of a layer shares is reported once with the number of clips. + +The mapping imports into Screens and Slices: input rects, Bezier warp grids (point mode and subdivisions), the homography as the corner pin, soft edges (the setup's power, luminance and gamma, widths from where soft-edged slices' input rects overlap), screen and slice colour, and each screen's output device as its display. + +Nocturne input rects cannot turn, so a turned one becomes its bounding box with the warp grid turned (quarter turns) or an inverted slice mask and a re-solved corner pin (flat slices), with soft edge off; DMX screens, unknown elements and parameters off their defaults are reported with where they were. + +`build/nocturne-import PRESET.xml|DIR... OUT_DIR` imports Advanced Output files on their own: each becomes `OUT_DIR/NAME.json` (a composition file holding only its screens) and `NAME.report.md`. + +Then `tools/sync-show.sh OUT.json`, and `tools/demo-show.sh OUT.json [SECONDS]` (default 20 s) plays it on the Mini's display as `--virtual 5` windows (or `DISPLAY_ID=` a real output) with `--debug-frame-bits`, stepping through the decks' columns through the beat clock's quantisation (`QUANTISE=bar` by default, `off` for the worst case; `AHEAD=1` selects the next deck a step early (default off: deck changes are cold), `TRIGGERS=0` gives the no-trigger baseline), and prints the benchmark's measures plus the hitches of the seconds after each trigger against the rest (`bench/trigger_seconds.py`) (it holds the Mini lock; `LFOS=1`, the default, swaps audio modulators on opacity for beat LFOs since the Mini has no audio input). + +## UI + +`build/Nocturne UI.app` is the JUCE UI, its own process and bundle (`com.azylume.nocturne.ui`, ADR 0010), signed with Nocturne Dev. JUCE 8.0.9 is fetched by CMake (FetchContent) at configure time. + +Run it with `open "build/Nocturne UI.app" --args [--host H] [--port N]` (H a name or an address; default the last H given, kept in `UI.settings`, else this machine; port 7400; `--hidden` and `--snapshot` runs neither use nor keep it); while it cannot connect the status area says why (the name does not resolve, or the addresses it resolved to); it reconnects every second while the engine is away, and a UI crash or kill never touches the engine. + +One window: transport bar, Perform (composition monitor with a view selector for the composition, any group or any screen with its mapping; preview monitor (the selected layer live, or the selected clip while it is not playing: its still, or with an engine run with `--clip-previews`, off by default, its media moving through keyframes sampled off the render thread, drawn through the clip's own effects on the preview thread; see `PreviewService` and `ClipEffectsPreview`); clip grid with deck tabs, the columns of stills (cells never narrower than 86 px: a deck with more columns than fit scrolls sideways by trackpad, Shift+wheel or its scrollbar, the column headers with it, the layer controls pinned; a column that starts playing by itself scrolls into view unless the user is dragging or has just scrolled), and the compact layer control with B/S, opacity, blend and Autopilot; effect chain, Macros/Autopilot/Cues and the library browser below) and Map (below). + +Clicking a thumbnail triggers, clicking a name selects, clicking a column header triggers the column; media drags from the browser or the Finder (local engine only) onto cells, a clip drags to another cell (Alt copies). A second click on a queued clip takes that layer's trigger back and Esc takes back all of them (`{"type": "trigger", "op": "cancel"[, "layer": L]}`). + +Cmd+Z / Cmd+Shift+Z and the Undo and Redo buttons (greyed when empty, the step's op as tooltip) follow the engine's history: every change and snapshot carries `"history": {"undo", "redo", "undoOp", "redoOp"}`, and the commands of one mouse drag or compound action carry the same `"gesture"` number, so the engine makes them one undo step (a drag keeps only its first inverse and its last value; moving a clip is two cells in one step). + +An edit that removes a layer or an effect (or replaces it with its clip) takes the macro targets, modulators and Cue steps on it along in the same step, undone together; the change carries a `"notice"` naming them, which the UI shows in the status area. + +- Below the grid: the effect chain of the composition, the selected layer or the selected clip as cards side by side (the vertical wheel scrolls them sideways) +- the first card holds the owner's own settings (tempo and quantisation; a layer's opacity, blend, transition and length, group and Autopilot; a clip's sync, play mode, length, step and transition), then one card per effect with a row per parameter and dry/wet last, a Presets box (the effect's presets under collapsible Built-in, Imported and Mine headers with a search field; a click applies one, Save as saves to Mine, right-click renames or deletes, in Mine only; a Classic effect also imports the presets of the VJ app the pack mirrors from the engine machine, which may make macOS ask for access to Documents), a bypass button and a remove button (bypass, `fxbypass EFFECT on|off`, is an undoable effect field: the effect's dry/wet uniform is forced to 0 and its side passes are skipped, so nothing recompiles) +- a header click collapses a card to a strip, a header drag reorders it, and effects drag from the browser's Effects tab onto the chain, a layer's control, a clip or the Composition header (double-click adds to the chain shown). + +A parameter row drags (one undo step), types after a double-click on its value, and has a right-click menu: Add to macro (eight fixed macros), Remove from a macro, Animate (a beat LFO with shape sine, saw, square or random over 1, 2, 4, 8 or 16 beats, or Kick, Snare, Hat or Bass with attack and release in ms, both sweeping a from..to range; its settings drop down under the row and the `~` tag folds them), Reset. + +The Macros pane: Macro Sets (load, save as), eight knobs (drag, click to select, double-click to rename), and the selected macro's editor: Assign (learn mode: click parameter bars to add them; Esc stops), its modulator (Kick, Snare, Hat or Bass with attack and release in ms, or a beat LFO), and its targets with a curve (presets or an editor with two control points) and a from..to range. + +The Audio panel lists each source with its level, its sensitivity field (drag or type) and the macros and parameters it drives, each with its live value (a beat LFO as its shape over one period, range and curve applied, with a playhead); a click jumps there. The bottom panes resize with the bar above them and the browser hides to a strip; both are remembered (`~/Library/Application Support/Nocturne/UI.settings`). + +Layers rename by double-clicking their name, decks by double-clicking their tab, columns by double-clicking their header. Clips multi-select (UI-local, never sent to the engine): Cmd-click toggles a clip, Shift-click selects the block from the last one, Shift-click on a layer's name its clips, Cmd+A the deck's, Esc clears. + +An effect dropped on a selected clip, the Clip tab's fields and effect parameters (values that differ show as mixed), Delete, a block drag (Alt copies) and the menus act on every selected clip as one undo step: a `batch` edit (`{"op": "batch", "edits": [...]}`) applies all its edits or none. + +- Right-click menus, each item with its shortcut and a tooltip: + - a deck (rename, duplicate, select its clips, delete) + - a column (fire, rename, select its clips, clear) + - a layer (rename, group, move up or down, select its clips, add, copy and paste effects, move clip effects to layer: a chain the clips carry alike goes to the layer, per deck where it differs, delete) + - a cell (trigger, select, select a layer's or column's clips, copy Cmd+C, paste Cmd+V, duplicate Cmd+D, clear, add an effect or a preset, copy Shift+Cmd+C and paste Shift+Cmd+V effects, remove an effect type, transport, step, transition, rename, relink, and repeat last edit Cmd+R: the last clip edit on every selected clip by effect type and parameter name, counting the clips without it) and an effect card (copy, paste, duplicate, bypass, remove); a layer's name drags onto another layer to reorder + - media or a whole folder from the browser drop onto a layer's control to fill its empty cells + - Delete removes the selected clips + - Cmd+Y redoes. + +The transport bar also has the composition file (name, an unsaved dot, Save and Open on the engine machine, Cmd+S), nudge held on - and +, /2 and x2, and the Section buttons (`cue mark`, lit while that Section plays). The middle pane has Macros, Autopilot (buttons per layer), Cues (by Section, add and remove) and History (the steps as this UI saw them). + +- The browser searches its tabs, groups media by folder and adds a clip to the selected layer on double-click +- its Effects tab lists our own effects under Ours and the pack under Classic, each effect opening into Built-in, Imported and Mine headers (only those holding presets), the search opening what it finds, and every header's open state kept in `UI.settings` +- a preset drags onto a clip, a layer, a selection, the Composition header or the chain with its settings, and a double-click adds it to the chain shown +- the Audio panel picks the engine's input and its channels (1, 2, 1+2, ...), sets Auto gain or a gain in dB with the applied gain shown live, warns about a channel that is silent or noise only, and shows the level, spectrum and onsets after gain. + +Every number field drags (Shift: ten steps), scrolls, or types after a click (up and down step). Map has Lock (select and look only) and renames slices and masks from the inspector. Styling lives in `src/ui/app/look.*` only. + +`--jpeg-previews` takes previews as a remote UI does; `--hidden` never shows the window, has no Dock icon and never activates (with `--snapshot`, for evidence on the laptop); `--snapshot FILE` renders the window to a PNG after 8 s (`--snapshot-after S`) and quits, `--snapshots N` takes N half a second apart; `--map` opens Map first (`--map-screen N` opens the Nth screen large, `--map-point K` selects corner K of its first slice, `--map-input` shows the Input view). + +### MIDI + +A controller plugs into the machine the UI runs on, and the UI turns its controls into the same edits and actions as a click, so the engine and the protocol know nothing of it. Right-click a parameter, a macro knob, a clip, a column header, a deck tab, Tap or Resync and pick MIDI learn, then move a control: a CC or the pitch bend moves a parameter or a macro (a knob or fader), a note or a CC pressed past half way fires a clip, a column, a deck, Tap or Resync (a key or a pad); Esc cancels, Forget MIDI removes one. + +A continuous control picks its target up: it moves it only once it comes within 2 % of the value the target has or crosses it, also after something else (a click, an undo, another machine) moved it, so nothing jumps; a modulator or macro driving the parameter adds to that base as before. A turn of a knob is one undo step (it ends 0.4 s after the last message), and of a burst of one control only its last value is sent. + +Settings > MIDI picks the inputs (all, off, or one) and lists the mappings with Remove; both are kept per machine in `UI.settings` (`midi`), by the show's ids, so a mapping to something another show lacks does nothing. Only an interactive UI opens MIDI inputs, and only once there are any; it follows controllers plugged in and out (`src/ui/app/midi.*`). + +### Map tab + +Every screen is a frame on one canvas with its live picture, its slices (outline through warp and corner pin) and masks (dashed), screen masks and slice masks alike. The selected slice or mask moves by dragging inside, reshapes from its corners or edge handles (an edge moves both points) and rotates from its handle (Shift snaps to 15 degrees); arrow keys nudge the selected point, or the whole shape, by one output pixel (Shift: ten). + +A slice's Warp is Off (corner pin), Bezier · corners (the 4 corners plus 2 round tangent handles each, 12 points: every edge a cubic Bezier, the interior the Coons patch of the edges, as one engine Bezier patch whose interior handles the engine derives by the Coons rule; a corner drags its two handles along, a handle bends only its own edge), or Grid 3 × 3, 4 × 4 or 5 × 5 points sampled from its current mapping: the grid points are the handles and the surface runs through them (Catmull-Rom edges with reflected end tangents, stored as the engine's Bezier anchors and edge handles under an identity corner pin, so the points and grid lines are exactly what renders); Off keeps the four outer points as the corner pin, Flatten to corners straightens the interior. + +The wheel only zooms around the cursor, a drag on empty space pans, Fit resets; double-clicking a screen's name (tree or canvas) opens it large, "← All screens", Esc or another double-click goes back; a tree row's right-click menu has Rename (also F2 or Enter on the selected screen; undoable `rename` with kind `screen`) and Open large. + +The Input view shows the selected slice's input large with every slice of that input as a coloured rectangle (its input rect): drag to move, corners and edges resize, edges snap to 0/100 % and to other rectangles (Alt: no snapping), Shift+drag on empty space draws a new slice on the selected screen, and the selected slice's overlaps are hatched. + +The inspector has the screen's output, test card and corner-target switches, the guides (composition %), and for a slice its input and input area, relative Move/Scale/Rotate, Warp, the corner pin in output pixels (or the selected grid point), soft edge (L/R/T/B, power, Auto from overlap) and + Mask on slice; for a mask its mode (inverted shows only inside), transform and points (a slice mask's in % of its input). + +Every number drags, scrolls or types, and every drag or wheel run is one undo step. While Map is open the outputs draw the guides, and a corner target through the selected point (machine-local `outputs` overlays, cleared when Map closes or the UI quits); guide positions, warps, masks, soft edges and test cards are undoable document edits. A group's settings card has a Projector choice that assigns it to a free display, creating its Screen in one step. + +On the composition monitor (also a group's or a screen's view, through its slice) the selected layer's Transform has handles: drag inside to move, a corner to scale, the round handle to rotate (Shift: 15 degrees); the first drag adds a Transform at the front of the chain. For a text clip (the selected one, or the one the layer plays) the handles move it and change its size. `--select-layer N` selects a layer for a snapshot. + +## Tests + +### Laptop runs + +On the laptop nothing a test, bench or smoke run starts shows or focuses a window: engines run `--offscreen` (no window, no activation; outputs render into textures and present to nothing, stats still count), the UI `--hidden`. `--virtual` and `--windowed` windows open behind other windows and never activate. The Mini keeps real windows. + +### Goldens + +Regenerate goldens after an intended picture change: `NOCTURNE_UPDATE_GOLDEN=1 build/nocturne_tests`, then review the PNGs in `tests/golden/`. + +### Test media + +Test clips in `tests/media/` are generated by `tests/media/make_media.sh` (needs Homebrew `ffmpeg`). Tests find them through `NOCTURNE_MEDIA_DIR` and goldens through `NOCTURNE_GOLDEN_DIR` when run outside the source tree. + +### UI tests + +`build/src/ui/nocturne_ui_tests` (protocol client and panel logic unit tests, the client over a real WebSocket, and headless smoke tests that connect the window to a harness engine, render it to `build/ui-shots/*.png`, trigger a clip, and drive the panels: effects added, reordered and removed by drag and drop, drags as one undo step, learn mode, Animate and the macro editor; the Map canvas: corner, edge, rotate with snap, interpolating warp point on the rendered surface, arrow nudge, slice mask, screen rename by F2, double-click to open large in the tree, zoom and pan, open large, auto soft edge, mask add and invert, test card, guides, corner target while dragging, the Input view, and group to projector; monitor Transform and text handles; presets by effect and group, their open state across a restart, search, and preset drags).