@cyanheads/pixoo-mcp-server 1.2.0 → 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 (54) hide show
  1. package/AGENTS.md +10 -7
  2. package/CLAUDE.md +10 -7
  3. package/Dockerfile +30 -10
  4. package/README.md +65 -86
  5. package/changelog/1.2.x/1.2.1.md +35 -0
  6. package/changelog/1.2.x/1.2.2.md +40 -0
  7. package/dist/mcp-server/resources/definitions/pixoo-design-guide.resource.d.ts.map +1 -1
  8. package/dist/mcp-server/resources/definitions/pixoo-design-guide.resource.js +4 -1
  9. package/dist/mcp-server/resources/definitions/pixoo-design-guide.resource.js.map +1 -1
  10. package/dist/mcp-server/tools/definitions/pixoo-compose-scene.tool.d.ts +173 -13
  11. package/dist/mcp-server/tools/definitions/pixoo-compose-scene.tool.d.ts.map +1 -1
  12. package/dist/mcp-server/tools/definitions/pixoo-compose-scene.tool.js +129 -28
  13. package/dist/mcp-server/tools/definitions/pixoo-compose-scene.tool.js.map +1 -1
  14. package/dist/mcp-server/tools/definitions/pixoo-design-brief.tool.d.ts.map +1 -1
  15. package/dist/mcp-server/tools/definitions/pixoo-design-brief.tool.js +15 -7
  16. package/dist/mcp-server/tools/definitions/pixoo-design-brief.tool.js.map +1 -1
  17. package/dist/mcp-server/tools/definitions/pixoo-display-text.tool.d.ts +2 -0
  18. package/dist/mcp-server/tools/definitions/pixoo-display-text.tool.d.ts.map +1 -1
  19. package/dist/mcp-server/tools/definitions/pixoo-display-text.tool.js +27 -12
  20. package/dist/mcp-server/tools/definitions/pixoo-display-text.tool.js.map +1 -1
  21. package/dist/mcp-server/tools/definitions/pixoo-push-image.tool.d.ts +30 -1
  22. package/dist/mcp-server/tools/definitions/pixoo-push-image.tool.d.ts.map +1 -1
  23. package/dist/mcp-server/tools/definitions/pixoo-push-image.tool.js +105 -26
  24. package/dist/mcp-server/tools/definitions/pixoo-push-image.tool.js.map +1 -1
  25. package/dist/mcp-server/tools/finish-schema.d.ts +28 -0
  26. package/dist/mcp-server/tools/finish-schema.d.ts.map +1 -0
  27. package/dist/mcp-server/tools/finish-schema.js +42 -0
  28. package/dist/mcp-server/tools/finish-schema.js.map +1 -0
  29. package/dist/renderer/finish.d.ts +34 -0
  30. package/dist/renderer/finish.d.ts.map +1 -0
  31. package/dist/renderer/finish.js +62 -0
  32. package/dist/renderer/finish.js.map +1 -0
  33. package/dist/renderer/icons.d.ts +14 -4
  34. package/dist/renderer/icons.d.ts.map +1 -1
  35. package/dist/renderer/icons.js +30 -22
  36. package/dist/renderer/icons.js.map +1 -1
  37. package/dist/renderer/keyframes.d.ts +40 -4
  38. package/dist/renderer/keyframes.d.ts.map +1 -1
  39. package/dist/renderer/keyframes.js +100 -53
  40. package/dist/renderer/keyframes.js.map +1 -1
  41. package/dist/renderer/remote-image.d.ts +13 -7
  42. package/dist/renderer/remote-image.d.ts.map +1 -1
  43. package/dist/renderer/remote-image.js +17 -21
  44. package/dist/renderer/remote-image.js.map +1 -1
  45. package/dist/renderer/scene-renderer.d.ts +45 -6
  46. package/dist/renderer/scene-renderer.d.ts.map +1 -1
  47. package/dist/renderer/scene-renderer.js +328 -159
  48. package/dist/renderer/scene-renderer.js.map +1 -1
  49. package/dist/renderer/text-engine.d.ts +24 -5
  50. package/dist/renderer/text-engine.d.ts.map +1 -1
  51. package/dist/renderer/text-engine.js +51 -28
  52. package/dist/renderer/text-engine.js.map +1 -1
  53. package/package.json +7 -6
  54. package/server.json +3 -3
@@ -4,51 +4,115 @@
4
4
  */
5
5
  import * as fs from 'node:fs/promises';
6
6
  import { invalidParams, notFound } from '@cyanheads/mcp-ts-core/errors';
7
- import { Canvas, downsampleSprite, drawText, FONT_3x5, FONT_5x7, lerpColor, loadImage, measureText, renderSprite, renderSvgPath, resolveColor, } from '@cyanheads/pixoo-toolkit';
7
+ import { Canvas, downsampleSprite, drawText, FONT_3x5, lerpColor, loadImage, measureText, renderSprite, renderSvgPath, resolveColor, } from '@cyanheads/pixoo-toolkit';
8
+ import { finishFrame } from './finish.js';
8
9
  import { ICONS } from './icons.js';
9
- import { compileEffect, getKeyframeValue } from './keyframes.js';
10
- import { fetchRemoteImageToTempPng, isRemoteSource } from './remote-image.js';
11
- import { drawStyledText, resolveX, resolveY, } from './text-engine.js';
10
+ import { compileEffect, getKeyframeValue, interpolateColorKeyframe, } from './keyframes.js';
11
+ import { fetchRemoteImageBytes, isRemoteSource } from './remote-image.js';
12
+ import { boxFits, drawStyledText, FONT_FACES, resolveX, resolveY, } from './text-engine.js';
12
13
  import { PALETTES, THEMES } from './themes.js';
14
+ /** The cache key of a sprite sheet: its path and grid, which fix the downsample. */
15
+ function spriteKey(el) {
16
+ return `${el.path}:${el.cols}:${el.rows}`;
17
+ }
13
18
  /**
14
19
  * Confirm a local image or sprite-sheet path is readable before the toolkit's
15
20
  * loader reaches it — the loader fails a missing file with an unclassified error.
16
21
  *
17
- * @throws {McpError} NotFound with `reason: 'asset_not_found'` when the path is
18
- * missing or unreadable.
22
+ * @throws {McpError} NotFound with `reason: 'asset_not_found'` and the calling tool's
23
+ * declared recovery when the path is missing or unreadable.
19
24
  */
20
- async function assertReadableAsset(assetPath, label) {
25
+ async function assertReadableAsset(assetPath, label, ctx) {
21
26
  try {
22
27
  await fs.access(assetPath, fs.constants.R_OK);
23
28
  }
24
29
  catch (err) {
25
- throw notFound(`${label} not found or unreadable: "${assetPath}".`, {
26
- reason: 'asset_not_found',
27
- path: assetPath,
28
- recovery: { hint: 'Pass an absolute path to an existing, readable image file.' },
29
- }, { cause: err });
30
+ throw notFound(`${label} not found or unreadable: "${assetPath}".`, { reason: 'asset_not_found', path: assetPath, ...ctx.recoveryFor('asset_not_found') }, { cause: err });
31
+ }
32
+ }
33
+ /**
34
+ * The message for an image source that was read but did not decode: the source as the
35
+ * caller passed it, then the decoder's reason. Only the reason's first line is kept —
36
+ * libvips repeats itself across several lines on a corrupt header.
37
+ */
38
+ export function decodeFailureMessage(label, source, err) {
39
+ const reason = (err instanceof Error ? err.message : String(err)).split('\n')[0];
40
+ return `${label} "${source}" could not be decoded: ${reason}.`;
41
+ }
42
+ /**
43
+ * The error for an image source or sprite sheet that was read but did not decode — a
44
+ * text file, an HTML page, a truncated download. Carries the calling tool's declared
45
+ * `invalid_image` recovery.
46
+ */
47
+ function undecodableAsset(el, err, ctx) {
48
+ const [label, field, value] = el.type === 'image' ? ['Image source', 'source', el.source] : ['Sprite sheet', 'path', el.path];
49
+ return invalidParams(decodeFailureMessage(label, value, err), { reason: 'invalid_image', [field]: value, ...ctx.recoveryFor('invalid_image') }, { cause: err });
50
+ }
51
+ /**
52
+ * An image source as `loadImage` takes it: a URL's fetched bytes, or a local path
53
+ * confirmed readable.
54
+ */
55
+ async function readImageSource(source, ctx) {
56
+ if (isRemoteSource(source))
57
+ return fetchRemoteImageBytes(source, ctx);
58
+ await assertReadableAsset(source, 'Image file', ctx);
59
+ return source;
60
+ }
61
+ /**
62
+ * Downsample a sprite element's sheet to its grid. The sheet must be a local file;
63
+ * a URL is refused without being fetched.
64
+ */
65
+ async function loadSpriteSheet(el, ctx) {
66
+ if (isRemoteSource(el.path)) {
67
+ throw notFound(`Sprite sheet path "${el.path}" is a URL; sprite sheets take an absolute local path.`, { reason: 'asset_not_found', path: el.path, ...ctx.recoveryFor('asset_not_found') });
30
68
  }
69
+ await assertReadableAsset(el.path, 'Sprite sheet', ctx);
70
+ return downsampleSprite(el.path, el.cols, el.rows).catch((err) => {
71
+ throw undecodableAsset(el, err, ctx);
72
+ });
73
+ }
74
+ /**
75
+ * The in-flight load for `key`, started by `load` on first request. Every caller
76
+ * awaits the same promise, so a failed load rejects each of them with one error.
77
+ */
78
+ function loadOnce(loads, key, load) {
79
+ let pending = loads.get(key);
80
+ if (!pending) {
81
+ pending = load();
82
+ loads.set(key, pending);
83
+ }
84
+ return pending;
31
85
  }
32
86
  /**
33
87
  * Preload all async assets referenced in elements. Images load onto a `size` canvas,
34
88
  * the scene's own, so an image with no `w`/`h` fits the display.
35
89
  *
90
+ * Each distinct load runs once per call, however many elements share it: a URL is
91
+ * fetched once, a sprite sheet decodes once per grid, and an image decodes once per
92
+ * placement — source, fit, kernel, position, and size, all of which `loadImage` bakes
93
+ * into its canvas. `loadImage` decodes and places in one call, so a source placed two
94
+ * ways decodes twice. An image's `finish` runs once per placement and finish, on the
95
+ * placement's canvas, which it never mutates: elements differing only in `finish` share
96
+ * the decode and nothing after it.
97
+ *
36
98
  * `ctx` is threaded through so a remote image fetch correlates to the originating
37
99
  * request in logs and traces, and stops when that request is cancelled.
100
+ *
101
+ * @throws {McpError} NotFound with `reason: 'asset_not_found'` for a missing or
102
+ * unreadable path, a sprite path given as a URL, or a URL that cannot be fetched.
103
+ * @throws {McpError} InvalidParams with `reason: 'invalid_image'` for an image source
104
+ * or sprite sheet that was read but did not decode.
105
+ * @throws {Error} The toolkit's `Unknown color` error for an unresolvable `finish`
106
+ * palette entry.
38
107
  */
39
108
  export async function preloadAssets(elements, ctx, size) {
40
109
  const cache = { images: new Map(), sprites: new Map() };
110
+ const sourceLoads = new Map();
111
+ const imageLoads = new Map();
112
+ const sheetLoads = new Map();
41
113
  await Promise.all(elements.map(async (el) => {
42
114
  if (el.type === 'image') {
43
- const source = el.source;
44
- // The toolkit's loadImage reads from disk, so a remote source is
45
- // staged to a temp PNG first and unlinked once it has been read.
46
- const remote = isRemoteSource(source);
47
- if (!remote)
48
- await assertReadableAsset(source, 'Image file');
49
- const tmpPathToCleanup = remote ? await fetchRemoteImageToTempPng(source, ctx) : undefined;
50
- const localPath = tmpPathToCleanup ?? source;
51
- const loadOpts = {
115
+ const placement = {
52
116
  size,
53
117
  fit: el.fit ?? 'contain',
54
118
  kernel: el.kernel ?? 'nearest',
@@ -56,22 +120,25 @@ export async function preloadAssets(elements, ctx, size) {
56
120
  y: typeof el.y === 'number' ? el.y : 0,
57
121
  };
58
122
  if (el.w !== undefined)
59
- loadOpts.width = el.w;
123
+ placement.width = el.w;
60
124
  if (el.h !== undefined)
61
- loadOpts.height = el.h;
62
- const canvas = await loadImage(localPath, loadOpts);
63
- if (tmpPathToCleanup) {
64
- fs.unlink(tmpPathToCleanup).catch(() => undefined);
65
- }
125
+ placement.height = el.h;
126
+ const placed = JSON.stringify([el.source, placement]);
127
+ const load = () => loadOnce(imageLoads, placed, async () => {
128
+ const image = await loadOnce(sourceLoads, el.source, () => readImageSource(el.source, ctx));
129
+ return loadImage(image, placement).catch((err) => {
130
+ throw undecodableAsset(el, err, ctx);
131
+ });
132
+ });
133
+ const { finish } = el;
134
+ const canvas = await (finish
135
+ ? loadOnce(imageLoads, JSON.stringify([el.source, placement, finish]), async () => finishFrame(await load(), finish))
136
+ : load());
66
137
  cache.images.set(el, canvas);
67
138
  }
68
139
  else if (el.type === 'sprite') {
69
- const key = `${el.path}:${el.cols}:${el.rows}`;
70
- if (!cache.sprites.has(key)) {
71
- await assertReadableAsset(el.path, 'Sprite sheet');
72
- const sprite = await downsampleSprite(el.path, el.cols, el.rows);
73
- cache.sprites.set(key, sprite);
74
- }
140
+ const key = spriteKey(el);
141
+ cache.sprites.set(key, await loadOnce(sheetLoads, key, () => loadSpriteSheet(el, ctx)));
75
142
  }
76
143
  }));
77
144
  return cache;
@@ -118,13 +185,135 @@ export function applyBackground(canvas, bg) {
118
185
  }
119
186
  canvas.clear();
120
187
  }
188
+ /** Draw an icon's filled parts, then its stroked parts, in one color. */
189
+ function drawIconPaths(canvas, paths, color, svgViewBox, targetRect) {
190
+ if (paths.fill)
191
+ renderSvgPath(canvas, paths.fill, color, svgViewBox, targetRect);
192
+ if (paths.stroke) {
193
+ renderSvgPath(canvas, paths.stroke, color, svgViewBox, targetRect, { mode: 'stroke' });
194
+ }
195
+ }
196
+ /**
197
+ * Render an icon as a top-to-bottom ramp, the way a text palette paints glyphs: the icon's
198
+ * top ink row takes `stop.from`, its bottom ink row `stop.to`, and each row between takes
199
+ * its even step. Only ink that lands on the canvas counts, so an icon clipped by an edge
200
+ * ramps across the rows still visible.
201
+ */
202
+ function renderIconRamp(canvas, paths, svgViewBox, targetRect, stop) {
203
+ const mask = new Canvas(canvas.width);
204
+ drawIconPaths(mask, paths, [255, 255, 255], svgViewBox, targetRect);
205
+ const inkAt = (x, y) => mask.getPixelRgba(x, y)[3] > 0;
206
+ const rows = [];
207
+ for (let y = 0; y < mask.height; y++) {
208
+ for (let x = 0; x < mask.width; x++) {
209
+ if (inkAt(x, y)) {
210
+ rows.push(y);
211
+ break;
212
+ }
213
+ }
214
+ }
215
+ const top = rows[0];
216
+ const bottom = rows.at(-1);
217
+ if (top === undefined || bottom === undefined)
218
+ return;
219
+ const from = resolveColor(stop.from);
220
+ const to = resolveColor(stop.to);
221
+ for (const y of rows) {
222
+ const color = lerpColor(from, to, bottom === top ? 0 : (y - top) / (bottom - top));
223
+ for (let x = 0; x < mask.width; x++) {
224
+ if (inkAt(x, y))
225
+ canvas.setPixel(x, y, color);
226
+ }
227
+ }
228
+ }
229
+ /**
230
+ * The layout entry for an element drawn over `box` — its placed pixels, `dx`/`dy` included.
231
+ * The element fits when the box lies wholly on the canvas.
232
+ */
233
+ function placedEntry(element, type, box, canvas) {
234
+ return { element, type, box, fits: boxFits(box, canvas.width, canvas.height), action: 'none' };
235
+ }
236
+ /** The element's stroke, each option at its default when omitted. */
237
+ function strokeOf(el) {
238
+ return { width: el.strokeWidth ?? 1, antialias: el.antialias ?? false };
239
+ }
240
+ /**
241
+ * Whether the toolkit draws `stroke` as a band rather than a 1px Bresenham line or
242
+ * midpoint circle, whose pixels stay within the endpoints or the diameter.
243
+ */
244
+ function isBand({ width, antialias }) {
245
+ return width > 1 || antialias;
246
+ }
247
+ /**
248
+ * The box of every pixel a band line lights, on the canvas or off it — the toolkit's stroke
249
+ * geometry: the major axis steps from one endpoint's pixel to the other's, and each step
250
+ * covers `width/2 · √(1 + slope²)` either side of the centerline. Aliased, the endpoints
251
+ * are floored and a pixel lights when its center lies in that half-open span; anti-aliased,
252
+ * the endpoints are exact and a pixel lights when it overlaps the span at all.
253
+ */
254
+ function bandLineBox(ex0, ey0, ex1, ey1, { width, antialias }) {
255
+ const snap = antialias ? (v) => v : Math.floor;
256
+ const [x0, y0, x1, y1] = [snap(ex0), snap(ey0), snap(ex1), snap(ey1)];
257
+ const xMajor = Math.abs(x1 - x0) >= Math.abs(y1 - y0);
258
+ const [a0, b0, a1, b1] = xMajor ? [x0, y0, x1, y1] : [y0, x0, y1, x1];
259
+ const slope = a1 === a0 ? 0 : (b1 - b0) / (a1 - a0);
260
+ const half = (width / 2) * Math.sqrt(1 + slope * slope);
261
+ const first = Math.floor(Math.min(a0, a1));
262
+ const last = Math.floor(Math.max(a0, a1));
263
+ // The centerline is straight, so its first and last steps bound it
264
+ const [m0, m1] = [b0 + (first - a0) * slope, b0 + (last - a0) * slope];
265
+ const lo = Math.min(m0, m1) - half;
266
+ const hi = Math.max(m0, m1) + half;
267
+ const minorFirst = antialias ? Math.floor(lo - 0.5) + 1 : Math.ceil(lo);
268
+ const minorSpan = (antialias ? Math.ceil(hi + 0.5) : Math.ceil(hi)) - minorFirst;
269
+ const majorSpan = last - first + 1;
270
+ return xMajor
271
+ ? { x: first, y: minorFirst, w: majorSpan, h: minorSpan }
272
+ : { x: minorFirst, y: first, w: minorSpan, h: majorSpan };
273
+ }
274
+ /**
275
+ * The box of every pixel a band ring lights: each pixel center nearer the circle's center
276
+ * than `radius + width/2`, or `radius + width/2 + ½` anti-aliased, where the band's
277
+ * coverage runs out.
278
+ */
279
+ function bandRingBox(cx, cy, radius, stroke) {
280
+ const reach = radius + stroke.width / 2 + (stroke.antialias ? 0.5 : 0);
281
+ const x = Math.floor(cx - reach) + 1;
282
+ const y = Math.floor(cy - reach) + 1;
283
+ return { x, y, w: Math.ceil(cx + reach) - x, h: Math.ceil(cy + reach) - y };
284
+ }
285
+ /**
286
+ * Composite an element's own scratch layer onto the canvas. `normal` lands each pixel
287
+ * source-over at its alpha × `opacity`, so a soft edge keeps its falloff as the element
288
+ * fades. Any other mode scales the layer's alpha by `opacity`, then blits the layer in
289
+ * that mode.
290
+ */
291
+ function compositeLayer(canvas, layer, opacity, blend) {
292
+ const fade = opacity / 100;
293
+ if (blend === 'normal') {
294
+ for (let y = 0; y < layer.height; y++) {
295
+ for (let x = 0; x < layer.width; x++) {
296
+ const [r, g, b, a] = layer.getPixelRgba(x, y);
297
+ if (a > 0)
298
+ canvas.blendPixel(x, y, [r, g, b], (a / 255) * fade);
299
+ }
300
+ }
301
+ return;
302
+ }
303
+ if (fade < 1) {
304
+ const { buffer } = layer;
305
+ for (let i = 3; i < buffer.length; i += 4)
306
+ buffer[i] = Math.round((buffer[i] ?? 0) * fade);
307
+ }
308
+ canvas.blit(layer, 0, 0, { mode: blend });
309
+ }
121
310
  /** Render a single element onto a canvas at a specific frame. */
122
311
  export function renderElement(canvas, el, elIdx, frameIdx, totalFrames, assets, layoutEntries) {
123
312
  // Compute keyframes / effects
124
313
  const kf = el.animate
125
314
  ? el.animate
126
315
  : el.effect
127
- ? compileEffect(el.effect.name, el.effect, totalFrames)
316
+ ? compileEffect(el.effect.name, el.effect, totalFrames, elIdx)
128
317
  : undefined;
129
318
  const visible = kf
130
319
  ? Boolean(getKeyframeValue(kf, 'visible', frameIdx, el.visible ?? true))
@@ -136,17 +325,23 @@ export function renderElement(canvas, el, elIdx, frameIdx, totalFrames, assets,
136
325
  const dyAnim = Number(getKeyframeValue(kf, 'dy', frameIdx, 0));
137
326
  const dx = (el.dx ?? 0) + dxAnim;
138
327
  const dy = (el.dy ?? 0) + dyAnim;
139
- // If opacity < 100, render to scratch canvas and blit with alpha
140
- const target = opacity < 100 ? new Canvas(canvas.width) : canvas;
328
+ // A keyframed `color` stands in for the element's own `color` on this frame.
329
+ const colorFrames = kf?.['color'];
330
+ const keyframedColor = colorFrames ? interpolateColorKeyframe(colorFrames, frameIdx) : undefined;
331
+ // Under opacity or a blend mode, the element draws on its own layer, composited at the end
332
+ const blend = el.blend ?? 'normal';
333
+ const target = opacity < 100 || blend !== 'normal' ? new Canvas(canvas.width) : canvas;
141
334
  switch (el.type) {
142
335
  case 'text': {
143
- const style = el.style ?? {};
144
- if (!style.color && el.color)
145
- style.color = el.color;
146
- const fontVariant = el.font === 'compact' ? 'compact' : 'standard';
336
+ // A copy: the per-frame color must not stick to the caller's style object.
337
+ const style = { ...el.style };
338
+ const color = keyframedColor ?? el.color;
339
+ if (!style.color && color)
340
+ style.color = color;
341
+ const fontVariant = el.font ?? 'standard';
147
342
  const px = el.x ?? 0;
148
343
  const py = el.y ?? 0;
149
- const font = fontVariant === 'compact' ? FONT_3x5 : FONT_5x7;
344
+ const font = FONT_FACES[fontVariant];
150
345
  const scale = style.scale ?? 1;
151
346
  const textW = measureText(el.text, { font, scale });
152
347
  const textH = font.height * scale;
@@ -154,29 +349,26 @@ export function renderElement(canvas, el, elIdx, frameIdx, totalFrames, assets,
154
349
  const resolvedY = resolveY(py, textH, canvas.height, dy);
155
350
  drawStyledText(target, el.text, resolvedX, resolvedY, style, fontVariant);
156
351
  layoutEntries.push({
157
- element: elIdx,
158
- type: 'text',
159
- box: { x: resolvedX, y: resolvedY, w: textW, h: textH },
160
- fits: resolvedX + textW <= canvas.width && resolvedY + textH <= canvas.height,
161
- action: 'none',
352
+ ...placedEntry(elIdx, 'text', { x: resolvedX, y: resolvedY, w: textW, h: textH }, canvas),
162
353
  font: fontVariant,
163
354
  scale,
164
355
  });
165
356
  break;
166
357
  }
167
358
  case 'icon': {
168
- let svgD;
359
+ let paths;
169
360
  let viewBox = '0 0 16 16';
170
361
  if (el.name) {
171
- const iconEntry = ICONS[el.name];
362
+ // Own keys only: an Object.prototype name such as "constructor" is not an icon.
363
+ const iconEntry = Object.hasOwn(ICONS, el.name) ? ICONS[el.name] : undefined;
172
364
  if (!iconEntry) {
173
365
  throw invalidParams(`Unknown icon "${el.name}". Use pixoo://reference/icons to browse available icons.`, { reason: 'unknown_icon' });
174
366
  }
175
- svgD = iconEntry.d;
367
+ paths = iconEntry;
176
368
  viewBox = iconEntry.viewBox;
177
369
  }
178
370
  else if (el.d) {
179
- svgD = el.d;
371
+ paths = { fill: el.d };
180
372
  viewBox = el.viewBox ?? '0 0 16 16';
181
373
  }
182
374
  else {
@@ -188,24 +380,26 @@ export function renderElement(canvas, el, elIdx, frameIdx, totalFrames, assets,
188
380
  const py = el.y ?? 0;
189
381
  const resolvedX = resolveX(px, w, canvas.width, dx);
190
382
  const resolvedY = resolveY(py, h, canvas.height, dy);
191
- const color = el.color ? resolveColor(el.color) : [255, 255, 255];
383
+ // Resolved even under a palette, so a bad color still fails as invalid_color.
384
+ const colorSpec = keyframedColor ?? el.color;
385
+ const color = colorSpec ? resolveColor(colorSpec) : [255, 255, 255];
192
386
  // Parse viewBox string ("0 0 W H") to extract dimensions [W, H]
193
387
  const vbParts = viewBox.split(/\s+/).map(Number);
194
- const vbW = vbParts[2] ?? 16;
195
- const vbH = vbParts[3] ?? 16;
196
- renderSvgPath(target, svgD, color, [vbW, vbH], [resolvedX, resolvedY, w, h]);
197
- layoutEntries.push({
198
- element: elIdx,
199
- type: 'icon',
200
- box: { x: resolvedX, y: resolvedY, w, h },
201
- fits: resolvedX + w <= canvas.width && resolvedY + h <= canvas.height,
202
- action: 'none',
203
- });
388
+ const svgViewBox = [vbParts[2] ?? 16, vbParts[3] ?? 16];
389
+ const targetRect = [resolvedX, resolvedY, w, h];
390
+ if (el.palette) {
391
+ renderIconRamp(target, paths, svgViewBox, targetRect, PALETTES[el.palette]);
392
+ }
393
+ else {
394
+ drawIconPaths(target, paths, color, svgViewBox, targetRect);
395
+ }
396
+ layoutEntries.push(placedEntry(elIdx, 'icon', { x: resolvedX, y: resolvedY, w, h }, canvas));
204
397
  break;
205
398
  }
206
399
  case 'rect': {
207
400
  const x = el.x + dx;
208
401
  const y = el.y + dy;
402
+ const fill = keyframedColor ?? el.color;
209
403
  if (el.gradient) {
210
404
  const from_ = resolveColor(el.gradient.from);
211
405
  const to_ = resolveColor(el.gradient.to);
@@ -225,55 +419,56 @@ export function renderElement(canvas, el, elIdx, frameIdx, totalFrames, assets,
225
419
  }
226
420
  }
227
421
  }
228
- else if (el.color) {
229
- target.fillRect(x, y, el.w, el.h, resolveColor(el.color));
422
+ else if (fill) {
423
+ target.fillRect(x, y, el.w, el.h, resolveColor(fill));
230
424
  }
231
425
  if (el.borderColor) {
232
- target.drawRect(x, y, el.w, el.h, resolveColor(el.borderColor));
426
+ target.drawRect(x, y, el.w, el.h, resolveColor(el.borderColor), {
427
+ width: el.strokeWidth ?? 1,
428
+ });
233
429
  }
234
- layoutEntries.push({
235
- element: elIdx,
236
- type: 'rect',
237
- box: { x, y, w: el.w, h: el.h },
238
- fits: x + el.w <= canvas.width && y + el.h <= canvas.height,
239
- action: 'none',
240
- });
430
+ layoutEntries.push(placedEntry(elIdx, 'rect', { x, y, w: el.w, h: el.h }, canvas));
241
431
  break;
242
432
  }
243
433
  case 'circle': {
244
434
  const cx = el.cx + dx;
245
435
  const cy = el.cy + dy;
246
- const color = el.color ? resolveColor(el.color) : [255, 255, 255];
436
+ const colorSpec = keyframedColor ?? el.color;
437
+ const color = colorSpec ? resolveColor(colorSpec) : [255, 255, 255];
438
+ const stroke = strokeOf(el);
247
439
  if (el.fill !== false) {
248
440
  target.fillCircle(cx, cy, el.radius, color);
249
441
  }
250
442
  else {
251
- target.drawCircle(cx, cy, el.radius, color);
443
+ target.drawCircle(cx, cy, el.radius, color, stroke);
252
444
  }
253
- layoutEntries.push({
254
- element: elIdx,
255
- type: 'circle',
256
- box: { x: cx - el.radius, y: cy - el.radius, w: el.radius * 2, h: el.radius * 2 },
257
- fits: true,
258
- action: 'none',
259
- });
445
+ // The circle covers its center pixel plus `radius` on each side; a band ring reaches past it.
446
+ const diameter = el.radius * 2 + 1;
447
+ const box = el.fill === false && isBand(stroke)
448
+ ? bandRingBox(cx, cy, el.radius, stroke)
449
+ : { x: cx - el.radius, y: cy - el.radius, w: diameter, h: diameter };
450
+ layoutEntries.push(placedEntry(elIdx, 'circle', box, canvas));
260
451
  break;
261
452
  }
262
453
  case 'line': {
263
- const color = el.color ? resolveColor(el.color) : [255, 255, 255];
264
- target.drawLine(el.x0 + dx, el.y0 + dy, el.x1 + dx, el.y1 + dy, color);
265
- layoutEntries.push({
266
- element: elIdx,
267
- type: 'line',
268
- box: {
269
- x: Math.min(el.x0, el.x1),
270
- y: Math.min(el.y0, el.y1),
271
- w: Math.abs(el.x1 - el.x0),
272
- h: Math.abs(el.y1 - el.y0),
273
- },
274
- fits: true,
275
- action: 'none',
276
- });
454
+ const colorSpec = keyframedColor ?? el.color;
455
+ const color = colorSpec ? resolveColor(colorSpec) : [255, 255, 255];
456
+ const x0 = el.x0 + dx;
457
+ const y0 = el.y0 + dy;
458
+ const x1 = el.x1 + dx;
459
+ const y1 = el.y1 + dy;
460
+ const stroke = strokeOf(el);
461
+ target.drawLine(x0, y0, x1, y1, color, stroke);
462
+ // A 1px line draws both endpoints, so it spans one pixel more than their distance.
463
+ const box = isBand(stroke)
464
+ ? bandLineBox(x0, y0, x1, y1, stroke)
465
+ : {
466
+ x: Math.min(x0, x1),
467
+ y: Math.min(y0, y1),
468
+ w: Math.abs(x1 - x0) + 1,
469
+ h: Math.abs(y1 - y0) + 1,
470
+ };
471
+ layoutEntries.push(placedEntry(elIdx, 'line', box, canvas));
277
472
  break;
278
473
  }
279
474
  case 'progress': {
@@ -302,13 +497,7 @@ export function renderElement(canvas, el, elIdx, frameIdx, totalFrames, assets,
302
497
  const labelY = y + Math.floor((el.h - FONT_3x5.height) / 2);
303
498
  drawText(target, el.label, labelX, labelY, [200, 200, 200], { font: FONT_3x5 });
304
499
  }
305
- layoutEntries.push({
306
- element: elIdx,
307
- type: 'progress',
308
- box: { x, y, w: el.w, h: el.h },
309
- fits: true,
310
- action: 'none',
311
- });
500
+ layoutEntries.push(placedEntry(elIdx, 'progress', { x, y, w: el.w, h: el.h }, canvas));
312
501
  break;
313
502
  }
314
503
  case 'sparkline': {
@@ -320,7 +509,8 @@ export function renderElement(canvas, el, elIdx, frameIdx, totalFrames, assets,
320
509
  const min_ = Math.min(...data);
321
510
  const max_ = Math.max(...data);
322
511
  const range = max_ - min_ || 1;
323
- const color = el.color ? resolveColor(el.color) : [100, 200, 255];
512
+ const colorSpec = keyframedColor ?? el.color;
513
+ const color = colorSpec ? resolveColor(colorSpec) : [100, 200, 255];
324
514
  if (el.kind === 'bar') {
325
515
  const barW = Math.max(1, Math.floor(el.w / data.length));
326
516
  for (let i = 0; i < data.length; i++) {
@@ -330,24 +520,19 @@ export function renderElement(canvas, el, elIdx, frameIdx, totalFrames, assets,
330
520
  }
331
521
  }
332
522
  else {
333
- const stepX = el.w / (data.length - 1);
523
+ // Scaled over w − 1 and h − 1, so the line fills exactly its w × h box.
524
+ const stepX = (el.w - 1) / (data.length - 1);
525
+ const bottom = y + el.h - 1;
526
+ const rowOf = (v) => bottom - Math.round(((v - min_) / range) * (el.h - 1));
334
527
  for (let i = 0; i < data.length - 1; i++) {
335
- const v0 = data[i] ?? 0;
336
- const v1 = data[i + 1] ?? 0;
337
528
  const x0 = Math.round(x + i * stepX);
338
- const y0 = y + el.h - Math.round(((v0 - min_) / range) * el.h);
529
+ const y0 = rowOf(data[i] ?? 0);
339
530
  const x1 = Math.round(x + (i + 1) * stepX);
340
- const y1 = y + el.h - Math.round(((v1 - min_) / range) * el.h);
531
+ const y1 = rowOf(data[i + 1] ?? 0);
341
532
  target.drawLine(x0, y0, x1, y1, color);
342
533
  }
343
534
  }
344
- layoutEntries.push({
345
- element: elIdx,
346
- type: 'sparkline',
347
- box: { x, y, w: el.w, h: el.h },
348
- fits: true,
349
- action: 'none',
350
- });
535
+ layoutEntries.push(placedEntry(elIdx, 'sparkline', { x, y, w: el.w, h: el.h }, canvas));
351
536
  break;
352
537
  }
353
538
  case 'bitmap': {
@@ -366,28 +551,28 @@ export function renderElement(canvas, el, elIdx, frameIdx, totalFrames, assets,
366
551
  target.setPixel(x + col, y + row, resolveColor(colorStr));
367
552
  }
368
553
  }
369
- layoutEntries.push({
370
- element: elIdx,
371
- type: 'bitmap',
372
- box: { x, y, w: el.rows[0]?.length ?? 0, h: el.rows.length },
373
- fits: true,
374
- action: 'none',
375
- });
554
+ const widest = el.rows.reduce((max, row) => Math.max(max, row.length), 0);
555
+ layoutEntries.push(placedEntry(elIdx, 'bitmap', { x, y, w: widest, h: el.rows.length }, canvas));
376
556
  break;
377
557
  }
378
558
  case 'pixels': {
559
+ let minX = Number.POSITIVE_INFINITY;
560
+ let minY = Number.POSITIVE_INFINITY;
561
+ let maxX = Number.NEGATIVE_INFINITY;
562
+ let maxY = Number.NEGATIVE_INFINITY;
379
563
  for (const pt of el.data) {
380
- const colorAnim = String(getKeyframeValue(kf, 'color', frameIdx, pt.color));
381
- const c = resolveColor(colorAnim);
382
- target.setPixel(pt.x + dx, pt.y + dy, c);
564
+ const x = pt.x + dx;
565
+ const y = pt.y + dy;
566
+ target.setPixel(x, y, resolveColor(keyframedColor ?? pt.color));
567
+ minX = Math.min(minX, x);
568
+ minY = Math.min(minY, y);
569
+ maxX = Math.max(maxX, x);
570
+ maxY = Math.max(maxY, y);
383
571
  }
384
- layoutEntries.push({
385
- element: elIdx,
386
- type: 'pixels',
387
- box: { x: 0, y: 0, w: 0, h: 0 },
388
- fits: true,
389
- action: 'none',
390
- });
572
+ const box = el.data.length === 0
573
+ ? { x: 0, y: 0, w: 0, h: 0 }
574
+ : { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 };
575
+ layoutEntries.push(placedEntry(elIdx, 'pixels', box, canvas));
391
576
  break;
392
577
  }
393
578
  case 'image': {
@@ -395,18 +580,17 @@ export function renderElement(canvas, el, elIdx, frameIdx, totalFrames, assets,
395
580
  if (cachedCanvas) {
396
581
  target.blit(cachedCanvas, dx, dy);
397
582
  }
398
- layoutEntries.push({
399
- element: elIdx,
400
- type: 'image',
401
- box: { x: el.x ?? 0, y: el.y ?? 0, w: el.w ?? canvas.width, h: el.h ?? canvas.height },
402
- fits: true,
403
- action: 'none',
404
- });
583
+ const box = {
584
+ x: (el.x ?? 0) + dx,
585
+ y: (el.y ?? 0) + dy,
586
+ w: el.w ?? canvas.width,
587
+ h: el.h ?? canvas.height,
588
+ };
589
+ layoutEntries.push(placedEntry(elIdx, 'image', box, canvas));
405
590
  break;
406
591
  }
407
592
  case 'sprite': {
408
- const key = `${el.path}:${el.cols}:${el.rows}`;
409
- const sprite = assets.sprites.get(key);
593
+ const sprite = assets.sprites.get(spriteKey(el));
410
594
  if (sprite) {
411
595
  const scale = el.scale ?? 1;
412
596
  const spriteW = sprite.cols * scale;
@@ -426,29 +610,14 @@ export function renderElement(canvas, el, elIdx, frameIdx, totalFrames, assets,
426
610
  originalBodyColor: sprite.bodyColor,
427
611
  originalDarkColor: sprite.darkColor,
428
612
  });
429
- layoutEntries.push({
430
- element: elIdx,
431
- type: 'sprite',
432
- box: { x: resolvedX, y: resolvedY, w: spriteW, h: spriteH },
433
- fits: resolvedX + spriteW <= canvas.width && resolvedY + spriteH <= canvas.height,
434
- action: 'none',
435
- });
613
+ const box = { x: resolvedX, y: resolvedY, w: spriteW, h: spriteH };
614
+ layoutEntries.push(placedEntry(elIdx, 'sprite', box, canvas));
436
615
  }
437
616
  break;
438
617
  }
439
618
  }
440
- // Blit scratch canvas onto main if we used opacity
441
- if (opacity < 100 && target !== canvas) {
442
- const alpha = opacity / 100;
443
- for (let py_ = 0; py_ < canvas.height; py_++) {
444
- for (let px_ = 0; px_ < canvas.width; px_++) {
445
- const [r, g, b, a] = target.getPixelRgba(px_, py_);
446
- if (a > 0) {
447
- canvas.blendPixel(px_, py_, [r, g, b], alpha);
448
- }
449
- }
450
- }
451
- }
619
+ if (target !== canvas)
620
+ compositeLayer(canvas, target, opacity, blend);
452
621
  }
453
622
  /** Render a complete frame. */
454
623
  export function renderFrame(frameIdx, totalFrames, background, elements, assets, size = 64) {