diff --git a/src/components/canvas/players/html5-player.ts b/src/components/canvas/players/html5-player.ts
index dd2b8b0e..2b3bb7a5 100644
--- a/src/components/canvas/players/html5-player.ts
+++ b/src/components/canvas/players/html5-player.ts
@@ -41,7 +41,7 @@ const BAKE_SCALES = [0.25, 0.5, 0.75, 1];
// A remembered frame holds its body markup, so a window bounds the memory. 300 frames covers a 10 s loop at 30 fps.
const REPEAT_WINDOW_FRAMES = 300;
// Part of every bake's cache key, so raising it redraws every cached bake.
-const FRAME_DRAWING_VERSION = 1;
+const FRAME_DRAWING_VERSION = 2;
const ANIMATION_OVERRIDE = ``;
// A base64 bitmap in a frame's markup: a CSS url(), an
source, or a canvas painted as a background.
@@ -109,7 +109,8 @@ async function foreignObjectSvgToWebp(svg: string, width: number, height: number
const canvas = document.createElement("canvas");
canvas.width = width * scale;
canvas.height = height * scale;
- const ctx = canvas.getContext("2d");
+ // Firefox's GPU canvas draws some of a frame's gradients as a solid fill. A canvas set for reading back draws in software.
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) throw new Error("2D context unavailable for foreignObject rasterise");
ctx.drawImage(img, 0, 0, width * scale, height * scale);
const blob = await new Promise(resolve => {
diff --git a/tests/html5-player.test.ts b/tests/html5-player.test.ts
index 6516f45a..924fc8b4 100644
--- a/tests/html5-player.test.ts
+++ b/tests/html5-player.test.ts
@@ -1050,6 +1050,15 @@ it("draws frames at the bake scale and sizes their texture back to the clip", as
expect((harness.contentContainer.children.at(-1) as PlaybackSprite).texture.resolution).toBe(0.5);
});
+it("draws frames on a software canvas, since Firefox's GPU canvas fills some gradients solid", async () => {
+ const { harness } = createStillPlayer();
+ const getContext = jest.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ drawImage: jest.fn() } as never);
+
+ await harness.runCapture();
+
+ expect(getContext).toHaveBeenCalledWith("2d", { willReadFrequently: true });
+});
+
it("draws a full-size frame of a fractional clip size as an unscaled draw does", async () => {
const { harness } = createStillPlayer();
Object.assign(harness, { renderedWidth: 1080.6, renderedHeight: 1920.4 });