Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 263 additions & 0 deletions examples/context-loss/context-loss.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
<!--
If not stated otherwise in this file or this component's LICENSE file the
following copyright and licenses apply:

Copyright 2020 Metrological

Licensed under the Apache License, Version 2.0 (the License);
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:," />
<title>Lightning - WebGL context loss / restore</title>
</head>
<body style="margin: 0; padding: 0">
<div id="context-loss-overlay" style="display: none; position: absolute; left: 0; top: 0; width: 1280px; height: 720px; background: rgba(0, 0, 0, 0.55); color: #ff5555; font: 48px sans-serif; align-items: center; justify-content: center; text-align: center; pointer-events: none;"></div>
<script type="module">
import lng from '../../dist/src/lightning.mjs';

// Scene covers both restore paths: an image texture (async decode +
// throttled upload), solid color blocks (immediate reload), a text
// texture (cheap canvas path) and a render-to-texture element
// (re-renders on the forced post-restore update).
class ContextLossExample extends lng.Application {
static _template() {
return {
Img: { x: 80, y: 60, src: '../landscape.jpg', scale: 0.8 },
Red: { x: 700, y: 60, w: 220, h: 220, rect: true, color: 0xffff0000 },
Green: { x: 960, y: 60, w: 220, h: 140, rect: true, color: 0xff00ff00 },
// PixelateShader reads texture dimensions per quad: it covers
// the recovery path where the shared image source has no
// native texture yet.
Pix: { x: 960, y: 240, w: 220, h: 148, src: '../landscape.jpg', shader: { type: lng.shaders.Pixelate } },
Rtt: {
x: 700, y: 320, w: 480, h: 200, renderToTexture: true,
Label: {
x: 20, y: 70, text: 'render-to-texture',
},
Box: { x: 20, y: 110, w: 120, h: 50, rect: true, color: 0xff0000ff },
},
Status: { x: 80, y: 620, text: 'Context running - click canvas to simulate loss' },
};
}
}

const options = {
stage: { w: 1280, h: 720, clearColor: 0xff000000, canvas2d: false, useImageWorker: false },
debug: true,
};

const app = new ContextLossExample(options);
const canvas = app.stage.getCanvas();
document.body.appendChild(canvas);

const status = app.tag('Status');
const img = app.tag('Img');
const setStatus = (text) => {
status.text = text;
};

const isImgLoaded = () => {
try {
const texture = img.texture;
return !!(texture && texture.isLoaded());
} catch (e) {
return false;
}
};

const seen = { lost: 0, restored: 0, failed: 0 };

// The in-canvas status line cannot repaint while the context is
// lost (all frame rendering is suspended), and some browsers keep
// compositing the last presented frame, so a lost context can look
// like nothing happened. This DOM overlay makes the state
// unmistakable; it is independent of WebGL.
const overlay = document.getElementById('context-loss-overlay');
const showOverlay = (html) => {
overlay.innerHTML = html;
overlay.style.display = 'flex';
};
const hideOverlay = () => {
overlay.style.display = 'none';
};

app.stage.on('contextLost', ({ generation }) => {
seen.lost++;
showOverlay('CONTEXT LOST<br><small style="font-size: 28px">click canvas to restore</small>');
setStatus(`CONTEXT LOST (gen ${generation}) - click canvas to restore`);
console.log(`[context-loss] contextLost generation=${generation}`);
});
app.stage.on('contextRestored', ({ generation, duration }) => {
seen.restored++;
hideOverlay();
setStatus(`Context restored (gen ${generation}, ${Math.round(duration)}ms) - click canvas to lose it again`);
console.log(`[context-loss] contextRestored generation=${generation} duration=${Math.round(duration)}ms`);
});
app.stage.on('contextRestoreFailed', ({ generation, error }) => {
seen.failed++;
showOverlay('RESTORE FAILED<br><small style="font-size: 28px">click canvas to retry</small>');
setStatus(`Restore FAILED (gen ${generation}) - click canvas to retry`);
console.warn(`[context-loss] contextRestoreFailed generation=${generation}`, error);
});

img.on('txError', (e) => {
console.error('[context-loss] image load error', e);
}); function getLoseContextExtension() {
const gl = app.stage.gl;
if (!gl) {
throw new Error('No WebGL context (canvas2d mode has no loss simulation)');
}
const ext = gl.getExtension('WEBGL_lose_context');
if (!ext) {
throw new Error('WEBGL_lose_context extension unavailable');
}
return ext;
}

// Fetch once while healthy: getExtension() returns null while the
// context is lost, so the same object must drive restore.
let loseExt = null;
try {
loseExt = getLoseContextExtension();
} catch (e) {
setStatus(`No context-loss extension: ${e.message}`);
}

let lost = false;
const hooks = {
get imgLoaded() {
return isImgLoaded();
},
get state() {
return app.stage.contextState;
},
get events() {
return seen;
},
lose() {
if (!loseExt) {
throw new Error('WEBGL_lose_context extension unavailable');
}
loseExt.loseContext();
lost = true;
},
restore() {
if (!loseExt) {
throw new Error('WEBGL_lose_context extension unavailable');
}
loseExt.restoreContext();
lost = false;
},
};
window.__contextLoss = hooks;
// Exposed for debugging and test harnesses.
window.__app = app;

canvas.addEventListener('click', () => {
if (!loseExt) {
return;
}
if (lost === false) {
hooks.lose();
} else {
hooks.restore();
}
setStatus(lost ? 'CONTEXT LOST - click canvas to restore' : 'Context running - click canvas to simulate loss');
});
console.log('[context-loss] ready! Click the canvas to toggle WebGL context loss.');

// Automation mode (`?automation`): runs the lose/restore cycle on its
// own and reports via console + window.__contextLossResult, mirroring
// the renderer integration test.
function waitFor(fn, timeoutMs, label) {
return new Promise((resolve, reject) => {
const start = Date.now();
const tick = () => {
let ok = false;
try {
ok = fn();
} catch (e) {
reject(new Error(`Timed out waiting for ${label}: ${e.message}`));
return;
}
if (ok) {
resolve();
} else if (Date.now() - start > timeoutMs) {
reject(new Error(`Timed out waiting for ${label}`));
} else {
requestAnimationFrame(tick);
}
};
tick();
});
}

function waitFrames(count) {
return new Promise((resolve) => {
const tick = () => {
count--;
if (count <= 0) {
resolve();
return;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
}

async function automation() {
try {
await waitFor(() => hooks.imgLoaded === true, 10000, 'initial texture load');
// Let the healthy frame settle before the harness screenshots it.
await waitFrames(5);

hooks.lose();
await waitFor(() => hooks.state === 'lost', 5000, 'contextLost');

// Several frames while lost: rendering must stay suspended
// without crashing or submitting GPU work.
await waitFrames(15);
if (hooks.state !== 'lost') {
throw new Error('Stage left lost state without a restore');
}

hooks.restore();
await waitFor(() => hooks.state === 'running', 15000, 'contextRestored');
await waitFor(() => hooks.imgLoaded === true, 15000, 'texture reload');
await waitFrames(5);
console.log('[context-loss] completed successfully');
window.__contextLossResult = { ok: true };
} catch (e) {
console.error(`[context-loss] FAILED: ${e.message}`);
// Paint an obviously wrong frame so a pixel comparison fails
// instead of hanging the harness.
const fallback = app.stage.createElement({
x: 0, y: 0, w: 1280, h: 720, rect: true, color: 0xffff00ff,
});
app.children = [...app.children, fallback];
await waitFrames(5);
window.__contextLossResult = { ok: false, message: e.message };
}
}

if (new URLSearchParams(location.search).has('automation')) {
automation();
}
</script>
</body>
</html>
10 changes: 8 additions & 2 deletions src/platforms/browser/ImageWorker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,267 +72,273 @@
}

cancel(image) {
this._worker.postMessage({type: "cancel", id: image.id});
this._items.delete(image.id);
// Guarded: cancellation can race platform teardown, after which the
// worker and its item map are gone.
if (this._worker) {
this._worker.postMessage({type: "cancel", id: image.id});
}
if (this._items) {
this._items.delete(image.id);
}
}

error(image, info) {
image.error(info);
this._items.delete(image.id);
}

finish(image, info) {
image.load(info);
this._items.delete(image.id);
}

}

class ImageWorkerImage {

constructor(manager, id, src) {
this._manager = manager;
this._id = id;
this._src = src;
this._onError = null;
this._onLoad = null;
}

get id() {
return this._id;
}

get src() {
return this._src;
}

set onError(f) {
this._onError = f;
}

set onLoad(f) {
this._onLoad = f;
}

cancel() {
this._manager.cancel(this);
}

load(info) {
if (this._onLoad) {
this._onLoad(info);
}
}

error(info) {
if (this._onError) {
this._onError(info);
}
}

}

/**
* Notice that, within the createWorker function, we must only use ES5 code to keep it ES5-valid after babelifying, as
* the converted code of this section is converted to a blob and used as the js of the web worker thread.
*/
const createWorker = function() {

function ImageWorkerServer() {

this.items = new Map();

var t = this;
onmessage = function(e) {
t._receiveMessage(e);
}

}

ImageWorkerServer.isPathAbsolute = function(path) {
return /^(?:\/|[a-z]+:\/\/)/.test(path) || path.substr(0, 5) == 'data:';
};

ImageWorkerServer.prototype._receiveMessage = function(e) {
if (e.data.type === 'config') {
this.config = e.data.config;

var base = this.config.path;
// the constructing of the basepath fails
// if location.hash contains a slash
var hasHashPath = /#.*?\//;
if(hasHashPath.test(base)){
base = base.replace(/#.*$/,'');
}

var parts = base.split("/");
parts.pop();
this._relativeBase = parts.join("/") + "/";

} else if (e.data.type === 'add') {
this.add(e.data.id, e.data.src);
} else if (e.data.type === 'cancel') {
this.cancel(e.data.id);
}
};

ImageWorkerServer.prototype.add = function(id, src) {
// Convert relative URLs.
if (!ImageWorkerServer.isPathAbsolute(src)) {
src = this._relativeBase + src;
}

if (src.substr(0,2) === "//") {
// This doesn't work for image workers.
src = this.config.protocol + src;
}

var item = new ImageWorkerServerItem(id, src);
var t = this;
item.onFinish = function(result) {
t.finish(item, result);
};
item.onError = function(info) {
t.error(item, info);
};
this.items.set(id, item);
item.start();
};

ImageWorkerServer.prototype.cancel = function(id) {
var item = this.items.get(id);
if (item) {
item.cancel();
this.items.delete(id);
}
}

ImageWorkerServer.prototype.finish = function(item, {imageBitmap, hasAlphaChannel}) {
postMessage({
type: "data",
id: item.id,
info: {
imageBitmap,
hasAlphaChannel
}
}, [imageBitmap]);
this.items.delete(item.id);
};

ImageWorkerServer.prototype.error = function(item, {type, message}) {
postMessage({
type: "error",
id: item.id,
info: {
type,
message
}
});
this.items.delete(item.id);
};

ImageWorkerServer.isWPEBrowser = function() {
return (navigator.userAgent.indexOf("WPE") !== -1);
};

function ImageWorkerServerItem(id, src) {

this._onError = undefined;
this._onFinish = undefined;
this._id = id;
this._src = src;
this._xhr = undefined;
this._mimeType = undefined;
this._canceled = false;

}

Object.defineProperty(ImageWorkerServerItem.prototype, 'id', {
get: function() {
return this._id;
}
});

Object.defineProperty(ImageWorkerServerItem.prototype, 'onFinish', {
get: function() {
return this._onFinish;
},
set: function(f) {
this._onFinish = f;
}
});

Object.defineProperty(ImageWorkerServerItem.prototype, 'onError', {
get: function() {
return this._onError;
},
set: function(f) {
this._onError = f;
}
});

ImageWorkerServerItem.prototype.start = function() {
this._xhr = new XMLHttpRequest();
this._xhr.open("GET", this._src, true);
this._xhr.responseType = "blob";

var t = this;
this._xhr.onerror = function(oEvent) {
t.error({type: "connection", message: "Connection error"});
};

this._xhr.onload = function(oEvent) {
var blob = t._xhr.response;
t._mimeType = blob.type;

t._createImageBitmap(blob);
};

this._xhr.send();
};

ImageWorkerServerItem.prototype._createImageBitmap = function(blob) {
var t = this;
createImageBitmap(blob, {premultiplyAlpha: 'premultiply', colorSpaceConversion: 'none', imageOrientation: 'none'}).then(function(imageBitmap) {
t.finish({
imageBitmap,
hasAlphaChannel: t._hasAlphaChannel()
});
}).catch(function(e) {
t.error({type: "parse", message: "Error parsing image data"});
});
};

ImageWorkerServerItem.prototype._hasAlphaChannel = function() {
if (ImageWorkerServer.isWPEBrowser()) {
// When using unaccelerated rendering image (https://github.com/WebPlatformForEmbedded/WPEWebKit/blob/wpe-20170728/Source/WebCore/html/ImageBitmap.cpp#L52),
// everything including JPG images are in RGBA format. Upload is way faster when using an alpha channel.
// @todo: after hardware acceleration is fixed and re-enabled, JPG should be uploaded in RGB to get the best possible performance and memory usage.
return true;
} else {
return (this._mimeType.indexOf("image/png") !== -1);
}
};

ImageWorkerServerItem.prototype.cancel = function() {
if (this._canceled) return;
if (this._xhr) {
this._xhr.abort();
}
this._canceled = true;
};

ImageWorkerServerItem.prototype.error = function(type, message) {
if (!this._canceled && this._onError) {
this._onError({type, message});
}
};

ImageWorkerServerItem.prototype.finish = function(info) {
if (!this._canceled && this._onFinish) {
this._onFinish(info);
}
};

var worker = new ImageWorkerServer();

Check failure

Code scanning / FossID

Third-party Partial Match: core Error

Local: src/platforms/browser/ImageWorker.mjs (Lines 77-343)
Remote: src/platforms/browser/ImageWorker.mjs (Lines 2-337) | License: Apache-2.0
Component: pkg:npm/@lightningjs/core@2.9.1
Match Type: Partial Match
Download: https://registry.npmjs.org/@lightningjs/core/-/core-2.9.1.tgz
Local Link: https://github.com/rdkcentral/Lightning/blob/47d715bf514d5f4a236491e915d8a3973ab63c9d/src/platforms/browser/ImageWorker.mjs#L77-L343
Remote link not available from FossID, see Download

Check failure

Code scanning / FossID

Third-party Partial Match: core Error

Local: src/platforms/browser/ImageWorker.mjs (Lines 77-343)
Remote: src/platforms/browser/ImageWorker.mjs (Lines 2-331) | License: Apache-2.0
Component: pkg:npm/@lightningjs/core@2.1.2
Match Type: Partial Match
Download: https://registry.npmjs.org/@lightningjs/core/-/core-2.1.2.tgz
Local Link: https://github.com/rdkcentral/Lightning/blob/47d715bf514d5f4a236491e915d8a3973ab63c9d/src/platforms/browser/ImageWorker.mjs#L77-L343
Remote link not available from FossID, see Download
};
40 changes: 40 additions & 0 deletions src/platforms/browser/ImageWorker.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* If not stated otherwise in this file or this component's LICENSE file the
* following copyright and licenses apply:
*
* Copyright 2020 Metrological
*
* Licensed under the Apache License, Version 2.0 (the License);
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, it, expect, vi } from 'vitest';
import ImageWorker from './ImageWorker.mjs';

describe('ImageWorker teardown', () => {
it('cancels pending loads while alive', () => {
const postMessage = vi.fn();
const worker = Object.create(ImageWorker.prototype);
Object.assign(worker, {
_worker: { postMessage },
_items: new Map([[1, {}]]),
});
worker.cancel({ id: 1 });
expect(postMessage).toHaveBeenCalledWith({ type: 'cancel', id: 1 });
expect(worker._items.has(1)).toBe(false);
});

it('tolerates cancellation after teardown', () => {
const worker = Object.create(ImageWorker.prototype);
Object.assign(worker, { _worker: undefined, _items: undefined });
expect(() => worker.cancel({ id: 1 })).not.toThrow();
});
});
Loading
Loading