Content packages for Chromatik, the Java digital lighting workstation.
Play video, and run GLSL shaders, on LEDs that aren't a screen.
Chromatik renders colour onto a point cloud, not a framebuffer. Every LED is an LXPoint with a real position in 3D space, which is exactly right for a geodesic dome skinned in LED panels and exactly wrong for anything that assumes a rectangular grid of pixels.
Chromatik ships an ImagePattern for still images. It has no video player. That's the gap this repo fills.
VideoPattern decodes a video on a background thread and projects each frame onto whatever 3D structure you've modelled, sampling a colour per LED through a full UV projection. A flat wall is just the special case where every point shares a z. Two more patterns put a different source through the same projection: one mirrors the live desktop, and one renders a GLSL fragment shader on the GPU.
The Shader pattern takes a .glsl file, in whichever dialect it happens to be written. A Processing sketch's shader has no #version and writes gl_FragColor; a Shadertoy shader has no main at all, only a mainImage. Both run unmodified, because the loader puts a prologue in front rather than asking you to port anything. Every uniform the file declares becomes a knob, and saving an edit in your editor recompiles it live.
Note
Status. Video and Screen Capture are complete, M0 to M5. Shader renders, loads any dialect, hot reloads and puts its uniforms on knobs, all confirmed in-app on macOS; Linux is written but has never had a GPU under it, and Windows is not written. See the roadmap.
No build tools required. You need Chromatik and nothing else.
Two files: Core, which carries the video engine both patterns share, plus whichever pattern you want.
-
Download from the latest release: the Core file for your computer, and one or both patterns.
File Core, Mac (any, Apple Silicon or Intel) chromatik-core-<version>-macos.jarCore, Windows chromatik-core-<version>-windows.jarCore, Linux (Intel/AMD) chromatik-core-<version>-linux-x86_64.jarCore, Linux (ARM, e.g. Raspberry Pi) chromatik-core-<version>-linux-arm64.jarVideo, plays a file chromatik-video-<version>.jarScreen Capture, mirrors the desktop chromatik-screen-<version>.jarShader, runs a .glslfile, Macchromatik-shader-<version>-macos.jarShader, Windows chromatik-shader-<version>-windows.jarShader, Linux (Intel/AMD) chromatik-shader-<version>-linux-x86_64.jarShader, Linux (ARM) chromatik-shader-<version>-linux-arm64.jar -
Drag each onto the Chromatik window. Chromatik installs them for you.
-
In the CONTENT tab, click Reload Package Content.
-
Add a pattern to a channel: category Laserphile, pattern Video or Screen Capture.
The Mac Core download carries both Apple Silicon and Intel builds, so there's nothing to check first. The pattern files are identical on every platform.
Chromatik has no way to express that one package needs another, so a pattern installed without Core will list itself and then refuse to load, saying which package is missing. Install Core and restart.
Important
Upgrading from v0.1.0? Delete chromatik-video-0.1.0-*.jar from your packages folder first. That release was a single jar carrying everything, so it isn't replaced by any of the files above: it sits alongside them, and Chromatik loads both, giving two packages of the same name and a duplicate of every class. Saved projects are unaffected, since the pattern's name and controls are unchanged.
projects/demo.lxp is a 30x30 grid with a Video pattern already on it, pointed at projects/demo-bars.mp4: colour bars over a grey ramp over a block that sweeps across the loop, so the playhead, looping and speed are all readable at a glance.
Download both, put the clip in ~/Chromatik/LaserphileVideo/, then open the project. The grid is 900 points, which is under the 1,000 that Chromatik's FREE tier will drive, so it runs real fixtures and not just the preview.
For the Shader pattern there is a second one: projects/demo.lxp, the same grid with a Shader pattern on it pointed at party_blob.glsl. Copy the six shaders in packages/chromatik-shader/shaders/ into ~/Chromatik/LaserphileShader/, then open the project. That folder is also where Browse opens by default, so anything you put there is one click away.
Prefer to place the file yourself?
Drop the .jar in your Chromatik packages folder and restart the app. Chromatik creates the folder the first time it runs.
| macOS | ~/Chromatik/Packages |
| Windows | C:\Users\<you>\Chromatik\Packages |
| Linux | ~/Chromatik/Packages |
The Screen Capture pattern renders black
Screen capture needs the operating system's permission, and Chromatik has to be the application that holds it. On macOS that's System Settings โ Privacy & Security โ Screen & System Audio Recording: switch Chromatik on, then restart it, since the permission is only picked up at launch.
Without the grant the capture device opens and then waits on a first frame the OS never sends, so the pattern renders black rather than reporting an error. After five seconds of that, the log in ~/Chromatik/Logs says so.
On Linux this needs an X11 session; a Wayland session has no X screen for FFmpeg to grab.
Every release is loaded on real hardware of each platform before it ships, so the FFmpeg natives are known to load and decode on all four. Installing and playing end to end inside Chromatik is exercised on macOS, so please open an issue if another platform misbehaves.
| Module | Package | Category in Chromatik | What it does |
|---|---|---|---|
packages/chromatik-core |
laserphile.chromatik.core |
no patterns of its own | The projection stage, the frame pipeline, and the bundled FFmpeg decode stack |
packages/chromatik-video |
laserphile.chromatik.video |
Laserphile โ Video | Plays a video file onto the model |
packages/chromatik-screen |
laserphile.chromatik.screen |
Laserphile โ Screen Capture | Puts the live desktop onto the model |
packages/chromatik-shader |
laserphile.chromatik.shader |
Laserphile โ Shader | Renders a GLSL fragment shader onto the model |
packages/chromatik-mcp |
laserphile.chromatik.mcp |
no patterns, a plugin | Lets an AI agent read and compose the show over MCP |
A Maven multi-module build, one module per Chromatik content package. Chromatik discovers packages by scanning ~/Chromatik/Packages/*.jar for a root lx.package file, so one jar is exactly one package and every plugin needs its own module. The root pom.xml is the parent: it holds the compiler settings, the provided LX dependencies, the lx.package filtering, the shade config, and the install profile, so a new module is a ~15-line pom.
Why a core package rather than a library each plugin bundles. Chromatik hands every jar in its packages folder to one shared class loader, and registers every public class it finds against the package that supplied it, logging an error for any name it has already seen. That isn't limited to patterns: 328 of the 332 classes it registers are org.bytedeco. Two plugins each carrying FFmpeg would mean 328 errors the moment both were installed, and a second 43 MB download. So the decode stack lives in one jar and the plugins take it at provided scope, which is why they weigh 12 KB. The same shared class loader is what lets them find it at runtime.
The repo is named for what it's growing into. Sibling laserphile.chromatik.* packages land alongside these as they're built, see docs/ADDING-A-PLUGIN.md.
- Model-agnostic projection. Works on any
LXModel: domes, sculptures, strips, matrices. Nothing assumes a grid. - Never blocks the engine. All decode and colour conversion happen off the LX engine thread.
run()does a lock-free read and a tight per-point loop. - Full transport. Play/pause, loop, 0.1x to 4x speed, and a two-way position slider you can scrub. Looping is gapless and scrubbing coalesces, so a fast drag doesn't queue up a hundred seeks.
- Live screen capture. A second pattern, Screen Capture, puts the desktop onto the LEDs in real time through the same projection controls. It keeps only the newest frame, so nothing buffers latency in between, and it carries no transport, because a live desktop has no playhead to move. Needs screen-recording permission for Chromatik.
- Full projection control. Yaw, pitch, roll, translate on three axes, scale, per-axis stretch, and scroll.
- Four wrap modes.
CLAMP,CLIP,TILE,MIRROR, matching the vocabulary of the built-inImagePattern. - Transparent background.
CLEARlets lower LX layers show through where the image doesn't reach. - Bilinear sampling. Cuts the shimmer you get when a sparse point cloud samples a small texture.
- Native file chooser. A
Browsebutton opens the real OS open dialog, no typing paths. It opens on the current file's folder, or on the folder you browsed to last, and for shaders on the plugin's own folder where the demos live. Files under~/Chromatikare stored as relative paths so a shared project still finds them. - GLSL shaders, in whichever dialect they were written. Processing-era files with no
#versionand Shadertoy files with nomainboth run untouched, because the loader wraps rather than rewrites. Eachuniformbecomes a knob, with its range taken from a trailing@rangecomment. - Live shader editing. Save the file and it recompiles, no button. A save that will not compile leaves the previous shader running and reports the compiler's message rather than going dark.
- FREE licence tier throughout. No
LXPluginanywhere, which is what that tier gates. Shader supplies its own device panel by implementingUIDeviceControls, which Chromatik checks before it consults the plugin registry, so even the custom UI needs no licence.
chromatik-mcp runs a Model Context Protocol server inside
Chromatik, so an agent such as Claude Code can look at the rig and build a show on it.
It installs differently from the other packages. Dropping the jar in is not enough: a plugin has to be enabled before it runs.
- Put
chromatik-mcp-<version>.jarin~/Chromatik/Packages. - Chromatik โ Preferences โ Plugins, tick Chromatik MCP.
- Restart Chromatik. The log prints the URL, and
~/Chromatik/LaserphileMCP/server.jsonholds it too. claude mcp add --transport http chromatik http://127.0.0.1:3579/mcp
Port 3579 by default, and the next free port above it if something already has that one. Override
with -Dlaserphile.mcp.port or LASERPHILE_MCP_PORT.
Eight tools. Everything is addressed by canonical LX path, so the output of one call is the input to the next.
| Tool | What it does |
|---|---|
lx_project |
The model's size and bounds, engine and tempo, output state, every channel |
lx_catalog |
Which patterns, effects and modulators are installed, with class names |
lx_docs |
What a component's parameters mean: path key, label, Java field, range, enum options |
lx_device |
One component's live values |
lx_set |
Set parameters in a batch. Undoable |
lx_add |
Add a channel, pattern or effect. Undoable |
lx_undo |
Step back through Chromatik's own undo history |
lx_look |
A PNG of what the rig is showing, plus brightness and coverage statistics |
Writes go through LXCommand, so an agent's work lands in Chromatik's undo history and Cmd-Z
reverses it exactly as if it had been clicked.
lx_docs earns its place on this repo's own plugins. A parameter's path key, its Java field name
and its panel label can all differ: the field wrapMode is keyed wrap and labelled "Wrap", and
fileName is keyed file. Paths use the key, so an agent reading the table further down this
README and guessing would build a path that does not resolve. lx_docs gives all three, plus the
enum option names, which appear nowhere in Chromatik's OSCQuery output.
Warning
There is no authentication. The server binds 127.0.0.1 only and validates the Origin header,
and it deliberately offers no option to bind anywhere else. Anything that can reach the port can
drive the rig and edit the project.
The server never raises output brightness or enables output; it can only lower them. Point it at a project you have saved.
Only for building from source. Installing a release needs none of this.
| Version | Notes | |
|---|---|---|
| JDK | 21 | Temurin recommended, to match Chromatik's own runtime |
| Maven | 3.9+ | |
| Chromatik | 1.2.1 | Pinned via lx.version in the root pom.xml |
Setting up JDK 21 on macOS
brew install --cask temurin@21
brew install maven
echo 'export JAVA_HOME="$(/usr/libexec/java_home -v 21)"' >> ~/.zshrc
echo 'export PATH="$JAVA_HOME/bin:$PATH"' >> ~/.zshrcOpen a new shell, then confirm all three agree:
java -version # 21.x
javac -version # 21.x
mvn -version # should report the Temurin 21 JAVA_HOMEbrew install openjdk@21 works too and skips the admin password prompt, but it's keg-only, so JAVA_HOME has to point at /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home by hand.
git clone https://github.com/moonbase-labs/chromatik-plugins.git
cd chromatik-plugins
# Build for this Mac, into packages/*/target/
mvn package
# Build and drop into ~/Chromatik/Packages
mvn -Pinstall installThen:
- Launch Chromatik. The log should show
Loading package content from: โฆfollowed by aPackage:Laserphile Videoline. - Add the pattern to a channel: category Laserphile, pattern Video.
- Pick a video. Click
Browse, or type a path into theFilebox. Either way the decode thread restarts on the spot.
A plain mvn package builds the macOS jar, which is the one this repo is developed against. Pass a profile to build for somewhere else:
mvn package -Pdist-windows
mvn package -Pdist-linux-x86_64
mvn package -Pdist-linux-arm64Only chromatik-core varies by platform, so a profile changes that jar and leaves the two pattern jars alone. There's no cross-compilation involved, the FFmpeg native is an ordinary Maven dependency, so any machine can build any target.
Check the jars before shipping them. The core jar goes on the classpath, because it has the natives; the pattern jars are arguments, so each is opened and checked in its own right rather than whichever the classpath happened to reach first. Same gate CI runs on real hardware of every platform:
java -cp packages/chromatik-core/target/chromatik-core-*-macos.jar \
ci/NativeLoadCheck.java \
packages/chromatik-video/target/chromatik-video-*.jar \
packages/chromatik-screen/target/chromatik-screen-*.jarTip
Relative paths resolve under ~/Chromatik/, absolute paths are used verbatim. Browse already stores anything under ~/Chromatik as a relative path, which keeps a .lxp project working when someone else opens it.
Important
On Chromatik's FREE tier, Art-Net, sACN, DDP, and OPC drive real fixtures for models up to 1,000 points, with rendering capped separately at 20,000. Develop and test against the 3D preview and you stay well inside both. Go over the output cap and Chromatik holds output back for as long as the model stays over, logging Network output is disabled due to license restrictions. Rigs above 1,000 points want a paid tier or an external output server.
Chromatik generates each panel from these automatically. They are listed in panel order, which is the order the panel reads left to right, top to bottom. The two patterns share the projection controls and differ in everything else.
| Parameter | Type | Default | Range | Description |
|---|---|---|---|---|
Level |
Compound | 1 |
0 to 1 | Master brightness |
Speed |
Compound | 1 |
0.1 to 4 | Playback rate. Affects the playhead only, never the decode rate |
Scale |
Compound | 1 |
0.1 to 10 | Zoom, larger values zoom in |
ScrollX ScrollY |
Compound | 0 |
-1 to 1 | UV offset, animate for a pan |
Yaw |
Compound | 0 |
-180 to 180 | Rotation about the vertical axis |
Roll |
Compound | 0 |
-180 to 180 | Rotation about the view axis |
Gamma |
Compound | 1 |
1 to 3 | Pulls mid-tones down. Around 2.2 undoes video's own brightness curve, which is what an LED usually wants |
XScale YScale |
Compound | 1 |
0.1 to 10 | Per-axis stretch on top of Scale |
Pitch |
Compound | 0 |
-180 to 180 | Rotation about the horizontal axis |
TransX TransY TransZ |
Compound | 0 |
-1 to 1 | Shift the image on each axis |
Wrap |
Enum | CLAMP |
CLAMP CLIP TILE MIRROR |
Sampling behaviour outside the image |
Background |
Enum | BLACK |
BLACK CLEAR |
Colour for points rejected by CLIP |
Interp |
Enum | BILINEAR |
NEAREST BILINEAR |
NEAREST is blocky, BILINEAR is smoother |
Position |
Compound | 0 |
0 to 1 | Playhead. Follows playback, and seeks when you drag it |
Play |
Boolean | on |
Run the playhead | |
Loop |
Boolean | on |
Start again on reaching the end | |
Restart |
Trigger | Jump back to the start and play | ||
Res |
Discrete | Auto |
128 256 384 512 Auto |
Longest edge to decode frames at. Auto follows the model's point count. Never enlarges a smaller source |
Browse |
Trigger | Pick a video with the native file chooser | ||
Reload |
Trigger | Re-open the current file | ||
File |
String | empty | Absolute path, or relative to ~/Chromatik. Written by Browse and saved with the project, no control of its own |
Every Compound parameter is modulatable, so any of them can be driven by an LFO, an envelope, or MIDI.
The same projection controls, minus everything that needs a timeline. Speed, Position, Play, Loop and Restart are absent because a live desktop has no playhead to move, and the file controls are absent because there is no file.
| Parameter | Type | Default | Range | Description |
|---|---|---|---|---|
Level |
Compound | 1 |
0 to 1 | Master brightness |
Scale |
Compound | 1 |
0.1 to 10 | Zoom, larger values zoom in |
ScrollX ScrollY |
Compound | 0 |
-1 to 1 | UV offset. With Scale, this is how you crop into part of the desktop |
Yaw Roll |
Compound | 0 |
-180 to 180 | Rotation about the vertical and the view axis |
Gamma |
Compound | 1 |
1 to 3 | Pulls mid-tones down. Around 2.2 undoes the display's own brightness curve |
XScale |
Compound | 1 |
0.1 to 10 | Horizontal stretch on top of Scale. The one to reach for fitting a 16:10 desktop |
Freeze |
Boolean | off |
Hold the current frame instead of following the screen | |
YScale |
Compound | 1 |
0.1 to 10 | Vertical stretch on top of Scale |
Pitch |
Compound | 0 |
-180 to 180 | Rotation about the horizontal axis |
TransX TransY TransZ |
Compound | 0 |
-1 to 1 | Shift the image on each axis |
Wrap |
Enum | CLAMP |
CLAMP CLIP TILE MIRROR |
Sampling behaviour outside the image |
Background |
Enum | BLACK |
BLACK CLEAR |
Colour for points rejected by CLIP |
Interp |
Enum | BILINEAR |
NEAREST BILINEAR |
NEAREST is blocky, BILINEAR is smoother |
Screen |
Discrete | 0 |
0 to 3 | Which display to capture. Ignored on Windows, which captures the whole desktop |
Cursor |
Boolean | on |
Include the mouse pointer | |
Res |
Discrete | Auto |
128 256 384 512 Auto |
Longest edge to capture at. Auto follows the model's point count. Never enlarges |
There is no capture-rate control: the capture runs at the engine's own frame rate, capped at 60, since there is nothing to gain from grabbing the screen faster than the renderer consumes it.
Screen, Cursor and Res are read when the capture device opens, so changing any of them reopens it. The engine frame rate is not watched the same way, because it is a slider and reopening per drag increment would stall the capture for the length of the drag; a new rate applies the next time the device opens, which includes switching the pattern off and on.
The projection controls again, plus a clock, plus whatever the shader itself declares.
| Parameter | Type | Default | Range | Description |
|---|---|---|---|---|
| (the shader's uniforms) | Compound / Boolean | per file | 0 to 1 | One control per uniform the file declares, named after it. See below |
Speed |
Compound | 1 |
0 to 4 | How fast the shader's clock advances. 0 holds it still |
Level |
Compound | 1 |
0 to 1 | Master brightness |
Play |
Boolean | on |
Advance the shader's clock | |
Scale ScrollX ScrollY Yaw Roll Gamma XScale YScale Pitch TransX/Y/Z Wrap Background Interp |
Exactly as on the other two patterns | |||
Res |
Discrete | Auto |
128 256 384 512 Auto |
Edge length to render at. Auto follows the model's point count |
Browse |
Trigger | Pick a .glsl file. Opens in ~/Chromatik/LaserphileShader/ |
||
Reload |
Trigger | Read and compile the file again | ||
File |
String | empty | The chosen path. Saved with the project, relative to ~/Chromatik where possible |
|
Error |
String | empty | What the compiler said about the last file that would not build |
Rendering happens on a GPU, in an offscreen context the pattern creates for itself, on the same background thread the video decoder would use. The clock is driven by the engine rather than read off the wall, which is what lets Speed scale it and Play hold it without the renderer knowing either control exists.
Every uniform the file declares becomes a control named after it, except the ones the plugin drives itself: time, resolution, and their Shadertoy spellings iTime, iResolution and iFrame.
A knob holds a 0 to 1 position, and the range is applied on the way to the shader. Unannotated, that range is 0 to 1. A trailing comment says otherwise:
uniform float depth; // @range(0.5, 4) @default(2)
uniform float rate; // @range(0.25, 2) @default(1)
uniform bool sharp; // @default(0)Storing the position rather than the value is what makes widening a @range non-destructive: the knob stays where it was instead of jumping. A bool becomes a switch, float and int become knobs, and anything larger, a vec or a mat, gets no control, because there is no sensible way for one knob to hold it.
Getting the defaults right matters more than it sounds. Left at zero, monjori divides by one of them and three of the six shipped shaders render a flat frame.
The file is never rewritten, only surrounded, and a #line directive puts the numbering back so a compile error names the line you are looking at.
| The file has | What happens |
|---|---|
No #version |
One is injected |
gl_FragColor |
Aliased to a core-profile output |
texture2D / textureCube |
Aliased to texture |
void mainImage(out vec4, in vec2) and no main |
Wrapped in the main Shadertoy would have supplied |
Its own #version |
Kept, with the prologue slotted in behind it |
| Neither entry point | Rejected, saying so. A vertex shader looks like this |
Entry points are matched as definitions rather than as words, so a file carrying #define mainImage main left over from a hand port is not mistaken for a Shadertoy shader and made to call itself.
The chosen file is watched. Save it and it recompiles, with no button to press. A save that will not compile leaves the previous shader running and puts the compiler's message in Error: a typo costs the edit, not the output.
An edit has to hold still across two checks a quarter-second apart before it is read, because editors do not write files atomically and half a file is not a syntax error worth reporting.
A MIDI surface binds its eight device knobs to the first eight of the pattern's remote controls, and an APC40 has no way to page past the eighth. So those eight are all continuous, and they are the first eight controls in the panel, in the same order:
| Knob | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Video | Level |
Speed |
Scale |
ScrollX |
ScrollY |
Yaw |
Roll |
Gamma |
| Screen Capture | Level |
Scale |
ScrollX |
ScrollY |
Yaw |
Roll |
Gamma |
XScale |
| Shader | uniform 1 | โฆ | up to 6 | Speed |
Level |
Scale |
ScrollX |
ScrollY |
Video and Screen Capture line up except at knob 2, where Screen Capture has no Speed to spend the slot on, so everything shifts up one and XScale reaches the row.
Shader is the one that breaks the pattern, deliberately. What is worth playing on a shader is the shader, so its own uniforms take the knobs from the left, and Speed and Level follow them. The row above shows a shader declaring six or more; one declaring two puts Speed on knob 3 and Level on knob 4. Six is the cap, so that Speed and Level always reach a knob no matter how much a file declares. A bool uniform becomes a switch and is pushed past the continuous ones wherever it was declared, since a switch inside the first eight costs a knob outright.
Because the set changes with the file, Shader is also the only pattern here whose knob layout is not fixed. Its device panel is its own for the same reason: Chromatik's default one reads a device's controls once and never looks again, so a knob created when a shader loads would never appear.
Pitch is the rotation left off the knobs. A projection sweeps it least of the three, and the freed slot goes to Gamma, which is what you reach for once the video is on real LEDs. Pitch is still on the panel and still mappable by hand.
The surface order carries on down the panel from there, so the nth control you read is the nth a surface sees.
Controls held back from the surface entirely sit at the end of the panel, which is what keeps everything ahead of them lined up. On Video that is Res, Browse, Reload and File. On Screen Capture it is Screen, Cursor and Res. On Shader it is Res, Browse, Reload, File and Error. Browse, Reload, File and Error go to disk or come back from it; the rest tear down the current source and open another, and both a screen device and a GL context take real time to build, so a swept knob would thrash them.
Two threads, one hand-off, no locks on the hot path.
flowchart LR
subgraph decode["๐๏ธ Decode thread (blocking is fine)"]
direction TB
SRC["FileVideoSource<br/><i>FFmpeg via JavaCV</i>"]
FRM["VideoFrame<br/><i>ARGB + stream time</i>"]
SRC --> FRM
end
RING[("FramePipeline<br/>bounded ring, 8 frames")]
BOX[["Mailbox<br/><i>seek ยท loop</i>"]]
subgraph engine["โก LX engine thread (must never block)"]
direction TB
RUN["VideoPattern.run(deltaMs)"]
CLK["PlaybackClock<br/><i>playhead</i>"]
PRJ["Projector<br/><i>UV projection + sampling</i>"]
COL["colors[point.index]"]
RUN --> CLK --> PRJ --> COL
end
FRM -- "publish (blocks when full)" --> RING
RING -. "frameFor(streamTime)" .-> RUN
RUN -- "transport" --> BOX
BOX -. "serviced between frames" .-> SRC
COL --> OUT(["LX output<br/>OPC / Art-Net / sACN"])
style decode fill:#1e1b4b,stroke:#6366f1,color:#e0e7ff
style engine fill:#422006,stroke:#eab308,color:#fef3c7
style RING fill:#064e3b,stroke:#10b981,color:#d1fae5
style BOX fill:#3b0764,stroke:#a855f7,color:#f3e8ff
The decode thread owns the FFmpeg grabber and pushes finished frames into a small bounded ring. The engine picks the newest frame that's due at the current playhead and leaves the rest. A full ring is the only brake on decoding, which makes back-pressure fall out for free: pausing or playing below 1x stalls the decode thread on the ring, and playing above 1x drains it so decode runs flat out to keep up. If decode still can't keep up, the engine re-projects the newest frame it has and the playhead carries on, so media time stays honest and frames are dropped instead.
Control flows the other way through a mailbox the decode thread reads between frames. Seeks coalesce to the newest target and carry a generation number, so a fast scrub never flashes footage from a position you've already dragged past. Looping happens entirely on the decode thread, so the seam is gapless: it stamps every frame with a timeline that keeps climbing straight through the loop point, which is the same timeline the playhead runs on.
Frames are decoded no larger than Res says, which on Auto comes from the model's point count. Sampling a few thousand points out of a 4K frame is wasted work, and the scaler is already converting every frame so resizing rides along in the same pass. What comes out is small enough to stay in cache while the projection walks it.
Colour is put right on the way out. Video stores brightness and two colour-difference channels, and turning those into red, green and blue needs one set of coefficients for standard-definition footage and another for high-definition. The file says which; JavaCV never passes that answer to the scaler, so every file is decoded as if it were standard definition. That costs up to 32 of 255 on a strongly coloured pixel and nothing at all on a grey one, which reads as a hue and saturation shift rather than as anything obviously broken. The scaler's error is a fixed linear mix of the RGB it produced, so it is undone with another one, per decoded pixel on the decode thread. A channel that arrives sitting on 0 or 255 is left alone: the scaler clamped it before we saw it, and correcting anyway lifts green off a pure magenta bar and turns it dirty.
The projection maths
Per point, in normalised model coordinates so nothing depends on the model's real-world size:
c = (xn-0.5, yn-0.5, zn-0.5) - (translateX, translateY, translateZ)
r = transpose(R) * c // inverse-rotate into the texture plane
u = r.x * invScaleX + 0.5 + scrollX
v = r.y * invScaleY + 0.5 + scrollY
// r.z is dropped: the projection is orthographic
R = Rz(roll) * Ry(yaw) * Rx(pitch), built once per frame in ProjectionParams.recompute() along with the reciprocal scales, so the per-point loop does no trig and no division.
(u, v) then goes through the wrap mode, and the result is sampled nearest or bilinear. Points that CLIP rejects take the background colour.
This is re-implemented from the documented behaviour of Chromatik's ImagePattern, not copied from it. Chromatik is proprietary; the algorithms aren't.
Source layout
packages/chromatik-core/src/main/java/laserphile/chromatik/core/, shared by every plugin and public only where a plugin actually reaches it:
| File | Thread | Role |
|---|---|---|
FrameSource.java |
decode | Interface. The seam that lets a file and a live screen share one pipeline and one projector. |
FramePipeline.java |
both | Owns the decode thread and the buffer: a ring for a file, one slot for a live source. Idempotent start/stop with a bounded join. |
VideoFrame.java |
both | A decoded frame plus its place on the timeline. |
ProjectionControls.java |
engine | The 16 projection controls every pattern shares, plus the snapshot-and-project call. |
ProjectionParams.java |
engine | Per-frame snapshot of the controls, with the rotation matrix and the tone curve precomputed. |
Projector.java |
engine | The per-point UV projection and sampling loop. Package-private: ProjectionControls is its only caller. |
ColorSpaceCorrection.java |
decode | Undoes the decoder's fixed BT.601 conversion on high-definition footage. |
WorkingResolution.java |
decode | How big to decode, and how to make a grabber produce that size. |
packages/chromatik-video/โฆ/video/ and packages/chromatik-screen/โฆ/screen/, one pattern each:
| File | Thread | Role |
|---|---|---|
VideoPattern.java |
engine | Orchestrator for file playback. Owns the transport, drives the clock and pipeline. |
FileVideoSource.java |
decode | Wraps FFmpegFrameGrabber. Video track only, so the audio is never decoded or seeked. |
PlaybackClock.java |
engine | The playhead. Pure state: play, speed, and the pending seek target, no I/O. |
ScreenCapturePattern.java |
engine | Orchestrator for the live desktop. No transport, because there is no timeline. |
ScreenCaptureSource.java |
capture | The desktop through FFmpeg's per-OS capture device. Live, no timeline. |
- M0 Decode spike. Benchmarked JavaCV/FFmpeg on real footage: ~4,450 fps at 384ร216, against the ~60 needed.
- M1 Skeleton. Package loads in-app, decode thread runs, engine stays non-blocking.
- M2 Projection MVP. Full UV projection, all four wrap modes, both background modes, nearest and bilinear.
- M3 Transport. Playback clock, play/pause, loop, speed, seek and scrub, ring buffer with back-pressure and a real drop policy.
- M4 Screen capture. A
Screen Capturepattern of its own, on FFmpeg's per-OS capture device, behind the existingFrameSourceseam with single-slot live buffering. - M5 Polish. BT.709 colour-space correction,
Gamma, working-resolution downscale, recoverable decode errors, a one-click demo project, and an uber-jar down from 2,314 classes to 473. (Per-OS build profiles landed early, with the release pipeline. Frame pooling was dropped: the downscale already keeps frames small enough that it earned nothing.)
The Shader pattern has its own run, numbered separately because it is a different feature rather than a later stage of the same one:
- S0 Offscreen context spike. Established that Chromatik offers no OpenGL to borrow, and that CGL makes a context off the main thread with no window. See
docs/milestones/shader/S0-context-spike.md. - S1 Skeleton. The shader as a
FrameSource, reusing the pipeline and the projection controls unchanged. - S2 Loading. Dialect normalisation, entry-point detection, and hot reload that survives a broken save.
- S3 Uniforms. A control per declared uniform,
@rangeannotations, and a device panel of its own so they can appear at all. - S4 Linux, and CI. GLX onto a pbuffer, plus a release gate for the shader jar's shape.
- S5 Windows. Needs a real window to hang a device context on, which through LWJGL's bindings means a registered window class, a native window procedure and a hand-assembled struct. Unwritten rather than half-written: it reports itself unsupported, and the other three platforms are unaffected.
Design decisions, open questions, and per-milestone detail live in docs/: PLAN.md is the source of truth, PROGRESS.md tracks state and carries the decisions log.
mvn package # build core and every plugin
mvn -Pinstall install # build, then copy into ~/Chromatik/Packages
mvn -pl :chromatik-video -am package # just one plugin, and the core it needs
mvn -Pinstall install -pl :chromatik-video -am # build and install both-am ("also make") is not optional for a plugin: it pulls chromatik-core into the same reactor, and without it Maven cannot resolve the dependency unless a matching core is already in ~/.m2. Installing a plugin without its core into ~/Chromatik/Packages gets you a pattern that lists itself and then refuses to load.
Chromatik reloads a rebuilt package from the CONTENT tab: Reload Package Content, or leave Auto-Reload Packages on.
Adding a plugin is four files and one line in the root pom: docs/ADDING-A-PLUGIN.md.
Tagging publishes. CI builds all four platform jars, loads each one on real hardware of its platform, then attaches them with checksums.
git tag v0.1.0 && git push origin v0.1.0Versions are semver: vMAJOR.MINOR.PATCH, optionally with a -prerelease suffix. A tag that isn't fails the build before anything is published, and a -prerelease tag (v0.2.0-rc.1) is marked as such on GitHub so it stays out of "latest release". Build metadata (+) is rejected: semver ignores it for precedence and it mangles download URLs.
The tag is the only place a release version lives. The pom stays on -SNAPSHOT naming the release it's heading for, and CI overwrites it at build time so the jar reports a real version through lx.package.
What counts as breaking, for a Chromatik package, is compatibility with saved .lxp projects, since those store the pattern's class name and its parameter paths:
| Means | |
|---|---|
| MAJOR | A saved project won't reload cleanly: a parameter renamed or removed, the pattern class renamed, or mediaDir changed. |
| MINOR | New parameters, new patterns, a newly supported platform. Existing projects unaffected. |
| PATCH | Fixes and performance work with no change to the parameter surface. |
While MAJOR is 0 this is pre-1.0, so a MINOR bump is allowed to break things. Reaching 1.0 is the promise not to.
A few things worth knowing before you touch the code:
Caution
Any enum used with an EnumParameter must be public, and so must its enclosing class. LX reflects on the enum's values() from another package. A package-private enum compiles cleanly and then throws IllegalAccessException the moment the pattern is instantiated. That's why ProjectionParams and its nested enums are public.
lx.versionis pinned deliberately. LX and GLX areprovidedscope: Chromatik supplies them at runtime through itsLXClassLoader. If the pinned version drifts from the installed app, the API mismatch shows up at runtime, not at compile time.- Never relocate the
org.bytedecopackages in the shade config. JavaCPP looks its native libraries up as classpath resources by literal path, so relocating those packages breaks native loading in a way that's genuinely unpleasant to debug. - The first
FFmpegFrameGrabber.start()per JVM costs about 6 seconds while JavaCPP extracts natives to~/.javacpp/cache. Every subsequent start is ~2 ms. It happens on the decode thread, so the UI stays responsive. swscalelogsno accelerated yuv420p->bgr24on startup. Benign: it means the conversion runs unvectorised, not that anything is wrong with it. It's unrelated to the colour-matrix correction, which is applied after the scaler rather than inside it.- The scaler can't be configured through JavaCV.
FFmpegFrameGrabberonly ever callssws_getCachedContext,sws_scaleandsws_freeContext, neversws_setColorspaceDetails, and it keeps its scaler context private. Going around it through a libavfilter graph needs the undecoded frame, andImageMode.RAWkeeps only the first ofyuv420p's three planes, because a JavaCVFramecarries one stride and the format needs three.
This project is MIT licensed. See LICENSE.
Important
The built jar is not purely MIT. It bundles FFmpeg via JavaCV / Bytedeco, and the default Bytedeco FFmpeg build is LGPL 2.1+. The MIT licence covers the source in this repository. If you redistribute a built jar, you're also redistributing LGPL binaries and take on the LGPL's obligations, notably keeping the FFmpeg portions LGPL and letting recipients replace them.
The -gpl classifier variants of the FFmpeg artifact are full GPL and would be far more restrictive. This project deliberately uses the default LGPL build, and you should keep it that way unless you've thought hard about the consequences.
Built for TeleCortex, a 2V icosahedron geodesic dome skinned in hand-made coreflute LED panels: roughly 5,725 individually addressable APA102/SK9822 LEDs driven by five Teensy microcontrollers, built to be shown at Blazing Swan in Western Australia.
The dome has played video before, on the bespoke Python and JavaScript stacks that preceded this. VideoPattern generalises that trick onto a platform with a real mixer, a real pattern engine, and a real UI.
- Chromatik and the open-source LX engine, by Mark Slee / Heron Arts.
- Laserphile (Derwent) for TeleCortex and the LED-control lineage this builds on.
- Moonbase Labs, a Perth collective creating experiences with light, sound, and technology.