@meonode/canvas 1.6.0-beta.1 → 1.6.0-beta.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.
@@ -31,22 +31,77 @@ function configure(options) {
31
31
  }
32
32
  }
33
33
  /**
34
- * Minimal Canvas-compatible wrapper returned when rendering in worker mode.
35
- * Exposes toBuffer / toBufferSync so callers can use the result identically.
34
+ * Proxies all skia-canvas Canvas APIs to a Canvas instance living inside a worker thread.
35
+ * Sync methods (toBufferSync, toURLSync) return from a pre-encoded PNG buffer.
36
+ * Async methods (toBuffer, toURL, toFile, getters) delegate to the worker.
36
37
  */
37
38
  class WorkerCanvas {
38
- _buffer;
39
- constructor(_buffer) {
40
- this._buffer = _buffer;
39
+ width;
40
+ height;
41
+ _buffer; // pre-encoded PNG for sync use
42
+ _pool;
43
+ _workerIdx;
44
+ _canvasId;
45
+ constructor(opts) {
46
+ this._buffer = opts.buffer;
47
+ this.width = opts.width;
48
+ this.height = opts.height;
49
+ this._pool = opts.pool;
50
+ this._workerIdx = opts.workerIdx;
51
+ this._canvasId = opts.canvasId;
41
52
  }
42
- toBufferSync(_format) {
53
+ _call(method, ...args) {
54
+ return this._pool.callOnCanvas(this._workerIdx, this._canvasId, method, args);
55
+ }
56
+ // --- Sync methods: return from pre-encoded PNG buffer ---
57
+ toBufferSync(_format, _options) {
43
58
  return this._buffer;
44
59
  }
45
- toBuffer(_format) {
46
- return Promise.resolve(this._buffer);
60
+ toURLSync(_format, _options) {
61
+ return `data:image/png;base64,${this._buffer.toString('base64')}`;
62
+ }
63
+ // --- Async methods: delegate to worker ---
64
+ toBuffer(format, options) {
65
+ return this._call('toBuffer', format, options);
66
+ }
67
+ toURL(format, options) {
68
+ return this._call('toURL', format, options);
69
+ }
70
+ toFile(filename, options) {
71
+ return this._call('toFile', filename, options);
72
+ }
73
+ /** Returns a Buffer (Sharp instance cannot be transferred across threads) */
74
+ toSharp(options) {
75
+ return this._call('toSharp', options);
76
+ }
77
+ toSharpSync(_options) {
78
+ throw new Error('[canvas] toSharpSync() is not available in worker mode — use toSharp() instead');
79
+ }
80
+ // --- Async convenience getters ---
81
+ get png() {
82
+ return this._call('toBuffer', 'png');
83
+ }
84
+ get webp() {
85
+ return this._call('toBuffer', 'webp');
86
+ }
87
+ get jpg() {
88
+ return this._call('toBuffer', 'jpg');
89
+ }
90
+ get svg() {
91
+ return this._call('toBuffer', 'svg');
92
+ }
93
+ get pdf() {
94
+ return this._call('toBuffer', 'pdf');
95
+ }
96
+ get raw() {
97
+ return this._call('toBuffer', 'raw');
98
+ }
99
+ /** Release the Canvas from worker memory. Call when done with this object. */
100
+ release() {
101
+ this._pool.releaseCanvas(this._workerIdx, this._canvasId);
47
102
  }
48
103
  }
49
- /** Lazy-instantiated worker pool singleton */
104
+ /** Worker thread pool routes render and canvas-call messages */
50
105
  class WorkerPool {
51
106
  workers = [];
52
107
  idle = [];
@@ -56,22 +111,30 @@ class WorkerPool {
56
111
  constructor(size) {
57
112
  this.init(size);
58
113
  }
59
- async init(size) {
114
+ init(size) {
60
115
  const workerFile = path.join(path.dirname(fileURLToPath(import.meta.url)), '../render.worker.js');
61
116
  for (let i = 0; i < size; i++) {
117
+ const workerIdx = i;
62
118
  const worker = new Worker(workerFile);
63
- worker.on('message', ({ id, buffer, error }) => {
64
- const task = this.pending.get(id);
119
+ worker.on('message', (msg) => {
120
+ const task = this.pending.get(msg.taskId);
65
121
  if (!task)
66
122
  return;
67
- this.pending.delete(id);
68
- this.idle.push(worker);
69
- this.drain();
70
- if (error) {
71
- task.reject(new Error(error));
123
+ this.pending.delete(msg.taskId);
124
+ if ('error' in msg) {
125
+ task.reject(new Error(msg.error));
126
+ return;
127
+ }
128
+ if ('canvasId' in msg) {
129
+ // Render complete — put worker back to idle
130
+ this.idle.push(worker);
131
+ this.drain();
132
+ const result = { buffer: msg.buffer, canvasId: msg.canvasId, workerIdx, width: msg.width, height: msg.height };
133
+ task.resolve(result);
72
134
  }
73
135
  else {
74
- task.resolve(buffer);
136
+ // Canvas method call complete
137
+ task.resolve(msg.result);
75
138
  }
76
139
  });
77
140
  this.workers.push(worker);
@@ -82,22 +145,36 @@ class WorkerPool {
82
145
  while (this.queue.length > 0 && this.idle.length > 0) {
83
146
  const task = this.queue.shift();
84
147
  const worker = this.idle.pop();
85
- worker.postMessage({ id: task.id, props: task.props });
148
+ const request = { type: 'render', taskId: task.id, props: task.props };
149
+ worker.postMessage(request);
86
150
  }
87
151
  }
88
152
  render(props) {
89
153
  return new Promise((resolve, reject) => {
90
154
  const id = this.nextId++;
91
- this.pending.set(id, { resolve, reject });
155
+ this.pending.set(id, { resolve: resolve, reject });
92
156
  if (this.idle.length > 0) {
93
157
  const worker = this.idle.pop();
94
- worker.postMessage({ id, props });
158
+ const request = { type: 'render', taskId: id, props };
159
+ worker.postMessage(request);
95
160
  }
96
161
  else {
97
162
  this.queue.push({ id, props });
98
163
  }
99
164
  });
100
165
  }
166
+ callOnCanvas(workerIdx, canvasId, method, args) {
167
+ return new Promise((resolve, reject) => {
168
+ const id = this.nextId++;
169
+ this.pending.set(id, { resolve: resolve, reject });
170
+ const request = { type: 'call', taskId: id, canvasId, method, args };
171
+ this.workers[workerIdx].postMessage(request);
172
+ });
173
+ }
174
+ releaseCanvas(workerIdx, canvasId) {
175
+ const request = { type: 'release', canvasId };
176
+ this.workers[workerIdx]?.postMessage(request);
177
+ }
101
178
  terminate() {
102
179
  this.workers.forEach(w => w.terminate());
103
180
  }
@@ -211,15 +288,17 @@ class RootNode extends ColumnNode {
211
288
  * @returns Promise resolving to the rendered Canvas instance
212
289
  */
213
290
  async render() {
214
- // Step 1: Load all images with a concurrency limit to avoid overwhelming remote sources
291
+ // Step 1: Load all images with a concurrency limit to avoid overwhelming remote sources.
292
+ // A per-render cache deduplicates identical src+color combinations within this render pass.
215
293
  const imageNodes = this.findAllImageNodes();
216
294
  if (imageNodes.length > 0) {
295
+ const imageCache = new Map();
217
296
  const CONCURRENCY = 5;
218
297
  const queue = [...imageNodes];
219
298
  const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
220
299
  while (queue.length > 0) {
221
300
  const node = queue.shift();
222
- await node.load();
301
+ await node.load(imageCache);
223
302
  }
224
303
  });
225
304
  await Promise.allSettled(workers);
@@ -259,8 +338,8 @@ const Root = async (props) => {
259
338
  if (!_workerPool) {
260
339
  _workerPool = new WorkerPool(_workerPoolSize);
261
340
  }
262
- const buffer = await _workerPool.render(props);
263
- return new WorkerCanvas(buffer);
341
+ const result = await _workerPool.render(props);
342
+ return new WorkerCanvas({ ...result, pool: _workerPool });
264
343
  }
265
344
  return new RootNode(props).render();
266
345
  };
@@ -0,0 +1,76 @@
1
+ import type { ExportFormat, ExportOptions, SaveOptions, RenderOptions } from 'skia-canvas';
2
+ import type { RootProps } from '../canvas/canvas.type.js';
3
+ export interface CanvasCallMap {
4
+ toBuffer: {
5
+ args: [ExportFormat, ExportOptions?];
6
+ result: Buffer;
7
+ };
8
+ toURL: {
9
+ args: [ExportFormat, ExportOptions?];
10
+ result: string;
11
+ };
12
+ toFile: {
13
+ args: [string, SaveOptions?];
14
+ result: void;
15
+ };
16
+ toSharp: {
17
+ args: [RenderOptions?];
18
+ result: Buffer;
19
+ };
20
+ }
21
+ export type CanvasCallMethod = keyof CanvasCallMap;
22
+ export type CallArgs<M extends CanvasCallMethod> = CanvasCallMap[M]['args'];
23
+ export type CallResult<M extends CanvasCallMethod> = CanvasCallMap[M]['result'];
24
+ export interface WorkerRenderRequest {
25
+ type: 'render';
26
+ taskId: number;
27
+ props: RootProps;
28
+ }
29
+ /** Discriminated union — narrows args alongside method in switch statements */
30
+ export type WorkerCallRequest = {
31
+ type: 'call';
32
+ taskId: number;
33
+ canvasId: number;
34
+ method: 'toBuffer';
35
+ args: CallArgs<'toBuffer'>;
36
+ } | {
37
+ type: 'call';
38
+ taskId: number;
39
+ canvasId: number;
40
+ method: 'toURL';
41
+ args: CallArgs<'toURL'>;
42
+ } | {
43
+ type: 'call';
44
+ taskId: number;
45
+ canvasId: number;
46
+ method: 'toFile';
47
+ args: CallArgs<'toFile'>;
48
+ } | {
49
+ type: 'call';
50
+ taskId: number;
51
+ canvasId: number;
52
+ method: 'toSharp';
53
+ args: CallArgs<'toSharp'>;
54
+ };
55
+ export interface WorkerReleaseRequest {
56
+ type: 'release';
57
+ canvasId: number;
58
+ }
59
+ export type WorkerRequest = WorkerRenderRequest | WorkerCallRequest | WorkerReleaseRequest;
60
+ export interface WorkerRenderResponse {
61
+ taskId: number;
62
+ canvasId: number;
63
+ buffer: Buffer;
64
+ width: number;
65
+ height: number;
66
+ }
67
+ export interface WorkerCallResponse {
68
+ taskId: number;
69
+ result: Buffer | string | void;
70
+ }
71
+ export interface WorkerErrorResponse {
72
+ taskId: number;
73
+ error: string;
74
+ }
75
+ export type WorkerResponse = WorkerRenderResponse | WorkerCallResponse | WorkerErrorResponse;
76
+ //# sourceMappingURL=worker.types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker.types.d.ts","sourceRoot":"","sources":["../../../src/canvas/worker.types.ts"],"names":[],"mappings":"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"}
@@ -3,19 +3,68 @@ import { RootNode } from './canvas/root.canvas.util.js';
3
3
 
4
4
  /**
5
5
  * Worker thread entry point for off-main-thread canvas rendering.
6
- * Receives serialized RootProps (with NodeDescriptor children), builds the
7
- * node tree, renders to canvas, encodes to PNG, and posts the buffer back.
6
+ *
7
+ * Message protocol (main worker):
8
+ * { type: 'render', taskId, props } — render and keep Canvas alive
9
+ * { type: 'call', taskId, canvasId, method, args } — call a method on a live Canvas
10
+ * { type: 'release', canvasId } — free Canvas from memory
11
+ *
12
+ * Responses (worker → main):
13
+ * WorkerRenderResponse — render complete (includes pre-encoded PNG buffer)
14
+ * WorkerCallResponse — method call result
15
+ * WorkerErrorResponse — any failure
8
16
  */
9
17
  if (!parentPort) {
10
18
  throw new Error('[render.worker] Must be run as a worker thread');
11
19
  }
12
- parentPort.on('message', async ({ id, props }) => {
13
- try {
14
- const canvas = await new RootNode(props).render();
15
- const buffer = canvas.toBufferSync('png');
16
- parentPort.postMessage({ id, buffer });
20
+ const canvases = new Map();
21
+ let nextCanvasId = 0;
22
+ function reply(msg) {
23
+ parentPort.postMessage(msg);
24
+ }
25
+ parentPort.on('message', async (msg) => {
26
+ if (msg.type === 'render') {
27
+ try {
28
+ const canvas = await new RootNode(msg.props).render();
29
+ const canvasId = nextCanvasId++;
30
+ canvases.set(canvasId, canvas);
31
+ reply({ taskId: msg.taskId, canvasId, buffer: canvas.toBufferSync('png'), width: canvas.width, height: canvas.height });
32
+ }
33
+ catch (err) {
34
+ reply({ taskId: msg.taskId, error: String(err) });
35
+ }
36
+ }
37
+ else if (msg.type === 'call') {
38
+ const canvas = canvases.get(msg.canvasId);
39
+ if (!canvas) {
40
+ reply({ taskId: msg.taskId, error: `[render.worker] Canvas ${msg.canvasId} not found` });
41
+ return;
42
+ }
43
+ try {
44
+ let result;
45
+ switch (msg.method) {
46
+ case 'toBuffer':
47
+ result = await canvas.toBuffer(...msg.args);
48
+ break;
49
+ case 'toURL':
50
+ result = await canvas.toURL(...msg.args);
51
+ break;
52
+ case 'toFile':
53
+ result = await canvas.toFile(...msg.args);
54
+ break;
55
+ case 'toSharp':
56
+ // Sharp instances can't be transferred across threads — serialize to buffer
57
+ result = await canvas.toSharp(...msg.args).toBuffer();
58
+ break;
59
+ }
60
+ reply({ taskId: msg.taskId, result });
61
+ }
62
+ catch (err) {
63
+ reply({ taskId: msg.taskId, error: String(err) });
64
+ }
17
65
  }
18
- catch (err) {
19
- parentPort.postMessage({ id, error: String(err) });
66
+ else {
67
+ // type === 'release'
68
+ canvases.delete(msg.canvasId);
20
69
  }
21
70
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meonode/canvas",
3
- "version": "1.6.0-beta.1",
3
+ "version": "1.6.0-beta.2",
4
4
  "description": "A declarative, component-based library for server-side canvas image generation. Write complex visuals with simple functions, similar to the composition style of @meonode/ui.",
5
5
  "keywords": [
6
6
  "canvas",