@fcannizzaro/streamdeck-react 0.1.10 → 0.1.11

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 (74) hide show
  1. package/LICENSE +190 -21
  2. package/README.md +2 -0
  3. package/dist/action.d.ts +2 -2
  4. package/dist/action.js +2 -2
  5. package/dist/bundler-shared.d.ts +11 -0
  6. package/dist/bundler-shared.js +11 -0
  7. package/dist/context/event-bus.d.ts +1 -1
  8. package/dist/context/event-bus.js +1 -1
  9. package/dist/context/touchstrip-context.d.ts +2 -0
  10. package/dist/context/touchstrip-context.js +5 -0
  11. package/dist/devtools/bridge.d.ts +35 -7
  12. package/dist/devtools/bridge.js +153 -46
  13. package/dist/devtools/highlight.d.ts +6 -0
  14. package/dist/devtools/highlight.js +106 -57
  15. package/dist/devtools/index.js +6 -0
  16. package/dist/devtools/observers/lifecycle.d.ts +4 -4
  17. package/dist/devtools/server.d.ts +6 -1
  18. package/dist/devtools/server.js +6 -1
  19. package/dist/devtools/types.d.ts +50 -6
  20. package/dist/font-inline.d.ts +5 -1
  21. package/dist/font-inline.js +8 -3
  22. package/dist/hooks/animation.d.ts +154 -0
  23. package/dist/hooks/animation.js +381 -0
  24. package/dist/hooks/events.js +1 -5
  25. package/dist/hooks/touchstrip.d.ts +6 -0
  26. package/dist/hooks/touchstrip.js +37 -0
  27. package/dist/index.d.ts +7 -2
  28. package/dist/index.js +3 -2
  29. package/dist/manifest-codegen.d.ts +38 -0
  30. package/dist/manifest-codegen.js +110 -0
  31. package/dist/node_modules/.bun/xxhash-wasm@1.1.0/node_modules/xxhash-wasm/esm/xxhash-wasm.js +3157 -0
  32. package/dist/plugin.js +20 -9
  33. package/dist/reconciler/host-config.js +19 -1
  34. package/dist/reconciler/vnode.d.ts +26 -0
  35. package/dist/reconciler/vnode.js +41 -10
  36. package/dist/render/buffer-pool.d.ts +19 -0
  37. package/dist/render/buffer-pool.js +51 -0
  38. package/dist/render/cache.d.ts +41 -0
  39. package/dist/render/cache.js +159 -5
  40. package/dist/render/image-cache.d.ts +53 -0
  41. package/dist/render/image-cache.js +128 -0
  42. package/dist/render/metrics.d.ts +58 -0
  43. package/dist/render/metrics.js +101 -0
  44. package/dist/render/pipeline.d.ts +46 -1
  45. package/dist/render/pipeline.js +370 -36
  46. package/dist/render/png.d.ts +10 -1
  47. package/dist/render/png.js +31 -13
  48. package/dist/render/render-pool.d.ts +26 -0
  49. package/dist/render/render-pool.js +141 -0
  50. package/dist/render/svg.d.ts +7 -0
  51. package/dist/render/svg.js +139 -0
  52. package/dist/render/worker.d.ts +1 -0
  53. package/dist/rollup.d.ts +23 -9
  54. package/dist/rollup.js +24 -9
  55. package/dist/roots/flush-coordinator.d.ts +18 -0
  56. package/dist/roots/flush-coordinator.js +38 -0
  57. package/dist/roots/registry.d.ts +6 -4
  58. package/dist/roots/registry.js +47 -33
  59. package/dist/roots/root.d.ts +32 -2
  60. package/dist/roots/root.js +104 -14
  61. package/dist/roots/settings-equality.d.ts +5 -0
  62. package/dist/roots/settings-equality.js +24 -0
  63. package/dist/roots/touchstrip-root.d.ts +93 -0
  64. package/dist/roots/touchstrip-root.js +383 -0
  65. package/dist/types.d.ts +62 -16
  66. package/dist/vite.d.ts +22 -8
  67. package/dist/vite.js +24 -8
  68. package/package.json +5 -4
  69. package/dist/context/touchbar-context.d.ts +0 -2
  70. package/dist/context/touchbar-context.js +0 -5
  71. package/dist/hooks/touchbar.d.ts +0 -6
  72. package/dist/hooks/touchbar.js +0 -37
  73. package/dist/roots/touchbar-root.d.ts +0 -45
  74. package/dist/roots/touchbar-root.js +0 -175
@@ -1,62 +1,364 @@
1
- import { vnodeToElement } from "../reconciler/vnode.js";
2
- import { fnv1a } from "./cache.js";
3
- import { encodePng } from "./png.js";
4
- import { createElement } from "react";
5
- import { fromJsx } from "@takumi-rs/helpers/jsx";
1
+ import { clearDirtyFlags, isContainerDirty } from "../reconciler/vnode.js";
2
+ import { computeCacheKey, computeTreeHash, fnv1a } from "./cache.js";
3
+ import { getBufferPool } from "./buffer-pool.js";
4
+ import { encodePngAsync } from "./png.js";
5
+ import { getImageCache, getTouchstripCache } from "./image-cache.js";
6
+ import { metrics } from "./metrics.js";
7
+ import { serializeSvgTree } from "./svg.js";
6
8
  //#region src/render/pipeline.ts
9
+ var ROOT_STYLE = {
10
+ display: "flex",
11
+ width: "100%",
12
+ height: "100%"
13
+ };
14
+ /** Warn threshold for consecutive identical renders in debug mode. */
15
+ var DUP_RENDER_WARN_THRESHOLD = 3;
16
+ /** Maximum recommended VNode tree depth. Warn in debug mode when exceeded. */
17
+ var MAX_DEPTH_WARN = 25;
18
+ /** Whether we've already warned about tree depth (avoid log spam). */
19
+ var depthWarned = false;
20
+ var SKIP_PROPS = new Set([
21
+ "children",
22
+ "className",
23
+ "src"
24
+ ]);
25
+ function copyPropsToNode(target, props) {
26
+ const keys = Object.keys(props);
27
+ for (let i = 0; i < keys.length; i++) {
28
+ const key = keys[i];
29
+ if (SKIP_PROPS.has(key)) continue;
30
+ const value = props[key];
31
+ if (key === "tw") continue;
32
+ target[key] = value;
33
+ }
34
+ }
35
+ function vnodeToTakumiNode(node, depth = 0) {
36
+ if (!depthWarned && depth > MAX_DEPTH_WARN) {
37
+ depthWarned = true;
38
+ console.warn(`[@fcannizzaro/streamdeck-react] VNode tree depth ${depth} exceeds recommended limit ${MAX_DEPTH_WARN}. Deep nesting increases render cost.`);
39
+ }
40
+ if (node.type === "#text") return {
41
+ type: "text",
42
+ text: node.text ?? ""
43
+ };
44
+ const props = node.props;
45
+ const rawTw = typeof props.tw === "string" ? props.tw : void 0;
46
+ const className = props.className;
47
+ let tw = rawTw;
48
+ if (typeof className === "string" && className.length > 0) tw = tw ? tw + " " + className : className;
49
+ if (node.type === "img" && typeof props.src === "string") {
50
+ const result = {
51
+ type: "image",
52
+ src: props.src
53
+ };
54
+ if (tw) result.tw = tw;
55
+ copyPropsToNode(result, props);
56
+ return result;
57
+ }
58
+ if (node.type === "svg") {
59
+ const result = {
60
+ type: "image",
61
+ src: serializeSvgTree(node),
62
+ tagName: "svg"
63
+ };
64
+ const width = typeof props.width === "number" ? props.width : void 0;
65
+ const height = typeof props.height === "number" ? props.height : void 0;
66
+ if (width != null) result.width = width;
67
+ if (height != null) result.height = height;
68
+ if (tw) result.tw = tw;
69
+ if (props.style) result.style = props.style;
70
+ return result;
71
+ }
72
+ const result = { type: "container" };
73
+ if (tw) result.tw = tw;
74
+ copyPropsToNode(result, props);
75
+ if (node.children.length > 0) result.children = node.children.map((child) => vnodeToTakumiNode(child, depth + 1));
76
+ return result;
77
+ }
78
+ /** Build the root Takumi container wrapping the VNode children. */
79
+ function buildTakumiRoot(container) {
80
+ return {
81
+ type: "container",
82
+ style: ROOT_STYLE,
83
+ children: container.children.map(vnodeToTakumiNode)
84
+ };
85
+ }
86
+ /**
87
+ * Convert a container's VNode children to Takumi nodes.
88
+ * Used by the touchstrip native-format path to build the Takumi node tree
89
+ * once and share it across all N segment renders in a single flush.
90
+ */
91
+ function buildTakumiChildren(container) {
92
+ return container.children.map(vnodeToTakumiNode);
93
+ }
94
+ function measureTree(nodes) {
95
+ let maxDepth = 0;
96
+ let count = 0;
97
+ function walk(children, depth) {
98
+ for (const child of children) {
99
+ count++;
100
+ if (depth > maxDepth) maxDepth = depth;
101
+ walk(child.children, depth + 1);
102
+ }
103
+ }
104
+ walk(nodes, 1);
105
+ return {
106
+ depth: maxDepth,
107
+ count
108
+ };
109
+ }
7
110
  function bufferToDataUri(buffer, format) {
8
111
  return `data:image/${format};base64,${(Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer)).toString("base64")}`;
9
112
  }
113
+ function emitProfile(config, times, opts) {
114
+ const stats = measureTree(opts.container.children);
115
+ const cache = config.imageCacheMaxBytes > 0 ? getImageCache(config.imageCacheMaxBytes) : null;
116
+ config.onProfile({
117
+ vnodeToElementMs: times.t1 - times.t0,
118
+ fromJsxMs: times.t2 - times.t1,
119
+ takumiRenderMs: times.t3 - times.t2,
120
+ hashMs: times.t4 - times.t3,
121
+ base64Ms: times.t5 - times.t4,
122
+ totalMs: times.t5 - times.t0,
123
+ skipped: opts.skipped,
124
+ cacheHit: opts.cacheHit,
125
+ treeDepth: stats.depth,
126
+ nodeCount: stats.count,
127
+ cacheStats: cache?.stats ?? null
128
+ });
129
+ }
10
130
  async function renderToDataUri(container, width, height, config) {
11
131
  if (container.children.length === 0) return null;
12
- const { node, stylesheets } = await fromJsx(createElement("div", { style: {
13
- display: "flex",
14
- width: "100%",
15
- height: "100%"
16
- } }, ...container.children.map(vnodeToElement)));
17
- const buffer = await config.renderer.render(node, {
18
- width,
19
- height,
20
- format: config.imageFormat,
21
- stylesheets,
22
- devicePixelRatio: config.devicePixelRatio
23
- });
132
+ metrics.recordFlush();
133
+ if (config.caching && !isContainerDirty(container)) {
134
+ metrics.recordDirtySkip();
135
+ return null;
136
+ }
137
+ const profiling = config.onProfile != null;
138
+ const t0 = profiling ? performance.now() : 0;
139
+ let t1 = t0;
140
+ let t2 = t0;
141
+ let t3 = t0;
142
+ let treeHash;
143
+ let cacheKey;
144
+ if (config.caching && config.imageCacheMaxBytes > 0) {
145
+ treeHash = computeTreeHash(container);
146
+ cacheKey = computeCacheKey(treeHash, width, height, config.devicePixelRatio, config.imageFormat);
147
+ const cached = getImageCache(config.imageCacheMaxBytes).get(cacheKey);
148
+ if (cached !== void 0) {
149
+ metrics.recordCacheHit();
150
+ if (profiling) {
151
+ const tNow = performance.now();
152
+ emitProfile(config, {
153
+ t0,
154
+ t1: tNow,
155
+ t2: tNow,
156
+ t3: tNow,
157
+ t4: tNow,
158
+ t5: tNow
159
+ }, {
160
+ skipped: false,
161
+ cacheHit: true,
162
+ container
163
+ });
164
+ }
165
+ container._dupCount = 0;
166
+ config.onRender?.(container, cached);
167
+ clearDirtyFlags(container);
168
+ return cached;
169
+ }
170
+ }
171
+ let buffer;
172
+ if (config.renderPool?.isAvailable) {
173
+ buffer = await config.renderPool.render(container.children, width, height, config.imageFormat, config.devicePixelRatio);
174
+ t3 = profiling ? performance.now() : 0;
175
+ t1 = t0;
176
+ t2 = t0;
177
+ } else {
178
+ const rootNode = buildTakumiRoot(container);
179
+ t1 = profiling ? performance.now() : 0;
180
+ t2 = t1;
181
+ buffer = await config.renderer.render(rootNode, {
182
+ width,
183
+ height,
184
+ format: config.imageFormat,
185
+ devicePixelRatio: config.devicePixelRatio
186
+ });
187
+ t3 = profiling ? performance.now() : 0;
188
+ }
24
189
  if (config.caching) {
25
190
  const hash = fnv1a(buffer);
26
- if (hash === container.lastSvgHash) return null;
191
+ if (hash === container.lastSvgHash) {
192
+ metrics.recordHashDedup();
193
+ if (config.debug) {
194
+ container._dupCount++;
195
+ if (container._dupCount > DUP_RENDER_WARN_THRESHOLD) console.warn(`[@fcannizzaro/streamdeck-react] ${container._dupCount} consecutive identical renders — component likely re-rendering without visual change`);
196
+ }
197
+ if (profiling) {
198
+ const t4 = performance.now();
199
+ emitProfile(config, {
200
+ t0,
201
+ t1,
202
+ t2,
203
+ t3,
204
+ t4,
205
+ t5: t4
206
+ }, {
207
+ skipped: true,
208
+ cacheHit: false,
209
+ container
210
+ });
211
+ }
212
+ clearDirtyFlags(container);
213
+ return null;
214
+ }
27
215
  container.lastSvgHash = hash;
216
+ container._dupCount = 0;
28
217
  }
218
+ const t4 = profiling ? performance.now() : 0;
29
219
  const dataUri = bufferToDataUri(buffer, config.imageFormat);
220
+ const t5 = profiling ? performance.now() : 0;
221
+ if (config.caching && config.imageCacheMaxBytes > 0) {
222
+ if (treeHash === void 0 || cacheKey === void 0) {
223
+ treeHash = computeTreeHash(container);
224
+ cacheKey = computeCacheKey(treeHash, width, height, config.devicePixelRatio, config.imageFormat);
225
+ }
226
+ getImageCache(config.imageCacheMaxBytes).set(cacheKey, dataUri, dataUri.length * 2 + 64);
227
+ }
228
+ metrics.recordRender(t3 - t0);
229
+ if (profiling) emitProfile(config, {
230
+ t0,
231
+ t1,
232
+ t2,
233
+ t3,
234
+ t4,
235
+ t5
236
+ }, {
237
+ skipped: false,
238
+ cacheHit: false,
239
+ container
240
+ });
30
241
  config.onRender?.(container, dataUri);
242
+ clearDirtyFlags(container);
31
243
  return dataUri;
32
244
  }
33
245
  async function renderToRaw(container, width, height, config) {
34
246
  if (container.children.length === 0) return null;
35
- const { node, stylesheets } = await fromJsx(createElement("div", { style: {
36
- display: "flex",
37
- width: "100%",
38
- height: "100%"
39
- } }, ...container.children.map(vnodeToElement)));
40
- const buffer = await config.renderer.render(node, {
41
- width,
42
- height,
43
- format: "raw",
44
- stylesheets,
45
- devicePixelRatio: config.devicePixelRatio
46
- });
247
+ metrics.recordFlush();
248
+ if (config.caching && !isContainerDirty(container)) {
249
+ metrics.recordDirtySkip();
250
+ return null;
251
+ }
252
+ const profiling = config.onProfile != null;
253
+ const t0 = profiling ? performance.now() : 0;
254
+ let t1 = t0;
255
+ let t3 = t0;
256
+ let treeHash;
257
+ let cacheKey;
258
+ if (config.caching && config.touchstripCacheMaxBytes > 0) {
259
+ treeHash = computeTreeHash(container);
260
+ cacheKey = computeCacheKey(treeHash, width, height, config.devicePixelRatio, "raw");
261
+ const cached = getTouchstripCache(config.touchstripCacheMaxBytes).get(cacheKey);
262
+ if (cached !== void 0) {
263
+ metrics.recordCacheHit();
264
+ if (profiling) {
265
+ const tNow = performance.now();
266
+ emitProfile(config, {
267
+ t0,
268
+ t1: tNow,
269
+ t2: tNow,
270
+ t3: tNow,
271
+ t4: tNow,
272
+ t5: tNow
273
+ }, {
274
+ skipped: false,
275
+ cacheHit: true,
276
+ container
277
+ });
278
+ }
279
+ clearDirtyFlags(container);
280
+ return {
281
+ buffer: cached,
282
+ width,
283
+ height
284
+ };
285
+ }
286
+ }
287
+ let buffer;
288
+ if (config.renderPool?.isAvailable) {
289
+ buffer = await config.renderPool.render(container.children, width, height, "raw", config.devicePixelRatio);
290
+ t3 = profiling ? performance.now() : 0;
291
+ t1 = t0;
292
+ } else {
293
+ const rootNode = buildTakumiRoot(container);
294
+ t1 = profiling ? performance.now() : 0;
295
+ buffer = await config.renderer.render(rootNode, {
296
+ width,
297
+ height,
298
+ format: "raw",
299
+ devicePixelRatio: config.devicePixelRatio
300
+ });
301
+ t3 = profiling ? performance.now() : 0;
302
+ }
47
303
  if (config.caching) {
48
304
  const hash = fnv1a(buffer);
49
- if (hash === container.lastSvgHash) return null;
305
+ if (hash === container.lastSvgHash) {
306
+ metrics.recordHashDedup();
307
+ if (profiling) {
308
+ const t4 = performance.now();
309
+ emitProfile(config, {
310
+ t0,
311
+ t1,
312
+ t2: t1,
313
+ t3,
314
+ t4,
315
+ t5: t4
316
+ }, {
317
+ skipped: true,
318
+ cacheHit: false,
319
+ container
320
+ });
321
+ }
322
+ clearDirtyFlags(container);
323
+ return null;
324
+ }
50
325
  container.lastSvgHash = hash;
51
326
  }
327
+ const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
328
+ if (config.caching && config.touchstripCacheMaxBytes > 0) {
329
+ if (treeHash === void 0 || cacheKey === void 0) {
330
+ treeHash = computeTreeHash(container);
331
+ cacheKey = computeCacheKey(treeHash, width, height, config.devicePixelRatio, "raw");
332
+ }
333
+ getTouchstripCache(config.touchstripCacheMaxBytes).set(cacheKey, buf, buf.byteLength + 64);
334
+ }
335
+ metrics.recordRender(t3 - t0);
336
+ if (profiling) {
337
+ const tEnd = performance.now();
338
+ emitProfile(config, {
339
+ t0,
340
+ t1,
341
+ t2: t1,
342
+ t3,
343
+ t4: tEnd,
344
+ t5: tEnd
345
+ }, {
346
+ skipped: false,
347
+ cacheHit: false,
348
+ container
349
+ });
350
+ }
351
+ clearDirtyFlags(container);
52
352
  return {
53
- buffer: Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer),
353
+ buffer: buf,
54
354
  width,
55
355
  height
56
356
  };
57
357
  }
58
358
  function cropSlice(raw, fullWidth, column, segmentWidth, segmentHeight) {
59
- const slice = Buffer.alloc(segmentWidth * segmentHeight * 4);
359
+ const pool = getBufferPool();
360
+ const sliceSize = segmentWidth * segmentHeight * 4;
361
+ const slice = pool.acquire(sliceSize);
60
362
  const srcRowBytes = fullWidth * 4;
61
363
  const dstRowBytes = segmentWidth * 4;
62
364
  const xOffset = column * segmentWidth * 4;
@@ -67,8 +369,40 @@ function cropSlice(raw, fullWidth, column, segmentWidth, segmentHeight) {
67
369
  }
68
370
  return slice;
69
371
  }
70
- function sliceToDataUri(raw, fullWidth, fullHeight, column, segmentWidth, segmentHeight) {
71
- return bufferToDataUri(encodePng(segmentWidth, segmentHeight, cropSlice(raw, fullWidth, column, segmentWidth, segmentHeight)), "png");
372
+ async function sliceToDataUriAsync(raw, fullWidth, fullHeight, column, segmentWidth, segmentHeight) {
373
+ const cropped = cropSlice(raw, fullWidth, column, segmentWidth, segmentHeight);
374
+ const png = await encodePngAsync(segmentWidth, segmentHeight, cropped);
375
+ getBufferPool().release(cropped);
376
+ return bufferToDataUri(png, "png");
377
+ }
378
+ async function renderSegmentToDataUri(container, fullWidth, segmentHeight, column, segmentWidth, format, config, prebuiltTakumiChildren) {
379
+ if (container.children.length === 0) return null;
380
+ const children = prebuiltTakumiChildren ?? container.children.map(vnodeToTakumiNode);
381
+ const innerNode = {
382
+ type: "container",
383
+ style: {
384
+ ...ROOT_STYLE,
385
+ width: fullWidth,
386
+ height: segmentHeight,
387
+ marginLeft: -(column * segmentWidth)
388
+ },
389
+ children
390
+ };
391
+ const clipNode = {
392
+ type: "container",
393
+ style: {
394
+ width: segmentWidth,
395
+ height: segmentHeight,
396
+ overflow: "hidden"
397
+ },
398
+ children: [innerNode]
399
+ };
400
+ return bufferToDataUri(await config.renderer.render(clipNode, {
401
+ width: segmentWidth,
402
+ height: segmentHeight,
403
+ format,
404
+ devicePixelRatio: config.devicePixelRatio
405
+ }), format);
72
406
  }
73
407
  //#endregion
74
- export { bufferToDataUri, renderToDataUri, renderToRaw, sliceToDataUri };
408
+ export { bufferToDataUri, buildTakumiChildren, buildTakumiRoot, measureTree, renderSegmentToDataUri, renderToDataUri, renderToRaw, sliceToDataUriAsync };
@@ -1,8 +1,17 @@
1
1
  /**
2
- * Encode raw RGBA pixels into a PNG buffer.
2
+ * Encode raw RGBA pixels into a PNG buffer (synchronous).
3
3
  *
4
4
  * @param width Image width in pixels.
5
5
  * @param height Image height in pixels.
6
6
  * @param rgba Raw RGBA pixel data (width × height × 4 bytes, row-major).
7
7
  */
8
8
  export declare function encodePng(width: number, height: number, rgba: Buffer | Uint8Array): Buffer;
9
+ /**
10
+ * Encode raw RGBA pixels into a PNG buffer (async).
11
+ * Uses libuv thread pool for deflate compression, avoiding main-thread blocking.
12
+ *
13
+ * @param width Image width in pixels.
14
+ * @param height Image height in pixels.
15
+ * @param rgba Raw RGBA pixel data (width × height × 4 bytes, row-major).
16
+ */
17
+ export declare function encodePngAsync(width: number, height: number, rgba: Buffer | Uint8Array): Promise<Buffer>;
@@ -1,4 +1,6 @@
1
- import { deflateSync } from "node:zlib";
1
+ import { getBufferPool } from "./buffer-pool.js";
2
+ import { deflate } from "node:zlib";
3
+ import { promisify } from "node:util";
2
4
  //#region src/render/png.ts
3
5
  var crcTable = new Uint32Array(256);
4
6
  for (let n = 0; n < 256; n++) {
@@ -35,14 +37,8 @@ var PNG_SIGNATURE = Buffer.from([
35
37
  26,
36
38
  10
37
39
  ]);
38
- /**
39
- * Encode raw RGBA pixels into a PNG buffer.
40
- *
41
- * @param width Image width in pixels.
42
- * @param height Image height in pixels.
43
- * @param rgba Raw RGBA pixel data (width × height × 4 bytes, row-major).
44
- */
45
- function encodePng(width, height, rgba) {
40
+ var deflateAsync = promisify(deflate);
41
+ function buildIhdr(width, height) {
46
42
  const ihdr = Buffer.alloc(13);
47
43
  ihdr.writeUInt32BE(width, 0);
48
44
  ihdr.writeUInt32BE(height, 4);
@@ -51,15 +47,22 @@ function encodePng(width, height, rgba) {
51
47
  ihdr[10] = 0;
52
48
  ihdr[11] = 0;
53
49
  ihdr[12] = 0;
50
+ return ihdr;
51
+ }
52
+ function buildFilteredScanlines(width, height, rgba) {
54
53
  const rowBytes = width * 4;
55
- const filtered = Buffer.alloc(height * (1 + rowBytes));
54
+ const filteredSize = height * (1 + rowBytes);
55
+ const filtered = getBufferPool().acquire(filteredSize);
56
56
  for (let y = 0; y < height; y++) {
57
57
  const dstOff = y * (1 + rowBytes);
58
58
  filtered[dstOff] = 0;
59
59
  const srcOff = y * rowBytes;
60
- for (let i = 0; i < rowBytes; i++) filtered[dstOff + 1 + i] = rgba[srcOff + i];
60
+ if (Buffer.isBuffer(rgba)) rgba.copy(filtered, dstOff + 1, srcOff, srcOff + rowBytes);
61
+ else filtered.set(rgba.subarray(srcOff, srcOff + rowBytes), dstOff + 1);
61
62
  }
62
- const compressed = deflateSync(filtered);
63
+ return filtered;
64
+ }
65
+ function assemblePng(ihdr, compressed) {
63
66
  return Buffer.concat([
64
67
  PNG_SIGNATURE,
65
68
  pngChunk("IHDR", ihdr),
@@ -67,5 +70,20 @@ function encodePng(width, height, rgba) {
67
70
  pngChunk("IEND", Buffer.alloc(0))
68
71
  ]);
69
72
  }
73
+ /**
74
+ * Encode raw RGBA pixels into a PNG buffer (async).
75
+ * Uses libuv thread pool for deflate compression, avoiding main-thread blocking.
76
+ *
77
+ * @param width Image width in pixels.
78
+ * @param height Image height in pixels.
79
+ * @param rgba Raw RGBA pixel data (width × height × 4 bytes, row-major).
80
+ */
81
+ async function encodePngAsync(width, height, rgba) {
82
+ const ihdr = buildIhdr(width, height);
83
+ const filtered = buildFilteredScanlines(width, height, rgba);
84
+ const compressed = await deflateAsync(filtered);
85
+ getBufferPool().release(filtered);
86
+ return assemblePng(ihdr, compressed);
87
+ }
70
88
  //#endregion
71
- export { encodePng };
89
+ export { encodePngAsync };
@@ -0,0 +1,26 @@
1
+ import { FontConfig } from '../types';
2
+ import { VNode } from '../reconciler/vnode';
3
+ export declare class RenderPool {
4
+ private worker;
5
+ private ready;
6
+ private failed;
7
+ private nextId;
8
+ private pending;
9
+ private initPromise;
10
+ private fonts;
11
+ constructor(fonts: FontConfig[]);
12
+ /** Start the worker. Call once during plugin initialization. */
13
+ initialize(): Promise<boolean>;
14
+ private doInitialize;
15
+ /** Whether the worker is available for offloaded rendering. */
16
+ get isAvailable(): boolean;
17
+ /**
18
+ * Render VNode children in the worker thread.
19
+ * Returns the raw raster buffer.
20
+ */
21
+ render(vnodes: VNode[], width: number, height: number, format: string, dpr: number): Promise<Buffer>;
22
+ /** Gracefully shut down the worker. */
23
+ shutdown(): Promise<void>;
24
+ private handleResponse;
25
+ private handleWorkerDeath;
26
+ }