<offscreen-canvas>

Just for fun little custom element that you can use to easily use an offscreen canvas and have an image backed by its own program running in it's own worker...

How to use

Most basically: put an <offscreen-canvas src="..."> element in your page around an <img> element with a width and height, where the src attribute is a JavaScript file. That file can contain an onmessage function which will receive a normal postMessage event from the main thread. That event will contain two things in its data - most importantly, a data.canvas which is a transferred canvas you can just paint on from the worker and will automatically paint in the main window canvas. The other thing it will pass is an data.imageBitmap representing the image inside. If you have provided no src on the image itself, the image will simply have a white background by default. Here's a simple example...

The simplest scenario to use in your page... It will spin up worker.js and be handed a 600x600 canvas and a white image bitmap...
<offscreen-canvas src="worker.js">
    <img width="600" height="600" />
</offscreen-canvas>
A very simple example worker file that paints the image with random rectangles on it every few hundred ms
function randomUpTo(max) {
  return Math.floor((Math.random() * max) + 1);
}

// going to be provided canvas
onmessage = function(evt) {
  var canvas = evt.data.canvas;
  var ctx = canvas.getContext("2d");
  
  setInterval(() => {
    ctx.drawImage(evt.data.imageBitmap, 0, 0, canvas.width, canvas.height);
    ctx.lineWidth = randomUpTo(20);
    let x = randomUpTo(canvas.width)
    let y = randomUpTo(canvas.height)
    let height = randomUpTo(Math.floor(canvas.height/2)) 
    let width = randomUpTo(Math.floor(canvas.width/2)) 
    ctx.strokeRect(x, y, x + width, x + height);
  }, 200)
};

With actual images tho...

The cool thing here is that if you do put a src on the image (don't forget to include crossorigin="Anonymous" if that's necessary), you don't have to put a width and height either, the canvas will be sized according to image's natural dimensions - and the same worker above will paint those random shapes on top the image. Note that this means you always have access to the unsullied image so you can easily set things back (as the above does). Note that your image's alt text will be transferred available via the canvas projection.

Messaging

The element itself also exposes its worker via a ._worker property which you can use to communicate via normal HTML messaging channels if you have a need to communicate back and forth -- and that's pretty simple too.

We can send messages back and forth via the ._worker property...
<offscreen-canvas src="worker-with-pause.js">
    <img src="fish.jpg" alt="A guppy swimming in a fishbowl" width="600" height="600" />
</offscreen-canvas>
<script>
await customElements.whenDefined("offscreen-canvas")
let clickableOne = document.querySelector("#x");
let paused = false;
clickableOne.onclick = () => {
  paused = !paused;
  clickableOne._worker.postMessage({ pause: paused })
}
      });
</script>

Bonus Round! Inlining a worker!

Let's say you don't even want to roundtrip it - you just want to put the worker source right there with the image... No problem-o.

Instead of providing a src attribute, you can provide one or more children which are scripts with the type text/offscreen-worker!
<offscreen-canvas>
    <img src="myImage.jpg"/>;
</offscreen-canvas<
<script type="text/offscreen-worker">
  function randomUpTo(max) {
    return Math.floor(Math.random() * max + 1);
  }

  var canvas, ctx, paused, originalBitmap;

  // going to be provided canvas
  onmessage = function(evt) {
    if (evt.data.canvas) {
      originalBitmap = evt.data.imageBitmap;
      canvas = evt.data.canvas;
      ctx = canvas.getContext("2d");
    } else if (typeof evt.data.pause !== 'undefined') {
      paused = evt.data.pause;
    }

    setInterval(() => {
      if (!paused) {
        ctx.drawImage(originalBitmap, 0, 0, canvas.width, canvas.height);
        ctx.lineWidth = randomUpTo(20);
        let x = randomUpTo(canvas.width);
        let y = randomUpTo(canvas.height);
        let height = randomUpTo(Math.floor(canvas.height / 2));
        let width = randomUpTo(Math.floor(canvas.width / 2));
        ctx.strokeRect(x, y, x + width, x + height);
      }
    }, 200);
  };
</script>
<script>
await customElements.whenDefined("offscreen-canvas")
let clickableOne = document.querySelector("#x");
let paused = false;
clickableOne.onclick = () => {
  paused = !paused;
  clickableOne._worker.postMessage({ pause: paused })
}
      });
</script>

Here's the code, running...

Why?

Well, for one, it's fun... But more than that, it seems that very six months or so I get an idea, or find some prompt (a new development with canvas, for example) for something which would require canvas - very often based on manipulation of an existing image. And, just about every time, I burn a bunch of time re-learning some of the subtleties of actually trying to juggle the bits involved to get it setup. Some of these are just general subtlties, but really, I always can't help but feel like the abstraction of images and canvases is a little more wonky than I'd like.

When Apple first introduced canvas, it introduced it as "an image element with a programatic drawing surface". Yet, it kind of isn't that. You'd like to think that a canvas helps "explain the magic" of an image, but from an API standpoint the two are kind of unfortunately unconnected. Maybe a canvas could've been a "better" image, one which you could provide a src attribute to or something to load an image onto it before you started working with it, and then you could 'crack it open'... But it wasn't anything like that, really, either.

So, now that we have all of these parts, I thought I might take a crack at plugging them together into something useful that let you do these things, as well as make the programatic surface easy to run off the main thread. Seems fun, why not?!