mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-02 03:30:04 +00:00
fix: pre-rasterize SubgraphNode SVG icon to bitmap canvas (#9172)
## 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.
<!-- Fixes #ISSUE_NUMBER -->
┆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 <action@github.com>
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
93
src/lib/litegraph/src/subgraph/svgBitmapCache.test.ts
Normal file
93
src/lib/litegraph/src/subgraph/svgBitmapCache.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
24
src/lib/litegraph/src/subgraph/svgBitmapCache.ts
Normal file
24
src/lib/litegraph/src/subgraph/svgBitmapCache.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user