MUI — a styled tree in, pixels and gestures out
MUI (Matari-UI) is the UI foundation for Matari Audio
plugins. You write a tree the way you would write CSS flexbox with tokens; MUI
lays it out intrinsically, turns every .union group into one filleted outline,
derives every shell as a true parallel inset of the outline before it, colours
every surface from a role palette, and hands a z-ordered paint list to Vello.
Layout is intrinsic: a float names a region around another node and
a slider thumb sits where two flex weights put it; explicit offsets are available when needed. No runtime style
strings: there is no .class("btn btn-sm") and there will not be one --
every value in the DSL is a Rust expression the compiler already checks.
Every crate is #![forbid(unsafe_code)] except mui-baseview and
mui-truce, which deny it and allow a few blocks: the wgpu surface on the
native window, Send for the window handle and the host's parent handle.
Every library crate compiles to wasm32-unknown-unknown; the native preview
host, mui-baseview's window and the example plugin do not. The
geometry, layout, style and motion crates stay small -- mui-motion has no
dependencies at all -- but the renderer, text and accessibility stack does
not: Vello, wgpu, harfrust, accesskit and truce put about 440 packages in
Cargo.lock.
Try it in your browser
Open the MUI playground to edit welded
shapes, curved partitions, cutouts and layouts with immediate visual feedback.
It accepts a documented subset of Rust builder expressions and runs the actual
MUI scene engine with Vello CPU compiled to WebAssembly in a worker. It does
not compile arbitrary Rust or measure native GPU performance. Edits stay in your
browser; shared links carry the source in their URL fragment.
Run locally with ./playground/build.sh (requires the WASM Rust target and
wasm-bindgen-cli 0.2.128), then serve playground/ with any static web server.
Under the hood
MUI owns layout, contours, input, animation and the paint list. Vello renders it:
mui-vello provides CPU rendering (vello_cpu) and GpuRenderer, a retained
renderer over classic Vello on wgpu 30. The host must keep the GpuRenderer
alive between frames to benefit from its caches. An unchanged
frame into the view they presented last records no GPU pass; a swapchain hands
out a new view per frame, so there the host saves the pass by not asking.
Plugin windows also have an automatic software fallback: Vello CPU rasterizes
into native pixel buffers without opening a wgpu device. MUI_RENDERER=cpu
forces that path for diagnostics. It retains unchanged frames and uses one CPU
thread; changed frames repaint fully. See the plugin host contract.
The classic-versus-Hybrid measurements that led here (history: MUI shipped on
vello_hybrid first) live in the
rendering investigation, including their
limits.
Contours are layout too
Use .union(fill) for a shared outline, .inside(padding) to partition a parent's
contour, .bend(amount) for a curved divider, and .cut(shape) / .keep(shape)
for boolean regions. Borders and parallel shells follow the resulting outline.
BorderRamp gives a shared contour continuous color while identifying its tabs.
See the shape-layout guide for the full contracts and
composable examples; the browser playground supports a smaller, explicit subset.
See the complete-controls guide for default focus
states, measured readouts, metadata-backed parameter controls, and intrinsic
settings/groups with accessible names and descriptions.
One frame
use mui::prelude::*;
let mut ui = Ui::default();
let mut cutoff = 0.5;
let mut bypass = false;
// Built every frame, like an immediate-mode tree. Widgets read last frame's
// gesture on their id, so state lives in your own variables.
let bypassed = toggle(&mut ui, "bypass", "Bypass", &mut bypass).size(S).tip("Bypass the filter");
let root = col![
row![title("Filter"), spacer(), bypassed].center(),
slider(&mut ui, "cutoff", "Cutoff", &mut cutoff, 0.0..=1.0).value_text(format!("{:.0} Hz", cutoff * 20e3)),
meter(&mut ui, "level", cutoff),
]
.gap(M)
.pad(L)
.radius(20)
.fill(Role::Surface)
.shadow(Shadow::soft(12.0));
let frame = ui
.frame(root, Some(Size::new(280.0, 120.0)), Input::default(), 1.0 / 60.0)
.unwrap();
assert!(frame.scene.paint.len() > 5);
// mui::vello::paint(&mut Gpu { scene, resources, cache, atlas: None }, frame.scene, Affine::IDENTITY)?;
Ui::frame takes an Input — pointer, wheel, key presses and typed text —
advances gestures and springs, styles the tree by state (hover and press are
mixed into a named node's fill through a spring), resolves layout and
geometry, and rebuilds the hit regions from the paths it painted, so what
responds and what you see cannot drift apart. A PointerInput converts into
an Input, so a pointer-only host passes one unchanged. What comes back:
frame.animating says whether to schedule another frame, frame.cursor is
what the hovered surface asks for, and frame.tip is the tooltip that came
due (already floated into the scene, handed back for a host that would
rather open a native window). Keys reach a widget through ui.keys(id),
which is empty unless id is focused, and a global shortcut through
ui.shortcuts(), which is not gated by focus at all — except by the one
rule every editor has: a focused text_input consumes the stream, so a z
in a search box is a z and not an undo.
The DSL
use mui::prelude::*;
let control = |id: &str| block(28.0, 28.0).pill().fill(Role::Primary).id(id).cursor(Cursor::Hand);
// A tab whose shell is a parallel inset of its own rounded outline.
let tab = col![control("plus"), control("phase"), control("warp")]
.gap(10.0)
.pad(22.0)
.min_w(92.0)
.center()
.id("tab")
.shell(12.0, Role::Raised);
// The tab and the panel unioned into one filleted shape.
let root = row![tab, block(520.0, 230.0).id("panel")]
.start()
.union(Role::Surface);
let scene = resolve(&SceneSpec::new(root)).unwrap();
assert_eq!(scene.surface("tab").unwrap().frame.size.width, 92.0);
| you write | it means |
|---|
row![..], col![..], stack![..], grid![3; ..] | flex axis, stack, cells; each arg goes through IntoEl, so &str is a text run |
row([..]), col([..]), stack([..]), grid(3, [..]) | the same four, taking an iterator |
block(w, h), spacer(), text("..") | a sized box, a grow(1) gap, a measured text run |
title(".."), body(".."), caption("..") | text at the theme's type_scale: 18, 13 and 11 px by default |
icon(sym::HOME) | a Material Symbols glyph in Theme::icon_font; .font(f) for another face |
.at(8, 4), .offset(dx, dy), .centered(), .centered_at(dx, dy) | placement in a stack or grid cell: from the top-left, a nudge, or centred. Any number type works in every numeric argument |
.gap(M), .pad(S), .pad(12), .pad((16, 8)), .gap(step(1.5)) | spacing tokens Xs S M L Xl from the theme, n units of its grid, or pixels; a pair is (x, y), Insets sets each side |
.grow(w), .shrink(w), .basis(px) | flexbox weights: .grow(1) takes a share of the surplus |
.w(Len::Pct(50)), .aspect(16.0 / 9.0) | percentage and ratio sizes |
.w(clamp(64.0, 30.0, 220.0)) | CSS clamp(min, pct%, max): fluid between two pixel stops |
.w(cq(40.0)) | a share of the nearest ancestor with a definite size on that axis -- CSS cqw/cqh, with no container-type to declare |
fits![wide, mid, thin], fits([..]) | SwiftUI's : the first candidate that measures inside the room on offer is the one that lays out and paints, no second build pass |
Alignment is inherited: a child without .anchor sits where its parent's
align and justify say, and Stretch is the default cross-axis value so
a row of controls fills its column unless told otherwise.
A plugin editor shell
This is the shape of a synth plugin's editor. A header, a scrolling parameter
list, a response curve and a status bar are forty-six lines, twenty-one of
them the tree itself, and not one coordinate:
use mui::prelude::*;
let mut ui = Ui::default();
let (mut bypass, mut preset) = (false, "Init".to_owned());
let mut values = [0.4, 0.5, 0.8, 0.2, 0.6];
const NAMES: [&str; 5] = ["Drive", "Tilt", "Mix", "Air", "Floor"];
let params: Vec<El> = NAMES
.iter()
.zip(&mut values)
.map(|(n, v)| slider(&mut ui, *n, n, v, 0.0..=1.0).el.into_el())
.collect();
let curve = canvas(|size| {
let pts = (0..=48).map(|i| {
let t = f64::from(i) / 48.0;
Point::new(t * size.width, size.height * (1.0 - t * t))
});
vec![Draw::stroke(Path::polyline(pts, false), Role::Primary, 2.0)]
});
let root = col![
row![
title("Synth"),
text_input(&mut ui, "preset", &mut preset).el.w(140),
spacer(),
toggle(&mut ui, "bypass", "Bypass", &mut bypass).into_el().tip("Bypass"),
]
.gap(S)
.center(),
row![
col(params).gap(S).scroll().w(200),
curve.grow(1.0).fill(Role::Raised).radius(12.0).cursor(Cursor::Crosshair),
]
.gap(M)
.grow(1.0),
row![caption("48 kHz"), spacer(), caption("2 voices")].center(),
]
.gap(M)
.pad(L)
.fill(Role::Surface);
let frame = ui
.frame(root, Some(Size::new(560.0, 340.0)), Input::default(), 1.0 / 60.0)
.unwrap();
assert!(frame.scene.surface("Drive").is_some());
The same tree at 240x600 and at 2000x300
A plugin window gets dragged to shapes nobody designed for. Reflow is three
declarations and no breakpoint: clamp(min, pct, max) is a length with two
stops, .min_col(px) makes a grid's declared column count a ceiling it drops
from, and .wrap() breaks a line.
use mui::prelude::*;
let editor = || {
col![
row![title("Synth"), spacer(), caption("v1.0")].baseline(),
// Three tabs, fluid between 64 and 120 px, wrapping when they run out.
row(["Osc", "Filter", "Env"].map(|n| {
row![caption(n)].w(clamp(64.0, 18.0, 120.0)).pad((0.0, 8.0))
}))
.gap(S)
.wrap(),
// Four knobs, as many across as fit at 120 px a column.
grid(4, ["a", "b", "c", "d"].map(|k| block(40.0, 40.0).id(k)))
.gap(S)
.min_col(120.0),
]
.gap(M)
.pad(M)
.w(pct(100.0))
.h(pct(100.0))
};
let stacked = |w: f64, h: f64| {
let spec = SceneSpec::new(editor()).offered(Size::new(w, h));
let scene = resolve(&spec).unwrap();
let y = |k: &str| scene.surface(k).unwrap().frame.y;
y("a") != y("b")
};
// One column in a thin window, four in a wide one. Same tree.
assert_eq!((stacked(240.0, 600.0), stacked(2000.0, 300.0)), (true, false));
The gallery's Responsive editor scene is this tree with knobs on it, and
scenes::tests resolves it at 240x600, 800x500 and 2000x300 asserting that
no child ever leaves its parent.
Plugins
mui-truce puts a MUI tree in a CLAP or VST3 plugin built with
truce. MuiEditor is truce's Editor: a
baseview child window in the host's window, a wgpu surface on it, and one
Ui::frame per native event. Bridge::bind connects a widget id to a truce
parameter. A drag becomes the host's begin/perform/end, and host automation
is the value the next tree reads:
fn editor(params: Arc<GainParams>) -> Box<dyn Editor> {
MuiEditor::new(params, Ui::default(), (300, 200), |ui, bridge| {
let gain = bridge.bind(ui, P::Gain, |ui, id, v| knob(ui, id, "Gain", v, 0.0..=1.0));
col![gain, title(bridge.text(P::Gain))].pad(L).fill(Role::Surface)
})
.resizable((260, 180))
.into_editor()
}
examples/gain-plugin is a complete plugin: a gain knob, a bypass toggle and
an output meter. crates/mui-truce/README.md has
the build commands and the clap-validator and pluginval results.
Both --release and --profile plugin use panic = "unwind" here. The final
plugin workspace controls the panic strategy: downstream plugins must also
set [profile.release] panic = "unwind". mui-truce rejects abort builds by
default, because an editor panic would otherwise terminate the DAW.
The explicit allow-panic-abort feature removes that protection; it is not
for shipping DAW plugins. See the shipping checklist
and the remaining Linux host-thread integration limitation there.
Hosts
mui-baseview: the native window, for apps (run) and for any plugin
framework (open, parented under the host's window). A View trait, the
event queue, the GPU surface, clipboard and AT-SPI.
cargo run --example app -p mui-baseview is a knob in a window.
mui-truce: truce's Editor over that window, plus the Bridge from widget
ids to truce parameters.
mui-vello::host: bring your own window and surface; Host presents a
resolved scene to it. Host::with_transparency(.., Transparency::Translucent)
composites with alpha where the surface offers it (Host::translucent says
whether it did): paint a faded root, Role::Surface.alpha(0.7), over an OS
blur for frosted glass.
mui-baseview::Requests::drag_out and drag_out_with_preview return a
Result<(), DragQueueError>. A successful call admits one owned file export for
the current primary mouse gesture; it does not establish destination import.
Handle Busy, Closed, GestureExpired, and InvalidFiles without retrying
the same gesture. The host starts the drag after a coherent scene is presented
and delegates native scheduling to its shared baseview adapter.
take_drag_events() delivers session progress and typed terminal outcomes;
drag_active() includes queued and native work. DragImage declares validated
backing pixels, logical display size, and cursor hotspot for bitmap previews.
Native graphics diagnostics and automatic MUI issues
Native hosts persist adapter attempts, startup stages, device errors and caught
window panics automatically. mui::diagnostics::log_path() finds the current
session; MUI_DIAGNOSTICS_DIR overrides its directory. Session files rotate at
1 MiB with one previous file, and startup retains the newest 64 session files.
Browser hosts use no-op native diagnostics.
Enable mui's optional reporting feature, then retain one reporter in the
app/plugin's owned state before opening windows. Registration failure can
leave reporting disabled while the editor continues to work:
let mut config = mui::diagnostics::Config::new("my-plugin", env!("CARGO_PKG_VERSION"));
config.build = option_env!("APP_GIT_REVISION").unwrap_or("unknown").into();
config.mui_revision = "your-MUI-git-pin".into();
let reporter = mui::diagnostics::Reporter::start(config)
.inspect_err(|error| eprintln!("MUI reporting unavailable: {error}"))
.ok();
// Store `reporter` on owned app/plugin state until all MUI windows are closed.
The reporter queues bounded, sanitized diagnostics locally and delivers them
on a managed background thread to https://matari-audio.com/api/support/mui.
Failed submissions retain their original bytes and retry after a minute or on
the next load. A report is deleted only after support acknowledges its exact
SHA-256 and a verified issue URL in Matari-Audio/MUI. Apps contain no GitHub
credentials. The server groups matching failures across apps, publishes only
bounded environment metadata, and retains detailed evidence privately for
30 days. The endpoint must be deployed and configured before delivery works.
Set MUI_REPORTING_DISABLED=1 in CI or an offline diagnostic session to retain
local evidence while preventing network delivery and live issue creation.
Startup, surface configuration and the first draw also use locked operation
markers. Normal completion and Rust unwind clear them; abrupt termination
leaves an unconfirmed operation interruption for recovery on the next
reporter load. Live operations in another process are skipped. This is not a
native stack dump and does not prove that MUI or a particular GPU caused the
host to terminate. Steady-frame native crashes and unrelated DAW crashes still
need OS postmortem evidence; no process-wide signal or panic hook is installed.
Custom hosts can use mui::diagnostics::operation(..) around their own risky
MUI startup calls and error(..) for MUI-owned failures.
Start and drop the reporter off the audio thread. Close/drop all windows first,
then drop the last reporter (put its field after window fields if relying on
struct field drop order). Shutdown joins its worker. HTTP has a five-second
deadline; the OS DNS resolver can take longer, so reporting shutdown can wait
for it. No timeout helper thread is detached from a plugin being unloaded.
The default queue holds 32 reports and 32 live or
interrupted operation markers per app directory. Export local diagnostics if
these limits are reached. Multiple instances of the same app/build share the
worker within one loaded MUI image.
Motion
Nothing is keyframed. .animate() puts a spring on a node's own visual
channels -- fill (in Oklch, hue the short way round), stroke width, radius,
text size, shadow blur, shell depths -- and a new declared value retargets
the live spring instead of restarting it, so a colour changed mid-flight
keeps its velocity. .animate_with(s) is the same with your own spring;
Spring::new(response_s, damping) is the tuning you want (1.0 is critical,
below that overshoots), Spring::instant() turns one off.
ui.tween(id, target) is the escape hatch for a number MUI cannot see --
a canvas sweep, a pan -- and ui.tween_with takes a spring. frame.animating
is true while any of them still moves, which is the only thing a host needs
to decide whether to schedule another frame.
Gestures have edges: ui.edit(id) reports Edit::Begin when a press
captures a target and Edit::End when it lets go (including a cancelled
one), which is exactly a plugin parameter's begin/end-edit bracket.
frame.edits is the whole list.
use mui::prelude::*;
let mut ui = Ui::default();
let mut gain = 0.5;
let sweep = ui.tween("sweep", gain);
let root = col![
block(60.0, 60.0).fill(Role::Primary).animate().id("lamp"),
knob(&mut ui, "gain", "Gain", &mut gain, 0.0..=1.0),
];
let frame = ui.frame(root, Some(Size::new(200.0, 200.0)), Input::default(), 1.0 / 60.0).unwrap();
assert!(sweep <= gain && frame.edits.is_empty());
Images and icons
Image::rgba(w, h, bytes) takes straight (non-premultiplied) RGBA8 -- what a
decoder hands back -- and Fill::Image(img, fit) paints it into whatever
outline the node already has, cropped (Cover), letterboxed (Contain) or
stretched (Fill). Decoding is the host's job: no library crate takes an
image dependency. Path::from_svg_data turns an icon's d attribute into a
Path (arcs included), so a symbol is geometry like everything else.
vello_cpu paints the pixmap itself. GpuRenderer converts each image
buffer once into a Vello image that Vello uploads and samples; a GPU texture
registered with GpuRenderer::set_texture is sampled in place. What was uploaded (or premultiplied, on the CPU) lives in a
mui_vello::Cache owned beside the renderer. It holds only a Weak to each
buffer and sweeps the entries the app has dropped on the next image lookup,
so a panel handing over a fresh frame buffer every frame does not grow it.
Accessibility
mui-access turns a ResolvedScene into an accesskit::TreeUpdate:
tree_update(&scene, focus, scale), where scale is the window's device
pixels per scene unit. A node says what it is in the tree itself --
.a11y(A11y::Button).named("OK") -- and the walk carries that onto the
surface; a node with no role reports as a group, and a control with no
label is named by its id (a group with none stays unnamed). A text_input reports its line
as a TextRun with each character's position and its selection, so a reader
follows the caret, and a reader's SetTextSelection comes back as
SemanticAction::set_selection. The
widgets in mui already describe themselves, so the preview just hands the
update to its accesskit_winit::Adapter after each frame.
Colour
A theme carries a Palette: a Mode, seven Pigments (hue and chroma, no
lightness), and two steps. Every surface, every ink, every state and the
other theme are derived. Ink is not a colour you pick: Role::Ink and
Role::Dim resolve against the fill they sit on, and Palette::on is
checked at 4.5:1.
use mui::prelude::*;
pub const SKIN: Theme = Theme {
palette: Palette {
neutral: Pigment::new(264.0, 0.015),
primary: Pigment::new(242.0, 0.131),
step: 0.045,
hover: 0.11,
..Palette::NEUTRAL
},
corners: Corners { box_: 28.0, concave: 32.0, ..Corners::DEFAULT },
..Theme::DEFAULT
};
let light = SKIN.palette.with_mode(Mode::Light);
assert!(light.is_valid());
crates/mui-preview/src/skin.rs is exactly this file. There is no
light-theme half because there is nothing in it a mode could contradict.
Crates
ARCHITECTURE.md has the crate graph and the scene walk --
how welds, shells, carves and text become one paint list -- and
docs/shape-layout.md the contour-aware layout
contracts.
Preview
cargo run -p mui-preview
The whole window is one tree: the sidebar's scene list, toggles and sliders
are mui widgets, the specimen is an anchored child of the stage, and a drag
on the stage pans it through an offset. Text reaches the pixels as a hinted glyph
run, not a filled outline: the host hands paint a mui_vello::Gpu.
bacon rebuilds and relaunches on save. Point MUI_PREVIEW_FONT at a
variable font and the Glyph scene grows a slider per axis; point
MUI_PREVIEW_THEME at a key = value file and the palette reloads while the
window is open. F12 outlines every surface and names the one under the
pointer, and the title bar reads the resolve and paint cost of the last
thirty frames.
winit's wheel, keys and modifiers ride into Input with the last pointer
sample of each batch, so Ui::scroll, Ui::focus and text_input work in
the window: the Scroll, Text, Tooltip, Canvas, Drag, Image, Wrap, Motion,
Grid, Select and Responsive editor scenes are there to prove it -- one per
thing the library claims to do. Drag the window narrow and the last one
reflows: it is pct(100) of the stage, so it is measured against whatever
the host gives it. Frame.cursor is applied with window.set_cursor; Frame.tip is
not, because the tooltip is already floated into the scene. Printable
characters go to Input.text only -- Key::Char is emitted just for
ctrl/cmd shortcuts, or text_input would insert every character twice.
Copy, cut and paste leave and re-enter through Frame::clipboard and
Input::clipboard; the preview loops them back to itself, so it is its own
clipboard and never touches the OS one. An input method reaches a field
through Input::ime: a Preedit is painted under the caret and never joins
the value, a Commit inserts like typed text, and Frame::ime tells the host
where to put the candidate window.
cargo run -p mui-vello --example headless -- /tmp/pill.png
is the stack end to end with no window, and
cargo run -p mui --features cpu --example snapshot -- /tmp/widgets.png
runs two Ui::frames of the widget card and rasterises them on the CPU:
no GPU, no window. With --features cpu, mui-vello renders through
vello_cpu and its snapshot test asserts actual pixels. --features cpu-threads rasterises on a rayon pool instead of one core; it is off by
default because a snapshot-sized pixmap loses more to thread hand-off than
it gains (BENCHMARKS.md measures both).
cargo run -p mui-scene --profile perf --example stress
resolves a ~1000-node tree at three window shapes and counts the allocations
each resolve makes. It lives in examples because it installs a counting
global allocator, which the library itself forbids.
Verify
MUI tester and UI evidence
cargo run -p mui-preview -- --test-ui runs the existing gallery as a small
MUI tester. It traverses every registered scene at three window sizes in both
themes, sends pointer, drag, wheel, keyboard and text input, and exercises the
overlay path. New gallery scenes join the traversal automatically. Each case
requires a submitted native frame; completion is recorded after window teardown.
This covers the gallery and existing native regressions, rather than every MUI
API or every DAW host.
.github/workflows/ui.yml runs on every Verify
event, independently of the library test jobs. Linux Vulkan/lavapipe and
OpenGL/llvmpipe presentation are required checks. Xvfb provides a real X11
display: the collector requires a nonuniform specimen in every native window
screenshot. Separate regressions check embedded editor pixels, expose, resize,
multiple windows, close/reopen, device loss and CPU fallback without a GPU.
Windows DX12 and macOS Metal are informational native probes on standard
VMs. An unavailable adapter or native fault fails the probe and saves evidence;
it is not converted into a skipped graphics test. They do not block the Linux
gate until the hosted adapter behavior is established. Neither those probes
nor software Linux rendering certify consumer NVIDIA/Intel driver support.
tools/ci/ui.py --output ui-results --gallery -- COMMAND supervises the tester
as a child process. On Linux, add --record and run inside a 1600×1200 Xvfb
display. Artifacts include adapter inventory, durable JSONL stages, stdout,
stderr, exit code/signal, screenshots, a video and per-scene cost summaries.
Measurements cover CPU resolve/submission time and retained texture memory;
they do not measure GPU completion or display latency. Interrupted journals
retain their last complete stage. CI disables network reporting to keep test
failures from creating production issues.
The GPUI comparison explains the device
requirements and recovery differences observed in upstream source.
Plugin editor CI on GitHub runners
.github/workflows/plugin-ui.yml is a reusable
workflow for already-built Linux x86_64 VST3 plugins. It runs pluginval 1.0.4
under Xvfb on Mesa software Vulkan and GL, with GUI tests enabled. Strictness 6
and three repeats exercise editor creation, close/reopen, automation and opening
while processing audio. Every job saves validator output, graphics environment,
MUI journals and a display recording, including on failure. A missing editor
test fails the job. CI disables automatic MUI issue submission.
Upload an unpacked bundle in the caller's build job, keeping the .vst3
directory in the artifact. Use a distinct artifact name for each plugin / call:
# In the Linux build job, after producing target/bundled/MyPlugin.vst3:
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
with:
name: my-plugin-linux-vst3
path: target/bundled/
# Under the caller workflow's jobs:, alongside its build job:
ui:
needs: build-linux
uses: Matari-Audio/MUI/.github/workflows/plugin-ui.yml@FULL_MUI_COMMIT
with:
artifact-name: my-plugin-linux-vst3
plugin-path: MyPlugin.vst3
require-mui-diagnostics: true
Replace FULL_MUI_COMMIT with the reviewed workflow commit. The caller's build
must also pin instrumented MUI for require-mui-diagnostics: true; that option
requires a first-frame submission record and rejects MUI error records. It is
false by default for older plugin builds. The record proves submission to the
surface, not visible pixels. Pluginval does not assert screenshots or editor
resizing; MUI's native presentation tests assert displayed pixels, resize and
close/reopen separately, on both software backends.
Standard runners are free for public repositories.
Software graphics catch lifecycle / rendering regressions but cannot reproduce
NVIDIA or Intel driver bugs. GitHub's paid larger runners
offer NVIDIA Tesla T4 on Linux / Windows and M2 GPU acceleration on macOS.
Use dedicated hardware runners for affected Intel / consumer NVIDIA models and
DAW-specific tests; the documented hosted GPU list does not offer Intel GPUs.
This workflow covers VST3: CLAP requires a host that exercises its GUI extension.
MUI's existing Windows verification deliberately skips GPU tests due to a WARP
fault, so a green Windows check currently does not certify Windows rendering.
Library checks
./tools/verify.sh
Formatting, tests, clippy with warnings denied, and a wasm check of the
library crates. BENCHMARKS.md is the frame budget of a synth-sized scene
on vello_cpu and GpuRenderer, reproduced by
cargo run -p mui-vello --profile perf --features cpu,gpu-effects --example bench.