@fcannizzaro/streamdeck-react 0.1.9 → 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 +3 -1
  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
@@ -0,0 +1,383 @@
1
+ import { clearDirtyFlags, createVContainer, isContainerDirty } from "../reconciler/vnode.js";
2
+ import { reconciler } from "../reconciler/renderer.js";
3
+ import { computeNativeTouchstripCacheKey, computeTreeHash, fnv1a } from "../render/cache.js";
4
+ import { getTouchstripNativeCache } from "../render/image-cache.js";
5
+ import { metrics } from "../render/metrics.js";
6
+ import { buildTakumiChildren, measureTree, renderSegmentToDataUri, renderToRaw, sliceToDataUriAsync } from "../render/pipeline.js";
7
+ import { EventBus } from "../context/event-bus.js";
8
+ import { DeviceContext, EventBusContext, GlobalSettingsContext } from "../context/providers.js";
9
+ import { partialHasChanges, shallowEqualSettings } from "./settings-equality.js";
10
+ import { TouchStripContext } from "../context/touchstrip-context.js";
11
+ import { createElement } from "react";
12
+ //#region src/roots/touchstrip-root.ts
13
+ var SEGMENT_WIDTH = 200;
14
+ var SEGMENT_HEIGHT = 100;
15
+ var DEFAULT_TOUCHSTRIP_FPS = 60;
16
+ var TouchStripRoot = class TouchStripRoot {
17
+ eventBus = new EventBus();
18
+ container;
19
+ fiberRoot;
20
+ columns = /* @__PURE__ */ new Map();
21
+ globalSettings;
22
+ setGlobalSettingsFn;
23
+ renderDebounceMs;
24
+ renderConfig;
25
+ deviceInfo;
26
+ disposed = false;
27
+ fps;
28
+ pluginWrapper;
29
+ _renderCount = 0;
30
+ _lastRenderReport = 0;
31
+ static RENDER_WARN_THRESHOLD = 65;
32
+ _rendering = false;
33
+ _pendingFlush = false;
34
+ _recentRenders = [];
35
+ _lastInteraction = 0;
36
+ static ANIMATION_WINDOW_MS = 100;
37
+ static ANIMATION_THRESHOLD = 2;
38
+ static INTERACTION_COOLDOWN_MS = 500;
39
+ static IDLE_THRESHOLD_MS = 2e3;
40
+ _lastFlushTime = 0;
41
+ /** Current render priority (lower = higher priority). Used by flush coordinator. */
42
+ get priority() {
43
+ const now = Date.now();
44
+ const cutoff = now - TouchStripRoot.ANIMATION_WINDOW_MS;
45
+ while (this._recentRenders.length > 0 && this._recentRenders[0] < cutoff) this._recentRenders.shift();
46
+ if (this._recentRenders.length > TouchStripRoot.ANIMATION_THRESHOLD) return 0;
47
+ if (now - this._lastInteraction < TouchStripRoot.INTERACTION_COOLDOWN_MS) return 1;
48
+ if (this._lastFlushTime > 0 && now - this._lastFlushTime > TouchStripRoot.IDLE_THRESHOLD_MS) return 3;
49
+ return 2;
50
+ }
51
+ /** Last rendered per-column data URIs. Used by devtools snapshots. */
52
+ lastSegmentUris = /* @__PURE__ */ new Map();
53
+ /**
54
+ * When true, doFlush skips pushing rendered segments to hardware.
55
+ * Set by the devtools bridge while a highlight overlay is active so
56
+ * that rapid re-renders don't overwrite the highlight on the device.
57
+ * The highlight path calls pushSegmentImages() directly (bypassing
58
+ * this flag).
59
+ *
60
+ * Mirrors ReactRoot.suppressHardwarePush — same pattern, different
61
+ * granularity (per-segment instead of single image).
62
+ */
63
+ suppressHardwarePush = false;
64
+ /**
65
+ * Push per-column data URIs to the physical Stream Deck touch strip.
66
+ * Used by the devtools highlight overlay to bypass suppressHardwarePush.
67
+ *
68
+ * @param uris Map of column → data URI to push to hardware.
69
+ * Columns not present in the map are left unchanged.
70
+ */
71
+ async pushSegmentImages(uris) {
72
+ if (this.disposed) return;
73
+ const promises = [];
74
+ for (const [column, uri] of uris) {
75
+ const entry = this.columns.get(column);
76
+ if (entry) promises.push(entry.sdkAction.setFeedback({ canvas: uri }).catch(() => {}));
77
+ }
78
+ await Promise.all(promises);
79
+ }
80
+ /** Exposes the VContainer for devtools inspection. */
81
+ get vcontainer() {
82
+ return this.container;
83
+ }
84
+ /** Sorted column numbers for devtools observer. */
85
+ get columnNumbers() {
86
+ return [...this.columns.keys()].sort((a, b) => a - b);
87
+ }
88
+ /** Column → actionId map for devtools observer. */
89
+ get columnActionMap() {
90
+ const map = /* @__PURE__ */ new Map();
91
+ for (const [col, entry] of this.columns) map.set(col, entry.actionId);
92
+ return map;
93
+ }
94
+ globalSettingsValue;
95
+ touchStripValue;
96
+ constructor(component, deviceInfo, initialGlobalSettings, renderConfig, renderDebounceMs, onGlobalSettingsChange, pluginWrapper, touchStripFPS, flushCoordinator) {
97
+ this.component = component;
98
+ this.flushCoordinator = flushCoordinator;
99
+ this.deviceInfo = deviceInfo;
100
+ this.globalSettings = { ...initialGlobalSettings };
101
+ this.renderConfig = renderConfig;
102
+ this.fps = touchStripFPS ?? DEFAULT_TOUCHSTRIP_FPS;
103
+ this.renderDebounceMs = touchStripFPS != null ? Math.max(1, Math.round(1e3 / touchStripFPS)) : renderDebounceMs;
104
+ this.pluginWrapper = pluginWrapper;
105
+ this.setGlobalSettingsFn = (partial) => {
106
+ const hasChanges = partialHasChanges(this.globalSettings, partial);
107
+ const nextSettings = hasChanges ? {
108
+ ...this.globalSettings,
109
+ ...partial
110
+ } : this.globalSettings;
111
+ onGlobalSettingsChange(nextSettings);
112
+ if (!hasChanges) return;
113
+ this.globalSettings = nextSettings;
114
+ this.globalSettingsValue = {
115
+ settings: this.globalSettings,
116
+ setSettings: this.setGlobalSettingsFn
117
+ };
118
+ this.scheduleRerender();
119
+ };
120
+ this.globalSettingsValue = {
121
+ settings: this.globalSettings,
122
+ setSettings: this.setGlobalSettingsFn
123
+ };
124
+ this.touchStripValue = {
125
+ width: 0,
126
+ height: SEGMENT_HEIGHT,
127
+ columns: [],
128
+ segmentWidth: SEGMENT_WIDTH,
129
+ fps: this.fps
130
+ };
131
+ this.container = createVContainer(() => {
132
+ this.flush();
133
+ });
134
+ this.fiberRoot = reconciler.createContainer(this.container, 0, null, false, null, "", (err) => {
135
+ console.error("[@fcannizzaro/streamdeck-react] TouchStrip uncaught error:", err);
136
+ }, (err) => {
137
+ console.error("[@fcannizzaro/streamdeck-react] TouchStrip caught error:", err);
138
+ }, (err) => {
139
+ console.error("[@fcannizzaro/streamdeck-react] TouchStrip recoverable error:", err);
140
+ }, () => {});
141
+ this.eventBus.ownerId = `touchstrip:${deviceInfo.id}`;
142
+ }
143
+ addColumn(column, actionId, sdkAction) {
144
+ this.columns.set(column, {
145
+ actionId,
146
+ sdkAction
147
+ });
148
+ this.updateTouchStripInfo();
149
+ this.scheduleRerender();
150
+ }
151
+ removeColumn(column) {
152
+ this.columns.delete(column);
153
+ if (this.columns.size === 0) return;
154
+ this.updateTouchStripInfo();
155
+ this.scheduleRerender();
156
+ }
157
+ get isEmpty() {
158
+ return this.columns.size === 0;
159
+ }
160
+ findColumnByActionId(actionId) {
161
+ for (const [column, entry] of this.columns) if (entry.actionId === actionId) return column;
162
+ }
163
+ updateTouchStripInfo() {
164
+ const sortedColumns = [...this.columns.keys()].sort((a, b) => a - b);
165
+ this.touchStripValue = {
166
+ width: (sortedColumns.length > 0 ? sortedColumns[sortedColumns.length - 1] + 1 : 0) * SEGMENT_WIDTH,
167
+ height: SEGMENT_HEIGHT,
168
+ columns: sortedColumns,
169
+ segmentWidth: SEGMENT_WIDTH,
170
+ fps: this.fps
171
+ };
172
+ }
173
+ render() {
174
+ if (this.disposed) return;
175
+ const element = this.buildTree();
176
+ reconciler.updateContainer(element, this.fiberRoot, null, () => {});
177
+ }
178
+ buildTree() {
179
+ let child = createElement(this.component);
180
+ if (this.pluginWrapper) child = createElement(this.pluginWrapper, null, child);
181
+ return createElement(TouchStripContext.Provider, { value: this.touchStripValue }, createElement(DeviceContext.Provider, { value: this.deviceInfo }, createElement(EventBusContext.Provider, { value: this.eventBus }, createElement(GlobalSettingsContext.Provider, { value: this.globalSettingsValue }, child))));
182
+ }
183
+ scheduleRerender() {
184
+ if (this.disposed) return;
185
+ this.render();
186
+ }
187
+ /** Record a user interaction for adaptive debounce. */
188
+ markInteraction() {
189
+ this._lastInteraction = Date.now();
190
+ }
191
+ get effectiveDebounceMs() {
192
+ const now = Date.now();
193
+ const cutoff = now - TouchStripRoot.ANIMATION_WINDOW_MS;
194
+ while (this._recentRenders.length > 0 && this._recentRenders[0] < cutoff) this._recentRenders.shift();
195
+ if (this._recentRenders.length > TouchStripRoot.ANIMATION_THRESHOLD) return 0;
196
+ if (now - this._lastInteraction < TouchStripRoot.INTERACTION_COOLDOWN_MS) return Math.min(this.renderDebounceMs, 16);
197
+ return this.renderDebounceMs;
198
+ }
199
+ async flush() {
200
+ if (this.disposed) return;
201
+ if (this._rendering) {
202
+ this._pendingFlush = true;
203
+ return;
204
+ }
205
+ this._recentRenders.push(Date.now());
206
+ const debounce = this.effectiveDebounceMs;
207
+ if (debounce > 0 && this.container.renderTimer !== null) clearTimeout(this.container.renderTimer);
208
+ if (debounce > 0) this.container.renderTimer = setTimeout(() => {
209
+ this.container.renderTimer = null;
210
+ this.submitFlush();
211
+ }, debounce);
212
+ else this.submitFlush();
213
+ }
214
+ /**
215
+ * Submit this root for flushing. Routes through the coordinator
216
+ * (priority-ordered) when available, or flushes directly.
217
+ */
218
+ submitFlush() {
219
+ if (this.disposed) return;
220
+ if (this.flushCoordinator) this.flushCoordinator.requestFlush(this);
221
+ else this.doFlush();
222
+ }
223
+ /**
224
+ * Execute the flush. Called by FlushCoordinator in priority order,
225
+ * or directly when no coordinator is present.
226
+ */
227
+ async executeFlush() {
228
+ await this.doFlush();
229
+ }
230
+ async doFlush() {
231
+ if (this.disposed || this.columns.size === 0) return;
232
+ this._rendering = true;
233
+ this._pendingFlush = false;
234
+ this._lastFlushTime = Date.now();
235
+ if (this.renderConfig.debug) {
236
+ this._renderCount++;
237
+ const now = Date.now();
238
+ if (now - this._lastRenderReport > 1e3) {
239
+ if (this._renderCount > TouchStripRoot.RENDER_WARN_THRESHOLD) console.warn(`[@fcannizzaro/streamdeck-react] TouchStrip rendered ${this._renderCount}x in 1s (FPS target: ${this.fps})`);
240
+ this._renderCount = 0;
241
+ this._lastRenderReport = now;
242
+ }
243
+ }
244
+ try {
245
+ const width = this.touchStripValue.width;
246
+ if (width === 0) return;
247
+ if (this.renderConfig.touchstripImageFormat !== "png") {
248
+ metrics.recordFlush();
249
+ if (this.renderConfig.caching && !isContainerDirty(this.container)) {
250
+ metrics.recordDirtySkip();
251
+ return;
252
+ }
253
+ const profiling = this.renderConfig.onProfile != null;
254
+ const t0 = profiling ? performance.now() : 0;
255
+ const sortedColumns = [...this.columns.keys()].sort((a, b) => a - b);
256
+ let treeHash;
257
+ let cacheKey;
258
+ let cacheHit = false;
259
+ if (this.renderConfig.caching && this.renderConfig.touchstripCacheMaxBytes > 0) {
260
+ treeHash = computeTreeHash(this.container);
261
+ cacheKey = computeNativeTouchstripCacheKey(treeHash, width, SEGMENT_HEIGHT, this.renderConfig.devicePixelRatio, this.renderConfig.touchstripImageFormat, sortedColumns);
262
+ const cache = getTouchstripNativeCache(this.renderConfig.touchstripCacheMaxBytes);
263
+ const cached = cache.get(cacheKey);
264
+ if (cached !== void 0) {
265
+ metrics.recordCacheHit();
266
+ cacheHit = true;
267
+ for (const [column, uri] of cached) {
268
+ this.lastSegmentUris.set(column, uri);
269
+ if (!this.suppressHardwarePush) this.columns.get(column)?.sdkAction.setFeedback({ canvas: uri }).catch(() => {});
270
+ }
271
+ if (profiling) {
272
+ const tNow = performance.now();
273
+ const stats = measureTree(this.container.children);
274
+ this.renderConfig.onProfile({
275
+ vnodeToElementMs: 0,
276
+ fromJsxMs: 0,
277
+ takumiRenderMs: tNow - t0,
278
+ hashMs: 0,
279
+ base64Ms: 0,
280
+ totalMs: tNow - t0,
281
+ skipped: false,
282
+ cacheHit: true,
283
+ treeDepth: stats.depth,
284
+ nodeCount: stats.count,
285
+ cacheStats: cache.stats
286
+ });
287
+ }
288
+ clearDirtyFlags(this.container);
289
+ }
290
+ }
291
+ if (!cacheHit) {
292
+ const takumiChildren = buildTakumiChildren(this.container);
293
+ const segmentResults = [];
294
+ const renderPromises = [...this.columns.entries()].map(async ([column]) => {
295
+ const sliceUri = await renderSegmentToDataUri(this.container, width, SEGMENT_HEIGHT, column, SEGMENT_WIDTH, this.renderConfig.touchstripImageFormat, this.renderConfig, takumiChildren);
296
+ if (sliceUri != null) {
297
+ segmentResults.push([column, sliceUri]);
298
+ this.lastSegmentUris.set(column, sliceUri);
299
+ }
300
+ });
301
+ await Promise.all(renderPromises);
302
+ segmentResults.sort((a, b) => a[0] - b[0]);
303
+ let skipped = false;
304
+ if (this.renderConfig.caching) {
305
+ const uriHash = fnv1a(segmentResults.map(([col, uri]) => `${col}:${uri}`).join("\0"));
306
+ if (uriHash === this.container.lastSvgHash) {
307
+ metrics.recordHashDedup();
308
+ skipped = true;
309
+ } else this.container.lastSvgHash = uriHash;
310
+ }
311
+ if (!skipped && !this.suppressHardwarePush) for (const [column, uri] of segmentResults) this.columns.get(column)?.sdkAction.setFeedback({ canvas: uri }).catch(() => {});
312
+ const elapsedMs = (profiling ? performance.now() : 0) - t0;
313
+ metrics.recordRender(elapsedMs);
314
+ if (this.renderConfig.caching && this.renderConfig.touchstripCacheMaxBytes > 0) {
315
+ if (treeHash === void 0 || cacheKey === void 0) {
316
+ treeHash = computeTreeHash(this.container);
317
+ cacheKey = computeNativeTouchstripCacheKey(treeHash, width, SEGMENT_HEIGHT, this.renderConfig.devicePixelRatio, this.renderConfig.touchstripImageFormat, sortedColumns);
318
+ }
319
+ const cache = getTouchstripNativeCache(this.renderConfig.touchstripCacheMaxBytes);
320
+ let byteSize = 64;
321
+ for (const [, uri] of segmentResults) byteSize += uri.length * 2 + 16;
322
+ cache.set(cacheKey, segmentResults, byteSize);
323
+ }
324
+ if (profiling) {
325
+ const stats = measureTree(this.container.children);
326
+ const nativeCache = this.renderConfig.touchstripCacheMaxBytes > 0 ? getTouchstripNativeCache(this.renderConfig.touchstripCacheMaxBytes) : null;
327
+ this.renderConfig.onProfile({
328
+ vnodeToElementMs: 0,
329
+ fromJsxMs: 0,
330
+ takumiRenderMs: elapsedMs,
331
+ hashMs: 0,
332
+ base64Ms: 0,
333
+ totalMs: elapsedMs,
334
+ skipped,
335
+ cacheHit: false,
336
+ treeDepth: stats.depth,
337
+ nodeCount: stats.count,
338
+ cacheStats: nativeCache?.stats ?? null
339
+ });
340
+ }
341
+ clearDirtyFlags(this.container);
342
+ }
343
+ } else {
344
+ const result = await renderToRaw(this.container, width, SEGMENT_HEIGHT, this.renderConfig);
345
+ if (result === null || this.disposed) return;
346
+ const feedbackPromises = [...this.columns.entries()].map(async ([column, entry]) => {
347
+ const sliceUri = await sliceToDataUriAsync(result.buffer, result.width, result.height, column, SEGMENT_WIDTH, SEGMENT_HEIGHT);
348
+ this.lastSegmentUris.set(column, sliceUri);
349
+ if (!this.suppressHardwarePush) entry.sdkAction.setFeedback({ canvas: sliceUri }).catch(() => {});
350
+ });
351
+ await Promise.all(feedbackPromises);
352
+ }
353
+ this.renderConfig.onRender?.(this.container, "");
354
+ } catch (err) {
355
+ console.error("[@fcannizzaro/streamdeck-react] TouchStrip render error:", err);
356
+ } finally {
357
+ this._rendering = false;
358
+ if (this._pendingFlush && !this.disposed) {
359
+ this._pendingFlush = false;
360
+ await this.doFlush();
361
+ }
362
+ }
363
+ }
364
+ updateGlobalSettings(settings) {
365
+ if (shallowEqualSettings(this.globalSettings, settings)) return;
366
+ this.globalSettings = { ...settings };
367
+ this.globalSettingsValue = {
368
+ settings: this.globalSettings,
369
+ setSettings: this.setGlobalSettingsFn
370
+ };
371
+ this.scheduleRerender();
372
+ }
373
+ unmount() {
374
+ this.disposed = true;
375
+ if (this.container.renderTimer !== null) clearTimeout(this.container.renderTimer);
376
+ this.eventBus.emit("willDisappear", void 0);
377
+ reconciler.updateContainer(null, this.fiberRoot, null, () => {});
378
+ this.eventBus.removeAllListeners();
379
+ this.columns.clear();
380
+ }
381
+ };
382
+ //#endregion
383
+ export { TouchStripRoot };
package/dist/types.d.ts CHANGED
@@ -72,31 +72,77 @@ export interface PluginConfig {
72
72
  onActionError?: (uuid: string, actionId: string, error: Error) => void;
73
73
  /** Enable the devtools WebSocket server. Browser devtools UI discovers it via port scanning. @default false */
74
74
  devtools?: boolean;
75
+ /** Enable performance diagnostics (render counters, duplicate detection, depth warnings). Defaults to `process.env.NODE_ENV !== 'production'`. */
76
+ debug?: boolean;
77
+ /** Maximum image cache size in bytes for key/dial renders. Set to 0 to disable. @default 16777216 (16 MB) */
78
+ imageCacheMaxBytes?: number;
79
+ /** Maximum touchstrip raw buffer cache size in bytes. Set to 0 to disable. @default 8388608 (8 MB) */
80
+ touchstripCacheMaxBytes?: number;
81
+ /** Offload Takumi rendering to a worker thread. Set to false to disable. @default true */
82
+ useWorker?: boolean;
83
+ /** Image format for touchstrip segment encoding. `"webp"` renders each segment directly via Takumi (faster, no custom PNG encoder). `"png"` uses the raw+crop+deflate path. @default "webp" */
84
+ touchstripImageFormat?: "webp" | "png";
75
85
  }
76
86
  export interface Plugin {
77
87
  connect(): Promise<void>;
78
88
  }
89
+ export interface ManifestActions {
90
+ }
91
+ /** Action UUID — a union of manifest UUIDs when available, plain `string` otherwise. */
92
+ export type ActionUUID = [keyof ManifestActions] extends [never] ? string : Extract<keyof ManifestActions, string>;
93
+ type HasController<UUID extends string, C extends string> = UUID extends keyof ManifestActions ? ManifestActions[UUID] extends {
94
+ controllers: readonly (infer Item)[];
95
+ } ? C extends Item ? true : false : false : false;
96
+ type KeySurface<UUID extends string> = HasController<UUID, "Keypad"> extends true ? {
97
+ key: ComponentType;
98
+ } : {
99
+ key?: ComponentType;
100
+ };
101
+ type EncoderSurface<UUID extends string> = HasController<UUID, "Encoder"> extends true ? {
102
+ dial: ComponentType;
103
+ touchStrip?: ComponentType;
104
+ } | {
105
+ dial?: ComponentType;
106
+ touchStrip: ComponentType;
107
+ } : {
108
+ dial?: ComponentType;
109
+ touchStrip?: ComponentType;
110
+ };
79
111
  export interface ActionConfig<S extends JsonObject = JsonObject> {
80
112
  uuid: string;
81
113
  key?: ComponentType;
82
114
  dial?: ComponentType;
83
- /** Full-strip touchbar component. When set, replaces per-encoder `dial` display with a single shared React tree that spans the entire touch strip. */
84
- touchBar?: ComponentType;
85
- /** Target frame rate for the touchbar animation loop and render pipeline. Controls both `useTick` cadence (via `useTouchBar().fps`) and the render debounce. @default 60 */
86
- touchBarFPS?: number;
115
+ /** Full-strip touchstrip component. When set, replaces per-encoder `dial` display with a single shared React tree that spans the entire touch strip. */
116
+ touchStrip?: ComponentType;
117
+ /** Target frame rate for the touchstrip animation loop and render pipeline. Controls both `useTick` cadence (via `useTouchStrip().fps`) and the render debounce. @default 60 */
118
+ touchStripFPS?: number;
87
119
  /** Encoder feedback layout. Defaults to a full-width `pixmap` canvas layout. Custom layouts should include a `pixmap` item keyed as `canvas`. */
88
120
  dialLayout?: EncoderLayout;
89
121
  wrapper?: WrapperComponent;
90
122
  defaultSettings?: Partial<S>;
91
123
  }
124
+ /** Resolved action config shape. When `ManifestActions` is populated (via `streamdeck-env.d.ts`), this becomes a mapped type that iterates over every UUID in the manifest and produces a discriminated union. Each member intersects `KeySurface<UUID>` and `EncoderSurface<UUID>` to enforce controller-specific requirements. When `ManifestActions` is empty, it falls back to the permissive `ActionConfig<S>`. */
125
+ export type ActionConfigInput<S extends JsonObject = JsonObject> = [keyof ManifestActions] extends [
126
+ never
127
+ ] ? ActionConfig<S> : {
128
+ [UUID in ActionUUID]: {
129
+ uuid: UUID;
130
+ /** Target frame rate for the touchstrip animation loop and render pipeline. Controls both `useTick` cadence (via `useTouchStrip().fps`) and the render debounce. @default 60 */
131
+ touchStripFPS?: number;
132
+ /** Encoder feedback layout. Defaults to a full-width `pixmap` canvas layout. Custom layouts should include a `pixmap` item keyed as `canvas`. */
133
+ dialLayout?: EncoderLayout;
134
+ wrapper?: WrapperComponent;
135
+ defaultSettings?: Partial<S>;
136
+ } & KeySurface<UUID> & EncoderSurface<UUID>;
137
+ }[ActionUUID];
92
138
  export interface ActionDefinition<S extends JsonObject = JsonObject> {
93
139
  uuid: string;
94
140
  key?: ComponentType;
95
141
  dial?: ComponentType;
96
- /** Full-strip touchbar component. When set, replaces per-encoder `dial` display with a single shared React tree that spans the entire touch strip. */
97
- touchBar?: ComponentType;
98
- /** Target frame rate for the touchbar animation loop and render pipeline. @default 60 */
99
- touchBarFPS?: number;
142
+ /** Full-strip touchstrip component. When set, replaces per-encoder `dial` display with a single shared React tree that spans the entire touch strip. */
143
+ touchStrip?: ComponentType;
144
+ /** Target frame rate for the touchstrip animation loop and render pipeline. @default 60 */
145
+ touchStripFPS?: number;
100
146
  /** Encoder feedback layout. Defaults to a full-width `pixmap` canvas layout. Custom layouts should include a `pixmap` item keyed as `canvas`. */
101
147
  dialLayout?: EncoderLayout;
102
148
  wrapper?: WrapperComponent;
@@ -161,7 +207,7 @@ export interface DialHints {
161
207
  touch?: string;
162
208
  longTouch?: string;
163
209
  }
164
- export interface TouchBarInfo {
210
+ export interface TouchStripInfo {
165
211
  /** Full render width in pixels (e.g., 800 for 4 encoders). */
166
212
  width: number;
167
213
  /** Strip height in pixels (always 100). */
@@ -173,19 +219,19 @@ export interface TouchBarInfo {
173
219
  /** Target frame rate for the animation loop. Pass to `useTick` for matched cadence. */
174
220
  fps: number;
175
221
  }
176
- export interface TouchBarTapPayload {
222
+ export interface TouchStripTapPayload {
177
223
  /** Absolute tap position across the full strip width. */
178
224
  tapPos: [x: number, y: number];
179
225
  hold: boolean;
180
226
  /** The encoder column that was touched. */
181
227
  column: number;
182
228
  }
183
- export interface TouchBarDialRotatePayload {
229
+ export interface TouchStripDialRotatePayload {
184
230
  column: number;
185
231
  ticks: number;
186
232
  pressed: boolean;
187
233
  }
188
- export interface TouchBarDialPressPayload {
234
+ export interface TouchStripDialPressPayload {
189
235
  column: number;
190
236
  }
191
237
  export interface EventMap {
@@ -205,9 +251,9 @@ export interface EventMap {
205
251
  title: string;
206
252
  settings: JsonObject;
207
253
  };
208
- touchBarTap: TouchBarTapPayload;
209
- touchBarDialRotate: TouchBarDialRotatePayload;
210
- touchBarDialDown: TouchBarDialPressPayload;
211
- touchBarDialUp: TouchBarDialPressPayload;
254
+ touchStripTap: TouchStripTapPayload;
255
+ touchStripDialRotate: TouchStripDialRotatePayload;
256
+ touchStripDialDown: TouchStripDialPressPayload;
257
+ touchStripDialUp: TouchStripDialPressPayload;
212
258
  }
213
259
  export {};
package/dist/vite.d.ts CHANGED
@@ -10,17 +10,31 @@ export interface StreamDeckReactOptions extends StreamDeckTargetOptions {
10
10
  * each successful build.
11
11
  */
12
12
  uuid?: string;
13
+ /**
14
+ * Path to the plugin `manifest.json`. When omitted, the plugin
15
+ * auto-detects by scanning the project root for a `*.sdPlugin/manifest.json`.
16
+ *
17
+ * Set to `false` to disable manifest type generation entirely.
18
+ */
19
+ manifest?: string | false;
13
20
  }
14
21
  /**
15
22
  * Vite plugin for Stream Deck React projects.
16
23
  *
17
- * - Inlines font files (`.ttf`, `.otf`, `.woff`, `.woff2`) imported by the
18
- * project into the bundle as `Buffer` instances so no runtime filesystem
19
- * access is needed.
20
- * - Copies platform-specific `@takumi-rs/core` native bindings (`.node` files)
21
- * into the bundle output directory.
22
- * - Strips devtools code in production builds (non-watch mode).
23
- * - Optionally restarts the Stream Deck plugin after each build when
24
- * {@link StreamDeckReactOptions.uuid} is provided.
24
+ * Responsibilities mapped to Vite lifecycle hooks:
25
+ *
26
+ * configResolved → detect dev/production mode, set strip flags
27
+ * buildStart → generate streamdeck-env.d.ts from manifest.json
28
+ * resolveId → redirect devtools imports (production) + font imports
29
+ * load → return noop devtools stub + inline font as base64 Buffer
30
+ * writeBundle → copy native .node bindings to output directory
31
+ * closeBundle → restart Stream Deck plugin (optional, via CLI)
32
+ *
33
+ * Font inlining:
34
+ * `.ttf`, `.otf`, `.woff`, `.woff2` imports are resolved to absolute
35
+ * paths and loaded as synthetic ES modules:
36
+ * `export default Buffer.from("<base64>", "base64");`
37
+ * This eliminates runtime filesystem access in the sandboxed
38
+ * Stream Deck Node.js environment.
25
39
  */
26
40
  export declare function streamDeckReact(options?: StreamDeckReactOptions): Plugin;
package/dist/vite.js CHANGED
@@ -1,19 +1,27 @@
1
1
  import { NOOP_DEVTOOLS_CODE, NOOP_DEVTOOLS_ID, copyNativeBindings, isLibraryDevtoolsImport, shouldStripDevtools } from "./bundler-shared.js";
2
2
  import { loadFont, resolveFontId } from "./font-inline.js";
3
+ import { generateManifestTypes } from "./manifest-codegen.js";
3
4
  import { resolve } from "node:path";
4
5
  import { exec } from "node:child_process";
5
6
  //#region src/vite.ts
6
7
  /**
7
8
  * Vite plugin for Stream Deck React projects.
8
9
  *
9
- * - Inlines font files (`.ttf`, `.otf`, `.woff`, `.woff2`) imported by the
10
- * project into the bundle as `Buffer` instances so no runtime filesystem
11
- * access is needed.
12
- * - Copies platform-specific `@takumi-rs/core` native bindings (`.node` files)
13
- * into the bundle output directory.
14
- * - Strips devtools code in production builds (non-watch mode).
15
- * - Optionally restarts the Stream Deck plugin after each build when
16
- * {@link StreamDeckReactOptions.uuid} is provided.
10
+ * Responsibilities mapped to Vite lifecycle hooks:
11
+ *
12
+ * configResolved → detect dev/production mode, set strip flags
13
+ * buildStart → generate streamdeck-env.d.ts from manifest.json
14
+ * resolveId → redirect devtools imports (production) + font imports
15
+ * load → return noop devtools stub + inline font as base64 Buffer
16
+ * writeBundle → copy native .node bindings to output directory
17
+ * closeBundle → restart Stream Deck plugin (optional, via CLI)
18
+ *
19
+ * Font inlining:
20
+ * `.ttf`, `.otf`, `.woff`, `.woff2` imports are resolved to absolute
21
+ * paths and loaded as synthetic ES modules:
22
+ * `export default Buffer.from("<base64>", "base64");`
23
+ * This eliminates runtime filesystem access in the sandboxed
24
+ * Stream Deck Node.js environment.
17
25
  */
18
26
  function streamDeckReact(options = {}) {
19
27
  let resolvedConfig;
@@ -29,6 +37,14 @@ function streamDeckReact(options = {}) {
29
37
  isDevelopment = isWatch || process.env.NODE_ENV === "development";
30
38
  stripDevtools = shouldStripDevtools(isWatch);
31
39
  },
40
+ buildStart() {
41
+ const warn = (msg) => resolvedConfig.logger.warn(msg);
42
+ const result = generateManifestTypes(resolvedConfig.root, options.manifest, warn);
43
+ if (result) {
44
+ this.addWatchFile(result.manifestPath);
45
+ if (result.written) resolvedConfig.logger.info("[@fcannizzaro/streamdeck-react] Generated src/streamdeck-env.d.ts");
46
+ }
47
+ },
32
48
  resolveId(source, importer) {
33
49
  if (stripDevtools && isLibraryDevtoolsImport(source, importer)) return NOOP_DEVTOOLS_ID;
34
50
  return resolveFontId(source, importer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fcannizzaro/streamdeck-react",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Build Stream Deck plugins with React — render components directly to keys, dials, and touch screens",
5
5
  "keywords": [
6
6
  "elgato",
@@ -13,7 +13,7 @@
13
13
  "bugs": {
14
14
  "url": "https://github.com/fcannizzaro/streamdeck-react/issues"
15
15
  },
16
- "license": "MIT",
16
+ "license": "Apache-2.0",
17
17
  "author": {
18
18
  "name": "Francesco Saverio Cannizzaro",
19
19
  "url": "https://github.com/fcannizzaro"
@@ -56,14 +56,15 @@
56
56
  },
57
57
  "scripts": {
58
58
  "build": "vite build",
59
- "check-types": "tsc --noEmit",
59
+ "typecheck": "tsc --noEmit",
60
60
  "test": "bun test ./src"
61
61
  },
62
62
  "dependencies": {
63
63
  "@elgato/streamdeck": "^2.0.2",
64
64
  "@takumi-rs/core": "^0.71.7",
65
65
  "@takumi-rs/helpers": "^0.71.7",
66
- "react-reconciler": "^0.33.0"
66
+ "react-reconciler": "^0.33.0",
67
+ "xxhash-wasm": "^1.1.0"
67
68
  },
68
69
  "devDependencies": {
69
70
  "@happy-dom/global-registrator": "^20.8.3",
@@ -1,2 +0,0 @@
1
- import { TouchBarInfo } from '../types';
2
- export declare const TouchBarContext: import('react').Context<TouchBarInfo>;
@@ -1,5 +0,0 @@
1
- import { createContext } from "react";
2
- //#region src/context/touchbar-context.ts
3
- var TouchBarContext = createContext(null);
4
- //#endregion
5
- export { TouchBarContext };
@@ -1,6 +0,0 @@
1
- import { TouchBarInfo, TouchBarTapPayload, TouchBarDialRotatePayload, TouchBarDialPressPayload } from '../types';
2
- export declare function useTouchBar(): TouchBarInfo;
3
- export declare function useTouchBarTap(callback: (payload: TouchBarTapPayload) => void): void;
4
- export declare function useTouchBarDialRotate(callback: (payload: TouchBarDialRotatePayload) => void): void;
5
- export declare function useTouchBarDialDown(callback: (payload: TouchBarDialPressPayload) => void): void;
6
- export declare function useTouchBarDialUp(callback: (payload: TouchBarDialPressPayload) => void): void;