From 8da07f2ce2c3534c46548ee938c1eaf6530c45df Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Sat, 28 Feb 2026 01:06:54 -0800 Subject: [PATCH] fix: pre-rasterize SubgraphNode SVG icon to bitmap canvas (#9172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Pre-rasterize the SubgraphNode SVG icon to a bitmap canvas to eliminate Firefox's per-frame SVG style processing. ## Changes - **What**: Add `getWorkflowBitmap()` that lazily rasterizes the `data:image/svg+xml` workflow icon to an `HTMLCanvasElement` (16×16) on first use. `SubgraphNode.drawTitleBox()` draws the cached bitmap instead of the raw SVG. ## Review Focus - Firefox re-processes SVG internal stylesheets (`stroke`, `stroke-linecap`, `stroke-width`) every time `ctx.drawImage(svgImage)` is called. Chrome caches the rasterization. This happens on every frame for every visible SubgraphNode. - Reporter confirmed strong subgraph correlation: "it may be happening in the default workflow with subgraph" / "didn't seem to happen just using manually wired up diffusion loader, clip, sampler, etc." - Falls back to the raw SVG Image if not yet loaded or if `getContext('2d')` returns null. ## Stack 3 of 4 in Firefox perf fix stack. Depends on #9170. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9172-fix-pre-rasterize-SubgraphNode-SVG-icon-to-bitmap-canvas-3116d73d365081babf17cf0848d37269) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action --- .../litegraph/src/subgraph/SubgraphNode.ts | 13 ++- .../src/subgraph/svgBitmapCache.test.ts | 93 +++++++++++++++++++ .../litegraph/src/subgraph/svgBitmapCache.ts | 24 +++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 src/lib/litegraph/src/subgraph/svgBitmapCache.test.ts create mode 100644 src/lib/litegraph/src/subgraph/svgBitmapCache.ts diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.ts index 08a322887f..8dd3c95c6b 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.ts @@ -42,11 +42,16 @@ import { ExecutableNodeDTO } from './ExecutableNodeDTO' import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO' import { PromotedWidgetViewManager } from './PromotedWidgetViewManager' import type { SubgraphInput } from './SubgraphInput' +import { createBitmapCache } from './svgBitmapCache' const workflowSvg = new Image() workflowSvg.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3E%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-width='1.3' d='M9.18613 3.09999H6.81377M9.18613 12.9H7.55288c-3.08678 0-5.35171-2.99581-4.60305-6.08843l.3054-1.26158M14.7486 2.1721l-.5931 2.45c-.132.54533-.6065.92789-1.1508.92789h-2.2993c-.77173 0-1.33797-.74895-1.1508-1.5221l.5931-2.45c.132-.54533.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.74896 1.1508 1.52211Zm-8.3033 0-.59309 2.45c-.13201.54533-.60646.92789-1.15076.92789H2.4021c-.7717 0-1.33793-.74895-1.15077-1.5221l.59309-2.45c.13201-.54533.60647-.9279 1.15077-.9279h2.29935c.77169 0 1.33792.74896 1.15076 1.52211Zm8.3033 9.8-.5931 2.45c-.132.5453-.6065.9279-1.1508.9279h-2.2993c-.77173 0-1.33797-.749-1.1508-1.5221l.5931-2.45c.132-.5453.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.7489 1.1508 1.5221Z'/%3E%3C/svg%3E %3C/svg%3E" +// Pre-rasterize the SVG to a bitmap canvas to avoid Firefox re-processing +// the SVG's internal stylesheet on every ctx.drawImage() call per frame. +const workflowBitmapCache = createBitmapCache(workflowSvg, 32) + /** * An instance of a {@link Subgraph}, displayed as a node on the containing (parent) graph. */ @@ -726,7 +731,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { if (!low_quality) { ctx.translate(25, 23) ctx.scale(-1.5, 1.5) - ctx.drawImage(workflowSvg, 0, -title_height, box_size, box_size) + ctx.drawImage( + workflowBitmapCache.get(), + 0, + -title_height, + box_size, + box_size + ) } ctx.restore() } diff --git a/src/lib/litegraph/src/subgraph/svgBitmapCache.test.ts b/src/lib/litegraph/src/subgraph/svgBitmapCache.test.ts new file mode 100644 index 0000000000..15a28f5aa7 --- /dev/null +++ b/src/lib/litegraph/src/subgraph/svgBitmapCache.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createBitmapCache } from './svgBitmapCache' + +function mockSvg( + overrides: Partial<{ complete: boolean; naturalWidth: number }> = {} +) { + const img = new Image() + Object.defineProperty(img, 'complete', { + value: overrides.complete ?? true + }) + Object.defineProperty(img, 'naturalWidth', { + value: overrides.naturalWidth ?? 16 + }) + return img +} + +describe('createBitmapCache', () => { + const BITMAP_SIZE = 16 + function mockGetContext(returnValue: CanvasRenderingContext2D | null) { + return vi + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockImplementation( + (() => returnValue) as typeof HTMLCanvasElement.prototype.getContext + ) + } + + const stubContext = { + drawImage: vi.fn() + } as unknown as CanvasRenderingContext2D + + it('returns the SVG when image is not yet complete', () => { + const svg = mockSvg({ complete: false, naturalWidth: 0 }) + const cache = createBitmapCache(svg, BITMAP_SIZE) + + expect(cache.get()).toBe(svg) + }) + + it('returns the SVG when naturalWidth is 0', () => { + const svg = mockSvg({ complete: true, naturalWidth: 0 }) + const cache = createBitmapCache(svg, BITMAP_SIZE) + + expect(cache.get()).toBe(svg) + }) + + it('creates a bitmap canvas once the SVG is loaded', () => { + const svg = mockSvg() + const cache = createBitmapCache(svg, BITMAP_SIZE) + mockGetContext(stubContext) + + const result = cache.get() + + expect(result).toBeInstanceOf(HTMLCanvasElement) + expect((result as HTMLCanvasElement).width).toBe(BITMAP_SIZE) + expect((result as HTMLCanvasElement).height).toBe(BITMAP_SIZE) + vi.restoreAllMocks() + }) + + it('returns the cached bitmap on subsequent calls', () => { + const svg = mockSvg() + const cache = createBitmapCache(svg, BITMAP_SIZE) + mockGetContext(stubContext) + + const first = cache.get() + const second = cache.get() + + expect(first).toBe(second) + vi.restoreAllMocks() + }) + + it('does not re-create the canvas on subsequent calls', () => { + const svg = mockSvg() + const cache = createBitmapCache(svg, BITMAP_SIZE) + mockGetContext(stubContext) + const createElementSpy = vi.spyOn(document, 'createElement') + + cache.get() + const callCount = createElementSpy.mock.calls.length + cache.get() + + expect(createElementSpy).toHaveBeenCalledTimes(callCount) + vi.restoreAllMocks() + }) + + it('falls back to SVG when canvas context is unavailable', () => { + const svg = mockSvg() + const cache = createBitmapCache(svg, BITMAP_SIZE) + mockGetContext(null) + + expect(cache.get()).toBe(svg) + vi.restoreAllMocks() + }) +}) diff --git a/src/lib/litegraph/src/subgraph/svgBitmapCache.ts b/src/lib/litegraph/src/subgraph/svgBitmapCache.ts new file mode 100644 index 0000000000..69d4eb070c --- /dev/null +++ b/src/lib/litegraph/src/subgraph/svgBitmapCache.ts @@ -0,0 +1,24 @@ +export function createBitmapCache(svg: HTMLImageElement, bitmapSize: number) { + let bitmap: HTMLCanvasElement | null = null + + return { + get(): HTMLCanvasElement | HTMLImageElement { + if (bitmap) return bitmap + if (!svg.complete || svg.naturalWidth === 0) return svg + + const canvas = document.createElement('canvas') + canvas.width = bitmapSize + canvas.height = bitmapSize + const ctx = canvas.getContext('2d') + if (!ctx) return svg + + try { + ctx.drawImage(svg, 0, 0, bitmapSize, bitmapSize) + } catch { + return svg + } + bitmap = canvas + return bitmap + } + } +}