Compare commits

...

461 commits

Author SHA1 Message Date
Arena Agent
fe5b75d92d Merge latest makepad work while preserving fork game crates and apps 2026-09-05 20:55:35 +00:00
Admin
a75fe914ff layout: extend turtle sizing and add Grid 2026-09-05 17:21:48 +02:00
Admin
b005c6e9b2 Preserve complete Route geometry within measured web memory budgets 2026-09-05 17:07:06 +02:00
Admin
9e61553c96 Keep Route 3D buildings under bounded web memory pressure 2026-09-05 15:21:31 +02:00
Admin
09fa1f080c Restore WebGL text with complete fallback samplers
Use cached transparent 2D and cube textures for unallocated optional samplers without bypassing invalid-resource, target, or framebuffer-feedback guards.

Require actual glyph draws in release smoke tests and keep shader compilation asynchronous in probe instrumentation.
2026-09-05 14:39:06 +02:00
Admin
1801e38453 Harden web renderer and make Route location opt-in
Bound drawable, geometry, texture, image, map and radar work; validate WebGL submissions and retire GPU resources safely. Treat context loss as terminal without re-entering Wasm after worker termination.

Add explicit Route location consent and regression coverage, including software-WebGL release probes for six deployed demos.
2026-09-05 12:13:36 +02:00
Admin
83a00d2801 flow: archive generated assets and add live source editing
Host or attach the asset service, archive generated and terminal outputs, and provide image/video/audio/text/3D viewers with fullscreen and gallery navigation. Load only visible bounded previews, generate video posters and text excerpts, and normalize legacy thumbnails off the UI thread.

Expose video frame counts and Sandbox chat providers, make failed queue cleanup idempotent, compact the panels, and correct short-wire direction. Replace App view with a movable, resizable CodeEditor overlay with debounced source saves, live graph updates, invalid-source preservation and close/reopen geometry.

Validation: release Flow, Flow UI and flowgraph suites passed 335 tests (two ignored). Updated Flow UI suite passed 107 tests; asset route suites passed nine tests after legacy preview fixes. The rebuilt release app passed remote editing, dragging, resizing, invalid-source, close/reopen and App removal checks; final All assets scrolling measured 16.5 ms median and 18.3 ms maximum over 12 inputs. Test instances were closed through the remote protocol.
2026-09-05 01:47:53 +02:00
Admin
75b295ce9a vj: synchronize decks and queue remote stem processing
Update deck and mixer synchronization, apply matched deck rates to splats, and queue remote stem extraction through the fleet. Refresh catalog and creator pipeline integration.

Validation: release VJ suite reports 890 passed, 10 failed, nine ignored. Eight mixer failures reproduce in serial execution (gain, transitions, sample alignment and video fades); two asynchronous media tests also failed in the full run. The new matched-rate splat test passes. These remaining failures are not claimed as fixed.
2026-09-05 01:45:43 +02:00
Admin
b49e2e5060 piano-model: calibrate partial gains and decay against recordings
Apply immutable pitch/velocity-interpolated modal calibration while preserving an explicit uncalibrated constructor. Add offline acoustic tooling, reference measurements and regression coverage.

Validation: 16 calibration/acoustic release tests passed, one ignored.
2026-09-05 01:42:39 +02:00
Admin
3ce27922a4 sim: use deck geometry for collision and sensing
Use deck surfaces consistently in dynamics, floor normals, sensing and voxel handling.

Validation: release makepad-game-sim tests passed.
2026-09-05 01:42:39 +02:00
Admin
3dfe5aaed3 show-control: restore sixteen DMX scenes with local save overlays
Bundle the recovered numbered scene bank, support sixteen slots, and keep editable current state and overrides in the local overlay. Validate legacy control mapping.

Validation: 27 release tests passed.
2026-09-05 01:42:39 +02:00
Admin
2f44d20b51 apple: avoid blocking video clocks and release native players once
Use output/presentation timestamps instead of AVPlayerItem.currentTime on the UI thread. Consume only fresh frames and stop polling paused players after their poster arrives. Balance Objective-C ownership and make native cleanup idempotent.

Validation: release Flow playback smoke and process sampling; the previous currentTime mutex hotspot is absent in the updated sample.
2026-09-05 01:42:39 +02:00
Admin
f8b9a670b7 widgets: preserve numeric edit completion and menu focus
Find numeric completion actions reliably and restore menu focus using tracked areas. Handle touch dismissal and focus changes without leaving menus open.
2026-09-05 01:42:39 +02:00
Admin
88048aa784 assets: extend authoring pipelines and typed asset search
Add sandbox authoring effects and character/composite creation, improve creator submission handling, and extend publishing/search metadata. Update importer conversions and the asset UI integration.

Validation: 31 store search tests, ten composite tests and two author-policy tests passed in release mode.
2026-09-05 01:42:39 +02:00
Admin
97912796a8 render: support rigged models and custom materials across viewers
Add declarative CSG joints, bindings and clips, custom render materials, and skinning/lightmap updates. Update shared media and XR viewers, including splat visibility and optional mesh HUD.

Validation: 400 release render tests and seven CSG rig tests passed.
2026-09-05 01:42:39 +02:00
Admin
1be1e2153c ai-hub: gate downloads by disk capacity and recover fleet admission
Account for missing and partial model files per volume, reserve headroom, and reject disk-constrained workers before accepting a job. Preserve typed admission failures so callers can choose another peer, and make activity gating and cancellation recover cleanly.

Validation: 592 release hub tests passed, one ignored; required-CUDA builds deployed to six idle Windows workers.
2026-09-05 01:42:38 +02:00
Admin
39a1b742ac flow-ui: a locked run's pictures and clips still take clicks — the viewer opens from the Output card again; only the inputs stay inert
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:17:11 +02:00
Admin
6f2fdfa861 flowgraph: a wire between nearly level ports takes the short run instead of a trip around the card (the fillet rule rejected any interior segment under two radii); fewer bends win only among routes of comparable length; six screenshot fixtures and the routing review
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:07:02 +02:00
Admin
1181a3bae4 flow + flow-ui + widgets: every creator pipeline is a template — 55 templates in six groups (Image, Video, Audio, 3D, Vision & text, Utilities), all evaluated and engine-exercised in tests; the New picker, the flows.templates tool and the palette group the same way; the Templates menu shows them under group headings, and a menu taller than the window scrolls
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 21:03:54 +02:00
Admin
2b4bff9a38 widgets + flow-ui: menu entries fire again — the bar's Closed action no longer hides the Selected one behind it, and an open menu takes pointer and key input before the panels beneath its popup
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:54:18 +02:00
Admin
30575f082d flow: route generation by request workload
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-04 20:27:00 +02:00
Admin
b0b0e41da0 flow-ui: select runs from the whole queue row
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-04 20:15:05 +02:00
Admin
f3b6c2f36c Merge branch 'work' into webdemos 2026-09-04 20:13:43 +02:00
Admin
778dbb8fc7 flow: accept bodyless 204 responses
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-04 20:12:49 +02:00
Admin
8f51704697 flow-ui: a Templates menu on the bar lists every template the server serves; choosing one creates and opens a flow from it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:09:13 +02:00
Admin
f45793a8fa vj: batch live video publishes into grid rows 2026-09-04 17:47:47 +02:00
Admin
28016ca897 vj: keep generated clips at the head of the grid 2026-09-04 17:41:18 +02:00
Admin
2a064d3928 workspace: add loader, haptics, voice, and runtime fixes 2026-09-04 17:39:41 +02:00
Admin
acd23289c6 flow + flow-ui: a cancelled run clears its cards and bars at once and its live nodes read cancelled; layout edits (move, size, flip) work while a run is shown; the queue row is one centre line — name, strip, state · time, x
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:24:17 +02:00
Admin
a6c5f15bf7 widgets: FabValueInput honours visible, so the seed picker's random mode hides the number field
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 15:04:50 +02:00
Admin
767e260b97 flow + flow-ui: parallel runs — random seeds with a die, a fleet parallelism estimate (generation nodes bound it; the chat box never caps the image fleet), batch routes, the Run dropdown (1 / max · N), Ctrl+Enter queues a run, and the Running panel becomes a Queue of every run with per-row and per-batch cancel and Clear all
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 15:01:15 +02:00
Admin
bf9418e2bc vj: ironfish / synth rack, program mix, and a clean release check
Clocked piano/ironfish/drum rack, program-bus mix, splat/mixer/music
updates. Silence the unused warnings that show up in `cargo check -p
makepad-vj --release`.
2026-09-04 14:54:07 +02:00
Admin
b37b1168fd media_view: the video, audio, mesh and splat viewers leave asset-ui for one crate; flow-ui shows every media type on the Output card and in the modal viewer
ViewSplat gains a typed set_scene_bytes so a viewer inside a face isolate never needs a script apply; MeshView gains visible.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:49:32 +02:00
Admin
a7774885a1 flow + flow-ui + flowgraph: the loaded flow is the editable design (Input.value lives in the file); Play clones it into a numbered locked run instance with a Design button back; inspector text rows follow the graph
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:37:38 +02:00
Admin
c71d6392f9 flow + flow-ui + flowgraph: a stale keep-alive is replayed on a fresh socket; a palette drop rewrites the file text so the new card mounts; the progress bar is centred in the header and inset past the corners
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:27:21 +02:00
Admin
7e397cf31e flow + flow-ui: Publish end node writes to the asset library; Assets tab in the Inspector; prompt-to-library template
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:27:21 +02:00
Admin
9a31d2104a flow-ui + widgets: a chosen model shows no node list, and a closed ComboBox shows the start of a long label
The picker's own label counts the ready nodes, so the GPU list under a
chosen model was clutter; it stays only for a model no node can serve,
where it names why. A closed ComboBox reset its text field to the tail of
the label after set_text; the cursor now sits at the start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 12:03:50 +02:00
Admin
24c927ad7e flow-ui + widgets: every dropdown is the searchable ComboBox
Model and format pickers, the inspector's one-of rows, the Ask face's
choice and face-declared pickers all render as ComboBox; the popup and
its input map through the canvas zoom. ComboBoxRef gains changed_label
and set_selected_by_label so the bindings did not have to change shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 12:01:42 +02:00
Admin
d20a20871a flowgraph: a routed wire turns right after its port and hugs its own card
The straight run out of a port is 10 px (8 px minimum) with the endpoint
fillets shrinking to fit, and the first and last vertical runs keep only
the narrow 6 px envelope from the wire's own card; other cards keep the
two-tier clearance and parallel wires keep their cable spacing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 11:48:58 +02:00
Admin
30ef92597f flow-ui: the image generator card shows only its settings; the picture lives on the Output card it feeds
The generator's own preview was clutter next to the Output card's
picture. The Image face drops its preview and generator cards are no
longer full-bleed; the two picture tests follow the picture to the
Output card.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 11:35:05 +02:00
Admin
5863c43eb1 flow-ui: an input card is one text area filling the card, with no name beside it
The host wrapped every bound face control in a labelled row, so the
prompt card showed a "value" label beside a fixed-height box. A
multi-line text area is now its own row and follows the card's size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 10:48:03 +02:00
Admin
cff15ac045 widgets: a fab number field drops a label that cannot fit instead of crushing it to a dot
A 54 px width chip left the "w" label one pixel column, which rendered
as a stray dot beside the number.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 10:43:12 +02:00
Admin
2a5172465c flowgraph + flow-ui: two wire modes never mixed — bezier or routed — and routed wires use narrow gaps
Routed mode never falls back to a curve: a wire with no corridor takes the
least-collision orthogonal path. Clearance is two-tier: the comfortable
12 px card clearance with 16 px fillets first, then 6 px per side with
8 px fillets, so a routed wire passes a 20 px gap. Bezier mode is a pure
cubic per wire. View → Wires switches the mode (in-process until the app
has a settings store).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 10:43:12 +02:00
Admin
27f02f2443 flowgraph: the canvas's style table resolves its registered type, so node and port icons draw again
The lib's script named FlowCanvasStyles unqualified before it was in
scope; the styles object never applied and every icon lookup came back
empty (the colours survived through the per-kind colour properties). The
style types are registered as components and the default references
mod.widgets.FlowCanvasStyles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 10:33:33 +02:00
Admin
c7241e00be hub: a node that evicted every resident releases its cached allocator pool before refusing a load or publishing usable VRAM
The 5090 kept about 1.5 GB of CUDA allocator pool after evicting every
model, so a card that fits flux2-dev refused it (30510 MB free reported,
32090 MB in a fresh process). Admission now trims the pool when the last
resident is gone and re-measures before refusing; usable VRAM is measured
after the same trim; the refusal names the pool it released.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 10:22:57 +02:00
Admin
c6890fd47a flowgraph: the flow canvas and wire router become libs/flowgraph, a widgets-only crate any UI can host; flow-ui is its first consumer
makepad-flowgraph carries the canvas (camera, cards, ports, wires,
selection, edits out), the router, a string-keyed view model
(GraphView/NodeView/PortView/EdgeView), script-registered node and port
styles keyed by kind, and a three-method NodeFaces trait for per-node
face widgets. It depends on makepad-widgets only. flow-ui projects its
splash-evaluated Graph through a 147-line adapter (graph_view.rs), keeps
its faces, file writer, panels and services, and renders pixel-identical
to before (hidden before/after grabs, zero differing pixels).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 10:22:21 +02:00
Admin
cf3b6488de flow-ui: oval port discs with a small input notch, the icon visually centred, and wires that start at the output's tip
The input notch was as wide as the output point; it is now a small notch.
The disc is a slight oval along the flow axis and the type icon shifts a
little toward the point (outputs) or away from the notch (inputs) so it
reads centred. Wires anchor at the tip of an output's point and at the
apex of an input's notch instead of the disc centre, so a wire leaves the
point along its axis.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 08:08:20 +02:00
Admin
f5ec84e671 flow-ui: the port disc's shape carries the flow direction — outputs end in a point, inputs have the matching dimple
Replaces the loose chevron beside the label. The cable side of an output
disc is drawn as a sharp > point, the cable side of an input disc as a >
dimple the point would fit into; both follow the card's flip. The type
icon stays centred and the labels sit close to the disc again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 07:58:15 +02:00
Admin
8f23e3b6ee flow-ui: the port chevron sits between the disc and its label, never inside the disc
The direction arrow shared the disc with the type icon and crowded it.
It now centres in the gap between the disc edge and the port name, so an
input reads "disc > label" and an output "label > disc"; the type icon
returns to the disc centre and the labels move out to make the room.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 07:52:02 +02:00
Admin
ad5e98bbad hub registry: flux2-dev's VRAM estimate is its measured peak, 30 GB
Measured on the RTX 5090 (2026-09-04, 1024x1024, 8 steps, nvidia-smi 250 ms
samples): 30.5 GB used at peak, the run completed in 52 s. The old 29 GB
was a pre-measurement guess that under-reported the model by 1.5 GB.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 07:48:01 +02:00
Admin
75c34413be hub: the PRO 6000 serves image as well as chat and text
flux2-dev cannot fit on the 5090 at the default reserve, and the role
table barred the only card that can hold it. The user opened image on
10.0.0.165 ("let the rtx serve images too"); the role test and the
flow's role-aware listing test follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 07:41:59 +02:00
Admin
c90df1fe0a flow + hub: a node advertises only the models it can admit, the flow picks admitted nodes for the named model, retries on refusal, and names every node's reason when none can take it
The 5090 listed flux2-dev ready (total VRAM passed the gate) then refused
every job (29696 + 2048 reserve > ~30510 usable); the flow picked it by
domain ETA and never retried. Nodes now publish vram_usable_mb and mark
un-admittable models too_small; the fleet gate uses usable VRAM; the
flow's gen executor picks admitted nodes for the requested model (ready
first), retries up to three nodes after an admission refusal, and when no
node can take the model its error says why per node (role, too small with
the numbers, waiting for VRAM). The flow's model listing drops (model,
node) pairs the node's fleet role bars, so the picker no longer counts the
chat-only PRO 6000 as ready for image; its label reads ready/absent/too
small with the GPUs named.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 00:45:42 +02:00
Admin
5a9eed960c Merge branch 'work' into webdemos 2026-09-04 00:18:58 +02:00
Admin
303458d07a webgl: a 2D texture pass builds its camera through set_ortho_matrix like every other backend, so the exploded z-layer view's camera reaches the GPU — the hand-built ortho branch uploaded an identity view and clipped every exploded draw, leaving the tweaker's layers view a bare window on the web; the Y flip for render-to-texture is one helper shared with the keep-camera branch
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-04 00:18:58 +02:00
Admin
4c370488e9 flow-ui: wires are stable and selectable — a route depends only on its own obstacles and keeps its kind unless a new one is clearly better, with a fixed tie-break; a click within six pixels selects a cable, the inspector shows the connection, Delete removes it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 00:00:26 +02:00
Admin
2e869f84ea route: the ocean-high overlay comes from the re-sharded archive (58 shards of at most 16 MB, one small leaf directory each) instead of two shards whose directories decoded to 407 MiB
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 23:58:08 +02:00
Admin
8c40d28a47 flow-ui: a close button at the top-left of the image viewer, fixed to the screen; the bar and the button fade with the viewer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 23:51:47 +02:00
Admin
73886d1664 Merge branch 'work' into webdemos 2026-09-03 23:45:22 +02:00
Admin
3c2530d000 web memory diet: the 805 MiB at load was the ocean-high archive's 13 M-entry leaf directory decoded to 407 MiB per lookup round and evicted at once — leaves now parse streaming into a window around the waiting tile ids (LeafParseLimits); a phone policy on the web (deviceMemory, UA, touch + short side) caps wasm at 512 MiB with a 320 MiB budget; archive leaf/range caches, reads and bakes in flight follow the budget; packed tile bytes stay packed until the bake decodes them; terrain scratch sized to the viewport and dropped with the layer; Cx::memory_report by owner. Phone viewport 1334 → 308 MiB after a minute of pans, desktop 1208 → 588
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 23:45:22 +02:00
Admin
2ed495f2ed flow-ui: the flip decision scores length, crossings, bends and loops instead of length alone, so a crossed pair unflips; ports and wire midpoints carry direction chevrons that hide below half zoom
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 23:44:42 +02:00
Admin
4781378985 flow-ui: the image viewer is modal for input — wheel, pointer and keys stop at it, and the canvas ignores a wheel under any open overlay
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 23:43:19 +02:00
Admin
b41fab482f flow-ui: a wire takes the simplest shape the geometry allows — the clear S-curve wins over any corridor route, tangents scale with the hop, level ports get a straight line, and no route has more bends than an obstacle forces
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 23:20:27 +02:00
Admin
6139a7386f flow + flow-ui: a card can face right-to-left — flip: true in the file mirrors its ports; the router is directional; a card auto-flips when that cuts its cable length below 80 percent, a hand flip pins it; the flip animates
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 23:13:39 +02:00
Admin
b7d95d823e flow-ui: the image viewer opens fitted and centred — the picture syncs after the stage has drawn instead of reading its cleared area as empty; a sampling-mode field replaces the abuse of the image shader's rotation; pan clamps at the picture's edge, 1:1 means one image pixel per device pixel, the checker stays fixed to the screen
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 23:00:51 +02:00
Admin
8ff1d1c41b flow-ui: wires never pass behind a card — endpoint cards are obstacles except at their own port stub, targets below or below-left route through a clear corridor; the selection outline is a stroke on the card's own rounded body under the port discs; a press on a picture box drags the card and a still click opens the viewer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:43:49 +02:00
Admin
f95e34b4c1 flow-ui: the inspector has one purpose — the selected node's header and note, the settings its card does not show, its connections as chips, its result, the raw face source folded under Advanced; a full-window image viewer with cursor-anchored zoom, pan, fit and 1:1, arrows through the instance's pictures, save and copy digest
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:38:44 +02:00
Admin
c79e84efca map: a tilt is two fingers moving together up or down — same vertical direction for both, the pair's stroke within a fifth of vertical; spread and angle no longer matter, so real fingers trigger it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:38:16 +02:00
Admin
4aec005469 flow-ui: wires route around cards with fillets and a pulse that travels the cable when a value lands; Clear drops an instance's generated state and keeps its inputs, as a route, a toolbar button, a menu entry and a shortcut
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:32:05 +02:00
Admin
4ee4f4cb79 flow-ui: the resize path sets walks and fits through typed setters, never a script apply from the main VM on an isolate's widget — a failed apply had left a freed script object behind and wedged every frame; a resized card fills its picture box, clips its face and lets the last flexible element take the height
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:15:37 +02:00
Admin
018ce7370d map: labels hold still through a gesture and never pop — the settled placement rides the camera delta while anything moves (pan now part of the motion signature; re-place only beyond the pan/zoom law), every draw uses the rect-centre fold pivot so CPU placement and the GPU warp agree (a fresh place drew about the screen corner: the giant space-warp labels), and a re-place cross-fades: survivors keep their birth, newcomers fade in, dropped labels retire from their own camera over 250 ms
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 22:10:03 +02:00
Admin
8eaa6ec3df map: two-finger tilt follows the phone convention — fingers up tilt into 3D, down flattens
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:06:35 +02:00
Admin
8b05496651 flow + flow-ui: width and height snap to the type's step (16 for images, the doc is the authority) in the fields, the inspector, the presets and the evaluator with a warning, so a fleet backend never sees an illegal size; text in cards and the inspector scrolls inside a bounded area that follows streaming until you scroll up
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:01:09 +02:00
Admin
2f3e393c59 web_server: a directory path without its trailing slash (/score) redirects to /score/ instead of 404, query preserved
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:00:31 +02:00
Admin
ddc4709b32 map: touch gestures — one-finger pan and double-tap zoom, two-finger pinch zoom around the midpoint, rotate with a 5° dead zone, and a parallel vertical slide for tilt, one state machine native and web; the web page keeps browser pinch-zoom out (non-passive touch listeners, touch-action none, maximum-scale 1)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 21:58:36 +02:00
Admin
4cd24c4905 route demo: the first location fix flies the map to the user at zoom 14, as native does; Amsterdam stays the default until a fix arrives
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 21:54:21 +02:00
Admin
bc6c620ede hub + flow: what the chat review found — the in-process route retries an empty completion too, a node says whether its prefill opened thinking so a brief-mode answer is never discarded, a preferred model falls back to normal election when no node has it, discovery keeps looking for the preferred model until patience runs out
Verified on the LAN fleet: a new prompt → paragraph → picture.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 21:50:59 +02:00
Admin
ca92ff9da4 route: the open sea on the web — the ocean-low/ocean-high overlays are an always-on group in the shared hosted overlay table, read through the archive plane (makepad.nl, cached); native keeps the local ocean files only as a dev override; the native-only OCEAN_MBTILES path is gone
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 21:44:36 +02:00
Admin
dbb46be699 Merge branch 'work' into webdemos 2026-09-03 21:43:15 +02:00
Admin
a53d9b6b40 route: no clock-driven night theme — the theme is the user's toggle, remembered in cx.storage, default day (the hour rule fired on the web for the first time once wasm had a clock and darkened the map after 19:00)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 21:43:15 +02:00
Admin
43302a63ed flow-ui: every card owns a draw list and draws in z order, selection brings it to the front; and the design review's findings — every event kind remapped through the camera, run events keyed by run id, no remount mid-run, an input journal that survives a failed PUT, terminal states reconciled, the total bar from the planned node set, isolate ownership on instance change, popups retired before an isolate is freed, the Ask face answers on a button, the menu bar navigates by keyboard, no per-frame allocation in the canvas draw
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 21:39:05 +02:00
Admin
dbc7838094 route: the maps-folder field and its save button are gone from the settings panel; the maps root is automatic
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 21:31:09 +02:00
Admin
635c3a3063 map: ready tiles are inserted at most two per frame (byte budget kept), queued visible ring first then margin ring, the stale tile drawn until its replacement lands — a restyle burst of 4-9 refined tiles no longer stalls a frame for 70-120 ms
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 21:23:34 +02:00
Admin
aa271c36f6 map + route + geodata: the Terrain layer through the archive plane — TerrainSource (hosted .mkmap elevation shards fetched by range through the shared reader, a local MBTiles only as a dev override), the hillshade rendered on the pool's heavy lane with reused TerrainScratch buffers (web capped at 2048×1536 at DPR 1, native DPR-aware), one request in flight; the demo checkbox now renders terrain like native
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 21:20:28 +02:00
Admin
d641cbf995 map: labels no longer vanish — the gesture label budget is charged from the placement loop, not from candidate collection (4-7 ms on the web at z15-16, up to 6000 candidates in space warp), so a place can no longer commit an empty cache; a truncated pass is never a strict cache hit, arms its own settle wake, and the at-rest follow-up chain is capped at 4
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 21:17:47 +02:00
Admin
cd33943a7a web_server: radar and weather run on KNMI's documented anonymous key when no --knmi-key-file is given; without --live-cache the pollers keep an in-memory cache and say so once
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 21:10:11 +02:00
Admin
01e77d99c0 map: the shimmer clock is one pass-level uniform (draw_pass.shiny_time, a map-owned slot, never draw_pass.time) written once per heartbeat tick instead of patched into every retained draw call's block — the settle tail stops re-uploading 600 uniform blocks per tick
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 21:09:44 +02:00
Admin
b04333265e webgl: uniform blocks upload only when their generation moved — one global monotonic counter on Cx hands out every generation (draw call create/dirty/zbias, draw list allocate/transform/re-record, pass allocate/time/dpi/ortho/camera and the web flipped copy, shader scope writes), so a reused pool slot can never match a cached generation; clear_draw_items, pool reuse and VAO recreation reset the caches; the JS caches key on the generation alone. Proven on a local build: the pan screenshot keeps every tile, the settle tail drops 20.5 → 12.8 MiB/s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 20:59:55 +02:00
Admin
179124f085 Merge branch 'work' into webdemos 2026-09-03 20:46:27 +02:00
Admin
e2a37c97f7 flow-ui: a format picker with common image sizes and a swap beside width and height, the picture clipped inside the card under the selection outline, a face-declared control mounts as a labelled row on the node it sets with a hint when it binds elsewhere, the Fn face gets a strip for them
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:46:21 +02:00
Admin
e689aea8f5 web_server + geodata + route: live radar, wind and weather on makepad.nl/api — one bounded poller per feed, hourly, disk-backed cache served from an Arc snapshot (a restart never re-polls early), 503 warming until the first result, health reports ok/warming/unavailable with timestamps; KNMI key from --knmi-key-file, the documented anonymous open-data key otherwise; libs/geodata fetches through the platform HTTP client instead of shelling out to curl; the client retries 503 after 30 s and disables a layer only on 404
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:46:04 +02:00
Admin
a50750fda6 flow-ui: Flows, Running and Palette as their own rounded panels with splitters, the inspector and source pane likewise, columns resizable; gaussian frame shadows; keys and IME reach the focused face field through the canvas transform
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:42:45 +02:00
Admin
199be9eb1d map + route: the six overlays (EV chargers, transit, nature, districts, building age, population) read through the same archive plane as the base tiles — OverlaySource {name, TileSourceConfig}, hosted .mkmap archives on makepad.nl fetched by range through the shared archive reader and disk cache, a local .mbtiles only as a dev override; one layer table (apps/route/src/overlays.rs) for the native and demo builds, the demo checkboxes now set overlays like native
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 20:41:27 +02:00
Admin
e48b056df1 Revert "webgl: a uniform block is uploaded only when its generation moved — per draw call (uniforms_gen), per draw list (uniforms_gen, recording_gen), per pass (pass_uniforms_gen) and per shader scope block; the JS side caches (ptr, len, gen) per uniform buffer and re-records/recompiles reset it; direct camera writers (vj effects, render scene, the web flipped copy) bump the pass generation"
This reverts commit 0d7ddee3f7.
2026-09-03 20:37:35 +02:00
Admin
df9f4498b6 flow: the style picker lives on the node whose input it sets — the add_style Fn, not the image
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:35:25 +02:00
Admin
2dbd1b7dca flow + hub: a flow's LLM turn asks the node not to think and sends no token cap for zero, so a fleet Qwen node answers with a visible paragraph; the empty-state placeholder draws one icon
Verified on the LAN fleet: prompt → paragraph → picture in twenty seconds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:34:02 +02:00
Admin
bb6d48fc6a Merge branch 'work' into webdemos 2026-09-03 20:29:04 +02:00
Admin
1f244282b1 map bake: a face-band triangle is a face only when all three records pack (a mixed triangle stays on the road path); a panicking pool job no longer takes the worker down — caught, reported once, the tile fails; the bake panic that blacked the native map and killed the web workers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 20:29:04 +02:00
Admin
075e1a74d4 flow-ui: pickers filled from the hub for image and text nodes, popups anchored through the canvas transform, labelled face controls, add_style explains itself, cards resize from a grip with size: vec2 kept in the file, full-bleed pictures, a click anywhere on a card selects it and still reaches the face, no remount on layout-only edits, panels float over the canvas
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:26:13 +02:00
Admin
d852699d08 route: no tile-source dropdown — makepad.nl is the tile source through the local range cache; a world.mkmap in the saved maps folder is the only override; the stale preference file is removed on start
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 20:23:07 +02:00
Admin
7db06e8c09 Merge branch 'work' into webdemos 2026-09-03 20:22:57 +02:00
Admin
0d7ddee3f7 webgl: a uniform block is uploaded only when its generation moved — per draw call (uniforms_gen), per draw list (uniforms_gen, recording_gen), per pass (pass_uniforms_gen) and per shader scope block; the JS side caches (ptr, len, gen) per uniform buffer and re-records/recompiles reset it; direct camera writers (vj effects, render scene, the web flipped copy) bump the pass generation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 20:22:56 +02:00
Admin
a8377920b7 hub + flow: a whitespace-only chat completion is retried once and then fails instead of passing as an answer; a flow's model is a fleet model id unless it names a weight file on disk; chat models show under the text domain in /v1/models
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:17:54 +02:00
Admin
fe23e067dd AGENTS.md: the execution policy — zero locking on the UI thread as one mechanism for native and wasm, no temporary threads (the pool), the standard operating flow (Codex codes, Fable designs and reviews, Grok tests), and the tweaker on Shift+F10
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:11:28 +02:00
Admin
7f2cc52e57 Merge branch 'work' into webdemos 2026-09-03 20:04:18 +02:00
Admin
bb49fe6989 map + platform: retained per-tile draw lists — each resident tile owns one DrawList2d per carto pass (fill, casing, stroke, icon, icon-high, shadow) and the label glyph batches are retained the same way, recorded when the bake, LOD ring, fringe/icon gates, flat/tilted or clip change and re-attached otherwise; a pan/zoom/tilt frame pushes this frame's uniforms onto the retained calls (DrawVars::update_uniforms_on_area, resolved slot table) and uploads zero instance bytes; the tilted per-pass depth is a pass_depth uniform; a held list's zbias resolves at entry (zbias_hold in every backend); the shimmer heartbeat patches shiny_time in place without a redraw; a freed/reused sub-list id is skipped by every draw-tree walker and the mask list re-records empty on the flat transition (contract test). Web pan tail 1,098 → 20 MiB/s, flat pan 2.4 → 0.43 MiB/frame; Metal grabs within the run-to-run noise floor
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 20:04:18 +02:00
Admin
64f6d41731 flow: pin the exact chat hand-off the Llm node makes (system, user turn, model) as a regression test
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:01:52 +02:00
Admin
662a1216e5 flow: the gen executor renews the hub lease every two seconds and says bye on shutdown, so fleet boxes stop reaping our jobs; flow-ui's bridge publishes run and instance events as messages on the bus, long runs return early with a subscription, and the AI gets templates, models and create tools
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:53:01 +02:00
Admin
a40ad74a63 flow-ui: the empty-state icon is the node's output type — a picture card waits with the picture icon, not the text glyph
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:47:41 +02:00
Admin
15c3b11da1 ai-services: what the pubsub review found — modules get a subscription seam and a publish sink through both WM paths, lease end flushes unsubscribes to hosted services, the subscription cap counts closing rows, an endpoint's queue is dropped after Unregister, the prompt drops a subscription on final
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:39:10 +02:00
Admin
4e8e4cd45f flow-ui: the design pass — menu bar and toolbar with the total run bar, continuous zoom through the draw-list view transform with pointer remapping, dark checker canvas with grid steps, shadowed cards with icon labels and port icons, glowing wires, per-node progress bars, full-bleed image cards, palette cards you drag out, the fab edit controls in the inspector, a template picker behind New, model pickers from the hub; MenuBar widget in the shared crate
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:38:21 +02:00
Admin
102ffc5fa5 ai-services: messages on the bus — a manifest declares topics, the engine subscribes on a tool's behalf or by ToolResult.subscribe, a service publishes Message frames, an idle conversation wakes on a message as an event turn under rate laws; the WM bus forwards the new frames; every app that matches the wire gets its arm
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:27:45 +02:00
Admin
14b194bed1 flow: the contact-sheet acceptance and an instance/run/value battery over real sockets — Http read, Fn, two images in parallel, an Ask that parks and resumes the run, a third image, an Http POST of the result
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:00:06 +02:00
Admin
7b10d61fc7 flow: the hub's models on the wire — GET /v1/models snapshots the discovered fleet (health + model list per node, refreshed on demand, fleet hints for tests) and /v1/nodes carries the model ids per domain
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:56:08 +02:00
Admin
f615054dab tweaker + webgl: click-to-climb continues only from the widget the climb started on, so a press on a sibling picks that sibling (the empty draw_bg was a bare View being pinned); the Shader tab says when a layer has no live draw call; the WebGL paint walk no longer resets every draw list's view_transform — the magnified material well drew at the window origin on the web
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 18:55:42 +02:00
Admin
de9aba3d83 vj + web: the Layer button works on the web — a second Window is a queried capability (OsType::is_single_window; the web creates none and reports it once), so the output becomes an in-page full-canvas layer with browser fullscreen (Esc, the browser's own fullscreen exit, or a double-click leave it); a pass without a draw list settles its dirty flag instead of erroring every frame; exitFullscreen fixed; fullscreenchange feeds the window geometry
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 18:53:02 +02:00
Admin
93e7dae15d Merge branch 'work' into webdemos 2026-09-03 18:38:26 +02:00
Admin
976ea0ed04 tweaker: Shift+F10 toggles it on every platform (KeyEvent::is_tweaker_toggle, one call site; the web page swallows exactly Shift+F10 so the browser never sees it); the exploded z-layer view has no keyboard shortcut any more — it is a button in the tweaker
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 18:38:26 +02:00
Admin
263d6f0ae8 flow-ui: the canvas — node frames with faces evaluated in an isolate per instance, typed port dots, bezier wires, drag-move and drag-connect, palette, inspector, source pane, App view, Running list — integrated with the chat bridge and wired to the run routes
Zoom is three discrete sizes with a pan-only camera: pointer hit-testing
ignores the draw-list view transform. Faces bind with bind := "node.port"
because typed widgets refuse unknown properties.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:34:29 +02:00
Admin
dd51a028aa vj: a go-to-start transport button per deck — seeks to 0:00 through the normal seek path, keeps the playing state, leaves the loop alone; skip_start.svg icon; decks test
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 18:31:18 +02:00
Admin
ec40fdb050 vj + widgets: double-click a knob or fader to reset it to its default — the Slider handles tap_count 2 and emits its normal Slide action; the deck controls carry their neutral defaults (pitch 0, gain/EQ/stems 1, filter centre, crossfader centre)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 18:27:49 +02:00
Admin
32bea89a92 vj: a playing slice keeps the big waveform of the whole sample — the active slot is an overlay on it (accent band, bracket edges, wrapped playhead, slot number) while the lane's viewport stays fixed; the grid cells are selectors only, their mini waveforms are gone
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:27:40 +02:00
Admin
8d542feea7 platform: a child pass orphaned by its attaching draw list is no longer painted — the window's gauss_scene pass stayed a live_with_parent child after the map went flat and re-encoded a frozen 400-item list every pan frame with stale geometry ids (new tile meshes × old instance counts, tens of millions of triangles into a texture nobody read); make_child_pass records the recording list + redraw id, stale passes are skipped, a cached View re-attaches on a cache hit; tests for the orphan gate and the pool generation contract
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 18:22:02 +02:00
Admin
ccf818313d Merge branch 'work' into webdemos 2026-09-03 18:20:07 +02:00
Admin
e3abf4dbbf map: the hosted-tile cache keeps the same ~2x-visible margin as the local one — a budget pinned at the visible set evicted the trailing edge of every pan on the next frame and refetched it a moment later
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 18:20:07 +02:00
Admin
09e4bf1e1e flow: what the batteries pinned — 3xx refused, a node object where a port reference is required is an error, undeclared Fn ports refuse edges, parameter errors point at the field, the omitted node is named, allowed HTTP methods listed, a 192 KiB source cap, an unterminated Flow{ is a parse error
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:15:39 +02:00
Admin
a2e0c43e4c flow: the old pipelines ship as templates — GET /v1/templates lists them with label, brief, inputs and outputs; POST /v1/flows/{name} {template} creates a definition from one
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:13:53 +02:00
Admin
e878c4dbb6 Merge branch 'work' into webdemos 2026-09-03 18:04:35 +02:00
Admin
0d3a9b84d6 map: nothing animates at rest — the water/foliage shimmer heartbeat runs only during interaction or a camera animation, plus a 1 s settle tail, then freezes at its last phase; flat and tilted views alike
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 18:04:35 +02:00
Admin
c36f1e78bc flow-ui: the AI service bridge — a flows service (authoring + what is running) and one service per definition on the aichat bus, per-turn context, tools answered off worker threads
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:04:14 +02:00
Admin
1980c24973 platform: a draw call whose geometry id went stale is skipped, not drawn with whatever mesh now sits in the reused slot — the runaway triangle count that took a web map pan to 1 fps; reported with a power-of-ten backoff, never per frame
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:01:27 +02:00
Admin
58f0700df3 flow: media inputs route by (domain, port) — inpaint takes image and mask as named inputs, control's image is the primary — and every gen node's declared port types are authoritative, including derived Image and Upscale; ocr template typed; empty legacy port arrays warn
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:58:36 +02:00
Admin
2a096e50c3 Merge branch 'work' into webdemos 2026-09-03 17:57:37 +02:00
Admin
5a9fa62eed vj: no timed std waits on wasm workers — blocking channels, one tagged deck+background inbox for the stems/lyrics workers, a lost-wakeup fix in the thumb queue, native-only gates on the video decode loops; the DJ web app stops losing a worker at start-up
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 17:57:37 +02:00
Admin
428d23e8ed flow: instances, runs and values on the wire — instance CRUD and inputs (answers a parked Ask), runs with cancel, values on the data plane with ETag/Range and PUT upload, run events fanned into the ring by topic, autostart, janitor, bounded shutdown; real seams by default with with_seams for tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:57:03 +02:00
Admin
098ba7737a flow: three hostile test batteries — the server routes over real sockets, the language, the engine — with the findings pinned as ignored tests naming the bug
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:54:25 +02:00
Admin
a8b0382dea map: the water/foliage shimmer read draw_pass.time, which flags every DrawMap* shader as animated and repaints the whole map at display rate forever — a Rust-stamped shiny_time uniform instead; the 20 Hz heartbeat drives it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 17:52:44 +02:00
Admin
6691420e25 Merge branch 'work' into webdemos 2026-09-03 17:49:38 +02:00
Admin
2aa0780d0a webgl: BGRA uploads become RGBA at upload and sample_as_bgra is a plain sample on the web, as on every native backend — the tilt-shift's sharp band showed red/blue-swapped water
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 17:49:38 +02:00
Admin
b3da0c5712 flow: ports declare their types — ports: {in: {name: @type}, out: {…}} — so a second image port (mask, last_frame, reference) is as typed as the first; Inpaint, edit references, Music.lyrics, Paint.reference_image, Control.control restored; DREAM closes its loop again; the node catalog walks the prelude
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:39:41 +02:00
Admin
90c4f59357 asset-store: bump the static-export golden snapshot for the new rights fields
44b3a7f02 added artist/artist_url/album/source_url/license/license_url to
the exported manifest.json search block, which is part of the hashed
snapshot payload -- the contract changed on purpose, so the deterministic
golden in deterministic_golden_rewrites_graph_and_indexes_every_route was
stale (expected 78d089..., actual 7331ed62...). Verified the export is
still deterministic (same hash across repeated runs) and updated the
golden constant, with a failure message that names the printed `left`
value as the new golden so the next bump is a one-liner.
2026-09-03 17:37:46 +02:00
Admin
639887dda0 webgl: texture passes get their depth target — the tilted map (and every 3D scene drawn into a texture) was draw-order only: hollow buildings, no roofs, landmarks buried
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:32:55 +02:00
Admin
2b98a1f048 flow: the run engine — instances with an inputs table, one VM per run with live Fn closures, a dataflow scheduler, five executors (chat over the hub session, gen over the fleet, fn, http under an egress policy, ask that parks a run), content-addressed values with a RAM budget and spill
Real adapters by default (HubChat, FleetGen, HubHttp); seams for tests; the
gen path is proven end to end against the in-process testpattern hub service.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:01:53 +02:00
Admin
d74875327f wasm bridge: the page environment carries js_worker_wait so the module links — the pool landing added the import for workers only
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 17:01:52 +02:00
Admin
13f935911a flow: the server host (state thread, two planes over bounded_http, definition routes, events long-poll, lock/listen/token files, watcher) and the client (fail-closed connect, session, subscriber, embed policy); apps/flow-server bin and the flow-ui shell that hosts the server in-process
The two lanes were written blind against the same wire spec; the seams were
reconciled by hand: one FlowSummary, an optional graph on a definition, the
epoch-stamped string event cursor, and strict_json::parse_depth for bodies
that nest deeper than the default cap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:55:59 +02:00
Admin
42213f95b1 Merge branch 'work' into webdemos 2026-09-03 16:41:35 +02:00
Admin
862f3bd298 asset widgets + chat ui + render: fan-outs and jobs on the runtime pool, the transcript read without a lock on draw
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 16:41:34 +02:00
Admin
2a902c646d flow: the recipe prelude loads behind the core one and every pipeline template evaluates, round-trips and is canonical — a test per template
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:41:34 +02:00
Admin
285bc9866f asset-ui: workers from the runtime pool, the video player owns its frames and audio state
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 16:38:48 +02:00
Admin
cb498c9c5c Merge branch 'work' into webdemos 2026-09-03 16:38:24 +02:00
Admin
9e2d65b50b pdf + photos + task + wm + video + automate + widgets: no per-job threads — pool jobs and start-up workers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 16:38:24 +02:00
Admin
90dfcb630b flow: the language — splash prelude for nodes, evaluate a flow file into a graph with typed ports and located errors, tool schema from Input/Output, canonical writer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:29:16 +02:00
Admin
72ab9b0f31 Merge branch 'work' into webdemos 2026-09-03 16:21:17 +02:00
Admin
697215e1d9 route + converse + example-map: every worker comes from the runtime pool or a start-up worker
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 16:21:17 +02:00
Admin
2d4037d141 flow: every asset-creator generation kind as a prelude prototype, and every pipeline as a flow template — inventory and gaps alongside
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:17:41 +02:00
Admin
b1a92b73c6 Merge branch 'work' into webdemos 2026-09-03 16:15:08 +02:00
Admin
6dcca0088b workspace: no timed std waits on web-reachable paths — the clippy gate covers every web demo's dependency set
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 16:15:08 +02:00
Admin
78a1681a49 bounded_http: the store's HTTP/1.1 server parser is its own crate — the store keeps only its JSON response shim; flow-server is the second consumer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:13:00 +02:00
Admin
5184340565 platform + vj + map + files + widgets + image_tiles: the runtime owns one warm two-lane task pool — jobs never spawn threads
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 16:06:44 +02:00
Admin
c7639f0841 clippy: timed std waits (sleep, recv_timeout, wait_timeout, park_timeout) are disallowed — they read the std clock and panic on wasm workers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 16:02:19 +02:00
Admin
dc9cce6c94 workspace: no std clock on the web in any crate the web apps link — the last start-up worker death is gone
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 15:33:31 +02:00
Admin
cb75d367d6 asset client + store: no std clock on the web — the catalog worker no longer dies at start-up
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 14:58:38 +02:00
Admin
35ac364eea vj: the catalog runtime pump no longer logs every poll
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 14:51:26 +02:00
Admin
71c0e1eedf vj: a paused deck is never moved by sync or the loop grid, a loop cell plays exactly its bars with the view held, and the stems banner clears
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 14:44:11 +02:00
Admin
4e61e44fca route + map: the desktop app streams the makepad.nl archive through a persistent on-disk range cache, chosen in settings
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 13:47:14 +02:00
Admin
707d7029e9 vj: every worker thread comes from the platform spawner — the loop ½ button no longer panics the web app
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 13:46:15 +02:00
Admin
acb8689ad7 vj: the loop grid is built and refined on the web too — one pool + channel path for both targets
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 13:33:39 +02:00
Admin
b0ec2a4d0a vj: the audio engine owns the mix state — the UI sends commands over a wait-free ring and reads snapshots, no lock on either thread
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 12:36:57 +02:00
Admin
569b4a7f7c dj-pack: every CC BY and CC BY-SA version and the public domain mark are redistributable licences
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 11:29:43 +02:00
Admin
44b3a7f02e dj-pack + vj: every track carries its artist, licence and source, and the DJ UI shows them
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 10:50:50 +02:00
Admin
83a8d4f69d fonts: the web demos start with the Latin set — CJK and emoji faces load on the first glyph that needs them
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 10:40:48 +02:00
Admin
c3d9314f8b vj: each deck's transport sits on two larger rows under its mixer — every button visible on both decks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 09:39:07 +02:00
Admin
daf88e10cb route demo: the tiles come from the repacked world archive at its own never-cached path
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 09:31:20 +02:00
Admin
645de42e52 vj: a deck plays and draws while it decodes, and the stems swap in sample-aligned — one streaming path for native and web
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 09:26:24 +02:00
Admin
a3248d1c51 web audio: the worklet gets the audio access pointer as its context — the thread-stack call gained a request id and the audio start was still passing the pointer in its place
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 08:56:04 +02:00
Admin
410acd0eaa web + vj + route: the console carries failures and one-line summaries, nothing per request, per tile or per hiccup
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 08:54:21 +02:00
Admin
58d3fd5d5d web audio: the output is created inside the first gesture, and a stalled worklet module load is retried on a fresh context
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 08:34:59 +02:00
Admin
2db9d4812c mkmap: a root record may decode to 512 MiB — the densest world shards list over five million tiles
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 05:20:46 +02:00
Admin
8ae7c3c396 map: every typed stream index stays u16 — streams chunk under 65,536 vertices
Measured first: the tilted start view carried 15.1 MiB of u16 and
11.6 MiB of u32 indices against 77.3 MiB of vertices, the u32 premium
5.7 MiB, all on the big tiles (fill up to 149 k and casing up to 98 k
vertices). Streams now split at bake time into triangle-safe chunks of
fewer than 65,536 vertices, one geometry per chunk (at most four on any
tile, 191 duplicated vertices in 10.9 M), so u32 leaves the typed
streams. Amsterdam start view: tilted 114.3 -> 108.6 MiB (fill 28.4 ->
24.7, casing 40.4 -> 38.4), flat 311.7 -> 287.7 (fill 83.9 -> 67.3,
fringe 57.1 -> 51.6); the harness prints vertex/index counts and widths
per stream. Same triangles in the same order; Metal hidden grab within
label noise of the head without it, web GPU gate clean (IDX1 lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 04:13:46 +02:00
Admin
d6d324144e map: round road caps are a fragment SDF, dead cap rows gone
A round cap was a hard disc fan of up to 32 vertices per road end with no
anti-aliasing; a Butt/Square start pushed a feather pair it never
indexed. The cap is now carried by the last body pair extended by half a
width, with uv.y = 1 + cap_axis, and the fragment computes the circle's
coverage with the same derivative-based AA as the band edge. Amsterdam
start view: casing 42.5 -> 40.4 MiB (shape 100 17.1 -> 16.5, shape 110
15.2 -> 14.5), tilted total 116.4 -> 114.3 MiB, flat 313.9 -> 311.7.
Cap edges gain AA; Metal hidden grab vs the head without it 1.23 % /
0.27 % start (label placement in the crops), web GPU gate clean
(CAP1 lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 01:04:31 +02:00
Admin
662d141d19 map: building wall instances on a 20-byte record
A wall edge was eleven f32 (44 B): both ends, base, height, normal, AO,
colour, z-bias. It is now i16 ends, u16 centimetre heights (max error
8 um in the fixtures), f16 AO and z-bias ticks and a UNorm8x4 colour; the
normal is derived from the edge in the shader with the courtyard winding
preserved. Both the wall draw and the projected wall-shadow draw read the
new record. Amsterdam start view: wall_inst 19.3 -> 8.8 MiB, tilted total
127.0 -> 116.4 MiB, flat unchanged. Metal hidden grab within noise of the
head without it (0.99 % / 0.27 %); web GPU gate clean (WALL2 lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 01:04:31 +02:00
Admin
13c2500678 Merge branch 'work' into webdemos 2026-09-03 00:29:15 +02:00
Admin
66617cdd7a vj: stem separation is a setting — hub by default, local only on purpose, never auto-started on the operator's machine
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 00:29:15 +02:00
Admin
aa907cab9c platform: a frame trace on both desktop backends, and a window that cannot present stops ticking at 600 Hz
MAKEPAD_TRACE=frames prints, every two seconds per window, what woke
each tick (the waitable beat, a timeout, a message, a timer, a drain,
the display link), the NextFrame gap histogram, the present gap
histogram, the flip lead and refused presents — one bool check per
tick when off, readable over --remote. It was written to chase the DJ
beat wave's judder on Windows and found the measurable defect on the
way: when no window could present (minimised, hidden, or a disconnected
RDP session where the compositor refuses every present), the paint
tick slept a millisecond and re-ran, dispatching NextFrame and
redrawing at about 600 Hz for frames nothing would show. That case is
paced at 8 ms now, the same cadence as video pacing, the idle beat and
a hidden macOS window; a visible window that drops a frame keeps its
1 ms retry.

The judder itself could not be measured hidden — a disconnected RDP
session presents nothing at all — and the reading is that a connected
RDP session's bursty ~30 fps frame delivery is what the person saw;
the trace settles it in one visible run. platform 153 tests; both
desktop targets check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 00:18:25 +02:00
Admin
97e0701ff8 vj + platform: the web audio thread never waits on a lock — a loaded deck plays
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 00:17:00 +02:00
Admin
0ef0311812 web audio: a throw inside the worklet's process() is reported with its real text instead of a bare ErrorEvent
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 00:10:10 +02:00
Admin
1e2dcd0da8 web: a pass without a draw list is skipped instead of taking the app down (F12 layers overlay)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-03 00:03:23 +02:00
Admin
b39d301ee2 web audio: the worklet links the whole module — every platform import the audio thread does not serve is stubbed, clocks and the UI wake are real
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 23:55:07 +02:00
Admin
fef80585cb map: marker stalks and stoplights are instances
Every floating POI or charger marker in 3D mode drew two crossed wall
quads on the 48-byte generic layout, and every signal a pole plus three
light quads; together they were the whole 17.1 MiB fill_3d_misc stream.
They are now one 24-byte instance each (anchor, arm/height, colour,
z-bias) over one shared stalk template and one shared stoplight template
per tile, drawn by a prop draw struct on the same lighting path.
Amsterdam start view: fill_3d_misc 17.1 -> 0.0 MiB, stalk_inst 0.1 MiB
(4,887), stoplight_inst 0.2 MiB (1,866); tilted total 143.7 -> 127.0 MiB,
flat unchanged (313.9). Metal hidden grab within the label-noise band of
the head without it (1.78 % / 0.42 % start); web GPU gate clean
(STALK1 lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:39:20 +02:00
Admin
7a5bf69272 ai-hub registry: the Salamander drumkit samples come from the makepad.nl mirror — the GitHub repo only carries the .sfz files
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 23:37:48 +02:00
Admin
fad1c49827 web server: audio and text files are served, and models/ is immutable like maps/
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 23:37:33 +02:00
Admin
dc85eb0ed3 web + vj: render-to-texture passes keep their 3D camera on WebGL, thumbnails wait for shader compile, bundled tiles re-ask — effect thumbnails match native
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 23:36:53 +02:00
Admin
0fdfba76a3 files: the web's makepad home is a fixed virtual path — std's temp_dir panics there
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 23:26:01 +02:00
Admin
eec516ee06 cef: a Windows backend — the browser's page renders on Windows
The crate had a macOS backend and a stub for every other OS whose
Browser::new refused, so on Windows the app drew its chrome and the
page stayed black. The macOS file becomes native.rs with its islands
gated (the Metal blit, the IOSurface target, the app bundle and
re-exec) and Windows islands beside them: libcef.dll loaded with the
dist's Release dir on the search path, the process handle as the main
args, the exe as its own subprocess, the resources hard-linked flat
beside libcef where it looks for them (the dist keeps icudtl and the
paks in Resources, which crashes libcef), the app's own timer as the
pump, software paint into our texture, and a per-process profile when
a sibling browser holds the profile lock (the unix singleton check
does not exist there). The API hash is pinned to 13800, the layout the
bindings were written for — asking each dist for its newest misaligned
the browser settings struct on 144 and 151. Linux keeps the stub.

Verified on the Windows box with CEF 151: google.com renders in a
standalone browser and in a WM tile (a warm instance adopted in 88 ms,
the next standby up with its own profile); on this Mac with CEF 138 the
accelerated path still renders. cef 4 tests; the browser checks for
windows-msvc. Windows paints in software for now; the D3D11 shared
texture is the next lane.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:04:01 +02:00
Admin
bc87a3d359 Merge branch 'work' into webdemos 2026-09-02 22:48:59 +02:00
Admin
4f98c73be2 vj: a deck shows what it is loading — fetch, decode, stems — with real progress
vj: a deck shows what it is loading — fetch, decode, stems — with real progress

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:48:58 +02:00
Admin
a2cbe38330 map, platform: the tile budgets follow the platform's one memory number
Cx::memory_budget_bytes() is set once at startup from the device
(ProcessInfo on macOS/iOS, ActivityManager on Android, GlobalMemoryStatusEx
on Windows, /proc/meminfo on Linux, the wasm maximum on the web) through
one policy: desktop keeps 1536 MiB unless RAM is under 8 GiB (then RAM/4),
mobile takes RAM/4 clamped to 384..1536 MiB. The map's four byte budgets
are fractions of it (upload 1/64, pending 1/4, tile cache 25/32, HTTP
cache 5/32; the tile cache caps at 1/2 on web heaps under 1 GiB) instead
of constants. At 1536 MiB they are the binary sizes 24/384/1200/240 MiB,
about 5 % above the old decimal constants. Harness totals unchanged; Metal
grab and web GPU gate within noise (BUDGET1 lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:48:11 +02:00
Admin
7a2c09a873 map: road-union faces on a 16-byte vertex
Shape-0 Boolean union faces (plazas, road bodies, tunnel and bridge
faces) leave the 28-byte road vertex for FaceVertexTyped: i16 anchor,
UNorm8x4 colour, F16x2 class/material + depth tick, F16x2 deck lift +
coverage. The road shader draws them through a second vertex format on
the same pass; face streams use u16 indices. Amsterdam start view:
casing 73.6 -> 58.7 MiB (42.5 casing + 16.2 face), tilted total
158.6 -> 143.7 MiB, flat 328.8 -> 313.9, 433 ms/tile. Metal hidden grab
within the noise floor of the head without it (1.26 % / 0.23 %, label
placement); web GPU gate clean. Faces now draw as one batch per tile
before the stroke bands, so a translucent shadowed face over a crossing
casing blends ground instead of stroke at that pixel — not visible in
the crops (FACE1 lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:48:11 +02:00
Admin
cc1f25622a vj: cached thumbnails appear within a second
vj: cached thumbnails appear within a second and the pipeline never hangs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:47:39 +02:00
Admin
afdffd71b9 vj: clicking an effect tile puts it in a channel on the web
vj: clicking an effect tile puts it in a channel on the web

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:43:45 +02:00
Admin
7b33f7bec6 vj + platform: a loaded deck actually plays on the web
vj + platform: a loaded deck actually plays on the web

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:43:14 +02:00
Admin
3b1a8c264f platform: an in-app drag works without an OS drag session (web, Linux) — effect tiles drop into the channels on the web
platform: an in-app drag works without an OS drag session, so effect tiles drop into the channels on the web

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:41:46 +02:00
Admin
e7be0e31dc vj + platform: the browser UI thread never waits on a lock, and the web hot paths log only errors and summaries
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:13:26 +02:00
Admin
362ac59407 wm: the checkout is found from the working directory too, and a sibling binary is an .exe on Windows
A wm started with cargo run from the repo root but with its target dir
elsewhere (CARGO_TARGET_DIR) found no checkout above its exe and fell
back to sibling binaries — which never exist as bare names on Windows —
so the launcher listed only the linked modules. The checkout is now
looked for above the exe and then above the current directory, and a
sibling binary carries the .exe extension on Windows. Every app is one
cargo run away again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:12:10 +02:00
Admin
d8539d89e1 map: the detail parse and merge stop allocating
Per-layer tag arenas and views replace a HashMap per feature, the tag
whitelist is applied while parsing, and the worker's output scratch
buffers are reused. Release, Amsterdam: mvt-parse 377,074 -> 3,071 and
detail-merge 2,713,553 -> 19,817 allocations (135x fewer); parse median
5.9 -> 1.8 ms, merge 63.6 -> 9.7 ms; bake 429 -> 344 ms/tile, totals
unchanged (158.6 MiB tilted). Buffers, labels and icons byte-identical
across the 25 fixtures; Metal grab within the noise floor (0.9 % / 0.1 %)
of the head without it; web GPU gate clean (ALLOC1 lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
463de64b33 web: shaders compile per draw list, link in parallel
The first WebGL frame compiled every registered program synchronously;
on ANGLE-Metal that is 8.6 s of from_wasm dispatch for the DJ app (35 ms
of wasm), during which no timer, fetch completion or animation frame can
run. SwiftShader hid it by compiling at first draw. Programs are now
queued only when a draw list references them and linked with
KHR_parallel_shader_compile; the first draw waits only for the programs
it needs. DJ app on the GPU: localhost fetches 8.7 s -> 0.2 s, 39
programs ready in 300 ms; route first draw at 224 ms (STARTUP1 lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
9b4a3cac3e network: every completion raises the UI signal
A runtime the platform did not create (the asset client builds its own)
has no wake fn, so on the web its responses sat until an unrelated timer
pumped the event loop. EventSink::emit now sets the UI signal as well;
the DJ app polls its store session on Event::Signal instead of waiting
for the next poll tick.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
7e66991246 map: finished bakes reach the screen on the next frame
The worker drain runs once per frame and uploads until a 6 ms / 24 MB
budget is spent (nearest tiles first). Its continuation was a redraw,
and a Draw never re-enters handle_event, so the rest of the queue waited
for an unrelated timer (2-16 s in the web demo). When tiles remain the
drain now also asks for a next frame; that event arrives after the pass
and drains again. Real GPU, no probe, no input: all 25 start-view tiles
inserted between 2 and 4 s at 60 fps (DRAIN1 lane + continuation fix).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
62f38a5b33 route: --hour=N pins the theme hour for harness grabs
The route theme follows the wall clock (night from 19:00), so a grab taken
in the evening compares dark against a daytime baseline. The flag is a
harness override only; without it the clock still rules.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
30fc13bba0 map-tiles: repack runs on all cores, resumes per shard, reports --status
Mac-side only: no in-place rewrite, no push, no fallback source. Shards are
written atomically and skipped on resume; root.mkidx lands once at the end.
Ranged --verify streams the output back. 16 jobs: 4.2 tiles/s on the
500-tile sample (REPACK2b lane).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
cb572aee0f map-tiles: makepad-map-repack rewrites an archive to what the renderer reads
A new binary in tools/map_tiles streams an .mkmap shard by shard into a
new archive: every tile's protobuf is decoded, the osm_* tag tables are
rewritten to the reader's key whitelist, the field-101 shadow sections are
stubbed (regions and building groups stay byte-identical), field 100 is
kept, and the tile is re-brotli'd with the archive's own codec and
dictionary; leaf directories and root.mkidx are rebuilt, output is
deterministic and resumable per shard with a sidecar manifest, --tiles
limits a run to a Hilbert range or a z/x/y list, --dry-run reports and
--verify decodes both archives and checks the policy. On the 25 Amsterdam
start-view tiles: 95.1 -> 47.6 MB decoded, 30.6 -> 16.7 MB compressed.
A label sort tie-break makes the bake byte-deterministic for the parity
test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
f987651778 map: the analytic road fringe is baked only when the view is flat
The fringe stream (AA skirts around the road-union faces) is drawn only
below 25 degrees of tilt, yet every tile baked it: 57 MiB of the Amsterdam
start view that the tilted demo never drew. The bake now takes a fringe
flag the way it takes the 3D flag: wanted while the tilt is under 30
degrees, dropped only above 40, decided in the frame loop next to the
2D/3D flag and re-baked through the same restyle path with a full road
core rebuild; the harness prints both the tilted and the flat totals.

Harness: 215.5 -> 158.6 MiB for the 25 start-view tiles tilted (fringe 0),
328.8 MiB flat (fringe 188.3), 429 ms/tile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
5a5eda2979 map: road, fill and roof streams on the typed vertex formats, u16 indices
Roads move from eight f32 lanes to a 28-byte RoadVertexTyped (i16 anchor
at 1/64 tile unit, f16 offset / depth / uv pairs, unorm8 colour, f16
params, exact f32 deck), ground fills and roofs to 16 bytes (i16 anchor,
unorm8 colour, f16 params; u16 depth ticks for fills, exact f32 height for
roofs); indices are u16 whenever a stream has fewer than 65,536 vertices.
The three shaders read the typed POD fields directly (the fetch converts),
uploads go through Geometry::update_typed with the shader's layout, the
space-warp subdivision decodes / interpolates / re-encodes the typed
records, and a tile that lands before its shader has drawn waits in the
pending queue instead of panicking. The shader compiler types a packed
field access as vec2f / vec4f, and the Metal name table gains the packed
short / ushort / uchar / char vector names.

Harness: 249.6 -> 215.5 MiB for the 25 start-view tiles, 384 ms/tile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
5b3378164c platform: thread-caching allocator for the threaded wasm build
std's wasm allocator is dlmalloc behind one global spin lock taken by every
thread on every allocation, so eight workers baking map tiles serialised on
it: a tile that bakes in 0.4 s natively took 15-20 s (mvt-parse 0.7-4.5 s,
detail-merge 2.3-12.5 s), and the whole start view needed 150 s. This
installs a #[global_allocator] for wasm32+atomics: per-thread size-class
free lists (16 B .. 32 KiB) carved from 64 KiB chunks of the dlmalloc heap,
remote frees pushed onto per-chunk Treiber stacks and drained by the owning
thread, exit-safe reclamation, the main thread's caches pre-filled at
startup so its hot path never touches the global lock.

Measured on the same capture with 8 workers: mvt-parse 7-68 ms, detail-merge
27-415 ms per tile (native 4-17 / 23-70), zero traps, zero Atomics.wait,
the full start view. The two-worker stop-gap in worker_count goes with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
55a38104aa platform: typed compact vertex formats and u16 indices
Draw shaders can declare geometry POD fields as F16x2/F16x4, U16x2/I16x2
(plain or normalised) and UNorm8x4/SNorm8x4; the fetch converts them to
vec2f/vec4f on Metal and WebGL2 (per-attribute vertexAttribPointer with
type, normalised flag, byte stride and offset), the headless JIT decodes
the same formats, and Vulkan/OpenGL/D3D11 refuse compact layouts with a
logged skip rather than drawing garbage. Attribute packing computes
physical offsets with natural alignment for the whole record; the all-F32
path stays byte-identical (stride = slots * 4, packed_geometry_N on GL).

Geometry::update_typed / update_typed_with_recycled_buffers take
IndexData::{U16,U32} plus a byte vector and the shader's input layout;
updates are validated (stride, whole vertices, index range) and every
geometry carries its index width and a layout signature that survive
releasing the CPU staging, so a draw checks layout and index type against
the resident buffers, never the staging enum. Compact fields are rejected
in instance PODs and in nested aggregates at shader validation; SNORM
minima clamp to -1 like WebGL2.

On Metal the decoded (logical) vertex is reached through the shader
context like the instance is, so every shader function that reads
geometry compiles.

No map stream is converted yet; that is the next cut.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
99d2180aad map: roofs on a 20-byte vertex, contact shadows as instances
Lifted shape-0 roofs leave the 48-byte universal vertex for a five-slot
RoofVertexPacked (anchor, unorm8 colour, exact metre height, f16
material | zbias ticks); DrawMapRoof reconstructs the fixed vector
channels and shares the colour pass, the fade, the LOD band and the
shadow-mask projection / footprint cut-out. Roof records that need more
(parapet AO depth variants, marker stalks) stay on the generic layout in
a fill_3d_misc stream. The tree and signal contact shadows are no longer
tessellated ten-segment discs in a vertex stream but four floats per disc
drawn as an instanced unit quad into the shadow mask, with the same
radial ramp.

Harness: 281.5 -> 249.6 MiB for the 25 start-view tiles, 387 ms/tile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
5b49fee084 platform, map: the UI thread never futex-waits on wasm
std's wasm allocator spins, so the Atomics.wait that killed one WebGL2
run in four came from std Mutex / Once contended between the browser main
thread and a tile worker. Every lock the UI thread shares with a worker
(task pool state, cancellation generation, task results, the scheduler,
the web request table, the archive read states and shard file cache) now
goes through lock_from_ui, which spins on try_lock on wasm32 and is a
plain lock elsewhere. The icon mesh cache, icon slot table and tag-key
whitelist are warmed on the UI thread before the tile pool starts so no
Once can wait either.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
9d664ca967 map: roads on a 32-byte vertex
Casing, stroke and fringe records leave the 48-byte universal vertex for
an 8-slot road layout: full-precision anchor and deck height, f16 offset /
depth / tessellator uv pairs, unorm8 colour, and one f16 integer that
packs class, material, dash id, kind (stroke / union face / fringe) and an
explicit expanded flag. DrawMapRoad reproduces DrawMapVector's expansion,
terrain, tilt, space-warp and shadow-mask behaviour for those streams;
casing/stroke draw through it in the colour passes, the fade cross-fade
and the shadow-mask projection, and the space-warp subdivision has a
road-stride midpoint.

Harness: 339.0 -> 281.5 MiB for the 25 start-view tiles, 437 ms/tile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
ef25495540 map: building shadows derived at draw time in a screen-space mask pass
The bake no longer unions every building's projected roof and silhouette
into a material-6 ground fill per tile (122.5 MiB of the Amsterdam start
view, plus the b-shadow bake laps). MapView renders one child pass per
frame: wall records swept along the sun as instanced quads, roof/deck
projections of the lifted geometry, footprint cut-outs, and the tree/signal
contact discs (split from the icon stream); every ground shader samples the
mask at its screen position with the same alpha the decals used.

Metal returns the pass texture bottom-up, so the sample is flipped per OS
(WebGL2 flips its render-to-texture projection instead). The draws inside
the mask pass unbind the mask sampler (feedback loop). The compact fill
shader sets the shadow varyings so land and water are shadowed too.

Harness: 475.5 -> 339.0 MiB for the 25 start-view tiles, 411 ms/tile.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:02:33 +02:00
Admin
02b58fc931 map: ground fills on a 20-byte vertex — position, colour, variant + AA coverage, depth ticks; DrawMapFill is the fill path of the map shader
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:02:33 +02:00
Admin
57995b6c92 map: uploaded staging is freed on a pool worker — a large free on the UI thread of the threaded web build contends the allocator lock, and a contended lock there is an Atomics.wait the main thread may not make
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:02:33 +02:00
Admin
9047600062 map: the bake report can dump the raw and decoded tiles it measured
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:02:33 +02:00
Admin
a6f495dfd3 map: street trees are instances of one template per tile; the CPU staging of every uploaded stream is dropped; the memory diet keeps its platform half only
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:02:33 +02:00
Admin
72b60b6d0c map: the memory diet folded in — CPU staging freed after upload, budgets from one platform number, bakes capped by bytes in flight, per-zoom bake profiles, a memory report; instance records count as retained CPU
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:02:33 +02:00
Admin
2d4ff16b93 map: POI symbols and building walls are instances — one shared mesh per symbol slot, one record per footprint edge, extruded and placed in the vertex shader
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:02:33 +02:00
Admin
383b83e783 map: the Amsterdam bake report — the real archive through the real bake path, bytes per stream and milliseconds per tile
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 22:02:33 +02:00
Admin
c2c7f51400 platform: a hosted window on Windows draws again — the depth buffer matches the shared target's allocation
Under the window manager a child renders into a shared texture the
host allocates at the next power of two, while the Window pass carried
a depth texture sized to the pass rect; D3D11 refuses render targets
whose colour and depth views differ in size, so the clear landed (it
goes to the view directly) and every draw was dropped — each hosted
tile on Windows showed only its clear colour, the terminal's blank
rectangle among them. The pass setup now sizes the depth buffer from
the target's allocation when it draws to a texture, from the pass rect
otherwise (a pure helper with tests). The hidden-windows switch is
honoured by the Windows backend on the first show, so a test run stays
off the desktop.

Verified on the box: a hosted terminal's first frame 720 ms after
launch with the prompt in it, a typed dir listed. platform 132 tests;
the windows-msvc target checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 22:01:54 +02:00
Admin
21fce80db6 vj: stale-recipe thumbnails are swept from storage on startup
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 21:57:24 +02:00
Admin
5c78ac04a3 vj: web effect thumbnails show the effect, not a black frame
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 21:48:05 +02:00
Admin
c527cd8e88 vj: the web thumbnail pipeline never blocks the main thread
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 21:27:34 +02:00
Admin
6fddf9e285 vj: loading a track onto a deck fetches and decodes its audio on the web
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 21:25:46 +02:00
Admin
0b5bff02cc Merge branch 'work' into webdemos 2026-09-02 21:11:47 +02:00
Admin
f35dd1dd8c vj: effect thumbnails actually render and persist on the web
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 21:11:47 +02:00
Admin
ff6cfec0fc wm: minimise, maximise and close in the bar's top right on Windows and Linux
The omarchy bar is the caption strip and the frame is fully
client-sized there, so a WM window had no window controls at all. The
bar's right cluster now ends in three of our SVG buttons after the
status modules — minimise, maximise (restore once maximised), close —
wired to the window's own minimize, maximize, restore and close;
hover washes the slot, the close carries the accent, tooltips name
them, and the drag query answers Client over them. macOS keeps its
traffic lights on the left. The gallery shows them on every platform.
wm 155; the WM checks for windows-msvc, linux-gnu and wasm32. A real
click on Windows is the box's to prove.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 21:11:05 +02:00
Admin
5c4076168c cef: the build script downloads its CEF distribution itself, on every host, with no shell
The browser could not build from a clean clone on Windows: the fetch
was a bash script needing python3 and tar. It is Rust in build.rs now:
the spotify index is read by a strict scanner, the newest stable
standard build for the platform is picked (or MAKEPAD_CEF_VERSION pins
one), the archive is downloaded to a .part file with its size and sha1
checked, extracted beside it, and a current-<platform>.txt pointer file
names the dist — read before the old symlink, so the Mac keeps its
pinned 138 while a fresh box gets the current build; a platform that
already has a dist is never bumped. MAKEPAD_CEF_OFFLINE refuses with the
dir and pointer named; MAKEPAD_CEF_DRY_RUN prints the plan (with
MAKEPAD_CEF_PLATFORM to resolve another host's). Build-time deps only:
ureq (rustls), bzip2 (bundled), tar, sha1_smol. The shell script is gone.

Proven on this Mac: a pinned re-download of the 138 macosarm64 archive
(255 MB, sha1 verified) differs in nothing from the existing dist over
1,193 files; dry runs for linux64 and windows64 pick the archives already
on disk; the browser checks with no download; the cef crate's build deps
compile for the windows-msvc and linux-gnu hosts. Picker and pointer
tests 5. An actual download on Windows or Linux is not yet run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 20:56:48 +02:00
Admin
1dcca41ec4 wm: a theme with no wallpapers fetches them on its own
The bundled default theme ships its colours, not its pictures, so a
fresh install had nothing behind the desk until someone ran the
importer. The desk now fetches the current theme's wallpapers from the
omarchy repo on a thread when its backgrounds folder is empty and shows
the first one the moment it lands; the importer shares the fetch. No
network or no pictures means nothing changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 20:49:58 +02:00
Admin
453197da4d dj-pack: analyse produces the beat grid, overview and loop-splat caches the demo cache ships
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 20:32:01 +02:00
Admin
c1f7b53dca asset-client + dj-pack: a long description never rejects a snapshot; the packer writes one bounded line
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 20:26:56 +02:00
Admin
ffd62da242 dj-pack + vj: the demo cache carries every per-track analysis — slices, beat grid, overviews — and the web app reads instead of computes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 20:22:09 +02:00
Admin
c7fe851c2e vj: effect thumbnails render, encode and persist in browser storage on the web
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 20:15:47 +02:00
Admin
a9b2185759 Merge branch 'work' into webdemos
# Conflicts:
#	apps/vj/src/main.rs
2026-09-02 20:06:45 +02:00
Admin
8625076661 aichat + photos + image_tiles: make me a picture and it lands on the wall; the wall filters as you type, tiles flying to their places
gen.image{prompt} is a service inside the assistant: on a worker it
runs the creator pipeline for the image domain against the node the
runner picks (LAN discovery, ETA-ranked, the resident flux model wins),
streams the node's progress into the tool card, and writes the picture
under the makepad home's gen/ dir; the creator library gains
generate_bytes — the request, poll and fetch that generate_and_publish
now shares — so no second pipeline exists. photos.add{path} bakes that
one file into the open library (only from the gen dir or the person's
home), re-opens the wall and glides onto the new picture; the module
executor answers it later through the host's reply sink. The model
chains them, launching photos in between. A registry seam found on the
way: a call dispatched in the same event a link was adopted reached the
port before its Registered, so the registry now answers a leading
Register at once.

The wall gained a search box: every keystroke re-cuts the packing over
the matches (every word in a title or link; title beats link, a whole
word beats a substring), each picture flies from its drawn rect to its
target over 450 ms, dropped pictures shrink and fade, returning ones
grow back; Esc clears; ⌘F or a bare slash focuses the box; the status
reads "35 of 293 · ro". photos.filter{query} sets the same from the
pane; instances are rebuilt only while a flight runs, and the clock is
the platform's.

Proven on the fleet: "make me a picture of a red bicycle on the moon
and put it in photos" → the file in 40 s, photos launched, added and
shown among the comics; typing r, ro, rob re-packs the wall live; the
filter through the pane in a process tile and as a module. ai-services
39, aichat 5, photos 9, image_tiles 12, creator 9; aichat and photos
check for wasm32.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 20:03:54 +02:00
Admin
53f0e965f8 vj: the DJ/VJ app exposes itself to the desktop assistant
A service over the engine the UI already drives: status (what is live
and next, the fader, overlay, autopilot, bpm), search over the catalog,
cue (the UI's content-click path), fader, next, autopilot with a style,
overlay, and the decks — deck_play, deck_stop, deck_load, crossfade —
through the same functions the console's controls call. A context line
follows the live item, fader and autopilot. Written by the delegate from
the sheets/files shapes; vj's ai tests 4, the binary suite 821 (the two
media decode-pool timing tests fail on this Mac before and after).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 20:03:54 +02:00
Admin
40e483f1ff vj: the web music browse actually fires — proven on the live path
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 20:02:06 +02:00
Admin
93232a2b2e platform: two pool workers on wasm until the allocator is per-thread
std's wasm allocator is one global spin lock, so workers that allocate
serialise on it and per-job throughput collapses with the worker count
while the browser main thread starves behind them. Measured on the map
bake in the route web demo: a tile takes 15-20 s with 8 workers and
0.1-0.5 s with 2; the start view completes in 80 s instead of 150 s.
Every web app's pools get the cap through worker_count. It goes when the
thread-caching allocator (ALLOC2) lands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 59096700d56cd8f3f15724e24129c0b428bf6ae7)
2026-09-02 19:36:48 +02:00
Admin
64d2d3acee map-build: an unfinished bake resumes or restarts itself, and the maps root does not depend on the cwd
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 19:25:33 +02:00
Admin
cc8ee5c9f5 vj: the web music browse fires when the store is ready, and startup never stalls the UI thread
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 19:14:21 +02:00
Admin
04a36ded31 map-build: the bake produces only what the renderer reads; a bad tile is skipped and logged
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 19:05:11 +02:00
Admin
017dc69faf Merge branch 'work' into webdemos 2026-09-02 18:52:08 +02:00
Admin
78bfd5c27c vj: the web explorer shows the store's tracks — the listing reaches the rows
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 18:52:08 +02:00
Admin
3691875775 wm: launcher icons — a photo for Photos, a shirt for Fabric, a globe for Route, a play badge for Video, a pulse for the task manager
Five 16 px icons in the set's own style (our SVGs, stroke 1.2,
currentColor), wired into the shell's icon set and the launcher map.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:47:34 +02:00
Admin
0950ed7b4e Merge branch 'work' into webdemos 2026-09-02 18:46:52 +02:00
Admin
4b529a1203 map: tile ranges fetch once, centre-out, and never time out while queued
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 18:46:52 +02:00
Admin
37686534b0 image_tiles: the wall is box-packed — justified rows keep every picture's own shape and fill the width
A pure packer (pack.rs) lays pictures in rank order into rows of one
target height and scales each full row to span the wall exactly, so
tall strips sit beside wide panels with no gaps and no overlap; the
last row keeps the target height instead of stretching two pictures
into giants; a 4 % gutter is split per cell. TileGrid gains the packed
wall as its default (packed: false gives the old uniform grid), frames
its camera on the packing's bounds, hit-tests through the row
partition, and picks each atlas page's level of detail from the
largest visible tile in it rather than one unit cell. photos needs no
change. image_tiles 10 (5 new), photos 7; the photos lib checks for
wasm32. Driven hidden: the 293 comics in justified rows, zoom, pan, a
click on a comic naming it, and photos.show gliding onto one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:38:48 +02:00
Admin
95fd7d6c82 platform: a hosted window with a dpi override lays out in its own points and gets the host's pointer remapped
A stdin-loop child stored the host's WindowGeomChange raw and never
recorded the host dpi as the window's native dpi, while the native
path converts every OS geometry through the app's dpi_override and
remaps every pointer. VJ is the app that sets an override (its console
scale shrinks the dpi to fit a tile's height), so under the window
manager it laid out in larger points than the pointer it was handed —
every click landed short — and each host resize re-set the raw host
size under the override, so consoles of the wrong physical size were
drawn into the shared framebuffer over the previous full one: the
stacked stale consoles the user saw. Terminals never showed it; they
set no override.

The stdin arms of every platform now apply a host geometry the way the
native path does (the host dpi recorded, the size converted through
the override) and remap pointer positions with the override scale;
window hit-tests compare in native points. No host-side change: the
crop is per presented frame and the swapchain handoff only bridges a
resize.

Proven hidden under the WM: the explorer tab answers a click, typing
filters the music list, a split and its close re-lay the console out
with no ghosts. platform 128 tests; Linux and Windows targets check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:37:49 +02:00
Admin
0e5e007f03 Merge branch 'work' into webdemos 2026-09-02 18:30:27 +02:00
Admin
5fdd5d5f47 vj: on the web the explorer lists the site store's music and a deck loads it with its stems
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 18:30:27 +02:00
Admin
190062e4a1 route: the maps app exposes its tool table to the desktop assistant
The same tools route's own assistant has — plan, add and remove stops,
status, along-the-way search, map fly-to and trip framing, layers,
theme, markers, geocoding, weather, trip history, navigation — go on
the AI bus from one table (the definitions local_agent already maps),
dispatched through the one execute_tool; reads are Read, anything that
moves the map or changes the trip is Act. The context line carries the
map view and the trip; the desktop pane opening closes route's own
assistant panel. Written by the delegate from the sheets shape; route
25 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:29:43 +02:00
Admin
a772ec8f40 terminal + browser: a warm-pool standby joins the AI bus only once adopted
Dormant instances registered their services, so the assistant could
type into a shell or steer a page nobody could see. The port opens at
startup for a real launch and on WmEvent::Adopted for a standby, the
way files already did.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:25:40 +02:00
Admin
432121ebe5 aichat engine + wm: launch, then use — the assistant continues in the same turn once the app it started is on the bus
An ok os.launch result is held while the launched app is not on the
bus and delivered when it registers (within 8 s), rewritten as "<App>
is running now. Its tools: … Call them directly." with the tool table
rebound mid-turn where the model can (the local model prepends the
update to the tool result; Claude keeps its native table until the next
turn and gets the names in the text); past the patience the result says
the app has not connected yet. An assistant entry that never got
visible text is dropped before a tool call and at the end of a turn,
and the panel draws an empty one as nothing — the blank block above
the first card is gone. The model's context now opens with the running
apps and their tool names and lists the others as os.launch candidates;
the WM's launch description says a running app's tools are already
there, and its answer for one that is already running says so and
brings it to the front.

Proven on the fleet model: "open the photos app and find the pictures
about dogs" → launch (running 4 s later) → search, summary, search →
the answer, one turn, the tile beside the pane. ai-services 38, wm 153,
aichat 2; aichat checks for wasm32.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:23:15 +02:00
Admin
73ffe87e8b Merge branch 'work' into webdemos 2026-09-02 18:11:05 +02:00
Admin
d1910b8fe4 web-server: a store snapshot's extensionless routes are served with the types the exporter recorded
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 18:11:05 +02:00
Admin
8b46ce0279 aichat: the composer's hint is a dark grey Ask AI, not the typed colour
The placeholder used the focused-text colour, and the composer is
always focused, so the hint looked like an entry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:09:14 +02:00
Admin
4ca36c1d92 ai hub + services: the assistant's model comes from wherever it is resident — the fleet chat box, with tools, then the local weights
The hub chat session's election no longer stops at this machine: a
co-located serving holder first, then a fleet chat node heard by
discovery and role-allowed for chat (a short patience for beacons),
then the weights on this machine — and an honest answer naming both
misses and where weights may be put when none of those exists. Tool
packs ride every route: the system text carries the tool table as the
node's chat_system, one splitter strips thinking and collects
<tool_call> bodies across deltas, the in-process worker's parser is the
one parser, and tool results go back as tool turns. A node that fails
mid-turn ends that turn with the node named; the person's next line
re-elects, served first on the new route. The tools-only guard on the
proxy is gone. The session exposes its route, and the panel's chip
shows it.

The local lookup is independent of the working directory: the env
override, then the makepad home's weights (Qwen preferred, largest),
then the checkout the binary came from, then the cwd — so an assistant
launched from a binary copy finds the same weights as one run from the
checkout, and the Local provider always builds even with none.

Proven live with no local weights: from the sheets overlay a plain
line reached the fleet's 27B (the node with the model resident won the
pick), the model called sheets.summary and answered in 12 s; the app
stayed at 254 MB. hub_chat + local_llm 11, services 34.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 18:06:00 +02:00
Admin
82ac76882e photos: the picture wall as an app — the SMBC archive on the tile engine, a module from its first line; fabric in the launcher
apps/photos (lib + bin) puts libs/image_tiles' TileGrid in a tile: it
opens a baked library found by name (IMAGE_TILES_HOME, the cwd's
local/image-tiles, the checkout the binary was built in — the smbc
collection first), shows a status line with the bake command when
there is none, and exposes photos.search (every query word in a title
or link), photos.show (the camera glides onto one picture) and
photos.summary — the same answer for the port and for the module's
executor. PhotosModule mints the view inside an isolate with an open
schema that takes a collection name, never a path; the WM links it
behind app-photos and lists it in the menu. image_tiles learned to
decode GIF and WebP and to read a manifest's pictures from disk, so
the bake can take local/smbc's mirror: the last 300 comics are baked
under local/image-tiles/smbc (293; seven of the mirror's files are
saved error pages). fabric joins the launcher with a title and a
polite close.

Driven hidden: the wall in a process tile beside the left pane, panned
and zoomed; search and show cards; the same wall as an in-process
module; fabric launched and closed; standalone photos with the F10
overlay on the left. photos 7, image_tiles 5, wm 152; the photos lib
checks for wasm32.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:59:12 +02:00
Admin
6105b61e6e sheets + files: the assistant reads and writes cells, makes folders, renames and trashes
sheets: read_range (one cell or an A1 rectangle, up to 2000 cells, rows
as tab-separated display values), find (case-insensitive over the used
range, 50 hits), write_cell and set_range (Act) — edits go through the
same commit path as paste and direct entry, so recalculation, undo and
the chrome all see them; the module executor answers the same tools.
files: mkdir, rename (a bare name, no separators) and trash (into the
app's own trash dir, never a delete) — Destructive, so the router shows
a confirm card first; every path through the jail, on the demo VFS as
on disk. Written by the delegate from the existing shapes; sheets 88,
files 158 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:58:35 +02:00
Admin
43c36c1a36 Merge branch 'work' into webdemos 2026-09-02 17:57:19 +02:00
Admin
3c033977f8 geodata: a data library keeps its own clock — the GUI platform is not a dependency
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 17:57:18 +02:00
Admin
7f1fefd56d terminal + browser: the assistant can read the screen, run a command, read and steer the tabs
Two services on the AI bus. terminal: read_screen (the visible grid as
text, cursor and cwd in the note), read_scrollback (up to 2000 recent
lines) and run — which types the line into the live, unsandboxed shell
exactly as the person would, refuses control characters and anything
over 4 kB, and answers at once so the model reads the outcome with
read_screen next. browser: page (the active tab's title and url — CEF
exposes no page text, so none is promised), tabs, navigate and new_tab,
http(s) or about:blank only. Context lines: the shell's cwd, the active
tab. Written by the delegate from the sheets/files shapes; terminal
331 + 5 tests, browser checks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:55:54 +02:00
Admin
4e58ab9dec vj: the output window exists only once opened — never on the web
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 17:52:32 +02:00
Admin
f80895accd Merge branch 'work' into webdemos 2026-09-02 17:47:46 +02:00
Admin
7deb052ad1 web-server: ETags from size and mtime, never from content; upgrades only where a socket route exists
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 17:47:46 +02:00
Admin
4317f58804 sim + render + chat: walk decks as a surface, the filmed body is no obstruction, the brief never asks
- libs/sim: DeckStrip (deck.rs) — every laid corridor registers its walk
  deck; movers pin to it exactly instead of climbing 2.5 m slab kerbs;
  feet inside a static box lift onto its top; the world composes floor,
  terrain, materials and voxel in one sampler
- libs/sim + libs/render: camera_path_limit takes the filmed body and never
  treats it as an obstruction (the coaster's ride boom was clamped to 1 m);
  the shadow gate's settle test gets a hair of float slack
- libs/asset/chat: the in-game builder never asks before building; the
  brief teaches game.driver and the race countdown, under budget

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:41:08 +02:00
Admin
dbe43bd327 wm + widgets: the AI panel on the left, pushing the body in
Decision 17: the chat never sits over the app. Under the WM the pane is
a left column of the desk row — its walk width is the reserved strip,
eased from nothing to the card plus a gap as it slides, the card
right-aligned in the strip so it comes in from off the desk's left edge,
its edge on the right — and the desk's layout takes what is left, tiles
snapping to it while the strip moves the way they follow a drag. In a
standalone Window the F10 slot does the same: it reserves the strip as
the body's left inset every frame, lays the body out beside it, and
reads the app's own ground colour after its retint. The background
switcher's index lives on App, not in a static.

Verified by hidden grabs: a terminal full width; F10 → the pane on the
left and the desk narrowed by 450 px with the terminal filling it; F10
→ full width again. sheets standalone: F10 → the grid starts right of
the card, nothing over cells; F10 → back. wm 152, widgets 138 (+ the
two known widget_tree failures), sheets 84; the WM checks for wasm32.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:38:39 +02:00
Admin
0b2be94917 Merge branch 'work' into webdemos 2026-09-02 17:35:00 +02:00
Admin
e6a305c7e7 ai-hub + dj-pack: a whole track fits a stems job; long tracks split into spans
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 17:35:00 +02:00
Admin
82def6b25b wm: every app under the desk — the chat keeps the keyboard through an automatic refocus, a pool that gives up, polite closes for browser, sheets and aichat
Driven native, one app at a time, with the assistant up at boot:
terminal, files, task and browser warm-adopted; sheets, image, video
and pdf launched cold; sheets also as an in-process module; the tools
answering through the pane (os.list/launch/close/open, files' listing,
a refused jail escape, a treemap, sheets' summary both ways).

What broke and is fixed: when a window closed or a client died the
layout's automatic refocus took the keyboard from the OPEN chat, so an
os.close typed in the pane sent the next console line into a sheet —
the refocus now leaves the keys with the pane while it is open (a click
on a tile still moves focus, through focus_client). A warm spawn that
cannot start (an installed layout without the binary) counts as a crash
so the pool gives that app up instead of retrying every tick. browser
and sheets link the WM API: a title in the bar and a polite close on
CloseRequested; aichat quits on CloseRequested (the desktop going down).

Measured (WM log, launched → first flip): warm-pooled apps 25–50 ms;
cold launches through cargo's freshness check 0.7–3.7 s (up to 14 s
under a parallel launch's target lock); the same binaries run plain
0.3–1.5 s after macOS has scanned them. wm 148, sheets 84, aichat 2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:24:54 +02:00
Admin
6d47c3f5ac wm: the omarchy themes moved to omacom/omarchy — the importer follows
basecamp/omarchy answers 301 on the API and the importer fetched no
wallpapers; the repo is omacom/omarchy now, same quattro branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:11:26 +02:00
Admin
341f66d10a Merge branch 'work' into webdemos 2026-09-02 17:06:28 +02:00
Admin
d9f04fcabf dj-pack: pack reads caches, never creates them; dry-run writes nothing
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 17:06:28 +02:00
Admin
d3cd233e23 wm: one desk for every target — the host seam, the assistant seated in-process, the web profile, and the assistant up at boot
The desk (layout, tiles, the shell, the AI pane, the module host, the
bus) is one program everywhere; what differs around it is asked through
host.rs: a monotonic clock in platform seconds (std::time traps on
wasm), whether child processes exist at all, a child's environment, the
theme choice kept in the desk's own storage namespace. Every Instant in
the WM is gone; the hub, the warm pool and process launches are skipped
where there are no processes, and the bar's clock falls back to the
platform epoch when it cannot fork date.

The pane has a second body: where the chat is linked in as a module —
the web build, or a desktop that switched aichat to module hosting — the
pane instantiates mod.widgets.AiChatOverlay by name, exactly as a
Window's F10 slot does, and the WM's own os service and every module
instance reach it as in-process links (pane_links.rs), never as frames.
The registry's web profile makes every linked module a Module and
answers os.launch of anything else with unavailable. sheets' Open and
Save go to the instance's storage jail, asynchronously, on every
platform.

The assistant starts with the desktop (decision 15): the aichat child
(native) or the overlay (in-process) comes up at startup with the pane
closed and no focus taken, drawn just off the desk's edge so the child
is configured and presents before any F10 — which now only slides it
in. The gallery fixture leaves the desk's DSL for a by-name host (its
construction overflowed the wasm stack).

Platform bug found by the web drive and fixed in libs/wasm_bridge: the
ToWasm reader added a block's absolute end to a relative base, so two
JS→wasm messages in one buffer decoded garbage and trapped; it seeks to
the stored end, with a batched-buffer test.

Verified natively by a hidden drive: the child's first frame 1002 ms
after boot, before any F10; F10 → the pane at once with the composer
focused; F10 → gone. On the web (threaded build, 1.5 MB brotli, COOP/
COEP): desk, pane with No model, os.list, an in-process Sheets tile and
its summary, terminal answered unavailable, zero traps. wm 148, sheets
84, app-module 3, wasm-bridge 1; wasm32 checks and the std::time clippy
gate clean for wm, sheets, app-module, aichat, wasm-bridge.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 16:57:45 +02:00
Admin
6857d86461 files: the projected treemap clips boxes at the near plane and keeps the camera above them
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:51:13 +02:00
Admin
94b8726ffb dj-pack: tracks in, stems through the hub, a site store snapshot out
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:50:11 +02:00
Admin
0684b4123d asset-store: export-static reads every content schema the server serves
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:49:57 +02:00
Admin
5d007e0373 files: an unset projection preference means 2.5D
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:44:42 +02:00
Admin
0105fa5cd8 files: the space view opens in 2.5D
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:41:53 +02:00
Admin
9630337538 asset-store: export-static selects what the namespace holds and says why when it does not
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:39:46 +02:00
Admin
511048ca85 vj: no filesystem paths on the web startup path — the store is the disk there
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:34:27 +02:00
Admin
412b3c920e files: the space view rescans through the virtual filesystem — the web has no other disk
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:32:06 +02:00
Admin
106b38cede wasm bridge: the imported memory honours the module's declared limits
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:28:44 +02:00
Admin
aa7345d8c5 vj: the whole app on the web — site store for content, embedded store for local imports, native-only seams Unavailable
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:23:52 +02:00
Admin
6be1118b2d vj: the whole app on the web — site store for content, embedded store for local imports, native-only seams Unavailable
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:12:34 +02:00
Admin
58ad22e94b Merge branch 'work' into webdemos 2026-09-02 16:00:18 +02:00
Admin
7613f3ef2f web-server: one deadline per response from its size, 404 before 405 for unknown API paths, one Earth radius
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 16:00:18 +02:00
Admin
6b77318209 chat: the in-game builder never asks before building
"make me a giant forest with roads" answered with a plan and "Want me to
go ahead and build it now?" — the player is holding a controller. The
agentic doctrine and the game brief now say it outright: a request is an
instruction, decide the open details, build in this turn, report in a
sentence; a turn that ends in a question is a failed turn.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 15:45:03 +02:00
Admin
6f8c08f4a6 web-server: POST /api/crash stores crash reports in a rotating log on both servers
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:44:46 +02:00
Admin
640602cc84 Merge branch 'work' into webdemos 2026-09-02 15:42:27 +02:00
Admin
6c32c88d1a web-server: ETags from size and mtime, deadlines from size, Allow per resource
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:42:27 +02:00
Admin
4946c9eb0d sim + render: a repaint is not a world edit — colour and glow restyles never rebake the lightmap
Crossroads rebaked its lightmap on every stoplight phase (70 bakes a
minute): the traffic kit's lamp repaint bumped the one static revision
the renderer keys its slabs, occlusion, shadow receivers AND the GPU
lightmap kick on. The world now carries a paint revision beside the
geometry revision; the static slabs repack on either, the bake kick
only on geometry. Tests pin both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 15:41:21 +02:00
Admin
9c9c72ba8d web-server: bodies land before workers, client keys normalized, verbs fail closed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:36:09 +02:00
Admin
441d604e5f vj: dropped or picked audio publishes into the connected store
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:32:39 +02:00
Admin
22faab2c8b Merge branch 'work' into webdemos 2026-09-02 15:30:29 +02:00
Admin
cf2b8ca4bd platform: no std::time on web — clippy guard and the platform clock everywhere a web build runs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:30:29 +02:00
Admin
d3472ebd35 tsdf: the clock-taking XR helpers are native-only — the browser has no depth camera and no Instant
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:29:00 +02:00
Admin
faf8112816 web path: the tessellator's lap timer, the trace span and the fusion cycle timer have no clock on the web
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:27:30 +02:00
Admin
82d0cfaab9 web path: the trace helper keeps its doc, the journal nonce steps a counter where there is no clock or pid
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:24:00 +02:00
Admin
0f967ce382 web path: the stream trace and the sqlite pager never wait on a clock the browser does not have
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:23:10 +02:00
Admin
942ff86df3 sqlite: the browser store has one owner — its locks never wait on a clock
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:21:02 +02:00
Admin
670cc76977 asset-store: the platform storage API is a hard dependency on the web, where the embedded store is the only store
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:17:16 +02:00
Admin
77d91385fc wm: the module contract and the first in-process app — sheets in a tile, in an isolate of its own
libs/app_module (makepad-app-module) is the contract (aicontrol §3):
AppModule (id, label, register into an isolate, an OpenSchema, create
into InstanceParts, capabilities), InstanceHandles (an InstanceScope
owner token, the storage jail as a cx.storage namespace, the viewport,
a ReplySink for calls that finish later), a ServiceExecutor addressed
apart from the root, and an OpenSchema that refuses raw paths — a file
is a handle the host issued or nothing. Only types live there.

The WM hosts one (module_host.rs): allocate the isolate, retint its
theme from the palette, register, create — one trusted entry into the
isolate, never a second &mut Cx beside the VM; teardown drops the root
first, runs shutdown in the isolate, frees it. MpModuleView is the tile
for an instance's root, drawing and dispatching with the isolate
INSTALLED on Cx (enter_isolate/leave_isolate, new in widgets) and
gating keys on the WM's focus; a TileHost trait covers both tile kinds
so the desk and the focus logic never ask which. The registry overlay
(apps.rs) links modules per app-* feature; the desktop default stays
Process, switched per app in ~/.makepad/wm/apps.splash or by a dev
--module flag. The bus gains its in-process leg: an instance is an
m<id> endpoint the pane addresses like any other — the leg the web
superbuild runs everything on.

sheets is lib + bin: SheetsModule mints MpSheets{} in the isolate and
answers sheets.summary on its executor; the standalone binary keeps its
Window and F10 overlay behind the standalone feature.

The drive found a platform bug: a shader compiled on a widget minted in
an isolate hit the draw-shader object cache by object index alone, so
an isolate's Button reused the main heap's entry and drew with zero
instance slots. The cache is keyed by (heap, object) in every backend,
and add_instance refuses a zero-slot draw call instead of dividing by
it. Isolate cost, measured (the P2 entry gate): 12 ms and ~8 MB per
isolate, flat to 32 — no pre-warming needed.

Verified by hidden grabs under the WM: /os.launch sheets → an
in-process Sheets tile (no child process), the apps row Desktop ·
Sheets, /sheets.summary answered through the in-process leg, a warm
terminal beside it, F10, close → the instance torn down and its isolate
freed. wm 145, sheets 84, app-module 3, services 33; platform and draw
check for windows-msvc, linux-gnu and wasm32.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 15:16:42 +02:00
Admin
9942e26f48 asset-store: the embedded module lives in its directory now (the browser-durability landing's deletion)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:16:05 +02:00
Admin
372bfd7960 asset-store: browser durability — generation extents over cx.storage, chunked CAS, quota and GC
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 15:14:48 +02:00
Admin
22b2bf9fc3 sim + chat: the composed world surface takes a map floor; the chat gets plan tools
- libs/sim: meshfloor.rs FloorRaster (one storey per column from a map's nav
  graph); composed_surface_sample_at(floor, terrain, materials, voxel) is the
  one seam nav, corridors and lots read; in-eval voxel ops no longer bump the
  history revision
- libs/asset/chat: world.get_plan / world.set_plan (shape-validated, stale
  revisions refused), the authoring state prefixed to every turn, map turns
  routed to the plan tools; game.md: the foundation line, the capability kinds
  verbatim, no order-independence claims

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 15:02:46 +02:00
Admin
0ecb81cf56 ai models: the model-crates env-var cleanup — 172 research knobs gone, the unset default is the code
The per-family model crates (flux, h3, paint, music, speech, stems,
vision, sfx, rife, trellis, beats, common) and the libs/diffusion
research binaries read some 190 environment variables that were
research knobs: tensor dumps, per-stage timing, oracle-parity and
fixture rigs, experiment toggles. The path taken with none of them set
is the one that ships; every such knob is deleted with the code it
gated, and every losing branch of an experiment toggle goes with its
toggle — dead kernels, fields and functions included. What remains are
the real configuration variables (the FLUX_*_MODE family, FLUX_GRAPH,
the VAE pool cap, the FLUX2 text-encoder residency, H3_VAE_BATCH, the
music3 caches and official modes, the stems/beats f16 switches, the
weight and data roots) and the build-script variables.

Rebased on the runtime cleanup: precision stays explicit everywhere
(GemmPrecision, f16_attention_operands, the H3 text precision, DA3's
StrictF32 in code); no act16, no H3_ACT_F16, no FLUX_ATTN_F16 or
FLUX_VAE_CONV_GEMM reads survive.

Reviewed by the delegate reviewer (APPLY, no findings) and gated on the
Windows CUDA box: all seventeen model crates check, motion and vision
tests, the hub and the diffusion bins — the gate caught one CUDA-only
tap marker the Mac never compiles, removed here. On this Mac: the same
checks plus motion 24, paint 158 and vision 23 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:50:06 +02:00
Admin
f51ca0795e workspace: the wasm interpreter's tests build at opt-level 1 — its own profile setting is ignored inside a workspace, and opt-level 0 overflowed the script eval stack
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:45:08 +02:00
Admin
1b7089962a files: the space view is where a tab starts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:42:22 +02:00
Admin
fd5d70c108 cargo-makepad: the app's own resources are packaged under its bin name, which is how self:// resolves
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:42:22 +02:00
Admin
04a94ef780 realtime: one service-log line when a live session opens and one when it closes
A headless box's service log showed only the startup banner for a whole
session; now it records the model and wire encodings on open, and on close the
elapsed time, frames in/out, fps, and the dropped/undecodable/unencoded counts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:41:09 +02:00
Admin
6b661bf4c5 web: crashes report themselves — panic text, breadcrumbs, memory, workers; a dead instance stops pumping
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:37:51 +02:00
Admin
dfe2087399 web-server: O(1) report admission, one connection deadline, shared route sampler, static fallbacks, cache policy
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:37:36 +02:00
Admin
fb1416d8c0 cargo-makepad: the threaded wasm module is linked with the 4 GiB wasm32 memory ceiling
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:37:26 +02:00
Admin
f6bbee90ef wasm bridge: shared memory asks for the 4 GiB wasm32 ceiling and steps down where the engine refuses
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:33:35 +02:00
Admin
b0646d0714 Merge branch 'work' into webdemos 2026-09-02 14:31:16 +02:00
Admin
232909d16a script: the VM reaches std and its slot through one host — no aliased references
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:31:15 +02:00
Admin
aa816ed8a6 windows h264 decoder: pictures come out one access unit later — rewrite the SPS level so the DPB is one picture deep
Measured on the body node: the Microsoft H.264 decoder accepts MF_LOW_LATENCY and
CODECAPI_AVLowLatencyMode and ignores both; it holds pictures until the DPB it
derives from level_idc is full (six access units in, nothing out until DRAIN), and
appending access unit delimiters changes nothing. With level_idc rewritten to 1.0
in every SPS the DPB is one picture and each picture is emitted as the next access
unit arrives (5 of 6 while streaming, the last on flush). flush() now sends
COMMAND_DRAIN. The captured-stream test replays dumped access units and requires
the pictures while streaming; the keyframe-request check is its own test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:30:44 +02:00
Admin
2b035ff33c route: the navigation session keeps time on the platform clock — the std clock traps on wasm
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:28:20 +02:00
Admin
ff62db3c5e ai libs: the runtime env-var cleanup — precision is a per-caller policy, not an environment side channel
The AI runtime crates read their precision, activation and kernel
choices from environment variables; the code path taken with none set
is the one that ships, so every knob that selected it is now an explicit
argument and every losing path is gone. GemmPrecision { f16_accumulate,
f16_activations } is passed by each caller: the default {true, true} is
the old unset Flux route; H3's DiT and text encoder pass {false, false}
(H3's >1e4 activations saturate f16 — the policy H3 used to set through
FLUX_GEMM_F16ACC=0 on itself), its VAE {true, false}; DA3's StrictF32
selects f32 packed attention in code; Hy-Motion carries an explicit
f16_attention_operands flag through its text refiner, its double and
single blocks and the CUDA backend (true in production, false only in
its full validator). The libs/diffusion bins — a separate workspace —
are migrated to the same shapes.

Benches and validators no longer set variables on themselves: llama's
skip-logits is a session option (the CUDA bench turns it on), OCR takes
explicit use_f16_gemm and tiled_roformer options, the lane speculative
probe reads its CLI. The live gates the first cut had deleted are back
as explicit-config tests: MMQ M=129, the strided-f32 MMV path, the
RMS+MUL CPU oracle. The loader's THREADS and CHUNK_MB stay real settings.
The Metal quantized-matmul experiment (metal_qmm and its vendored MLX
kernels) was reachable only through a knob and goes with it.

Reviewed in three rounds by the delegate reviewer (the last round
accepted everything but one Hy-Motion call site, fixed in round four
and reviewed here), and gated on the Windows CUDA box: lib checks of
common/paint/loader/cuda/llm/motion/vision, motion 24 and vision 23
tests, the hub check, the diffusion bins, llm 253 passed / 1 ignored.
On this Mac: the same checks plus the motion and vision tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:26:48 +02:00
Admin
c24c2064fd aichat: the Window overlay — F10 in every standalone app, the in-process port, the /ai bridge routes, sheets as the pilot
Every Window now carries an AiChatSlot beside the tweaker: zero cost
while off, inert when the window manager hosts the process (the WM's
pane is the chat then), and on the first bare F10 it instantiates the
chat's module root BY NAME — mod.widgets.AiChatOverlay — which exists
when the app links makepad-aichat and calls its script_mod; an app that
does not gets one log line. The overlay slides in from the right over
the body with the WM pane's motion, owns the pointer inside its rect and
the keyboard through its composer, keeps ticking while hidden, and draws
in the window's retained overlay list so it composites over the deferred
body. Requests from outside the tree ride Cx globals, never statics.

An app exposes itself with one call: AiServicePort::open(cx, manifest)
is the hosted transport under the WM and, standalone, an in-process link
parked on Cx (PendingServiceLinks) that whichever chat root is up adopts
into its registry — the overlay today, the superbuild's pane later. The
registry wakes the UI when it sends, so an in-process call is answered
without waiting for a pointer event. NoModel leaves the engine feature
so a build without a model runtime still answers honestly and keeps the
tool console; aichat's engine is a default-on feature.

The bridge: Cx::ai_callback beside tweak_callback, /ai?on=1|0, /ai?say=…
and /ai/transcript (the overlay publishes the transcript as JSON after
each state change), installed by makepad_aichat::script_mod.

sheets links aichat and exposes sheets.summary (name, used range, header
row, selection) through the port: standalone the overlay answers it,
under the WM the bus does.

The local model no longer loads on a registration: its session starts
on the first user line, so an app joining the bus costs nothing.

Verified by hidden grabs: sheets standalone /ai?on=1 → the overlay with
the summary card and no model; F10 closes it; under the WM the sheets
tile shows no overlay and F10 toggles the WM pane. widgets 135 + 4
(two widget_tree find_within tests fail before and after this change),
aichat 33, sheets 83 + 2, services 29; widgets and aichat check for
wasm32.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:24:41 +02:00
Admin
e2a262b05a asset-store: the portable core — embedded feature, page-store and CAS seams, a synchronous in-process API
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:14:27 +02:00
Admin
19cf377238 platform: the thread runtime contract — spawner, tasks, pools, scheduler, UI waker
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:14:15 +02:00
Admin
d76abf387e Merge branch 'work' into webdemos 2026-09-02 14:14:14 +02:00
Admin
ab8febc66d cargo-makepad: fonts packaged from the app's font manifest
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:14:14 +02:00
Admin
d1e7a6fea8 windows h264 decoder: AVLowLatencyMode is a VT_UI4; the round-trip test tolerates the MF encoder's access unit delimiters
The Microsoft H.264 decoder answers a VT_BOOL CODECAPI_AVLowLatencyMode with
"VT_UI4 != pValue->vt" (seen on the body node), so send the number. The
Media Foundation encoder opens each access unit with NAL 9, which the
keyframe check now skips.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:13:54 +02:00
Admin
05fd7e2dae Merge branch 'work' into webdemos 2026-09-02 14:12:33 +02:00
Admin
611c9ebd8c cargo-makepad: production packaging stays off fat LTO; script VM under LTO investigated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:12:33 +02:00
Admin
2e48c5bfb7 asset-client: the static-store read mode and polled runtime
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:10:50 +02:00
Admin
acad401218 sim: an agent with no route holds and retries instead of walking into the wall
A partial route to the nearest reachable cell, a hold at its end, and
re-planning with backoff (15 to 240 ticks) replace the old head-
straight-at-it fallback; cell derivation prefilters entities per chunk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:10:23 +02:00
Admin
dcf7d21060 windows h264 decoder: ICodecAPI low-latency, per-pump trace, access-unit dumps, and the stream tests run on Windows
Trace every ProcessOutput HRESULT/status, keep the first access units beside the
trace so a stream can be replayed offline, ask for CODECAPI_AVLowLatencyMode through
ICodecAPI as well as the MF_LOW_LATENCY attribute, and let the round-trip test plus a
captured-stream replay test run on a Windows box.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:08:57 +02:00
Admin
60ee97858c cargo-makepad: package artifacts carry a content hash so a re-upload is a new URL
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 14:03:59 +02:00
Admin
d6cc49ad22 windows mft: PROVIDES_SAMPLES is bit 0x100, not bit 0
The Microsoft H.264 decoder reports output stream flags 0x107 (whole samples,
single sample per buffer, fixed size); reading bit 0 as "the MFT allocates its
own samples" handed ProcessOutput no buffer and every call failed with
E_INVALIDARG — seen in the MAKEPAD_H264_DEBUG trace on the body node.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 14:00:58 +02:00
Admin
c1febc7985 windows h264 stream decoder: low-latency mode, output type before first ProcessOutput, real MF_E codes, trace file
The live decoder never produced a frame on the body node: MF_E_TRANSFORM_STREAM_CHANGE
was hand-derived as 0xC00D6D60, which is MF_E_TRANSFORM_TYPE_NOT_SET; the real stream
change (0xC00D6D61) was treated as a hard error, the output type was only negotiated
lazily, and without MF_LOW_LATENCY the decoder holds a reorder window a 2-3 frame live
pipeline never fills. Set MF_LOW_LATENCY on the transform, commit NV12 before the first
ProcessOutput, re-negotiate on TYPE_NOT_SET/STREAM_CHANGE/BUFFERTOOSMALL, drain on
NOTACCEPTING, and stamp packets with monotonic 100 ns timestamps. MAKEPAD_H264_DEBUG=<file>
traces packets, HRESULTs, negotiations and frames for headless services.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 13:55:28 +02:00
Admin
4081e48c35 Merge branch 'work' into webdemos 2026-09-02 13:54:36 +02:00
Admin
e5d37e0b0b platform: a web file picker and file drop that hand apps bytes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:54:36 +02:00
Admin
2f5a9849c6 files: the file browser on the AI bus — four read-only tools through a correlated, cancellable runner
Hosted by the window manager, files opens one AiServicePort with a
manifest built from the same table the app's own panel reads its tool
specs from, so the two can never drift: list_dir, read_file, stat,
treemap_summary, all Read. The calls run on a worker of their own
(ai_service.rs): every job carries the engine's call id and a cancel
flag the folder walk checks on every entry, a queued job cancelled
before its turn never runs, treemap_summary reports a permille per
direct child, and results come back by id whatever the order. The old
panel's order-only runner stays beside it for the old chat, behind the
chat feature it belongs to.

The port opens at startup unless the process is a warm-pool standby,
which waits for Adopted so a dormant instance never shows as a running
Files; a context line (folder, view, selection) follows every change;
ChatOpen from the desktop closes the app's own panel; the jail resolves
every path exactly as before.

Verified by hidden grabs under the WM: /os.launch files → the pane's
apps row reads Desktop · Files; list_dir, a refused jail escape, and a
treemap summary answered as cards. 151 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 13:45:13 +02:00
Admin
de937ac01c route: the web build is a one-to-one recompile — native UI, service seams behind it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:43:19 +02:00
Admin
84b46c11a8 mbtile reader: file-backed readers are native-only so the map stack builds for wasm
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:42:30 +02:00
Admin
17437a2f53 Merge branch 'work' into webdemos 2026-09-02 13:35:43 +02:00
Admin
4d75e65b13 web server: hardening after security review — worker-only parsing, FD-relative static opens, strict framing, bounded work, panic recovery, Cloudflare-aware limits
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:34:33 +02:00
Admin
b0e34c2f57 wm: the AI pane and its bus — the aichat child seated in the slot, F10, the os service with the typed open
The chat is a child app (apps/aichat) seated in the WM's right-side pane
instead of a tile. The WM gives it the slot (shell/ai_pane.rs: 440 px
clamped to 40 % of the desk, easeOutQuint in/out, opaque card with a
2 px edge, one MpRunView that keeps ticking the child while hidden), the
bus (ai_bus.rs: up-frames stamped with the WM's own endpoint ids, every
registration remembered and replayed when the pane connects, down-frames
routed by endpoint, a synthetic Unregister when a client dies, ChatOpen
broadcast on every slide) and its own os service on that bus: list (text
+ JSON rows), launch/focus/close by registry id, and open through the
same typed OpenRequest a file browser's double-click takes.

Bare F10 toggles the pane before the keymap and before any tile
(decision 8); the pane owns the pointer inside its rect and the keyboard
while open, claimed after its run view's next draw; the child's Close
means hide; its death empties the slot; shutdown kills it. The pane's
child is invisible to everything the desk enumerates. The WM declares
its font set (International, with the emoji family it uses).

aichat: the composer takes the keyboard after its first draw and keeps
it across sends; the running card's bar is a pixel width.

Verified by hidden --remote grabs: F10 → the pane with the os
registration in the apps row; /os.list, /os.launch terminal (tile beside
the pane, keys stay on the chat), /os.focus; F10 again → pane gone.
142 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 13:29:04 +02:00
Admin
1940f3da25 fonts: leave apps/wm untouched — its font-set declaration waits for the aichat lane to land
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:23:12 +02:00
Admin
6981e9052b fonts: manifest generated from the chains, app-level font assets, a symbol fallback, deprecated i18n aliases
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:22:42 +02:00
Admin
942476f68c vj: the whole app type-checks for wasm32 — portable hub discovery and typed-unavailable client facades
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:21:14 +02:00
Admin
f233756d22 game brief: races, rings, mocap and biomes are one call each
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 13:18:36 +02:00
Admin
89fe45355b platform: wheels and flight sticks are game inputs, with an output-report handle
IOHIDManager claims known wheels and joystick-class devices, parses
them by HID usage (steering, pedals, twist, throttle, hat, buttons) and
hands out an IOHIDDeviceSetReport handle so an app can drive force
feedback from userspace — no kext, no DriverKit. The studio wire learns
RemoteJoystick.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 13:18:36 +02:00
Admin
c3f631d4e3 livepipe: one reusable pipe from a camera to a fleet node and back
Source (webcam by device, a still for tests), one VideoToolbox encoder
session per run for the H.264 wire with raw as the fallback, the hub
realtime session on a node chosen by domain, typed events (status,
connected, aux, rate, ended) through a mailbox to any consumer. The
sandbox's mocap is the first; fabric is next.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 13:18:36 +02:00
Admin
730b5b8e08 video: the Windows H.264 stream decoder pulls output before it knows the format
The decode loop returned before ProcessOutput until the output type was
negotiated — but the MFT only reports MF_E_TRANSFORM_STREAM_CHANGE, the
event that starts negotiation, FROM ProcessOutput. A node fed Annex-B
access units never yielded a frame. Now the pump always pulls, a stream
change picks NV12, sets the type and refreshes size and stride, need-
more-input ends the pump, and rows are de-strided on the way out. The
decision is a pure function with a regression test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 13:18:36 +02:00
Admin
79e7526b1e sqlite: a page-store seam — the file backend as before, an in-memory backend, and open_memory / open_with
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:18:18 +02:00
Admin
a6d0338ad2 widgets: popup menu items run in the popup owner's script VM; app_main! releases its borrow after a trap
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:15:40 +02:00
Admin
d931f9d764 map: tile archive third pass — per-pass pruning, validated watcher metadata, shared blob bytes, timer watchdog, atomic cancellation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:14:57 +02:00
Admin
52251b77c2 platform: a namespaced async key/value storage API on Cx — files natively, IndexedDB on the web
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:06:35 +02:00
Admin
e21db96d50 route: demo review fixes — route origin, rain lifecycle, hosted route validation, request context, provisioning
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 13:06:09 +02:00
Admin
81ef71c38c network: native backends honour the response body cap
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 12:52:06 +02:00
Admin
a7eae82e41 map: the tile build keeps time with the platform clock
std::time::Instant trapped on every wasm tile worker; the profilers and stage timers
now use Cx::monotonic_now, and the native watcher, writer and their tests are gated
to native targets.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 12:43:31 +02:00
Admin
38fe712728 Merge branch 'work' into webdemos 2026-09-02 12:41:06 +02:00
Admin
09b41a8dc9 fonts: FontSet and FontPolicy — one application choice, selected-only loading, a font-assets manifest
Latin (IBM Plex) is the web default, International (adds the CJK and emoji faces) the
desktop default; app_main! records the choice before widgets register, the theme only
registers the selected chains, a glyph miss never starts a load, and the resolved font
resources are emitted as the makepad.font-assets.v1 section for the packager.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 12:40:50 +02:00
Admin
e0c6e74bf2 aichat: the progress bar and system lines use the theme's highlight colour
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 12:37:33 +02:00
Admin
7b9ed2c3de aichat: the assistant as an app — the panel owns the engine, the bus client, settings with the local-only lock
apps/aichat is a lib and a bin. The panel widget owns the registry, the
model and the transcript, draws EngineState as user lines, markdown
answers, tool cards with progress and confirm buttons, and system lines
over a composer; every event pumps the engine and a busy turn asks for
the next frame. The bus client turns the window manager's stamped
studio frames into registry links under the WM's own endpoint ids
(registry.register_as). Settings persist under ~/.makepad/aichat with
the cloud lock kept in code. The binary is both the standalone window
and the WM's special child.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 12:33:32 +02:00
Admin
fde3f398d9 Merge branch 'work' into webdemos 2026-09-02 12:29:56 +02:00
Admin
2308736a2a ai-services: the real models behind the engine feature — local through the hub, Claude, and none
LocalModel drives the hub chat session; a changed tool table or system
prompt rides the next turn as an update block instead of restarting an
append-only context. ClaudeModel owns the history and rebuilds the
per-turn provider with the new native tool array, dots mapped to the
double underscore Anthropic allows. NoModel answers nothing but says so,
so the web demo's pane, services and tool console work without a
backend. Provider rows report honest availability, the local model file
policy is one function, and the crate no longer needs the converse seam.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 12:25:56 +02:00
Admin
14d0723872 cargo-makepad wasm: production packaging — strip, small profile LTO, optional binaryen, size report
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 12:24:41 +02:00
Admin
8ae0ffb951 ai-services: the engine core — registry, router and conversation, tested against a scripted model
The registry issues endpoints, answers each port's registration, keeps
where every instance lives, builds the tool table (each app once,
canonical names) and the briefs, pumps the links and forgets dead ones.
The core drives a Cx-free Model seam: routes each tool call to the
instance the model named (or the focused one), stripping the selector;
holds destructive calls for a confirm card, with host risk floors that a
service can raise but never lower; times out silent services, extended
by progress up to a hard cap; answers unknown names with the real ones;
reconfigures the model when the registry moves and restarts it when it
cannot rebind; honours result dispositions (end the turn, reset the
conversation); caps tool rounds; and offers a local tool console that
drives services without a model. Time is host-supplied seconds, never
Instant, so the same core runs on the web.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 12:23:07 +02:00
Admin
5cdcaedb8e Merge branch 'work' into webdemos 2026-09-02 12:20:14 +02:00
Admin
ba1010d7c3 asset-client: review fixes — additive web feature, shared transport contract, guarded cache and base URL
The web feature no longer hides the native API; both transport adapters validate
responses through one upper layer with framing headers refused and redirects off;
the platform transport passes a body cap to makepad-network (honoured by the web
backend); the filesystem cache verifies before deduplicating; BaseUrl canonicalizes
and rejects dot segments; ClientLocation is authoritative.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 12:19:48 +02:00
Admin
2acb7983ab ai-services: wire v2 — endpoints, receiver-side caps, result disposition
An app id says what a service is; a host-issued EndpointId says which
instance. Ports register with a nonce, learn their endpoint from the
host's Registered answer, take only frames addressed to it, and never
stamp themselves on the way up. Hosted frames are refused over a size
cap before deserialization and every variant is validated on receipt;
tool schemas must describe an argument object. Results carry bounded
structured data and a disposition (continue, end the turn, reset the
conversation); Cancelled and TimedOut are outcomes of their own.
InstanceMeta records where an instance lives for the model.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 12:14:52 +02:00
Admin
d292ec7fe4 Merge branch 'work' into webdemos 2026-09-02 12:04:26 +02:00
Admin
126d8c9168 route: the demo profile runs in the browser — merged panel draw, platform clock, unavailable backends tolerated
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 12:04:08 +02:00
Admin
cf4c84c22f keys: F10 is the assistant — the exploded-view debugger moves to Shift+F10, the screen recorder to Ctrl+F10
The platform's exploded z-layer viewer consumed F10 before any app saw
it. F10 now belongs to the AI chat (the WM pane and the Window overlay);
the debugger is Shift+F10 and the recorder Ctrl+F10, beside the
designer's F12. The recorder widget gains a hotkey_ctrl setting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 12:01:37 +02:00
Admin
c55a315515 platform: monotonic clock beside the wall clock, trap-safe dispatch, studio worker only with a studio
Cx::monotonic_now backs every elapsed and deadline measurement (Instant natively,
performance.now on the web, reachable from workers); dispatch keeps its handler
installed across traps and rejects re-entry without panicking; the studio websocket
worker spawns only when a studio is configured and waits with park_timeout instead of
spinning.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:56:54 +02:00
Admin
9633ded6f4 web server: site static serving plus the first nav backends
tools/web_server becomes the deployable site server on our own HTTP stack: Range, HEAD,
brotli negotiation, ETags, cache and cross-origin headers, error reporting, and
/api/healthz, /api/search, /api/route and /api/along over map_nav with bounded query
and route workers and a data directory outside the public root. makepad-network learns
HEAD and exposes raw header lines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:42:36 +02:00
Admin
0fd1ea2e92 route: demo profile — native/demo features, side-panel and provisioning seams, hosted tiles, HTTP nav client
The demo build has no AI panel, no first-run bake and no local files: tiles come from the
hosted .mkmap archive, and search, routing, along-route lookups, weather, radar and wind
go through the site's API with client-side trip state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:41:14 +02:00
Admin
5d28ed551a map: tile archive review fixes — legacy MBTiles path restored, decode off the UI thread, retry, cancellation, blob dedup, validation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:34:56 +02:00
Admin
4144dc414c files: the spy test filesystem implements the clock
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:31:46 +02:00
Admin
9ae91aca3c asset-store: static export contract — versioned static index and a sanitized deterministic export-static tool
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:28:31 +02:00
Admin
fe920c8988 files: review fixes — demo scan exclusions, Zipf sizes, trash root guarded, depth bound, tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:28:30 +02:00
Admin
47ac158a42 asset-client: portable primitives — completion transport, cache store, static-site config
A completion-based Transport with the native TCP adapter and a platform-HTTP adapter over a
client-owned network runtime, a CacheStore seam with the filesystem cache and a memory
LRU, BlobContent / ClientLocation / Unavailable types, and native/web features so the
client builds for wasm32; static-site connect is a typed Unavailable until the runtime lane.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:26:03 +02:00
Admin
68fb182bf4 Merge work into webdemos: the unified trace switch
The startup trace keeps its topic gate and runs on the platform clock so it stays
wasm-safe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:25:03 +02:00
Admin
3c7566a565 Merge work into webdemos: the mp prefix rename
apps/files, apps/sheets, libs/wm_api, libs/wm_theme and the renamed env vars and script
namespaces meet the demo seams; added demo files move to the renamed paths; the files app
keeps its own makepad_home so demo builds without the chat feature do not link the AI hub.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:23:11 +02:00
Admin
3389475d33 trace: one switch — MAKEPAD_TRACE=<topics>, trace!(topic, …), and /trace on the bridge
The per-crate *_TRACE / *_DEBUG / *_DUMP environment switches in
platform, widgets and cef become topics of one process-wide set:
startup, frame, present, timer, studio, gpu.*, shader.*, gl.*,
runview.dpi, wayland, tweak, map.*, cef. Hierarchical matching with
negation and all; read once at init; settable on a running instance
through GET /trace?topics=…; an atomic short-circuit keeps the
no-tracing case off the lock in per-frame code; spans are absent on
wasm32 where Instant panics.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:23:08 +02:00
Admin
debd8c1075 rename: the mp prefix goes — apps/wm, files, terminal, browser, task, sheets, image, video, pdf; libs/wm_api and wm_theme
Packages are makepad-<name> with the short name as the binary. Env vars
follow (MAKEPAD_WM_*, MAKEPAD_FILES_*, MAKEPAD_TERMINAL_*), config moves
under ~/.makepad/<app>/, the theme namespaces are mod.wm_theme and
mod.browser_theme, the hosted AI envelope key is wm_ai. platform/video
becomes makepad-platform-video so the video app can be makepad-video.
Carries the Score entries that were pending in the WM's curated table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:17:12 +02:00
Admin
a7af3ea99a platform + mpfiles: platform clock instead of std::time on the web, unwind-safe event dispatch
std::time is unsupported on wasm32-unknown-unknown; the demo paths now use Cx::time_now
and the pinned demo clock, and Cx::call_event_handler restores its handler slot on unwind
with an explicit re-entry diagnostic instead of cascading into a second panic.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:11:42 +02:00
Admin
0537687b96 mpfiles: distinct repo-owned pictures for the demo thumbnail pool
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 11:04:28 +02:00
Admin
3203e32f4a finance: review fixes — id remapping on persist, structural determinism tests, one cfg seam
Persisting the generated ledger remaps every id through an explicit map so category
parents and scheduled entries resolve in a fresh native database; determinism is proven
by a structural fingerprint plus reference-integrity and transfer-pair tests; native
persistence and import live behind one cfg-gated module interface with a runtime
pinned day (2026-08-28 in demo builds) used by all shared range code.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:58:42 +02:00
Admin
aa96dbd625 cargo-makepad wasm: package the bin target's wasm and create dirs before minifying
A package whose bin name differs from the package name (apps/finance: finance) no longer
fails at packaging; the bin is resolved from cargo metadata, default-run or --bin, and the
minified JS copies are written after their directories exist.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:53:12 +02:00
Admin
58e1f72d72 mpsheets: review fixes for the demo seam
Visibility wrappers around the picker and disk controls, initial document and demo
loading owned by SheetDocs, a clipboard seam instead of a platform cfg in the view,
loading a demo resets history, selection and scroll, ASCII help text, stronger
bundled-document tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:50:51 +02:00
Admin
ad24534b94 map: persistent asynchronous tile archive over a completion-based byte source
libs/mbtile_reader gains pure root/leaf/blob parsing; widgets/map gains archive.rs with
a ByteSource trait, a file backend on the worker pool and an HTTP-Range backend over
cx.http_request, a TileArchive that fetches the root once, caches leaf directories and
dedups in-flight reads, and MapView::set_source_config so local archives and hosted
archives share one request path into the existing tile build and ready-tile delivery.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:50:15 +02:00
Admin
13b4c485e5 mpfiles: demo build — procedural fake filesystem, still-image thumbnails, chat feature
The chat feature (default on) owns the AI hub dependency so a demo graph never names it;
demo builds install a seeded procedural DemoVfs of ~38k files with Zipf sizes and deep
paths, read text through Vfs::read_bytes, take thumbnails from an embedded picture pool
with one still per video, run listing/scan/thumbs inline, and keep prefs in memory.
Process spawning is confined to the native seam.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:48:41 +02:00
Admin
0ccd384a73 platform web: focus dispatch, window-zero geometry and generation-correct ids after the startup deferral
Review fixes: focus/lost-focus events dispatch unconditionally again, only window
zero's creation updates the shared browser geometry, the guarded sites resolve the
generation-correct id of physical slot zero, and four regression tests cover the
deferred-geometry state machine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:47:35 +02:00
Admin
1e93309385 AGENTS.md: designs stay local; no OS screenshots; focus and hidden-window laws
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 10:36:02 +02:00
Admin
daff390fc6 finance: demo feature — generated ledger, SQLite target-gated, import hidden
seed::generate(years, today) is the pure deterministic generator; native first-run
persists it through the unchanged db path, demo builds keep the ledger in memory with a
pinned day so screenshots are stable. makepad-sqlite is a non-wasm dependency and the db
module only exists outside demo builds; the CSV import screen is absent in demo builds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:33:55 +02:00
Admin
c17480faf1 render: a non-rigid body may carry its own orientation
A coaster car upside down at the apex of a loop is a body whose
quaternion is not the default; the renderer now honours it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 10:33:30 +02:00
Admin
5b309b311f vj: 32-bit usize constants no longer overflow on wasm32
The 4-16 GiB cache budgets are computed in u64 and saturate to usize::MAX on 32-bit
targets, same as the shared libs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:31:59 +02:00
Admin
83d8eac9cc mpsheets: demo feature and the SheetDocs seam
A demo build carries three bundled formula sheets instead of disk CSV open/save; the
seam is one trait with a native fs impl and a bundled impl chosen at start, the
Save/path chrome hides behind can_save, and the wasm clipboard write is skipped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:23:47 +02:00
Admin
ed5b2fa509 web: wasm32 portability in libs and web startup geometry deferral
libs/ai/loader, libs/sqlite_query, libs/video_flow: 32-bit const overflows and unix-only
file locking/bulk reads gated so the app crates type-check for wasm32-unknown-unknown.
platform web: ToWasmInit arrived before the app's Window widget allocated window zero and
indexed an empty pool; the browser geometry is now stored and applied once the window
exists, and the other id_zero index sites are guarded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV6BzHQiJEvvK9EPc1d4ks
2026-09-02 10:23:47 +02:00
Admin
876e3c4e18 game brief: buildings have insides — ask for a program at the door
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 09:50:47 +02:00
Admin
d7f9813443 game brief: to change a map, edit the plan you were given; vegetation is a layer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 09:16:57 +02:00
Admin
b94bc58a7f ai-services: the wire, the app port and the panel state — one conversation, many apps
An app exposes an AI service (manifest, tools with a risk class, a port
that receives calls and answers them later); a host aggregates every
connected service into one chat. Hosted by mpwm the port rides the
studio protocol's Custom frames under the mpwm_ai envelope; embedded, it
is a channel pair. The engine behind the feature flag and the panel that
draws EngineState land in the next lanes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 09:15:53 +02:00
Admin
adbb078b68 render: a water volume can be physics without a picture
The water shader blends premultiplied, so an alpha-zero sheet meant to
hide the river's buoyancy boxes became additive and painted bright
stepped rectangles over the ribbon and the meadow — every claim-level
invariant green while the screen was wrong. WaterVolume::draw_sheet
splits physical from visual: river volumes keep floating things and
never draw; declared pools still do.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 04:42:34 +02:00
Admin
e18cc694fc chat: a tool call the model wrapped inside its own think block still executes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 04:42:34 +02:00
Admin
1ef6f59c3e game brief: on a diet and world.plan-first
33.7k bytes to 16.4k: the plan doctrine at the top, the laws, the one-
call list, the build order for a new game, how to read assists and
refusals; per-verb prose left to the verb docs. Back under the context
budget the crate's own test enforces.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 04:11:26 +02:00
Admin
c86a750258 chat: a game session reads its doctrine first and is not the generation assistant
The system prompt chose the generation persona and the <<tool>> JSON
guidance whenever image.generate was among the tools — which a game
session legitimately carries for missing art — and appended the game
doctrine 19k characters later. Against the exact live prompt the model
answered "I can build that for you." and stopped; the same request
with the doctrine first emitted the call. Agentic is now decided by the
presence of world.* tools: doctrine first, an in-game builder persona,
the model's trained tool template, and no duplicate capability text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 03:37:57 +02:00
Admin
f2b02531b7 drumkit: a sample-based kit for the score preview; the physical model parked as drumkit_phys
The kit that plays drum scores is now a sample player over the Salamander Drumkit (velocity layers with round-robins, derived toms, a synthesised clap), loaded off the audio thread and swapped in atomically. The physically modelled kit stays as makepad-drumkit-phys for a later pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:52:12 +02:00
Admin
066c78292e piano model: the lower register is hammered, not plucked
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:52:12 +02:00
Admin
79882b5fb6 fabric: a photo or a live camera to a fitted sewing pattern
apps/fabric runs SAM 3D Body in-process (install + licence through the hub panel), measures the rest-pose body mesh (libs/fabric/measure: 25 tape measurements from plane slices and landmarks) and drafts parametric patterns (libs/fabric/draft: T-shirt, A-line skirt, easy trousers; nesting, true-scale SVG, tiled A4 PDF). Live mode streams the webcam at a few frames a second, shows the posed body with the measurement rings riding on it, and re-drafts when the numbers settle. Measurements sit in a DataGrid that copies as tab-separated text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:51:45 +02:00
Admin
8ea3684250 widgets: EventOrder reachable from the DSL; DataGrid hit-tests where it was drawn
A View can now say event_order: EventOrder.Down in script. The DataGrid cached its geometry at draw time, before a Fill sibling could shift it, so clicks in a right-hand column landed outside; it re-anchors to its drawn rect on every event.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:51:45 +02:00
Admin
cb97fc569f vj: the loop splat — a song sliced into per-stem, beat-quantized loops on the APC40 grid
Eight song sections by drums, bass, vocals, other and mix; launch and swap on the bar, right-click halves and quarters, per-cell waveforms with score blocks, a full-width score popup (drums, bass tab, melody with lyrics) played back through the piano and the sample kit, an NMF drum transcriber, Basic Pitch for pitched stems and Beat This! grid refinement when installed, beat jump and phase-flip buttons, a loading overlay for the grid, and the shared model install panel in INSTALL MODELS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:51:44 +02:00
Admin
120b7e23ae score view: the engraver as a shared library, with drum and pitched score builders
libs/score_view carries the engraving, spacing, font and document code out of the score app so any app can show notation: a ScoreView widget with width/page/content fits, pan and zoom, a playhead, a dark palette, drum-key labels, lyrics under melody notes, and builders for drum, pitched and bass-tab scores. The score app uses it. Vector glyphs no longer write depth for transparent fragments, and the Metal screenshot staging sizes itself from the source texture.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:51:44 +02:00
Admin
31e5faaff5 ai-hub: local model runner, licence acknowledgements, a shared install panel; Beat This!, Basic Pitch and the Salamander drum-kit entries
LocalModels runs registry models in-process (install state, resumable downloads, recorded licence acknowledgements at $MAKEPAD_HOME/license_acks.json, weight paths by file role) and libs/ai/hub_ui is the install panel + licence modal every app can embed. New native ports: Beat This! (beats + downbeats) and Basic Pitch (note transcription) with their registry entries; the Salamander Drumkit samples (CC BY-SA 3.0, 37 files pinned by size and sha256) as a sample bank the downloader fetches like a model.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:51:44 +02:00
Admin
f4af6df5a0 sim: terrain knows who changed it — a plan layer over player history
Chunks keep the player's bytes apart from the composed density: plan
presses land in an owner-keyed PlanTerrainPatch that is retracted with
its feature and never touches history, so removing a railway takes its
bed with it and a human fill under it survives. Surface queries answer
Surface | Hole | Outside instead of pretending the heightfield continues
over a hole or past the border; landforms issued inside a plan eval are
plan, outside it history. Seven tests pin the layering, including
same-inputs-twice equality.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:38:40 +02:00
Admin
c189b035af game brief: agents follow the rules by themselves — you never script a stop
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 02:29:06 +02:00
Admin
7ff875a33e ai-hub: keep a peer's in-flight beats/notes/local work out of the body commits
The last two hub commits staged whole files and carried uncommitted hunks
of another lane (beats-native, notes-native, the local runner, new
domains and license keys) that reference files not yet in the tree. This
restores those files to the body changes only; the other lane's edits
stay in its working tree.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 01:47:49 +02:00
Admin
8c568dfcb0 ai-hub: drop the SAM 3D Body reference worker backend
The native port covers everything the Python reference worker did (body,
hands, mask prompt, multi-person), so the subprocess backend, its fake
worker harness, the sam3dbody-ref registry entry and the
MAKEPAD_SAM3DBODY_* environment go. The packet validator moves to the
native backend.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 01:45:22 +02:00
Admin
a648cf8808 ai-hub: body session options — hands, detect, persons=N
The sam3dbody backend reads its options from the request's prompt string
(`hands`, `detect`, `persons=N`): `hands` runs the full mode and the
packet carries which hands were fused and their boxes; `detect` finds up
to N persons with SAM 3.1 (an optional native-segment role on the body
entry, the same artifact the segment entry pins) and runs one body pass
per person with its box and mask, so the packet's people array grows.
The body crate shares one body pass between the packet, mask and hands
entry points, and infer_full takes the mask prompt too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 01:36:25 +02:00
Admin
62dff26092 ai-body: the mask prompt — a person's segmentation mask conditions the body pass
BodyModel::infer_masked takes a provided person box and its full-frame 0/1
mask: the mask is warped with the crop's own affine (bilinear, rounded back
to 0/1 like the reference's uint8 warp), encoded by the prompt encoder's
mask CNN (four stride-2 convolutions with channel LayerNorm and erf GELU,
then a 1x1 to 1280) and added to the backbone tokens before the ray
conditioning, replacing the folded no-mask term. Oracle parity on the
mask fixture: warp exact, every CNN stage within f32 accumulation noise,
conditioned context 8e-4, end to end kp3d 2.2 mm / kp2d 0.25 px. 41 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 01:29:15 +02:00
Admin
c9e6d88bf1 ai-body: the hands pass — hand crops, the hand decoder, the hand-mode rig and the wrist fusion
BodyModel::infer_full runs the reference's full mode natively: the body
pass's hand boxes become two 512 crops (the left one cut from the mirrored
image, padding 0.9), each goes through the backbone, its own ray
conditioning and the hand decoder (the body decoder with the *_hand
tensors), the hand-mode rig pre-transform (local_to_world_wrist, wrist and
root offsets, non-hand parameters zeroed) and the hand camera head's
scale factor of 10; the left result is un-mirrored. The fusion gates each
hand (wrist angle, box size, keypoint spread, wrist distance), re-prompts
the body decoder with the trusted wrists and elbows as point prompts (the
decoder now takes N prompt tokens and a previous estimate), and writes the
fused wrist angles, hand parameters and hand scale/shape back before the
final rig pass. pose.rs gains the roma xyz (extrinsic) and XZY (intrinsic)
euler pairs; mhr.rs returns joint global rotations.

Oracle parity on both full-mode fixtures: every hand-decoder stage per
step (tokens 7e-3, heads 4e-4, rig params 1e-4), fusion reports as the
reference (one hand trusted on the standing photo, both on the crop), and
end to end kp3d 1.6 mm / kp2d 0.85 px. 36 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 01:26:21 +02:00
Admin
0e94bbac42 game brief: rivers, highways, junctions and gates are one call
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 01:23:58 +02:00
Admin
525ba1c2f4 metal: a device-resident two-way decoder layer, and the body decoder rides it
One backend call per SAM-style decoder layer (PE norms, token
self-attention, token-to-image cross-attention, erf-GELU feed-forward,
final norm) inside one command buffer, with the layer's f32 weights cached
on the device under their content identity and pooled transients.
gpu_two_way_layer_resident in the common backend; CUDA declines and the
per-op path stays. Body decoder loop on the M3 Max: layers 100 -> 9 ms,
frame 405 -> 266 ms, oracle parity unchanged (kp3d 1.5 mm).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 00:25:59 +02:00
Admin
d006d0a1bc metal: resident f32 linears keep their weight on the device
gpu_linear_f32_resident promised a resident weight but the Metal tensor
backend re-uploaded it every call, which for the MHR rig's 664 MB pose
corrective matrix cost 50 ms a step. Metal tensors now carry a content
identity (fresh at creation and after every in-place write, so a cache
keyed by it can never serve a stale weight), and the resident linear caches
the device buffer under it. Body frame on the M3 Max: 705 -> 405 ms (rig
302 -> 20 ms, refine 41 -> 13 ms, heads 10 -> 6 ms).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 00:20:46 +02:00
Admin
d53c77d4a5 metal: a device-resident ViT stack, and the body backbone rides it
The Metal tensor backend is host-Vec based: every op copied its inputs up
and its result back, which put the DINOv3 backbone at 2.1 s a frame on an
M3 Max. try_vit_backbone_resident_f32 runs a whole pre-norm ViT stack
(LayerNorm, q/k/v, rotate-half rope from tables, flash attention, out,
LayerNorm, SwiGLU, down; residuals in place) inside one command buffer
against cached bf16 weights, with a new kernel_makepad_rope_half_tables_f32
kernel for the rope. The precompiled metallib now carries the bf16 GEMM
kernels the runtime source compile already enabled on bfloat devices.

The common backend exposes it as gpu_vit_backbone_resident (CUDA declines;
its per-op path is already resident) and the body backbone tries it first:
233 ms a frame on the M3 Max, same oracle parity (kp3d 1.5 mm).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 00:16:31 +02:00
Admin
a2aaa8f778 ai-body: the FP8 bias rides a column-broadcast add on the device
The FP8 backbone mode added each linear's bias by downloading, tiling and
re-uploading it per call, which cost more than the FP8 GEMM saved. A
gpu_add_cols_broadcast op (CUDA kernel; host loop on the Metal tensor
backend) adds a cols-wide bias to every row on the device, and each linear
keeps its bias resident after the first upload.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 00:05:03 +02:00
Admin
8964ba6313 ai-body: an FP8 backbone mode, off by default, measured against the oracle
BodyModel::set_backbone_fp8 quantises every backbone weight to E4M3 with
a per-tensor absmax scale and runs the tensor-core FP8 GEMM (bias
broadcast after). A backend without FP8 turns it off per layer on the
first refusal, so Metal keeps bf16. The oracle test reports its
accuracy and timing next to bf16; the default stays bf16 until the
numbers say otherwise.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:54:43 +02:00
Admin
a9ce596e07 ai-body: the crop warp runs across cores
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:52:13 +02:00
Admin
7598346ff5 ai-body: tensor-core GEMMs for the backbone, and the rig's correctives only where they count
The backbone's linears now take the cuBLASLt bias-epilogue / bf16 mm
paths (bf16 operands, f32 accumulation, bf16 output — the reference's
precision) with the f32-accumulating GEMM as the fallback. The rig's
pose correctives run on the final refinement step only by default: the
intermediate steps only feed keypoints back into the decoder, and the
oracle shows the difference is 0.4 mm (2.1 vs 1.7 mm against the
reference) for half the loop's rig time; `correctives_every_step` keeps
the exact mode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:49:46 +02:00
Admin
4be6d19695 ai-body: the test modules import the grid constants they still use
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:43:53 +02:00
Admin
45b5b98612 ai-body: the crop size is a runtime knob, and the loop reports where its time goes
The backbone, conditioning and decoder now take any square crop whose
side is a multiple of 16 (512 is the trained size); BodyModel::set_crop_size
selects it and caches the dense positional grid per size. Measured on the
oracle image against the reference: 512 gives 1.7 mm mean keypoint error,
384 and 256 about 2 cm mean (6 to 7 cm worst joint), 192 falls apart —
the knob is a real accuracy trade, not free speed. The decoder loop
reports its split (layer chain, heads, rig+camera, refinement) so the
next optimisation is chosen on numbers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:33:26 +02:00
Admin
97e9572f42 speech: one STT/TTS API on every platform, through the ai-hub
Apps ask the hub for a recognizer or a voice and get one; where it runs is
the hub's decision. AiHub::start_stt / start_tts return poll-driven
sessions shaped like the chat session. The Auto ladder is Whisper/Kokoro in
this process (weights present, machine election), on the machine node over
loopback, on a LAN node, else the OS engine; SpeechReach::Local is the
"don't reach out" knob. Audio always comes back as PCM: the app owns the
device.

Three layers:
- makepad-ai-speech is the whole speech model family, engines only.
  libs/voice (Whisper + Silero VAD) folds in as the `whisper` and `vad`
  modules next to kokoro and indextts, each a cargo feature; the Apple
  bridges and the Speaker/VoiceTranscriber selection leave it.
- makepad-system-speech (new) is the OS speech services as blocking fns:
  Apple SpeechAnalyzer/AVSpeechSynthesizer via Swift, Windows.Media.Speech*
  on the vendored bindings, Android SpeechRecognizer/TextToSpeech through
  MakepadSpeech.java (API 26 floor), espeak-ng on Linux. It models the two
  STT shapes honestly: PCM in (Whisper, Apple) versus an engine that owns
  the microphone (Android, Windows), with capabilities the caller reads.
- the hub grows speech sessions, in-process Whisper/Kokoro workers with the
  residency election, a `whisper` wire backend (stt domain, registry entry
  pinned to ggerganov/whisper.cpp) so a Mac can serve a Quest, and a
  `language` field on the generate request.

Consumers: the Window voice input runs on an STT session and switches to
engine-mic mode when the recognizer owns the microphone; converse's
SpeechOutput is a lazily started TTS session plus a pump thread; route
drops its private speech copy for converse; vj's lyrics fallback and the
alignment bakes call the engines directly.

Verified here: speech-roundtrip through the real sessions (Apple voice in,
in-process Whisper on Metal out, 4.3% WER); system-speech-test TTS->STT
verbatim; hub/converse/system-speech unit tests; msvc, aarch64-android and
linux-gnu cross-checks; Java against android-34. Windows, Android and Linux
bridges are compile-checked only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:32:35 +02:00
Admin
0fd356dbf0 windows: the vendored bindings are generated from a checked-in filter
libs/windows/windows-rs/src/Windows/mod.rs was a hand-pruned snapshot with
no recipe behind it; adding a WinRT namespace meant hand-copying vtables.
Now tools/windows_bindgen (standalone, windows-bindgen 0.62.1) regenerates
it from filter.txt: package mode with the COM `_Impl` traits, Win32 imports
linked through windows_core, upstream's 800-column rustfmt (the repo-root
rustfmt.toml disables formatting and has to be overridden), the filter
closed over every dependency the generator would otherwise skip a member
for, the namespace tree folded into one file with every feature gate kept,
and the two spots where the published 0.62.1 generator predates the vendored
core 0.62.2 normalized (Error::from_thread, imp::array_proxy).

The filter is the old file's item list plus Windows.Globalization.Language,
Windows.Media.SpeechRecognition and Windows.Media.SpeechSynthesis for the
OS speech engines. The generated `deprecated` gate is declared as a feature.
Checked on x86_64-pc-windows-msvc: platform, video, network, mpterm,
system-speech.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:32:35 +02:00
Admin
346f31f8c4 ai-body: flash attention for the head-dim-64 blocks
The backbone (20 heads) and the decoder (8 heads) both have 64-wide
heads, which the stack's FA2 kernel covers: f16 operands with f32
softmax and accumulation, the reference's own precision class, instead
of the composite path that materialises the 1029 x 1029 scores per head.
The composite path stays as the fallback where a backend lacks the
kernel. Oracle parity unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:15:56 +02:00
Admin
b22259b542 ai-body: the context stays on the GPU; only the pose token leaves the loop
The ray-conditioning conv used to download the 1024 x 1280 embedding,
concatenate the 99 ray features on the host and upload the result; it is
now two resident linears over the two column blocks with the no-mask
term folded into the bias, plus an add (32 ms -> a few on the 4090).
The decoder loop downloaded the whole normalised token block every
layer for its one pose row; it now slices that row and fetches the
block once at the end (or per layer under a trace). Same numbers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:13:46 +02:00
Admin
d78411a63b ai-body: the per-step work moves to the GPU
On the 4090 the frame was 885 ms of which 840 were host scalar loops in
the decoder loop: the three 70-row refinement FFNs (about 240M
multiply-adds a step), the two heads, and the rig's 55k x 3000
corrective output layer plus its identity blendshape sum. Those are now
GPU-resident linears (heads.rs GpuStepHeads, MhrRig::prepare_gpu), the
skinning computes one transform per joint instead of one per influence,
and the results are identical (all 29 oracle tests unchanged). The rig
still runs entirely on the host when no GPU side was prepared.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:12:05 +02:00
Admin
6a1c16b2cb ai-body: third-party notices — what the port is implemented after, and what it is not
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:05:43 +02:00
Admin
9ff44e87a6 ai-hub: the body-native wiring, this time only the lane's hunks
The recut in a6341981d applied its patch against the wrong directory
and restored the six shared files to the previous tree without the
body-native hunks. This commit adds exactly those: the `body-native`
feature and optional dependency, the pinned `sam3dbody` registry entry
and its test, the backend arms and the module declaration. Working
tree untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:03:47 +02:00
Admin
a6341981d8 ai-hub: the body-native commit carried a peer's in-flight hub hunks; put them back where they were
66e5e2f11 committed the working tree of the shared hub files and with it
another lane's uncommitted edits (a new domain, request fields, a
backend arm, manifest lines). This commit restores those files to the
previous tree plus only the body-native hunks. The working tree is
untouched: the peer's edits stay on disk as their uncommitted work,
exactly as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:03:20 +02:00
Admin
66e5e2f117 ai-hub: SAM 3D Body runs natively — sam3dbody on the body domain, oracle-matched end to end
model.rs closes the loop: crop -> backbone -> ray-conditioned context ->
six decoder steps, each through the pose head, the rig, the camera and
the projection, then the packet the sandbox already reads (kp3d/kp2d in
camera axes, the 204 rig parameters, global rotation, camera translation,
joint positions). Against the reference on the oracle image, on Metal:
3D keypoints within 1.7 mm, 2D within 0.4 px, rig parameters, camera
and rotation within 2e-3. packet.rs writes the JSON by hand with the
reference worker's rounding and field order.

The hub gains the `body-native` feature (default on): registry entry
`sam3dbody` pinned to the Comfy-Org repack by revision, size and sha,
body_native_backend.rs beside the subprocess reference backend with the
same live_step contract, the `body` capability advertised when the
feature is compiled, and a stubbed test double for the CPU-only tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:01:49 +02:00
Admin
69d842c400 ai-body: the promptable pose decoder and its refinement loop, oracle-matched on Metal
decoder.rs builds the 145-row token set (pose, previous, prompt, two
hand-box rows, 70 keypoint rows, 70 3D-keypoint rows), runs the six
layers with the SAM-style repeated positional encoding (none on the
first layer's self-attention), cross-attends against the ray-conditioned
context, and after each step hands the normalised pose token to the
heads and updates the keypoint rows from the caller's feedback: the
2D-keypoint positional FFN and the bilinearly sampled context features
on the valid rows, the pelvis-centred 3D positional FFN on the rest.
heads.rs holds the host-side ReLU FFN heads, the refinement FFNs, the
hand-box MLP and the hand classifier. Against the oracle on Metal, every
layer's residual stream is within 6e-3, every step's pose head within
8e-4 and camera head within 2e-5; token assembly and the host heads
match to 1e-4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:58:29 +02:00
Admin
9e343a8f20 ai-body: the DINOv3 ViT-H+/16 backbone, crop and ray conditioning; Metal gains rope-half and affine layer norm
dino.rs is the 32-block ViT-H+/16 on the gpu_* surface after the TRELLIS
conditioner: bf16-resident linears with f32 accumulation, layer scale
folded into the output projections, rotate-half rope, SwiGLU, the
model's own final norm. preprocess.rs is the crop (box -> 1.25 pad ->
3:4 -> square -> 512 bilinear warp, ImageNet normalisation), the CLIFF
condition vector and the patch rays; condition.rs is the dense
positional encoding and the ray-conditioned decoder context. Against the
reference oracle on Metal: backbone 1.1% mean relative (bf16 noise),
ray-conditioned context 8e-5, crop within one u8 rounding step, rays and
dense PE 1e-7. One reference detail the paper does not state: its shrink
of the ray field is an antialiased filter whose taps clip at the image
edge, so the two edge patches sample inside their block centre (9.03 and
501.97 rather than 7.5 and 503.5); block centres left 0.1 of error.

The Metal tensor backend was missing rope_half (it aliased the
interleaved layout) and layer_norm_mul_add; both now exist with the
CUDA contract, which is what lets this backbone run on Apple silicon.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:56:46 +02:00
Admin
8211ae6d85 ai-body: the MHR rig and the pose head's parameter decoding, oracle-exact
mhr.rs is the Momentum Human Rig on the CPU: blendshapes, the 889x249
parameter transform, parents-first similarity kinematics (x,y,z,w
quaternions, Rz Ry Rx eulers, 2^s scales), the sparse-then-dense pose
corrective MLP over joints 2.., linear blend skinning and the 308-row
keypoint regression. pose.rs decodes the 519-wide head output: 6d
rotations, the 23 ball / 58 hinge / 6 translation body layout, the
mixed-dof hand layout, scale and hand component bases, the camera
translation and the perspective projection. Against the reference
oracle: vertices within 1e-4 cm with correctives on, keypoints within
1e-6 m, rig parameters within 1e-7. Two conventions the spec could not
settle on paper are now settled by the fixture: the head's global
rotation triple arrives Z,Y,X-ordered, and the corrective features start
at joint 2 (750 wide). Fixture tests skip cleanly without the oracle
directory or the weights.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:51:19 +02:00
Admin
f51b5f3bbf ai-body: the crate for the native SAM 3D Body port, with its weights reader
makepad-ai-body joins the AI model workspace: the constants of the
architecture (DINOv3 ViT-H+/16 at 512, the 6-layer promptable decoder,
the 519-wide pose head, the 127-joint / 18439-vertex MHR rig) and the
single-file safetensors reader for the Comfy-Org repack, which fails
closed on a Meta checkpoint-style header and checks the shapes the port
is written for at load. The backbone, decoder and rig modules follow
in their own lanes against the spec under local/agent_state/sam3dbody.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:31:12 +02:00
Admin
294cb9ea47 video: the single-frame mp4 is written on every platform, not stubbed
encode_intra_frame_mp4 on Windows and Linux now goes through the
platform file encoder (Media Foundation sink writer, GStreamer): one
keyframe-only stream, one frame, a scratch file read back and removed.
It pays the container machinery the Apple session path avoids, but a
tile tape baked on any machine reads on any other, which is what the
image-tiles library sits on. A round-trip test encodes a frame and
decodes it through the platform decoder on whichever platform runs it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:24:40 +02:00
Admin
929f822ca5 video: the single-frame mp4 encode exists on every platform
encode_intra_frame_mp4 was macOS-only, and libs/image_tiles imports it
unconditionally, so image-tiles, its example and source-library did not
build for Windows (found by a Windows-target cargo check sweep of the
workspace; every other crate checks clean). Other platforms now get the
same signature answering with an error, the way the rest of this file
stubs what it cannot do yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:20:43 +02:00
Admin
b7fe32cf07 game brief: a destructible world, path-edited railways, and models that load now
The doctrine catches up with the engine: landforms and tunnels are one
call and digging works anywhere; railways and roads are edited by their
path points (crossings generate, styles switch, any model drives);
freshly modeled aliases are live immediately — never park a display
substitute.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 22:15:45 +02:00
Admin
5692fabbdf sim: the landform world proves itself — walker through the tunnel included
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 22:15:45 +02:00
Admin
3fcccf3b47 sim: the whole world is implicitly editable, and one seam says where the ground is
The voxel layer's volume gate is gone — any edit materializes chunks
anywhere on the map (volumes remain as styling), with the lazy-chunk
economics proven by test: zero storage until the first op. The composed
surface seam (GameWorld::surface_height_at: heightfield where untouched,
voxel surface where a chunk owns it) is what ground queries, senses and
the drape corridor read, so a mountain raised mid-game and a dug crater
are the same truth the vehicles drive on. landform.rs adds the macro
shapes (mountain/hill/ridge/valley/crater/plateau, fbm detail, slope
rock + snow recolor) and the tunnel op rides the capsule-filtered
materialization.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 22:15:38 +02:00
Admin
f7093bf82f map_nav: the search db's positioned read builds on Windows
searchdb imported std::os::unix::fs::FileExt for read_exact_at, which
does not exist on Windows (reported from a Windows checkout of work).
A small ReadExactAt trait now wraps unix pread and Windows seek_read,
both cursor-free, so the shared reader keeps serving lookups from
several threads without a lock. Checked on x86_64-pc-windows-msvc.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:13:13 +02:00
Admin
914471fa31 ai-hub: a feed session whose last socket left ends on its idle timeout; skin: parent, skinned centroid and a nodes-only rig for retargets
A realtime feed session parked in its wait-for-a-frame loop forever when
the client died without sending stop (a sandbox quit left job-2 live on
.123 for five minutes holding the GPU slot). The wait loop now returns
to the top of the session loop once no socket is left, where the idle
timeout counts a socketless session down. Test covers it.

SkinnedModel gains node_parent/node_count, joint_skinned_centroid (the
direction a leaf limb actually runs, from the flesh it skins) and
from_nodes (a mesh-less rig for hierarchy-maths tests) — what the
sandbox's webcam mocap retarget needs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B626urtY1Xo4hQdLzvSK6F
2026-09-01 22:06:29 +02:00
Admin
895b9a71c3 particles: an emitter can ride a body's own frame
EmitterAnchor::EntityLocal carries an offset that yaws with the entity —
a locomotive's chimney keeps its smoke over the funnel through every
corner. The step lookup resolves (position, yaw) instead of position.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 20:51:32 +02:00
Admin
ea50c77970 chat_ui: the feed's session gets its profile brief back
Same orphaning as the sandbox: context::assemble had no caller post-P8,
so gen/vj sessions ran brief-less. The executor's capability doc leads
with the assembled profile layer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 20:36:55 +02:00
Admin
c18b7f4169 game brief: railways are one call too
The verbs existed with the right doc strings, but the model reads the
brief, not the verb table — and the brief's ONE CALL list never said
railway, so it hand-placed track pieces, horrendously. Now it says it,
with the two-line essential shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 20:20:04 +02:00
Admin
b0380bac34 image-tiles: the picture-wall engine as a library — tape atlases, a baker CLI, and the TileGrid widget
The Source Library wall's pixel engine, extracted for anyone's pictures:
libs/image_tiles carries the NV12 slot pyramid, the 32x32 shard atlas
geometry and the one-HEVC-intra-frame-per-file tape codec (tape.rs), a
small SQLite index (db.rs), a priority decode pool (store.rs), and the
TileGrid widget — instanced tiles batched per atlas page, anchor-locked
wheel zoom with log-space glide, continuous per-shard LOD with
crossfades, full-resolution promotion under byte budgets with LRU
eviction, and uniform-only re-presents inside the pad of the last build.
image-tiles-bake downloads a manifest of URLs (fetch pool free to be
wide, encode pool hard-capped — concurrent VT session churn has panicked
the encoder's IOMMU) and bakes a library; examples/image_tiles views one.
No JPEGs on disk; decode and encode are the hardware's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 20:19:56 +02:00
Admin
0d7185e0de asset chat: catalog SQL answers in-process, and the game brief stops teaching the y mistake
Post-P8 the game session still advertised assets.query/assets.schema but
the in-process executor answered both Unavailable — the model's main
alias-search tool was dead, and every "find me X" turn gave up on the
store. Both now execute over the server's bounded /v1/assets/query route
(schema via sqlite_master plus the usage notes). The game brief gains the
new-game-vs-edit rule, interior design law, generate-after-search, and
explicit y semantics so player_pos().y stops being pasted into
ground-relative fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 20:18:31 +02:00
Admin
237da90a36 render: the sprite lane hands the screen draw back the way it found it
One DrawSceneScreen serves both the in-world video screen and every
sprite billboard, and the sprite loop left its last pose, size and atlas
behind — so the next frame's "is there a screen" gate (zero screen_size
draws nothing) read the leftover and drew the whole spritemap as one
opaque quad: under the last-drawn unit in C&C, and hanging in the sky of
any level that draws no sprites at all. Snapshot the host's pos/size/
texture before the lane and restore after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 19:54:29 +02:00
Admin
6251f7c615 ai-hub: body domain — live pose packets ride the realtime session
New `body` backend (sam3dbody-ref): a persistent length-prefixed-PNG /
JSON-lines worker subprocess seam with ready handshake, per-frame timeout,
bounded restarts. LiveFrameOut grows aux_json — structured per-frame JSON
sent to the client before the frame — and output_encoding "none" makes a
session pose-only (refused with loop_mode feedback, also on control flips,
which upgraded apply_control to Result). Worker code+model stay
box-provisioned via MAKEPAD_SAM3DBODY_WORKER; the repo carries only the
MIT seam. Codex lane + Fable review (ready handshake, spawn timeout).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0165w1ZL1f1TruX5u2qC7mSX
2026-09-01 19:05:34 +02:00
Admin
176433a56e tweaker: click-again climbs the pick — the container under the children is reachable
The deepest-wins pick could never land on a container its children fully
cover (the chat panel, any rounded-bg pane). Now clicking inside the
pinned widget again walks the pin up one ancestor per click, skipping
design-transparent views, zero-rect wrappers and ancestors whose area
misses the click (a splitter's grab bar); at the window the climb wraps
back to the deepest pick. The window itself is never borrowed — it is
mid-dispatch when the handler runs (tree lookups decide before any
widget borrow).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 18:18:30 +02:00
1547 changed files with 388216 additions and 64889 deletions

124
AGENTS.md
View file

@ -7,6 +7,11 @@
> studio websocket bridge for all agent work. Full spec: [App Remote Control](#app-remote-control---remote).
## Execution Policy
- **Designs stay local.** Design documents, plans, and reports are local files
(`local/agent_state/<topic>/DESIGN.md`) that lanes read from disk. Never
publish them to the web (no Artifacts, no hosted pages); summarize in the
terminal instead.
- Launch UI programs as standalone release binaries from this checkout. Do
not use the Studio remote bridge, `ObserveMount`, `RunItem`, or any
`cargo-makepad studio` websocket client.
@ -31,8 +36,56 @@
can be run directly in the shell.
- A standalone app's built-in screenshot/capture hook is valid for visual
inspection.
- **System-level screenshots are FORBIDDEN.** Never run `screencapture`,
`CGWindowListCreateImage`/`CGDisplayCreateImage` scripts, `xcap`,
`import`, `scrot`, `grim`, `xwd`, PowerShell/Win32 screen grabs, or any
other OS screen capture — not of the display, not of a window, not
"just the caption". The user's screen is private. The only image of a
running app you may ever take is the app's own `--remote` grab (`/g`,
`/gq`, `/tweak/grab`), which renders the app's own drawable and nothing
else. If something only shows in the OS layer (native caption buttons,
other apps, the desktop), ask the user for a screenshot instead of
taking one.
- When adding a new example crate, update both the Cargo workspace and
`makepad.splash`.
- **Zero locking on the UI thread, one mechanism everywhere.** The UI
thread never takes a `Mutex`/`RwLock`/`Condvar` that another thread can
hold, and never blocks on a channel. UI → workers/audio is commands over
a channel (bounded, non-blocking send; a full queue is reported and
retried next frame). Workers/audio → UI is snapshots over atomics, a
triple buffer, or a channel read with `try_recv`. Large payloads (PCM,
stems, grids, images) travel as `Arc` through the channel; a replaced
payload is handed back so the UI thread does the drop, never a realtime
thread. A realtime callback (audio) owns its state, never takes a lock
the UI can hold, never allocates on the hot path. This is ONE code path
for native and wasm — no `cfg` fork where desktop keeps shared mutexes.
On wasm both the browser UI thread and the AudioWorklet thread abort on
`Atomics.wait`, and a spinning fallback against a busy audio callback is
a 100 % CPU feedback loop that kills audio and frame rate together (DJ
web, 2026-09-03). `lock_from_ui` is only acceptable on state provably
touched by the UI thread alone.
- **Standard operating flow — who does what.** The main session (Fable)
designs, briefs, manages and reviews; it does not write the code itself
except one-line fixes. **Codex writes the code**: every implementation lane
is a Codex lane with a precise brief (observations, files, rules, the
verification commands) launched through `local/tools/delegate` /
`local/agent_state/webdemos/tasks/queue.sh` and landed through
`local/tools/integrate`. **Grok does the tests and the token-heavy work**:
test suites, audits, surveys, log reading, conflict resolution passes,
reviews of large diffs (`delegate grok` / `research-grok` / `review-grok`).
A Fable subagent is the exception, only for a design-level change the
other two cannot carry (a new platform mechanism), and it stops as soon as
the API is fixed so Codex can do the conversions. Keep at most six lanes
per provider; land everything through the integrator; the user tries the
result — no routine captures.
- **No temporary threads — use the pool.** Never spawn a thread for one
job (`std::thread::spawn` is unsupported on wasm anyway; the platform
spawner works everywhere). Background work goes to the platform thread
pool (`cx.thread_spawner()` / the pool `TaskHandle` API) or to a
long-lived worker created once at start-up and fed over a channel. On
the web a Web Worker takes hundreds of milliseconds to come up, so a
per-job thread is a stall; on desktop it is still churn. One mechanism
on both targets.
## Standalone Launch
1. `cargo build --release -p <package>` from this checkout.
@ -44,6 +97,22 @@
## App Remote Control (`--remote`)
> **Focus law.** A `--remote` app opens its window VISIBLE BUT UNFOCUSED and
> stays that way: it never activates, never becomes key, and bridge clicks
> never raise it. The user keeps typing wherever they were. Everything the
> bridge does (grabs, `/m`, `/k`, `/t`, `/snap`) works without focus because
> input is injected through the app's event loop, not the OS. Do not work
> around this (`MAKEPAD_FOCUS=1` exists only for a run the user asks to see
> in front); `MAKEPAD_NO_FOCUS=1` gives a non-remote launch the same manners.
> **Who may open a visible window.** Subagent/lane verification runs HIDDEN:
> launch with `MAKEPAD_HIDE_WINDOWS=1 <bin> --remote` — the window never
> appears, grabs (`/g`), `/snap`, `/m`, `/k`, `/t` all still work offscreen.
> Only the integrating session opens the one visible, unfocused window the
> user watches; several look-alike windows on screen made the user "go
> insane" (2026-08-26).
Any makepad app launched with `--remote` runs a localhost HTTP server inside
the process and prints one line before the UI appears:
@ -111,6 +180,8 @@ this pattern as an executable end-to-end test across three example apps.
`GET /gq` (or `/close` each window, then `/quit`). Never leave test windows
on the user's screen, and never `pkill` when the protocol is available.
- **Never touch an instance the user is running.** Launch your own.
- **`/g` is the only camera.** No OS-level screen capture of any kind (see
Execution Policy) — the remote grab is what you get.
- **A vanished window or app with `[makepad-remote] user closed …` in the log
means the human dismissed it — it was in their way.** Do **not** treat that
as a crash and do **not** relaunch it. The app prints
@ -151,6 +222,59 @@ this pattern as an executable end-to-end test across three example apps.
- **Cost when idle is zero.** The event loop only upshifts its paint clock
while a remote request is in flight.
### The TWEAKER (`/tweak/*`) — design feedback and live styling
Every `--remote` app carries a design-feedback overlay (plan of record:
repo-root `tweaker.md`; implementation: `widgets/src/tweaker.rs`). Off it
costs nothing. On, the person (or you) points at the UI: pointer events over
the window body are swallowed before widget dispatch — **clicking a Button in
tweak mode outlines it and never fires it** — and the window grows a property
sidebar next to the (compressed) app UI. Shift+F10 toggles it in-app; every edit,
theirs or yours, lands in one shared diff log.
| Route | Answer | Notes |
|---|---|---|
| `/tweak` `?on=1\|0&annotate=1\|0` | `{"on":1,"annotate":0}` | toggle the overlay / the freehand draw mode (Alt-drag draws too) |
| `/tweak/state` | `{"on":1,"sel":{path,ty,r,band},"props":[{n,v,set}],"hover":…,"diff":[…],"ann":[…]}` | the STRUCTURE feedback: pinned selection, its real reflected properties (`set:1` = explicitly applied), the edit log, annotation strokes with the widget paths they touch |
| `/tweak/apply` (POST) | `{"ok":1,"path":…,"changed":[{path,prop,old,new}]}` | body `{"path":"a.b.c","splash":"{padding: Inset{left: 20}}"}` or the one-property shorthand `{"path":…,"prop":"draw_bg.border_radius","value":"8"}`. Evaluates the chunk onto that ONE instance through the ordinary apply machinery (`+:` merge rules intact) and triggers a full relayout. Answers after the next drawn frame |
| `/tweak/diff` | `{"diff":[{path,prop,old,new}…]}` | the raw edit log, in order |
| `/tweak/clear` | `{"ok":1}` | reset diff + annotations |
| `/tweak/final` | `{"final":[…coalesced…],"ann":[…],"drew":0\|1,"png":path?}` | **read this when tweaking is done**: per (path, prop) only the original and final value, churn collapsed. When the user drew, `png` is the composited screenshot — look at it, the strokes mean something |
| `/tweak/grab` | like `/g` | the overlay (outlines, strokes, sidebar) draws in the window's own pass, so any grab is already composited |
`local/tools/tweak` wraps all of this:
`tweak PORT on`, `tweak PORT state`, `tweak PORT apply PATH PROP VALUE`,
`tweak PORT splash PATH 'CHUNK'`, `tweak PORT final`, …
**How to listen.** Sidebar edits push to you: each one emits a marked
`TWEAK sidebar <path> <prop> <old> -> <new>` line into the app log — the
`/log` tail is your ear; you never poll `/tweak/state` for changes. Talk back
on `/tweak/apply` (values or whole shader chunks) to the same selected
instance.
**Write-back (you do this part — the overlay never writes source).** When the
session is done, take `/tweak/final` and edit the splash source:
1. Resolve each entry's widget path to its DSL site: the dotted path mirrors
the `script_mod!` tree (`/d` shows the same ids). `-` segments are
anonymous containers — skip them when searching the source.
2. Write each property at the **most specific existing site** — the widget's
own `name := Type{…}` block if it has one; create one only when none
exists.
3. Respect the merge law: a property inside a typed sub-struct goes through
`+:` (`draw_bg +: { border_radius: 8 }`), never a replacing
`draw_bg: {…}`. Plain walk/layout values (`padding`, `margin`, `width`)
are set directly (`padding: Inset{left: 20}`).
4. Values come back in source spelling (`#rrggbbaa` colors, plain numbers) —
paste them as-is. Mind the Rust-tokenizer hex-`e` trap in `script_mod!`:
`#1e1e2e` must be written `#x1e1e2e`.
5. Rebuild and relaunch; verify the value survived with `/tweak/state` or
`/snap` before calling it done.
Reflection truth: `props` come from the widget's live Rust fields plus the
type's DSL-declared shader inputs (`instance()`/`uniform()`), so the list is
what the widget actually exposes — there is no synthetic schema to drift.
### Studio remote bridge (the older path)
The studio (`studio/desktop` + `studio/hub`) drives a hosted app over a

View file

@ -1,17 +1,20 @@
workspace.members = [
# === app ===
"apps/mpbrowser",
"apps/mpterm",
"apps/mpwm",
"apps/browser",
"apps/terminal",
"apps/wm",
"apps/aichat",
"apps/finance",
"apps/mpsheets",
"libs/mp_theme",
"libs/mp_wm_api",
"apps/mptask",
"apps/mpimage",
"apps/mpvideo",
"apps/mppdf",
"apps/mpfiles",
"apps/sheets",
"apps/photos",
"libs/wm_theme",
"libs/wm_api",
"libs/app_module",
"apps/task",
"apps/image",
"apps/video",
"apps/pdf",
"apps/files",
"apps/route",
# === arcade (game.md) — networked AI game sandbox ===
"apps/arcade",
@ -27,6 +30,10 @@ workspace.members = [
"libs/game/assets",
"apps/asset-ui",
"apps/asset-server",
"apps/flow-server",
"apps/flow-ui",
"libs/flowgraph",
"libs/media_view",
"apps/ai-hub",
"apps/vj",
"apps/mixer",
@ -40,6 +47,8 @@ workspace.members = [
"libs/asset/store",
"libs/asset/chat",
"libs/chat_ui",
"libs/ai/hub_ui",
"libs/ai/services",
"libs/asset/annotate",
"libs/render",
"libs/raytrace",
@ -50,6 +59,8 @@ workspace.members = [
"libs/sim/math",
"libs/show_control",
"libs/asset/creator",
"libs/bounded_http",
"libs/flow",
"libs/strict_json",
# === remesh (FaithC port) / xatlas (jpcy port) ===
"libs/remesh",
@ -94,6 +105,9 @@ workspace.members = [
"examples/render_to_texture",
# === digital-fabrication product ===
"apps/fab",
"apps/fabric",
"libs/fabric/measure",
"libs/fabric/draft",
# === xr app ===
"xr",
# === studio ===
@ -106,6 +120,8 @@ workspace.members = [
"libs/score_layout",
"libs/score_play",
"libs/score_render",
"libs/score_view",
"libs/score_view/tests/embed_app",
"libs/score_ai",
"libs/score_import",
"libs/score_pdf",
@ -114,6 +130,8 @@ workspace.members = [
"libs/midi_file",
"libs/soundfont",
"libs/piano_model",
"libs/drumkit",
"libs/drumkit_phys",
"libs/musicxml",
# === own MP3 / Ogg Vorbis decoders ===
"libs/audio_decode",
@ -123,6 +141,7 @@ workspace.members = [
"libs/audio_lyrics",
# === stems + lyrics side-channel bake/publish (asset-ui + VJ) ===
"libs/audio_sidechannels",
"libs/vj_analysis",
"libs/teamtalk",
# === pictures of audio (spectrogram, wave strip, composite) ===
"libs/audio_picture",
@ -135,16 +154,23 @@ workspace.members = [
"libs/frametween",
# === archive.org search + download content input (VJ / asset-ui) ===
"libs/archive_org",
# === image tile wall: HEVC tape atlases + baker CLI + TileGrid widget
# (the engine extracted from the Source Library picture wall) ===
"libs/image_tiles",
"examples/image_tiles",
# === mp4 sample index for range-streaming playback ===
"libs/mp4_index",
# === necessary tools ===
"platform/video",
"tools/cargo_makepad",
"tools/makepad_loader",
# === OSM PBF -> tile archive + nav artifact bake passes (CLI + in-app) ===
"libs/map_build",
"tools/map_tiles",
"tools/map_bake",
"tools/remote",
"tools/dj_pack",
"libs/system_speech",
# === tests ===
"platform/script/test",
]
@ -163,7 +189,6 @@ workspace.exclude = [
"libs/diffusion",
# the AI model workspace (loader + cuda/metal stores + model crates) — aiarch.md
"libs/ai",
"libs/voice",
"widgets/test",
"libs/stitch",
"libs/wasm_bridge/test",
@ -280,6 +305,9 @@ strip = true
inherits = "release"
debug = true
[profile.test.package.makepad-stitch]
opt-level = 1
#[profile.dev.package.makepad-live-tokenizer]
#opt-level = 3
#[profile.dev.package.makepad-live-compiler]

View file

@ -14,7 +14,7 @@ makepad-ai-hub = { path = "../../libs/ai/hub", default-features = false }
# Feature passthrough: the headless node exposes exactly the library's
# feature set, so box launch scripts keep their --features flags working.
[features]
default = ["flux", "paint", "paint-cuda", "llm", "tts", "indextts", "video", "interpolate", "audio", "mesh", "matte-native", "depth-native", "segment-native", "upscale-native", "motion-native", "rig-native", "splat-native"]
default = ["flux", "paint", "paint-cuda", "llm", "tts", "indextts", "video", "interpolate", "audio", "mesh", "matte-native", "depth-native", "segment-native", "body-native", "upscale-native", "motion-native", "rig-native", "splat-native", "stems-native"]
python-backends = ["makepad-ai-hub/python-backends"]
flux = ["makepad-ai-hub/flux"]
paint = ["makepad-ai-hub/paint"]
@ -29,7 +29,9 @@ mesh = ["makepad-ai-hub/mesh"]
matte-native = ["makepad-ai-hub/matte-native"]
depth-native = ["makepad-ai-hub/depth-native"]
segment-native = ["makepad-ai-hub/segment-native"]
body-native = ["makepad-ai-hub/body-native"]
upscale-native = ["makepad-ai-hub/upscale-native"]
motion-native = ["makepad-ai-hub/motion-native"]
splat-native = ["makepad-ai-hub/splat-native"]
rig-native = ["makepad-ai-hub/rig-native"]
stems-native = ["makepad-ai-hub/stems-native"]

View file

@ -36,10 +36,14 @@ fn run() -> Result<(), AssetAiError> {
let mut cache_dir: Option<PathBuf> = None;
let mut registry_path: Option<PathBuf> = None;
let mut machine = false;
let mut activity_probe = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--activity-probe" => {
activity_probe = Some(args.next().ok_or_else(|| AssetAiError::Io("--activity-probe needs seconds (1..3600)".into()))?.parse::<u64>().map_err(|_| AssetAiError::Io("invalid activity probe seconds".into()))?);
}
"--port" => {
let value = args
.next()
@ -78,7 +82,7 @@ fn run() -> Result<(), AssetAiError> {
}
"--help" | "-h" => {
println!(
"{SERVICE_NAME} {SERVICE_VERSION}\nusage: {SERVICE_NAME} [--port N] [--host ADDR] [--fleet NAME] [--cache-dir PATH] [--registry PATH] [--machine]"
"{SERVICE_NAME} {SERVICE_VERSION}\nusage: {SERVICE_NAME} [--port N] [--host ADDR] [--fleet NAME] [--cache-dir PATH] [--registry PATH] [--machine] [--activity-probe SECONDS]"
);
return Ok(());
}
@ -88,6 +92,10 @@ fn run() -> Result<(), AssetAiError> {
}
}
if let Some(seconds) = activity_probe {
return makepad_ai_hub::activity::run_probe(seconds);
}
let port = match port {
Some(port) => port,
None => match std::env::var("MAKEPAD_ASSET_AI_PORT") {

46
apps/aichat/Cargo.toml Normal file
View file

@ -0,0 +1,46 @@
# aichat — the assistant, as an app.
#
# One conversation that drives every application through the AI services
# layer. It is hosted three ways with one code base: under the window
# manager as a special child seated in the pane slot (this binary,
# `--stdin-loop`), standalone as its own window (this binary), and inside
# any app's Window as the F10 overlay or inside the mobile/web superbuild
# (this crate's lib, `makepad_aichat::script_mod` + `AiChatPanel{}`).
#
# The panel widget OWNS the engine: the service registry, the model, the
# transcript. Hosts only feed it links (in-process) or bus frames (the
# window manager's studio Custom frames) and give it a place to draw.
#
# Plan of record: repo-root aicontrol.md.
[package]
name = "makepad-aichat"
version = "0.1.0"
edition = "2021"
default-run = "aichat"
[lib]
name = "makepad_aichat"
path = "src/lib.rs"
[[bin]]
name = "aichat"
path = "src/main.rs"
[features]
default = ["engine"]
# The real models (the hub's local runtime, the cloud providers). Off for a
# build without a model runtime — the web page answers with NoModel and the
# tool console still works.
engine = ["makepad-ai-services/engine", "dep:makepad-asset-creator"]
[dependencies]
makepad-widgets = { path = "../../widgets" }
makepad-wm-theme = { path = "../../libs/wm_theme" }
makepad-wm-api = { path = "../../libs/wm_api" }
makepad-ai-services = { path = "../../libs/ai/services", default-features = false }
makepad-strict-json = { path = "../../libs/strict_json" }
# The generative pipelines behind the assistant's own `gen` service
# (src/gen.rs): the creator runner picks a fleet node and brings the
# picture back. Native only, with the engine.
makepad-asset-creator = { path = "../../libs/asset/creator", optional = true }

213
apps/aichat/src/bus.rs Normal file
View file

@ -0,0 +1,213 @@
//! The client half of the window manager's service bus.
//!
//! Under the WM the other apps are not in this process. The WM forwards
//! their up-frames to the aichat child as studio `Custom` frames, each
//! stamped with the endpoint the WM issued to the sender, and forwards
//! the aichat child's down-frames (which name their target endpoint) back
//! to the right client. This adapter turns those frames into ordinary
//! [`ServiceLink`]s in the panel's registry, so the engine never knows
//! whether a service is a channel away or a process away.
//!
//! One link per endpoint. A `Register` from an endpoint the registry does
//! not know creates the link and registers it under the WM's endpoint id
//! (`register_as`); a later `Register` from the same endpoint is just the
//! manifest going down the existing link, where the registry answers it.
//! The WM tells us about a dead client by sending `Unregister` on its
//! behalf. Everything the registry sends down a bus link is drained here
//! and put on the wire to the WM.
use makepad_ai_services::engine::ServiceRegistry;
use makepad_ai_services::port::{ServiceLink, ServiceLinkHost};
use makepad_ai_services::wire::*;
use makepad_widgets::makepad_platform::studio::AppToStudio;
use makepad_widgets::*;
use std::collections::HashMap;
#[derive(Default)]
pub struct ServiceBus {
hosts: HashMap<EndpointId, ServiceLinkHost>,
}
impl ServiceBus {
/// An up-frame from the WM. `None` for frames that are not the bus's.
pub fn on_custom(&mut self, registry: &ServiceRegistry, json: &str) -> bool {
let Some(frame) = HostedUp::parse(json) else { return false };
let Some(from) = frame.from.clone() else { return true };
match (&frame.msg, self.hosts.get(&from)) {
(ServiceUp::Register { manifest, .. }, None) => {
let (link, host) = ServiceLink::pair(manifest.clone());
if registry.register_as(link, from.clone(), "", None).is_ok() {
let _ = host.up.send(frame);
self.hosts.insert(from, host);
}
}
(_, Some(host)) => {
let _ = host.up.send(frame);
}
(_, None) => {}
}
true
}
/// Put every frame the registry sent down a bus link on the wire.
/// Links whose registry side is gone are dropped.
pub fn relay_down(&mut self, registry: &ServiceRegistry) {
for frame in self.drain_down(registry) {
Cx::send_studio_message(AppToStudio::Custom(frame.to_json()));
}
}
fn drain_down(&mut self, registry: &ServiceRegistry) -> Vec<HostedDown> {
let mut gone: Vec<EndpointId> = Vec::new();
let mut frames = Vec::new();
for (endpoint, host) in &self.hosts {
loop {
match host.down.try_recv() {
Ok(mut frame) => {
// `Registered` travels without a target; the WM
// routes it by the endpoint we register as.
if frame.to.is_none() {
frame.to = Some(endpoint.clone());
}
frames.push(frame);
}
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
gone.push(endpoint.clone());
break;
}
}
}
}
for endpoint in gone {
self.hosts.remove(&endpoint);
registry.unregister(&endpoint);
}
frames
}
pub fn len(&self) -> usize {
self.hosts.len()
}
pub fn is_empty(&self) -> bool {
self.hosts.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use makepad_ai_services::engine::{EngineCore, NoModelWithReason, RegistryUp};
#[test]
fn hosted_subscriptions_and_messages_cross_the_adapter_unchanged() {
let registry = ServiceRegistry::new();
let mut bus = ServiceBus::default();
let endpoint = EndpointId("w4".into());
let manifest = ServiceManifest::new("flow", "Flow", "A flow.")
.with_topic(TopicDef::new("run", "Run events."));
let register = HostedUp {
from: Some(endpoint.clone()),
msg: ServiceUp::Register { manifest, port_tag: 4 },
};
assert!(bus.on_custom(&registry, &register.to_json()));
registry.pump();
let host = bus.hosts.get(&endpoint).unwrap();
let _registered = host.down.try_recv().unwrap();
assert!(registry.send(
&endpoint,
ServiceDown::Subscribe {
sub_id: "s1".into(),
topic: "run".into(),
filter: None,
},
));
assert!(matches!(
host.down.try_recv().unwrap().msg,
ServiceDown::Subscribe { sub_id, topic, filter: None }
if sub_id == "s1" && topic == "run"
));
let message = HostedUp {
from: Some(endpoint.clone()),
msg: ServiceUp::Message {
sub_id: "s1".into(),
topic: "run".into(),
text: "finished".into(),
data: None,
final_: true,
},
};
assert!(bus.on_custom(&registry, &message.to_json()));
assert!(matches!(
registry.pump().as_slice(),
[RegistryUp::Message { endpoint: from, sub_id, message }]
if from == &endpoint && sub_id == "s1" && message.final_
));
}
#[test]
fn engine_shutdown_is_flushed_through_the_hosted_service_bus() {
let registry = ServiceRegistry::new();
let mut bus = ServiceBus::default();
let endpoint = EndpointId("w4".into());
let manifest = ServiceManifest::new("flow", "Flow", "A flow.")
.with_tool(ToolDef::new(
"watch",
"Watch a run.",
r#"{"type":"object","properties":{}}"#,
Risk::Read,
))
.with_topic(TopicDef::new("run", "Run events."));
assert!(bus.on_custom(
&registry,
&HostedUp {
from: Some(endpoint.clone()),
msg: ServiceUp::Register { manifest, port_tag: 4 },
}
.to_json(),
));
let mut engine = EngineCore::new(
registry.clone(),
Box::new(NoModelWithReason::new("not used by the tool console")),
None,
0x44,
);
engine.send("/flow.watch {}", 0.0);
let call_id = bus
.drain_down(&registry)
.into_iter()
.find_map(|frame| match frame.msg {
ServiceDown::Call(call) => Some(call.call_id),
_ => None,
})
.expect("the hosted service receives the call");
assert!(bus.on_custom(
&registry,
&HostedUp {
from: Some(endpoint.clone()),
msg: ServiceUp::Result(
ToolResult::ok(call_id, "watching", "")
.with_subscription(SubscriptionRequest::new("run")),
),
}
.to_json(),
));
engine.pump(0.1);
let sub_id = bus
.drain_down(&registry)
.into_iter()
.find_map(|frame| match frame.msg {
ServiceDown::Subscribe { sub_id, .. } => Some(sub_id),
_ => None,
})
.expect("the hosted service receives the subscription");
assert_eq!(sub_id, "l44-s1");
engine.shutdown();
assert!(matches!(
bus.drain_down(&registry).as_slice(),
[HostedDown { to: Some(to), msg: ServiceDown::Unsubscribe { sub_id: ended } }]
if to == &endpoint && ended == &sub_id
));
}
}

297
apps/aichat/src/gen.rs Normal file
View file

@ -0,0 +1,297 @@
//! The assistant's own generative service: `gen.image{prompt}`.
//!
//! The hub's pipelines are not an app, so no app registers them; the
//! panel registers this service in-process beside whatever apps join.
//! An `image` call runs the creator pipeline on a worker thread (the
//! runner is blocking: node pick over the LAN fleet, request, poll,
//! fetch), streams the node's progress into the card, writes the picture
//! under the makepad home's `gen` folder and answers with the path — the
//! model then hands that path to `photos.add`, which puts it on the wall.
//! Nothing goes through the asset store. Without the `engine` feature (the
//! web page) the service still exists and says it cannot.
use makepad_ai_services::engine::ServiceRegistry;
use makepad_ai_services::port::{AiServicePort, PortEvent};
use makepad_ai_services::wire::{Risk, ServiceCall, ServiceManifest, ToolDef, ToolResult};
use makepad_widgets::*;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, TryRecvError};
use std::sync::Arc;
/// The service id on the bus.
pub const SERVICE_ID: &str = "gen";
pub fn manifest() -> ServiceManifest {
ServiceManifest::new(
SERVICE_ID,
"Generate",
"The machine's generative pipelines, on the fleet's GPU nodes: a \
picture from a prompt. The picture is saved on this machine under \
the makepad home's gen folder and the answer carries its path. To \
SHOW it, call photos.add with that path (launch Photos first with \
os.launch if it is not running) the wall then glides onto it. A \
generation takes half a minute on a warm node, longer when a node \
must load the model.",
)
.with_tool(ToolDef::new(
"image",
"Generate one picture from a text prompt on a fleet image node; saves it under the makepad home's gen folder and returns the path.",
r#"{"type":"object","properties":{"prompt":{"type":"string","description":"what the picture shows, in plain words"},"width":{"type":"integer","description":"pixels, optional (default 1024)"},"height":{"type":"integer","description":"pixels, optional (default 1024)"}},"required":["prompt"]}"#,
Risk::Act,
))
}
/// What the worker reports back.
enum GenMsg {
Progress(String, u16),
Done(Result<GenDone, String>),
}
struct GenDone {
path: PathBuf,
node: String,
}
struct Job {
call_id: String,
cancel: Arc<AtomicBool>,
rx: Receiver<GenMsg>,
}
/// The in-process port plus the jobs in flight.
pub struct GenService {
port: AiServicePort,
jobs: Vec<Job>,
}
impl GenService {
/// Open the service and register it in the panel's registry. `None`
/// only when the manifest does not validate (a programming error).
pub fn open(registry: &ServiceRegistry) -> Option<GenService> {
let (port, link) = AiServicePort::in_process(manifest()).ok()?;
registry.register(link, "built in", None).ok()?;
Some(GenService { port, jobs: Vec::new() })
}
/// Drain the port and the workers; called on every panel event.
pub fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
for ev in self.port.handle_event(cx, event) {
match ev {
PortEvent::Call(call) => self.start(call),
PortEvent::Cancel { call_id } => {
if let Some(job) = self.jobs.iter().find(|j| j.call_id == call_id) {
job.cancel.store(true, Ordering::Relaxed);
}
}
PortEvent::Registered(_)
| PortEvent::ChatOpen { .. }
| PortEvent::Subscribe { .. }
| PortEvent::Unsubscribe { .. } => {}
}
}
self.poll();
}
fn start(&mut self, call: ServiceCall) {
let id = call.call_id.clone();
if call.tool != "image" {
self.port.reply(ToolResult::refused(&id, format!("gen has no tool `{}`; it has image", call.tool)));
return;
}
let args = match parse_image_args(&call.args) {
Ok(a) => a,
Err(why) => {
self.port.reply(ToolResult::refused(&id, why));
return;
}
};
#[cfg(not(feature = "engine"))]
{
let _ = args;
self.port.reply(ToolResult::unavailable(&id, "this build has no pipeline runtime; pictures need the native app"));
}
#[cfg(feature = "engine")]
{
use makepad_widgets::makepad_platform::thread::SignalToUI;
let cancel = Arc::new(AtomicBool::new(false));
let (tx, rx) = std::sync::mpsc::channel();
let worker_cancel = cancel.clone();
let spawned = std::thread::Builder::new().name("gen-image".into()).spawn(move || {
let progress_tx = tx.clone();
let mut progress = |note: &str, permille: u16| {
let _ = progress_tx.send(GenMsg::Progress(note.to_string(), permille));
SignalToUI::set_ui_signal();
};
let result = run_image(&args, &worker_cancel, &mut progress);
let _ = tx.send(GenMsg::Done(result));
SignalToUI::set_ui_signal();
});
match spawned {
Ok(_) => self.jobs.push(Job { call_id: id, cancel, rx }),
Err(e) => self.port.reply(ToolResult::failed(&id, format!("could not start the generation: {e}"))),
}
}
}
fn poll(&mut self) {
let mut done: Vec<usize> = Vec::new();
for (i, job) in self.jobs.iter().enumerate() {
loop {
match job.rx.try_recv() {
Ok(GenMsg::Progress(note, permille)) => self.port.progress(&job.call_id, &note, permille),
Ok(GenMsg::Done(Ok(out))) => {
let path = out.path.to_string_lossy().to_string();
self.port.reply(
ToolResult::ok(
&job.call_id,
format!("saved {path} (made on {}). Show it on the wall with photos.add {{\"path\":\"{path}\"}}.", out.node),
"saved",
)
.with_data(format!("{{\"path\":{},\"node\":{}}}", json_string(&path), json_string(&out.node))),
);
done.push(i);
break;
}
Ok(GenMsg::Done(Err(e))) => {
let result = if e == "cancelled" { ToolResult::cancelled(&job.call_id) } else { ToolResult::failed(&job.call_id, e) };
self.port.reply(result);
done.push(i);
break;
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
self.port.reply(ToolResult::failed(&job.call_id, "the generation worker died"));
done.push(i);
break;
}
}
}
}
for i in done.into_iter().rev() {
self.jobs.remove(i);
}
}
}
/// The arguments of one `image` call, checked.
#[derive(Clone, Debug, PartialEq)]
pub struct ImageArgs {
pub prompt: String,
pub width: u32,
pub height: u32,
}
/// Sizes are clamped to what the image nodes serve; a prompt is required.
pub fn parse_image_args(args: &str) -> Result<ImageArgs, String> {
use makepad_strict_json as json;
let fields = match json::parse(args.as_bytes()) {
Ok(json::Value::Obj(fields)) => fields,
_ => return Err("image needs a JSON object with a `prompt`".to_string()),
};
let get = |key: &str| fields.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone());
let prompt = get("prompt").and_then(|v| v.as_str().map(|s| s.trim().to_string())).unwrap_or_default();
if prompt.is_empty() {
return Err("image needs a `prompt`".to_string());
}
if prompt.len() > 4000 {
return Err("the prompt is longer than 4000 bytes".to_string());
}
let dim = |key: &str| -> Result<u32, String> {
match get(key) {
None => Ok(1024),
Some(json::Value::Int(i)) if (256..=2048).contains(&i) => Ok(i as u32),
Some(_) => Err(format!("`{key}` must be an integer from 256 to 2048")),
}
};
Ok(ImageArgs { prompt, width: dim("width")?, height: dim("height")? })
}
/// A short file-name-safe slug of the prompt.
pub fn slug(prompt: &str) -> String {
let mut out = String::new();
for ch in prompt.chars().flat_map(|c| c.to_lowercase()) {
if ch.is_ascii_alphanumeric() {
out.push(ch);
} else if !out.ends_with('-') && !out.is_empty() {
out.push('-');
}
if out.len() >= 40 {
break;
}
}
let out = out.trim_matches('-').to_string();
if out.is_empty() { "picture".to_string() } else { out }
}
/// The file extension for a content type the nodes produce.
pub fn extension_for(content_type: &str) -> &'static str {
match content_type.split(';').next().unwrap_or("").trim() {
"image/jpeg" => "jpg",
"image/webp" => "webp",
"image/gif" => "gif",
_ => "png",
}
}
fn json_string(s: &str) -> String {
makepad_strict_json::s(s.to_string()).to_json()
}
#[cfg(feature = "engine")]
fn run_image(args: &ImageArgs, cancel: &Arc<AtomicBool>, progress: &mut dyn FnMut(&str, u16)) -> Result<GenDone, String> {
use makepad_asset_creator::makepad_ai_hub::home::makepad_home;
use makepad_asset_creator::makepad_strict_json as json;
use makepad_asset_creator::runner::generate_bytes;
let body = json::obj(vec![
("prompt", json::s(args.prompt.clone())),
("width", json::Value::Int(args.width as i64)),
("height", json::Value::Int(args.height as i64)),
]);
let seed = (Cx::time_now().max(0.0) * 1_000_000_000.0) as u64;
progress("finding an image node", 0);
let generated = generate_bytes("image.generate", &body, seed, cancel, progress)?;
let artifact = generated.artifact.ok_or("the node returned no picture")?;
let dir = makepad_home().join("gen");
std::fs::create_dir_all(&dir).map_err(|e| format!("cannot make {}: {e}", dir.display()))?;
let stamp = Cx::time_now().max(0.0) as u64;
let path = dir.join(format!("{stamp}-{}.{}", slug(&args.prompt), extension_for(&artifact.content_type)));
std::fs::write(&path, &artifact.bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))?;
Ok(GenDone { path, node: generated.node })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_manifest_validates_and_the_tool_acts() {
let m = manifest();
m.validate().expect("a manifest the wire accepts");
assert_eq!(m.id, "gen");
assert_eq!(m.tools.len(), 1);
assert_eq!(m.tools[0].name, "image");
assert_eq!(m.tools[0].risk, Risk::Act);
}
#[test]
fn image_args_need_a_prompt_and_sane_sizes() {
let a = parse_image_args(r#"{"prompt":"a red bicycle on the moon"}"#).unwrap();
assert_eq!((a.width, a.height), (1024, 1024));
let b = parse_image_args(r#"{"prompt":"x","width":512,"height":768}"#).unwrap();
assert_eq!((b.width, b.height), (512, 768));
assert!(parse_image_args(r#"{"width":512}"#).unwrap_err().contains("prompt"));
assert!(parse_image_args(r#"{"prompt":"x","width":16}"#).unwrap_err().contains("256"));
assert!(parse_image_args("nope").is_err());
}
#[test]
fn file_names_are_slugs_with_the_right_extension() {
assert_eq!(slug("A red bicycle, on the Moon!"), "a-red-bicycle-on-the-moon");
assert_eq!(slug(" "), "picture");
assert!(slug(&"word ".repeat(30)).len() <= 41);
assert_eq!(extension_for("image/png"), "png");
assert_eq!(extension_for("image/jpeg; charset=binary"), "jpg");
assert_eq!(extension_for("application/octet-stream"), "png");
}
}

89
apps/aichat/src/lib.rs Normal file
View file

@ -0,0 +1,89 @@
//! aichat as a library: the panel widget that owns the engine, the WM-bus
//! client half, the settings, and the module root. A host links this,
//! calls [`script_mod`] after the widgets' own, and puts `AiChatPanel{}`
//! where the chat goes — the standalone binary and the WM pane child do
//! that themselves; a plain `Window` does it for free through its F10
//! slot, which instantiates `mod.widgets.AiChatOverlay{}` by name; the
//! superbuild seats the same overlay in its pane.
//!
//! Linking this crate also gives the bridge its `/ai` routes: `script_mod`
//! installs `Cx::ai_callback`, the way the widgets crate installs the
//! tweaker's, so `/ai?on=1`, `/ai?say=…` and `/ai/transcript` drive and
//! read the chat in a hidden instance.
pub use makepad_widgets;
use makepad_widgets::ai_slot::AiSlotRequests;
use makepad_widgets::makepad_platform::ScriptVmCx;
use makepad_widgets::*;
pub mod bus;
pub mod gen;
pub mod overlay;
pub mod panel;
pub mod settings;
pub use bus::ServiceBus;
pub use overlay::{AiChatOverlay, AiTranscript};
pub use panel::{AiChatPanel, AiChatPanelAction};
pub use settings::AiSettings;
/// Register the panel and the overlay, and give the bridge its `/ai`
/// routes. Call once after `makepad_widgets::script_mod`.
pub fn script_mod(vm: &mut ScriptVm) {
crate::panel::script_mod(vm);
crate::overlay::script_mod(vm);
vm.cx_mut().ai_callback = Some(ai_callback);
}
fn arg<'a>(args: &'a [(String, String)], keys: &[&str]) -> Option<&'a str> {
for key in keys {
if let Some((_, value)) = args.iter().find(|(k, _)| k == key) {
return Some(value.as_str());
}
}
None
}
/// The bridge's `/ai` dispatcher. `toggle` (`on=1|0`, or flip) and `say`
/// (`say=TEXT`, opening the overlay if it is closed) are requests the
/// slot and the overlay take on their next event; `transcript` is what
/// the overlay last published. Never borrows a widget.
fn ai_callback(cx: &mut Cx, op: &str, args: &[(String, String)]) -> Result<String, String> {
match op {
"toggle" => {
let is_open = cx.global::<AiSlotRequests>().is_open;
let on = match arg(args, &["on"]) {
Some(value) => !matches!(value, "0" | "false" | "off" | "no"),
None => !is_open,
};
cx.global::<AiSlotRequests>().open = Some(on);
// The slot takes the request on its next event: make one.
cx.new_next_frame();
cx.redraw_all();
Ok(format!("{{\"on\":{}}}", on as u8))
}
"say" => {
let text = arg(args, &["say", "t"]).unwrap_or("").trim().to_string();
if text.is_empty() {
return Err("need say=TEXT".into());
}
let req = cx.global::<AiSlotRequests>();
if !req.is_open {
req.open = Some(true);
}
req.say.push(text);
cx.new_next_frame();
cx.redraw_all();
Ok("{\"ok\":1}".into())
}
"transcript" => {
let json = cx.global::<AiTranscript>().json.clone();
if json.is_empty() {
Ok("{\"status\":\"closed\",\"provider\":\"\",\"apps\":[],\"entries\":[],\"generation\":0}".into())
} else {
Ok(json)
}
}
other => Err(format!("no ai op `{other}`; there are toggle, say, transcript")),
}
}

79
apps/aichat/src/main.rs Normal file
View file

@ -0,0 +1,79 @@
//! aichat: the assistant as its own window, and as the window manager's
//! special child. Both are this binary: standalone it is a window with
//! the panel in it and whatever services are linked in-process (none,
//! until an app embeds it); under the WM (`--stdin-loop`) the WM seats it
//! in the pane slot and every other app's service reaches it over the bus
//! as studio `Custom` frames, which the panel turns into registry links.
pub use makepad_widgets;
use makepad_aichat::AiChatPanelAction;
use makepad_widgets::*;
app_main!(App);
script_mod! {
use mod.prelude.widgets.*
use mod.widgets.*
startup() do #(App::script_component(vm)){
ui: Root{
main_window := Window{
window.inner_size: vec2(460, 760)
window.title: "AI"
pass +: { clear_color: theme.color_bg_app }
body +: {
panel := AiChatPanel{
width: Fill
height: Fill
}
}
}
}
}
}
#[derive(Script, ScriptHook)]
pub struct App {
#[live]
ui: WidgetRef,
}
impl MatchEvent for App {
fn handle_startup(&mut self, cx: &mut Cx) {
makepad_wm_api::set_title(cx, "AI");
}
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) {
for action in actions {
if let Some(widget_action) = action.as_widget_action() {
if let AiChatPanelAction::Close = widget_action.cast() {
// Under the WM the pane hides; standalone the window stays.
let _ = makepad_wm_api::send(cx, &makepad_wm_api::WmRequest::Close);
}
}
}
}
}
impl AppMain for App {
fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
crate::makepad_widgets::script_mod(vm);
makepad_wm_theme::apply(vm);
makepad_aichat::script_mod(vm);
self::script_mod(vm)
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
// Bus frames reach the panel through its own handle_event; of the
// WM's own frames only the polite close is ours (the pane hides us
// by leaving us running; this is the desktop going down).
if let Event::Custom(json) = event {
if let Some(makepad_wm_api::WmEvent::CloseRequested) = makepad_wm_api::WmEvent::parse(json) {
cx.quit();
return;
}
}
self.match_event(cx, event);
self.ui.handle_event(cx, event, &mut Scope::empty());
}
}

261
apps/aichat/src/overlay.rs Normal file
View file

@ -0,0 +1,261 @@
//! The chat's module root: what a host seats in-process.
//!
//! `mod.widgets.AiChatOverlay{}` is what the Window's AI slot
//! (`widgets/src/ai_slot.rs`) instantiates by name on F10, and what the
//! superbuild seats in its pane. It is a `View` with the panel in it and
//! three duties around it: adopt the in-process service links the apps
//! parked on `Cx` ([`PendingServiceLinks`]) into the panel's registry,
//! send the lines the bridge asked to say ([`AiSlotRequests::say`]),
//! and publish the transcript as JSON ([`AiTranscript`]) after each draw
//! so `/ai/transcript` answers without touching a widget. The panel's
//! Escape (an idle, empty composer) asks the slot to close.
use crate::panel::{AiChatPanel, AiChatPanelAction};
use makepad_ai_services::port::PendingServiceLinks;
use makepad_ai_services::state::{Entry, EngineState, Status, ToolStatus};
use makepad_widgets::ai_slot::AiSlotRequests;
use makepad_widgets::makepad_micro_serde::*;
use makepad_widgets::*;
script_mod! {
use mod.prelude.widgets_internal.*
use mod.widgets.*
mod.widgets.AiChatOverlayBase = #(AiChatOverlay::register_widget(vm))
mod.widgets.AiChatOverlay = set_type_default() do mod.widgets.AiChatOverlayBase{
width: Fill
height: Fill
panel := AiChatPanel{
width: Fill
height: Fill
}
}
}
/// The transcript as the bridge reads it: a `Cx` global the overlay
/// rewrites after every draw of the panel.
#[derive(Default)]
pub struct AiTranscript {
pub json: String,
}
#[derive(SerJson)]
struct TranscriptRow {
kind: String,
text: String,
title: String,
status: String,
note: String,
}
#[derive(SerJson)]
struct Transcript {
status: String,
provider: String,
apps: Vec<String>,
entries: Vec<TranscriptRow>,
generation: u64,
}
/// The transcript JSON for a state: `status`, `provider`, the connected
/// apps, and one row per entry with what its card says.
pub fn transcript_json(state: &EngineState) -> String {
let status = match &state.status {
Status::Idle => "idle".to_string(),
Status::Loading { phase, fraction } => format!("loading {phase} {:.0}%", fraction * 100.0),
Status::Thinking => "thinking".to_string(),
Status::Streaming => "streaming".to_string(),
Status::WaitingForTool => "waiting_for_tool".to_string(),
Status::Error(e) => format!("error: {e}"),
};
let entries = state
.entries
.iter()
.map(|entry| match entry {
Entry::User { text } => TranscriptRow {
kind: "user".into(),
text: text.clone(),
title: String::new(),
status: String::new(),
note: String::new(),
},
Entry::Event(event) => TranscriptRow {
kind: "event".into(),
text: event.text.clone(),
title: format!("{} · {}", event.service_label, event.topic),
status: if event.final_ { "final".into() } else { "message".into() },
note: if event.dropped == 0 {
format!("sub_id: {}", event.sub_id)
} else {
format!("sub_id: {} · dropped: {}", event.sub_id, event.dropped)
},
},
Entry::Assistant { text, streaming } => TranscriptRow {
kind: "assistant".into(),
text: text.clone(),
title: String::new(),
status: if *streaming { "streaming".into() } else { "done".into() },
note: String::new(),
},
Entry::Tool(t) => {
let (status, note, text) = match &t.status {
ToolStatus::Confirm => ("confirm".to_string(), String::new(), String::new()),
ToolStatus::Running { note, permille } => ("running".to_string(), format!("{note} {permille}"), String::new()),
ToolStatus::Done { outcome, note, text } => (outcome.slug().to_string(), note.clone(), text.clone()),
};
TranscriptRow { kind: "tool".into(), text, title: t.title.clone(), status, note }
}
Entry::System { text } => TranscriptRow {
kind: "system".into(),
text: text.clone(),
title: String::new(),
status: String::new(),
note: String::new(),
},
})
.collect();
Transcript {
status,
provider: state.provider_label.clone(),
apps: state.services.iter().filter(|s| s.connected).map(|s| s.label.clone()).collect(),
entries,
generation: state.generation,
}
.serialize_json()
}
#[derive(Script, ScriptHook, Widget)]
pub struct AiChatOverlay {
#[source]
source: ScriptObjectRef,
#[deref]
view: View,
/// The state generation last published as JSON.
#[rust]
published: Option<u64>,
}
impl AiChatOverlay {
/// The transcript JSON, rewritten when the engine's state moved — on
/// every event as well as every draw, so a closed slot (no draws) still
/// answers `/ai/transcript` with the turn that finished behind it.
fn publish_transcript(&mut self, cx: &mut Cx) {
let json = self
.view
.widget(cx, ids!(panel))
.borrow::<AiChatPanel>()
.and_then(|panel| {
let state = panel.state()?;
if self.published == Some(state.generation) {
return None;
}
Some((state.generation, transcript_json(state)))
});
if let Some((generation, json)) = json {
self.published = Some(generation);
cx.global::<AiTranscript>().json = json;
}
}
/// The links the apps parked and the lines the bridge asked for, into
/// the panel.
fn adopt_requests(&mut self, cx: &mut Cx) {
let links = cx.global::<PendingServiceLinks>().take();
let says = std::mem::take(&mut cx.global::<AiSlotRequests>().say);
if links.is_empty() && says.is_empty() {
return;
}
let panel = self.view.widget(cx, ids!(panel));
let Some(mut panel) = panel.borrow_mut::<AiChatPanel>() else {
return;
};
for link in links {
let label = link.manifest.label.clone();
match panel.registry().register(link, "in this window", None) {
Ok(endpoint) => log!("aichat: {label} joined in-process as {}", endpoint.as_str()),
Err(e) => log!("aichat: {label} refused: {e}"),
}
}
for text in says {
panel.say(cx, text);
}
}
}
impl Widget for AiChatOverlay {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
self.adopt_requests(cx);
self.view.handle_event(cx, event, scope);
self.publish_transcript(cx);
if let Event::Actions(actions) = event {
for action in actions {
if let Some(widget_action) = action.as_widget_action() {
if let AiChatPanelAction::Close = widget_action.cast() {
cx.global::<AiSlotRequests>().open = Some(false);
cx.redraw_all();
}
}
}
}
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
let step = self.view.draw_walk(cx, scope, walk);
if step.is_done() {
self.publish_transcript(cx);
}
step
}
}
#[cfg(test)]
mod tests {
use super::*;
use makepad_ai_services::state::{EventEntry, ServiceInfo, ToolEntry};
use makepad_ai_services::wire::ToolOutcome;
#[test]
fn the_transcript_reads_as_json_rows() {
let mut state = EngineState::default();
state.provider_label = "No model".into();
state.services.push(ServiceInfo {
id: "sheets".into(),
endpoint: "e1".into(),
label: "Sheets".into(),
parent: None,
location: "in this window".into(),
connected: true,
launchable: false,
tool_count: 1,
});
state.entries.push(Entry::User { text: "/sheets.summary {}".into() });
state.entries.push(Entry::Event(EventEntry {
sub_id: "s1".into(),
service_label: "Sheets".into(),
topic: "changes".into(),
text: "A1 changed".into(),
data: None,
dropped: 2,
final_: false,
}));
state.entries.push(Entry::Tool(ToolEntry {
call_id: "c1".into(),
service: "sheets".into(),
service_label: "Sheets".into(),
tool: "summary".into(),
title: "Sheets · summary".into(),
args: "{}".into(),
status: ToolStatus::Done { outcome: ToolOutcome::Ok, note: "3 × 4".into(), text: "Sheet1, 3 rows".into() },
preview: false,
expanded: false,
}));
state.generation = 7;
let json = transcript_json(&state);
assert!(json.contains(r#""status":"idle""#), "{json}");
assert!(json.contains(r#""apps":["Sheets"]"#), "{json}");
assert!(json.contains(r#""kind":"user""#) && json.contains(r#""kind":"tool""#), "{json}");
assert!(json.contains(r#""kind":"event""#) && json.contains("sub_id: s1") && json.contains("dropped: 2"), "{json}");
assert!(json.contains(r#""title":"Sheets · summary""#) && json.contains(r#""status":"ok""#), "{json}");
assert!(json.contains(r#""generation":7"#), "{json}");
}
}

589
apps/aichat/src/panel.rs Normal file
View file

@ -0,0 +1,589 @@
//! The chat panel: the one widget every host shows. It owns the engine —
//! the service registry, the model, the transcript — and draws
//! `EngineState` as a transcript of user lines, assistant text, tool cards
//! (running, done, waiting for a confirm), and system lines, over a
//! composer.
//!
//! Hosts talk to it in two ways: they hand it service links
//! (`registry()`) or bus frames (`on_custom`), and they listen for
//! [`AiChatPanelAction`]s. The engine runs on the panel's own events: every
//! event pumps it, and while a turn is in flight the panel asks for the
//! next frame so streaming, deadlines and the cloud provider's polling all
//! advance without a host timer.
use crate::bus::ServiceBus;
use crate::gen::GenService;
use crate::settings::AiSettings;
#[cfg(feature = "engine")]
use makepad_ai_services::engine::models::{build_model, provider_rows};
use makepad_ai_services::engine::NoModelWithReason;
use makepad_ai_services::engine::{EngineCore, EngineEvent, ServiceRegistry};
use makepad_ai_services::state::*;
use makepad_ai_services::wire::ToolOutcome;
use makepad_widgets::*;
script_mod! {
use mod.prelude.widgets_internal.*
use mod.widgets.*
mod.widgets.AiChatPanelBase = #(AiChatPanel::register_widget(vm))
let Line = Label{
width: Fill
height: Fit
draw_text +: {
color: theme.color_text
text_style: theme.font_regular{font_size: 9.5}
}
}
let Row = View{
width: Fill
height: Fit
flow: Down
padding: Inset{left: 14 right: 14 top: 4 bottom: 4}
}
mod.widgets.AiChatPanel = set_type_default() do mod.widgets.AiChatPanelBase{
width: Fill
height: Fill
flow: Down
draw_bg +: { color: theme.color_bg_app }
header := SolidView{
width: Fill
height: 36
flow: Right
spacing: 10
padding: Inset{left: 14 right: 8}
align: Align{y: 0.5}
draw_bg +: { color: theme.color_bg_container }
title := Label{
text: "AI"
draw_text +: {
color: theme.color_text
text_style: theme.font_bold{font_size: 10.5}
}
}
provider := Label{
width: Fill
text: ""
draw_text +: {
color: theme.color_text_meta
text_style: theme.font_regular{font_size: 8.5}
}
}
clear_button := ButtonFlatter{ text: "Clear" }
}
apps_row := Label{
width: Fill
height: Fit
padding: Inset{left: 14 right: 14 top: 6 bottom: 2}
max_lines: 2
text: ""
draw_text +: {
color: theme.color_text_meta
text_style: theme.font_regular{font_size: 8.5}
}
}
transcript := PortalList{
width: Fill
height: Fill
auto_tail: true
UserRow := Row{
padding: Inset{left: 14 right: 14 top: 10 bottom: 4}
user_text := Line{
draw_text +: {
color: theme.color_text
text_style: theme.font_bold{font_size: 9.5}
}
}
}
EventRow := Row{
margin: Inset{left: 10 right: 10 top: 5 bottom: 5}
padding: Inset{left: 10 right: 10 top: 7 bottom: 7}
draw_bg +: { color: theme.color_bg_container }
event_title := Line{
draw_text +: {
color: theme.color_text_hl
text_style: theme.font_bold{font_size: 8.5}
}
}
event_text := Line{}
event_meta := Line{
draw_text +: {
color: theme.color_text_meta
text_style: theme.font_regular{font_size: 8.0}
}
}
}
AssistantRow := Row{
assistant_md := Markdown{
width: Fill
height: Fit
body: ""
}
}
StreamRow := Row{
stream_text := Line{}
}
ToolRow := Row{
padding: Inset{left: 22 right: 14 top: 3 bottom: 3}
tool_head := View{
width: Fill
height: Fit
cursor: MouseCursor.Hand
tool_title := Line{
draw_text +: {
color: theme.color_text_meta
text_style: theme.font_regular{font_size: 8.5}
}
}
}
tool_note := Line{
draw_text +: {
color: theme.color_text_meta
text_style: theme.font_regular{font_size: 8.5}
}
}
tool_bar := SolidView{
width: Fill
height: 2
margin: Inset{top: 3}
draw_bg +: { color: theme.color_text_hl }
}
tool_detail := Line{
visible: false
draw_text +: {
color: theme.color_text_meta
text_style: theme.font_regular{font_size: 8.0}
}
}
}
ConfirmRow := Row{
padding: Inset{left: 22 right: 14 top: 6 bottom: 6}
confirm_title := Line{
draw_text +: {
color: theme.color_text
text_style: theme.font_regular{font_size: 9.0}
}
}
confirm_buttons := View{
width: Fill
height: Fit
flow: Right
spacing: 8
margin: Inset{top: 4}
run_button := Button{ text: "Run" }
deny_button := ButtonFlat{ text: "Cancel" }
}
}
SystemRow := Row{
system_text := Line{
draw_text +: {
color: theme.color_text_hl
text_style: theme.font_regular{font_size: 8.5}
}
}
}
}
status := Label{
width: Fill
height: Fit
padding: Inset{left: 14 right: 14 top: 4 bottom: 2}
max_lines: 2
text: ""
draw_text +: {
color: theme.color_text_meta
text_style: theme.font_regular{font_size: 8.5}
}
}
composer := View{
width: Fill
height: Fit
flow: Right
spacing: 8
padding: Inset{left: 12 right: 12 top: 6 bottom: 10}
align: Align{y: 0.5}
input := TextInput{
width: Fill
height: Fit
empty_text: "Ask AI"
// The prompt is a hint, not text: a dark grey in every state,
// never the typed colour (the composer is always focused).
draw_text +: {
color_empty: #666666
color_empty_hover: #777777
color_empty_focus: #666666
}
}
send_button := Button{ text: "Send" }
}
}
}
/// What the panel tells its host.
#[derive(Clone, Debug, PartialEq, Default)]
pub enum AiChatPanelAction {
/// Esc with an empty composer and no turn in flight: the host may hide
/// the pane.
Close,
#[default]
None,
}
#[derive(Script, ScriptHook, Widget)]
pub struct AiChatPanel {
#[source]
source: ScriptObjectRef,
#[deref]
view: View,
#[rust]
engine: Option<EngineCore>,
#[rust]
registry: ServiceRegistry,
#[rust]
bus: ServiceBus,
/// The assistant's own `gen` service (pictures from the fleet), joined
/// to the registry with the engine.
#[rust]
gen: Option<GenService>,
#[rust]
settings: Option<AiSettings>,
#[rust]
drawn_generation: u64,
#[rust]
next_frame: NextFrame,
/// The composer took the keyboard once it existed on screen — a
/// focus set before the first draw lands on no area at all.
#[rust]
composer_focused: bool,
}
impl AiChatPanel {
/// The registry a host plugs in-process links into.
pub fn registry(&self) -> &ServiceRegistry {
&self.registry
}
/// A studio `Custom` frame that may be a bus frame from the WM.
pub fn on_custom(&mut self, json: &str) -> bool {
self.bus.on_custom(&self.registry, json)
}
pub fn state(&self) -> Option<&EngineState> {
self.engine.as_ref().map(|e| e.state())
}
fn settings(&mut self) -> &AiSettings {
if self.settings.is_none() {
self.settings = Some(AiSettings::load());
}
self.settings.as_ref().unwrap()
}
/// The engine comes up on first use — a local model is a long load
/// and no host pays it before the person opens the pane.
fn ensure_engine(&mut self) -> &mut EngineCore {
if self.engine.is_none() {
let lease_id = self.widget_uid().0;
let settings = self.settings().clone();
// The real models ride the `engine` feature; a build without a
// runtime (the web page) says so and keeps the tool console.
#[cfg(feature = "engine")]
let (model, rows): (Box<dyn makepad_ai_services::Model>, Vec<ProviderRow>) = (
match build_model(&settings.provider, settings.local_only) {
Ok(m) => m,
Err(reason) => Box::new(NoModelWithReason::new(reason)),
},
provider_rows(settings.local_only),
);
#[cfg(not(feature = "engine"))]
let (model, rows): (Box<dyn makepad_ai_services::Model>, Vec<ProviderRow>) =
(Box::new(NoModelWithReason::new("this build has no model runtime")), Vec::new());
let mut core = EngineCore::new(self.registry.clone(), model, None, lease_id);
// The state is the panel's window into the core; the core owns
// it, so provider facts go in through the core.
core.set_provider_facts(settings.provider.clone(), rows, settings.local_only);
self.engine = Some(core);
self.gen = GenService::open(&self.registry);
}
self.engine.as_mut().unwrap()
}
fn now(cx: &Cx) -> f64 {
cx.seconds_since_app_start()
}
/// A line as if typed and sent — the bridge's `/ai?say=`.
pub fn say(&mut self, cx: &mut Cx, text: String) {
self.send(cx, text);
}
fn send(&mut self, cx: &mut Cx, text: String) {
let now = Self::now(cx);
self.ensure_engine().send(&text, now);
let input = self.view.text_input(cx, ids!(input));
input.set_text(cx, "");
// The widget drops the keyboard on submit; a chat composer keeps
// it, so the next line can be typed straight away.
input.take_key_focus(cx);
self.view.redraw(cx);
}
fn apps_line(&self, state: &EngineState) -> String {
if state.services.is_empty() {
return "No apps connected.".into();
}
state
.services
.iter()
.map(|s| if s.connected { s.label.clone() } else { format!("{} (not running)", s.label) })
.collect::<Vec<_>>()
.join(" · ")
}
fn status_line(state: &EngineState) -> String {
let rate = state.rate.map(|r| format!(" · {r:.0} tok/s")).unwrap_or_default();
match &state.status {
Status::Idle => rate.trim_start_matches(" · ").to_string(),
Status::Loading { phase, fraction } => format!("loading {phase} {:.0}%", fraction * 100.0),
Status::Thinking => {
if state.thinking.is_empty() {
"thinking…".to_string()
} else {
let tail: String = state.thinking.chars().rev().take(120).collect::<Vec<_>>().into_iter().rev().collect();
format!("thinking… {tail}")
}
}
Status::Streaming => format!("writing{rate}"),
Status::WaitingForTool => "waiting for the app…".to_string(),
Status::Error(e) => e.clone(),
}
}
}
impl Drop for AiChatPanel {
fn drop(&mut self) {
// The engine only enqueues Unsubscribe frames. Flush them while the
// hosted bus and registry still exist; field destruction is too late.
if let Some(engine) = self.engine.as_mut() {
engine.shutdown();
}
self.bus.relay_down(&self.registry);
}
}
impl Widget for AiChatPanel {
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
if let Event::Custom(json) = event {
self.on_custom(json);
}
// Esc: stop a running turn; with nothing running and an empty
// composer, ask the host to hide the pane.
if let Event::KeyDown(ke) = event {
if ke.key_code == KeyCode::Escape {
let busy = self.engine.as_ref().map(|e| e.state().status.is_busy()).unwrap_or(false);
let empty = self.view.text_input(cx, ids!(input)).text().trim().is_empty();
if busy {
let now = Self::now(cx);
self.ensure_engine().cancel(now);
self.view.redraw(cx);
} else if empty {
cx.widget_action(self.widget_uid(), AiChatPanelAction::Close);
}
}
}
// The built-in services answer before the engine pumps, so a
// finished picture lands in this same event.
if let Some(gen) = self.gen.as_mut() {
gen.handle_event(cx, event);
}
// Drive the engine on every event; the bus relays what it sent.
let now = Self::now(cx);
let mut changed = false;
let mut busy = false;
if let Some(engine) = self.engine.as_mut() {
for ev in engine.pump(now) {
match ev {
EngineEvent::Changed => changed = true,
EngineEvent::Confirm { .. } => changed = true,
}
}
busy = engine.needs_pump()
|| engine.state().status.is_busy()
|| matches!(engine.state().status, Status::Loading { .. });
}
self.bus.relay_down(&self.registry);
if changed {
self.view.redraw(cx);
}
if busy {
self.next_frame = cx.new_next_frame();
}
self.view.handle_event(cx, event, scope);
self.widget_match_event(cx, event, scope);
}
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
// The engine exists once the panel is on screen.
let _ = self.ensure_engine();
let state = self.engine.as_ref().unwrap().state().clone();
self.drawn_generation = state.generation;
self.view.label(cx, ids!(provider)).set_text(cx, &format!("{}{}", state.provider_label, if state.local_only { " · local only" } else { "" }));
self.view.label(cx, ids!(apps_row)).set_text(cx, &self.apps_line(&state));
self.view.label(cx, ids!(status)).set_text(cx, &Self::status_line(&state));
while let Some(item) = self.view.draw_walk(cx, scope, walk).step() {
let Some(mut list) = item.borrow_mut::<PortalList>() else { continue };
let total = state.entries.len();
list.set_item_range(cx, 0, total);
while let Some(index) = list.next_visible_item(cx) {
if index >= total {
continue;
}
let entry = &state.entries[index];
let row = match entry {
Entry::User { text } => {
let row = list.item(cx, index, id!(UserRow));
row.label(cx, ids!(user_text)).set_text(cx, text);
row
}
Entry::Event(event) => {
let row = list.item(cx, index, id!(EventRow));
row.label(cx, ids!(event_title))
.set_text(cx, &format!("{} · {}", event.service_label, event.topic));
row.label(cx, ids!(event_text)).set_text(cx, &event.text);
let mut meta = format!("sub_id: {}", event.sub_id);
if event.dropped != 0 {
meta.push_str(&format!(" · dropped: {}", event.dropped));
}
if event.final_ {
meta.push_str(" · final");
}
if let Some(data) = &event.data {
meta.push_str(&format!("\n{data}"));
}
row.label(cx, ids!(event_meta)).set_text(cx, &meta);
row
}
Entry::Assistant { text, streaming: false } => {
let row = list.item(cx, index, id!(AssistantRow));
// No text, no row: a blank block above a card is a gap.
row.set_visible(cx, !text.trim().is_empty());
if let Some(mut md) = row.widget(cx, ids!(assistant_md)).borrow_mut::<Markdown>() {
md.set_text(cx, text);
}
row
}
Entry::Assistant { text, streaming: true } => {
let row = list.item(cx, index, id!(StreamRow));
row.set_visible(cx, !text.trim().is_empty());
row.label(cx, ids!(stream_text)).set_text(cx, text);
row
}
Entry::Tool(t) if matches!(t.status, ToolStatus::Confirm) => {
let row = list.item(cx, index, id!(ConfirmRow));
row.label(cx, ids!(confirm_title)).set_text(cx, &format!("{} — this changes things outside the app. Run it?", t.title));
row
}
Entry::Tool(t) => {
let row = list.item(cx, index, id!(ToolRow));
let (glyph, note, permille, detail) = match &t.status {
ToolStatus::Running { note, permille } => ("", note.clone(), *permille, String::new()),
ToolStatus::Done { outcome, note, text } => {
let glyph = if outcome.is_ok() { "" } else { "" };
let note = if note.is_empty() { outcome.slug().to_string() } else { note.clone() };
(glyph, note, 1000, text.clone())
}
ToolStatus::Confirm => ("?", String::new(), 0, String::new()),
};
row.label(cx, ids!(tool_title)).set_text(cx, &format!("{glyph} {}", t.title));
row.label(cx, ids!(tool_note)).set_text(cx, &note);
let bar_visible = matches!(t.status, ToolStatus::Running { .. });
row.view(cx, ids!(tool_bar)).set_visible(cx, bar_visible);
if bar_visible {
// The bar is a fraction of the row's inner width
// (the row's rect is last frame's; the first
// frame of a card draws it at a token width).
let inner = (row.area().rect(cx).size.x - 36.0).max(24.0);
let px = inner * (permille as f64 / 1000.0).max(0.05);
let mut bar = row.view(cx, ids!(tool_bar));
script_apply_eval!(cx, bar, { width: #(px) });
}
let detail_label = row.label(cx, ids!(tool_detail));
detail_label.set_visible(cx, t.expanded && !detail.is_empty());
if t.expanded {
detail_label.set_text(cx, &format!("{}\n{}", t.args, detail));
}
let _ = matches!(t.status, ToolStatus::Done { outcome: ToolOutcome::Ok, .. });
row
}
Entry::System { text } => {
let row = list.item(cx, index, id!(SystemRow));
row.label(cx, ids!(system_text)).set_text(cx, text);
row
}
};
row.draw_all(cx, &mut Scope::empty());
}
}
if !self.composer_focused {
self.composer_focused = true;
self.view.text_input(cx, ids!(input)).take_key_focus(cx);
}
DrawStep::done()
}
}
impl WidgetMatchEvent for AiChatPanel {
fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions, scope: &mut Scope) {
let input = self.view.text_input(cx, ids!(input));
let returned = input.returned(actions).map(|(text, _)| text);
if let Some(text) = returned {
self.send(cx, text);
} else if self.view.button(cx, ids!(send_button)).clicked(actions) {
let text = input.text();
self.send(cx, text);
}
if self.view.button(cx, ids!(clear_button)).clicked(actions) {
let now = Self::now(cx);
self.ensure_engine().clear(now);
self.view.redraw(cx);
}
// Cards: confirm buttons and click-to-expand.
let list = self.view.portal_list(cx, ids!(transcript));
let items = list.items_with_actions(actions);
if !items.is_empty() {
let now = Self::now(cx);
let call_ids: Vec<Option<String>> = {
let state = self.ensure_engine().state();
items
.iter()
.map(|(index, _)| match state.entries.get(*index) {
Some(Entry::Tool(t)) => Some(t.call_id.clone()),
_ => None,
})
.collect()
};
for ((_, item), call_id) in items.iter().zip(call_ids) {
let Some(call_id) = call_id else { continue };
if item.button(cx, ids!(run_button)).clicked(actions) {
self.ensure_engine().confirm(&call_id, true, now);
self.view.redraw(cx);
} else if item.button(cx, ids!(deny_button)).clicked(actions) {
self.ensure_engine().confirm(&call_id, false, now);
self.view.redraw(cx);
} else if item.view(cx, ids!(tool_head)).finger_up(actions).is_some() {
self.ensure_engine().toggle_tool(&call_id);
self.view.redraw(cx);
}
}
}
let _ = scope;
}
}

101
apps/aichat/src/settings.rs Normal file
View file

@ -0,0 +1,101 @@
//! What the person chose: which model answers, and whether cloud models
//! are locked out. Two lines in `~/.makepad/aichat/settings`, the lock a
//! promise kept in code (a cloud choice under the lock normalises to
//! local at load and is refused at set), never a menu filter.
use makepad_ai_services::state::ProviderChoice;
use std::path::PathBuf;
#[derive(Clone, Debug, PartialEq)]
pub struct AiSettings {
pub provider: ProviderChoice,
/// Default ON: the person must switch it off before any cloud model
/// can be picked.
pub local_only: bool,
}
impl Default for AiSettings {
fn default() -> Self {
AiSettings { provider: ProviderChoice::Local, local_only: true }
}
}
impl AiSettings {
/// The lock, applied: a cloud provider under the lock becomes local.
/// "none" (no model) is always allowed — it reaches nothing.
pub fn normalized(mut self) -> Self {
if self.local_only {
if let ProviderChoice::Cloud(slug) = &self.provider {
if slug != "none" {
self.provider = ProviderChoice::Local;
}
}
}
self
}
/// Can this choice be made under the current lock?
pub fn allows(&self, choice: &ProviderChoice) -> Result<(), String> {
match choice {
ProviderChoice::Cloud(slug) if self.local_only && slug != "none" => Err("Local AI only is on".into()),
_ => Ok(()),
}
}
pub fn parse(text: &str) -> AiSettings {
let mut s = AiSettings::default();
for line in text.lines() {
let Some((k, v)) = line.split_once('=') else { continue };
match k.trim() {
"provider" => s.provider = ProviderChoice::from_slug(v.trim()),
"local_only" => s.local_only = v.trim() != "false",
_ => {}
}
}
s.normalized()
}
pub fn render(&self) -> String {
format!("provider={}\nlocal_only={}\n", self.provider.slug(), self.local_only)
}
pub fn path() -> Option<PathBuf> {
if cfg!(target_arch = "wasm32") {
return None;
}
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(PathBuf::from(home).join(".makepad").join("aichat").join("settings"))
}
pub fn load() -> AiSettings {
match Self::path().and_then(|p| std::fs::read_to_string(p).ok()) {
Some(text) => Self::parse(&text),
None => AiSettings::default(),
}
}
pub fn save(&self) -> Result<(), String> {
let Some(path) = Self::path() else { return Ok(()) };
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::write(&path, self.render()).map_err(|e| e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_lock_is_kept_at_load_and_at_set() {
let s = AiSettings::parse("provider=claude-api\nlocal_only=true\n");
assert_eq!(s.provider, ProviderChoice::Local, "a cloud pref under the lock cannot resurrect a cloud model");
assert!(s.allows(&ProviderChoice::Cloud("claude-api".into())).is_err());
assert!(s.allows(&ProviderChoice::Cloud("none".into())).is_ok());
let open = AiSettings::parse("provider=claude-api\nlocal_only=false\n");
assert_eq!(open.provider, ProviderChoice::Cloud("claude-api".into()));
assert_eq!(AiSettings::parse(&open.render()), open);
assert_eq!(AiSettings::parse("garbage"), AiSettings::default());
}
}

View file

@ -19,6 +19,7 @@ makepad-render = { path = "../../libs/render" }
makepad-gltf = { path = "../../libs/gltf" }
# Splat viewer: ViewSplat + XrSceneView desktop host.
makepad-xr = { path = "../../xr" }
makepad-media-view = { path = "../../libs/media_view" }
# REAL Asset Server frontend: shared session lifecycle (discovery/auth/
# retry), catalog runtimes, committed-event subscriber, verified cache. The
# VJ worker owns these crates; this app consumes the public API only.

View file

@ -44,7 +44,7 @@ use makepad_audio_lyrics::bake::LyricsBaker;
use makepad_audio_lyrics::TrackLyrics;
use makepad_audio_sidechannels::{encode_stem_oggs, publish_side_channels};
use makepad_widgets::log;
use makepad_widgets::makepad_platform::thread::SignalToUI;
use makepad_widgets::makepad_platform::thread::{SignalToUI, ThreadOptions, ThreadSpawner};
use std::collections::HashSet;
use std::path::PathBuf;
use std::str::FromStr;
@ -545,14 +545,8 @@ pub struct AnalysisQueue {
fetch_generation: u64,
}
impl Default for AnalysisQueue {
fn default() -> Self {
AnalysisQueue::start()
}
}
impl AnalysisQueue {
pub fn start() -> AnalysisQueue {
pub fn start(spawner: ThreadSpawner) -> AnalysisQueue {
let (bake_tx, bake_requests) = channel::<BakeRequest>();
let (bake_done, bake_rx) = channel::<BakeMsg>();
let (fetch_tx, fetch_requests) = channel::<FetchRequest>();
@ -561,12 +555,26 @@ impl AnalysisQueue {
let worker_batch = Arc::clone(&batch);
// A failed spawn is not fatal: the queue simply never runs, and
// every enqueue reports it instead of pretending to work.
let _ = std::thread::Builder::new()
.name("asset-ui-stem-bake".into())
.spawn(move || bake_loop(bake_requests, bake_done, worker_batch));
let _ = std::thread::Builder::new()
.name("asset-ui-stem-fetch".into())
.spawn(move || fetch_loop(fetch_requests, fetch_done));
match spawner.spawn_worker(
ThreadOptions {
name: Some("asset-ui-stem-bake".into()),
..Default::default()
},
move || bake_loop(bake_requests, bake_done, worker_batch),
) {
Ok(handle) => handle.detach(),
Err(error) => log!("analysis bake worker unavailable: {error}"),
}
match spawner.spawn_worker(
ThreadOptions {
name: Some("asset-ui-stem-fetch".into()),
..Default::default()
},
move || fetch_loop(fetch_requests, fetch_done),
) {
Ok(handle) => handle.detach(),
Err(error) => log!("analysis fetch worker unavailable: {error}"),
}
AnalysisQueue {
bake_tx,
bake_rx,
@ -1473,7 +1481,8 @@ mod tests {
#[test]
fn the_queue_counts_a_batch_and_keeps_its_verdict() {
let mut queue = AnalysisQueue::start();
let cx = makepad_widgets::Cx::new(Box::new(|_, _| {}));
let mut queue = AnalysisQueue::start(cx.thread_spawner());
assert!(!queue.busy());
assert_eq!(queue.status_line(), "");
assert_eq!(queue.progress_fraction(), 0.0);

View file

@ -13,12 +13,11 @@
//! - gallery preview decodes run on a small worker pool (2..=8 threads)
//! pulling a last-in-first-out stack, capped so old off-screen work drops.
use makepad_widgets::makepad_platform::thread::SignalToUI;
use makepad_widgets::makepad_platform::thread::{SignalToUI, ThreadOptions, ThreadSpawner};
use makepad_widgets::{decode_image_from_data, ImageBuffer};
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::mpsc::{channel, Receiver, Sender};
use std::sync::{Arc, Condvar, Mutex};
/// What one background read is for.
pub enum IoPurpose {
@ -164,35 +163,39 @@ pub enum IoDone {
pub struct ArtifactIo {
tx: Sender<IoRequest>,
rx: Receiver<IoDone>,
gallery: Arc<GalleryStack>,
}
impl ArtifactIo {
pub fn start() -> Self {
pub fn start(spawner: ThreadSpawner) -> Self {
let (request_tx, request_rx) = channel::<IoRequest>();
let (done_tx, done_rx) = channel::<IoDone>();
let gallery = Arc::new(GalleryStack::new());
std::thread::Builder::new()
.name("asset-ui-artifact-io".into())
.spawn({
let done_tx = done_tx.clone();
let gallery = Arc::clone(&gallery);
move || dispatch_loop(request_rx, done_tx, gallery)
})
.expect("artifact io dispatcher");
let n = gallery_worker_count();
for i in 0..n {
let done_tx = done_tx.clone();
let gallery = Arc::clone(&gallery);
std::thread::Builder::new()
.name(format!("asset-ui-preview-{i}"))
.spawn(move || gallery_loop(gallery, done_tx))
.expect("gallery decode worker");
}
let (gallery_tx, gallery_rx) = channel::<IoRequest>();
spawner
.spawn_worker(
ThreadOptions {
name: Some("asset-ui-artifact-io".into()),
..Default::default()
},
{
let done_tx = done_tx.clone();
move || dispatch_loop(request_rx, done_tx, gallery_tx)
},
)
.expect("artifact io dispatcher")
.detach();
spawner
.spawn_worker(
ThreadOptions {
name: Some("asset-ui-preview".into()),
..Default::default()
},
move || gallery_loop(gallery_rx, done_tx),
)
.expect("gallery decode worker")
.detach();
Self {
tx: request_tx,
rx: done_rx,
gallery,
}
}
@ -206,12 +209,6 @@ impl ArtifactIo {
}
}
impl Drop for ArtifactIo {
fn drop(&mut self) {
self.gallery.shutdown();
}
}
fn is_gallery(purpose: &IoPurpose) -> bool {
matches!(
purpose,
@ -221,13 +218,13 @@ fn is_gallery(purpose: &IoPurpose) -> bool {
)
}
fn dispatch_loop(rx: Receiver<IoRequest>, tx: Sender<IoDone>, gallery: Arc<GalleryStack>) {
fn dispatch_loop(rx: Receiver<IoRequest>, tx: Sender<IoDone>, gallery: Sender<IoRequest>) {
// One connected client per session, reused across opens: against the
// local server a fresh connect costs more than the payload.
let mut store: Option<(crate::import::ServerSession, makepad_asset_client::AssetClient)> = None;
while let Ok(request) = rx.recv() {
if is_gallery(&request.purpose) {
gallery.push_latest(request);
let _ = gallery.send(request);
continue;
}
let done = process_with_store(request, &mut store);
@ -236,18 +233,27 @@ fn dispatch_loop(rx: Receiver<IoRequest>, tx: Sender<IoDone>, gallery: Arc<Galle
}
SignalToUI::set_ui_signal();
}
gallery.shutdown();
}
fn gallery_loop(gallery: Arc<GalleryStack>, tx: Sender<IoDone>) {
while let Some(request) = gallery.pop_latest() {
let file = request.file.clone();
let done = process(request);
gallery.finish(&file);
if tx.send(done).is_err() {
return;
fn gallery_loop(rx: Receiver<IoRequest>, tx: Sender<IoDone>) {
let mut gallery = GalleryStack::new();
while let Ok(request) = rx.recv() {
gallery.push_latest(request);
for request in rx.try_iter() {
gallery.push_latest(request);
}
while let Some(request) = gallery.pop_latest() {
let file = request.file.clone();
let done = process(request);
gallery.finish(&file);
if tx.send(done).is_err() {
return;
}
SignalToUI::set_ui_signal();
for request in rx.try_iter() {
gallery.push_latest(request);
}
}
SignalToUI::set_ui_signal();
}
}
@ -264,24 +270,22 @@ struct GalleryInner {
}
struct GalleryStack {
inner: Mutex<GalleryInner>,
cv: Condvar,
inner: GalleryInner,
}
impl GalleryStack {
fn new() -> Self {
Self {
inner: Mutex::new(GalleryInner {
inner: GalleryInner {
stack: Vec::new(),
decoding: HashSet::new(),
shutdown: false,
}),
cv: Condvar::new(),
},
}
}
fn push_latest(&self, request: IoRequest) {
let mut g = self.inner.lock().expect("gallery stack");
fn push_latest(&mut self, request: IoRequest) {
let g = &mut self.inner;
if g.shutdown {
return;
}
@ -294,43 +298,30 @@ impl GalleryStack {
let drop_n = g.stack.len() - GALLERY_STACK_CAP;
g.stack.drain(0..drop_n);
}
self.cv.notify_one();
}
fn pop_latest(&self) -> Option<IoRequest> {
let mut g = self.inner.lock().expect("gallery stack");
loop {
if g.shutdown {
return None;
}
while let Some(request) = g.stack.pop() {
if g.decoding.insert(request.file.clone()) {
return Some(request);
}
}
g = self.cv.wait(g).expect("gallery stack");
fn pop_latest(&mut self) -> Option<IoRequest> {
let g = &mut self.inner;
if g.shutdown {
return None;
}
while let Some(request) = g.stack.pop() {
if g.decoding.insert(request.file.clone()) {
return Some(request);
}
}
None
}
fn finish(&self, file: &str) {
let mut g = self.inner.lock().expect("gallery stack");
g.decoding.remove(file);
fn finish(&mut self, file: &str) {
self.inner.decoding.remove(file);
}
fn shutdown(&self) {
let mut g = self.inner.lock().expect("gallery stack");
g.shutdown = true;
self.cv.notify_all();
fn shutdown(&mut self) {
self.inner.shutdown = true;
}
}
fn gallery_worker_count() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
.clamp(2, 8)
}
/// A read whose bytes may come from the store. Everything past the fetch is
/// the same decode the local-file path uses — one viewer, two sources.
fn process_with_store(
@ -865,7 +856,8 @@ mod tests {
std::fs::write(&payload, b"mp4-bytes").unwrap();
let copy = dir.join("viewer-open.mp4");
let io = ArtifactIo::start();
let cx = makepad_widgets::Cx::new(Box::new(|_, _| {}));
let io = ArtifactIo::start(cx.thread_spawner());
io.request(IoRequest {
file: "clip.mp4".into(),
path: payload.clone(),
@ -1143,7 +1135,7 @@ mod tests {
#[test]
fn gallery_stack_is_last_requested_first_and_rebumps() {
let stack = GalleryStack::new();
let mut stack = GalleryStack::new();
let mk = |file: &str| IoRequest {
file: file.into(),
path: PathBuf::from(file),
@ -1168,7 +1160,7 @@ mod tests {
#[test]
fn gallery_stack_drops_oldest_when_capped() {
let stack = GalleryStack::new();
let mut stack = GalleryStack::new();
for i in 0..(GALLERY_STACK_CAP + 10) {
stack.push_latest(IoRequest {
file: format!("f{i}"),

View file

@ -56,6 +56,9 @@ use makepad_asset_client::{
use makepad_asset_data::{AssetId, AssetRevisionId};
pub use makepad_asset_data::AssetKind;
use makepad_widgets::log;
use makepad_widgets::makepad_platform::thread::{
Lane, TaskHandle, TaskPool, ThreadOptions, ThreadSpawner,
};
use std::collections::VecDeque;
use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
@ -360,6 +363,8 @@ pub struct SearchResults {
/// happens through `start`/`poll`/`submit_search`/`select` only.
#[derive(Default)]
pub struct AssetStore {
pool: Option<TaskPool>,
spawner: Option<ThreadSpawner>,
/// Continuous ai-content-library → catalog publisher. Declared BEFORE
/// `embedded` so it is joined while the server it publishes into is
/// still alive.
@ -397,7 +402,7 @@ pub struct AssetStore {
/// boxes directly (the store advertises nothing any more — generation
/// is client-driven, aicore §9).
pub profiles: Remote<Vec<JobProfileDto>>,
profiles_rx: Option<std::sync::mpsc::Receiver<Vec<JobProfileDto>>>,
profiles_task: Option<TaskHandle<Vec<JobProfileDto>>>,
/// Committed catalog events, newest first, capped.
pub events: VecDeque<CatalogEventDto>,
/// The event feed delivered its initial cursor and is following commits.
@ -480,7 +485,7 @@ pub struct AssetStore {
succession_note: Option<String>,
/// The previous session is being torn down off-thread. Flips when its
/// cache roots are free for the next session to open.
releasing: Option<Arc<AtomicBool>>,
releasing: Option<TaskHandle<()>>,
/// What to connect to once `releasing` flips.
pending_session: Option<PendingSession>,
}
@ -498,11 +503,13 @@ impl AssetStore {
/// the client finds it through the same discovery/health path any LAN
/// peer would. Set the env var to skip embed and talk to a standalone
/// server instead.
pub fn start(&mut self, library_dir: PathBuf) {
pub fn start(&mut self, library_dir: PathBuf, pool: TaskPool, spawner: ThreadSpawner) {
if self.connector.is_some() || self.server.is_some() {
return;
}
self.library_dir = library_dir;
self.pool = Some(pool);
self.spawner = Some(spawner);
self.server_root = default_asset_server_root();
self.beacon = beacon_from_env();
self.embed = embed_policy_from_env();
@ -642,7 +649,7 @@ impl AssetStore {
label: handles.server_label.clone(),
server_id: handles.server_id,
});
self.endpoints = Some(handles.endpoints);
self.endpoints = handles.endpoints;
self.token = handles.token.clone();
self.handles = Some(*handles);
self.connector = None;
@ -655,17 +662,15 @@ impl AssetStore {
}
}
// Fleet-built generation profiles landing from their worker thread.
if let Some(rx) = &self.profiles_rx {
match rx.try_recv() {
if let Some(result) = self.profiles_task.as_mut().and_then(TaskHandle::try_take) {
self.profiles_task = None;
match result {
Ok(profiles) => {
self.profiles_rx = None;
self.profiles = Remote::Ready(profiles);
changed = true;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.profiles_rx = None;
self.profiles = Remote::Failed("fleet probe thread died".to_string());
Err(error) => {
self.profiles = Remote::Failed(format!("fleet profile job failed: {error}"));
changed = true;
}
}
@ -806,13 +811,23 @@ impl AssetStore {
/// Start the embedded server's background loops and become the host.
fn begin_hosting(&mut self, server: makepad_asset_store::AssetServer, token: &str) {
if self.host_loops == HostLoops::Run {
self.publish =
start_publish_loop(&server, token, self.library_dir.clone());
let Some(spawner) = self.spawner.as_ref() else {
log!("asset store: host workers unavailable (thread runtime not configured)");
self.role = ServerRole::Host;
self.embedded = Some(server);
return;
};
self.publish = start_publish_loop(
spawner,
&server,
token,
self.library_dir.clone(),
);
// LIVECODING: observed origin directories, catalogued in place.
// Only the HOST runs this — reference admission is loopback
// privilege, so an attached client never observes for somebody
// else's store.
self.observe = start_observe_loop(&server, token);
self.observe = start_observe_loop(spawner, &server, token);
}
self.role = ServerRole::Host;
self.embedded = Some(server);
@ -856,39 +871,39 @@ impl AssetStore {
self.search_continuation = false;
self.next_cursor = None;
self.detail_req = None;
self.profiles_rx = None;
self.profiles_task = None;
self.probe_req = None;
self.gc_req = None;
self.gc_cancel_req = None;
self.retire_reqs.clear();
let released = Arc::new(AtomicBool::new(false));
self.releasing = Some(released.clone());
let Some(handles) = self.handles.take() else {
released.store(true, Ordering::Release);
let Some(pool) = &self.pool else {
log!("asset store: session release refused (runtime pool unavailable)");
return;
};
let done = released.clone();
let spawned = std::thread::Builder::new()
.name("asset-ui-session-release".to_string())
.spawn(move || {
handles.shutdown();
done.store(true, Ordering::Release);
});
if let Err(error) = spawned {
// The closure (and the session inside it) was dropped, which
// already joined the runtimes right here. Nothing is left to
// wait for, so never leave the swap parked on a thread that
// does not exist.
log!("asset store: session release thread refused ({error}); released inline");
released.store(true, Ordering::Release);
}
let slot = match pool.reserve(Lane::Heavy) {
Ok(slot) => slot,
Err(error) => {
log!("asset store: session release delayed ({error})");
return;
}
};
let Some(handles) = self.handles.take() else {
self.releasing = None;
return;
};
self.releasing = Some(slot.submit(move || handles.shutdown()));
}
/// Open the decided session once the previous one has let go.
fn finish_swap(&mut self) -> bool {
if let Some(released) = &self.releasing {
if !released.load(Ordering::Acquire) {
return false;
if self.releasing.is_none() && self.handles.is_some() {
self.begin_release();
return false;
}
if let Some(releasing) = &mut self.releasing {
let Some(result) = releasing.try_take() else { return false };
if let Err(error) = result {
log!("asset store: session release failed: {error}");
}
}
self.releasing = None;
@ -1150,18 +1165,20 @@ impl AssetStore {
/// they can execute right now. Runs on its own thread — LAN probes must
/// never stall a frame — and lands through `profiles_rx` in [`Self::poll`].
fn submit_profiles(&mut self) {
let (tx, rx) = std::sync::mpsc::channel();
self.profiles_rx = Some(rx);
self.profiles = Remote::Loading;
let _ = std::thread::Builder::new()
.name("asset-ui-profiles".to_string())
.spawn(move || {
let Some(pool) = &self.pool else {
self.profiles = Remote::Failed("runtime task pool unavailable".into());
return;
};
match pool.submit(Lane::Light, move || {
let snapshots = makepad_asset_creator::runner::fleet_snapshots();
let profiles = makepad_asset_importer::gen_profiles::build_profiles(
&snapshots, "gen",
);
let _ = tx.send(profiles);
});
makepad_asset_importer::gen_profiles::build_profiles(&snapshots, "gen")
}) {
Ok(handle) => self.profiles_task = Some(handle),
Err(error) => {
self.profiles = Remote::Failed(format!("fleet profile job refused: {error}"));
}
}
}
fn on_catalog_event(&mut self, event: ClientEvent) -> bool {
@ -1673,21 +1690,17 @@ fn start_embedded_asset_server_at(
Ok((server, token))
}
/// Stop flag for the single in-process publish loop. A `static` (not an
/// `Arc`) because `watch::run` borrows it for the thread's whole life and
/// there is at most one loop per process.
static PUBLISH_STOP: AtomicBool = AtomicBool::new(false);
/// Owns the publisher thread; dropping the store stops and joins it.
struct PublishLoop {
join: Option<std::thread::JoinHandle<()>>,
stop: Arc<AtomicBool>,
task: Option<TaskHandle<()>>,
}
impl Drop for PublishLoop {
fn drop(&mut self) {
PUBLISH_STOP.store(true, Ordering::Release);
if let Some(join) = self.join.take() {
let _ = join.join();
self.stop.store(true, Ordering::Release);
if let Some(task) = self.task.take() {
task.detach();
}
}
}
@ -1697,6 +1710,7 @@ impl Drop for PublishLoop {
/// slow or refused connection can never stall the UI, and every failure is
/// a log line — never a panic.
fn start_publish_loop(
spawner: &ThreadSpawner,
server: &makepad_asset_store::AssetServer,
token: &str,
library_dir: PathBuf,
@ -1705,10 +1719,14 @@ fn start_publish_loop(
let server_id = server.server_id();
let token = token.to_string();
let cache = asset_ui_home().join("publish-cache");
PUBLISH_STOP.store(false, Ordering::Release);
let join = std::thread::Builder::new()
.name("asset-ui-publish".to_string())
.spawn(move || {
let stop = Arc::new(AtomicBool::new(false));
let worker_stop = stop.clone();
let task = spawner.spawn_worker(
ThreadOptions {
name: Some("asset-ui-publish".into()),
..Default::default()
},
move || {
let mut config = makepad_asset_client::ClientConfig::new(cache);
config.token = Some(token);
let mut client = match makepad_asset_client::AssetClient::connect(
@ -1736,12 +1754,13 @@ fn start_publish_loop(
// Log publications, failures and retries; out-of-scope rows
// (the pack-import bulk) stay silent by design.
true,
&PUBLISH_STOP,
&worker_stop,
);
log!("publish loop: stopped");
});
match join {
Ok(join) => Some(PublishLoop { join: Some(join) }),
},
);
match task {
Ok(task) => Some(PublishLoop { stop, task: Some(task) }),
Err(error) => {
log!("publish loop: could not spawn: {error}");
None
@ -1749,19 +1768,17 @@ fn start_publish_loop(
}
}
/// Stop flag for the single in-process observe loop.
static OBSERVE_STOP: AtomicBool = AtomicBool::new(false);
/// Owns the observer thread; dropping the store stops and joins it.
struct ObserveLoop {
join: Option<std::thread::JoinHandle<()>>,
stop: Arc<AtomicBool>,
task: Option<TaskHandle<()>>,
}
impl Drop for ObserveLoop {
fn drop(&mut self) {
OBSERVE_STOP.store(true, Ordering::Release);
if let Some(join) = self.join.take() {
let _ = join.join();
self.stop.store(true, Ordering::Release);
if let Some(task) = self.task.take() {
task.detach();
}
}
}
@ -1789,6 +1806,7 @@ fn observe_origins() -> Vec<PathBuf> {
/// Observe effect-document origins into the server this process hosts.
/// Connect + watch happen on the thread; every failure is a log line.
fn start_observe_loop(
spawner: &ThreadSpawner,
server: &makepad_asset_store::AssetServer,
token: &str,
) -> Option<ObserveLoop> {
@ -1797,10 +1815,14 @@ fn start_observe_loop(
let token = token.to_string();
let cache = asset_ui_home().join("observe-cache");
let origins = observe_origins();
OBSERVE_STOP.store(false, Ordering::Release);
let join = std::thread::Builder::new()
.name("asset-ui-observe".to_string())
.spawn(move || {
let stop = Arc::new(AtomicBool::new(false));
let worker_stop = stop.clone();
let task = spawner.spawn_worker(
ThreadOptions {
name: Some("asset-ui-observe".into()),
..Default::default()
},
move || {
let mut config = makepad_asset_client::ClientConfig::new(cache);
config.token = Some(token);
let mut client = match makepad_asset_client::AssetClient::connect(
@ -1817,12 +1839,13 @@ fn start_observe_loop(
makepad_asset_store::observe::run(
&mut client,
&makepad_asset_store::observe::ObserveConfig::vjfx(origins),
&OBSERVE_STOP,
&worker_stop,
);
log!("observe loop: stopped");
});
match join {
Ok(join) => Some(ObserveLoop { join: Some(join) }),
},
);
match task {
Ok(task) => Some(ObserveLoop { stop, task: Some(task) }),
Err(error) => {
log!("observe loop: could not spawn: {error}");
None
@ -2078,6 +2101,13 @@ mod tests {
namespace: "game".into(),
kind: None,
title: title.into(),
creator: String::new(),
artist: String::new(),
artist_url: String::new(),
album: String::new(),
source_url: String::new(),
license: String::new(),
license_url: String::new(),
snippet: String::new(),
score: 0,
live: true,
@ -2368,6 +2398,14 @@ mod tests {
(server, token)
}
fn ensure_test_runtime(store: &mut AssetStore) {
if store.pool.is_none() {
let cx = makepad_widgets::Cx::new(Box::new(|_, _| {}));
store.pool = Some(cx.task_pool());
store.spawner = Some(cx.thread_spawner());
}
}
/// Publish one minimal real asset directly (the synchronous
/// `AssetClient`, not through `AssetStore` — publishing is the import
/// pipeline's job, already covered elsewhere; this test starts from
@ -2412,6 +2450,7 @@ mod tests {
/// `AssetStore::poll()` loop (no discovery — explicit endpoints), and
/// wait for the initial auto-search `poll()` fires on connect to land.
fn connect_store_to(store: &mut AssetStore, server: &makepad_asset_store::AssetServer, token: &str) {
ensure_test_runtime(store);
let config = SessionConfig {
endpoints: Some(ApiEndpoints { control: server.control_addr(), data: server.data_addr() }),
server_id: Some(server.server_id()),
@ -2448,6 +2487,7 @@ mod tests {
secs: u64,
ready: F,
) {
ensure_test_runtime(store);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(secs);
loop {
store.poll();

View file

@ -1,15 +1,16 @@
//! Artifact playback + waveform strip.
//!
//! Same shape as the sandbox's `VideoAudio` mixer (apps/sandbox/src/
//! video_player.rs): a process-global resampling stereo queue mixed
//! additively from the `cx.audio_output` callback, so playback needs no
//! plumbing through the widget tree. The service emits PCM16 WAV (kokoro:
//! mono 24kHz, sa3-sfx: stereo 44.1kHz), decoded here with a minimal RIFF
//! parser (libs/asset/ai wav.rs is encode-only).
//! The UI owns a transport handle and the device callback owns the playback
//! engine. Commands and atomic snapshots cross between them; neither side
//! waits on a mutex. The service emits PCM16 WAV (kokoro: mono 24kHz,
//! sa3-sfx: stereo 44.1kHz), decoded here with a minimal RIFF parser
//! (libs/asset/ai wav.rs is encode-only).
use makepad_widgets::makepad_platform::audio::AudioBuffer;
use makepad_widgets::makepad_platform::thread::{Lane, TaskHandle, TaskPool};
use std::cell::RefCell;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, LazyLock, Mutex};
use std::sync::{mpsc, Arc};
#[derive(Clone)]
pub struct WavPcm {
@ -103,24 +104,22 @@ pub fn parse_wav(bytes: &[u8]) -> Result<WavPcm, String> {
/// advances it; UI code only loads, pauses and seeks.
const FP_ONE: u64 = 1 << 32;
struct WavMixer {
clip: Mutex<Option<Arc<WavPcm>>>,
struct AudioSnapshot {
cursor_fp: AtomicU64,
playing: AtomicBool,
ack: AtomicU64,
}
impl Default for WavMixer {
impl Default for AudioSnapshot {
fn default() -> Self {
Self {
clip: Mutex::new(None),
cursor_fp: AtomicU64::new(0),
playing: AtomicBool::new(false),
ack: AtomicU64::new(0),
}
}
}
static WAV_MIXER: LazyLock<WavMixer> = LazyLock::new(WavMixer::default);
// ---------------------------------------------------------------------------
// Separated layers ("split audio layers")
// ---------------------------------------------------------------------------
@ -150,33 +149,301 @@ pub const STEM_LANES: usize = 4;
/// files are published and fetched in, so no index ever has to be remapped.
pub const STEM_LANE_NAMES: [&str; STEM_LANES] = ["drums", "bass", "vocals", "other"];
struct StemMixer {
lanes: Mutex<Option<Arc<[StemPcm; STEM_LANES]>>>,
/// Per-lane mute. Read by the audio callback, written by the UI.
mute: [AtomicBool; STEM_LANES],
/// Whether the layers are what the transport plays. Kept separate from
/// the lock so the callback can tell "no stems" from "stems, but the UI
/// holds the lock this quantum" — the second must be silence, not a
/// blip of the mixed track.
active: AtomicBool,
/// The clip generation these layers belong to. The mixed audio and the
/// four stems arrive on two different workers in either order; this is
/// what lets the second one to land know it is the same track.
generation: AtomicU64,
enum AudioCommand {
InstallClip {
serial: u64,
clip: Arc<WavPcm>,
},
ClearClip {
serial: u64,
},
InstallStems {
serial: u64,
lanes: Arc<[StemPcm; STEM_LANES]>,
},
ClearStems {
serial: u64,
},
Play {
serial: u64,
},
Pause {
serial: u64,
},
Stop {
serial: u64,
},
Seek {
serial: u64,
cursor_fp: u64,
},
MuteLane {
serial: u64,
lane: usize,
muted: bool,
},
}
impl Default for StemMixer {
fn default() -> Self {
enum RetiredAudio {
Clip(Arc<WavPcm>),
Stems(Arc<[StemPcm; STEM_LANES]>),
}
struct PendingDecode {
generation: u64,
task: TaskHandle<Result<WavPcm, String>>,
}
/// UI-thread transport handle. It owns the requested state and communicates
/// with the realtime callback exclusively through commands and atomics.
struct AudioMixer {
commands: mpsc::Sender<AudioCommand>,
retired: mpsc::Receiver<RetiredAudio>,
snapshot: Arc<AudioSnapshot>,
engine: Option<AudioEngine>,
clip: Option<Arc<WavPcm>>,
stems: Option<Arc<[StemPcm; STEM_LANES]>>,
stem_generation: u64,
muted: [bool; STEM_LANES],
cursor_fp: u64,
playing: bool,
serial: u64,
load_generation: u64,
pending_decodes: Vec<PendingDecode>,
}
/// Realtime-owned state. Once installed in `cx.audio_output`, only the audio
/// callback touches these payloads and cursors.
pub struct AudioEngine {
commands: mpsc::Receiver<AudioCommand>,
retired: mpsc::Sender<RetiredAudio>,
snapshot: Arc<AudioSnapshot>,
clip: Option<Arc<WavPcm>>,
stems: Option<Arc<[StemPcm; STEM_LANES]>>,
muted: [bool; STEM_LANES],
cursor_fp: u64,
playing: bool,
ack: u64,
}
impl AudioMixer {
fn new() -> Self {
let (command_tx, command_rx) = mpsc::channel();
let (retired_tx, retired_rx) = mpsc::channel();
let snapshot = Arc::new(AudioSnapshot::default());
let engine = AudioEngine {
commands: command_rx,
retired: retired_tx,
snapshot: snapshot.clone(),
clip: None,
stems: None,
muted: [false; STEM_LANES],
cursor_fp: 0,
playing: false,
ack: 0,
};
Self {
lanes: Mutex::new(None),
mute: Default::default(),
active: AtomicBool::new(false),
generation: AtomicU64::new(u64::MAX),
commands: command_tx,
retired: retired_rx,
snapshot,
engine: Some(engine),
clip: None,
stems: None,
stem_generation: u64::MAX,
muted: [false; STEM_LANES],
cursor_fp: 0,
playing: false,
serial: 0,
load_generation: 0,
pending_decodes: Vec::new(),
}
}
fn take_engine(&mut self) -> Option<AudioEngine> {
self.engine.take()
}
fn next_serial(&mut self) -> u64 {
self.serial = self.serial.wrapping_add(1).max(1);
self.serial
}
fn send(&self, command: AudioCommand) {
let _ = self.commands.send(command);
}
fn refresh_snapshot(&mut self) {
let ack = self.snapshot.ack.load(Ordering::Acquire);
if ack >= self.serial {
self.cursor_fp = self.snapshot.cursor_fp.load(Ordering::Acquire);
self.playing = self.snapshot.playing.load(Ordering::Acquire);
}
}
fn pump(&mut self) {
self.refresh_snapshot();
for retired in self.retired.try_iter() {
match retired {
RetiredAudio::Clip(clip) => drop(clip),
RetiredAudio::Stems(stems) => drop(stems),
}
}
let pending_decodes = std::mem::take(&mut self.pending_decodes);
let mut waiting = Vec::with_capacity(pending_decodes.len());
for mut pending in pending_decodes {
let Some(result) = pending.task.try_take() else {
waiting.push(pending);
continue;
};
match result {
Ok(Ok(pcm)) if pending.generation == self.load_generation => {
self.install(pcm, pending.generation);
}
Ok(Err(error)) if pending.generation == self.load_generation => {
makepad_widgets::log!("audio decode failed: {error}");
}
Err(error) if pending.generation == self.load_generation => {
makepad_widgets::log!("audio decode task failed: {error}");
}
_ => {}
}
}
self.pending_decodes = waiting;
}
fn clear_stems(&mut self) {
self.stems = None;
self.stem_generation = u64::MAX;
self.muted = [false; STEM_LANES];
let serial = self.next_serial();
self.send(AudioCommand::ClearStems { serial });
}
fn clear(&mut self) {
self.clear_stems();
self.clip = None;
self.cursor_fp = 0;
self.playing = false;
let serial = self.next_serial();
self.send(AudioCommand::ClearClip { serial });
}
fn install(&mut self, pcm: WavPcm, generation: u64) -> bool {
if self.load_generation != generation {
return false;
}
if pcm.frames.is_empty() || pcm.sample_rate == 0 {
self.clear();
return false;
}
if self.stem_generation != generation {
self.clear_stems();
}
let clip = Arc::new(pcm);
self.clip = Some(clip.clone());
self.cursor_fp = 0;
self.playing = false;
let serial = self.next_serial();
self.send(AudioCommand::InstallClip { serial, clip });
true
}
fn load(&mut self, pcm: WavPcm) -> bool {
self.load_generation = self.load_generation.wrapping_add(1);
self.install(pcm, self.load_generation)
}
fn set_stems(&mut self, lanes: [StemPcm; STEM_LANES], generation: u64) -> bool {
if self.load_generation != generation {
return false;
}
if lanes
.iter()
.any(|lane| lane.frames.is_empty() || lane.sample_rate == 0)
{
self.clear_stems();
return false;
}
let lanes = Arc::new(lanes);
self.stems = Some(lanes.clone());
self.stem_generation = generation;
self.muted = [false; STEM_LANES];
let serial = self.next_serial();
self.send(AudioCommand::InstallStems { serial, lanes });
true
}
fn play(&mut self) {
let Some(clip) = self.clip.as_ref() else {
return;
};
let end = (clip.frames.len() as u64) << 32;
if self.cursor_fp >= end {
self.cursor_fp = 0;
}
self.playing = true;
let serial = self.next_serial();
self.send(AudioCommand::Play { serial });
}
fn pause(&mut self) {
self.playing = false;
let serial = self.next_serial();
self.send(AudioCommand::Pause { serial });
}
fn stop(&mut self) {
self.playing = false;
self.cursor_fp = 0;
let serial = self.next_serial();
self.send(AudioCommand::Stop { serial });
}
fn seek_fraction(&mut self, fraction: f64) {
let Some(clip) = self.clip.as_ref() else {
return;
};
let frame = (fraction.clamp(0.0, 1.0) * clip.frames.len() as f64) as u64;
self.cursor_fp = frame.min(clip.frames.len() as u64) << 32;
let serial = self.next_serial();
self.send(AudioCommand::Seek {
serial,
cursor_fp: self.cursor_fp,
});
}
fn set_lane_muted(&mut self, lane: usize, muted: bool) {
let Some(slot) = self.muted.get_mut(lane) else {
return;
};
*slot = muted;
let serial = self.next_serial();
self.send(AudioCommand::MuteLane {
serial,
lane,
muted,
});
}
}
static STEM_MIXER: LazyLock<StemMixer> = LazyLock::new(StemMixer::default);
thread_local! {
static AUDIO_MIXER: RefCell<AudioMixer> = RefCell::new(AudioMixer::new());
}
fn with_mixer<T>(f: impl FnOnce(&mut AudioMixer) -> T) -> T {
AUDIO_MIXER.with(|mixer| f(&mut mixer.borrow_mut()))
}
/// Move the engine into the app's one audio callback. It is intentionally
/// one-shot: there must never be a second owner of realtime state.
pub fn take_engine() -> AudioEngine {
with_mixer(|mixer| mixer.take_engine()).expect("audio engine is installed once")
}
/// Poll background decodes and reclaim callback-retired payloads on the UI.
pub fn pump() {
with_mixer(AudioMixer::pump);
}
/// Install four separated layers over the loaded clip. From here the
/// transport plays their SUM instead of the mixed track — which is also the
@ -190,63 +457,32 @@ static STEM_MIXER: LazyLock<StemMixer> = LazyLock::new(StemMixer::default);
/// Refused (and the layers cleared) when a lane is empty: half a stem set is
/// a lie about what the asset carries.
pub fn set_stems(lanes: [StemPcm; STEM_LANES], generation: u64) -> bool {
if LOAD_GENERATION.load(Ordering::Acquire) != generation {
return false;
}
if lanes
.iter()
.any(|lane| lane.frames.is_empty() || lane.sample_rate == 0)
{
clear_stems();
return false;
}
for mute in &STEM_MIXER.mute {
mute.store(false, Ordering::Release);
}
*STEM_MIXER.lanes.lock().unwrap() = Some(Arc::new(lanes));
STEM_MIXER.generation.store(generation, Ordering::Release);
STEM_MIXER.active.store(true, Ordering::Release);
true
with_mixer(|mixer| mixer.set_stems(lanes, generation))
}
/// Back to the mixed track. Called whenever the clip changes, so a new
/// selection can never play the previous track's layers.
pub fn clear_stems() {
STEM_MIXER.active.store(false, Ordering::Release);
STEM_MIXER.generation.store(u64::MAX, Ordering::Release);
*STEM_MIXER.lanes.lock().unwrap() = None;
for mute in &STEM_MIXER.mute {
mute.store(false, Ordering::Release);
}
with_mixer(AudioMixer::clear_stems);
}
/// True when the transport is playing separated layers.
pub fn stems_ready() -> bool {
STEM_MIXER.active.load(Ordering::Acquire)
with_mixer(|mixer| mixer.stems.is_some())
}
pub fn lane_muted(lane: usize) -> bool {
STEM_MIXER
.mute
.get(lane)
.is_some_and(|mute| mute.load(Ordering::Acquire))
with_mixer(|mixer| mixer.muted.get(lane).copied().unwrap_or(false))
}
pub fn set_lane_muted(lane: usize, muted: bool) {
if let Some(mute) = STEM_MIXER.mute.get(lane) {
mute.store(muted, Ordering::Release);
}
with_mixer(|mixer| mixer.set_lane_muted(lane, muted));
}
/// Length of the installed layers, for the honest "these are this track's
/// stems" check a host wants before it draws the toggles.
pub fn stems_seconds() -> f64 {
STEM_MIXER
.lanes
.lock()
.unwrap()
.as_ref()
.map_or(0.0, |lanes| lanes[0].seconds())
with_mixer(|mixer| mixer.stems.as_ref().map_or(0.0, |lanes| lanes[0].seconds()))
}
/// One lane at a fixed-point cursor, linearly interpolated — the same
@ -276,88 +512,44 @@ fn sample_lane(lane: &StemPcm, cursor: u64) -> (f32, f32) {
/// a new clip generation, which invalidates any separated layers installed
/// for the previous one.
pub fn load(pcm: WavPcm) -> bool {
let generation = LOAD_GENERATION.fetch_add(1, Ordering::AcqRel) + 1;
install(pcm, generation)
}
/// Install a decoded clip under the generation it was decoded FOR. A newer
/// pick having happened in the meantime drops it.
///
/// The layers are cleared unless they were installed for THIS clip: the
/// fetch of a track's stems and the decode of its mixed audio run on two
/// workers, and whichever finishes second must not wipe the first.
fn install(pcm: WavPcm, generation: u64) -> bool {
if LOAD_GENERATION.load(Ordering::Acquire) != generation {
return false;
}
if pcm.frames.is_empty() || pcm.sample_rate == 0 {
clear();
return false;
}
if STEM_MIXER.generation.load(Ordering::Acquire) != generation {
clear_stems();
}
WAV_MIXER.playing.store(false, Ordering::Release);
*WAV_MIXER.clip.lock().unwrap() = Some(Arc::new(pcm));
WAV_MIXER.cursor_fp.store(0, Ordering::Release);
true
with_mixer(|mixer| mixer.load(pcm))
}
/// Discard the loaded clip and make the transport unavailable.
pub fn clear() {
clear_stems();
WAV_MIXER.playing.store(false, Ordering::Release);
*WAV_MIXER.clip.lock().unwrap() = None;
WAV_MIXER.cursor_fp.store(0, Ordering::Release);
with_mixer(AudioMixer::clear);
}
/// Which load request is current. A user clicking down a list starts several;
/// only the newest may install itself, or a slow decode of the track before
/// last lands on top of the one they are looking at.
static LOAD_GENERATION: AtomicU64 = AtomicU64::new(0);
/// Take a track's bytes — WAV, MP3 or Ogg — and make the transport play them.
///
/// Decoding happens on a worker: the music library is MP3s, and turning six
/// minutes of one into PCM on the frame thread is a visible stall. The mixer
/// is process-global, so the worker installs the result itself; there is
/// nothing to plumb back through the widget tree.
/// Decoding happens in the runtime pool: the music library is MP3s, and
/// turning six minutes of one into PCM on the frame thread is a visible
/// stall. The UI polls the task and sends the accepted result to the engine.
///
/// The transport goes unavailable immediately, because the previous track is
/// no longer what the well is showing — a stale clip left loaded is a play
/// button that plays the wrong song.
/// Returns the clip generation this request claimed, which is what a
/// side-channel fetch for the same track carries back into [`set_stems`].
pub fn load_clip_async(bytes: Vec<u8>) -> u64 {
let generation = LOAD_GENERATION.fetch_add(1, Ordering::AcqRel) + 1;
clear();
let bytes = Arc::new(bytes);
let spawned = std::thread::Builder::new()
.name("asset-ui-audio-decode".into())
.spawn({
let bytes = Arc::clone(&bytes);
move || {
let Ok(pcm) = decode_clip(&bytes) else {
return;
};
// A newer pick happened while this was decoding: drop it.
install(pcm, generation);
}
});
if spawned.is_err() {
// No worker to be had: decode here rather than leave a dead
// transport under a drawn waveform.
if let Ok(pcm) = decode_clip(&bytes) {
install(pcm, generation);
pub fn load_clip_async(pool: &TaskPool, bytes: Vec<u8>) -> u64 {
with_mixer(|mixer| {
mixer.load_generation = mixer.load_generation.wrapping_add(1);
let generation = mixer.load_generation;
mixer.clear();
match pool.submit(Lane::Heavy, move || decode_clip(&bytes)) {
Ok(task) => mixer
.pending_decodes
.push(PendingDecode { generation, task }),
Err(error) => makepad_widgets::log!("audio decode job refused: {error}"),
}
}
generation
generation
})
}
/// The clip generation currently claimed. A side-channel fetch records it
/// with the request and hands it back to [`set_stems`].
pub fn clip_generation() -> u64 {
LOAD_GENERATION.load(Ordering::Acquire)
with_mixer(|mixer| mixer.load_generation)
}
/// Any container the catalog carries, in the mixer's shape. RIFF is parsed
@ -388,48 +580,45 @@ pub fn decode_clip(bytes: &[u8]) -> Result<WavPcm, String> {
/// Start or resume. Starting from the end restarts at zero.
pub fn play() {
let clip = WAV_MIXER.clip.lock().unwrap();
let Some(clip) = clip.as_ref() else { return };
let end = (clip.frames.len() as u64) << 32;
if WAV_MIXER.cursor_fp.load(Ordering::Acquire) >= end {
WAV_MIXER.cursor_fp.store(0, Ordering::Release);
}
WAV_MIXER.playing.store(true, Ordering::Release);
with_mixer(AudioMixer::play);
}
pub fn pause() {
WAV_MIXER.playing.store(false, Ordering::Release);
with_mixer(AudioMixer::pause);
}
/// Stop returns to the start but retains the decoded clip for replay.
pub fn stop() {
pause();
WAV_MIXER.cursor_fp.store(0, Ordering::Release);
with_mixer(AudioMixer::stop);
}
pub fn is_ready() -> bool {
WAV_MIXER.clip.lock().unwrap().is_some()
with_mixer(|mixer| mixer.clip.is_some())
}
pub fn is_playing() -> bool {
WAV_MIXER.playing.load(Ordering::Acquire) && !at_end()
with_mixer(|mixer| {
mixer.refresh_snapshot();
let end = mixer
.clip
.as_ref()
.map_or(0, |clip| (clip.frames.len() as u64) << 32);
mixer.playing && mixer.cursor_fp < end
})
}
pub fn duration_secs() -> f64 {
WAV_MIXER
.clip
.lock()
.unwrap()
.as_ref()
.map_or(0.0, |clip| clip.seconds())
with_mixer(|mixer| mixer.clip.as_ref().map_or(0.0, |clip| clip.seconds()))
}
/// Truthful device-clocked playhead, never derived from a UI timer.
pub fn playhead_secs() -> f64 {
let clip = WAV_MIXER.clip.lock().unwrap();
let Some(clip) = clip.as_ref() else { return 0.0 };
(WAV_MIXER.cursor_fp.load(Ordering::Acquire) as f64 / FP_ONE as f64)
/ clip.sample_rate as f64
with_mixer(|mixer| {
mixer.refresh_snapshot();
mixer.clip.as_ref().map_or(0.0, |clip| {
(mixer.cursor_fp as f64 / FP_ONE as f64) / clip.sample_rate as f64
})
})
}
/// Normalized playhead across the loaded clip for the waveform overlay:
@ -443,19 +632,18 @@ pub fn playhead_fraction() -> f64 {
}
pub fn at_end() -> bool {
let clip = WAV_MIXER.clip.lock().unwrap();
let Some(clip) = clip.as_ref() else { return false };
WAV_MIXER.cursor_fp.load(Ordering::Acquire) >= (clip.frames.len() as u64) << 32
with_mixer(|mixer| {
mixer.refresh_snapshot();
mixer
.clip
.as_ref()
.is_some_and(|clip| mixer.cursor_fp >= (clip.frames.len() as u64) << 32)
})
}
/// Sample-accurate fractional seek, clamped to the decoded clip.
pub fn seek_fraction(frac: f64) {
let clip = WAV_MIXER.clip.lock().unwrap();
let Some(clip) = clip.as_ref() else { return };
let frame = (frac.clamp(0.0, 1.0) * clip.frames.len() as f64) as u64;
WAV_MIXER
.cursor_fp
.store((frame.min(clip.frames.len() as u64)) << 32, Ordering::Release);
with_mixer(|mixer| mixer.seek_fraction(frac));
}
/// Long-form threshold for the audition policy below: at/under this a voice
@ -484,95 +672,170 @@ pub fn format_time(secs: f64) -> String {
format!("{minutes}:{:04.1}", secs - minutes as f64 * 60.0)
}
/// One additive source in the app's single `cx.audio_output` callback.
/// The callback never blocks on a UI load/seek: a contended quantum is silent.
pub fn mix_into(output: &mut AudioBuffer, device_rate: f64) {
if !WAV_MIXER.playing.load(Ordering::Acquire) || device_rate <= 0.0 {
return;
impl AudioEngine {
fn retire(&self, retired: RetiredAudio) {
let _ = self.retired.send(retired);
}
let Ok(clip) = WAV_MIXER.clip.try_lock() else { return };
let Some(clip) = clip.as_ref() else {
WAV_MIXER.playing.store(false, Ordering::Release);
return;
};
let end = (clip.frames.len() as u64) << 32;
let mut cursor = WAV_MIXER.cursor_fp.load(Ordering::Acquire);
if cursor >= end {
WAV_MIXER.playing.store(false, Ordering::Release);
return;
}
let step = ((clip.sample_rate as f64 / device_rate) * FP_ONE as f64) as u64;
if step == 0 {
return;
}
const GAIN: f32 = 0.9;
// Separated layers REPLACE the mixed track while they are installed.
// A contended lane lock is silence for this quantum, never a blip of
// the original — the same rule the clip lock above follows.
if STEM_MIXER.active.load(Ordering::Acquire) {
let Ok(guard) = STEM_MIXER.lanes.try_lock() else { return };
let Some(lanes) = guard.as_ref() else { return };
// ONE cursor for all four lanes — they cannot drift from each other
// — re-derived from the track cursor at every quantum, so they
// cannot drift from the timeline the waveform and the transport
// draw either. The layers are at the model's rate; the clip may not
// be, hence the second step.
let stem_rate = lanes[0].sample_rate.max(1) as f64;
let stem_step = ((stem_rate / device_rate) * FP_ONE as f64) as u64;
let secs = (cursor as f64 / FP_ONE as f64) / clip.sample_rate as f64;
let mut stem_cursor = (secs * stem_rate * FP_ONE as f64) as u64;
// The mute flags are read ONCE per quantum, not per sample: a
// toggle lands on the next buffer, which is inaudible, and the
// callback stays free of per-sample atomics.
let audible: [bool; STEM_LANES] =
std::array::from_fn(|lane| !STEM_MIXER.mute[lane].load(Ordering::Relaxed));
fn drain_commands(&mut self) {
while let Ok(command) = self.commands.try_recv() {
let serial = match command {
AudioCommand::InstallClip { serial, clip } => {
if let Some(old) = self.clip.replace(clip) {
self.retire(RetiredAudio::Clip(old));
}
self.cursor_fp = 0;
self.playing = false;
serial
}
AudioCommand::ClearClip { serial } => {
if let Some(old) = self.clip.take() {
self.retire(RetiredAudio::Clip(old));
}
self.cursor_fp = 0;
self.playing = false;
serial
}
AudioCommand::InstallStems { serial, lanes } => {
if let Some(old) = self.stems.replace(lanes) {
self.retire(RetiredAudio::Stems(old));
}
self.muted = [false; STEM_LANES];
serial
}
AudioCommand::ClearStems { serial } => {
if let Some(old) = self.stems.take() {
self.retire(RetiredAudio::Stems(old));
}
self.muted = [false; STEM_LANES];
serial
}
AudioCommand::Play { serial } => {
if let Some(clip) = self.clip.as_ref() {
let end = (clip.frames.len() as u64) << 32;
if self.cursor_fp >= end {
self.cursor_fp = 0;
}
self.playing = true;
}
serial
}
AudioCommand::Pause { serial } => {
self.playing = false;
serial
}
AudioCommand::Stop { serial } => {
self.playing = false;
self.cursor_fp = 0;
serial
}
AudioCommand::Seek { serial, cursor_fp } => {
self.cursor_fp = self.clip.as_ref().map_or(0, |clip| {
cursor_fp.min((clip.frames.len() as u64) << 32)
});
serial
}
AudioCommand::MuteLane {
serial,
lane,
muted,
} => {
if let Some(slot) = self.muted.get_mut(lane) {
*slot = muted;
}
serial
}
};
self.ack = serial;
}
}
fn publish(&self) {
self.snapshot.cursor_fp.store(self.cursor_fp, Ordering::Relaxed);
self.snapshot.playing.store(self.playing, Ordering::Relaxed);
self.snapshot.ack.store(self.ack, Ordering::Release);
}
/// Add this transport to the app's output. Commands are drained first;
/// no application lock or wait occurs on the realtime callback.
pub fn mix_into(&mut self, output: &mut AudioBuffer, device_rate: f64) {
self.drain_commands();
if !self.playing || device_rate <= 0.0 {
self.publish();
return;
}
let Some(clip) = self.clip.as_ref() else {
self.playing = false;
self.publish();
return;
};
let end = (clip.frames.len() as u64) << 32;
if self.cursor_fp >= end {
self.playing = false;
self.publish();
return;
}
let step = ((clip.sample_rate as f64 / device_rate) * FP_ONE as f64) as u64;
if step == 0 {
self.publish();
return;
}
const GAIN: f32 = 0.9;
if let Some(lanes) = self.stems.as_ref() {
let stem_rate = lanes[0].sample_rate.max(1) as f64;
let stem_step = ((stem_rate / device_rate) * FP_ONE as f64) as u64;
let secs = (self.cursor_fp as f64 / FP_ONE as f64) / clip.sample_rate as f64;
let mut stem_cursor = (secs * stem_rate * FP_ONE as f64) as u64;
for frame in 0..output.frame_count() {
if self.cursor_fp >= end {
self.playing = false;
self.cursor_fp = end;
break;
}
let (mut l, mut r) = (0.0f32, 0.0f32);
for (index, lane) in lanes.iter().enumerate() {
if self.muted[index] {
continue;
}
let (ll, rr) = sample_lane(lane, stem_cursor);
l += ll;
r += rr;
}
l *= GAIN;
r *= GAIN;
for channel in 0..output.channel_count() {
output.channel_mut(channel)[frame] += if channel == 0 { l } else { r };
}
self.cursor_fp = self.cursor_fp.saturating_add(step);
stem_cursor = stem_cursor.saturating_add(stem_step);
}
self.cursor_fp = self.cursor_fp.min(end);
self.publish();
return;
}
for frame in 0..output.frame_count() {
if cursor >= end {
WAV_MIXER.playing.store(false, Ordering::Release);
cursor = end;
if self.cursor_fp >= end {
self.playing = false;
self.cursor_fp = end;
break;
}
let (mut l, mut r) = (0.0f32, 0.0f32);
for (lane, on) in lanes.iter().zip(audible.iter()) {
if !on {
continue;
}
let (ll, rr) = sample_lane(lane, stem_cursor);
l += ll;
r += rr;
}
l *= GAIN;
r *= GAIN;
let index = (self.cursor_fp >> 32) as usize;
let fraction = (self.cursor_fp & (FP_ONE - 1)) as f32 / FP_ONE as f32;
let next = (index + 1).min(clip.frames.len() - 1);
let (al, ar) = clip.frames[index];
let (bl, br) = clip.frames[next];
let l = (al + (bl - al) * fraction) * GAIN;
let r = (ar + (br - ar) * fraction) * GAIN;
for channel in 0..output.channel_count() {
output.channel_mut(channel)[frame] += if channel == 0 { l } else { r };
}
cursor = cursor.saturating_add(step);
stem_cursor = stem_cursor.saturating_add(stem_step);
self.cursor_fp = self.cursor_fp.saturating_add(step);
}
WAV_MIXER.cursor_fp.store(cursor.min(end), Ordering::Release);
return;
self.cursor_fp = self.cursor_fp.min(end);
self.publish();
}
for frame in 0..output.frame_count() {
if cursor >= end {
WAV_MIXER.playing.store(false, Ordering::Release);
cursor = end;
break;
}
let index = (cursor >> 32) as usize;
let fraction = (cursor & (FP_ONE - 1)) as f32 / FP_ONE as f32;
let next = (index + 1).min(clip.frames.len() - 1);
let (al, ar) = clip.frames[index];
let (bl, br) = clip.frames[next];
let l = (al + (bl - al) * fraction) * GAIN;
let r = (ar + (br - ar) * fraction) * GAIN;
for channel in 0..output.channel_count() {
output.channel_mut(channel)[frame] += if channel == 0 { l } else { r };
}
cursor = cursor.saturating_add(step);
}
WAV_MIXER.cursor_fp.store(cursor.min(end), Ordering::Release);
}
// ---------------------------------------------------------------------------
@ -660,7 +923,10 @@ pub fn waveform_bgra(pcm: &WavPcm, width: usize, height: usize) -> Vec<u32> {
mod tests {
use super::*;
static TRANSPORT_TEST_LOCK: Mutex<()> = Mutex::new(());
fn reset_transport() -> AudioEngine {
AUDIO_MIXER.with(|slot| *slot.borrow_mut() = AudioMixer::new());
take_engine()
}
fn transport_pcm() -> WavPcm {
WavPcm {
@ -715,7 +981,7 @@ mod tests {
#[test]
fn transport_is_device_clocked_pauseable_seekable_and_restarts_at_end() {
let _serial = TRANSPORT_TEST_LOCK.lock().unwrap();
let mut engine = reset_transport();
clear();
assert!(load(transport_pcm()));
assert!(is_ready());
@ -728,7 +994,7 @@ mod tests {
assert_eq!(playhead_secs(), 0.0);
assert_eq!(playhead_fraction(), 0.0);
let mut output = AudioBuffer::new_with_size(2, 2);
mix_into(&mut output, 10.0);
engine.mix_into(&mut output, 10.0);
assert!((playhead_secs() - 0.2).abs() < 1e-9);
// The drawn playhead tracks the same device-clocked cursor.
assert!((playhead_fraction() - 0.5).abs() < 1e-9);
@ -737,7 +1003,7 @@ mod tests {
pause();
let paused_at = playhead_secs();
let mut silent = AudioBuffer::new_with_size(2, 2);
mix_into(&mut silent, 10.0);
engine.mix_into(&mut silent, 10.0);
assert_eq!(playhead_secs(), paused_at);
assert!(silent.channel(0).iter().all(|sample| *sample == 0.0));
@ -752,7 +1018,7 @@ mod tests {
assert_eq!(playhead_secs(), 0.0);
let mut to_end = AudioBuffer::new_with_size(8, 2);
mix_into(&mut to_end, 10.0);
engine.mix_into(&mut to_end, 10.0);
assert!(at_end());
assert!(!is_playing());
clear();
@ -768,7 +1034,7 @@ mod tests {
#[test]
fn layers_replace_the_mixed_track_and_mute_one_at_a_time() {
let _serial = TRANSPORT_TEST_LOCK.lock().unwrap();
let mut engine = reset_transport();
clear();
assert!(!stems_ready(), "no clip, no layers");
assert!(load(transport_pcm()));
@ -788,7 +1054,7 @@ mod tests {
play();
let mut all = AudioBuffer::new_with_size(1, 2);
mix_into(&mut all, 10.0);
engine.mix_into(&mut all, 10.0);
assert!(
(all.channel(0)[0] - expect(1_000 + 2_000 + 4_000 + 8_000)).abs() < 1e-4,
"all four layers sum: {}",
@ -803,7 +1069,7 @@ mod tests {
set_lane_muted(2, true);
assert!(lane_muted(2) && !lane_muted(0));
let mut without_vocals = AudioBuffer::new_with_size(1, 2);
mix_into(&mut without_vocals, 10.0);
engine.mix_into(&mut without_vocals, 10.0);
assert!(
(without_vocals.channel(0)[0] - expect(1_000 + 2_000 + 8_000)).abs() < 1e-4,
"vocals muted: {}",
@ -816,7 +1082,7 @@ mod tests {
set_lane_muted(index, true);
}
let mut silent = AudioBuffer::new_with_size(1, 2);
mix_into(&mut silent, 10.0);
engine.mix_into(&mut silent, 10.0);
assert!(silent.channel(0)[0].abs() < 1e-6, "{}", silent.channel(0)[0]);
// Clearing the layers hands playback back to the mixed track.
@ -824,7 +1090,7 @@ mod tests {
assert!(!stems_ready());
seek_fraction(0.5);
let mut mixed = AudioBuffer::new_with_size(1, 2);
mix_into(&mut mixed, 10.0);
engine.mix_into(&mut mixed, 10.0);
assert!(
(mixed.channel(0)[0] - 0.8 * GAIN).abs() < 1e-4,
"the clip's own third frame: {}",
@ -854,7 +1120,7 @@ mod tests {
#[test]
fn an_empty_layer_is_refused_rather_than_played_as_a_hole() {
let _serial = TRANSPORT_TEST_LOCK.lock().unwrap();
let _engine = reset_transport();
clear();
assert!(load(transport_pcm()));
assert!(!set_stems(
@ -909,7 +1175,7 @@ mod tests {
#[test]
fn empty_clip_is_unavailable() {
let _serial = TRANSPORT_TEST_LOCK.lock().unwrap();
let _engine = reset_transport();
clear();
assert!(!load(WavPcm {
frames: Vec::new(),

View file

@ -30,6 +30,7 @@ use makepad_chat_ui::{ChatFeed, ClientTools, FeedConfig};
use makepad_asset_client::dto::ChatToolOutcomeDto;
use makepad_asset_client::json::{self, Value};
use makepad_asset_client::{ApiEndpoints, ChatAttachment};
use makepad_widgets::Cx;
use std::path::PathBuf;
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::{Arc, Mutex};
@ -493,7 +494,13 @@ impl ChatBridge {
/// The store session is up: open the chat on its broker. The session
/// itself is created lazily, on the first turn.
pub fn connect(&mut self, endpoints: ApiEndpoints, token: Option<String>, cache: PathBuf) {
pub fn connect(
&mut self,
cx: &Cx,
endpoints: ApiEndpoints,
token: Option<String>,
cache: PathBuf,
) {
let tools = AppTools {
defaults: self.defaults.clone(),
fleet: self.fleet.clone(),
@ -503,6 +510,7 @@ impl ChatBridge {
self.feed = Some(ChatFeed::start(
FeedConfig::new(endpoints, token, cache, "gen", "gen"),
Box::new(tools),
cx.thread_spawner(),
));
}
@ -939,12 +947,13 @@ mod tests {
fn snap(url: &str, domain: &str, id: &str, state: &str) -> BoxSnapshot {
BoxSnapshot {
base_url: url.into(),
health: Some(HealthJson { realtime: None,
health: Some(HealthJson { realtime: None, activity: None,
service: "makepad-asset-ai".into(),
version: "t".into(),
gpu: Some("RTX".into()),
vram_free_mb: Some(20000),
vram_total_mb: Some(24576),
vram_usable_mb: None,
models_loaded: vec![id.into()],
jobs_pending: Some(0),
node_id: Some(1),
@ -953,6 +962,7 @@ mod tests {
capabilities: Some(vec![domain.into()]),
vram_reserve_mb: Some(1024),
queue_limit: Some(4),
max_job_body_bytes: None,
fleet: None,
lanes: None,
}),

View file

@ -18,13 +18,13 @@ use makepad_asset_client::{
AnnotationUpload, ApiEndpoints, AssetClient, ClientConfig,
};
use makepad_asset_data::{sha256, AssetKind, BlobId};
use makepad_widgets::makepad_platform::thread::{Lane, TaskPool};
use makepad_widgets::{log, vec3f};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::sync::Arc;
use std::thread;
/// Import-card preview strip size.
/// How many imported items the LOAD grid remembers a picture for. A grid,
@ -835,6 +835,7 @@ pub struct ImportPage {
/// status prefix — without it the bar restarted per pack, which over a
/// 38-pack run read as noise.
all_run: Option<(usize, usize)>,
pool: Option<TaskPool>,
}
impl Default for ImportPage {
@ -854,11 +855,16 @@ impl Default for ImportPage {
rx: None,
icon_resume: IconResumeGate::default(),
all_run: None,
pool: None,
}
}
}
impl ImportPage {
pub fn set_task_pool(&mut self, pool: TaskPool) {
self.pool = Some(pool);
}
/// Selected full Kenney kit (name, official page).
pub fn selected_pack_id(&self) -> (String, String) {
let packs = on_disk_kenney_packs();
@ -1532,9 +1538,10 @@ impl ImportPage {
self.icon_resume = icon_resume;
self.kenney_phase = ImportPhase::compiling(pack_name.clone());
let cancel = self.cancel.clone();
thread::Builder::new()
.name("asset-ui-kenney-import".into())
.spawn(move || {
self.pool
.as_ref()
.ok_or("runtime task pool is not configured")?
.submit(Lane::Heavy, move || {
let phase = run_kenney_import(
&dir,
&out,
@ -1548,7 +1555,8 @@ impl ImportPage {
);
let _ = tx.send(phase);
})
.map_err(|e| format!("failed to start compile thread: {e}"))?;
.map(|handle| handle.detach())
.map_err(|e| format!("failed to submit compile job: {e}"))?;
Ok(())
}
@ -1580,13 +1588,15 @@ impl ImportPage {
self.icon_resume = icon_resume;
self.kenney_phase = ImportPhase::compiling("kaykit");
let cancel = self.cancel.clone();
thread::Builder::new()
.name("asset-ui-kaykit-import".into())
.spawn(move || {
self.pool
.as_ref()
.ok_or("runtime task pool is not configured")?
.submit(Lane::Heavy, move || {
let phase = run_kaykit_import(&dir, &out, spec, server, &tx, &cancel, &icon_resume_rx);
let _ = tx.send(phase);
})
.map_err(|e| format!("failed to start KayKit import thread: {e}"))?;
.map(|handle| handle.detach())
.map_err(|e| format!("failed to submit KayKit import job: {e}"))?;
Ok(())
}
@ -1619,9 +1629,10 @@ impl ImportPage {
self.kenney_phase = ImportPhase::compiling("all");
self.all_run = Some((0, present.len()));
let cancel = self.cancel.clone();
thread::Builder::new()
.name("asset-ui-kenney-import-all".into())
.spawn(move || {
self.pool
.as_ref()
.ok_or("runtime task pool is not configured")?
.submit(Lane::Heavy, move || {
let total = present.len();
let mut ok = Vec::new();
let mut failed = Vec::new();
@ -1749,7 +1760,8 @@ impl ImportPage {
skipped,
});
})
.map_err(|e| format!("failed to start import-all thread: {e}"))?;
.map(|handle| handle.detach())
.map_err(|e| format!("failed to submit import-all job: {e}"))?;
Ok(())
}
@ -4003,6 +4015,12 @@ fn publish_compiled_pack(
categories: vec!["kenney".into(), pack_name.to_string()],
tags,
creator: KENNEY_CREDITS.to_string(),
artist: String::new(),
artist_url: String::new(),
album: String::new(),
source_url: String::new(),
license: String::new(),
license_url: String::new(),
generator: "pack_import".into(),
backend: "asset-ui".into(),
model: pack_name.to_string(),

View file

@ -30,7 +30,6 @@ use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::sync::Arc;
use std::thread;
use crate::import::{KENNEY_MODULE, KAYKIT_MODULE, NASA_SKY_MODULE, PackModule};
use makepad_asset_importer::tdm_zipsync::{
@ -306,6 +305,7 @@ pub struct ClassicImportCard {
/// a classic pack publishes real thumbnails and its staging can be
/// reclaimed the moment publish succeeds. See [`IconResumeGate`].
icon_resume: IconResumeGate,
pool: Option<TaskPool>,
}
struct IsoSync {
@ -358,6 +358,7 @@ impl ClassicImportCard {
tdm: None,
iso: None,
icon_resume: IconResumeGate::default(),
pool: None,
}
}
@ -658,6 +659,7 @@ impl ClassicImportCard {
path_override: String,
server: Option<ServerSession>,
) -> Result<(), String> {
self.pool = Some(cx.task_pool());
if self.compiling() {
return Err(format!(
"a {} import is already running",
@ -1540,9 +1542,10 @@ impl ClassicImportCard {
// until the UI has taken every landing for icon rendering.
let (gate, icon_resume_rx) = IconResumeGate::armed();
self.icon_resume = gate;
thread::Builder::new()
.name(format!("asset-ui-{}-import", source.id()))
.spawn(move || {
self.pool
.as_ref()
.ok_or("runtime task pool is not configured")?
.submit(Lane::Heavy, move || {
let phase = run_classic_import(
&dir,
&out,
@ -1555,7 +1558,8 @@ impl ClassicImportCard {
);
let _ = tx.send(phase);
})
.map_err(|e| format!("failed to start classic import thread: {e}"))?;
.map(|handle| handle.detach())
.map_err(|e| format!("failed to submit classic import job: {e}"))?;
Ok(())
}
@ -2267,6 +2271,12 @@ fn publish_classic_pack(
categories: vec![source.id().into(), pack_name.to_string()],
tags,
creator: source.credits().to_string(),
artist: String::new(),
artist_url: String::new(),
album: String::new(),
source_url: String::new(),
license: String::new(),
license_url: String::new(),
generator: "classic_import".into(),
backend: "asset-ui".into(),
model: pack_name.to_string(),

View file

@ -55,7 +55,9 @@ mod import_classic;
mod store_content;
mod library;
mod mask_paint;
mod mesh_view;
mod mesh_view {
pub use makepad_media_view::mesh_view::*;
}
mod music_page;
use crate::mask_paint::{MaskPaint, MaskPaintAction};
mod pipeline;
@ -63,7 +65,9 @@ mod runs_chip;
mod scheduler;
mod store_views;
mod thumbnail_renderer;
mod video_player;
mod video_player {
pub use makepad_media_view::{FileVideoPlayer as VideoPlayer, VideoDecoder};
}
mod webcam;
use crate::artifact_io::{
@ -244,7 +248,7 @@ use crate::store_views::{
StoreListPanel, StoreRow,
TileDelete,
};
use crate::video_player::VideoPlayer;
use crate::video_player::{VideoDecoder, VideoPlayer};
use makepad_micro_serde::SerJson;
use makepad_widgets::*;
@ -4010,7 +4014,7 @@ pub struct App {
fleet_timer: Timer,
/// LAN beacon listener; polled on the fleet timer.
#[rust]
discovered: Option<makepad_ai_hub::discovery::Discovered>,
discovered: Option<makepad_ai_hub::discovery::Discovery>,
#[rust]
job_timer: Timer,
#[rust]
@ -4036,6 +4040,8 @@ pub struct App {
library: Option<Library>,
#[rust]
video: Option<VideoPlayer>,
#[rust]
video_decoder: Option<VideoDecoder>,
/// The file the viewer's current video came from — Restart and the loop
/// toggle re-open it.
video_path: Option<PathBuf>,
@ -4323,8 +4329,16 @@ impl App {
// -- setup ---------------------------------------------------------------
fn setup(&mut self, cx: &mut Cx) {
let pool = cx.task_pool();
let spawner = cx.thread_spawner();
let (video_decoder, mut video_audio) =
VideoDecoder::start(spawner.clone()).expect("asset-ui video decoder worker");
self.video_decoder = Some(video_decoder);
let _ = std::fs::create_dir_all(artifacts_dir());
self.artifact_io = Some(ArtifactIo::start());
self.artifact_io = Some(ArtifactIo::start(spawner.clone()));
self.analysis = Some(AnalysisQueue::start(spawner.clone()));
self.import_page.set_task_pool(pool.clone());
self.music_import_page.set_task_pool(pool.clone());
self.load_fleet_prefs();
self.library = Some(Library::open(repo_path("local/ai_content_library")));
self.saved_presets = fast_presets::load(&fast_presets::store_path());
@ -4349,7 +4363,11 @@ impl App {
// The store hosts the embedded Asset Server; hand it the library it
// must publish. Library::open ran above, so the product backfill is
// already on disk when the watcher's first poll reads index.json.
self.store.start(PathBuf::from(repo_path("local/ai_content_library")));
self.store.start(
PathBuf::from(repo_path("local/ai_content_library")),
pool,
spawner,
);
self.asset_store_timer = cx.start_interval(0.2);
// Opening stays metadata-only; every missing preview is queued here
// and regenerated a bounded slice at a time once frames are flowing.
@ -4548,11 +4566,12 @@ impl App {
self.refresh_voice_ui(cx);
self.sync_preset_name_box(cx);
// Speakers: wav artifacts + video soundtrack.
// Speakers: both engines move into the callback and own their state.
let mut audio_engine = crate::audio::take_engine();
cx.audio_output(0, move |info, output| {
output.zero();
crate::audio::mix_into(output, info.sample_rate);
crate::video_player::mix_into(output, info.sample_rate);
audio_engine.mix_into(output, info.sample_rate);
video_audio.mix_into(output, info.sample_rate);
});
// Headless drive.
@ -7541,7 +7560,7 @@ impl App {
// WAV must not call play() or a 200ms DS_* / Quake
// shot becomes a loop (play-at-end restarts).
if audition && audio::autoplay_one_shot(domain, pcm.seconds()) {
crate::video_player::stop_audio();
self.stop_video_audio();
audio::play();
self.arm_audio_pump(cx);
}
@ -7589,7 +7608,8 @@ impl App {
// behind a new open or an error state.
self.stop_video_playback();
self.clear_video_frame(cx);
match VideoPlayer::new(&path.to_string_lossy()) {
let decoder = self.video_decoder.as_ref().expect("video decoder started");
match VideoPlayer::new(&path.to_string_lossy(), decoder) {
Ok(player) => {
self.ui.label(cx, ids!(video_info)).set_text(
cx,
@ -9101,7 +9121,7 @@ impl App {
&& audio::is_ready()
&& !audio::is_playing()
{
crate::video_player::stop_audio();
self.stop_video_audio();
audio::play();
self.arm_audio_pump(cx);
self.sync_audio_ui(cx);
@ -9963,7 +9983,7 @@ impl App {
if !self.chat.is_linked() {
if let Some(endpoints) = self.store.endpoints {
let cache = session_config_from_env().cache_parent.join("cache-chat");
self.chat.connect(endpoints, self.store.token.clone(), cache);
self.chat.connect(cx, endpoints, self.store.token.clone(), cache);
// The pane says "waiting for the asset server" until
// something redraws it, and the feed only marks itself
// dirty once a turn runs — so the line would sit there
@ -10714,7 +10734,8 @@ impl App {
if let Some(path) = item.as_ref().and_then(|item| item.payload.clone()) {
self.stop_video_playback();
self.clear_video_frame(cx);
match VideoPlayer::new(&path.to_string_lossy()) {
let decoder = self.video_decoder.as_ref().expect("video decoder started");
match VideoPlayer::new(&path.to_string_lossy(), decoder) {
Ok(player) => {
self.library_video_file = Some(file.clone());
self.video = Some(player);
@ -10789,7 +10810,8 @@ impl App {
self.library_audio_file = Some(file.clone());
// The transport: decoded off the frame thread and
// installed when it lands.
let clip_gen = crate::audio::load_clip_async(bytes.clone());
let pool = cx.task_pool();
let clip_gen = crate::audio::load_clip_async(&pool, bytes.clone());
// And, exactly once per track, what the store
// already holds BESIDE the mixed audio: the four
// separated layers and the transcript. The clip
@ -10930,11 +10952,9 @@ impl App {
// -- "Split audio layers": the bake queue and its consumers -----------
/// The bake + fetch lanes, started on first use. Two threads parked on
/// a channel is the whole cost of having them.
/// The bake + fetch lanes, started once with the app and fed by channels.
fn analysis(&mut self) -> &mut analysis::AnalysisQueue {
self.analysis
.get_or_insert_with(analysis::AnalysisQueue::start)
self.analysis.as_mut().expect("analysis workers started")
}
/// The selected catalog hit when it is an AUDIO asset: id and title.
@ -12400,7 +12420,13 @@ impl App {
/// [`Self::clear_video_frame`] is also called.
fn stop_video_playback(&mut self) {
self.video = None;
crate::video_player::stop_audio();
self.stop_video_audio();
}
fn stop_video_audio(&self) {
if let Some(decoder) = &self.video_decoder {
decoder.stop_audio();
}
}
/// Blank the actual video WIDGET texture (not only the app-side handle),
@ -12792,7 +12818,8 @@ impl App {
fn restart_viewer_video(&mut self, cx: &mut Cx) -> bool {
let Some(path) = self.video_path.clone() else { return false };
self.stop_video_playback();
match VideoPlayer::new(&path.to_string_lossy()) {
let decoder = self.video_decoder.as_ref().expect("video decoder started");
match VideoPlayer::new(&path.to_string_lossy(), decoder) {
Ok(player) => {
self.video = Some(player);
self.sync_video_transport(cx);
@ -14133,7 +14160,7 @@ impl MatchEvent for App {
} else {
// A user-resumed WAV preview wins over a stale video
// soundtrack in the shared device callback.
crate::video_player::stop_audio();
self.stop_video_audio();
audio::play();
self.arm_audio_pump(cx);
}
@ -14434,13 +14461,11 @@ impl AppMain for App {
fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
crate::makepad_widgets::script_mod(vm);
// Draw shaders must register before the widgets that declare them.
makepad_render::script_mod(vm);
makepad_xr::script_mod(vm);
makepad_media_view::script_mod(vm);
// Shared preview widgets (ContentPreview / AudioView): the pool this
// app draws catalog content with, and the same one the VJ and DJ
// surfaces adopt.
makepad_asset_widgets::script_mod(vm);
crate::mesh_view::script_mod(vm);
crate::mask_paint::script_mod(vm);
crate::billboard_view::script_mod(vm);
crate::thumbnail_renderer::script_mod(vm);
@ -14691,14 +14716,17 @@ impl AppMain for App {
}
}
self.scrub_audio(cx, event);
if self.audio_timer.is_event(event).is_some() && audio::is_ready() {
self.sync_audio_ui(cx);
// The Library rail has its own transport over the same mixer.
if self.surface == Surface::Library && self.library_audio_file.is_some() {
self.refresh_library_audio(cx);
// Playback that started from the transport re-arms the
// transcript's own per-frame follow.
self.arm_lyrics_pump(cx);
if self.audio_timer.is_event(event).is_some() {
audio::pump();
if audio::is_ready() {
self.sync_audio_ui(cx);
// The Library rail has its own transport over the same mixer.
if self.surface == Surface::Library && self.library_audio_file.is_some() {
self.refresh_library_audio(cx);
// Playback that started from the transport re-arms the
// transcript's own per-frame follow.
self.arm_lyrics_pump(cx);
}
}
}
if self.audio_timer.is_event(event).is_some() && self.webcam.capturing {
@ -15918,6 +15946,13 @@ mod world_style_tests {
namespace: namespace.to_string(),
kind: Some(makepad_asset_data::AssetKind::World),
title: title.to_string(),
creator: String::new(),
artist: String::new(),
artist_url: String::new(),
album: String::new(),
source_url: String::new(),
license: String::new(),
license_url: String::new(),
snippet: String::new(),
score: 0,
live: true,

View file

@ -12,11 +12,11 @@
use crate::import::{ImportPhase, ServerSession};
use makepad_asset_client::{AssetClient, ClientConfig};
use makepad_asset_importer::music_import::{self, MusicReport};
use makepad_widgets::makepad_platform::thread::{Lane, TaskPool};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::sync::Arc;
use std::thread;
/// The card's whole state: which folder, what the worker is doing, and the
/// one-line result of the last run.
@ -44,6 +44,7 @@ pub struct MusicImportPage {
/// Aliases the finished run landed, waiting to be handed to the
/// analysis queue. Non-empty only when `split_layers` was on.
pending_analysis: Vec<String>,
pool: Option<TaskPool>,
}
/// What the worker sends back: live progress, then exactly one verdict.
@ -65,6 +66,7 @@ impl Default for MusicImportPage {
split_layers: false,
bake_lyrics: false,
pending_analysis: Vec::new(),
pool: None,
}
}
}
@ -74,6 +76,10 @@ impl Default for MusicImportPage {
pub const MUSIC_NAMESPACE: &str = "music";
impl MusicImportPage {
pub fn set_task_pool(&mut self, pool: TaskPool) {
self.pool = Some(pool);
}
/// The job this card would enqueue right now, or `None` while no folder
/// has been picked.
pub fn job(&self) -> Option<crate::import::ImportJob> {
@ -209,13 +215,18 @@ impl MusicImportPage {
total: 0,
current: String::new(),
};
thread::Builder::new()
.name("asset-ui-music-import".into())
.spawn(move || {
let Some(pool) = self.pool.clone() else {
return Err(self.refuse("runtime task pool is not configured".into()));
};
match pool.submit(Lane::Heavy, move || {
let msg = run_music_import(&dir, server, &tx, &cancel);
let _ = tx.send(msg);
})
.map_err(|e| self.refuse(format!("failed to start music import thread: {e}")))?;
}) {
Ok(handle) => handle.detach(),
Err(error) => {
return Err(self.refuse(format!("failed to submit music import job: {error}")));
}
}
Ok(())
}

View file

@ -218,16 +218,16 @@ impl Preset {
/// Python rigger, or another arbitrary model merely because it is resident.
// This is the warm, local Makepad-Llama model advertised by the Mac fleet
// node. Keeping it as fallback avoids an accidental large-model cold pull.
const CHARACTER_LLM_MODEL: &str = "qwen3.5-9b";
use makepad_asset_creator::character::CHARACTER_LLM_MODEL;
/// Fleet-wide default for text expansion once a node has the audited weights
/// ready. This is a preference, not a hard pin: first-run provisioning stays
/// explicit and the warm 9B lane remains immediately usable.
const PREFERRED_EXPAND_MODEL: &str = "qwen3.8-27b";
const CHARACTER_IMAGE_MODEL: &str = "flux1-dev";
const CHARACTER_MATTE_MODEL: &str = "birefnet-hr";
const CHARACTER_MESH_MODEL: &str = "trellis-2";
const CHARACTER_RIG_MODEL: &str = "skintokens";
const CHARACTER_MOTION_MODEL: &str = "hy-motion";
use makepad_asset_creator::character::CHARACTER_IMAGE_MODEL;
use makepad_asset_creator::character::CHARACTER_MATTE_MODEL;
use makepad_asset_creator::character::CHARACTER_MESH_MODEL;
use makepad_asset_creator::character::CHARACTER_RIG_MODEL;
use makepad_asset_creator::character::CHARACTER_MOTION_MODEL;
/// Instruction image editing (reference image + "change …" prompt).
const EDIT_MODEL: &str = "flux2-klein-4b";
/// Sprite enhancement runs on the 32B dev DiT, NOT the 4-step distilled
@ -257,7 +257,7 @@ const SPLAT_MODEL: &str = "triposplat";
/// A character expansion substantially shorter than the 40-90 words asked
/// for by `expand_rig.txt` is not a usable rig-safe brief. Refuse to quietly
/// continue with it; the user can see and retry the failed LLM stage.
const CHARACTER_EXPANSION_MIN_WORDS: usize = 24;
// The shared character contract owns brief validation.
/// A character reconstruction gets two deterministic second chances when the
/// rig or animated-skin quality gate rejects it. The matte/image are
@ -544,7 +544,7 @@ pub const PRESETS: &[Preset] = &[
// (idle/walk/jump locomotion, see mesh_view play mode).
Preset::linear(
"character (playable)",
&["text", "image", "matte", "mesh", "rig", "motion"],
makepad_asset_creator::character::CHARACTER_DOMAINS,
// Character geometry is downstream of this one image: Schnell's
// four-step distillation is useful for previews, but it is the wrong
// silent affinity fallback for the rig master. Pin the validated
@ -1329,21 +1329,7 @@ impl Pipeline {
return unusable("was empty");
}
if self.is_character_pipeline() {
let words = text.split_whitespace().count();
if words < CHARACTER_EXPANSION_MIN_WORDS {
return Err(format!(
"LLM character brief is too short ({words} words, need at least {CHARACTER_EXPANSION_MIN_WORDS}); refusing to start image generation"
));
}
if !text
.to_lowercase()
.contains(&self.prompt.trim().to_lowercase())
{
return Err(format!(
"LLM character brief dropped identity anchor {:?}; refusing to start image generation",
self.prompt.trim()
));
}
makepad_asset_creator::character::validate_brief(&self.prompt, text)?;
}
return Ok(text.to_string());
}
@ -1501,16 +1487,7 @@ impl Pipeline {
let is_music_target = target == "music";
request.target_domain = Some(target);
if self.is_character_pipeline() {
request.identity_anchor = Some(self.prompt.trim().to_string());
// Named-character identity and rig-safe presentation are
// constraints, not a variant hunt. Keep this expansion
// low-temperature and deterministic enough to avoid
// inventing conflicting signature traits.
request.temperature = Some(0.0);
request.style = Some(
"When the intent names an established character, preserve the exact named identity and canonical official design unchanged. Do not redesign, genericize, or guess traits. If a visual trait is uncertain, omit it instead of inventing it; it is better to say 'canonical official design unchanged' and spend the remaining prompt on full-body framing, a relaxed wide A-pose with straight diagonal arms and hands clear above the hips, visible gaps between every limb and the torso, even studio light, a uniform plain background, and a clean separated silhouette. Rigging constraints may change pose and spacing but never delete canonical anatomy or worn pieces."
.to_string(),
);
makepad_asset_creator::character::configure_expansion(&mut request, &self.prompt);
}
// Music expansion carries a compact structured production
// brief AND original section-tagged lyrics. Scale its budget
@ -3381,7 +3358,7 @@ impl Pipeline {
} => {
let bytes = response
.filter(|response| !failed && response.status_code == 200)
.and_then(|response| response.body.clone());
.and_then(|response| response.body.as_deref().map(<[u8]>::to_vec));
let Some(bytes) = bytes else {
return self.candidate_failed(
cx,
@ -3589,7 +3566,7 @@ impl Pipeline {
Req::Artifact(stage, artifact) => {
let bytes = response
.filter(|r| !failed && r.status_code == 200)
.and_then(|r| r.body.clone());
.and_then(|r| r.body.as_deref().map(<[u8]>::to_vec));
let Some(bytes) = bytes else {
return self.fail_stage_or_skip_expander(
cx,
@ -3950,12 +3927,13 @@ mod tests {
use makepad_ai_hub::protocol::{HealthJson, ModelInfoJson, MODEL_STATE_LOADED};
BoxSnapshot {
base_url: url.to_string(),
health: Some(HealthJson { realtime: None,
health: Some(HealthJson { realtime: None, activity: None,
service: "test".to_string(),
version: "1".to_string(),
gpu: Some("GPU".to_string()),
vram_free_mb: Some(24_000),
vram_total_mb: Some(24_000),
vram_usable_mb: None,
models_loaded: vec!["flux1-schnell".to_string()],
jobs_pending: Some(0),
node_id: Some(1),
@ -3964,6 +3942,7 @@ mod tests {
capabilities: Some(vec!["image".to_string()]),
vram_reserve_mb: Some(0),
queue_limit: Some(8),
max_job_body_bytes: None,
fleet: None,
lanes: None,
}),
@ -3995,12 +3974,13 @@ mod tests {
use makepad_ai_hub::protocol::{HealthJson, ModelInfoJson, MODEL_STATE_LOADED};
BoxSnapshot {
base_url: url.to_string(),
health: Some(HealthJson { realtime: None,
health: Some(HealthJson { realtime: None, activity: None,
service: "test".to_string(),
version: "1".to_string(),
gpu: Some("24 GB GPU".to_string()),
vram_free_mb: Some(24 * 1024),
vram_total_mb: Some(24 * 1024),
vram_usable_mb: None,
models_loaded: models
.iter()
.filter(|(_, state)| *state == MODEL_STATE_LOADED)
@ -4013,6 +3993,7 @@ mod tests {
capabilities: Some(vec!["text".to_string()]),
vram_reserve_mb: Some(0),
queue_limit: Some(8),
max_job_body_bytes: None,
fleet: None,
lanes: None,
}),
@ -4957,7 +4938,10 @@ Arrangement: Pulsing bass, gated drums and widening analog pads."
let mut want: Vec<(String, String)> = [
("audio", "moss-sfx"),
("audio", "sa3-sfx"),
("audio", "salamander-drumkit"),
("audio", "woosh-sfx"),
("beats", "beat-this"),
("body", "sam3dbody"),
("control", "flux1-canny-dev"),
("control", "flux1-depth-dev"),
("depth", "da3-metric-large"),
@ -4975,6 +4959,8 @@ Arrangement: Pulsing bass, gated drums and widening analog pads."
("music", "minimax-music3"),
("music", "minimax-music3-q4"),
("music", "ace-step-1.5-xl"),
("notes", "basic-pitch"),
("ocr", "chandra-ocr-2"),
("paint", "hunyuan3d-paint-2.1"),
("rig", "skintokens"),
("rig", "skintokens-oracle"),
@ -4982,6 +4968,8 @@ Arrangement: Pulsing bass, gated drums and widening analog pads."
("splat", "triposplat"),
("speech", "indextts-2.5"),
("speech", "kokoro"),
("stems", "bs-roformer-4stem"),
("stt", "whisper-large-v3-turbo"),
("text", "qwen3.8-27b"),
("upscale", "realesrgan-x4plus"),
("vision", "qwen3.8-27b-vision"),
@ -5042,7 +5030,7 @@ Arrangement: Pulsing bass, gated drums and widening analog pads."
/// fails visibly as a service gap instead of rerouting.
const DOCUMENTED_OVERRIDE_PINS: &[(&str, &str)] = &[("text", CHARACTER_LLM_MODEL)];
/// Every pin must reference a model the registry actually has in the
/// Every pin must reference a model the registry can serve for the
/// pinned domain — or be a documented cache-registry override above.
/// Catches typos and silent registry drift.
#[test]
@ -5057,7 +5045,13 @@ Arrangement: Pulsing bass, gated drums and widening analog pads."
registry
.models
.iter()
.any(|entry| entry.id == *model && entry.domain.as_str() == *domain),
.any(|entry| {
entry.id == *model
&& (entry.domain.as_str() == *domain
|| (*domain == "edit"
&& entry.domain.as_str() == "image"
&& model.starts_with("flux2-dev")))
}),
"preset {:?} pins unknown model {domain}/{model}",
preset.name
);

View file

@ -2542,6 +2542,13 @@ mod tests {
namespace: "gen".into(),
kind: Some(AssetKind::Prop),
title: title.into(),
creator: String::new(),
artist: String::new(),
artist_url: String::new(),
album: String::new(),
source_url: String::new(),
license: String::new(),
license_url: String::new(),
snippet: "a thing".into(),
score: 10,
live,

View file

@ -823,6 +823,7 @@ impl ThumbnailRenderer {
color_adjust: vec4(0.0, 1.0, 1.0, 0.0),
dynamic: true,
depth_order: 0.0,
custom_material: None,
part_poses: Vec::new(),
}),
frame,

View file

@ -1,637 +0,0 @@
//! Video artifact playback — the sandbox's proven decode pattern
//! (apps/sandbox/src/video_player.rs): a decode thread pulls frames + audio
//! from the platform video-file seam (`makepad_platform::video_file`,
//! hardware codecs), a small ring buffer hands BGRA frames to the render
//! thread paced by pts against a wall clock, and the audio track mixes into
//! this app's `cx.audio_output` closure.
//!
//! Playback is PLAY-ONCE: at end-of-stream the remaining audio drains and
//! the clip stops (the sandbox pattern's loop-forever reopen was what users
//! heard as "the soundtrack never ends"). Loading a new artifact drops the
//! previous player (its `Drop` silences the queue); `stop_audio()` silences
//! immediately and stays muted until the next clip starts.
//!
//! Copied rather than imported: the sandbox is an app crate under active
//! concurrent development, not a library — and this pattern is ~250 lines.
use makepad_widgets::log;
use makepad_widgets::makepad_platform::audio::AudioBuffer;
use makepad_widgets::makepad_platform::video_file::{nv12, VideoFileDecoder};
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const RING_FRAMES: usize = 3;
const AUDIO_AHEAD_SECS: f64 = 1.0;
struct Frame {
pts_100ns: i64,
bgra: Vec<u32>,
}
struct Shared {
frames: Mutex<VecDeque<Frame>>,
stop: AtomicBool,
/// The decode thread exited for good — a honored stop or a fatal
/// decode error. End-of-stream no longer ends the thread: it PARKS
/// (see `eos`) so seeks — loop restarts, scrubs — stay instant.
done: AtomicBool,
/// The stream is fully decoded and the thread is parked waiting for a
/// seek or a stop. With an empty ring this is the player's EOS state.
eos: AtomicBool,
/// Requested playback position in 100ns units; -1 = none. The decode
/// thread consumes it (an in-place decoder seek — no reopen).
seek_100ns: AtomicI64,
}
/// Soundtrack-queue ownership ticket. Every player claims a fresh epoch; a
/// DETACHED decode thread of a dropped player still holds its old epoch and
/// its pushes bounce off the queue (see [`VideoPlayer::drop`]).
static NEXT_CLIP_EPOCH: AtomicU64 = AtomicU64::new(1);
/// Half a frame at 60 fps: a seek target inside a frame's span lands on
/// that frame instead of the next.
const FRAME_EPS_100NS: i64 = 83_000;
pub struct VideoPlayer {
pub width: u32,
pub height: u32,
/// Container-reported duration; 0 when the container does not say.
pub duration_100ns: i64,
shared: Arc<Shared>,
started: Option<Instant>,
last_pts: i64,
/// While paused: when the pause began. The clock rebases by the paused
/// span on resume, so playback continues where it stopped instead of
/// skipping the frames "missed" on the wall clock.
paused_at: Option<Instant>,
epoch: u64,
}
impl VideoPlayer {
pub fn new(path: &str) -> Result<Self, String> {
let info = VideoFileDecoder::open(path)
.map_err(|e| e.to_string())?
.info()
.clone();
if info.width == 0 || info.height == 0 {
return Err(format!(
"video reports zero size: {}x{}",
info.width, info.height
));
}
log!(
"video: {} {}x{} {}/{} fps codec {:?} audio {} ({} Hz, {} ch)",
path,
info.width,
info.height,
info.fps_num,
info.fps_den,
info.video_codec,
info.has_audio,
info.audio_sample_rate,
info.audio_channels
);
// Fresh clip: take ownership of the soundtrack queue. Claiming a new
// epoch drops any previous tail, lifts the sticky stop_audio() mute,
// and locks every straggler push from an older detached decode
// thread out of the queue.
let epoch = NEXT_CLIP_EPOCH.fetch_add(1, Ordering::Relaxed);
{
let mut audio = video_audio().lock().unwrap();
audio.clear();
audio.muted = false;
audio.owner = epoch;
}
let shared = Arc::new(Shared {
frames: Mutex::new(VecDeque::new()),
stop: AtomicBool::new(false),
done: AtomicBool::new(false),
eos: AtomicBool::new(false),
seek_100ns: AtomicI64::new(-1),
});
let thread_shared = shared.clone();
let thread_path = path.to_string();
// The JoinHandle is deliberately dropped: teardown must never join a
// possibly wedged hardware decoder on the UI thread. The thread is
// detached; `stop` + the audio epoch make that safe.
std::thread::Builder::new()
.name("asset-ui-video-decode".into())
.spawn(move || {
match VideoFileDecoder::open(&thread_path) {
Ok(decoder) => decode_loop(thread_path, decoder, &thread_shared, epoch),
Err(e) => log!("video: decode thread open failed: {}", e),
}
thread_shared.done.store(true, Ordering::Release);
})
.map_err(|e| e.to_string())?;
Ok(Self {
width: info.width,
height: info.height,
duration_100ns: info.duration_100ns,
shared,
started: None,
last_pts: 0,
paused_at: None,
epoch,
})
}
/// The newest frame whose pts has been reached; `None` keeps whatever is
/// on the texture. Call once per render frame.
pub fn is_paused(&self) -> bool {
self.paused_at.is_some()
}
/// Freeze the picture; the soundtrack mutes with it. Idempotent.
pub fn pause(&mut self) {
if self.paused_at.is_none() {
self.paused_at = Some(Instant::now());
video_audio().lock().unwrap().muted = true;
}
}
/// Continue from the paused position by pushing the clock base forward
/// by the paused span. Idempotent.
pub fn resume(&mut self) {
if let Some(paused_at) = self.paused_at.take() {
if let Some(started) = &mut self.started {
*started += paused_at.elapsed();
}
video_audio().lock().unwrap().muted = false;
}
}
pub fn take_due_frame(&mut self) -> Option<Vec<u32>> {
if self.paused_at.is_some() {
// Paused normally: hold the picture. Freshly seeked while
// paused (clock unset): show the seek target frame once.
if self.started.is_some() {
return None;
}
let mut frames = self.shared.frames.lock().unwrap();
let frame = frames.pop_front()?;
self.last_pts = frame.pts_100ns;
// Keep the clock unset: resume rebases at the next frame.
return Some(frame.bgra);
}
let mut frames = self.shared.frames.lock().unwrap();
let first_pts = frames.front()?.pts_100ns;
let started = *self.started.get_or_insert_with(|| {
Instant::now() - Duration::from_nanos(first_pts.max(0) as u64 * 100)
});
let media_100ns = (started.elapsed().as_nanos() / 100) as i64;
let mut due = None;
while frames.front().is_some_and(|f| f.pts_100ns <= media_100ns) {
due = frames.pop_front();
}
if let Some(frame) = &due {
self.last_pts = frame.pts_100ns;
}
due.map(|f| f.bgra)
}
pub fn position_secs(&self) -> f64 {
self.last_pts as f64 / 10_000_000.0
}
pub fn duration_secs(&self) -> f64 {
self.duration_100ns as f64 / 10_000_000.0
}
/// Jump playback to `secs`. The decode thread reopens the file and
/// discards up to the target; the picture clock rebases on the first
/// frame that arrives, so play continues from there (paused stays
/// paused, showing the seeked frame).
pub fn seek(&mut self, secs: f64) {
let target = (secs.max(0.0) * 10_000_000.0) as i64;
self.shared.seek_100ns.store(target, Ordering::Release);
self.shared.frames.lock().unwrap().clear();
self.started = None;
self.last_pts = target;
let mut audio = video_audio().lock().unwrap();
if audio.owner == self.epoch {
audio.clear();
}
}
/// True end-of-playback for the frame pump: the decode thread has exited
/// (end of stream, decode error, or stop) and every buffered frame has
/// been taken. The soundtrack tail may still be draining in the audio
/// callback — that needs no frame pump.
pub fn at_end(&self) -> bool {
(self.shared.eos.load(Ordering::Acquire) || self.shared.done.load(Ordering::Acquire))
&& self.shared.frames.lock().unwrap().is_empty()
}
/// True while a seek request is still unconsumed by the decode thread —
/// the host coalesces scrub drags on this instead of flooding.
pub fn seek_pending(&self) -> bool {
self.shared.seek_100ns.load(Ordering::Acquire) >= 0
}
}
impl Drop for VideoPlayer {
fn drop(&mut self) {
self.shared.stop.store(true, Ordering::Relaxed);
// NEVER join the decode thread here: a wedged hardware decoder call
// would hang the UI thread. The detached thread observes `stop`
// between packets and exits on its own; until then the epoch guard
// keeps its audio pushes out of the queue, and its frame ring dies
// with the last Arc.
let mut audio = video_audio().lock().unwrap();
if audio.owner == self.epoch {
audio.clear();
audio.owner = 0;
}
}
}
fn decode_loop(_path: String, mut decoder: VideoFileDecoder, shared: &Shared, epoch: u64) {
let info = decoder.info().clone();
let mut audio_eos = false;
let mut rgb_scratch = Vec::new();
loop {
if shared.stop.load(Ordering::Relaxed) {
return;
}
let seek = shared.seek_100ns.swap(-1, Ordering::AcqRel);
if seek >= 0 {
shared.eos.store(false, Ordering::Release);
// In-place decoder seek (SetCurrentPosition / reader rebuild in
// the platform layer, ~10 ms) — never a full reopen, which is
// what makes SCRUBBING realtime and a loop restart seamless.
match decoder.seek(seek) {
Ok(()) => {
audio_eos = !info.has_audio;
shared.frames.lock().unwrap().clear();
loop {
if shared.stop.load(Ordering::Relaxed) {
return;
}
if shared.seek_100ns.load(Ordering::Acquire) >= 0 {
break; // newer scrub target supersedes this one
}
match decoder.next_frame() {
Ok(Some(frame)) if frame.pts_100ns + FRAME_EPS_100NS < seek => {}
Ok(Some(frame)) => {
nv12::nv12_to_rgb8(
&frame.nv12,
frame.width,
frame.height,
&mut rgb_scratch,
);
let mut bgra =
Vec::with_capacity((frame.width * frame.height) as usize);
for px in rgb_scratch.chunks_exact(3) {
bgra.push(
0xff00_0000
| ((px[0] as u32) << 16)
| ((px[1] as u32) << 8)
| px[2] as u32,
);
}
shared
.frames
.lock()
.unwrap()
.push_back(Frame { pts_100ns: frame.pts_100ns, bgra });
break;
}
Ok(None) => break,
Err(e) => {
log!("video: seek decode error: {}", e);
break;
}
}
}
// Audio follows the picture: drop queued samples and
// skip the soundtrack forward to the target.
if info.has_audio {
video_audio().lock().unwrap().clear_for(epoch);
loop {
if shared.stop.load(Ordering::Relaxed) {
return;
}
match decoder.next_audio() {
Ok(Some(chunk)) if chunk.pts_100ns < seek => {}
Ok(Some(chunk)) => {
video_audio().lock().unwrap().push_i16(
epoch,
&chunk.samples,
chunk.channels,
chunk.sample_rate,
);
break;
}
Ok(None) => {
audio_eos = true;
break;
}
Err(_) => {
audio_eos = true;
break;
}
}
}
}
}
Err(e) => log!("video: decoder seek failed: {}", e),
}
continue;
}
if info.has_audio && !audio_eos {
while video_audio().lock().unwrap().buffered_secs() < AUDIO_AHEAD_SECS {
match decoder.next_audio() {
Ok(Some(chunk)) => video_audio().lock().unwrap().push_i16(
epoch,
&chunk.samples,
chunk.channels,
chunk.sample_rate,
),
Ok(None) => {
audio_eos = true;
break;
}
Err(e) => {
log!("video: audio decode error: {}", e);
audio_eos = true;
break;
}
}
}
}
if shared.frames.lock().unwrap().len() >= RING_FRAMES {
std::thread::sleep(Duration::from_millis(4));
continue;
}
match decoder.next_frame() {
Ok(Some(frame)) => {
nv12::nv12_to_rgb8(&frame.nv12, frame.width, frame.height, &mut rgb_scratch);
let mut bgra = Vec::with_capacity((frame.width * frame.height) as usize);
for px in rgb_scratch.chunks_exact(3) {
bgra.push(
0xff00_0000
| ((px[0] as u32) << 16)
| ((px[1] as u32) << 8)
| px[2] as u32,
);
}
shared.frames.lock().unwrap().push_back(Frame {
pts_100ns: frame.pts_100ns,
bgra,
});
}
Ok(None) => {
// End of stream: drain the soundtrack tail, then PARK. The
// thread stays alive serving seeks — a loop restart or a
// scrub back into the clip is a ~10 ms decoder seek, not a
// teardown and reopen.
if info.has_audio && !audio_eos {
loop {
if shared.stop.load(Ordering::Relaxed) {
return;
}
match decoder.next_audio() {
Ok(Some(chunk)) => video_audio().lock().unwrap().push_i16(
epoch,
&chunk.samples,
chunk.channels,
chunk.sample_rate,
),
Ok(None) => break,
Err(e) => {
log!("video: audio tail decode error: {}", e);
break;
}
}
}
audio_eos = true;
}
shared.eos.store(true, Ordering::Release);
while shared.eos.load(Ordering::Acquire) {
if shared.stop.load(Ordering::Relaxed) {
return;
}
if shared.seek_100ns.load(Ordering::Acquire) >= 0 {
break; // the top of the loop consumes it
}
std::thread::sleep(Duration::from_millis(10));
}
continue;
}
Err(e) => {
log!("video: decode error: {}", e);
return;
}
}
}
}
// ---------------------------------------------------------------------------
// Soundtrack queue (identical mixer shape to the wav player in audio.rs)
// ---------------------------------------------------------------------------
impl VideoAudio {
/// Epoch-guarded queue drop for a seek: only the owning player's decode
/// thread may flush what it queued.
fn clear_for(&mut self, epoch: u64) {
if self.owner == epoch {
self.frames.clear();
self.cursor = 0.0;
}
}
}
pub struct VideoAudio {
frames: VecDeque<(f32, f32)>,
cursor: f64,
source_rate: f64,
/// Sticky mute raised by [`stop_audio`]: the decode thread may still be
/// refilling the queue, so a plain clear would go audible again ~a
/// second later. Cleared when the next clip starts.
muted: bool,
/// Epoch of the ONE player allowed to push (0 = none). Detached decode
/// threads of dropped players carry stale epochs and are locked out.
owner: u64,
}
impl VideoAudio {
const fn new() -> Self {
Self {
frames: VecDeque::new(),
cursor: 0.0,
source_rate: 0.0,
muted: false,
owner: 0,
}
}
fn clear(&mut self) {
self.frames.clear();
self.cursor = 0.0;
}
fn buffered_secs(&self) -> f64 {
if self.source_rate <= 0.0 {
return 0.0;
}
(self.frames.len() as f64 - self.cursor).max(0.0) / self.source_rate
}
fn push_i16(&mut self, epoch: u64, samples: &[i16], channels: u16, rate: u32) {
if self.muted || self.owner != epoch {
return;
}
self.source_rate = rate as f64;
let ch = channels.max(1) as usize;
const GAIN: f32 = 0.9;
for frame in samples.chunks_exact(ch) {
let l = frame[0] as f32 / 32768.0 * GAIN;
let r = frame[ch - 1] as f32 / 32768.0 * GAIN;
self.frames.push_back((l, r));
}
}
fn mix(&mut self, output: &mut AudioBuffer, device_rate: f64) {
if self.frames.is_empty() || self.source_rate <= 0.0 || device_rate <= 0.0 {
return;
}
let step = self.source_rate / device_rate;
let channels = output.channel_count();
for frame in 0..output.frame_count() {
let index = self.cursor as usize;
if index + 1 >= self.frames.len() {
break;
}
let fraction = (self.cursor - index as f64) as f32;
let (al, ar) = self.frames[index];
let (bl, br) = self.frames[index + 1];
let l = al + (bl - al) * fraction;
let r = ar + (br - ar) * fraction;
for channel in 0..channels {
let s = if channel == 0 { l } else { r };
output.channel_mut(channel)[frame] += s;
}
self.cursor += step;
}
let consumed = self.cursor as usize;
if consumed > 0 {
self.frames.drain(..consumed.min(self.frames.len()));
self.cursor -= consumed as f64;
}
// A lone trailing frame can never be interpolated: once the queue is
// down to it the clip is over (or hard-underrun) — release it so the
// soundtrack ends instead of pinning the final sample forever.
if self.frames.len() <= 1 {
self.clear();
}
}
}
static VIDEO_AUDIO: Mutex<VideoAudio> = Mutex::new(VideoAudio::new());
fn video_audio() -> &'static Mutex<VideoAudio> {
&VIDEO_AUDIO
}
/// Mix queued video audio into the device buffer (one line in the app's
/// `cx.audio_output` closure).
pub fn mix_into(output: &mut AudioBuffer, device_rate: f64) {
if let Ok(mut audio) = video_audio().lock() {
audio.mix(output, device_rate);
}
}
/// Silences the video soundtrack immediately and keeps it silent (the
/// decode thread may still be refilling) until the next clip starts —
/// stop-button / tab-switch hook. Revoking ownership locks every live
/// decode thread out of the queue, not just muting it.
pub fn stop_audio() {
if let Ok(mut audio) = video_audio().lock() {
audio.clear();
audio.muted = true;
audio.owner = 0;
}
}
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// Both tests touch the process-global soundtrack queue.
static VIDEO_TEST_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn at_end_needs_decode_exit_and_a_drained_ring() {
let _serial = VIDEO_TEST_LOCK.lock().unwrap();
let shared = Arc::new(Shared {
frames: Mutex::new(VecDeque::new()),
stop: AtomicBool::new(false),
done: AtomicBool::new(false),
eos: AtomicBool::new(false),
seek_100ns: AtomicI64::new(-1),
});
shared.frames.lock().unwrap().push_back(Frame {
pts_100ns: 0,
bgra: vec![0xff00_0000; 4],
});
let mut player = VideoPlayer {
width: 2,
height: 2,
duration_100ns: 0,
shared: shared.clone(),
started: None,
last_pts: 0,
paused_at: None,
epoch: u64::MAX,
};
// Still decoding: never EOS, with or without buffered frames.
assert!(!player.at_end());
// Decode exited, but a due frame is still buffered: pump keeps going.
shared.done.store(true, Ordering::Release);
assert!(!player.at_end());
// The pts-0 frame is immediately due; taking it drains the ring.
assert!(player.take_due_frame().is_some());
assert!(player.at_end(), "decode done + drained ring is EOS");
// A stopped-but-undrained ring is also not EOS until taken.
assert!(player.take_due_frame().is_none());
}
#[test]
fn soundtrack_queue_ignores_stale_epochs_and_revoked_ownership() {
let _serial = VIDEO_TEST_LOCK.lock().unwrap();
{
let mut audio = video_audio().lock().unwrap();
audio.clear();
audio.muted = false;
audio.owner = 7;
audio.source_rate = 0.0;
}
// A dropped player's detached thread (stale epoch) cannot push.
video_audio()
.lock()
.unwrap()
.push_i16(6, &[1000, 1000], 2, 48_000);
assert_eq!(video_audio().lock().unwrap().frames.len(), 0);
// The owning epoch pushes fine.
video_audio()
.lock()
.unwrap()
.push_i16(7, &[1000, 1000, 2000, 2000], 2, 48_000);
assert_eq!(video_audio().lock().unwrap().frames.len(), 2);
// stop_audio revokes ownership: even the former owner is locked out.
stop_audio();
video_audio()
.lock()
.unwrap()
.push_i16(7, &[1000, 1000], 2, 48_000);
let audio = video_audio().lock().unwrap();
assert_eq!(audio.frames.len(), 0);
assert_eq!(audio.owner, 0);
assert!(audio.muted);
}
}

29
apps/browser/Cargo.toml Normal file
View file

@ -0,0 +1,29 @@
# browser — a Chrome-like browser as a plain full-window Makepad app.
#
# Chromium (CEF, libs/cef) renders ONLY the page, GPU-accelerated straight
# into an IOSurface-backed Makepad texture. All browser chrome — the tab
# strip, the toolbar with back/forward/reload, the omnibox and the menu —
# is Makepad splash UI, styled from the wm theme when hosted by
# makepad-wm (`MAKEPAD_WM_THEME_SPLASH`) and from a Chrome-dark palette otherwise.
#
# Runs standalone, and unmodified inside makepad-wm / Studio tiles via the
# shared --stdin-loop client runtime every Makepad app has.
[package]
name = "makepad-browser"
version = "0.1.0"
edition = "2021"
default-run = "browser"
[[bin]]
name = "browser"
path = "src/main.rs"
[dependencies]
makepad-widgets = { path = "../../widgets", features = ["cef"] }
makepad-cef = { path = "../../libs/cef" }
makepad-ai-services = { path = "../../libs/ai/services" }
makepad-strict-json = { path = "../../libs/strict_json" }
makepad-wm-theme = { path = "../../libs/wm_theme" }
# The window manager's vocabulary: the bar's title, the polite close.
makepad-wm-api = { path = "../../libs/wm_api" }

View file

Before

Width:  |  Height:  |  Size: 231 B

After

Width:  |  Height:  |  Size: 231 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 310 B

After

Width:  |  Height:  |  Size: 310 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 228 B

After

Width:  |  Height:  |  Size: 228 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 950 B

After

Width:  |  Height:  |  Size: 950 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 346 B

After

Width:  |  Height:  |  Size: 346 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 314 B

After

Width:  |  Height:  |  Size: 314 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 867 B

After

Width:  |  Height:  |  Size: 867 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 892 B

After

Width:  |  Height:  |  Size: 892 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 284 B

After

Width:  |  Height:  |  Size: 284 B

Before After
Before After

220
apps/browser/src/ai.rs Normal file
View file

@ -0,0 +1,220 @@
//! The browser on the desktop's AI bus.
//!
//! The current CEF wrapper mirrors navigation metadata but does not bind its
//! frame text/source callbacks, so `page` reports the active title and URL.
use makepad_ai_services::wire::{Risk, ServiceCall, ServiceManifest, ToolDef, ToolResult};
use makepad_strict_json::{self as json, Value};
pub struct PageState {
pub title: String,
pub url: String,
}
pub struct TabState {
pub title: String,
pub url: String,
pub active: bool,
}
/// The bus-facing subset of the webview. Keeping it as a trait makes the
/// closed dispatcher testable without starting CEF or a window.
pub trait BrowserTarget {
fn page(&self) -> Option<PageState>;
fn tabs(&self) -> Vec<TabState>;
fn navigate(&mut self, url: &str) -> bool;
fn new_tab(&mut self, url: &str);
}
pub fn manifest() -> ServiceManifest {
ServiceManifest::new(
"browser",
"Browser",
"The live web browser. Its read tools report the active page and all tabs; its action tools steer the active tab or open a new one.",
)
.with_tool(ToolDef::new(
"page",
"Read the active tab's displayed title and URL. The current CEF binding does not expose visible page text or source, so this tool cannot return page text.",
r#"{"type":"object","properties":{},"additionalProperties":false}"#,
Risk::Read,
))
.with_tool(ToolDef::new(
"tabs",
"Read every open tab's displayed title and URL, with the active tab marked.",
r#"{"type":"object","properties":{},"additionalProperties":false}"#,
Risk::Read,
))
.with_tool(ToolDef::new(
"navigate",
"Navigate the active tab to an http:// or https:// URL, or to about:blank.",
r#"{"type":"object","properties":{"url":{"type":"string"}},"required":["url"],"additionalProperties":false}"#,
Risk::Act,
))
.with_tool(ToolDef::new(
"new_tab",
"Open and activate a new tab at an http:// or https:// URL, or at about:blank.",
r#"{"type":"object","properties":{"url":{"type":"string"}},"required":["url"],"additionalProperties":false}"#,
Risk::Act,
))
}
/// Answer one browser call through a closed match over the four advertised
/// names. URL actions use the webview's existing navigation methods.
pub fn answer(call: &ServiceCall, target: &mut impl BrowserTarget) -> ToolResult {
match call.tool.as_str() {
"page" => {
if let Err(error) = empty_args(&call.args) {
return ToolResult::refused(&call.call_id, error);
}
match target.page() {
Some(page) => ToolResult::ok(
&call.call_id,
format!("title: {}\nurl: {}", page.title, page.url),
format!("{}{}", page.title, page.url),
),
None => ToolResult::unavailable(&call.call_id, "there is no active browser tab"),
}
}
"tabs" => {
if let Err(error) = empty_args(&call.args) {
return ToolResult::refused(&call.call_id, error);
}
let tabs = target.tabs();
let mut text = String::new();
for (index, tab) in tabs.iter().enumerate() {
if index > 0 {
text.push('\n');
}
text.push_str(if tab.active { "[active] " } else { " " });
text.push_str(&tab.title);
text.push_str("");
text.push_str(&tab.url);
}
if text.is_empty() {
text.push_str("no tabs open");
}
ToolResult::ok(&call.call_id, text, format!("{} tabs", tabs.len()))
}
"navigate" => {
let url = match url_arg(&call.args, "navigate") {
Ok(url) => url,
Err(error) => return ToolResult::refused(&call.call_id, error),
};
if target.navigate(&url) {
ToolResult::ok(&call.call_id, format!("navigating to {url}"), "navigating")
} else {
ToolResult::unavailable(&call.call_id, "there is no active browser tab")
}
}
"new_tab" => {
let url = match url_arg(&call.args, "new_tab") {
Ok(url) => url,
Err(error) => return ToolResult::refused(&call.call_id, error),
};
target.new_tab(&url);
ToolResult::ok(&call.call_id, format!("opened a new tab at {url}"), "navigating")
}
other => ToolResult::refused(
&call.call_id,
format!("browser has no tool `{other}`; it has page, tabs, navigate, new_tab"),
),
}
}
fn empty_args(args: &str) -> Result<(), String> {
let fields = object_args(args)?;
if let Some((key, _)) = fields.first() {
return Err(format!("unknown argument `{key}`"));
}
Ok(())
}
fn url_arg(args: &str, tool: &str) -> Result<String, String> {
let fields = object_args(args)?;
if let Some((key, _)) = fields.iter().find(|(key, _)| key != "url") {
return Err(format!("unknown argument `{key}`"));
}
let Some(url) = fields
.iter()
.find(|(key, _)| key == "url")
.and_then(|(_, value)| value.as_str())
else {
return Err(format!("{tool}.url must be a string"));
};
if !allowed_url(url) {
return Err(format!(
"{tool}.url must start with http:// or https://, or be exactly about:blank"
));
}
Ok(url.to_string())
}
fn object_args(args: &str) -> Result<Vec<(String, Value)>, String> {
match json::parse(args.as_bytes()) {
Ok(Value::Obj(fields)) => Ok(fields),
Ok(_) => Err("tool arguments must be a JSON object".to_string()),
Err(error) => Err(format!("invalid tool arguments: {error}")),
}
}
fn allowed_url(url: &str) -> bool {
if url == "about:blank" {
return true;
}
if url.chars().any(char::is_whitespace) || url.chars().any(char::is_control) {
return false;
}
url.strip_prefix("http://")
.or_else(|| url.strip_prefix("https://"))
.is_some_and(|rest| !rest.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use makepad_ai_services::wire::ToolOutcome;
#[derive(Default)]
struct FakeTarget {
navigated: Vec<String>,
}
impl BrowserTarget for FakeTarget {
fn page(&self) -> Option<PageState> {
None
}
fn tabs(&self) -> Vec<TabState> {
Vec::new()
}
fn navigate(&mut self, url: &str) -> bool {
self.navigated.push(url.to_string());
true
}
fn new_tab(&mut self, url: &str) {
self.navigated.push(url.to_string());
}
}
#[test]
fn browser_manifest_validates() {
manifest().validate().expect("a valid browser manifest");
}
#[test]
fn navigate_refuses_non_http_urls() {
let mut target = FakeTarget::default();
for url in ["file:///etc/passwd", "javascript:alert(1)", "data:text/plain,no"] {
let call = ServiceCall {
call_id: "c1".into(),
tool: "navigate".into(),
args: format!(r#"{{"url":"{url}"}}"#),
};
let result = answer(&call, &mut target);
assert_eq!(result.outcome, ToolOutcome::Refused);
}
assert!(target.navigated.is_empty());
}
}

View file

@ -1,7 +1,7 @@
//! The browser chrome: a custom-drawn Chrome-style tab strip (favicon +
//! title tabs with a close x, a + for a new tab) and the toolbar
//! (back / forward / reload, the omnibox with search icon and bookmark star,
//! the menu button). Hard-square Omarchy look, colours from `mod.mpb_theme`.
//! the menu button). Hard-square Omarchy look, colours from `mod.browser_theme`.
use crate::tabs::{TabId, TabSummary};
use makepad_widgets::image::DrawImage;
@ -18,62 +18,62 @@ script_mod! {
height: 36
draw_bg +: {
color: uniform(mod.mpb_theme.darker_background)
color: uniform(mod.browser_theme.darker_background)
pixel: fn() {
return self.color
}
}
draw_tab +: {
color: uniform(mod.mpb_theme.darker_background)
color: uniform(mod.browser_theme.darker_background)
pixel: fn() {
return self.color
}
}
draw_tab_hover +: {
color: uniform(mod.mpb_theme.dark_background)
color: uniform(mod.browser_theme.dark_background)
pixel: fn() {
return self.color
}
}
draw_tab_active +: {
color: uniform(mod.mpb_theme.background)
color: uniform(mod.browser_theme.background)
pixel: fn() {
return self.color
}
}
draw_sep +: {
color: uniform(mod.mpb_theme.muted)
color: uniform(mod.browser_theme.muted)
pixel: fn() {
return self.color
}
}
draw_text +: {
color: mod.mpb_theme.foreground
color: mod.browser_theme.foreground
text_style: theme.font_regular{
font_size: 9.5
}
}
draw_text_dim +: {
color: mod.mpb_theme.dark_foreground
color: mod.browser_theme.dark_foreground
text_style: theme.font_regular{
font_size: 9.5
}
}
draw_close +: {
svg: crate_resource("self:resources/icons/close.svg")
color: mod.mpb_theme.dark_foreground
color: mod.browser_theme.dark_foreground
}
draw_close_active +: {
svg: crate_resource("self:resources/icons/close.svg")
color: mod.mpb_theme.foreground
color: mod.browser_theme.foreground
}
draw_plus +: {
svg: crate_resource("self:resources/icons/plus.svg")
color: mod.mpb_theme.foreground
color: mod.browser_theme.foreground
}
draw_globe +: {
svg: crate_resource("self:resources/icons/globe.svg")
color: mod.mpb_theme.dark_foreground
color: mod.browser_theme.dark_foreground
}
}
@ -87,14 +87,14 @@ script_mod! {
align: Align{x: 0.5 y: 0.5}
icon_walk: Walk{width: 16 height: 16}
draw_icon +: {
color: mod.mpb_theme.foreground
color: mod.browser_theme.foreground
}
draw_bg +: {
border_radius: 0.0
border_size: 0.0
color: #00000000
color_hover: mod.mpb_theme.lighter_background
color_down: mod.mpb_theme.muted
color_hover: mod.browser_theme.lighter_background
color_down: mod.browser_theme.muted
color_focus: #00000000
}
}
@ -109,8 +109,8 @@ script_mod! {
padding: Inset{left: 14 right: 14 top: 0 bottom: 0}
margin: Inset{left: 0 right: 0 top: 0 bottom: 0}
draw_text +: {
color: mod.mpb_theme.foreground
color_hover: mod.mpb_theme.bright_foreground
color: mod.browser_theme.foreground
color_hover: mod.browser_theme.bright_foreground
text_style: theme.font_regular{
font_size: 10
}
@ -119,8 +119,8 @@ script_mod! {
border_radius: 0.0
border_size: 0.0
color: #00000000
color_hover: mod.mpb_theme.lighter_background
color_down: mod.mpb_theme.muted
color_hover: mod.browser_theme.lighter_background
color_down: mod.browser_theme.muted
}
}
@ -137,7 +137,7 @@ script_mod! {
padding: Inset{left: 6 right: 6 top: 4 bottom: 4}
spacing: 2
draw_bg +: {
color: mod.mpb_theme.background
color: mod.browser_theme.background
}
back_btn := MpToolButton{
@ -162,14 +162,14 @@ script_mod! {
padding: Inset{left: 10 right: 2 top: 0 bottom: 0}
spacing: 6
draw_bg +: {
color: mod.mpb_theme.darker_background
color: mod.browser_theme.darker_background
}
Icon{
margin: Inset{top: 9 bottom: 0 left: 0 right: 0}
icon_walk: Walk{width: 14 height: 14}
draw_icon +: {
svg: crate_resource("self:resources/icons/search.svg")
color: mod.mpb_theme.dark_foreground
color: mod.browser_theme.dark_foreground
}
}
omnibox := TextInputFlat{
@ -197,14 +197,14 @@ script_mod! {
border_color_empty: #00000000
}
draw_cursor +: {
color: mod.mpb_theme.bright_foreground
color: mod.browser_theme.bright_foreground
}
draw_text +: {
color: mod.mpb_theme.foreground
color_hover: mod.mpb_theme.foreground
color_focus: mod.mpb_theme.bright_foreground
color_empty: mod.mpb_theme.dark_foreground
color_empty_hover: mod.mpb_theme.dark_foreground
color: mod.browser_theme.foreground
color_hover: mod.browser_theme.foreground
color_focus: mod.browser_theme.bright_foreground
color_empty: mod.browser_theme.dark_foreground
color_empty_hover: mod.browser_theme.dark_foreground
text_style: theme.font_regular{
font_size: 10.5
}
@ -217,7 +217,7 @@ script_mod! {
icon_walk: Walk{width: 15 height: 15}
draw_icon +: {
svg: crate_resource("self:resources/icons/star.svg")
color: mod.mpb_theme.dark_foreground
color: mod.browser_theme.dark_foreground
}
}
}

View file

@ -1,12 +1,14 @@
//! mpbrowser: a Chrome-like browser as a plain full-window Makepad app.
//! browser: a Chrome-like browser as a plain full-window Makepad app.
//! CEF renders the page (GPU-accelerated into a shared IOSurface texture);
//! every bit of chrome is Makepad. Runs standalone or inside makepad-wm /
//! Studio tiles via the shared --stdin-loop client runtime.
pub use makepad_widgets;
use makepad_ai_services::port::{AiServicePort, PortEvent};
use makepad_cef::BootstrapResult;
use makepad_widgets::*;
mod ai;
mod chrome;
mod tabs;
mod theme;
@ -27,7 +29,7 @@ script_mod! {
ui: Root{
main_window := Window{
window.inner_size: vec2(1280, 860)
window.title: "mpbrowser"
window.title: "browser"
body +: {
flow: Overlay
View{
@ -52,14 +54,14 @@ script_mod! {
flow: Down
padding: Inset{top: 4 bottom: 4 left: 0 right: 0}
draw_bg +: {
color: mod.mpb_theme.background
color: mod.browser_theme.background
}
menu_new_tab := MpMenuItem{text: "New tab"}
menu_close_tab := MpMenuItem{text: "Close tab"}
menu_reload := MpMenuItem{text: "Reload"}
Hr{}
menu_gpu := MpMenuItem{text: "chrome://gpu"}
menu_about := MpMenuItem{text: "About mpbrowser"}
menu_about := MpMenuItem{text: "About browser"}
}
}
}
@ -155,8 +157,46 @@ pub struct App {
focus_frame: NextFrame,
#[rust]
focus_retries: u32,
#[rust]
ai_port: Option<AiServicePort>,
#[rust]
ai_context: String,
}
struct AiBrowserTarget<'a> {
cx: &'a mut Cx,
webview: &'a mut WebView,
}
impl ai::BrowserTarget for AiBrowserTarget<'_> {
fn page(&self) -> Option<ai::PageState> {
let info = self.webview.active_info();
info.id.map(|_| ai::PageState {
title: info.title,
url: info.url,
})
}
fn tabs(&self) -> Vec<ai::TabState> {
self.webview
.ai_tabs()
.into_iter()
.map(|(title, url, active)| ai::TabState { title, url, active })
.collect()
}
fn navigate(&mut self, url: &str) -> bool {
if self.webview.active_id().is_none() {
return false;
}
self.webview.navigate(self.cx, url);
true
}
fn new_tab(&mut self, url: &str) {
self.webview.new_tab(self.cx, url, true);
}
}
impl App {
fn with_webview<R>(&self, cx: &mut Cx, f: impl FnOnce(&mut Cx, &mut WebView) -> R) -> Option<R> {
@ -221,6 +261,8 @@ impl App {
.with_webview(cx, |_cx, wv| (wv.summaries(), wv.active_info()))
.unwrap_or_default();
self.refresh_ai_context(&info);
if let Some(mut strip) = self.ui.widget(cx, ids!(tab_strip)).borrow_mut::<TabStrip>() {
strip.set_tabs(cx, summaries);
}
@ -260,16 +302,18 @@ impl App {
});
let title = if info.title.is_empty() {
"mpbrowser".to_string()
"browser".to_string()
} else {
format!("{}mpbrowser", info.title)
format!("{}browser", info.title)
};
self.ui.window(cx, ids!(main_window)).set_title(cx, &title);
// The bar shows the page title too when the window manager hosts us.
makepad_wm_api::set_title(cx, &title);
if info.render_mode != self.reported_mode && info.render_mode != "None" {
self.reported_mode = info.render_mode.clone();
log!(
"mpbrowser: page rendering is {} (accelerated frames so far: {}, last blit {}us)",
"browser: page rendering is {} (accelerated frames so far: {}, last blit {}us)",
info.render_mode,
info.accelerated_frames,
info.last_blit_micros
@ -278,6 +322,63 @@ impl App {
self.ui.redraw(cx);
}
fn refresh_ai_context(&mut self, info: &webview::ActiveInfo) {
let context = if info.id.is_some() {
format!("active tab: {}{}", info.title, info.url)
} else {
"no active tab".to_string()
};
if context == self.ai_context {
return;
}
self.ai_context = context.clone();
if let Some(port) = self.ai_port.as_ref() {
port.set_context(&context);
}
}
fn refresh_ai_context_from_webview(&mut self, cx: &mut Cx) {
let info = self
.with_webview(cx, |_cx, webview| webview.active_info())
.unwrap_or_default();
self.refresh_ai_context(&info);
}
fn drain_ai_port(&mut self, cx: &mut Cx, event: &Event) {
let events = match self.ai_port.as_mut() {
Some(port) => port.handle_event(cx, event),
None => return,
};
for event in events {
match event {
PortEvent::Registered(endpoint) => {
log!("browser: AI service registered as {}", endpoint.as_str());
self.ai_context.clear();
self.refresh_ai_context_from_webview(cx);
}
PortEvent::Call(call) => {
let result = self
.with_webview(cx, |cx, webview| {
let mut target = AiBrowserTarget { cx, webview };
ai::answer(&call, &mut target)
})
.unwrap_or_else(|| {
makepad_ai_services::wire::ToolResult::unavailable(
&call.call_id,
"the browser view is not ready",
)
});
if let Some(port) = self.ai_port.as_ref() {
port.reply(result);
}
self.refresh_chrome(cx);
}
PortEvent::Cancel { .. } | PortEvent::ChatOpen { .. } => {}
PortEvent::Subscribe { .. } | PortEvent::Unsubscribe { .. } => {}
}
}
}
fn navigate_from_omnibox(&mut self, cx: &mut Cx, text: &str) {
let url = tabs::resolve_omnibox(text);
if url.is_empty() {
@ -361,14 +462,20 @@ impl App {
impl MatchEvent for App {
fn handle_startup(&mut self, cx: &mut Cx) {
// A warm-pool standby is not a running Browser: its service opens on
// `WmEvent::Adopted`, never while dormant — the assistant must not
// steer a page nobody can see.
if !makepad_wm_api::warm_start() {
self.ai_port = AiServicePort::open(cx, ai::manifest());
}
match makepad_cef::startup_phases() {
Some((bundle_ms, exec_gap_ms)) => log!(
"mpbrowser: window up at {} ms after main (app bundle prepared in {} ms, exec-to-main gap {} ms)",
"browser: window up at {} ms after main (app bundle prepared in {} ms, exec-to-main gap {} ms)",
uptime_ms(),
bundle_ms,
exec_gap_ms
),
None => log!("mpbrowser: window up at {} ms after main", uptime_ms()),
None => log!("browser: window up at {} ms after main", uptime_ms()),
}
let urls = initial_urls();
if urls.is_empty() {
@ -463,8 +570,8 @@ impl MatchEvent for App {
.with_webview(cx, |_cx, wv| wv.active_info())
.unwrap_or_default();
let html = format!(
"<!doctype html><title>About mpbrowser</title><body style=\"background:{bg};color:{fg};font:14px -apple-system,Helvetica,sans-serif;padding:32px\">\
<h2 style=\"font-weight:500\">mpbrowser</h2>\
"<!doctype html><title>About browser</title><body style=\"background:{bg};color:{fg};font:14px -apple-system,Helvetica,sans-serif;padding:32px\">\
<h2 style=\"font-weight:500\">browser</h2>\
<p>Makepad chrome, Chromium Embedded Framework {cef} page rendering.</p>\
<p>Page rendering path: <b>{mode}</b> (accelerated frames: {frames}, last GPU blit: {blit}µs)</p>\
<p>ANGLE backend: {angle}</p></body>",
@ -527,8 +634,8 @@ impl AppMain for App {
fn script_mod(vm: &mut ScriptVm) -> ScriptValue {
crate::makepad_widgets::script_mod(vm);
// The family theme bridge retints the stock widgets from the WM
// theme; the chrome roles go into mod.mpb_theme.
mp_theme::apply(vm);
// theme; the chrome roles go into mod.browser_theme.
makepad_wm_theme::apply(vm);
palette().apply(vm);
chrome::script_mod(vm);
webview::script_mod(vm);
@ -536,6 +643,22 @@ impl AppMain for App {
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
// The window manager asked politely (SUPER+W): go now, ahead of the
// kill that follows its grace. A warm-pool browser needs no waking —
// CEF paints nothing until a tile presents it.
if let Event::Custom(json) = event {
match makepad_wm_api::WmEvent::parse(json) {
Some(makepad_wm_api::WmEvent::Adopted) if self.ai_port.is_none() => {
self.ai_port = AiServicePort::open(cx, ai::manifest());
}
_ => {}
}
if let Some(makepad_wm_api::WmEvent::CloseRequested) = makepad_wm_api::WmEvent::parse(json) {
cx.quit();
return;
}
}
self.drain_ai_port(cx, event);
if let Event::KeyDown(ke) = event {
if self.handle_shortcut(cx, ke) {
return;
@ -622,5 +745,5 @@ pub fn app_main() {
#[cfg(any(target_arch = "wasm32", target_os = "android", target_env = "ohos"))]
pub fn app_main() {
panic!("mpbrowser is desktop-only");
panic!("browser is desktop-only");
}

View file

@ -1,14 +1,14 @@
//! The browser-chrome palette. Theming lives in splash: the chrome reads
//! `mod.mpb_theme.*` (tab strip, toolbar, omnibox, icon roles), which this
//! `mod.browser_theme.*` (tab strip, toolbar, omnibox, icon roles), which this
//! module evaluates into the VM before the UI modules.
//!
//! Under makepad-wm the roles come from the WM's theme.splash
//! (`MPWM_THEME_SPLASH`, line-scanned by `mp_theme`, the family bridge);
//! (`MAKEPAD_WM_THEME_SPLASH`, line-scanned by `makepad_wm_theme`, the family bridge);
//! standalone runs get Chrome's own dark palette.
use makepad_widgets::*;
/// Chrome-dark roles, keyed like the mpwm theme so one mapping serves both.
/// Chrome-dark roles, keyed like the wm theme so one mapping serves both.
#[derive(Clone, Debug)]
pub struct Palette {
/// Tab strip background (the "frame").
@ -43,10 +43,10 @@ impl Palette {
}
}
/// The WM palette when mpwm exported one, else Chrome dark.
/// The WM palette when wm exported one, else Chrome dark.
pub fn current() -> Self {
let fallback = Self::chrome_dark();
let Some(p) = mp_theme::current() else {
let Some(p) = makepad_wm_theme::current() else {
return fallback;
};
Self {
@ -63,11 +63,11 @@ impl Palette {
}
}
/// The `mod.mpb_theme = {...}` splash source. Runtime-evaluated, so plain
/// The `mod.browser_theme = {...}` splash source. Runtime-evaluated, so plain
/// `#hex` (the `#x` escape is a proc-macro-only hazard).
pub fn splash_source(&self) -> String {
format!(
"mod.mpb_theme = {{\n\
"mod.browser_theme = {{\n\
\x20 darker_background: {}\n\
\x20 background: {}\n\
\x20 dark_background: {}\n\
@ -93,13 +93,13 @@ impl Palette {
)
}
/// Evaluate `mod.mpb_theme` into the VM. Call after
/// Evaluate `mod.browser_theme` into the VM. Call after
/// `makepad_widgets::script_mod(vm)` and before the chrome modules.
pub fn apply(&self, vm: &mut ScriptVm) {
let script_mod_id = ScriptMod {
cargo_manifest_path: env!("CARGO_MANIFEST_DIR").to_string(),
module_path: "mpb_theme".to_string(),
file: "mpb_theme.splash".to_string(),
module_path: "browser_theme".to_string(),
file: "browser_theme.splash".to_string(),
line: 0,
column: 0,
code: self.splash_source(),
@ -107,7 +107,7 @@ impl Palette {
};
vm.eval(script_mod_id);
for e in vm.take_errors() {
log!("mpbrowser theme: {}", e);
log!("browser theme: {}", e);
}
}
@ -129,7 +129,7 @@ impl Palette {
.c{{display:flex;height:100%;align-items:center;justify-content:center;\
flex-direction:column;gap:10px}}.n{{font-size:28px;letter-spacing:1px;color:{fgb}}}\
.s{{color:{fgd}}}</style></head><body><div class=c>\
<div class=n>mpbrowser</div><div class=s>Type a URL or search in the box above</div>\
<div class=n>browser</div><div class=s>Type a URL or search in the box above</div>\
</div></body></html>",
bg = self.darker_background,
fg = self.foreground,

View file

@ -11,9 +11,9 @@
use crate::tabs::{TabId, TabModel, TabSummary};
// The surface policy is the stock Browser widget's — one source of truth for
// how a CEF page survives a resize.
use makepad_widgets::browser::{
needs_new_surface, surface_alloc, Browser as BrowserKeys, RESIZE_INTERVAL, SETTLE,
};
#[cfg(target_os = "macos")]
use makepad_widgets::browser::{needs_new_surface, surface_alloc, SETTLE};
use makepad_widgets::browser::{Browser as BrowserKeys, RESIZE_INTERVAL};
use makepad_widgets::image::DrawImage;
use makepad_widgets::*;
@ -27,13 +27,13 @@ script_mod! {
width: Fill
height: Fill
draw_empty +: {
color: uniform(mod.mpb_theme.darker_background)
color: uniform(mod.browser_theme.darker_background)
pixel: fn() {
return self.color
}
}
draw_status +: {
color: mod.mpb_theme.dark_foreground
color: mod.browser_theme.dark_foreground
text_style: theme.font_regular{
font_size: 11
}
@ -102,11 +102,11 @@ pub struct WebView {
pump_started: bool,
}
/// Env-gated resize tracing (`MPB_TRACE=1`): timestamps + sizes on every
/// Env-gated resize tracing (`MAKEPAD_BROWSER_TRACE=1`): timestamps + sizes on every
/// draw, resize and target swap. Debug rig — not for committing.
pub fn trace_on() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("MPB_TRACE").is_some())
*ON.get_or_init(|| std::env::var_os("MAKEPAD_BROWSER_TRACE").is_some())
}
macro_rules! trace {
@ -177,6 +177,18 @@ impl WebView {
self.tabs.summaries()
}
/// Title, URL and active marker for the browser's AI `tabs` tool.
pub fn ai_tabs(&self) -> Vec<(String, String, bool)> {
self.tabs
.tabs
.iter()
.enumerate()
.map(|(index, tab)| {
(tab.display_title(), tab.url.clone(), index == self.tabs.active)
})
.collect()
}
pub fn active_info(&self) -> ActiveInfo {
let Some(tab) = self.tabs.active() else {
return ActiveInfo::default();
@ -520,7 +532,7 @@ impl WebView {
}
if !self.first_frame_logged {
self.first_frame_logged = true;
log!("mpbrowser: first page frame at {} ms", crate::uptime_ms());
log!("browser: first page frame at {} ms", crate::uptime_ms());
}
}
let generation = browser.nav_generation();
@ -705,7 +717,7 @@ impl Widget for WebView {
Ok(()) => {
self.cef_ready = true;
log!(
"mpbrowser: CEF {} initialized at {} ms",
"browser: CEF {} initialized at {} ms",
makepad_cef::CEF_VERSION,
crate::uptime_ms()
);

View file

@ -18,7 +18,7 @@ gltf = ["dep:makepad-fab-loader-gltf"]
makepad-widgets = { path = "../../widgets", version = "2.0.0" }
makepad-fab-shell = { path = "../../libs/fab", version = "0.1.0" }
makepad-fab-loader-gltf = { path = "loaders/gltf", version = "0.1.0", optional = true }
makepad-video = { path = "../../platform/video", version = "1.0.0" }
makepad-video = { package = "makepad-platform-video", path = "../../platform/video", version = "1.0.0" }
makepad-zune-png = { path = "../../libs/zune/zune-png", version = "0.5.2" }
[dev-dependencies]

View file

@ -1,5 +1,5 @@
//! Encode a directory of `frame_%06d.png` files into an H.264 mp4 via the
//! platform hardware encoder (`makepad-video` / VideoToolbox on macOS).
//! platform hardware encoder (`makepad-platform-video` / VideoToolbox on macOS).
//!
//! ```text
//! frames_to_mp4 <dir> <out.mp4> [--fps 24] [--start N] [--end M] [--bitrate BPS] [--crf N]

18
apps/fabric/Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "makepad-fabric"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
description = "Fabric: a photo in, a fitted sewing pattern out — body model, measurements, draft, cut sheet"
[dependencies]
# The window manager's vocabulary: the bar's title and the polite close.
makepad-wm-api = { path = "../../libs/wm_api" }
makepad-widgets = { path = "../../widgets" }
makepad-fabric-measure = { path = "../../libs/fabric/measure" }
makepad-fabric-draft = { path = "../../libs/fabric/draft" }
# The body model runs in-process (Metal on the Mac, CUDA on a box); the
# hub does install, licence acknowledgement and weight location.
makepad-ai-body = { path = "../../libs/ai/models/body" }
makepad-ai-hub = { path = "../../libs/ai/hub", default-features = false, features = ["local", "body-native"] }
makepad-ai-hub-ui = { path = "../../libs/ai/hub_ui" }

View file

@ -0,0 +1,539 @@
use makepad_fabric_measure::{BodyMesh, Line, Measured, Ring};
use makepad_widgets::*;
use std::sync::Arc;
script_mod! {
use mod.prelude.widgets_internal.*
use mod.widgets.*
set_type_default() do #(DrawBodyPoint::script_shader(vm)) {
..mod.draw.DrawQuad
pixel: fn() {
let d = length(self.pos - vec2(0.5, 0.5))
let a = clamp((0.5 - d) * 7.0, 0.0, 1.0)
let near = #x80e7ff
let far = #x27435d
let c = far.mix(near, 1.0 - self.depth)
return vec4(c.xyz * a, a)
}
}
set_type_default() do #(DrawFabricLine::script_shader(vm)) {
..mod.draw.DrawQuad
pixel: fn() {
// A line as a distance field inside its bounding quad. The
// endpoints are LOCAL to the quad (the turtle may still shift
// rect_pos after the instance is written), like the chart's
// segment shader.
let p = self.pos * self.rect_size
let ab = self.p1 - self.p0
let t = clamp(dot(p - self.p0, ab) / max(dot(ab, ab), 0.0001), 0.0, 1.0)
let d = length(p - (self.p0 + ab * t))
let aa = 1.0 - smoothstep(self.half_width - 0.6, self.half_width + 0.6, d)
let alpha = aa * self.color.w
return vec4(self.color.xyz * alpha, alpha)
}
}
mod.widgets.FabricBodyViewBase = #(FabricBodyView::register_widget(vm))
mod.widgets.FabricBodyView = set_type_default() do mod.widgets.FabricBodyViewBase {
width: Fill
height: Fill
draw_bg +: {color: #x11161d}
draw_point +: {}
draw_line +: {}
draw_text +: {
color: #x9aa8b7
text_style: theme.font_regular{font_size: 9.0}
}
}
}
#[derive(Script, ScriptHook)]
#[repr(C)]
pub struct DrawBodyPoint {
#[deref]
draw_super: DrawQuad,
#[live]
depth: f32,
}
#[derive(Script, ScriptHook)]
#[repr(C)]
pub struct DrawFabricLine {
#[deref]
draw_super: DrawQuad,
#[live]
pub color: Vec4f,
#[live]
p0: Vec2f,
#[live]
p1: Vec2f,
#[live]
half_width: f32,
}
impl DrawFabricLine {
pub fn segment(&mut self, cx: &mut Cx2d, from: DVec2, to: DVec2, width: f64) {
if (to - from).length() < 0.01 {
return;
}
let half = width * 0.5;
let pad = half + 1.0;
let min = dvec2(from.x.min(to.x) - pad, from.y.min(to.y) - pad);
let max = dvec2(from.x.max(to.x) + pad, from.y.max(to.y) + pad);
self.p0 = v2f(from - min);
self.p1 = v2f(to - min);
self.half_width = half as f32;
self.draw_abs(cx, Rect { pos: min, size: max - min });
}
}
fn v2f(value: DVec2) -> Vec2f {
Vec2f {
x: value.x as f32,
y: value.y as f32,
}
}
#[derive(Clone, Copy)]
struct BodyDrag {
from: DVec2,
yaw: f64,
pitch: f64,
pan: DVec2,
panning: bool,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct BodyPoseMapping {
ring_vertices: Vec<Vec<usize>>,
line_vertices: Vec<[usize; 2]>,
}
pub(crate) fn map_measurements_to_vertices(
mesh: &BodyMesh,
measured: &Measured,
) -> BodyPoseMapping {
let nearest = |point| nearest_vertex_index(&mesh.vertices, measured.scale, point);
BodyPoseMapping {
ring_vertices: measured
.rings
.iter()
.map(|ring| ring.points.iter().copied().map(nearest).collect())
.collect(),
line_vertices: measured
.lines
.iter()
.map(|line| [nearest(line.from), nearest(line.to)])
.collect(),
}
}
fn nearest_vertex_index(vertices: &[[f32; 3]], scale: f32, point: [f32; 3]) -> usize {
vertices
.iter()
.enumerate()
.map(|(index, vertex)| {
let dx = vertex[0] * scale - point[0];
let dy = vertex[1] * scale - point[1];
let dz = vertex[2] * scale - point[2];
(index, dx * dx + dy * dy + dz * dz)
})
.min_by(|left, right| left.1.total_cmp(&right.1))
.map(|(index, _)| index)
.unwrap_or(0)
}
fn mirror_x(value: f64, mirrored: bool) -> f64 {
if mirrored { -value } else { value }
}
fn bounds(points: &[[f32; 3]], scale: f32) -> Option<([f32; 3], f32)> {
let mut min = [f32::INFINITY; 3];
let mut max = [f32::NEG_INFINITY; 3];
for point in points {
for axis in 0..3 {
let value = point[axis] * scale;
min[axis] = min[axis].min(value);
max[axis] = max[axis].max(value);
}
}
if !min[0].is_finite() {
return None;
}
let centre = [
(min[0] + max[0]) * 0.5,
(min[1] + max[1]) * 0.5,
(min[2] + max[2]) * 0.5,
];
let dx = max[0] - min[0];
let dy = max[1] - min[1];
let dz = max[2] - min[2];
let radius = (dx * dx + dy * dy + dz * dz).sqrt().max(1.0) * 0.5;
Some((centre, radius))
}
#[derive(Script, ScriptHook, Widget)]
pub struct FabricBodyView {
#[uid]
uid: WidgetUid,
#[source]
source: ScriptObjectRef,
#[walk]
walk: Walk,
#[layout]
layout: Layout,
#[redraw]
#[area]
area: Area,
#[live]
draw_bg: DrawColor,
#[live]
draw_point: DrawBodyPoint,
#[live]
draw_line: DrawFabricLine,
#[live]
draw_text: DrawText,
#[rust]
mesh: Option<Arc<BodyMesh>>,
#[rust]
posed: Option<Arc<Vec<[f32; 3]>>>,
#[rust]
rings: Vec<Ring>,
#[rust]
lines: Vec<Line>,
#[rust]
pose_mapping: BodyPoseMapping,
#[rust]
mesh_scale: f32,
#[rust]
centre: [f32; 3],
#[rust]
radius: f32,
#[rust(0.35)]
yaw: f64,
#[rust(-0.06)]
pitch: f64,
#[rust(1.0)]
zoom: f64,
#[rust]
pan: DVec2,
#[rust]
drag: Option<BodyDrag>,
#[rust(true)]
mirrored: bool,
}
impl FabricBodyView {
pub fn set_body(
&mut self,
cx: &mut Cx,
mesh: Arc<BodyMesh>,
measured: &Measured,
pose_mapping: BodyPoseMapping,
) {
self.mesh_scale = measured.scale;
self.rings = measured.rings.clone();
self.lines = measured.lines.clone();
self.pose_mapping = pose_mapping;
if self.posed.is_none() {
self.fit_bounds(&mesh.vertices);
self.reset_camera();
}
self.mesh = Some(mesh);
self.redraw(cx);
}
pub fn set_pose(&mut self, cx: &mut Cx, posed: Option<Arc<Vec<[f32; 3]>>>) {
match posed {
Some(posed)
if self
.mesh
.as_ref()
.is_some_and(|mesh| mesh.vertices.len() == posed.len()) =>
{
if self.posed.is_none() {
self.fit_bounds(posed.as_slice());
self.reset_camera();
}
self.posed = Some(posed);
}
_ => {
self.posed = None;
if let Some(fit) = self
.mesh
.as_ref()
.and_then(|mesh| bounds(&mesh.vertices, self.mesh_scale))
{
(self.centre, self.radius) = fit;
self.reset_camera();
}
}
}
self.redraw(cx);
}
pub fn set_mirrored(&mut self, cx: &mut Cx, mirrored: bool) {
self.mirrored = mirrored;
self.redraw(cx);
}
fn fit_bounds(&mut self, points: &[[f32; 3]]) {
if let Some((centre, radius)) = bounds(points, self.mesh_scale) {
self.centre = centre;
self.radius = radius;
}
}
fn reset_camera(&mut self) {
self.yaw = 0.35;
self.pitch = -0.06;
self.zoom = 1.0;
self.pan = dvec2(0.0, 0.0);
}
fn project(&self, point: [f32; 3], mesh_point: bool, rect: Rect) -> Option<(DVec2, f32)> {
let scale = if mesh_point { self.mesh_scale } else { 1.0 } as f64;
let x = point[0] as f64 * scale - self.centre[0] as f64;
let y = point[1] as f64 * scale - self.centre[1] as f64;
let z = point[2] as f64 * scale - self.centre[2] as f64;
let (sy, cy) = self.yaw.sin_cos();
let (sp, cp) = self.pitch.sin_cos();
let rx = mirror_x(cy * x + sy * z, self.mirrored);
let rz = -sy * x + cy * z;
let ry = cp * y - sp * rz;
let rz = sp * y + cp * rz;
let fov = 35.0_f64.to_radians();
let fit_distance = self.radius as f64 / (fov * 0.5).tan() * 1.2;
let camera_z = fit_distance / self.zoom.max(0.08) - rz;
if camera_z <= 0.01 {
return None;
}
let focal = rect.size.y.max(1.0) * 0.5 / (fov * 0.5).tan();
let centre = rect.pos + rect.size * 0.5 + self.pan;
let screen = dvec2(centre.x + focal * rx / camera_z, centre.y - focal * ry / camera_z);
Some((screen, camera_z as f32))
}
fn projected_polyline(&self, points: &[[f32; 3]], rect: Rect) -> Vec<(DVec2, f32)> {
points
.iter()
.filter_map(|point| self.project(*point, false, rect))
.collect()
}
}
impl Widget for FabricBodyView {
fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep {
let rect = cx.walk_turtle(walk);
self.draw_bg.draw_abs(cx, rect);
cx.push_clip_rect(rect);
let Some(mesh) = self.mesh.as_ref() else {
self.draw_text.color = Vec4f {
x: 0.48,
y: 0.55,
z: 0.62,
w: 1.0,
};
self.draw_text.draw_abs(
cx,
rect.pos + rect.size * 0.5 - dvec2(70.0, 5.0),
"drop a photo to start",
);
cx.pop_clip_rect();
cx.add_aligned_rect_area(&mut self.area, rect);
return DrawStep::done();
};
let posed = self
.posed
.as_deref()
.filter(|posed| posed.len() == mesh.vertices.len())
.map(Vec::as_slice);
let display_vertices = posed.unwrap_or(mesh.vertices.as_slice());
let mut points: Vec<(DVec2, f32)> = display_vertices
.iter()
.filter_map(|point| self.project(*point, true, rect))
.collect();
points.sort_by(|a, b| b.1.total_cmp(&a.1));
let min_depth = points.iter().map(|point| point.1).fold(f32::INFINITY, f32::min);
let max_depth = points
.iter()
.map(|point| point.1)
.fold(f32::NEG_INFINITY, f32::max);
let depth_span = (max_depth - min_depth).max(0.001);
self.draw_point.begin_many_instances(cx);
for (point, depth) in points {
self.draw_point.depth = (depth - min_depth) / depth_span;
self.draw_point.draw_abs(
cx,
Rect {
pos: point - dvec2(1.25, 1.25),
size: dvec2(2.5, 2.5),
},
);
}
self.draw_point.end_many_instances(cx);
let rings: Vec<(String, Vec<(DVec2, f32)>)> = self
.rings
.iter()
.enumerate()
.map(|(ring_index, ring)| {
let points = match (
posed,
self.pose_mapping.ring_vertices.get(ring_index),
) {
(Some(posed), Some(indices)) if indices.len() == ring.points.len() => indices
.iter()
.filter_map(|&index| self.project(*posed.get(index)?, true, rect))
.collect(),
_ => self.projected_polyline(&ring.points, rect),
};
(ring.key.replace('_', " "), points)
})
.collect();
let lines: Vec<(DVec2, DVec2)> = self
.lines
.iter()
.enumerate()
.filter_map(|(line_index, line)| {
let (from, to, mesh_points) = match (
posed,
self.pose_mapping.line_vertices.get(line_index),
) {
(Some(posed), Some([from, to])) =>
(*posed.get(*from)?, *posed.get(*to)?, true),
_ => (line.from, line.to, false),
};
Some((
self.project(from, mesh_points, rect)?.0,
self.project(to, mesh_points, rect)?.0,
))
})
.collect();
self.draw_line.begin_many_instances(cx);
self.draw_line.color = Vec4f {
x: 1.0,
y: 0.38,
z: 0.18,
w: 0.92,
};
for (_, points) in &rings {
for pair in points.windows(2) {
self.draw_line.segment(cx, pair[0].0, pair[1].0, 1.5);
}
if let (Some(first), Some(last)) = (points.first(), points.last()) {
self.draw_line.segment(cx, last.0, first.0, 1.5);
}
}
self.draw_line.color = Vec4f {
x: 0.42,
y: 0.78,
z: 1.0,
w: 0.9,
};
for (from, to) in lines {
self.draw_line.segment(cx, from, to, 1.25);
}
self.draw_line.end_many_instances(cx);
self.draw_text.color = Vec4f {
x: 1.0,
y: 0.66,
z: 0.48,
w: 1.0,
};
for (key, points) in rings {
if let Some(front) = points.iter().min_by(|a, b| a.1.total_cmp(&b.1)) {
self.draw_text
.draw_abs(cx, front.0 + dvec2(4.0, -5.0), &key);
}
}
cx.pop_clip_rect();
cx.add_aligned_rect_area(&mut self.area, rect);
DrawStep::done()
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) {
match event.hits(cx, self.area) {
Hit::FingerDown(event) if event.device.is_primary_hit() => {
if event.tap_count >= 2 {
self.reset_camera();
self.redraw(cx);
return;
}
self.drag = Some(BodyDrag {
from: event.abs,
yaw: self.yaw,
pitch: self.pitch,
pan: self.pan,
panning: event.modifiers.shift,
});
}
Hit::FingerMove(event) => {
if let Some(drag) = self.drag {
let delta = event.abs - drag.from;
if drag.panning {
self.pan = drag.pan + delta;
} else {
self.yaw = drag.yaw - delta.x * 0.008;
self.pitch = (drag.pitch + delta.y * 0.007).clamp(-1.35, 1.35);
}
self.redraw(cx);
}
}
Hit::FingerUp(_) => self.drag = None,
Hit::FingerScroll(event) => {
self.zoom = (self.zoom * (-event.scroll.y * 0.004).exp()).clamp(0.15, 12.0);
self.redraw(cx);
}
Hit::FingerHoverIn(_) | Hit::FingerHoverOver(_) => {
cx.set_cursor(MouseCursor::Grab);
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nearest_vertex_mapping_uses_scaled_rest_mesh_positions() {
let mesh = BodyMesh {
vertices: vec![[0.0, 0.0, 0.0], [4.0, 0.0, 0.0], [9.0, 0.0, 0.0]],
faces: Vec::new(),
landmarks: None,
};
let measured = Measured {
values: makepad_fabric_measure::Measurements::sample(),
scale: 2.0,
rings: vec![Ring {
key: "test_ring",
y_cm: 0.0,
points: vec![[0.2, 0.0, 0.0], [7.5, 0.0, 0.0]],
skin_perimeter_cm: 0.0,
tape_perimeter_cm: 0.0,
}],
lines: vec![Line {
key: "test_line",
from: [7.5, 0.0, 0.0],
to: [17.0, 0.0, 0.0],
}],
};
let mapping = map_measurements_to_vertices(&mesh, &measured);
assert_eq!(mapping.ring_vertices, vec![vec![0, 1]]);
assert_eq!(mapping.line_vertices, vec![[1, 2]]);
}
#[test]
fn mirror_transform_only_flips_horizontal_view_axis() {
assert_eq!(mirror_x(12.5, false), 12.5);
assert_eq!(mirror_x(12.5, true), -12.5);
assert_eq!(mirror_x(-3.0, true), 3.0);
}
}

303
apps/fabric/src/camera.rs Normal file
View file

@ -0,0 +1,303 @@
use makepad_widgets::{
makepad_platform::video::{
CameraFrameLayout, CameraFrameRef, VideoFormatId, VideoInputId, VideoInputsEvent,
VideoPixelFormat,
},
Cx, CxMediaApi,
};
use std::{
cmp::Reverse,
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, Mutex,
},
time::{Duration, Instant},
};
pub const SEND_MAX_WIDTH: usize = 640;
const PREVIEW_MAX_WIDTH: usize = 320;
const PREVIEW_INTERVAL: Duration = Duration::from_millis(100);
#[derive(Clone, Debug, PartialEq)]
pub struct CameraRgbFrame {
pub width: u32,
pub height: u32,
pub rgb: Vec<u8>,
pub serial: u64,
}
#[derive(Default)]
struct PreviewSlot {
frame: Option<CameraRgbFrame>,
updated_at: Option<Instant>,
}
#[derive(Default)]
struct CameraMailboxInner {
want: AtomicBool,
serial: AtomicU64,
frame: Mutex<Option<CameraRgbFrame>>,
model_size: Mutex<Option<(u32, u32)>>,
preview: Mutex<PreviewSlot>,
}
/// A one-frame handoff from the camera callback to the model worker. The
/// callback only converts a model-sized frame after the worker asks for one;
/// the smaller preview is independent and limited to ten updates per second.
#[derive(Clone, Default)]
pub struct CameraMailbox {
inner: Arc<CameraMailboxInner>,
}
impl CameraMailbox {
pub fn request(&self) {
if let Ok(mut frame) = self.inner.frame.lock() {
*frame = None;
}
self.inner.want.store(true, Ordering::Release);
}
pub fn take(&self) -> Option<CameraRgbFrame> {
self.inner.frame.lock().ok()?.take()
}
pub fn peek_preview(&self) -> Option<CameraRgbFrame> {
self.inner.preview.lock().ok()?.frame.clone()
}
pub fn model_size(&self) -> Option<(u32, u32)> {
*self.inner.model_size.lock().ok()?
}
fn capture(&self, frame: &CameraFrameRef<'_>) {
let wanted = self.inner.want.swap(false, Ordering::AcqRel);
let preview_due = self
.inner
.preview
.lock()
.ok()
.map(|slot| {
slot.updated_at
.map(|updated| updated.elapsed() >= PREVIEW_INTERVAL)
.unwrap_or(true)
})
.unwrap_or(false);
if !wanted && !preview_due {
return;
}
if wanted {
if let Some((rgb, width, height)) = frame_to_rgb(frame) {
let serial = self.inner.serial.fetch_add(1, Ordering::Relaxed) + 1;
if let Ok(mut size) = self.inner.model_size.lock() {
*size = Some((width, height));
}
if let Ok(mut slot) = self.inner.frame.lock() {
*slot = Some(CameraRgbFrame {
width,
height,
rgb,
serial,
});
}
}
}
if preview_due {
if let Some((rgb, width, height)) = frame_to_rgb_max(frame, PREVIEW_MAX_WIDTH) {
let serial = self.inner.serial.fetch_add(1, Ordering::Relaxed) + 1;
if let Ok(mut slot) = self.inner.preview.lock() {
slot.frame = Some(CameraRgbFrame {
width,
height,
rgb,
serial,
});
slot.updated_at = Some(Instant::now());
}
}
}
}
}
/// Register the capture callback. Calling this also asks the platform to
/// enumerate cameras, which produces `Event::VideoInputs` on the UI thread.
pub fn install_camera(cx: &mut Cx, mailbox: CameraMailbox) {
cx.camera_frame_input(0, move |frame| mailbox.capture(&frame));
}
/// Choose the first device's smallest raw-YUV format at least 640x360.
pub fn pick_camera(event: &VideoInputsEvent) -> Option<(VideoInputId, VideoFormatId)> {
let device = event.descs.first()?;
let format = device
.formats
.iter()
.filter(|format| {
format.width >= 640
&& format.height >= 360
&& matches!(
format.pixel_format,
VideoPixelFormat::NV12 | VideoPixelFormat::YUY2
)
})
.min_by_key(|format| {
(
format.width.saturating_mul(format.height),
if format.pixel_format == VideoPixelFormat::NV12 {
0
} else {
1
},
Reverse((format.frame_rate.unwrap_or(0.0) * 1000.0) as u64),
)
})?;
Some((device.input_id, format.format_id))
}
/// Convert NV12 or YUY2 to packed RGB8, using an integer sampling step so
/// the entire frame fits within [`SEND_MAX_WIDTH`].
pub fn frame_to_rgb(frame: &CameraFrameRef<'_>) -> Option<(Vec<u8>, u32, u32)> {
frame_to_rgb_max(frame, SEND_MAX_WIDTH)
}
fn frame_to_rgb_max(
frame: &CameraFrameRef<'_>,
max_width: usize,
) -> Option<(Vec<u8>, u32, u32)> {
let (width, height) = (frame.width, frame.height);
if width == 0 || height == 0 || max_width == 0 {
return None;
}
let step = width.div_ceil(max_width).max(1);
let out_width = width.div_ceil(step);
let out_height = height.div_ceil(step);
let mut rgb = Vec::with_capacity(out_width.checked_mul(out_height)?.checked_mul(3)?);
for out_y in 0..out_height {
let source_y = (out_y * step).min(height - 1);
for out_x in 0..out_width {
let source_x = (out_x * step).min(width - 1);
let (y, u, v) = match frame.layout {
CameraFrameLayout::NV12 => nv12_pixel(frame, source_x, source_y)?,
CameraFrameLayout::YUY2 => yuy2_pixel(frame, source_x, source_y)?,
_ => return None,
};
rgb.extend_from_slice(&yuv_to_rgb(y, u, v));
}
}
Some((
rgb,
u32::try_from(out_width).ok()?,
u32::try_from(out_height).ok()?,
))
}
fn nv12_pixel(frame: &CameraFrameRef<'_>, x: usize, y: usize) -> Option<(u8, u8, u8)> {
if frame.plane_count < 2 {
return None;
}
let y_plane = frame.planes[0];
let uv_plane = frame.planes[1];
let y_index = y
.checked_mul(y_plane.row_stride)?
.checked_add(x.checked_mul(y_plane.pixel_stride)?)?;
let uv_index = (y / 2)
.checked_mul(uv_plane.row_stride)?
.checked_add((x / 2).checked_mul(uv_plane.pixel_stride)?)?;
Some((
*y_plane.bytes.get(y_index)?,
*uv_plane.bytes.get(uv_index)?,
*uv_plane.bytes.get(uv_index + 1)?,
))
}
fn yuy2_pixel(frame: &CameraFrameRef<'_>, x: usize, y: usize) -> Option<(u8, u8, u8)> {
if frame.plane_count < 1 {
return None;
}
let plane = frame.planes[0];
let pixel_stride = plane.pixel_stride.max(2);
let pair = y
.checked_mul(plane.row_stride)?
.checked_add((x / 2).checked_mul(pixel_stride.checked_mul(2)?)?)?;
Some((
*plane.bytes.get(pair + (x & 1) * pixel_stride)?,
*plane.bytes.get(pair + 1)?,
*plane.bytes.get(pair + pixel_stride + 1)?,
))
}
/// One BT.709, video-range YUV pixel to RGB8.
pub(crate) fn yuv_to_rgb(y: u8, u: u8, v: u8) -> [u8; 3] {
let c = i32::from(y) - 16;
let d = i32::from(u) - 128;
let e = i32::from(v) - 128;
let clip = |value: i32| ((value + 128) >> 8).clamp(0, 255) as u8;
[
clip(298 * c + 459 * e),
clip(298 * c - 55 * d - 136 * e),
clip(298 * c + 541 * d),
]
}
#[cfg(test)]
mod tests {
use super::*;
use makepad_widgets::makepad_platform::video::{
CameraColorMatrix, CameraFramePlaneRef,
};
#[test]
fn yuv_grey_red_and_blue() {
let grey = yuv_to_rgb(126, 128, 128);
assert!(grey.iter().all(|channel| (126i16 - i16::from(*channel)).abs() <= 2));
let red = yuv_to_rgb(81, 90, 240);
assert!(red[0] > 220 && red[1] < 60 && red[2] < 60, "{red:?}");
let blue = yuv_to_rgb(41, 240, 110);
assert!(blue[2] > 220 && blue[0] < 60 && blue[1] < 60, "{blue:?}");
}
#[test]
fn nv12_integer_downsample_keeps_the_whole_frame() {
const WIDTH: usize = 64;
const HEIGHT: usize = 32;
let mut y_plane = vec![0u8; WIDTH * HEIGHT];
for y in 0..HEIGHT {
for x in 0..WIDTH {
y_plane[y * WIDTH + x] = 16 + ((x + y) % 200) as u8;
}
}
let uv_plane = vec![128u8; WIDTH * HEIGHT / 2];
let frame = CameraFrameRef {
timestamp_ns: 0,
width: WIDTH,
height: HEIGHT,
layout: CameraFrameLayout::NV12,
matrix: CameraColorMatrix::BT709,
plane_count: 2,
planes: [
CameraFramePlaneRef {
bytes: &y_plane,
row_stride: WIDTH,
pixel_stride: 1,
},
CameraFramePlaneRef {
bytes: &uv_plane,
row_stride: WIDTH,
pixel_stride: 2,
},
CameraFramePlaneRef::empty(),
],
};
let (rgb, width, height) = frame_to_rgb_max(&frame, 16).unwrap();
assert_eq!((width, height), (16, 8));
assert_eq!(rgb.len(), 16 * 8 * 3);
assert_eq!(&rgb[..3], &yuv_to_rgb(y_plane[0], 128, 128));
let last_source = y_plane[28 * WIDTH + 60];
assert_eq!(&rgb[rgb.len() - 3..], &yuv_to_rgb(last_source, 128, 128));
}
}

View file

@ -0,0 +1,73 @@
use makepad_ai_hub::{
local::{InstallState, LocalModels},
registry::LicenseRestriction,
};
use makepad_ai_hub_ui::{ModelRowInstallState, ModelRowState};
pub const BODY_MODEL_ID: &str = "sam3dbody";
pub const BODY_MODEL_ROLE: &str = "native-body";
pub fn body_model_row(models: &LocalModels) -> ModelRowState {
let spec = models.spec(BODY_MODEL_ID);
let bytes_from_spec = spec
.map(|spec| spec.files.iter().filter_map(|file| file.size).sum())
.unwrap_or(0);
let (bytes_done, bytes_total, state) = match models.install_state(BODY_MODEL_ID) {
InstallState::NotInstalled { bytes_total } => {
(0, bytes_total.max(bytes_from_spec), ModelRowInstallState::NotInstalled)
}
InstallState::Partial {
bytes_done,
bytes_total,
} => (bytes_done, bytes_total, ModelRowInstallState::NotInstalled),
InstallState::Installed => (
bytes_from_spec,
bytes_from_spec,
ModelRowInstallState::Installed,
),
};
let license = spec.and_then(|spec| spec.license.as_ref());
ModelRowState {
model_id: BODY_MODEL_ID.to_string(),
name: "SAM 3D Body".to_string(),
bytes_total,
bytes_done,
state,
license_name: license
.map(|license| license.name.clone())
.unwrap_or_else(|| "Licence unavailable".to_string()),
restriction: license
.map(|license| restriction_name(license.restriction).to_string())
.unwrap_or_else(|| "restricted".to_string()),
}
}
pub fn body_model_status(models: &LocalModels, downloading: bool) -> String {
if !models.license_acknowledged(BODY_MODEL_ID) {
return "licence not accepted".to_string();
}
match models.install_state(BODY_MODEL_ID) {
InstallState::Installed => "installed · 2.8 GB · Metal".to_string(),
InstallState::NotInstalled { .. } => "not installed · 2.8 GB".to_string(),
InstallState::Partial {
bytes_done,
bytes_total,
} => {
let percent = bytes_done.saturating_mul(100) / bytes_total.max(1);
if downloading {
format!("downloading {percent} %")
} else {
format!("not installed · {percent} % downloaded")
}
}
}
}
fn restriction_name(restriction: LicenseRestriction) -> &'static str {
match restriction {
LicenseRestriction::None => "none",
LicenseRestriction::NonCommercial => "non-commercial",
LicenseRestriction::Community => "community",
LicenseRestriction::Restricted => "restricted",
}
}

1802
apps/fabric/src/main.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,398 @@
use crate::body_view::DrawFabricLine;
use makepad_fabric_draft::{flatten, nest, offset, Layout as PatternLayout, Part, Pattern, Point};
use makepad_widgets::*;
script_mod! {
use mod.prelude.widgets_internal.*
use mod.widgets.*
mod.widgets.FabricPatternViewBase = #(FabricPatternView::register_widget(vm))
mod.widgets.FabricPatternView = set_type_default() do mod.widgets.FabricPatternViewBase {
width: Fill
height: Fill
draw_bg +: {color: #x0d1218}
draw_line +: {}
draw_text +: {
color: #xc5d0dc
text_style: theme.font_regular{font_size: 8.5}
}
}
}
#[derive(Clone, Copy)]
struct PatternDrag {
from: DVec2,
pan: DVec2,
}
#[derive(Clone, Copy, Default)]
struct Bounds {
min: DVec2,
max: DVec2,
valid: bool,
}
impl Bounds {
fn include(&mut self, point: DVec2) {
if !self.valid {
self.min = point;
self.max = point;
self.valid = true;
} else {
self.min.x = self.min.x.min(point.x);
self.min.y = self.min.y.min(point.y);
self.max.x = self.max.x.max(point.x);
self.max.y = self.max.y.max(point.y);
}
}
fn size(self) -> DVec2 {
let size = self.max - self.min;
dvec2(size.x.max(1.0), size.y.max(1.0))
}
fn centre(self) -> DVec2 {
(self.min + self.max) * 0.5
}
}
#[derive(Script, ScriptHook, Widget)]
pub struct FabricPatternView {
#[uid]
uid: WidgetUid,
#[source]
source: ScriptObjectRef,
#[walk]
walk: Walk,
#[layout]
layout: Layout,
#[redraw]
#[area]
area: Area,
#[live]
draw_bg: DrawColor,
#[live]
draw_line: DrawFabricLine,
#[live]
draw_text: DrawText,
#[rust]
pattern: Option<Pattern>,
#[rust]
nested: Option<PatternLayout>,
#[rust]
error: String,
#[rust]
bounds: Bounds,
#[rust(1.0)]
zoom: f64,
/// The view aspect the current nest was chosen for.
#[rust(1.0)]
nest_aspect: f64,
#[rust]
pan: DVec2,
#[rust]
drag: Option<PatternDrag>,
}
impl FabricPatternView {
pub fn set_pattern(&mut self, cx: &mut Cx, pattern: Pattern) {
self.pattern = Some(pattern);
// Nested on the next draw, for the pane's shape.
self.nested = None;
self.error.clear();
self.zoom = 1.0;
self.pan = dvec2(0.0, 0.0);
self.redraw(cx);
}
pub fn set_error(&mut self, cx: &mut Cx, error: impl Into<String>) {
self.pattern = None;
self.nested = None;
self.error = error.into();
self.redraw(cx);
}
/// Nest onto the fabric width whose finished layout has the pane's
/// aspect ratio, so the pieces fill the view instead of a tall strip.
fn ensure_nest(&mut self, rect: Rect) {
let target = (rect.size.x - 24.0).max(1.0) / (rect.size.y - 24.0).max(1.0);
if self.nested.is_some() && (self.nest_aspect / target).ln().abs() < 0.12 {
return;
}
const WIDTHS: [f64; 9] = [
900.0, 1200.0, 1500.0, 2000.0, 2500.0, 3000.0, 4000.0, 5000.0, 6500.0,
];
let best = {
let Some(pattern) = &self.pattern else { return };
let mut best: Option<(f64, PatternLayout, Bounds)> = None;
for width in WIDTHS {
let layout = nest(pattern, width);
let bounds = pattern_bounds(pattern, &layout);
let size = bounds.size();
let aspect = size.x.max(1.0) / size.y.max(1.0);
let score = (aspect / target).ln().abs();
if best.as_ref().map_or(true, |(other, _, _)| score < *other) {
best = Some((score, layout, bounds));
}
}
best
};
if let Some((_, layout, bounds)) = best {
self.nested = Some(layout);
self.bounds = bounds;
self.nest_aspect = target;
}
}
fn to_screen(&self, point: DVec2, rect: Rect) -> DVec2 {
let size = self.bounds.size();
let fit = ((rect.size.x - 24.0) / size.x)
.min((rect.size.y - 24.0) / size.y)
.max(0.0001);
rect.pos + rect.size * 0.5
+ self.pan
+ (point - self.bounds.centre()) * fit * self.zoom
}
fn part_offset<'a>(
&'a self,
layout: &'a PatternLayout,
part_index: usize,
) -> (Point, f64) {
layout
.placements
.iter()
.find(|placement| placement.part == part_index)
.map(|placement| (placement.offset, placement.rotation_deg))
.unwrap_or((Point::default(), 0.0))
}
fn path_segments(
&self,
path: &makepad_fabric_draft::Path,
offset_by: Point,
rotation: f64,
rect: Rect,
) -> Vec<(DVec2, DVec2)> {
let points = flatten(path, 1.0);
let transformed: Vec<DVec2> = points
.iter()
.map(|point| self.to_screen(place(*point, offset_by, rotation), rect))
.collect();
let mut segments: Vec<_> = transformed
.windows(2)
.map(|pair| (pair[0], pair[1]))
.collect();
if path.closed {
if let (Some(first), Some(last)) = (transformed.first(), transformed.last()) {
segments.push((*last, *first));
}
}
segments
}
}
impl Widget for FabricPatternView {
fn draw_walk(&mut self, cx: &mut Cx2d, _scope: &mut Scope, walk: Walk) -> DrawStep {
let rect = cx.walk_turtle(walk);
self.draw_bg.draw_abs(cx, rect);
cx.push_clip_rect(rect);
self.ensure_nest(rect);
let (Some(pattern), Some(layout)) = (&self.pattern, &self.nested) else {
let message = if self.error.is_empty() {
"pattern preview"
} else {
&self.error
};
self.draw_text.color = Vec4f {
x: 0.52,
y: 0.58,
z: 0.65,
w: 1.0,
};
self.draw_text
.draw_abs(cx, rect.pos + dvec2(14.0, 16.0), message);
cx.pop_clip_rect();
cx.add_aligned_rect_area(&mut self.area, rect);
return DrawStep::done();
};
struct Stroke {
from: DVec2,
to: DVec2,
color: Vec4f,
width: f64,
}
let mut strokes = Vec::new();
let mut labels = Vec::new();
let cut_color = Vec4f {
x: 0.93,
y: 0.96,
z: 0.99,
w: 1.0,
};
let seam_color = Vec4f {
x: 0.40,
y: 0.49,
z: 0.58,
w: 0.72,
};
let mark_color = Vec4f {
x: 1.0,
y: 0.40,
z: 0.18,
w: 0.95,
};
for (part_index, part) in pattern.parts.iter().enumerate() {
let (part_offset, rotation) = self.part_offset(layout, part_index);
let cut_path = offset(&part.outline, part.seam_allowance_mm);
for (from, to) in self.path_segments(&cut_path, part_offset, rotation, rect) {
strokes.push(Stroke {
from,
to,
color: cut_color,
width: 1.25,
});
}
for (from, to) in self.path_segments(&part.outline, part_offset, rotation, rect) {
strokes.push(Stroke {
from,
to,
color: seam_color,
width: 0.75,
});
}
for path in &part.internal {
for (from, to) in self.path_segments(path, part_offset, rotation, rect) {
strokes.push(Stroke {
from,
to,
color: seam_color,
width: 0.75,
});
}
}
for notch in &part.notches {
let at = self.to_screen(place(*notch, part_offset, rotation), rect);
strokes.push(Stroke {
from: at + dvec2(-3.0, -3.0),
to: at + dvec2(3.0, 3.0),
color: mark_color,
width: 1.0,
});
strokes.push(Stroke {
from: at + dvec2(-3.0, 3.0),
to: at + dvec2(3.0, -3.0),
color: mark_color,
width: 1.0,
});
}
let grain_from = self.to_screen(place(part.grainline.0, part_offset, rotation), rect);
let grain_to = self.to_screen(place(part.grainline.1, part_offset, rotation), rect);
strokes.push(Stroke {
from: grain_from,
to: grain_to,
color: mark_color,
width: 1.0,
});
let label_at = part
.labels
.first()
.map(|label| label.at)
.unwrap_or(part.outline.start);
labels.push((
self.to_screen(place(label_at, part_offset, rotation), rect),
format!(
"{} · {}",
part.name,
if part.on_fold {
"cut on fold".to_string()
} else {
format!("cut {}", part.cut_count)
}
),
));
}
self.draw_line.begin_many_instances(cx);
for stroke in strokes {
self.draw_line.color = stroke.color;
self.draw_line
.segment(cx, stroke.from, stroke.to, stroke.width);
}
self.draw_line.end_many_instances(cx);
self.draw_text.color = Vec4f {
x: 0.78,
y: 0.84,
z: 0.90,
w: 1.0,
};
for (at, text) in labels {
self.draw_text.draw_abs(cx, at + dvec2(4.0, -5.0), &text);
}
cx.pop_clip_rect();
cx.add_aligned_rect_area(&mut self.area, rect);
DrawStep::done()
}
fn handle_event(&mut self, cx: &mut Cx, event: &Event, _scope: &mut Scope) {
match event.hits(cx, self.area) {
Hit::FingerDown(event) if event.device.is_primary_hit() => {
self.drag = Some(PatternDrag {
from: event.abs,
pan: self.pan,
});
}
Hit::FingerMove(event) => {
if let Some(drag) = self.drag {
self.pan = drag.pan + event.abs - drag.from;
self.redraw(cx);
}
}
Hit::FingerUp(_) => self.drag = None,
Hit::FingerScroll(event) => {
self.zoom = (self.zoom * (-event.scroll.y * 0.004).exp()).clamp(0.1, 30.0);
self.redraw(cx);
}
Hit::FingerHoverIn(_) | Hit::FingerHoverOver(_) => {
cx.set_cursor(MouseCursor::Grab)
}
_ => {}
}
}
}
fn place(point: Point, offset: Point, rotation_deg: f64) -> DVec2 {
let angle = rotation_deg.to_radians();
let (sin, cos) = angle.sin_cos();
dvec2(
point.x * cos - point.y * sin + offset.x,
point.x * sin + point.y * cos + offset.y,
)
}
fn pattern_bounds(pattern: &Pattern, layout: &PatternLayout) -> Bounds {
let mut bounds = Bounds::default();
for (part_index, part) in pattern.parts.iter().enumerate() {
let (part_offset, rotation) = layout
.placements
.iter()
.find(|placement| placement.part == part_index)
.map(|placement| (placement.offset, placement.rotation_deg))
.unwrap_or((Point::default(), 0.0));
include_part(&mut bounds, part, part_offset, rotation);
}
if !bounds.valid && layout.width_mm > 0.0 && layout.height_mm > 0.0 {
bounds.include(dvec2(0.0, 0.0));
bounds.include(dvec2(layout.width_mm, layout.height_mm));
}
bounds
}
fn include_part(bounds: &mut Bounds, part: &Part, part_offset: Point, rotation: f64) {
let cut = offset(&part.outline, part.seam_allowance_mm);
for point in flatten(&cut, 1.0) {
bounds.include(place(point, part_offset, rotation));
}
}

676
apps/fabric/src/pipeline.rs Normal file
View file

@ -0,0 +1,676 @@
use crate::{
body_view::{map_measurements_to_vertices, BodyPoseMapping},
camera::CameraMailbox,
};
use makepad_ai_body::model::BodyModel;
use makepad_fabric_measure::{measure, BodyMesh, MeasureOptions, Measured};
use makepad_widgets::image_cache::ImageBuffer;
use makepad_widgets::makepad_platform::thread::SignalToUI;
use std::{
collections::VecDeque,
path::{Path, PathBuf},
sync::{
atomic::{AtomicBool, Ordering},
mpsc::{self, Receiver, Sender},
Arc,
},
thread,
time::{Duration, Instant},
};
const PHOTO_CROP_SIZE: usize = 512;
const LIVE_CROP_SIZE: usize = 384;
const SHAPE_ALPHA: f32 = 0.35;
const POSE_ALPHA: f32 = 0.6;
const SHAPE_RESET_GAP: Duration = Duration::from_secs(1);
const FRAME_TIMEOUT: Duration = Duration::from_secs(2);
const FRAME_POLL: Duration = Duration::from_millis(5);
pub enum PipelineMessage {
Stage(String),
LiveFrame {
fps: f32,
model_ms: f32,
pose_ms: f32,
person: bool,
bbox: Option<[f32; 4]>,
},
Done {
measured: Box<Measured>,
mesh: Arc<BodyMesh>,
posed: Option<Arc<Vec<[f32; 3]>>>,
pose_mapping: BodyPoseMapping,
reset_pose: bool,
},
Failed(String),
}
struct RunRequest {
photo: PathBuf,
weights: PathBuf,
height_cm: Option<f32>,
}
struct LiveRequest {
weights: PathBuf,
height_cm: Option<f32>,
mailbox: CameraMailbox,
}
enum WorkerRequest {
Photo(RunRequest),
Live(LiveRequest),
}
pub struct Pipeline {
request_tx: Sender<WorkerRequest>,
message_rx: Receiver<PipelineMessage>,
live_stop: Arc<AtomicBool>,
}
impl Pipeline {
pub fn new() -> Self {
let (request_tx, request_rx) = mpsc::channel();
let (message_tx, message_rx) = mpsc::channel();
let live_stop = Arc::new(AtomicBool::new(true));
let worker_stop = live_stop.clone();
thread::Builder::new()
.name("fabric-body-pipeline".to_string())
.spawn(move || worker(request_rx, message_tx, worker_stop))
.expect("spawn fabric body worker");
Self {
request_tx,
message_rx,
live_stop,
}
}
pub fn run(
&self,
photo: PathBuf,
weights: PathBuf,
height_cm: Option<f32>,
) -> Result<(), String> {
self.request_tx
.send(WorkerRequest::Photo(RunRequest {
photo,
weights,
height_cm,
}))
.map_err(|_| "the body model worker stopped".to_string())
}
pub fn start_live(
&self,
weights: PathBuf,
height_cm: Option<f32>,
mailbox: CameraMailbox,
) -> Result<(), String> {
self.live_stop.store(false, Ordering::Release);
if self
.request_tx
.send(WorkerRequest::Live(LiveRequest {
weights,
height_cm,
mailbox,
}))
.is_err()
{
self.live_stop.store(true, Ordering::Release);
return Err("the body model worker stopped".to_string());
}
Ok(())
}
pub fn stop_live(&self) {
self.live_stop.store(true, Ordering::Release);
}
pub fn poll(&self) -> Vec<PipelineMessage> {
self.message_rx.try_iter().collect()
}
}
fn emit(sender: &Sender<PipelineMessage>, message: PipelineMessage) -> bool {
if sender.send(message).is_err() {
return false;
}
SignalToUI::set_ui_signal();
true
}
fn worker(
requests: Receiver<WorkerRequest>,
messages: Sender<PipelineMessage>,
live_stop: Arc<AtomicBool>,
) {
let mut loaded: Option<(PathBuf, BodyModel)> = None;
while let Ok(request) = requests.recv() {
let result = match request {
WorkerRequest::Photo(request) => run_one(&request, &messages, &mut loaded),
WorkerRequest::Live(request) => {
run_live(&request, &messages, &mut loaded, &live_stop)
}
};
if let Err(error) = result {
if !emit(&messages, PipelineMessage::Failed(error)) {
return;
}
}
}
}
fn run_one(
request: &RunRequest,
messages: &Sender<PipelineMessage>,
loaded: &mut Option<(PathBuf, BodyModel)>,
) -> Result<(), String> {
emit(messages, PipelineMessage::Stage("decoding photo…".to_string()));
let (rgb, width, height) = decode_rgb(&request.photo)?;
ensure_model(&request.weights, messages, loaded)?;
emit(messages, PipelineMessage::Stage("inferring…".to_string()));
let model = &mut loaded.as_mut().expect("model was loaded above").1;
model
.set_crop_size(PHOTO_CROP_SIZE)
.map_err(|error| format!("could not configure the body model: {error}"))?;
let packet = model
.infer(&rgb, width, height, None)
.map_err(|error| format!("body inference failed: {error}"))?;
let person = packet
.people
.first()
.ok_or_else(|| "no person found".to_string())?;
let posed = Arc::new(posed_vertices(
model,
&person.shape,
&person.expr,
person.mhr,
person.global_rot,
)?);
let vertices = model.rig().rest_vertices(&person.shape, &person.expr);
let face_indices = model
.weights
.i64_shaped("head_pose.faces", &[36_874, 3])
.map_err(|error| format!("could not read the body mesh faces: {error}"))?;
let mesh = Arc::new(body_mesh_from_flat(&vertices, &face_indices)?);
emit(messages, PipelineMessage::Stage("measuring…".to_string()));
let measured = measure(
&mesh,
&MeasureOptions {
height_cm: request.height_cm,
},
)
.map_err(|error| error.to_string())?;
let pose_mapping = map_measurements_to_vertices(&mesh, &measured);
emit(
messages,
PipelineMessage::Done {
measured: Box::new(measured),
mesh,
posed: Some(posed),
pose_mapping,
reset_pose: true,
},
);
Ok(())
}
fn ensure_model(
weights: &Path,
messages: &Sender<PipelineMessage>,
loaded: &mut Option<(PathBuf, BodyModel)>,
) -> Result<(), String> {
if loaded
.as_ref()
.map(|(path, _)| path != weights)
.unwrap_or(true)
{
emit(
messages,
PipelineMessage::Stage("loading model 2.8 GB…".to_string()),
);
let model = BodyModel::load(weights)
.map_err(|error| format!("could not load the body model: {error}"))?;
*loaded = Some((weights.to_path_buf(), model));
}
Ok(())
}
fn run_live(
request: &LiveRequest,
messages: &Sender<PipelineMessage>,
loaded: &mut Option<(PathBuf, BodyModel)>,
stop: &AtomicBool,
) -> Result<(), String> {
if stop.load(Ordering::Acquire) {
return Ok(());
}
ensure_model(&request.weights, messages, loaded)?;
let model = &mut loaded.as_mut().expect("model was loaded above").1;
model
.set_crop_size(LIVE_CROP_SIZE)
.map_err(|error| format!("could not configure live body inference: {error}"))?;
let face_indices = model
.weights
.i64_shaped("head_pose.faces", &[36_874, 3])
.map_err(|error| format!("could not read the body mesh faces: {error}"))?;
let started = Instant::now();
let mut previous_bbox = None;
let mut smoother = ShapeSmoother::default();
let mut pose_smoother = PoseSmoother::default();
let mut fps = FpsCounter::default();
let mut reset_pose = true;
while !stop.load(Ordering::Acquire) {
request.mailbox.request();
let wait_started = Instant::now();
let frame = loop {
if stop.load(Ordering::Acquire) {
return Ok(());
}
if let Some(frame) = request.mailbox.take() {
break Some(frame);
}
if wait_started.elapsed() >= FRAME_TIMEOUT {
break None;
}
thread::sleep(FRAME_POLL);
};
let Some(frame) = frame else {
if !emit(
messages,
PipelineMessage::Stage("no camera frames".to_string()),
) {
return Ok(());
}
continue;
};
let model_started = Instant::now();
let packet = model
.infer(&frame.rgb, frame.width, frame.height, previous_bbox)
.map_err(|error| format!("live body inference failed: {error}"))?;
let model_ms = model_started.elapsed().as_secs_f32() * 1000.0;
let now = started.elapsed();
let person = packet.people.first();
let bbox = person.map(|person| person.bbox);
let mut pose_ms = 0.0;
let mut posed = None;
let smoothed_shape = if let Some(person) = person {
previous_bbox = Some(expand_bbox(
person.bbox,
frame.width,
frame.height,
0.15,
));
let (mhr, global_rot) = pose_smoother.observe(person.mhr, person.global_rot);
let pose_started = Instant::now();
posed = Some(Arc::new(posed_vertices(
model,
&person.shape,
&person.expr,
mhr,
global_rot,
)?));
pose_ms = pose_started.elapsed().as_secs_f32() * 1000.0;
Some(smoother.observe_person(person.shape, now))
} else {
previous_bbox = None;
smoother.observe_miss(now);
pose_smoother.reset();
None
};
let current_fps = fps.tick(now);
if !emit(
messages,
PipelineMessage::LiveFrame {
fps: current_fps,
model_ms,
pose_ms,
person: person.is_some(),
bbox,
},
) {
return Ok(());
}
if stop.load(Ordering::Acquire) {
return Ok(());
}
let Some(shape) = smoothed_shape else {
continue;
};
let expression = [0.0f32; 72];
let vertices = model.rig().rest_vertices(&shape, &expression);
let mesh = Arc::new(body_mesh_from_flat(&vertices, &face_indices)?);
let measured = measure(
&mesh,
&MeasureOptions {
height_cm: request.height_cm,
},
)
.map_err(|error| error.to_string())?;
let pose_mapping = map_measurements_to_vertices(&mesh, &measured);
if !emit(
messages,
PipelineMessage::Done {
measured: Box::new(measured),
mesh,
posed,
pose_mapping,
reset_pose,
},
) {
return Ok(());
}
reset_pose = false;
}
Ok(())
}
#[derive(Default)]
struct PoseSmoother {
mhr: Option<[f32; 204]>,
global_rot: Option<[f32; 3]>,
}
impl PoseSmoother {
fn observe(
&mut self,
mhr: [f32; 204],
global_rot: [f32; 3],
) -> ([f32; 204], [f32; 3]) {
let global_rot = match self.global_rot {
Some(previous) => std::array::from_fn(|index| {
previous[index] + POSE_ALPHA * (global_rot[index] - previous[index])
}),
None => global_rot,
};
let mut mhr = match self.mhr {
Some(previous) => std::array::from_fn(|index| {
previous[index] + POSE_ALPHA * (mhr[index] - previous[index])
}),
None => mhr,
};
// The packet's 204 values are exactly the rig's [pose 136 | scales 68]
// input. Global rotation is pose slots 3..6; keep the separately
// smoothed copy authoritative before MhrRig::forward pads to 249.
mhr[3..6].copy_from_slice(&global_rot);
self.mhr = Some(mhr);
self.global_rot = Some(global_rot);
(mhr, global_rot)
}
fn reset(&mut self) {
self.mhr = None;
self.global_rot = None;
}
}
#[derive(Default)]
struct ShapeSmoother {
shape: Option<[f32; 45]>,
last_person: Option<Duration>,
}
impl ShapeSmoother {
fn observe_person(&mut self, shape: [f32; 45], now: Duration) -> [f32; 45] {
if self
.last_person
.is_some_and(|last| now.saturating_sub(last) > SHAPE_RESET_GAP)
{
self.shape = None;
}
self.last_person = Some(now);
let smoothed = match self.shape {
Some(previous) => std::array::from_fn(|index| {
previous[index] + SHAPE_ALPHA * (shape[index] - previous[index])
}),
None => shape,
};
self.shape = Some(smoothed);
smoothed
}
fn observe_miss(&mut self, now: Duration) {
if self
.last_person
.is_some_and(|last| now.saturating_sub(last) > SHAPE_RESET_GAP)
{
self.shape = None;
self.last_person = None;
}
}
}
#[derive(Default)]
struct FpsCounter {
samples: VecDeque<Duration>,
}
impl FpsCounter {
fn tick(&mut self, now: Duration) -> f32 {
self.samples.push_back(now);
while self.samples.len() > 10 {
self.samples.pop_front();
}
let Some(first) = self.samples.front().copied() else {
return 0.0;
};
let seconds = now.saturating_sub(first).as_secs_f32();
if seconds <= f32::EPSILON {
0.0
} else {
(self.samples.len().saturating_sub(1)) as f32 / seconds
}
}
}
pub(crate) fn expand_bbox(
bbox: [f32; 4],
width: u32,
height: u32,
amount: f32,
) -> [f32; 4] {
let box_width = (bbox[2] - bbox[0]).max(0.0);
let box_height = (bbox[3] - bbox[1]).max(0.0);
[
(bbox[0] - box_width * amount).clamp(0.0, width as f32),
(bbox[1] - box_height * amount).clamp(0.0, height as f32),
(bbox[2] + box_width * amount).clamp(0.0, width as f32),
(bbox[3] + box_height * amount).clamp(0.0, height as f32),
]
}
fn posed_vertices(
model: &BodyModel,
shape: &[f32; 45],
expression: &[f32; 72],
mut mhr: [f32; 204],
global_rot: [f32; 3],
) -> Result<Vec<[f32; 3]>, String> {
// BodyPerson::mhr is already model_params(): pose 0..136 followed by
// scales 136..204. The root translation remains zero and the model's
// global rotation occupies 3..6. forward() pads the 45 identity slots
// to the rig's 249-wide internal vector, then returns rig-space cm.
mhr[3..6].copy_from_slice(&global_rot);
let output = model.rig().forward(shape, &mhr, expression, true);
vertices_from_flat(&output.verts)
}
fn decode_rgb(path: &Path) -> Result<(Vec<u8>, u32, u32), String> {
let bytes = std::fs::read(path)
.map_err(|error| format!("could not read {}: {error}", path.display()))?;
let extension = path
.extension()
.and_then(|extension| extension.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let image = match extension.as_str() {
"jpg" | "jpeg" => ImageBuffer::from_jpg(&bytes),
"png" => ImageBuffer::from_png(&bytes),
_ => return Err("choose a JPG or PNG photo".to_string()),
}
.map_err(|error| format!("could not decode {}: {error}", path.display()))?;
let pixel_count = image
.width
.checked_mul(image.height)
.ok_or_else(|| "photo dimensions are too large".to_string())?;
if image.data.len() < pixel_count {
return Err("decoded photo has too few pixels".to_string());
}
let width = u32::try_from(image.width).map_err(|_| "photo is too wide".to_string())?;
let height = u32::try_from(image.height).map_err(|_| "photo is too tall".to_string())?;
Ok((argb_to_rgb(&image.data[..pixel_count]), width, height))
}
pub(crate) fn argb_to_rgb(pixels: &[u32]) -> Vec<u8> {
let mut rgb = Vec::with_capacity(pixels.len() * 3);
for pixel in pixels {
rgb.push((pixel >> 16) as u8);
rgb.push((pixel >> 8) as u8);
rgb.push(*pixel as u8);
}
rgb
}
pub(crate) fn faces_i64_to_u32(
values: &[i64],
vertex_count: usize,
) -> Result<Vec<[u32; 3]>, String> {
if values.len() % 3 != 0 {
return Err("body face index buffer is not made of triangles".to_string());
}
values
.chunks_exact(3)
.enumerate()
.map(|(face_index, triangle)| {
let mut face = [0; 3];
for corner in 0..3 {
let index = usize::try_from(triangle[corner]).map_err(|_| {
format!("body face {face_index} contains a negative vertex index")
})?;
if index >= vertex_count {
return Err(format!(
"body face {face_index} references vertex {index}, but there are {vertex_count} vertices"
));
}
face[corner] = u32::try_from(index)
.map_err(|_| format!("body vertex index {index} exceeds u32"))?;
}
Ok(face)
})
.collect()
}
pub(crate) fn body_mesh_from_flat(
vertices: &[f32],
face_indices: &[i64],
) -> Result<BodyMesh, String> {
let vertices = vertices_from_flat(vertices)?;
let faces = faces_i64_to_u32(face_indices, vertices.len())?;
Ok(BodyMesh {
vertices,
faces,
landmarks: None,
})
}
fn vertices_from_flat(vertices: &[f32]) -> Result<Vec<[f32; 3]>, String> {
if vertices.is_empty() || vertices.len() % 3 != 0 {
return Err("body vertex buffer is empty or malformed".to_string());
}
Ok(vertices
.chunks_exact(3)
.map(|point| [point[0], point[1], point[2]])
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converts_aarrggbb_to_rgb() {
assert_eq!(
argb_to_rgb(&[0xff_12_34_56, 0x00_ab_cd_ef]),
vec![0x12, 0x34, 0x56, 0xab, 0xcd, 0xef]
);
}
#[test]
fn builds_a_tiny_checked_mesh() {
let mesh = body_mesh_from_flat(
&[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
&[0, 1, 2],
)
.unwrap();
assert_eq!(mesh.vertices.len(), 3);
assert_eq!(mesh.faces, vec![[0, 1, 2]]);
assert!(faces_i64_to_u32(&[0, 1, 3], 3).is_err());
assert!(faces_i64_to_u32(&[-1, 1, 2], 3).is_err());
}
#[test]
fn live_shape_ema_converges_and_resets_after_a_gap() {
let mut smoother = ShapeSmoother::default();
assert_eq!(
smoother.observe_person([0.0; 45], Duration::ZERO),
[0.0; 45]
);
let first = smoother.observe_person([10.0; 45], Duration::from_millis(250));
assert!((first[0] - 3.5).abs() < 0.0001);
let second = smoother.observe_person([10.0; 45], Duration::from_millis(500));
assert!((second[0] - 5.775).abs() < 0.0001);
smoother.observe_miss(Duration::from_millis(1_501));
let reset = smoother.observe_person([8.0; 45], Duration::from_millis(1_750));
assert_eq!(reset, [8.0; 45]);
}
#[test]
fn live_pose_ema_smooths_parameters_and_keeps_global_slots_in_sync() {
let mut smoother = PoseSmoother::default();
let mut initial_mhr = [0.0; 204];
initial_mhr[20] = 2.0;
let (initial, initial_rot) = smoother.observe(initial_mhr, [1.0, 2.0, 3.0]);
assert_eq!(initial[3..6], initial_rot);
let mut next_mhr = [10.0; 204];
next_mhr[20] = 12.0;
let (smoothed, smoothed_rot) = smoother.observe(next_mhr, [3.0, 4.0, 5.0]);
assert!((smoothed[20] - 8.0).abs() < 0.0001);
assert_eq!(smoothed_rot, [2.2, 3.2, 4.2]);
assert_eq!(smoothed[3..6], smoothed_rot);
smoother.reset();
let (reset, _) = smoother.observe([7.0; 204], [0.5, 1.0, 1.5]);
assert_eq!(reset[20], 7.0);
}
#[test]
fn bbox_expansion_is_fifteen_percent_and_clamped() {
let expanded = expand_bbox([100.0, 50.0, 300.0, 250.0], 640, 360, 0.15);
for (actual, expected) in expanded.into_iter().zip([70.0, 20.0, 330.0, 280.0]) {
assert!((actual - expected).abs() < 0.0001, "{expanded:?}");
}
assert_eq!(
expand_bbox([5.0, 10.0, 635.0, 350.0], 640, 360, 0.15),
[0.0, 0.0, 640.0, 360.0]
);
}
#[test]
fn fps_counter_tracks_recent_frame_cadence() {
let mut counter = FpsCounter::default();
assert_eq!(counter.tick(Duration::ZERO), 0.0);
for quarter in 1..=4 {
let fps = counter.tick(Duration::from_millis(quarter * 250));
assert!((fps - 4.0).abs() < 0.0001, "{fps}");
}
}
}

25
apps/files/Cargo.toml Normal file
View file

@ -0,0 +1,25 @@
[package]
name = "makepad-files"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "files"
path = "src/main.rs"
[features]
default = ["chat"]
chat = ["dep:makepad-ai-hub"]
demo = []
[dependencies]
makepad-widgets = { path = "../../widgets" }
makepad-wm-theme = { path = "../../libs/wm_theme" }
makepad-wm-api = { path = "../../libs/wm_api" }
# The AI services wire: bounded read and confirmed mutation tools on the bus
# (hosted port, id-correlated runner — see ai_service.rs).
makepad-ai-services = { path = "../../libs/ai/services" }
makepad-strict-json = { path = "../../libs/strict_json" }
# The ask panel's local model: an in-process Qwen GGUF on makepad-ggml, loaded
# only when the panel is first opened.
makepad-ai-hub = { path = "../../libs/ai/hub", default-features = false, features = ["llm"], optional = true }

64
apps/files/build.rs Normal file
View file

@ -0,0 +1,64 @@
#[path = "../../libs/ai/models/paint/src/png.rs"]
#[allow(dead_code)]
mod png;
use std::{env, fs, path::Path};
const WIDTH: u32 = 256;
const HEIGHT: u32 = 128;
const PALETTES: [(&str, [u8; 3], [u8; 3], [u8; 3]); 6] = [
("aurora-vignette.png", [28, 31, 78], [69, 175, 170], [227, 151, 232]),
("canyon-vignette.png", [86, 35, 38], [221, 119, 70], [255, 211, 128]),
("lagoon-vignette.png", [12, 55, 77], [28, 154, 166], [151, 232, 207]),
("meadow-vignette.png", [36, 68, 45], [124, 167, 79], [234, 213, 126]),
("twilight-vignette.png", [36, 25, 65], [103, 71, 141], [238, 144, 116]),
("cinema-still.png", [19, 24, 39], [56, 75, 105], [235, 176, 91]),
];
fn mix(a: u8, b: u8, amount: u32) -> u32 {
(u32::from(a) * (255 - amount) + u32::from(b) * amount) / 255
}
fn picture(top: [u8; 3], bottom: [u8; 3], accent: [u8; 3], seed: u32) -> Vec<u8> {
let mut pixels = Vec::with_capacity((WIDTH * HEIGHT * 3) as usize);
let glow_x = 36 + seed * 31;
let glow_y = 22 + (seed * 17) % 54;
for y in 0..HEIGHT {
for x in 0..WIDTH {
let blend = (y * 190 / (HEIGHT - 1) + x * 65 / (WIDTH - 1)).min(255);
let edge_x = (x as i32 * 2 - (WIDTH - 1) as i32).unsigned_abs();
let edge_y = (y as i32 * 2 - (HEIGHT - 1) as i32).unsigned_abs();
let vignette = edge_x * edge_x * 18 / ((WIDTH - 1) * (WIDTH - 1))
+ edge_y * edge_y * 24 / ((HEIGHT - 1) * (HEIGHT - 1));
let glow_dx = (x as i32 - glow_x as i32).unsigned_abs();
let glow_dy = (y as i32 - glow_y as i32).unsigned_abs();
let glow = 72u32.saturating_sub(glow_dx * glow_dx / 360 + glow_dy * glow_dy / 90);
let texture = ((x * 17 + y * 31 + seed * 43) & 7) as i32 - 3;
for channel in 0..3 {
let base = mix(top[channel], bottom[channel], blend);
let lit = (base * (72 - glow) + u32::from(accent[channel]) * glow) / 72;
pixels.push((lit as i32 - vignette as i32 + texture).clamp(0, 255) as u8);
}
}
}
pixels
}
fn write_if_changed(path: &Path, bytes: &[u8]) {
if fs::read(path).ok().as_deref() != Some(bytes) {
fs::write(path, bytes).expect("write generated files demo picture");
}
}
fn main() {
println!("cargo:rerun-if-changed=../../libs/ai/models/paint/src/png.rs");
println!("cargo:rerun-if-changed=build.rs");
let out_dir = env::var_os("OUT_DIR").expect("Cargo did not set OUT_DIR");
for (seed, (name, top, bottom, accent)) in PALETTES.iter().copied().enumerate() {
let pixels = picture(top, bottom, accent, seed as u32);
let encoded = png::encode_png(WIDTH, HEIGHT, png::PngColor::Rgb, &pixels);
write_if_changed(&Path::new(&out_dir).join(name), &encoded);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

View file

Before

Width:  |  Height:  |  Size: 427 B

After

Width:  |  Height:  |  Size: 427 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 297 B

After

Width:  |  Height:  |  Size: 297 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 199 B

After

Width:  |  Height:  |  Size: 199 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 130 B

After

Width:  |  Height:  |  Size: 130 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 160 B

After

Width:  |  Height:  |  Size: 160 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 130 B

After

Width:  |  Height:  |  Size: 130 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 247 B

After

Width:  |  Height:  |  Size: 247 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 175 B

After

Width:  |  Height:  |  Size: 175 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 342 B

After

Width:  |  Height:  |  Size: 342 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 183 B

After

Width:  |  Height:  |  Size: 183 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 230 B

After

Width:  |  Height:  |  Size: 230 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 218 B

After

Width:  |  Height:  |  Size: 218 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 305 B

After

Width:  |  Height:  |  Size: 305 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 109 B

After

Width:  |  Height:  |  Size: 109 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 252 B

After

Width:  |  Height:  |  Size: 252 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 197 B

After

Width:  |  Height:  |  Size: 197 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 128 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 127 B

After

Width:  |  Height:  |  Size: 127 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 359 B

After

Width:  |  Height:  |  Size: 359 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 186 B

After

Width:  |  Height:  |  Size: 186 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 152 B

After

Width:  |  Height:  |  Size: 152 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 159 B

After

Width:  |  Height:  |  Size: 159 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 188 B

After

Width:  |  Height:  |  Size: 188 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 141 B

After

Width:  |  Height:  |  Size: 141 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 398 B

After

Width:  |  Height:  |  Size: 398 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 185 B

After

Width:  |  Height:  |  Size: 185 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 256 B

After

Width:  |  Height:  |  Size: 256 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 148 B

After

Width:  |  Height:  |  Size: 148 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 166 B

After

Width:  |  Height:  |  Size: 166 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 305 B

After

Width:  |  Height:  |  Size: 305 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 120 B

After

Width:  |  Height:  |  Size: 120 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 152 B

After

Width:  |  Height:  |  Size: 152 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 140 B

After

Width:  |  Height:  |  Size: 140 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 160 B

After

Width:  |  Height:  |  Size: 160 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 92 B

After

Width:  |  Height:  |  Size: 92 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 93 B

After

Width:  |  Height:  |  Size: 93 B

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 713 B

After

Width:  |  Height:  |  Size: 713 B

Before After
Before After

View file

@ -0,0 +1,250 @@
//! The file browser on the desktop's AI bus.
//!
//! Hosted by the window manager, the app opens one [`AiServicePort`] with
//! the manifest from `chat_tools::service_manifest` and answers the calls
//! that come back through it. The tools are the same seven the app's own
//! panel has; what is new is how they run: every call carries the
//! engine's `call_id`, the answer carries it back, and the person (or the
//! router) can give up on a call mid-walk. The old panel's runner is
//! order-only and cannot do either, so this one sits beside it rather
//! than inside it, and the two never share a job.
//!
//! One worker thread, one job at a time. A job in flight is cancelled by
//! a flag the walk checks on every entry; a job still queued behind it is
//! cancelled before it starts. Progress from the walk and the finished
//! results come back on channels the UI drains on its signal.
use std::{
collections::HashMap,
path::PathBuf,
sync::{
atomic::{AtomicBool, Ordering},
mpsc::{channel, Receiver, Sender},
Arc,
},
};
use makepad_ai_services::wire::{ServiceCall, ToolResult};
use makepad_strict_json as json;
use makepad_widgets::makepad_platform::thread::{SignalToUI, ThreadOptions, ThreadSpawner};
use crate::chat_tools::{run_with, ToolJob, ToolOutcome};
#[cfg(test)]
use std::thread;
/// One call on its way to the worker.
struct ServiceJob {
call_id: String,
job: ToolJob,
cancel: Arc<AtomicBool>,
}
/// What the worker sends back, in the order it happened.
pub enum ServiceReply {
Result { result: ToolResult, mutated: bool },
Progress { call_id: String, note: String, permille: u16 },
}
/// The bus's tool worker: correlated by call id, cancellable.
pub struct ServiceRunner {
jobs: Sender<ServiceJob>,
replies: Receiver<ServiceReply>,
/// The cancel flag of every call not yet answered.
live: HashMap<String, Arc<AtomicBool>>,
}
impl ServiceRunner {
pub fn new(spawner: &ThreadSpawner) -> Self {
let (jobs, job_rx) = channel::<ServiceJob>();
let (reply_tx, replies) = channel::<ServiceReply>();
if let Ok(handle) = spawner.spawn_worker(
ThreadOptions { name: Some("files-ai-service".into()), ..Default::default() },
move || {
while let Ok(ServiceJob { call_id, job, cancel }) = job_rx.recv() {
let (result, mutated) = if cancel.load(Ordering::Relaxed) {
// Given up on while it waited its turn: never started.
(ToolResult::cancelled(&call_id), false)
} else {
let progress_tx = reply_tx.clone();
let progress_id = call_id.clone();
let progress = move |permille: u16| {
let _ = progress_tx.send(ServiceReply::Progress {
call_id: progress_id.clone(),
note: "measuring…".to_string(),
permille,
});
SignalToUI::set_ui_signal();
};
let outcome = run_with(&job, &cancel, &progress);
if cancel.load(Ordering::Relaxed) {
// The walk stopped early on the flag: what it has
// is a floor nobody asked for any more.
(ToolResult::cancelled(&call_id), false)
} else {
let mutated = outcome.mutated;
(result_for(&call_id, outcome), mutated)
}
};
if reply_tx.send(ServiceReply::Result { result, mutated }).is_err() {
return;
}
SignalToUI::set_ui_signal();
}
},
) {
handle.detach();
}
Self { jobs, replies, live: HashMap::new() }
}
/// Queue one call. `cwd` is the folder the person is looking at — what
/// a relative path is read from — and `home` the jail.
pub fn submit(&mut self, call: &ServiceCall, cwd: PathBuf, home: PathBuf) {
let cancel = Arc::new(AtomicBool::new(false));
self.live.insert(call.call_id.clone(), cancel.clone());
let job = ToolJob {
name: call.tool.clone(),
args: flat_args(&call.args),
cwd,
home,
};
let _ = self.jobs.send(ServiceJob { call_id: call.call_id.clone(), job, cancel });
}
/// Give up on one call, running or queued. Unknown ids are nothing.
pub fn cancel(&mut self, call_id: &str) {
if let Some(flag) = self.live.get(call_id) {
flag.store(true, Ordering::Relaxed);
}
}
/// Everything the worker sent since the last drain. A result retires
/// its call's flag.
pub fn drain(&mut self) -> Vec<ServiceReply> {
let replies: Vec<ServiceReply> = self.replies.try_iter().collect();
for reply in &replies {
if let ServiceReply::Result { result, .. } = reply {
self.live.remove(&result.call_id);
}
}
replies
}
}
/// A tool's outcome as the wire says it. The tools already sort their own
/// failures into "refused" (the jail, an unknown name) and "could not"
/// (the disk said no); the wire keeps that distinction.
fn result_for(call_id: &str, outcome: ToolOutcome) -> ToolResult {
if !outcome.is_error {
return ToolResult::ok(call_id, outcome.text, outcome.note);
}
if outcome.note.starts_with("refused") || outcome.note.starts_with("unknown tool") {
ToolResult::refused(call_id, outcome.text)
} else {
ToolResult::failed(call_id, outcome.text)
}
}
/// The call's JSON argument object as the tools read it: one string per
/// key. Numbers and booleans become their text; nested values are dropped,
/// since no tool here takes one.
pub fn flat_args(args: &str) -> Vec<(String, String)> {
let Ok(json::Value::Obj(fields)) = json::parse(args.as_bytes()) else {
return Vec::new();
};
fields
.into_iter()
.filter_map(|(key, value)| {
let text = match value {
json::Value::Str(s) => s,
json::Value::Int(i) => i.to_string(),
json::Value::F64(f) => f.to_string(),
json::Value::Bool(b) => b.to_string(),
_ => return None,
};
Some((key, text))
})
.collect()
}
#[cfg(test)]
// Native worker tests use std deadlines while exercising blocking threads.
#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
mod tests {
use super::*;
use makepad_ai_services::wire::ToolOutcome as Outcome;
use std::time::{Duration, Instant};
fn call(id: &str, tool: &str, args: &str) -> ServiceCall {
ServiceCall { call_id: id.into(), tool: tool.into(), args: args.into() }
}
fn wait_for(runner: &mut ServiceRunner, n: usize) -> Vec<ToolResult> {
let deadline = Instant::now() + Duration::from_secs(20);
let mut out = Vec::new();
while out.len() < n && Instant::now() < deadline {
for reply in runner.drain() {
if let ServiceReply::Result { result: r, .. } = reply {
out.push(r);
}
}
thread::sleep(Duration::from_millis(5));
}
out
}
#[test]
fn json_args_become_the_tools_flat_pairs() {
let args = flat_args(r#"{"path":"~/Downloads","top":3,"deep":{"x":1},"quick":true}"#);
assert_eq!(
args,
vec![
("path".to_string(), "~/Downloads".to_string()),
("top".to_string(), "3".to_string()),
("quick".to_string(), "true".to_string()),
]
);
assert!(flat_args("not json").is_empty());
assert!(flat_args("[1,2]").is_empty());
}
#[test]
fn results_carry_their_own_call_ids_and_a_queued_cancel_never_runs() {
let home = crate::model::home_dir();
let spawner = makepad_widgets::Cx::new(Box::new(|_, _| {})).thread_spawner();
let mut runner = ServiceRunner::new(&spawner);
// An unknown tool is refused; a jail escape is refused; both keep
// their ids whatever order the worker answers in.
runner.submit(&call("a", "stat", r#"{"path":"/etc/passwd"}"#), home.clone(), home.clone());
runner.submit(&call("b", "rm_rf", r#"{"path":"~"}"#), home.clone(), home.clone());
runner.submit(&call("c", "stat", r#"{"path":"~"}"#), home.clone(), home.clone());
// Cancelled while it is still queued: it must come back cancelled
// without the tool ever running.
runner.cancel("c");
assert_eq!(runner.live.len(), 3);
let results = wait_for(&mut runner, 3);
assert_eq!(results.len(), 3);
let by_id = |id: &str| results.iter().find(|r| r.call_id == id).unwrap();
assert_eq!(by_id("a").outcome, Outcome::Refused);
assert!(by_id("a").text.contains("refused"));
assert_eq!(by_id("b").outcome, Outcome::Refused);
assert!(by_id("b").text.contains("no tool called"));
assert_eq!(by_id("c").outcome, Outcome::Cancelled);
assert_eq!(runner.live.len(), 0);
// An unknown id is nothing.
runner.cancel("zzz");
}
#[test]
fn a_folder_walk_stops_on_the_flag() {
let cancel = AtomicBool::new(true);
let started = Instant::now();
let (bytes, files, complete) = crate::chat_tools::measure_for_test(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")),
&cancel,
);
assert_eq!((bytes, files, complete), (0, 0, false));
assert!(started.elapsed() < Duration::from_secs(1));
}
}

View file

@ -1,6 +1,6 @@
//! The sidebar's bookmarks: folders the user keeps, in their own section.
//!
//! Bookmarks are the one piece of mpfiles state that outlives the process, so
//! Bookmarks are the one piece of files state that outlives the process, so
//! the format is the one a person can fix in an editor when it goes wrong: one
//! absolute path per line, in the order the sidebar shows them. That is also
//! what GNOME Files stores (`~/.config/gtk-3.0/bookmarks`), minus the URI
@ -18,9 +18,9 @@ use std::{
/// last slot would be saved and never shown, which is worse than refusing it.
pub const MAX_BOOKMARKS: usize = 12;
/// The bookmarks file for a given home directory.
pub fn config_path(home: &Path) -> PathBuf {
home.join(".config").join("mpfiles").join("bookmarks")
/// The bookmarks file for a given Makepad home directory.
pub fn config_path(makepad_home: &Path) -> PathBuf {
makepad_home.join("files/bookmarks")
}
/// The bookmark list, in sidebar order, and where it is persisted.
@ -160,7 +160,7 @@ mod tests {
#[test]
fn survives_a_round_trip_through_a_real_file() {
let home = std::env::temp_dir().join("mpfiles-test-bookmarks");
let home = std::env::temp_dir().join("files-test-bookmarks");
let _ = fs::remove_dir_all(&home);
fs::create_dir_all(&home).unwrap();

View file

@ -17,7 +17,7 @@ pub use makepad_ai_hub::local_llm::ChatEvent;
/// Where the weights live, relative to the checkout this was built from.
pub const MODEL_FILE: &str = "local/models/Qwen3.5-9B-UD-Q4_K_XL.gguf";
/// The environment variable that overrides it.
pub const MODEL_ENV: &str = "MPFILES_CHAT_MODEL";
pub const MODEL_ENV: &str = "MAKEPAD_FILES_CHAT_MODEL";
pub struct ChatAgent {
session: HubChatSession,
@ -57,7 +57,7 @@ impl ChatAgent {
/// Where the weights are, or `None` when this machine has none.
///
/// `MPFILES_CHAT_MODEL` wins; otherwise the file is looked for relative to the
/// `MAKEPAD_FILES_CHAT_MODEL` wins; otherwise the file is looked for relative to the
/// working directory, then up from the binary (which finds `target/release`
/// runs from anywhere), then in the checkout this binary was compiled in.
pub fn model_path() -> Option<PathBuf> {

1019
apps/files/src/chat_tools.rs Normal file

File diff suppressed because it is too large Load diff

1674
apps/files/src/demo.rs Normal file

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more