@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 +4 -4
- package/src/index.d.ts +1 -0
- package/src/layout-pool.js +41 -2
- package/src/renderer-lite.js +101 -17
- package/src/renderer-lite.test.js +3 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/renderer",
|
|
3
|
-
"version": "0.7.
|
|
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.
|
|
16
|
-
"@xiboplayer/
|
|
17
|
-
"@xiboplayer/
|
|
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
package/src/layout-pool.js
CHANGED
|
@@ -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
|
|
package/src/renderer-lite.js
CHANGED
|
@@ -1860,27 +1860,36 @@ export class RendererLite {
|
|
|
1860
1860
|
}
|
|
1861
1861
|
|
|
1862
1862
|
const videoEl = widgetElement.querySelector('video');
|
|
1863
|
-
if (videoEl
|
|
1863
|
+
if (videoEl) {
|
|
1864
|
+
videoEl.pause();
|
|
1864
1865
|
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
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
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
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
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
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
|
-
//
|
|
821
|
-
|
|
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
|
});
|