Fabric.js Document Engine

Search documentation

Find a page or section

中文

Image filters

View as Markdown

Fabric.js image filters that run off the main thread, with progress and cancel.

FabricImage.applyFilters() is synchronous. It runs every filter over every pixel before the page can paint or answer a click, so a blur on a large photo freezes the editor (fabric.js #9532). Fabric's WebGL backend is faster on some filters, but the work still happens on the main thread, and slow GPUs or lost WebGL contexts fall back to the 2D path. createFilterWorker runs Fabric.js image filters in a Web Worker instead.

Try it

Loading the live demo. Its code is below.

filter-worker-demo.tsx
"use client";
 
import { FabricImage, filters } from "fabric";
import {
  createFilterWorker,
  type FilterWorker,
  type ImageFilter,
} from "fabricjs-document-engine/filters";
import { CpuIcon, RotateCcwIcon, SnailIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
 
import { DemoCanvas, DemoFrame, Status, Toolbar, ToolButton } from "./demo-ui";
import { PAGE, useFabricCanvas } from "./use-fabric-canvas";
 
const SOURCE = { width: 2400, height: 1500 };
 
function photo() {
  const element = document.createElement("canvas");
  element.width = SOURCE.width;
  element.height = SOURCE.height;
  const ctx = element.getContext("2d")!;
  const sky = ctx.createLinearGradient(0, 0, 0, SOURCE.height);
  sky.addColorStop(0, "#7ad9ec");
  sky.addColorStop(0.6, "#ffd666");
  sky.addColorStop(1, "#f2836f");
  ctx.fillStyle = sky;
  ctx.fillRect(0, 0, SOURCE.width, SOURCE.height);
  for (let index = 0; index < 600; index += 1) {
    ctx.fillStyle = `hsl(${(index * 47) % 360} 70% ${40 + (index % 30)}%)`;
    ctx.beginPath();
    ctx.arc(
      (index * 389) % SOURCE.width,
      (index * 251) % SOURCE.height,
      10 + (index % 50),
      0,
      Math.PI * 2
    );
    ctx.fill();
  }
  return element.toDataURL("image/jpeg", 0.9);
}
 
const heavy = (): ImageFilter[] => [
  new filters.Blur({ blur: 0.25 }),
  new filters.Convolute({ matrix: [0, -1, 0, -1, 5, -1, 0, -1, 0] }),
  new filters.Saturation({ saturation: 0.4 }),
];
 
function measureFreeze(work: () => Promise<unknown>) {
  const channel = new MessageChannel();
  let last = performance.now();
  let longest = 0;
  let running = true;
  channel.port1.onmessage = () => {
    const now = performance.now();
    longest = Math.max(longest, now - last);
    last = now;
    if (running) channel.port2.postMessage(null);
  };
  channel.port2.postMessage(null);
  return work().then(() => {
    running = false;
    channel.port1.close();
    return Math.round(Math.max(longest, performance.now() - last));
  });
}
 
export default function FilterWorkerDemo() {
  const t = useTranslations("demos");
  const { elementRef, canvas } = useFabricCanvas();
  const imageRef = useRef<FabricImage | null>(null);
  const workerRef = useRef<FilterWorker | null>(null);
  const spinnerRef = useRef<HTMLSpanElement>(null);
  const [busy, setBusy] = useState(false);
  const [progress, setProgress] = useState(0);
  const [message, setMessage] = useState("");
 
  useEffect(() => {
    if (!canvas) return;
    let cancelled = false;
    const worker = createFilterWorker();
    workerRef.current = worker;
    void FabricImage.fromURL(photo()).then((image) => {
      if (cancelled) return;
      image.scaleToWidth(PAGE.width);
      image.set({
        left: 0,
        top: 0,
        originX: "left",
        originY: "top",
        selectable: false,
      });
      imageRef.current = image;
      canvas.add(image);
      canvas.requestRenderAll();
    });
    let frame = 0;
    let angle = 0;
    const spin = () => {
      angle = (angle + 6) % 360;
      if (spinnerRef.current)
        spinnerRef.current.style.transform = `rotate(${angle}deg)`;
      frame = requestAnimationFrame(spin);
    };
    frame = requestAnimationFrame(spin);
    return () => {
      cancelled = true;
      cancelAnimationFrame(frame);
      worker.terminate();
      if (imageRef.current) canvas.remove(imageRef.current);
      imageRef.current = null;
    };
  }, [canvas]);
 
  async function run(inWorker: boolean) {
    const image = imageRef.current;
    const worker = workerRef.current;
    if (!canvas || !image || !worker || busy) return;
    setBusy(true);
    setProgress(0);
    setMessage(t("filters.running"));
    await new Promise((resolve) => setTimeout(resolve, 50));
    const freeze = await measureFreeze(async () => {
      if (inWorker) {
        await worker.apply(image, heavy(), { onProgress: setProgress });
      } else {
        image.filters = heavy() as never;
        image.applyFilters();
        setProgress(1);
      }
      canvas.requestRenderAll();
    });
    setMessage(
      inWorker
        ? t("filters.workerResult", { freeze, mode: worker.mode })
        : t("filters.fabricResult", { freeze })
    );
    setBusy(false);
  }
 
  async function reset() {
    const image = imageRef.current;
    if (!canvas || !image || !workerRef.current) return;
    await workerRef.current.apply(image, []);
    setProgress(0);
    setMessage("");
  }
 
  return (
    <DemoFrame>
      <DemoCanvas elementRef={elementRef} label={t("canvas")}>
        <span
          ref={spinnerRef}
          aria-hidden
          className="absolute top-2 right-2 size-5 rounded-full border-2 border-[#6f00ff] border-t-transparent"
        />
      </DemoCanvas>
 
      <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
        <div
          className="h-full bg-[#6f00ff] transition-[width]"
          style={{ width: `${Math.round(progress * 100)}%` }}
        />
      </div>
 
      <Toolbar>
        <ToolButton
          icon={CpuIcon}
          label={t("filters.worker")}
          primary
          disabled={!canvas || busy}
          onClick={() => void run(true)}
        />
        <ToolButton
          icon={SnailIcon}
          label={t("filters.fabric")}
          disabled={!canvas || busy}
          onClick={() => void run(false)}
        />
        <ToolButton
          icon={RotateCcwIcon}
          label={t("filters.reset")}
          iconOnly
          disabled={!canvas || busy}
          onClick={() => void reset()}
        />
      </Toolbar>
 
      <Status tone={busy ? "busy" : message ? "ok" : "idle"}>
        {message || t("filters.hint")}
      </Status>
    </DemoFrame>
  );
}

Run the same blur in the worker and with Fabric, and watch the spinner in the corner. It stops whenever the page is frozen. The status shows the longest freeze in milliseconds.

Run filters in a worker

import { filters } from "fabric";
import { createFilterWorker } from "fabricjs-document-engine/filters";
 
const filterWorker = createFilterWorker({ engine });
 
await filterWorker.apply(image, [
  new filters.Blur({ blur: 0.2 }),
  new filters.Brightness({ brightness: 0.1 }),
]);
  • The picture goes to the worker as an ImageBitmap, and the result comes back the same way. Both are transferred, not copied.
  • The worker draws on an OffscreenCanvas and uses Fabric's own filter classes, so the result has the same pixels as Fabric's 2D filters.
  • The image only changes when the result is ready. With engine, the change is one undo step and saves with the document.
  • apply resolves true when the image was updated.

Progress and newest run wins

Filters that change each pixel on its own, such as brightness, contrast and color matrices, run together over bands of rows, with a progress report after each band. Blur, convolute and pixelate run over the whole picture as one step.

slider.addEventListener("input", () => {
  void filterWorker.apply(
    image,
    [new filters.Blur({ blur: Number(slider.value) })],
    {
      onProgress: (done) => (progress.value = done),
    }
  );
});

While a slider moves, each new run for an image cancels the one before it, and the older promise resolves false. Only the last value is drawn.

Cancel

const controller = new AbortController();
const run = filterWorker.apply(image, heavyFilters, {
  signal: controller.signal,
});
controller.abort();
await run.catch(() => undefined);

An aborted run rejects with the signal's reason and leaves the image as it was.

Where it runs

SituationWhere the filters run
Module workers and OffscreenCanvas (Chrome 80, Firefox 114, Safari 16.4 and later)In a Web Worker
Older browsers, or the CommonJS buildOn the main thread, in short steps with a pause after each
BlendImage, Resize or your own filter classesWith Fabric's applyFilters

filterWorker.mode says which one is in use. Vite, webpack and Next.js pick up the worker file on their own. To run on the main thread on purpose, for example in tests, pass worker: false; createWorker lets you make the worker yourself in other setups.

const filterWorker = createFilterWorker({ worker: false });

All options are in the Filters API. For slow drawing with many objects, see Rendering many objects. Fabric.js image filters keep their filters array, so saved documents open with the same look.