@cyanheads/pixoo-mcp-server 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/AGENTS.md +5 -2
  2. package/CLAUDE.md +5 -2
  3. package/README.md +13 -9
  4. package/changelog/1.2.x/1.2.2.md +40 -0
  5. package/dist/mcp-server/resources/definitions/pixoo-design-guide.resource.d.ts.map +1 -1
  6. package/dist/mcp-server/resources/definitions/pixoo-design-guide.resource.js +4 -1
  7. package/dist/mcp-server/resources/definitions/pixoo-design-guide.resource.js.map +1 -1
  8. package/dist/mcp-server/tools/definitions/pixoo-compose-scene.tool.d.ts +96 -2
  9. package/dist/mcp-server/tools/definitions/pixoo-compose-scene.tool.d.ts.map +1 -1
  10. package/dist/mcp-server/tools/definitions/pixoo-compose-scene.tool.js +85 -10
  11. package/dist/mcp-server/tools/definitions/pixoo-compose-scene.tool.js.map +1 -1
  12. package/dist/mcp-server/tools/definitions/pixoo-design-brief.tool.d.ts.map +1 -1
  13. package/dist/mcp-server/tools/definitions/pixoo-design-brief.tool.js +14 -6
  14. package/dist/mcp-server/tools/definitions/pixoo-design-brief.tool.js.map +1 -1
  15. package/dist/mcp-server/tools/definitions/pixoo-display-text.tool.d.ts +2 -0
  16. package/dist/mcp-server/tools/definitions/pixoo-display-text.tool.d.ts.map +1 -1
  17. package/dist/mcp-server/tools/definitions/pixoo-display-text.tool.js +27 -12
  18. package/dist/mcp-server/tools/definitions/pixoo-display-text.tool.js.map +1 -1
  19. package/dist/mcp-server/tools/definitions/pixoo-push-image.tool.d.ts +30 -1
  20. package/dist/mcp-server/tools/definitions/pixoo-push-image.tool.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/definitions/pixoo-push-image.tool.js +105 -26
  22. package/dist/mcp-server/tools/definitions/pixoo-push-image.tool.js.map +1 -1
  23. package/dist/mcp-server/tools/finish-schema.d.ts +28 -0
  24. package/dist/mcp-server/tools/finish-schema.d.ts.map +1 -0
  25. package/dist/mcp-server/tools/finish-schema.js +42 -0
  26. package/dist/mcp-server/tools/finish-schema.js.map +1 -0
  27. package/dist/renderer/finish.d.ts +34 -0
  28. package/dist/renderer/finish.d.ts.map +1 -0
  29. package/dist/renderer/finish.js +62 -0
  30. package/dist/renderer/finish.js.map +1 -0
  31. package/dist/renderer/keyframes.d.ts +5 -1
  32. package/dist/renderer/keyframes.d.ts.map +1 -1
  33. package/dist/renderer/keyframes.js +16 -2
  34. package/dist/renderer/keyframes.js.map +1 -1
  35. package/dist/renderer/remote-image.d.ts +9 -7
  36. package/dist/renderer/remote-image.d.ts.map +1 -1
  37. package/dist/renderer/remote-image.js +13 -21
  38. package/dist/renderer/remote-image.js.map +1 -1
  39. package/dist/renderer/scene-renderer.d.ts +45 -6
  40. package/dist/renderer/scene-renderer.d.ts.map +1 -1
  41. package/dist/renderer/scene-renderer.js +203 -65
  42. package/dist/renderer/scene-renderer.js.map +1 -1
  43. package/dist/renderer/text-engine.d.ts +24 -5
  44. package/dist/renderer/text-engine.d.ts.map +1 -1
  45. package/dist/renderer/text-engine.js +51 -28
  46. package/dist/renderer/text-engine.js.map +1 -1
  47. package/package.json +2 -2
  48. package/server.json +3 -3
package/AGENTS.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** pixoo-mcp-server
4
- **Version:** 1.2.1
4
+ **Version:** 1.2.2
5
5
  **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.9`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.1.0 (protocol revision 2026-07-28 alongside the 2025 era)
@@ -187,6 +187,7 @@ Pixoo-specific error reasons declared on tools:
187
187
  | `device_rejected` | `ServiceUnavailable` | Firmware returned non-zero `error_code` | — |
188
188
  | `no_device_configured` | `InvalidParams` | Device tool called without `PIXOO_IP` | — |
189
189
  | `asset_not_found` | `NotFound` | Image/sprite path or URL unreadable | — |
190
+ | `invalid_image` | `InvalidParams` | Image source or sprite path read but not decodable | — |
190
191
  | `invalid_color` | `InvalidParams` | `resolveColor` throw — invalid color name or format | — |
191
192
  | `unknown_icon` | `InvalidParams` | Icon name not in registry | — |
192
193
  | `discovery_failed` | `ServiceUnavailable` | Divoom cloud unreachable | `true` |
@@ -234,11 +235,13 @@ src/
234
235
  text-engine.ts # Gradient ramp + shadow + outline text engine, overflow handling
235
236
  scene-renderer.ts # Element vocabulary, layout resolver, frame rendering
236
237
  keyframes.ts # Keyframe interpolation + animation preset compiler
238
+ finish.ts # Palette finish (quantize + dither) for one frame, or frames sharing one palette
237
239
  preview.ts # PNG/contact-sheet/GIF encoding
238
- remote-image.ts # https image fetch to a temp file for the toolkit loader; stops on ctx.signal
240
+ remote-image.ts # https image fetch into memory for the toolkit loader; stops on ctx.signal
239
241
  mcp-server/
240
242
  tools/
241
243
  device-push.ts # Shared post-render push: preview kept on failure, visibility notice
244
+ finish-schema.ts # Shared `finish` input schema (colors | palette, plus dither)
242
245
  tools/definitions/
243
246
  pixoo-display-text.tool.ts
244
247
  pixoo-compose-scene.tool.ts
package/CLAUDE.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** pixoo-mcp-server
4
- **Version:** 1.2.1
4
+ **Version:** 1.2.2
5
5
  **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.9`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
7
  **MCP SDK:** `@modelcontextprotocol/server` ^2.1.0 (protocol revision 2026-07-28 alongside the 2025 era)
@@ -187,6 +187,7 @@ Pixoo-specific error reasons declared on tools:
187
187
  | `device_rejected` | `ServiceUnavailable` | Firmware returned non-zero `error_code` | — |
188
188
  | `no_device_configured` | `InvalidParams` | Device tool called without `PIXOO_IP` | — |
189
189
  | `asset_not_found` | `NotFound` | Image/sprite path or URL unreadable | — |
190
+ | `invalid_image` | `InvalidParams` | Image source or sprite path read but not decodable | — |
190
191
  | `invalid_color` | `InvalidParams` | `resolveColor` throw — invalid color name or format | — |
191
192
  | `unknown_icon` | `InvalidParams` | Icon name not in registry | — |
192
193
  | `discovery_failed` | `ServiceUnavailable` | Divoom cloud unreachable | `true` |
@@ -234,11 +235,13 @@ src/
234
235
  text-engine.ts # Gradient ramp + shadow + outline text engine, overflow handling
235
236
  scene-renderer.ts # Element vocabulary, layout resolver, frame rendering
236
237
  keyframes.ts # Keyframe interpolation + animation preset compiler
238
+ finish.ts # Palette finish (quantize + dither) for one frame, or frames sharing one palette
237
239
  preview.ts # PNG/contact-sheet/GIF encoding
238
- remote-image.ts # https image fetch to a temp file for the toolkit loader; stops on ctx.signal
240
+ remote-image.ts # https image fetch into memory for the toolkit loader; stops on ctx.signal
239
241
  mcp-server/
240
242
  tools/
241
243
  device-push.ts # Shared post-render push: preview kept on failure, visibility notice
244
+ finish-schema.ts # Shared `finish` input schema (colors | palette, plus dither)
242
245
  tools/definitions/
243
246
  pixoo-display-text.tool.ts
244
247
  pixoo-compose-scene.tool.ts
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  <div align="center">
9
9
 
10
- [![Version](https://img.shields.io/badge/Version-1.2.1-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/pixoo-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.1.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/pixoo-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/pixoo-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.2-blueviolet.svg?style=flat-square)](https://bun.sh/)
10
+ [![Version](https://img.shields.io/badge/Version-1.2.2-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/pixoo-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.1.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/pixoo-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/pixoo-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.2-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
11
 
12
12
  </div>
13
13
 
@@ -31,7 +31,7 @@ Divoom Pixoo LED matrix displays on the local network, with the Pixoo-64 as the
31
31
  |:---|:---|
32
32
  | `pixoo_display_text` | Render styled text with themes, gradients, shadows, and auto-fit, static or animated, and push it |
33
33
  | `pixoo_compose_scene` | Compose layered scenes of text, icons, widgets, shapes, bitmaps, images, and sprites, static or animated |
34
- | `pixoo_push_image` | Resize a local or https image to the LED grid and push it |
34
+ | `pixoo_push_image` | Resize a local or https image to the LED grid and push it, an animated GIF or WebP as an animation |
35
35
  | `pixoo_overlay_text` | Set or clear a device-rendered scrolling text overlay |
36
36
  | `pixoo_control_device` | Read or change brightness, screen state, channel, or clock face |
37
37
  | `pixoo_discover_devices` | Find Pixoo devices and their LAN IPs through Divoom's cloud discovery |
@@ -53,15 +53,17 @@ Tools cover the same ground for tool-only clients: `pixoo_control_device` reads
53
53
  ### `pixoo_display_text` <sub>tool</sub>
54
54
 
55
55
  - `text` as a string or an array of lines; `theme` (`midnight`, `ember`, `claude`, `ice`, `neon`, `forest`, `mono`) sets the background and default palette. `style` takes a `palette` ramp (`ember`, `ice`, `neon`, `fire`, `lavender`, `claude`, `mono`) or a custom `{ from, to }`, plus `shadow`, `outline`, and `scale` 1–8; `position` is semantic or in pixels, and `align` lines up multi-line text
56
- - `layout[]` reports every fit decision as an `action` (`shrunk-to-compact`, `scrolling`, `truncated`, …). Single-line text falls back from the standard to the compact font unless `font` is set, and text still too wide only scrolls under `effect: "auto"` or `"scroll"`
56
+ - `font`: `standard` (5×7) and `compact` (3×5) draw printable ASCII plus `° ← ↑ → ↓ ▲ ▼ ♥ · …`; `numerals` is an 11×18 digit face for clocks and readouts that draws 0–9, space, and `: . - + / % ° ?`, and text holding any other character fails validation, naming those characters
57
+ - `layout[]` reports every fit decision as an `action` (`none`, `shrunk-to-compact`, or `scrolling`), the `font` used, and whether each line's box `fits` on the panel; a scrolling line's box starts at x 0, where its static frame draws it. Single-line text falls back from the standard to the compact font unless `font` is set — never to `numerals` — and text still too wide only scrolls under `effect: "auto"` or `"scroll"`
57
58
  - `effect`: `scroll` makes one pass in up to 40 frames, `auto` scrolls only on overflow, and `float` and `pulse` loop over 20 frames; `frames` reports the count, and the animation pushes as one device animation
58
59
 
59
60
  ---
60
61
 
61
62
  ### `pixoo_compose_scene` <sub>tool</sub>
62
63
 
63
- - Up to 50 `elements` drawn back-to-front: `text`, `icon`, `rect`, `circle`, `line`, `progress`, `sparkline`, `bitmap`, `pixels`, `image` (absolute path or https URL), `sprite` (absolute path). The `background` is a solid color, a `v` / `h` / `r` gradient, or a `theme`
64
- - Returns `layout[]`: each element's placed box and whether it `fits` on the panel. An absolute `output` path saves the first frame as a PNG in place of the `PIXOO_OUTPUT_DIR` auto-save. Typed failures: `asset_not_found`, `invalid_color`, `unknown_icon`, `invalid_output_path`
64
+ - Up to 50 `elements` drawn back-to-front: `text` (in the same three fonts as `pixoo_display_text`), `icon`, `rect`, `circle`, `line`, `progress`, `sparkline`, `bitmap`, `pixels`, `image` (absolute path or https URL, with the same `finish` as `pixoo_push_image`), `sprite` (absolute path). The `background` is a solid color, a `v` / `h` / `r` gradient, or a `theme`
65
+ - Every element takes `opacity` (each pixel lands at its own alpha × `opacity`, so soft edges fade evenly) and `blend`: `normal`, `add` (glows and light beams), `screen`, or `multiply`. `line` and outline `circle` take `strokeWidth` and `antialias`, and a `rect` border takes `strokeWidth`, growing inward; either field on a shape that draws no stroke fails validation, naming it
66
+ - Returns `layout[]`: each element's placed box — for a wide or anti-aliased stroke, every pixel it draws — and whether it `fits` on the panel. An absolute `output` path saves the first frame as a PNG in place of the `PIXOO_OUTPUT_DIR` auto-save. Typed failures: `asset_not_found`, `invalid_image` (an image or sprite that was read but does not decode), `invalid_color`, `unknown_icon`, `invalid_output_path`
65
67
  - Animation through per-element `effect` presets (`float`, `scroll-left`, `scroll-right`, `pulse`, `blink`, `twinkle`, `drift`, `fade-in`, `fade-out`) or raw `animate` keyframes over `dx`, `dy`, `opacity` (numbers or numeric strings), `visible` (`true`/`false`), and `color` (interpolated through RGB on any element with a `color`), each track holding at least one keyframe; `frames` 1–40, `speed` 10–2000 ms per frame (default 150). An effect's `amplitude` sets the movement of `float`, `scroll-*`, and `drift`, and the 0–1 depth of the `pulse` and `twinkle` opacity dip
66
68
 
67
69
  ---
@@ -69,7 +71,9 @@ Tools cover the same ground for tool-only clients: `pixoo_control_device` reads
69
71
  ### `pixoo_push_image` <sub>tool</sub>
70
72
 
71
73
  - `source` is an absolute local path or an https URL, with downloads capped at 10 MB; `fit` is `contain` (default), `cover`, or `fill`, and `kernel` is `nearest` (default, for pixel art), `lanczos3` (photos), or `mitchell`
72
- - An unreadable path or URL fails as `asset_not_found`; the preview is the exact resized frame the device receives
74
+ - A source that decodes as an animated GIF or WebP, whatever its file name, pushes as an animation of up to 40 frames, sampled evenly from a longer source. It plays at the source's total duration over the pushed frame count (150 ms when the source records no delays), or at `speed` (10–2000 ms per frame); `frames`, `sourceFrames`, and `speed` report what was pushed
75
+ - `finish` reduces the image to a palette before the push: exactly one of `colors` (2–256, built from the image) or `palette` (1–256 hex or named colors), plus `dither` (`none`, `bayer4`, `floyd-steinberg`). Transparent pixels stay unlit, and an animation's `colors` palette is shared by every frame
76
+ - An unreadable path or URL fails as `asset_not_found`, a source that is read but does not decode (a text file, an HTML page, a truncated download) fails as `invalid_image`, and an unresolvable `finish` palette entry fails as `invalid_color`; the preview is the exact frame the device receives, or a grid of every frame for an animation
73
77
 
74
78
  ---
75
79
 
@@ -135,7 +139,7 @@ Pixoo-specific:
135
139
 
136
140
  - All composition happens on the host in an RGBA canvas pipeline (`@cyanheads/pixoo-toolkit`); the device receives finished RGB frames
137
141
  - Pushes switch the device to the custom channel and run one at a time, spaced by `PIXOO_PUSH_MIN_INTERVAL_MS` (default 1000) so rapid pushes don't freeze the device
138
- - Animations cap at 40 frames, past which the device becomes unstable
142
+ - Animations cap at 40 frames, past which the device becomes unstable; `pixoo_push_image` samples a longer GIF or WebP down to 40
139
143
 
140
144
  Agent-friendly output:
141
145
 
@@ -300,10 +304,10 @@ The Dockerfile defaults to HTTP transport, stateless session mode, and logs to `
300
304
  |:---|:---|
301
305
  | `src/index.ts` | `createApp()` entry point: registers tools and resources and initializes the Pixoo service. |
302
306
  | `src/config/` | Server-specific environment variable parsing and validation with Zod. |
303
- | `src/mcp-server/tools/` | Tool definitions (`*.tool.ts`) and the shared post-render push path. |
307
+ | `src/mcp-server/tools/` | Tool definitions (`*.tool.ts`), the shared post-render push path, and the shared `finish` input schema. |
304
308
  | `src/mcp-server/resources/` | Resource definitions (`*.resource.ts`). |
305
309
  | `src/services/pixoo/` | `PixooService`: wraps `@cyanheads/pixoo-toolkit` with push pacing, result mapping, and device state reads. |
306
- | `src/renderer/` | Pure rendering pipeline with no device dependency: element renderers, styled-text engine, themes, icons, effect compiler, preview encoding, remote image fetch. |
310
+ | `src/renderer/` | Pure rendering pipeline with no device dependency: element renderers, styled-text engine, themes, icons, effect compiler, palette finishing, preview encoding, remote image fetch. |
307
311
  | `tests/` | Unit and integration tests mirroring `src/`. |
308
312
 
309
313
  ## Development guide
@@ -0,0 +1,40 @@
1
+ ---
2
+ summary: "Adopts @cyanheads/pixoo-toolkit 0.10.0: animated GIF and WebP push as animations, images take a palette finish, scene elements take blend modes and stroke widths, and text gains a numerals face. Undecodable images fail as invalid_image, and filled circles and JPEG/WebP images render differently."
3
+ breaking: false
4
+ security: false
5
+ ---
6
+
7
+ # 1.2.2 — 2026-09-26
8
+
9
+ ## Added
10
+
11
+ - **`finish` on `pixoo_push_image` and scene `image` elements** reduces the image to a palette: `colors` (2–256) or `palette` (1–256 colors), plus `dither` (`none`, `bayer4`, `floyd-steinberg`) ([#27](https://github.com/cyanheads/pixoo-mcp-server/issues/27)). An animation's `colors` palette is shared across frames; a bad palette entry fails as `invalid_color`.
12
+ - **`blend` on every `pixoo_compose_scene` element** — `normal` (default), `add`, `screen`, or `multiply` ([#29](https://github.com/cyanheads/pixoo-mcp-server/issues/29)).
13
+ - **`strokeWidth` and `antialias` on `line` and outline `circle`; `strokeWidth` on a `rect` border**, growing inward (#29). Either on a shape that draws no stroke fails `-32602` naming it; a wide or anti-aliased stroke's layout box covers every pixel drawn.
14
+ - **`font: "numerals"`** on `pixoo_display_text` and scene `text` — an 11×18 digit face drawing 0–9, space, and `: . - + / % ° ?`; any other character fails `-32602` naming it, and auto-fit never picks it ([#30](https://github.com/cyanheads/pixoo-mcp-server/issues/30)).
15
+ - **`pixoo_push_image` `speed` input and `frames`, `sourceFrames`, `speed` output fields** for animated sources ([#28](https://github.com/cyanheads/pixoo-mcp-server/issues/28)).
16
+ - **`pixoo_output_dir` and `pixoo_push_min_interval_ms` options in `manifest.json`**, so a Claude Desktop bundle install can set `PIXOO_OUTPUT_DIR` and `PIXOO_PUSH_MIN_INTERVAL_MS`.
17
+
18
+ ## Changed
19
+
20
+ - **An animated GIF or WebP given to `pixoo_push_image` pushes as an animation** instead of its first frame, whatever its file extension (#28). Up to 40 frames, sampled evenly from a longer source, play at the source's total duration over the pushed frame count.
21
+ - **Filled `circle` elements draw a rounder disc** (`dx² + dy² ≤ r² + r`, from the toolkit), so every filled circle's pixels change.
22
+ - **JPEG and WebP images decode at full resolution before resizing** (from the toolkit), so their pushed pixels change.
23
+ - **An image source that is read but does not decode fails as `-32602` `invalid_image`** naming the source and the decoder's reason, instead of an unclassified `-32603`, in `pixoo_push_image` and compose `image` and `sprite` elements ([#34](https://github.com/cyanheads/pixoo-mcp-server/issues/34)).
24
+ - **`asset_not_found` carries its tool's declared recovery hint, reworded, on both image tools**, and a sprite `path` given as a URL says sprite sheets take a local path ([#51](https://github.com/cyanheads/pixoo-mcp-server/issues/51)).
25
+ - **https image sources load from memory** — they render when the temp dir is unwritable, and an https SVG renders like the same local file instead of rasterizing at its intrinsic size ([#23](https://github.com/cyanheads/pixoo-mcp-server/issues/23)).
26
+ - **A partially transparent pixel under `opacity` below 100 fades at its own alpha × opacity**, so soft-edged images no longer brighten as they fade (#29).
27
+ - **`twinkle` renders the same frames for the same input**; its jitter comes from the frame index and element position ([#50](https://github.com/cyanheads/pixoo-mcp-server/issues/50)).
28
+ - **`pixoo_display_text` reports `fits: false` for text leaving the panel on any edge** ([#49](https://github.com/cyanheads/pixoo-mcp-server/issues/49)), and a scrolling line reports `box.x: 0`, where its frame draws it ([#55](https://github.com/cyanheads/pixoo-mcp-server/issues/55)).
29
+ - **`standard` and `compact` draw `° ← ↑ → ↓ ▲ ▼ ♥ · …` as glyphs** instead of `?` (#30).
30
+ - **`Object.prototype` names such as `constructor` fail as `invalid_color`** on every color input of `pixoo_compose_scene`, `pixoo_display_text`, and `pixoo_overlay_text`, which now rejects them before any device call ([#32](https://github.com/cyanheads/pixoo-mcp-server/issues/32)).
31
+ - **`pixoo_design_brief` and the design guide** describe dithering, anti-aliasing, the new glyphs and numerals face, a glow recipe, and the Pixoo-64's measured dark and mid-level response.
32
+
33
+ ## Fixed
34
+
35
+ - **`pixoo_compose_scene` loads each distinct asset once per call** — elements sharing a sprite sheet, or an image source and placement, share one decode, and a URL is fetched once ([#52](https://github.com/cyanheads/pixoo-mcp-server/issues/52)).
36
+ - **Standard text at scale 2 is 14 px tall** in the design brief and guide, which said 10 px.
37
+
38
+ ## Dependencies
39
+
40
+ - `@cyanheads/pixoo-toolkit` ^0.8.2 → ^0.10.0, exempt from the `bunfig.toml` release-age hold beside the framework
@@ -1 +1 @@
1
- {"version":3,"file":"pixoo-design-guide.resource.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/resources/definitions/pixoo-design-guide.resource.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAY,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAwGrD,eAAO,MAAM,wBAAwB,2GAyBnC,CAAC"}
1
+ {"version":3,"file":"pixoo-design-guide.resource.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/resources/definitions/pixoo-design-guide.resource.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAY,CAAC,EAAE,MAAM,wBAAwB,CAAC;AA2GrD,eAAO,MAAM,wBAAwB,2GAyBnC,CAAC"}
@@ -13,8 +13,10 @@ At typical viewing distance for a 64×64 LED display:
13
13
  - **Minimum eye size:** 1–2px across; 2×2 fills are more reliable than single pixels
14
14
  - **Minimum limb gap:** 2 rows between horizontal elements prevents blur
15
15
  - **Font choices:**
16
- - Standard (5×7): default for all text. Readable at 1× scale. Excellent at 2× (10px tall = chunky block letters)
16
+ - Standard (5×7): default for all text. Readable at 1× scale. Excellent at 2× (14px tall = chunky block letters)
17
17
  - Compact (3×5): secondary text, captions, labels when space is tight
18
+ - Standard and compact both draw printable ASCII plus \`° ← ↑ → ↓ ▲ ▼ ♥ · …\` for units, trends, and separators
19
+ - Numerals (11×18): clocks and hero readouts. Draws 0–9, space, and \`: . - + / % ° ?\` only, each digit on one 13px advance so a changing value keeps its width. Auto-fit never picks it; text holding any other character is rejected, so units and labels go in their own standard or compact text element
18
20
  - Scale ≥2: decorative or hero use only — verify fit before pushing
19
21
 
20
22
  ## Palette Discipline
@@ -103,6 +105,7 @@ At typical viewing distance for a 64×64 LED display:
103
105
  - **getConfig() omits SelectIndex** on current Pixoo-64 firmware — use getChannel() for reliable channel reads
104
106
  - **Text overlays (pixoo_overlay_text)** persist across channel switches until explicitly cleared
105
107
  - **Discovery requires internet** — Divoom cloud endpoint even for local device control
108
+ - **Dark and mid levels render darker than on a monitor** — on a Pixoo-64 at brightness 100, channel levels 0–4 stay dark, and mid-levels render darker than on an sRGB monitor, shifting muted warm colors toward red (\`#D97757\` reads red). Previews show the sRGB values pushed, not the panel's response.
106
109
  `;
107
110
  export const pixooDesignGuideResource = resource('pixoo://reference/design-guide', {
108
111
  name: 'design-guide',
@@ -1 +1 @@
1
- {"version":3,"file":"pixoo-design-guide.resource.js","sourceRoot":"","sources":["../../../../src/mcp-server/resources/definitions/pixoo-design-guide.resource.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,QAAQ,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAErD,MAAM,YAAY,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoGpB,CAAC;AAEF,MAAM,CAAC,MAAM,wBAAwB,GAAG,QAAQ,CAAC,gCAAgC,EAAE;IACjF,IAAI,EAAE,cAAc;IACpB,KAAK,EAAE,oBAAoB;IAC3B,WAAW,EACT,uNAAuN;IACzN,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC;IACpB,qEAAqE;IACrE,SAAS,EAAE,EAAE,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,QAAQ,EAAE;IAEtD,OAAO,CAAC,OAAO,EAAE,IAAI;QACnB,OAAO,YAAY,CAAC;IACtB,CAAC;IAED,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC;QACjB,SAAS,EAAE;YACT;gBACE,GAAG,EAAE,gCAAgC;gBACrC,IAAI,EAAE,cAAc;gBACpB,QAAQ,EAAE,eAAe;gBACzB,WAAW,EACT,iFAAiF;aACpF;SACF;KACF,CAAC;CACH,CAAC,CAAC"}
1
+ {"version":3,"file":"pixoo-design-guide.resource.js","sourceRoot":"","sources":["../../../../src/mcp-server/resources/definitions/pixoo-design-guide.resource.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAE,QAAQ,EAAE,CAAC,EAAE,MAAM,wBAAwB,CAAC;AAErD,MAAM,YAAY,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuGpB,CAAC;AAEF,MAAM,CAAC,MAAM,wBAAwB,GAAG,QAAQ,CAAC,gCAAgC,EAAE;IACjF,IAAI,EAAE,cAAc;IACpB,KAAK,EAAE,oBAAoB;IAC3B,WAAW,EACT,uNAAuN;IACzN,QAAQ,EAAE,eAAe;IACzB,MAAM,EAAE,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC;IACpB,qEAAqE;IACrE,SAAS,EAAE,EAAE,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,QAAQ,EAAE;IAEtD,OAAO,CAAC,OAAO,EAAE,IAAI;QACnB,OAAO,YAAY,CAAC;IACtB,CAAC;IAED,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC;QACjB,SAAS,EAAE;YACT;gBACE,GAAG,EAAE,gCAAgC;gBACrC,IAAI,EAAE,cAAc;gBACpB,QAAQ,EAAE,eAAe;gBACzB,WAAW,EACT,iFAAiF;aACpF;SACF;KACF,CAAC;CACH,CAAC,CAAC"}
@@ -29,6 +29,12 @@ export declare const pixooComposeScene: import("@cyanheads/mcp-ts-core").ToolDef
29
29
  elements: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
30
30
  visible: z.ZodOptional<z.ZodBoolean>;
31
31
  opacity: z.ZodOptional<z.ZodNumber>;
32
+ blend: z.ZodOptional<z.ZodEnum<{
33
+ add: "add";
34
+ multiply: "multiply";
35
+ normal: "normal";
36
+ screen: "screen";
37
+ }>>;
32
38
  dx: z.ZodOptional<z.ZodNumber>;
33
39
  dy: z.ZodOptional<z.ZodNumber>;
34
40
  effect: z.ZodOptional<z.ZodObject<{
@@ -69,6 +75,7 @@ export declare const pixooComposeScene: import("@cyanheads/mcp-ts-core").ToolDef
69
75
  color: z.ZodOptional<z.ZodString>;
70
76
  font: z.ZodOptional<z.ZodEnum<{
71
77
  compact: "compact";
78
+ numerals: "numerals";
72
79
  standard: "standard";
73
80
  }>>;
74
81
  style: z.ZodOptional<z.ZodObject<{
@@ -92,6 +99,12 @@ export declare const pixooComposeScene: import("@cyanheads/mcp-ts-core").ToolDef
92
99
  }, z.core.$strip>, z.ZodObject<{
93
100
  visible: z.ZodOptional<z.ZodBoolean>;
94
101
  opacity: z.ZodOptional<z.ZodNumber>;
102
+ blend: z.ZodOptional<z.ZodEnum<{
103
+ add: "add";
104
+ multiply: "multiply";
105
+ normal: "normal";
106
+ screen: "screen";
107
+ }>>;
95
108
  dx: z.ZodOptional<z.ZodNumber>;
96
109
  dy: z.ZodOptional<z.ZodNumber>;
97
110
  effect: z.ZodOptional<z.ZodObject<{
@@ -146,6 +159,12 @@ export declare const pixooComposeScene: import("@cyanheads/mcp-ts-core").ToolDef
146
159
  }, z.core.$strip>, z.ZodObject<{
147
160
  visible: z.ZodOptional<z.ZodBoolean>;
148
161
  opacity: z.ZodOptional<z.ZodNumber>;
162
+ blend: z.ZodOptional<z.ZodEnum<{
163
+ add: "add";
164
+ multiply: "multiply";
165
+ normal: "normal";
166
+ screen: "screen";
167
+ }>>;
149
168
  dx: z.ZodOptional<z.ZodNumber>;
150
169
  dy: z.ZodOptional<z.ZodNumber>;
151
170
  effect: z.ZodOptional<z.ZodObject<{
@@ -186,9 +205,16 @@ export declare const pixooComposeScene: import("@cyanheads/mcp-ts-core").ToolDef
186
205
  to: z.ZodString;
187
206
  }, z.core.$strip>>;
188
207
  borderColor: z.ZodOptional<z.ZodString>;
208
+ strokeWidth: z.ZodOptional<z.ZodNumber>;
189
209
  }, z.core.$strip>, z.ZodObject<{
190
210
  visible: z.ZodOptional<z.ZodBoolean>;
191
211
  opacity: z.ZodOptional<z.ZodNumber>;
212
+ blend: z.ZodOptional<z.ZodEnum<{
213
+ add: "add";
214
+ multiply: "multiply";
215
+ normal: "normal";
216
+ screen: "screen";
217
+ }>>;
192
218
  dx: z.ZodOptional<z.ZodNumber>;
193
219
  dy: z.ZodOptional<z.ZodNumber>;
194
220
  effect: z.ZodOptional<z.ZodObject<{
@@ -220,9 +246,17 @@ export declare const pixooComposeScene: import("@cyanheads/mcp-ts-core").ToolDef
220
246
  radius: z.ZodNumber;
221
247
  color: z.ZodOptional<z.ZodString>;
222
248
  fill: z.ZodOptional<z.ZodBoolean>;
249
+ strokeWidth: z.ZodOptional<z.ZodNumber>;
250
+ antialias: z.ZodOptional<z.ZodBoolean>;
223
251
  }, z.core.$strip>, z.ZodObject<{
224
252
  visible: z.ZodOptional<z.ZodBoolean>;
225
253
  opacity: z.ZodOptional<z.ZodNumber>;
254
+ blend: z.ZodOptional<z.ZodEnum<{
255
+ add: "add";
256
+ multiply: "multiply";
257
+ normal: "normal";
258
+ screen: "screen";
259
+ }>>;
226
260
  dx: z.ZodOptional<z.ZodNumber>;
227
261
  dy: z.ZodOptional<z.ZodNumber>;
228
262
  effect: z.ZodOptional<z.ZodObject<{
@@ -254,9 +288,17 @@ export declare const pixooComposeScene: import("@cyanheads/mcp-ts-core").ToolDef
254
288
  x1: z.ZodNumber;
255
289
  y1: z.ZodNumber;
256
290
  color: z.ZodOptional<z.ZodString>;
291
+ strokeWidth: z.ZodOptional<z.ZodNumber>;
292
+ antialias: z.ZodOptional<z.ZodBoolean>;
257
293
  }, z.core.$strip>, z.ZodObject<{
258
294
  visible: z.ZodOptional<z.ZodBoolean>;
259
295
  opacity: z.ZodOptional<z.ZodNumber>;
296
+ blend: z.ZodOptional<z.ZodEnum<{
297
+ add: "add";
298
+ multiply: "multiply";
299
+ normal: "normal";
300
+ screen: "screen";
301
+ }>>;
260
302
  dx: z.ZodOptional<z.ZodNumber>;
261
303
  dy: z.ZodOptional<z.ZodNumber>;
262
304
  effect: z.ZodOptional<z.ZodObject<{
@@ -303,6 +345,12 @@ export declare const pixooComposeScene: import("@cyanheads/mcp-ts-core").ToolDef
303
345
  }, z.core.$strip>, z.ZodObject<{
304
346
  visible: z.ZodOptional<z.ZodBoolean>;
305
347
  opacity: z.ZodOptional<z.ZodNumber>;
348
+ blend: z.ZodOptional<z.ZodEnum<{
349
+ add: "add";
350
+ multiply: "multiply";
351
+ normal: "normal";
352
+ screen: "screen";
353
+ }>>;
306
354
  dx: z.ZodOptional<z.ZodNumber>;
307
355
  dy: z.ZodOptional<z.ZodNumber>;
308
356
  effect: z.ZodOptional<z.ZodObject<{
@@ -342,6 +390,12 @@ export declare const pixooComposeScene: import("@cyanheads/mcp-ts-core").ToolDef
342
390
  }, z.core.$strip>, z.ZodObject<{
343
391
  visible: z.ZodOptional<z.ZodBoolean>;
344
392
  opacity: z.ZodOptional<z.ZodNumber>;
393
+ blend: z.ZodOptional<z.ZodEnum<{
394
+ add: "add";
395
+ multiply: "multiply";
396
+ normal: "normal";
397
+ screen: "screen";
398
+ }>>;
345
399
  dx: z.ZodOptional<z.ZodNumber>;
346
400
  dy: z.ZodOptional<z.ZodNumber>;
347
401
  effect: z.ZodOptional<z.ZodObject<{
@@ -375,6 +429,12 @@ export declare const pixooComposeScene: import("@cyanheads/mcp-ts-core").ToolDef
375
429
  }, z.core.$strip>, z.ZodObject<{
376
430
  visible: z.ZodOptional<z.ZodBoolean>;
377
431
  opacity: z.ZodOptional<z.ZodNumber>;
432
+ blend: z.ZodOptional<z.ZodEnum<{
433
+ add: "add";
434
+ multiply: "multiply";
435
+ normal: "normal";
436
+ screen: "screen";
437
+ }>>;
378
438
  dx: z.ZodOptional<z.ZodNumber>;
379
439
  dy: z.ZodOptional<z.ZodNumber>;
380
440
  effect: z.ZodOptional<z.ZodObject<{
@@ -409,6 +469,12 @@ export declare const pixooComposeScene: import("@cyanheads/mcp-ts-core").ToolDef
409
469
  }, z.core.$strip>, z.ZodObject<{
410
470
  visible: z.ZodOptional<z.ZodBoolean>;
411
471
  opacity: z.ZodOptional<z.ZodNumber>;
472
+ blend: z.ZodOptional<z.ZodEnum<{
473
+ add: "add";
474
+ multiply: "multiply";
475
+ normal: "normal";
476
+ screen: "screen";
477
+ }>>;
412
478
  dx: z.ZodOptional<z.ZodNumber>;
413
479
  dy: z.ZodOptional<z.ZodNumber>;
414
480
  effect: z.ZodOptional<z.ZodObject<{
@@ -450,9 +516,30 @@ export declare const pixooComposeScene: import("@cyanheads/mcp-ts-core").ToolDef
450
516
  mitchell: "mitchell";
451
517
  nearest: "nearest";
452
518
  }>>;
519
+ finish: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
520
+ colors: z.ZodNumber;
521
+ dither: z.ZodDefault<z.ZodEnum<{
522
+ bayer4: "bayer4";
523
+ "floyd-steinberg": "floyd-steinberg";
524
+ none: "none";
525
+ }>>;
526
+ }, z.core.$strict>, z.ZodObject<{
527
+ palette: z.ZodArray<z.ZodString>;
528
+ dither: z.ZodDefault<z.ZodEnum<{
529
+ bayer4: "bayer4";
530
+ "floyd-steinberg": "floyd-steinberg";
531
+ none: "none";
532
+ }>>;
533
+ }, z.core.$strict>]>>;
453
534
  }, z.core.$strip>, z.ZodObject<{
454
535
  visible: z.ZodOptional<z.ZodBoolean>;
455
536
  opacity: z.ZodOptional<z.ZodNumber>;
537
+ blend: z.ZodOptional<z.ZodEnum<{
538
+ add: "add";
539
+ multiply: "multiply";
540
+ normal: "normal";
541
+ screen: "screen";
542
+ }>>;
456
543
  dx: z.ZodOptional<z.ZodNumber>;
457
544
  dy: z.ZodOptional<z.ZodNumber>;
458
545
  effect: z.ZodOptional<z.ZodObject<{
@@ -519,6 +606,7 @@ export declare const pixooComposeScene: import("@cyanheads/mcp-ts-core").ToolDef
519
606
  }>;
520
607
  font: z.ZodOptional<z.ZodEnum<{
521
608
  compact: "compact";
609
+ numerals: "numerals";
522
610
  standard: "standard";
523
611
  }>>;
524
612
  scale: z.ZodOptional<z.ZodNumber>;
@@ -559,8 +647,14 @@ export declare const pixooComposeScene: import("@cyanheads/mcp-ts-core").ToolDef
559
647
  }, {
560
648
  readonly reason: "asset_not_found";
561
649
  readonly code: JsonRpcErrorCode.NotFound;
562
- readonly when: "An image or sprite path could not be read.";
563
- readonly recovery: "Verify the file path exists and is readable, or check the URL is reachable.";
650
+ readonly when: "An image source or sprite path could not be read, a sprite path was a URL, or an image URL could not be fetched.";
651
+ readonly recovery: "Pass an absolute path to an existing, readable image file, or for an image source a reachable https URL serving 10 MiB or less; sprite sheets take an absolute local path.";
652
+ readonly thrownBy: "service";
653
+ }, {
654
+ readonly reason: "invalid_image";
655
+ readonly code: JsonRpcErrorCode.InvalidParams;
656
+ readonly when: "An image source or sprite path was read but did not decode as an image.";
657
+ readonly recovery: "Each image source and sprite path must be a complete PNG, JPEG, GIF, WebP, AVIF, TIFF, or SVG image; a text file, an HTML page, or a truncated download will not decode.";
564
658
  readonly thrownBy: "service";
565
659
  }, {
566
660
  readonly reason: "invalid_color";
@@ -1 +1 @@
1
- {"version":3,"file":"pixoo-compose-scene.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/pixoo-compose-scene.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAY,MAAM,+BAA+B,CAAC;AAmW3E,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAqT5B,CAAC"}
1
+ {"version":3,"file":"pixoo-compose-scene.tool.d.ts","sourceRoot":"","sources":["../../../../src/mcp-server/tools/definitions/pixoo-compose-scene.tool.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EAAQ,CAAC,EAAE,MAAM,wBAAwB,CAAC;AACjD,OAAO,EAAE,gBAAgB,EAAY,MAAM,+BAA+B,CAAC;AAwb3E,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA8T5B,CAAC"}
@@ -8,10 +8,12 @@ import { JsonRpcErrorCode, McpError } from '@cyanheads/mcp-ts-core/errors';
8
8
  import { Canvas, NAMED_COLORS, savePng } from '@cyanheads/pixoo-toolkit';
9
9
  import { getServerConfig } from '../../../config/server-config.js';
10
10
  import { pushKeepingPreview, visibilityNotice } from '../device-push.js';
11
+ import { FinishSchema } from '../finish-schema.js';
11
12
  import { ICONS } from '../../../renderer/icons.js';
12
13
  import { numericValue } from '../../../renderer/keyframes.js';
13
14
  import { autoSavePreview, buildContactSheet, saveGifPreview, savePngPreview, } from '../../../renderer/preview.js';
14
15
  import { renderScene } from '../../../renderer/scene-renderer.js';
16
+ import { describeMissingNumerals, FONT_VARIANTS, missingNumeralGlyphs, } from '../../../renderer/text-engine.js';
15
17
  import { getPixooService } from '../../../services/pixoo/pixoo-service.js';
16
18
  // --- Shared sub-schemas ---
17
19
  const EffectSchema = z
@@ -67,6 +69,10 @@ const AnimateSchema = z
67
69
  const BaseElementProps = {
68
70
  visible: z.boolean().optional().describe('Whether the element is visible (default: true).'),
69
71
  opacity: z.number().int().min(0).max(100).optional().describe('Opacity 0–100 (default: 100).'),
72
+ blend: z
73
+ .enum(['normal', 'add', 'screen', 'multiply'])
74
+ .optional()
75
+ .describe('How the element combines with what lies beneath it (default: normal, drawn over it). add sums the light of both, clamped at full brightness — for glows and light beams; screen brightens more gently; multiply darkens.'),
70
76
  dx: z.number().int().optional().describe('X offset nudge in pixels.'),
71
77
  dy: z.number().int().optional().describe('Y offset nudge in pixels.'),
72
78
  effect: EffectSchema.optional().describe('Named animation preset for this element.'),
@@ -107,15 +113,31 @@ const StyleSchema = z
107
113
  })
108
114
  .describe('Text style options.');
109
115
  // --- Element schemas ---
110
- const TextElementSchema = z.object({
116
+ const TextElementSchema = z
117
+ .object({
111
118
  type: z.literal('text').describe('Text element type.'),
112
119
  text: z.string().describe('Text content to render.'),
113
120
  x: XPosSchema.optional().describe('X position (default: 0).'),
114
121
  y: YPosSchema.optional().describe('Y position (default: 0).'),
115
122
  color: z.string().optional().describe('Flat text color.'),
116
- font: z.enum(['standard', 'compact']).optional().describe('Font variant.'),
123
+ font: z
124
+ .enum(FONT_VARIANTS)
125
+ .optional()
126
+ .describe('Font variant (default: standard): standard (5×7) or compact (3×5), each printable ASCII plus ° ← ↑ → ↓ ▲ ▼ ♥ · …; or numerals (11×18 digits for clocks and readouts), which draws only 0–9, space, and : . - + / % ° ?, so text holding any other character is rejected — put units and labels in a separate standard or compact text element.'),
117
127
  style: StyleSchema.optional().describe('Text style options.'),
118
128
  ...BaseElementProps,
129
+ })
130
+ .superRefine((el, ctx) => {
131
+ if (el.font !== 'numerals')
132
+ return;
133
+ const missing = missingNumeralGlyphs(el.text);
134
+ if (missing.length === 0)
135
+ return;
136
+ ctx.addIssue({
137
+ code: 'custom',
138
+ path: ['text'],
139
+ message: `${describeMissingNumerals(missing)} Put units and labels in a separate text element in the standard or compact font.`,
140
+ });
119
141
  });
120
142
  const IconElementSchema = z.object({
121
143
  type: z.literal('icon').describe('Icon element type.'),
@@ -133,7 +155,8 @@ const IconElementSchema = z.object({
133
155
  .describe("Named palette painted as a top-to-bottom color ramp from the icon's top row to its bottom row. Takes precedence over color."),
134
156
  ...BaseElementProps,
135
157
  });
136
- const RectElementSchema = z.object({
158
+ const RectElementSchema = z
159
+ .object({
137
160
  type: z.literal('rect').describe('Rectangle element type.'),
138
161
  x: z.number().int().describe('X coordinate.'),
139
162
  y: z.number().int().describe('Y coordinate.'),
@@ -148,17 +171,51 @@ const RectElementSchema = z.object({
148
171
  })
149
172
  .optional()
150
173
  .describe('Gradient fill.'),
151
- borderColor: z.string().optional().describe('1px border color.'),
174
+ borderColor: z
175
+ .string()
176
+ .optional()
177
+ .describe('Border color. The border is drawn inside the rect, strokeWidth pixels thick.'),
178
+ strokeWidth: z
179
+ .number()
180
+ .int()
181
+ .min(1)
182
+ .optional()
183
+ .describe('Border thickness in whole pixels, growing inward from the rect edge (default: 1). Requires borderColor.'),
152
184
  ...BaseElementProps,
185
+ })
186
+ .refine((el) => el.strokeWidth === undefined || Boolean(el.borderColor), {
187
+ error: 'strokeWidth sets the thickness of the borderColor border; set borderColor, or drop strokeWidth.',
188
+ path: ['strokeWidth'],
153
189
  });
154
- const CircleElementSchema = z.object({
190
+ /** The error for a stroke field on a circle that fills, which draws no outline. */
191
+ const filledCircleError = (field) => `${field} applies to an outline circle only; set fill: false, or drop ${field}.`;
192
+ const CircleElementSchema = z
193
+ .object({
155
194
  type: z.literal('circle').describe('Circle element type.'),
156
195
  cx: z.number().int().describe('Center X coordinate.'),
157
196
  cy: z.number().int().describe('Center Y coordinate.'),
158
197
  radius: z.number().int().min(1).describe('Radius in pixels.'),
159
198
  color: z.string().optional().describe('Circle color.'),
160
199
  fill: z.boolean().optional().describe('Filled or outline only (default: true).'),
200
+ strokeWidth: z
201
+ .number()
202
+ .int()
203
+ .min(1)
204
+ .optional()
205
+ .describe('Outline thickness in whole pixels, centered on the circle (default: 1). Outline circles only (fill: false).'),
206
+ antialias: z
207
+ .boolean()
208
+ .optional()
209
+ .describe('Shade each outline pixel by how much of it the ring covers, for a smooth curve (default: false, hard pixel edges). Outline circles only (fill: false).'),
161
210
  ...BaseElementProps,
211
+ })
212
+ .refine((el) => el.fill === false || el.strokeWidth === undefined, {
213
+ error: filledCircleError('strokeWidth'),
214
+ path: ['strokeWidth'],
215
+ })
216
+ .refine((el) => el.fill === false || el.antialias === undefined, {
217
+ error: filledCircleError('antialias'),
218
+ path: ['antialias'],
162
219
  });
163
220
  const LineElementSchema = z.object({
164
221
  type: z.literal('line').describe('Line element type.'),
@@ -167,6 +224,16 @@ const LineElementSchema = z.object({
167
224
  x1: z.number().int().describe('End X coordinate.'),
168
225
  y1: z.number().int().describe('End Y coordinate.'),
169
226
  color: z.string().optional().describe('Line color.'),
227
+ strokeWidth: z
228
+ .number()
229
+ .int()
230
+ .min(1)
231
+ .optional()
232
+ .describe('Line thickness in whole pixels, centered on the line (default: 1).'),
233
+ antialias: z
234
+ .boolean()
235
+ .optional()
236
+ .describe('Shade each pixel by how much of it the line covers, for smooth diagonals (default: false, hard pixel edges).'),
170
237
  ...BaseElementProps,
171
238
  });
172
239
  const ProgressElementSchema = z.object({
@@ -240,6 +307,7 @@ const ImageElementSchema = z.object({
240
307
  .enum(['nearest', 'lanczos3', 'mitchell'])
241
308
  .optional()
242
309
  .describe('Resize kernel: nearest for pixel art, lanczos3 for photos (default: nearest).'),
310
+ finish: FinishSchema.optional(),
243
311
  ...BaseElementProps,
244
312
  });
245
313
  const SpriteElementSchema = z.object({
@@ -264,9 +332,9 @@ const ElementSchema = z
264
332
  .discriminatedUnion('type', [
265
333
  TextElementSchema.describe('Styled text element rendered at a position.'),
266
334
  IconElementSchema.describe('Built-in or custom SVG icon element.'),
267
- RectElementSchema.describe('Filled or gradient rectangle element.'),
335
+ RectElementSchema.describe('Filled, gradient, or bordered rectangle element.'),
268
336
  CircleElementSchema.describe('Filled or outline circle element.'),
269
- LineElementSchema.describe('Single-pixel line element.'),
337
+ LineElementSchema.describe('Straight line element, 1px wide unless strokeWidth is set.'),
270
338
  ProgressElementSchema.describe('Horizontal progress bar widget.'),
271
339
  SparklineElementSchema.describe('Sparkline chart widget (line or bar).'),
272
340
  BitmapElementSchema.describe('Bitmap element using explicit palette indices.'),
@@ -296,7 +364,7 @@ const LayoutEntrySchema = z
296
364
  action: z
297
365
  .enum(['none', 'shrunk-to-compact', 'scrolling', 'wrapped', 'truncated', 'clipped'])
298
366
  .describe('Overflow action taken by the renderer.'),
299
- font: z.enum(['standard', 'compact']).optional().describe('Font variant used (text only).'),
367
+ font: z.enum(FONT_VARIANTS).optional().describe('Font variant used (text only).'),
300
368
  scale: z.number().optional().describe('Scale factor applied (text only).'),
301
369
  })
302
370
  .describe('Layout report entry.');
@@ -423,8 +491,15 @@ export const pixooComposeScene = tool('pixoo_compose_scene', {
423
491
  {
424
492
  reason: 'asset_not_found',
425
493
  code: JsonRpcErrorCode.NotFound,
426
- when: 'An image or sprite path could not be read.',
427
- recovery: 'Verify the file path exists and is readable, or check the URL is reachable.',
494
+ when: 'An image source or sprite path could not be read, a sprite path was a URL, or an image URL could not be fetched.',
495
+ recovery: 'Pass an absolute path to an existing, readable image file, or for an image source a reachable https URL serving 10 MiB or less; sprite sheets take an absolute local path.',
496
+ thrownBy: 'service',
497
+ },
498
+ {
499
+ reason: 'invalid_image',
500
+ code: JsonRpcErrorCode.InvalidParams,
501
+ when: 'An image source or sprite path was read but did not decode as an image.',
502
+ recovery: 'Each image source and sprite path must be a complete PNG, JPEG, GIF, WebP, AVIF, TIFF, or SVG image; a text file, an HTML page, or a truncated download will not decode.',
428
503
  thrownBy: 'service',
429
504
  },
430
505
  {