@xiboplayer/renderer 0.7.6 → 0.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/renderer",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "description": "RendererLite - Fast, efficient XLF layout rendering engine",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -12,9 +12,9 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "pdfjs-dist": "^4.10.38",
15
- "@xiboplayer/cache": "0.7.6",
16
- "@xiboplayer/utils": "0.7.6",
17
- "@xiboplayer/schedule": "0.7.6"
15
+ "@xiboplayer/cache": "0.7.8",
16
+ "@xiboplayer/schedule": "0.7.8",
17
+ "@xiboplayer/utils": "0.7.8"
18
18
  },
19
19
  "devDependencies": {
20
20
  "jsdom": "^25.0.1",
package/src/index.d.ts CHANGED
@@ -74,6 +74,7 @@ export class RendererLite {
74
74
 
75
75
  updateLayoutDuration(): void;
76
76
  checkLayoutComplete(): void;
77
+ hasActiveLayoutTimer(): boolean;
77
78
 
78
79
  cleanup(): void;
79
80
  }
@@ -167,6 +167,15 @@ export class LayoutPool {
167
167
  * @param {HTMLElement} container
168
168
  */
169
169
  static releaseMediaElements(container) {
170
+ // Defer the actual release by one animation frame to give the GPU compositor
171
+ // time to stop referencing textures from the old layout. Without this delay,
172
+ // the compositor may still hold stale mailbox references when we destroy the
173
+ // video backing, causing SharedImageManager::ProduceSkia "non-existent mailbox"
174
+ // errors (Chrome bug: race in shared_image_manager.cc acknowledged in a TODO).
175
+ requestAnimationFrame(() => LayoutPool._releaseMediaElementsSync(container));
176
+ }
177
+
178
+ static _releaseMediaElementsSync(container) {
170
179
  let videoCount = 0;
171
180
  let hlsCount = 0;
172
181
 
@@ -195,13 +204,43 @@ export class LayoutPool {
195
204
  a.load();
196
205
  });
197
206
 
207
+ // Release media inside iframes (embedded widgets with HLS streams, webcams, etc.)
208
+ // We can't querySelectorAll('video') across iframe boundaries, but we can:
209
+ // 1. Try to access same-origin iframe contentDocument
210
+ // 2. Force-remove the iframe src to stop all network activity
211
+ let iframeCount = 0;
212
+ container.querySelectorAll('iframe').forEach(iframe => {
213
+ try {
214
+ // Same-origin iframes: reach inside and release videos
215
+ const doc = iframe.contentDocument || iframe.contentWindow?.document;
216
+ if (doc) {
217
+ doc.querySelectorAll('video').forEach(v => {
218
+ v.pause();
219
+ v.removeAttribute('src');
220
+ v.load();
221
+ videoCount++;
222
+ });
223
+ doc.querySelectorAll('audio').forEach(a => {
224
+ a.pause();
225
+ a.removeAttribute('src');
226
+ a.load();
227
+ });
228
+ }
229
+ } catch (_) {
230
+ // Cross-origin: can't access contentDocument
231
+ }
232
+ // Force stop all iframe network activity (HLS segments, SSE, WebSocket, etc.)
233
+ iframe.src = 'about:blank';
234
+ iframeCount++;
235
+ });
236
+
198
237
  // Destroy PDF documents and release GPU canvas backing stores
199
238
  container.querySelectorAll('.pdf-widget').forEach(el => {
200
239
  if (el._pdfDestroy) el._pdfDestroy();
201
240
  });
202
241
 
203
- if (videoCount > 0) {
204
- log.info(`Released ${videoCount} video(s)${hlsCount ? ` (${hlsCount} HLS)` : ''}`);
242
+ if (videoCount > 0 || iframeCount > 0) {
243
+ log.info(`Released ${videoCount} video(s)${hlsCount ? ` (${hlsCount} HLS)` : ''}${iframeCount ? `, ${iframeCount} iframe(s)` : ''}`);
205
244
  }
206
245
  }
207
246
 
@@ -1860,27 +1860,36 @@ export class RendererLite {
1860
1860
  }
1861
1861
 
1862
1862
  const videoEl = widgetElement.querySelector('video');
1863
- if (videoEl && widget.options.loop !== '1') videoEl.pause();
1863
+ if (videoEl) {
1864
+ videoEl.pause();
1864
1865
 
1865
- // Stop MediaStream tracks (webcam/mic) to release the device
1866
- if (videoEl?._mediaStream) {
1867
- videoEl._mediaStream.getTracks().forEach(t => t.stop());
1868
- videoEl._mediaStream = null;
1869
- videoEl.srcObject = null;
1870
- }
1866
+ // Stop MediaStream tracks (webcam/mic) to release the device
1867
+ if (videoEl._mediaStream) {
1868
+ videoEl._mediaStream.getTracks().forEach(t => t.stop());
1869
+ videoEl._mediaStream = null;
1870
+ videoEl.srcObject = null;
1871
+ }
1871
1872
 
1872
- // Destroy HLS.js instance to free worker + buffers
1873
- if (videoEl?._hlsInstance) {
1874
- videoEl._hlsInstance.destroy();
1875
- videoEl._hlsInstance = null;
1876
- }
1873
+ // Destroy HLS.js instance to free worker + buffers
1874
+ if (videoEl._hlsInstance) {
1875
+ videoEl._hlsInstance.destroy();
1876
+ videoEl._hlsInstance = null;
1877
+ }
1878
+
1879
+ // Release decoded video buffers (GPU dmabufs) — without this, paused
1880
+ // videos hold texture memory until the layout is evicted from the pool.
1881
+ // removeAttribute('src') + load() forces the browser to drop the decoded
1882
+ // frame, releasing GPU dmabufs immediately instead of at pool eviction.
1883
+ videoEl.removeAttribute('src');
1884
+ videoEl.load();
1877
1885
 
1878
- // Remove event listeners to prevent accumulation across widget cycles
1879
- if (videoEl?._eventCleanup) {
1880
- for (const [event, handler] of videoEl._eventCleanup) {
1881
- videoEl.removeEventListener(event, handler);
1886
+ // Remove event listeners to prevent accumulation across widget cycles
1887
+ if (videoEl._eventCleanup) {
1888
+ for (const [event, handler] of videoEl._eventCleanup) {
1889
+ videoEl.removeEventListener(event, handler);
1890
+ }
1891
+ videoEl._eventCleanup = null;
1882
1892
  }
1883
- videoEl._eventCleanup = null;
1884
1893
  }
1885
1894
 
1886
1895
  const audioEl = widgetElement.querySelector('audio');
@@ -1902,6 +1911,21 @@ export class RendererLite {
1902
1911
  widgetElement._pdfCleanup();
1903
1912
  }
1904
1913
 
1914
+ // Stop embedded widget iframes (HLS live streams, webcams, etc.)
1915
+ // Setting src=about:blank kills all network activity (HLS segment fetches,
1916
+ // WebSocket connections, SSE streams) and releases video decode buffers.
1917
+ const iframes = widgetElement.querySelectorAll('iframe');
1918
+ for (const iframe of iframes) {
1919
+ try {
1920
+ const doc = iframe.contentDocument || iframe.contentWindow?.document;
1921
+ if (doc) {
1922
+ doc.querySelectorAll('video').forEach(v => { v.pause(); v.removeAttribute('src'); v.load(); });
1923
+ doc.querySelectorAll('audio').forEach(a => { a.pause(); a.removeAttribute('src'); a.load(); });
1924
+ }
1925
+ } catch (_) {}
1926
+ iframe.src = 'about:blank';
1927
+ }
1928
+
1905
1929
  return { widget, animPromise };
1906
1930
  }
1907
1931
 
@@ -2863,6 +2887,13 @@ export class RendererLite {
2863
2887
  return true;
2864
2888
  }
2865
2889
 
2890
+ // Don't preload if already in-flight (prevents triple preload when
2891
+ // 75% and 90% timers both fire before the async preload completes)
2892
+ if (this._preloadingLayoutId === layoutId) {
2893
+ this.log.info(`Layout ${layoutId} preload already in-flight, skipping`);
2894
+ return true;
2895
+ }
2896
+
2866
2897
  try {
2867
2898
  this.log.info(`Preloading layout ${layoutId} into pool...`);
2868
2899
 
@@ -3095,6 +3126,50 @@ export class RendererLite {
3095
3126
  }
3096
3127
 
3097
3128
  this.log.info(`Swapped to preloaded layout ${layoutId} (instant transition)`);
3129
+ this._logResourceStats(layoutId);
3130
+ }
3131
+
3132
+ /**
3133
+ * Log resource allocation stats for debugging memory/GPU leaks.
3134
+ * Called after every layout swap to track DOM node accumulation,
3135
+ * video element lifecycle, and pool state.
3136
+ */
3137
+ _logResourceStats(layoutId) {
3138
+ const domNodes = document.querySelectorAll('*').length;
3139
+ const videos = document.querySelectorAll('video').length;
3140
+ const videosSrc = document.querySelectorAll('video[src]').length;
3141
+ const canvases = document.querySelectorAll('canvas').length;
3142
+ const iframes = document.querySelectorAll('iframe').length;
3143
+ const images = document.querySelectorAll('img').length;
3144
+ const poolSize = this.layoutPool ? this.layoutPool.size : 0;
3145
+ const regionCount = this.regions ? this.regions.size : 0;
3146
+ const widgetElements = [...(this.regions?.values() || [])].reduce(
3147
+ (sum, r) => sum + (r.widgetElements?.size || 0), 0
3148
+ );
3149
+ const jsHeap = performance?.memory ? {
3150
+ used: Math.round(performance.memory.usedJSHeapSize / 1048576),
3151
+ total: Math.round(performance.memory.totalJSHeapSize / 1048576),
3152
+ limit: Math.round(performance.memory.jsHeapSizeLimit / 1048576),
3153
+ } : null;
3154
+
3155
+ // Count blob URLs still tracked (potential leak indicator)
3156
+ const blobUrls = this._blobUrls ? [...this._blobUrls.values()].reduce((s, set) => s + set.size, 0) : 0;
3157
+ const blobLayouts = this._blobUrls ? this._blobUrls.size : 0;
3158
+
3159
+ // Preload wrapper divs in DOM (should be 0-1 in normal operation)
3160
+ const preloadWrappers = document.querySelectorAll('.renderer-lite-preload-wrapper').length;
3161
+
3162
+ // Audio overlay elements
3163
+ const audioEls = document.querySelectorAll('audio').length;
3164
+
3165
+ const heapStr = jsHeap ? `heap=${jsHeap.used}/${jsHeap.total}MB (limit ${jsHeap.limit}MB)` : 'heap=N/A';
3166
+ this.log.info(
3167
+ `[Resources] layout=${layoutId} dom=${domNodes} videos=${videos}(src=${videosSrc}) ` +
3168
+ `canvas=${canvases} iframe=${iframes} img=${images} audio=${audioEls} ` +
3169
+ `pool=${poolSize} preloadWrappers=${preloadWrappers} ` +
3170
+ `regions=${regionCount} widgets=${widgetElements} ` +
3171
+ `blobs=${blobUrls}(${blobLayouts} layouts) ${heapStr}`
3172
+ );
3098
3173
  }
3099
3174
 
3100
3175
  /**
@@ -3132,6 +3207,15 @@ export class RendererLite {
3132
3207
  this._swapToPreloadedLayout(layoutId);
3133
3208
  }
3134
3209
 
3210
+ /**
3211
+ * Check if the layout timer is active (running or deferred waiting for metadata).
3212
+ * Used to detect stalled layouts that need timer restart.
3213
+ * @returns {boolean}
3214
+ */
3215
+ hasActiveLayoutTimer() {
3216
+ return this.layoutTimer !== null || this._deferredTimerLayoutId !== null;
3217
+ }
3218
+
3135
3219
  /**
3136
3220
  * Check if all regions have completed one full cycle
3137
3221
  * This is informational only - layout timer is authoritative
@@ -817,12 +817,9 @@ describe('RendererLite', () => {
817
817
  const region = { id: 'r1', widgets: [widget1, widget2], isDrawer: false };
818
818
  renderer.regions = new Map([['r1', region]]);
819
819
 
820
- // One still unprobed
821
- expect(renderer._hasUnprobedVideos()).toBe(true);
822
-
823
- // Probe the second
824
- widget2.duration = 30;
825
- widget2._probed = true;
820
+ // Once any video is probed, updateLayoutDuration has already run with
821
+ // real metadata, so _hasUnprobedVideos() returns false — no further
822
+ // deferral needed.
826
823
  expect(renderer._hasUnprobedVideos()).toBe(false);
827
824
  });
828
825
  });