I've been working from zero experience of web workers to now for longer than I'd like to admit and it has me defeated.
I want to move a long task, canvas.toBlob(/* jpeg stuff */)
to a worker but sending an SVG to the worker, draw it onto the offscreen canvas, then convert to a JPEG and send it back to the main thread.
This is my code:
self.onmessage = async event => {
const { imageSVG, width, height } = event.data;
try {
// Set up the offscreen canvas
const offscreen = new OffscreenCanvas(width, height);
const ctx = offscreen.getContext('2d');
// Create the image and draw to canvas
const imageSrc = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(imageSVG)))}`;
const imgBlob = await fetch(imageSrc)
.then(response => response.blob());
const img = await createImageBitmap(imgBlob);
ctx.drawImage(img, 0, 0, width, height);
offscreen.convertToBlob({
type: 'image/jpeg',
quality: 1.0
}).then(blob => {
postMessage({ blob: blob });
});
}
catch(error) {
postMessage(`Error: ${error}`);
}
};
The error I can't get past is InvalidStateError: The source image could not be decoded.
and I can't find much online about what it means.
I did come across a github comment, now lost to a million closed tabs, that suggested wrapping the data URI in an SVGImageElement, but I don't know what that means and they didn't elaborate.
While the specs currently say this should be supported, no browser has implemented the decoding of SVG from a Blob
and thus in a Worker
, and they don't plan to. I even wrote a PR so that it's removed from the specs.
The reason they don't want to do it is that they'd need to expose all their DOM path to workers, while currently workers don't need it.
As for a workaround, you have to decode your image from the UI context going through an <img>
:
if (blob.type === "image/svg+xml") {
const img = new Image();
img.src = URL.createObjectURL(blob);
await img.decode();
URL.revokeObjectURL(img.src);
const bmp = await createImageBitmap(img);
worker.postMessage(bmp, [bmp]);
}
Might be noted that most of the work should be done on another thread anyway and shouldn't block your UI, but to be 100% safe you could use a cross-origin isolated <iframe>
which would run on another thread, though the setup is way more complicated.
Ps: If your SVG contains <foreignObject>
, then you currently need to use a data:
URL instead of a blob:
, ... for reasons, and then draw the <img>
on a canvas before getting an ImageBitmap
out of the canvas, for no good reasons I'm afraid.
So that would give
if (blob.type === "image/svg+xml") {
const markup = await blob.text();
// smallest URL encoding, to be safe you can also use encodeURIComponent() or base64
const encoded = markup.replace(/%/g, "%25").replace(/:/g, "%3A").replace(/#/g, "%23");
const img = new Image();
img.src = `data:image/svg+xml,${encoded}`;
await img.decode();
const canvas = new OffscreenCanvas(img.width, img.height);
canvas.getContext("2d").drawImage(img, 0, 0);
const bmp = await createImageBitmap(canvas);
worker.postMessage(bmp, [bmp]);
}
(async () => {
const markup = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="70">
<foreignObject width="100" height="70" x="10">
<div xmlns="http://www.w3.org/1999/xhtml">
<h1 style="color: red">Hello</h1>
</div>
</foreignObject>
</svg>`;
const encoded = markup.replace(/%/g, "%25").replace(/:/g, "%3A").replace(/#/g, "%23");
const img = new Image();
img.src = `data:image/svg+xml,${encoded}`;
await img.decode();
const canvas = new OffscreenCanvas(img.width, img.height);
canvas.getContext("2d").drawImage(img, 0, 0);
const bmp = await createImageBitmap(canvas);
const offCanvas = document.querySelector("canvas").transferControlToOffscreen();
const worker = new Worker(getWorkerURL());
worker.onmessage = ({ data }) => console.log(data);
worker.postMessage({ bmp, offCanvas }, [bmp, offCanvas]);
function getWorkerURL() {
const content = `onmessage = ({ data: { bmp, offCanvas } }) => {
const { width, height } = bmp;
postMessage({ msg:"received a new object", width, height });
offCanvas.width = width;
offCanvas.height = height;
const ctx = offCanvas.getContext("2d");
let blur = 0;
let direction = .02;
const anim = () => {
blur += direction;
if (blur > 6 || blur <= 0) {
direction *= -1
}
ctx.clearRect(0, 0, width, height);
ctx.filter = \`blur(\${blur}px\`;
ctx.drawImage(bmp, 0, 0);
requestAnimationFrame(anim);
}
requestAnimationFrame(anim);
};`
const blob = new Blob([content], { type: "text/javascript" });
return URL.createObjectURL(blob);
}
})().catch(console.error);
<canvas></canvas>