@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.
- package/AGENTS.md +10 -7
- package/CLAUDE.md +10 -7
- package/Dockerfile +30 -10
- package/README.md +65 -86
- package/changelog/1.2.x/1.2.1.md +35 -0
- package/changelog/1.2.x/1.2.2.md +40 -0
- package/dist/mcp-server/resources/definitions/pixoo-design-guide.resource.d.ts.map +1 -1
- package/dist/mcp-server/resources/definitions/pixoo-design-guide.resource.js +4 -1
- package/dist/mcp-server/resources/definitions/pixoo-design-guide.resource.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pixoo-compose-scene.tool.d.ts +173 -13
- package/dist/mcp-server/tools/definitions/pixoo-compose-scene.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/pixoo-compose-scene.tool.js +129 -28
- package/dist/mcp-server/tools/definitions/pixoo-compose-scene.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pixoo-design-brief.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/pixoo-design-brief.tool.js +15 -7
- package/dist/mcp-server/tools/definitions/pixoo-design-brief.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pixoo-display-text.tool.d.ts +2 -0
- package/dist/mcp-server/tools/definitions/pixoo-display-text.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/pixoo-display-text.tool.js +27 -12
- package/dist/mcp-server/tools/definitions/pixoo-display-text.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/pixoo-push-image.tool.d.ts +30 -1
- package/dist/mcp-server/tools/definitions/pixoo-push-image.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/pixoo-push-image.tool.js +105 -26
- package/dist/mcp-server/tools/definitions/pixoo-push-image.tool.js.map +1 -1
- package/dist/mcp-server/tools/finish-schema.d.ts +28 -0
- package/dist/mcp-server/tools/finish-schema.d.ts.map +1 -0
- package/dist/mcp-server/tools/finish-schema.js +42 -0
- package/dist/mcp-server/tools/finish-schema.js.map +1 -0
- package/dist/renderer/finish.d.ts +34 -0
- package/dist/renderer/finish.d.ts.map +1 -0
- package/dist/renderer/finish.js +62 -0
- package/dist/renderer/finish.js.map +1 -0
- package/dist/renderer/icons.d.ts +14 -4
- package/dist/renderer/icons.d.ts.map +1 -1
- package/dist/renderer/icons.js +30 -22
- package/dist/renderer/icons.js.map +1 -1
- package/dist/renderer/keyframes.d.ts +40 -4
- package/dist/renderer/keyframes.d.ts.map +1 -1
- package/dist/renderer/keyframes.js +100 -53
- package/dist/renderer/keyframes.js.map +1 -1
- package/dist/renderer/remote-image.d.ts +13 -7
- package/dist/renderer/remote-image.d.ts.map +1 -1
- package/dist/renderer/remote-image.js +17 -21
- package/dist/renderer/remote-image.js.map +1 -1
- package/dist/renderer/scene-renderer.d.ts +45 -6
- package/dist/renderer/scene-renderer.d.ts.map +1 -1
- package/dist/renderer/scene-renderer.js +328 -159
- package/dist/renderer/scene-renderer.js.map +1 -1
- package/dist/renderer/text-engine.d.ts +24 -5
- package/dist/renderer/text-engine.d.ts.map +1 -1
- package/dist/renderer/text-engine.js +51 -28
- package/dist/renderer/text-engine.js.map +1 -1
- package/package.json +7 -6
- 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,
|
|
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 {
|
|
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'`
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
123
|
+
placement.width = el.w;
|
|
60
124
|
if (el.h !== undefined)
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
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 =
|
|
70
|
-
|
|
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
|
-
//
|
|
140
|
-
const
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
359
|
+
let paths;
|
|
169
360
|
let viewBox = '0 0 16 16';
|
|
170
361
|
if (el.name) {
|
|
171
|
-
|
|
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
|
-
|
|
367
|
+
paths = iconEntry;
|
|
176
368
|
viewBox = iconEntry.viewBox;
|
|
177
369
|
}
|
|
178
370
|
else if (el.d) {
|
|
179
|
-
|
|
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
|
-
|
|
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
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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 (
|
|
229
|
-
target.fillRect(x, y, el.w, el.h, resolveColor(
|
|
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
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
529
|
+
const y0 = rowOf(data[i] ?? 0);
|
|
339
530
|
const x1 = Math.round(x + (i + 1) * stepX);
|
|
340
|
-
const y1 =
|
|
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
|
-
|
|
370
|
-
|
|
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
|
|
381
|
-
const
|
|
382
|
-
target.setPixel(
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|
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
|
-
|
|
430
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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) {
|