@solidtv/renderer 1.0.8 → 1.1.1

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 (34) hide show
  1. package/dist/src/core/CoreNode.js +38 -11
  2. package/dist/src/core/CoreNode.js.map +1 -1
  3. package/dist/src/core/CoreTextureManager.js +82 -24
  4. package/dist/src/core/CoreTextureManager.js.map +1 -1
  5. package/dist/src/core/Stage.d.ts +23 -2
  6. package/dist/src/core/Stage.js +44 -8
  7. package/dist/src/core/Stage.js.map +1 -1
  8. package/dist/src/core/TextureError.d.ts +2 -1
  9. package/dist/src/core/TextureError.js +2 -0
  10. package/dist/src/core/TextureError.js.map +1 -1
  11. package/dist/src/core/lib/ImageWorker.d.ts +1 -1
  12. package/dist/src/core/lib/ImageWorker.js +46 -26
  13. package/dist/src/core/lib/ImageWorker.js.map +1 -1
  14. package/dist/src/core/text-rendering/SdfTextRenderer.js +2 -2
  15. package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
  16. package/dist/src/core/textures/ColorTexture.js +3 -1
  17. package/dist/src/core/textures/ColorTexture.js.map +1 -1
  18. package/dist/src/core/textures/ImageTexture.js +24 -16
  19. package/dist/src/core/textures/ImageTexture.js.map +1 -1
  20. package/dist/src/main-api/Renderer.d.ts +20 -3
  21. package/dist/src/main-api/Renderer.js +24 -4
  22. package/dist/src/main-api/Renderer.js.map +1 -1
  23. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  24. package/dist/tsconfig.tsbuildinfo +1 -1
  25. package/package.json +1 -1
  26. package/src/core/CoreNode.ts +36 -11
  27. package/src/core/CoreTextureManager.ts +101 -22
  28. package/src/core/Stage.ts +57 -17
  29. package/src/core/TextureError.ts +2 -0
  30. package/src/core/lib/ImageWorker.ts +50 -27
  31. package/src/core/text-rendering/SdfTextRenderer.ts +3 -2
  32. package/src/core/textures/ColorTexture.ts +3 -1
  33. package/src/core/textures/ImageTexture.ts +29 -20
  34. package/src/main-api/Renderer.ts +32 -3
@@ -281,12 +281,21 @@ export class CoreTextureManager extends EventEmitter {
281
281
  );
282
282
  } else {
283
283
  console.warn(
284
- '[Lightning] Imageworker is 0 or not supported on this browser. Image loading will be slower.',
284
+ '[Lightning] Image worker count is 0 or workers are not supported on this browser. Image loading will be slower.',
285
285
  );
286
286
  }
287
287
 
288
288
  this.initialized = true;
289
289
  this.emit('initialized');
290
+
291
+ // Anything that arrived before initialization completed is now safe to
292
+ // process. Without this, queued textures would sit until the next frame
293
+ // tick happens to call processSome().
294
+ if (this.uploadTextureQueue.size > 0) {
295
+ this.processSome(Infinity).catch((err) => {
296
+ console.error('Failed to drain pre-init texture queue:', err);
297
+ });
298
+ }
290
299
  }
291
300
 
292
301
  /**
@@ -295,6 +304,9 @@ export class CoreTextureManager extends EventEmitter {
295
304
  * @param texture - The texture to upload
296
305
  */
297
306
  enqueueUploadTexture(texture: Texture): void {
307
+ if (texture.state === 'failed' || texture.state === 'freed') {
308
+ return;
309
+ }
298
310
  this.uploadTextureQueue.add(texture);
299
311
  }
300
312
 
@@ -308,7 +320,6 @@ export class CoreTextureManager extends EventEmitter {
308
320
  textureType: Type,
309
321
  props: ExtractProps<TextureMap[Type]>,
310
322
  ): InstanceType<TextureMap[Type]> {
311
- let texture: Texture | undefined;
312
323
  const TextureClass = this.txConstructors[textureType];
313
324
  if (!TextureClass) {
314
325
  throw new TextureError(
@@ -316,20 +327,26 @@ export class CoreTextureManager extends EventEmitter {
316
327
  `Texture type "${textureType}" is not registered`,
317
328
  );
318
329
  }
319
- const resolvedProps = TextureClass.resolveDefaults(props as any);
320
- const cacheKey = TextureClass.makeCacheKey(resolvedProps as any);
321
- if (cacheKey && this.keyCache.has(cacheKey)) {
322
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
323
- texture = this.keyCache.get(cacheKey)!;
324
- } else {
325
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
326
- texture = new TextureClass(this, resolvedProps as any);
327
330
 
328
- if (cacheKey) {
329
- this.initTextureToCache(texture, cacheKey);
331
+ // Cache key is computed from raw props (each Texture's makeCacheKey
332
+ // inlines its own defaults) so we can skip the resolveDefaults
333
+ // allocation on a cache hit.
334
+ const cacheKey = TextureClass.makeCacheKey(props as any);
335
+ if (cacheKey) {
336
+ const cached = this.keyCache.get(cacheKey);
337
+ if (cached) {
338
+ return cached as InstanceType<TextureMap[Type]>;
330
339
  }
331
340
  }
332
341
 
342
+ const resolvedProps = TextureClass.resolveDefaults(props as any);
343
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
344
+ const texture = new TextureClass(this, resolvedProps as any);
345
+
346
+ if (cacheKey) {
347
+ this.initTextureToCache(texture, cacheKey);
348
+ }
349
+
333
350
  return texture as InstanceType<TextureMap[Type]>;
334
351
  }
335
352
 
@@ -381,7 +398,10 @@ export class CoreTextureManager extends EventEmitter {
381
398
  console.error(`Failed to upload texture:`, err);
382
399
  texture.setState(
383
400
  'failed',
384
- new TextureError(TextureErrorCode.TEXTURE_DATA_NULL),
401
+ new TextureError(
402
+ TextureErrorCode.TEXTURE_UPLOAD_FAILED,
403
+ err instanceof Error ? err.message : undefined,
404
+ ),
385
405
  );
386
406
  });
387
407
  return;
@@ -432,7 +452,7 @@ export class CoreTextureManager extends EventEmitter {
432
452
  }
433
453
 
434
454
  const coreContext = texture.loadCtxTexture();
435
- if (coreContext !== null && coreContext.state === 'loaded') {
455
+ if (coreContext.state === 'loaded') {
436
456
  texture.setState('loaded');
437
457
  return;
438
458
  }
@@ -460,24 +480,83 @@ export class CoreTextureManager extends EventEmitter {
460
480
  const platform = this.platform;
461
481
  const startTime = platform.getTimeStamp();
462
482
 
463
- // Process uploads - await each upload to prevent GPU overload
483
+ // Decode / fetch ("getTextureData") is IO-bound and parallelisable across
484
+ // image workers, while GPU upload is effectively serial. Keep a small
485
+ // sliding window of in-flight data fetches so the next decode runs while
486
+ // we're uploading the current one.
487
+ const prefetchLimit = Math.max(1, this.numImageWorkers);
488
+ const pending: Array<{ texture: Texture; data: Promise<unknown> }> = [];
489
+
490
+ // Helper avoids TS narrowing `texture.state` permanently after the first
491
+ // discriminated check — the property is mutable and can transition across
492
+ // awaits, so we need to re-read it freshly each time.
493
+ const isDead = (texture: Texture): boolean =>
494
+ texture.state === 'failed' || texture.state === 'freed';
495
+
496
+ const fillPrefetch = () => {
497
+ while (
498
+ pending.length < prefetchLimit &&
499
+ this.uploadTextureQueue.size > 0
500
+ ) {
501
+ const [texture] = this.uploadTextureQueue;
502
+ if (!texture) break;
503
+ this.uploadTextureQueue.delete(texture);
504
+
505
+ if (isDead(texture)) {
506
+ continue;
507
+ }
508
+
509
+ // Swallow the rejection here so an early failure doesn't surface as
510
+ // an unhandled promise rejection while it sits in the prefetch
511
+ // window; we re-check state after awaiting.
512
+ const data =
513
+ texture.textureData === null
514
+ ? texture.getTextureData().catch((err) => {
515
+ console.error('Failed to fetch texture data:', err);
516
+ return null;
517
+ })
518
+ : Promise.resolve(texture.textureData);
519
+
520
+ pending.push({ texture, data });
521
+ }
522
+ };
523
+
524
+ fillPrefetch();
525
+
464
526
  while (
465
- this.uploadTextureQueue.size > 0 &&
527
+ pending.length > 0 &&
466
528
  platform.getTimeStamp() - startTime < maxProcessingTime
467
529
  ) {
468
- const [texture] = this.uploadTextureQueue;
469
- if (!texture) break;
470
- this.uploadTextureQueue.delete(texture);
530
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
531
+ const next = pending.shift()!;
532
+ // Top up the prefetch window before awaiting — the next decode starts
533
+ // now and overlaps with this upload.
534
+ fillPrefetch();
535
+
536
+ if (isDead(next.texture)) {
537
+ continue;
538
+ }
539
+
471
540
  try {
472
- if (texture.textureData === null) {
473
- await texture.getTextureData();
541
+ await next.data;
542
+ if (isDead(next.texture)) {
543
+ continue;
474
544
  }
475
- await this.uploadTexture(texture);
545
+ await this.uploadTexture(next.texture);
476
546
  } catch (error) {
477
547
  console.error('Failed to upload texture:', error);
478
548
  // Continue with next texture instead of stopping entire queue
479
549
  }
480
550
  }
551
+
552
+ // Time ran out before we got to these. Put them back so we don't lose
553
+ // them — their getTextureData() is already in flight and will populate
554
+ // `textureData` for the next tick.
555
+ for (const { texture } of pending) {
556
+ if (!isDead(texture)) {
557
+ this.uploadTextureQueue.add(texture);
558
+ }
559
+ }
481
560
  }
482
561
 
483
562
  public hasUpdates(): boolean {
package/src/core/Stage.ts CHANGED
@@ -750,15 +750,67 @@ export class Stage {
750
750
  return null;
751
751
  }
752
752
 
753
- createNode(props: Partial<CoreNodeProps>) {
754
- const resolvedProps = this.resolveNodeDefaults(props);
753
+ createNode(props: Partial<CoreNodeProps>, resolved = false) {
754
+ // When `resolved` is true, the caller (typically a framework integration
755
+ // like solid-tv) built `props` via `createNodeProps` and filled it in
756
+ // over time. CoreNode adopts that bag directly — no second resolution
757
+ // pass and no second allocation.
758
+ const resolvedProps = resolved
759
+ ? (props as CoreNodeProps)
760
+ : this.resolveNodeDefaults(props);
755
761
  return new CoreNode(this, resolvedProps);
756
762
  }
757
763
 
758
- createTextNode(props: Partial<CoreTextNodeProps>) {
764
+ createTextNode(props: Partial<CoreTextNodeProps>, resolved = false) {
765
+ const resolvedProps = resolved
766
+ ? (props as CoreTextNodeProps)
767
+ : this.resolveTextNodeDefaults(props);
768
+
769
+ const resolvedTextRenderer = this.resolveTextRenderer(
770
+ resolvedProps,
771
+ resolvedProps.textRendererOverride as keyof TextRenderers | null,
772
+ );
773
+
774
+ if (!resolvedTextRenderer) {
775
+ throw new Error(
776
+ `No compatible text renderer found for ${resolvedProps.fontFamily}`,
777
+ );
778
+ }
779
+
780
+ return new CoreTextNode(this, resolvedProps, resolvedTextRenderer);
781
+ }
782
+
783
+ /**
784
+ * Allocate a fully-resolved CoreNodeProps bag — the same shape and
785
+ * defaults the renderer would otherwise build inside `createNode`.
786
+ *
787
+ * Frameworks (e.g. solid-tv) call this once per node at construction
788
+ * time, fill it in as user props flow in, then pass it back via
789
+ * `createNode(props, true)`. The renderer adopts the bag directly:
790
+ * one allocation instead of two, and a stable hidden class for the
791
+ * node's lifetime.
792
+ */
793
+ createNodeProps(initial?: Partial<CoreNodeProps>): CoreNodeProps {
794
+ return this.resolveNodeDefaults(initial ?? {});
795
+ }
796
+
797
+ /**
798
+ * Allocate a fully-resolved CoreTextNodeProps bag. See
799
+ * {@link createNodeProps}.
800
+ */
801
+ createTextNodeProps(initial?: Partial<CoreTextNodeProps>): CoreTextNodeProps {
802
+ return this.resolveTextNodeDefaults(initial ?? {});
803
+ }
804
+
805
+ /**
806
+ * Apply text-specific defaults on top of a base CoreNodeProps build.
807
+ * Shared by `createTextNode` and `createTextNodeProps`.
808
+ */
809
+ protected resolveTextNodeDefaults(
810
+ props: Partial<CoreTextNodeProps>,
811
+ ): CoreTextNodeProps {
759
812
  const fontSize = props.fontSize || 16;
760
813
  const resolvedProps = this.resolveNodeDefaults(props) as CoreTextNodeProps;
761
-
762
814
  resolvedProps.text = props.text ?? '';
763
815
  resolvedProps.textRendererOverride = props.textRendererOverride ?? null;
764
816
  resolvedProps.fontSize = fontSize;
@@ -776,19 +828,7 @@ export class Stage {
776
828
  resolvedProps.maxWidth = props.maxWidth || 0;
777
829
  resolvedProps.maxHeight = props.maxHeight || 0;
778
830
  resolvedProps.forceLoad = props.forceLoad || false;
779
-
780
- const resolvedTextRenderer = this.resolveTextRenderer(
781
- resolvedProps,
782
- resolvedProps.textRendererOverride as keyof TextRenderers | null,
783
- );
784
-
785
- if (!resolvedTextRenderer) {
786
- throw new Error(
787
- `No compatible text renderer found for ${resolvedProps.fontFamily}`,
788
- );
789
- }
790
-
791
- return new CoreTextNode(this, resolvedProps, resolvedTextRenderer);
831
+ return resolvedProps;
792
832
  }
793
833
 
794
834
  setBoundsMargin(value: number | [number, number, number, number]) {
@@ -2,6 +2,7 @@ export enum TextureErrorCode {
2
2
  MEMORY_THRESHOLD_EXCEEDED = 'MEMORY_THRESHOLD_EXCEEDED',
3
3
  TEXTURE_DATA_NULL = 'TEXTURE_DATA_NULL',
4
4
  TEXTURE_TYPE_NOT_REGISTERED = 'TEXTURE_TYPE_NOT_REGISTERED',
5
+ TEXTURE_UPLOAD_FAILED = 'TEXTURE_UPLOAD_FAILED',
5
6
  }
6
7
 
7
8
  const defaultMessages: Record<TextureErrorCode, string> = {
@@ -9,6 +10,7 @@ const defaultMessages: Record<TextureErrorCode, string> = {
9
10
  [TextureErrorCode.TEXTURE_DATA_NULL]: 'Texture data is null',
10
11
  [TextureErrorCode.TEXTURE_TYPE_NOT_REGISTERED]:
11
12
  'Texture type is not registered',
13
+ [TextureErrorCode.TEXTURE_UPLOAD_FAILED]: 'Texture upload failed',
12
14
  };
13
15
 
14
16
  export class TextureError extends Error {
@@ -67,7 +67,7 @@ function createImageWorker() {
67
67
 
68
68
  var blob = xhr.response;
69
69
  var withAlphaChannel =
70
- premultiplyAlpha !== undefined
70
+ premultiplyAlpha !== undefined && premultiplyAlpha !== null
71
71
  ? premultiplyAlpha
72
72
  : hasAlphaChannel(blob.type);
73
73
 
@@ -83,7 +83,7 @@ function createImageWorker() {
83
83
  imageOrientation: 'none',
84
84
  })
85
85
  .then(function (data) {
86
- resolve({ data, premultiplyAlpha: premultiplyAlpha });
86
+ resolve({ data: data, premultiplyAlpha: withAlphaChannel });
87
87
  })
88
88
  .catch(function (error) {
89
89
  reject(error);
@@ -91,13 +91,13 @@ function createImageWorker() {
91
91
  return;
92
92
  } else if (
93
93
  supportsOptionsCreateImageBitmap === false &&
94
- supportsOptionsCreateImageBitmap === false
94
+ supportsFullCreateImageBitmap === false
95
95
  ) {
96
96
  // Fallback for browsers that do not support createImageBitmap with options
97
97
  // this is supported for Chrome v50 to v52/54 that doesn't support options
98
98
  createImageBitmap(blob)
99
99
  .then(function (data) {
100
- resolve({ data, premultiplyAlpha: premultiplyAlpha });
100
+ resolve({ data: data, premultiplyAlpha: withAlphaChannel });
101
101
  })
102
102
  .catch(function (error) {
103
103
  reject(error);
@@ -109,7 +109,7 @@ function createImageWorker() {
109
109
  imageOrientation: 'none',
110
110
  })
111
111
  .then(function (data) {
112
- resolve({ data, premultiplyAlpha: premultiplyAlpha });
112
+ resolve({ data: data, premultiplyAlpha: withAlphaChannel });
113
113
  })
114
114
  .catch(function (error) {
115
115
  reject(error);
@@ -156,7 +156,6 @@ function createImageWorker() {
156
156
  /* eslint-enable */
157
157
 
158
158
  export class ImageWorkerManager {
159
- imageWorkersEnabled = true;
160
159
  messageManager: Record<number, MessageCallback> = {};
161
160
  workers: Worker[] = [];
162
161
  workerLoad: number[] = [];
@@ -172,6 +171,8 @@ export class ImageWorkerManager {
172
171
  );
173
172
  this.workers.forEach((worker, index) => {
174
173
  worker.onmessage = (event) => this.handleMessage(event, index);
174
+ worker.onerror = (event) => this.handleWorkerError(event, index);
175
+ worker.onmessageerror = (event) => this.handleWorkerError(event, index);
175
176
  });
176
177
  }
177
178
 
@@ -194,6 +195,25 @@ export class ImageWorkerManager {
194
195
  }
195
196
  }
196
197
 
198
+ private handleWorkerError(event: Event | ErrorEvent, workerIndex: number) {
199
+ const message =
200
+ event instanceof ErrorEvent && event.message
201
+ ? event.message
202
+ : 'Image worker encountered an unrecoverable error';
203
+
204
+ // Reject all pending requests; we cannot map a worker-level crash to a
205
+ // specific message id, so fail everything outstanding to avoid hangs.
206
+ for (const id in this.messageManager) {
207
+ const msg = this.messageManager[id];
208
+ if (msg) {
209
+ const [, reject] = msg;
210
+ delete this.messageManager[id];
211
+ reject(new Error(message));
212
+ }
213
+ }
214
+ this.workerLoad[workerIndex] = 0;
215
+ }
216
+
197
217
  private createWorkers(
198
218
  numWorkers = 1,
199
219
  createImageBitmapSupport: CreateImageBitmapSupport,
@@ -224,19 +244,22 @@ export class ImageWorkerManager {
224
244
  const blob: Blob = new Blob([workerCode], {
225
245
  type: 'application/javascript',
226
246
  });
227
- const blobURL: string = (self.URL ? URL : webkitURL).createObjectURL(blob);
247
+ const urlFactory = self.URL ? URL : webkitURL;
248
+ const blobURL: string = urlFactory.createObjectURL(blob);
228
249
  const workers: Worker[] = [];
229
250
  for (let i = 0; i < numWorkers; i++) {
230
251
  workers.push(new Worker(blobURL));
231
252
  this.workerLoad.push(0);
232
253
  }
254
+ // Workers retain the script; the URL itself is no longer needed.
255
+ urlFactory.revokeObjectURL(blobURL);
233
256
  return workers;
234
257
  }
235
258
 
236
259
  private getNextWorkerIndex(): number {
237
260
  if (this.workers.length === 0) return -1;
238
261
 
239
- let minLoad = 99;
262
+ let minLoad = Infinity;
240
263
  let workerIndex = 0;
241
264
 
242
265
  for (let i = 0; i < this.workers.length; i++) {
@@ -264,26 +287,26 @@ export class ImageWorkerManager {
264
287
  ): Promise<TextureData> {
265
288
  return new Promise((resolve, reject) => {
266
289
  try {
267
- if (this.workers) {
268
- const id = this.nextId++;
269
- this.messageManager[id] = [resolve, reject];
270
- const nextWorkerIndex = this.getNextWorkerIndex();
271
-
272
- if (nextWorkerIndex !== -1) {
273
- const worker = this.workers[nextWorkerIndex];
274
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
275
- this.workerLoad[nextWorkerIndex]!++;
276
- worker!.postMessage({
277
- id,
278
- src: src,
279
- premultiplyAlpha,
280
- sx,
281
- sy,
282
- sw,
283
- sh,
284
- });
285
- }
290
+ const nextWorkerIndex = this.getNextWorkerIndex();
291
+ if (nextWorkerIndex === -1) {
292
+ reject(new Error('No image workers available'));
293
+ return;
286
294
  }
295
+
296
+ const id = this.nextId++;
297
+ this.messageManager[id] = [resolve, reject];
298
+ const worker = this.workers[nextWorkerIndex];
299
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
300
+ this.workerLoad[nextWorkerIndex]!++;
301
+ worker!.postMessage({
302
+ id,
303
+ src: src,
304
+ premultiplyAlpha,
305
+ sx,
306
+ sy,
307
+ sw,
308
+ sh,
309
+ });
287
310
  } catch (error) {
288
311
  reject(error);
289
312
  }
@@ -216,13 +216,14 @@ const generateTextLayout = (
216
216
  ): TextLayout => {
217
217
  const fontSize = props.fontSize;
218
218
  const fontFamily = props.fontFamily;
219
- const lineHeight = props.lineHeight;
220
219
  const metrics = SdfFontHandler.getFontMetrics(fontFamily, fontSize);
221
- const verticalAlign = props.verticalAlign;
222
220
 
223
221
  const fontData = fontCache.data;
224
222
  const commonFontData = fontData.common;
225
223
  const designFontSize = fontData.info.size;
224
+ const designLineHeight = commonFontData.lineHeight;
225
+ const lineHeight =
226
+ props.lineHeight || (designLineHeight * fontSize) / designFontSize;
226
227
 
227
228
  const atlasWidth = commonFontData.scaleW;
228
229
  const atlasHeight = commonFontData.scaleH;
@@ -70,7 +70,9 @@ export class ColorTexture extends Texture {
70
70
  }
71
71
 
72
72
  static override makeCacheKey(props: ColorTextureProps): string {
73
- return `ColorTexture,${props.color}`;
73
+ // Mirror the default from resolveDefaults so the key is stable whether
74
+ // or not the caller has run defaults first.
75
+ return `ColorTexture,${props.color || 0xffffffff}`;
74
76
  }
75
77
 
76
78
  static override resolveDefaults(
@@ -147,11 +147,22 @@ export class ImageTexture extends Texture {
147
147
  data: HTMLImageElement | null;
148
148
  premultiplyAlpha: boolean;
149
149
  }>((resolve, reject) => {
150
+ let objectUrl: string | null = null;
151
+
152
+ const cleanup = () => {
153
+ if (objectUrl !== null) {
154
+ URL.revokeObjectURL(objectUrl);
155
+ objectUrl = null;
156
+ }
157
+ };
158
+
150
159
  img.onload = () => {
160
+ cleanup();
151
161
  resolve({ data: img, premultiplyAlpha: hasAlpha });
152
162
  };
153
163
 
154
164
  img.onerror = (err) => {
165
+ cleanup();
155
166
  const errorMessage =
156
167
  err instanceof Error
157
168
  ? err.message
@@ -162,7 +173,8 @@ export class ImageTexture extends Texture {
162
173
  };
163
174
 
164
175
  if (src instanceof Blob) {
165
- img.src = URL.createObjectURL(src);
176
+ objectUrl = URL.createObjectURL(src);
177
+ img.src = objectUrl;
166
178
  } else {
167
179
  img.src = src;
168
180
  }
@@ -218,10 +230,11 @@ export class ImageTexture extends Texture {
218
230
 
219
231
  async loadImage(src: string) {
220
232
  const { premultiplyAlpha, sx, sy, sw, sh } = this.props;
233
+ const isBase64 = isBase64Image(src);
221
234
 
222
235
  if (this.txManager.hasCreateImageBitmap === true) {
223
236
  if (
224
- isBase64Image(src) === false &&
237
+ isBase64 === false &&
225
238
  this.txManager.hasWorker === true &&
226
239
  this.txManager.imageWorkerManager !== null
227
240
  ) {
@@ -235,15 +248,9 @@ export class ImageTexture extends Texture {
235
248
  );
236
249
  }
237
250
 
238
- let blob;
239
-
240
- if (isBase64Image(src) === true) {
241
- blob = dataURIToBlob(src);
242
- } else {
243
- blob = await fetchJson(src, 'blob').then(
244
- (response) => response as Blob,
245
- );
246
- }
251
+ const blob = isBase64
252
+ ? dataURIToBlob(src)
253
+ : ((await fetchJson(src, 'blob')) as Blob);
247
254
 
248
255
  return this.createImageBitmap(blob, premultiplyAlpha, sx, sy, sw, sh);
249
256
  }
@@ -357,16 +364,18 @@ export class ImageTexture extends Texture {
357
364
  return false;
358
365
  }
359
366
 
360
- let cacheKey = `ImageTexture,${key},${props.premultiplyAlpha ?? 'true'},${
361
- props.maxRetryCount
362
- }`;
367
+ // Inline default values so the key is stable whether or not the caller
368
+ // has run them through resolveDefaults first. Must mirror the defaults
369
+ // in resolveDefaults below.
370
+ const premultiplyAlpha = props.premultiplyAlpha ?? true;
371
+ const maxRetryCount = props.maxRetryCount ?? 5;
372
+
373
+ let cacheKey = `ImageTexture,${key},${premultiplyAlpha},${maxRetryCount}`;
363
374
 
364
- if (props.sh !== null && props.sw !== null) {
365
- cacheKey += ',';
366
- cacheKey += props.sx ?? '';
367
- cacheKey += props.sy ?? '';
368
- cacheKey += props.sw || '';
369
- cacheKey += props.sh || '';
375
+ if (props.sh != null && props.sw != null) {
376
+ cacheKey += `,${props.sx ?? ''},${props.sy ?? ''},${props.sw || ''},${
377
+ props.sh || ''
378
+ }`;
370
379
  }
371
380
 
372
381
  return cacheKey;
@@ -680,8 +680,12 @@ export class RendererMain extends EventEmitter {
680
680
  */
681
681
  createNode<ShNode extends CoreShaderNode<any>>(
682
682
  props: Partial<INodeProps<ShNode>>,
683
+ resolved = false,
683
684
  ): INode<ShNode> {
684
- const node = this.stage.createNode(props as Partial<CoreNodeProps>);
685
+ const node = this.stage.createNode(
686
+ props as Partial<CoreNodeProps>,
687
+ resolved,
688
+ );
685
689
 
686
690
  if (ENABLE_INSPECTOR && this.inspector) {
687
691
  return this.inspector.createNode(node) as unknown as INode<ShNode>;
@@ -690,6 +694,20 @@ export class RendererMain extends EventEmitter {
690
694
  return node as unknown as INode<ShNode>;
691
695
  }
692
696
 
697
+ /**
698
+ * Allocate a fully-resolved CoreNodeProps bag — the same shape and
699
+ * defaults the renderer would otherwise build inside `createNode`.
700
+ *
701
+ * Frameworks (e.g. solid-tv) call this once per node at construction
702
+ * time, fill it in as user props flow in, then pass it back via
703
+ * `createNode(props, true)`. The renderer adopts the bag directly:
704
+ * one allocation instead of two, and a stable hidden class for the
705
+ * node's lifetime.
706
+ */
707
+ createNodeProps(initial?: Partial<CoreNodeProps>): CoreNodeProps {
708
+ return this.stage.createNodeProps(initial);
709
+ }
710
+
693
711
  /**
694
712
  * Create a new scene graph text node
695
713
  *
@@ -704,8 +722,11 @@ export class RendererMain extends EventEmitter {
704
722
  * @param props
705
723
  * @returns
706
724
  */
707
- createTextNode(props: Partial<ITextNodeProps>): ITextNode {
708
- const textNode = this.stage.createTextNode(props as CoreTextNodeProps);
725
+ createTextNode(props: Partial<ITextNodeProps>, resolved = false): ITextNode {
726
+ const textNode = this.stage.createTextNode(
727
+ props as CoreTextNodeProps,
728
+ resolved,
729
+ );
709
730
 
710
731
  if (ENABLE_INSPECTOR && this.inspector) {
711
732
  return this.inspector.createTextNode(textNode) as unknown as ITextNode;
@@ -714,6 +735,14 @@ export class RendererMain extends EventEmitter {
714
735
  return textNode as unknown as ITextNode;
715
736
  }
716
737
 
738
+ /**
739
+ * Allocate a fully-resolved CoreTextNodeProps bag. See
740
+ * {@link createNodeProps}.
741
+ */
742
+ createTextNodeProps(initial?: Partial<CoreTextNodeProps>): CoreTextNodeProps {
743
+ return this.stage.createTextNodeProps(initial);
744
+ }
745
+
717
746
  /**
718
747
  * Destroy a node
719
748
  *