@xiboplayer/renderer 0.7.5 → 0.7.7

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 (2) hide show
  1. package/package.json +4 -4
  2. package/src/renderer-lite.js +101 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/renderer",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
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.5",
16
- "@xiboplayer/schedule": "0.7.5",
17
- "@xiboplayer/utils": "0.7.5"
15
+ "@xiboplayer/schedule": "0.7.7",
16
+ "@xiboplayer/utils": "0.7.7",
17
+ "@xiboplayer/cache": "0.7.7"
18
18
  },
19
19
  "devDependencies": {
20
20
  "jsdom": "^25.0.1",
@@ -714,6 +714,7 @@ export class RendererLite {
714
714
  if (maxRegionDuration > 0 && maxRegionDuration !== this.currentLayout.duration) {
715
715
  const oldDuration = this.currentLayout.duration;
716
716
  this.currentLayout.duration = maxRegionDuration;
717
+ this.currentLayout._durationFromMetadata = true;
717
718
 
718
719
  this.log.info(`Layout duration updated: ${oldDuration}s → ${maxRegionDuration}s (based on video metadata)`);
719
720
  const final_ = !this._hasUnprobedVideos();
@@ -1639,7 +1640,9 @@ export class RendererLite {
1639
1640
  // Dynamic layouts (useDuration=0 videos): defer timer until video metadata
1640
1641
  // provides real durations. Safety timeout ensures corrupt/missing videos
1641
1642
  // don't freeze the display forever.
1642
- if (layout.isDynamic && this._hasUnprobedVideos()) {
1643
+ // Skip deferral if updateLayoutDuration() already set the duration from
1644
+ // video metadata (e.g. during preload or a previous play of this layout).
1645
+ if (layout.isDynamic && !layout._durationFromMetadata && this._hasUnprobedVideos()) {
1643
1646
  this._deferredTimerLayoutId = layoutId;
1644
1647
  this._layoutTimerStartedAt = Date.now();
1645
1648
  this.log.info(`Layout ${layoutId} has unprobed videos — deferring timer until metadata loads`);
@@ -1662,14 +1665,30 @@ export class RendererLite {
1662
1665
  }
1663
1666
 
1664
1667
  /**
1665
- * Check if any video widget has useDuration=0 ("play to end") and hasn't
1666
- * been corrected by video metadata yet. The XLF always provides a non-zero
1667
- * duration attribute (typically 60s), so we check the _probed flag instead.
1668
+ * Check if any region's longest-running video widget (useDuration=0) hasn't
1669
+ * been probed yet. Used to decide whether to defer the layout timer.
1670
+ *
1671
+ * Only checks widgets that have had <video> elements created (during preload
1672
+ * or show). Widgets that haven't been displayed yet can never be probed —
1673
+ * checking them would always force a 30s timeout on layouts with multiple
1674
+ * video widgets per region.
1675
+ *
1676
+ * Returns false if the layout duration has already been updated from video
1677
+ * metadata (meaning at least one probe succeeded and updateLayoutDuration
1678
+ * computed a real duration), since the timer can start with that value.
1668
1679
  */
1669
1680
  _hasUnprobedVideos() {
1681
+ // If any video was probed and updateLayoutDuration ran, the layout duration
1682
+ // is already based on real metadata — no need to defer further.
1683
+ for (const [, region] of this.regions) {
1684
+ for (const widget of region.widgets) {
1685
+ if (widget.type === 'video' && widget.useDuration === 0 && widget._probed) return false;
1686
+ }
1687
+ }
1688
+ // No videos probed at all — check if there are any that need probing
1670
1689
  for (const [, region] of this.regions) {
1671
1690
  for (const widget of region.widgets) {
1672
- if (widget.useDuration === 0 && !widget._probed) return true;
1691
+ if (widget.type === 'video' && widget.useDuration === 0) return true;
1673
1692
  }
1674
1693
  }
1675
1694
  return false;
@@ -1841,27 +1860,36 @@ export class RendererLite {
1841
1860
  }
1842
1861
 
1843
1862
  const videoEl = widgetElement.querySelector('video');
1844
- if (videoEl && widget.options.loop !== '1') videoEl.pause();
1863
+ if (videoEl) {
1864
+ videoEl.pause();
1845
1865
 
1846
- // Stop MediaStream tracks (webcam/mic) to release the device
1847
- if (videoEl?._mediaStream) {
1848
- videoEl._mediaStream.getTracks().forEach(t => t.stop());
1849
- videoEl._mediaStream = null;
1850
- videoEl.srcObject = null;
1851
- }
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
+ }
1852
1872
 
1853
- // Destroy HLS.js instance to free worker + buffers
1854
- if (videoEl?._hlsInstance) {
1855
- videoEl._hlsInstance.destroy();
1856
- videoEl._hlsInstance = null;
1857
- }
1873
+ // Destroy HLS.js instance to free worker + buffers
1874
+ if (videoEl._hlsInstance) {
1875
+ videoEl._hlsInstance.destroy();
1876
+ videoEl._hlsInstance = null;
1877
+ }
1858
1878
 
1859
- // Remove event listeners to prevent accumulation across widget cycles
1860
- if (videoEl?._eventCleanup) {
1861
- for (const [event, handler] of videoEl._eventCleanup) {
1862
- videoEl.removeEventListener(event, handler);
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();
1885
+
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;
1863
1892
  }
1864
- videoEl._eventCleanup = null;
1865
1893
  }
1866
1894
 
1867
1895
  const audioEl = widgetElement.querySelector('audio');
@@ -2844,6 +2872,13 @@ export class RendererLite {
2844
2872
  return true;
2845
2873
  }
2846
2874
 
2875
+ // Don't preload if already in-flight (prevents triple preload when
2876
+ // 75% and 90% timers both fire before the async preload completes)
2877
+ if (this._preloadingLayoutId === layoutId) {
2878
+ this.log.info(`Layout ${layoutId} preload already in-flight, skipping`);
2879
+ return true;
2880
+ }
2881
+
2847
2882
  try {
2848
2883
  this.log.info(`Preloading layout ${layoutId} into pool...`);
2849
2884
 
@@ -3076,6 +3111,50 @@ export class RendererLite {
3076
3111
  }
3077
3112
 
3078
3113
  this.log.info(`Swapped to preloaded layout ${layoutId} (instant transition)`);
3114
+ this._logResourceStats(layoutId);
3115
+ }
3116
+
3117
+ /**
3118
+ * Log resource allocation stats for debugging memory/GPU leaks.
3119
+ * Called after every layout swap to track DOM node accumulation,
3120
+ * video element lifecycle, and pool state.
3121
+ */
3122
+ _logResourceStats(layoutId) {
3123
+ const domNodes = document.querySelectorAll('*').length;
3124
+ const videos = document.querySelectorAll('video').length;
3125
+ const videosSrc = document.querySelectorAll('video[src]').length;
3126
+ const canvases = document.querySelectorAll('canvas').length;
3127
+ const iframes = document.querySelectorAll('iframe').length;
3128
+ const images = document.querySelectorAll('img').length;
3129
+ const poolSize = this.layoutPool ? this.layoutPool.size : 0;
3130
+ const regionCount = this.regions ? this.regions.size : 0;
3131
+ const widgetElements = [...(this.regions?.values() || [])].reduce(
3132
+ (sum, r) => sum + (r.widgetElements?.size || 0), 0
3133
+ );
3134
+ const jsHeap = performance?.memory ? {
3135
+ used: Math.round(performance.memory.usedJSHeapSize / 1048576),
3136
+ total: Math.round(performance.memory.totalJSHeapSize / 1048576),
3137
+ limit: Math.round(performance.memory.jsHeapSizeLimit / 1048576),
3138
+ } : null;
3139
+
3140
+ // Count blob URLs still tracked (potential leak indicator)
3141
+ const blobUrls = this._blobUrls ? [...this._blobUrls.values()].reduce((s, set) => s + set.size, 0) : 0;
3142
+ const blobLayouts = this._blobUrls ? this._blobUrls.size : 0;
3143
+
3144
+ // Preload wrapper divs in DOM (should be 0-1 in normal operation)
3145
+ const preloadWrappers = document.querySelectorAll('.renderer-lite-preload-wrapper').length;
3146
+
3147
+ // Audio overlay elements
3148
+ const audioEls = document.querySelectorAll('audio').length;
3149
+
3150
+ const heapStr = jsHeap ? `heap=${jsHeap.used}/${jsHeap.total}MB (limit ${jsHeap.limit}MB)` : 'heap=N/A';
3151
+ this.log.info(
3152
+ `[Resources] layout=${layoutId} dom=${domNodes} videos=${videos}(src=${videosSrc}) ` +
3153
+ `canvas=${canvases} iframe=${iframes} img=${images} audio=${audioEls} ` +
3154
+ `pool=${poolSize} preloadWrappers=${preloadWrappers} ` +
3155
+ `regions=${regionCount} widgets=${widgetElements} ` +
3156
+ `blobs=${blobUrls}(${blobLayouts} layouts) ${heapStr}`
3157
+ );
3079
3158
  }
3080
3159
 
3081
3160
  /**