Files
ComfyUI_frontend/tests-ui/tests/composables/maskeditor/useCanvasHistory.test.ts
Tristan Sommer 4adcf09cca GPU accelerated maskeditor rendering (#6767)
## GPU accelerated brush engine for the mask editor

- Full GPU acceleration using TypeGPU and type-safe shaders
- Catmull-Rom Spline Smoothing
- arc-length equidistant resampling
- much improved performance, even for huge images
- photoshop like opacity clamping for brush strokes
- much improved soft brushes
- fallback to CPU fully implemented, much improved CPU rendering
features as well

### Tested Browsers
- Chrome (fully supported)
- Safari 26 (fully supported, prev versions CPU fallback)
- Firefox (CPU fallback, flags needed for full support)



https://github.com/user-attachments/assets/b7b5cb8a-2290-4a95-ae7d-180e11fccdb0



https://github.com/user-attachments/assets/4297aaa5-f249-499a-9b74-869677f1c73b



https://github.com/user-attachments/assets/602b4783-3e2b-489e-bcb9-70534bcaac5e

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6767-GPU-accelerated-maskeditor-rendering-2b16d73d3650818cb294e1fca03f6169)
by [Unito](https://www.unito.io)
2025-11-22 09:07:16 -05:00

513 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useCanvasHistory } from '@/composables/maskeditor/useCanvasHistory'
let mockMaskCanvas: any
let mockRgbCanvas: any
let mockMaskCtx: any
let mockRgbCtx: any
const mockStore = {
maskCanvas: null as any,
rgbCanvas: null as any,
maskCtx: null as any,
rgbCtx: null as any
}
vi.mock('@/stores/maskEditorStore', () => ({
useMaskEditorStore: vi.fn(() => mockStore)
}))
// Mock ImageBitmap for test environment
if (typeof globalThis.ImageBitmap === 'undefined') {
globalThis.ImageBitmap = class ImageBitmap {
width: number
height: number
constructor(width = 100, height = 100) {
this.width = width
this.height = height
}
close() {}
} as any
}
describe('useCanvasHistory', () => {
beforeEach(() => {
vi.clearAllMocks()
let rafCallCount = 0
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(
(cb: FrameRequestCallback) => {
if (rafCallCount++ < 100) {
setTimeout(() => cb(0), 0)
}
return rafCallCount
}
)
vi.spyOn(window, 'alert').mockImplementation(() => {})
const createMockImageData = () => {
return {
data: new Uint8ClampedArray(100 * 100 * 4),
width: 100,
height: 100
} as ImageData
}
mockMaskCtx = {
getImageData: vi.fn(() => createMockImageData()),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
}
mockRgbCtx = {
getImageData: vi.fn(() => createMockImageData()),
putImageData: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn()
}
mockMaskCanvas = {
width: 100,
height: 100
}
mockRgbCanvas = {
width: 100,
height: 100
}
mockStore.maskCanvas = mockMaskCanvas
mockStore.rgbCanvas = mockRgbCanvas
mockStore.maskCtx = mockMaskCtx
mockStore.rgbCtx = mockRgbCtx
})
describe('initialization', () => {
it('should initialize with default values', () => {
const history = useCanvasHistory()
expect(history.canUndo.value).toBe(false)
expect(history.canRedo.value).toBe(false)
})
it('should save initial state', () => {
const history = useCanvasHistory()
history.saveInitialState()
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(history.canUndo.value).toBe(false)
expect(history.canRedo.value).toBe(false)
})
it('should wait for canvas to be ready', () => {
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
mockStore.maskCanvas = { ...mockMaskCanvas, width: 0, height: 0 }
const history = useCanvasHistory()
history.saveInitialState()
expect(rafSpy).toHaveBeenCalled()
mockStore.maskCanvas = mockMaskCanvas
})
it('should wait for context to be ready', () => {
const rafSpy = vi.spyOn(window, 'requestAnimationFrame')
mockStore.maskCtx = null
const history = useCanvasHistory()
history.saveInitialState()
expect(rafSpy).toHaveBeenCalled()
mockStore.maskCtx = mockMaskCtx
})
})
describe('saveState', () => {
it('should save a new state', () => {
const history = useCanvasHistory()
history.saveInitialState()
mockMaskCtx.getImageData.mockClear()
mockRgbCtx.getImageData.mockClear()
history.saveState()
expect(mockMaskCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(mockRgbCtx.getImageData).toHaveBeenCalledWith(0, 0, 100, 100)
expect(history.canUndo.value).toBe(true)
})
it('should clear redo states when saving new state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.saveState()
history.undo()
expect(history.canRedo.value).toBe(true)
history.saveState()
expect(history.canRedo.value).toBe(false)
})
it('should respect maxStates limit', () => {
const history = useCanvasHistory(3)
history.saveInitialState()
history.saveState()
history.saveState()
history.saveState()
history.saveState()
expect(history.canUndo.value).toBe(true)
let undoCount = 0
while (history.canUndo.value && undoCount < 10) {
history.undo()
undoCount++
}
expect(undoCount).toBe(2)
})
it('should call saveInitialState if not initialized', () => {
const history = useCanvasHistory()
history.saveState()
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
})
it('should not save state if context is missing', () => {
const history = useCanvasHistory()
history.saveInitialState()
mockStore.maskCtx = null
mockMaskCtx.getImageData.mockClear()
mockRgbCtx.getImageData.mockClear()
history.saveState()
expect(mockMaskCtx.getImageData).not.toHaveBeenCalled()
mockStore.maskCtx = mockMaskCtx
})
})
describe('undo', () => {
it('should undo to previous state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
expect(history.canUndo.value).toBe(false)
expect(history.canRedo.value).toBe(true)
})
it('should show alert when no undo states available', () => {
const alertSpy = vi.spyOn(window, 'alert')
const history = useCanvasHistory()
history.saveInitialState()
history.undo()
expect(alertSpy).toHaveBeenCalledWith('No more undo states available')
})
it('should undo multiple times', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.saveState()
history.saveState()
history.undo()
expect(history.canUndo.value).toBe(true)
history.undo()
expect(history.canUndo.value).toBe(true)
history.undo()
expect(history.canUndo.value).toBe(false)
})
it('should not undo beyond first state', () => {
const alertSpy = vi.spyOn(window, 'alert')
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
history.undo()
expect(alertSpy).toHaveBeenCalled()
})
})
describe('redo', () => {
it('should redo to next state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
mockMaskCtx.putImageData.mockClear()
mockRgbCtx.putImageData.mockClear()
history.redo()
expect(mockMaskCtx.putImageData).toHaveBeenCalled()
expect(mockRgbCtx.putImageData).toHaveBeenCalled()
expect(history.canRedo.value).toBe(false)
expect(history.canUndo.value).toBe(true)
})
it('should show alert when no redo states available', () => {
const alertSpy = vi.spyOn(window, 'alert')
const history = useCanvasHistory()
history.saveInitialState()
history.redo()
expect(alertSpy).toHaveBeenCalledWith('No more redo states available')
})
it('should redo multiple times', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.saveState()
history.saveState()
history.undo()
history.undo()
history.undo()
history.redo()
expect(history.canRedo.value).toBe(true)
history.redo()
expect(history.canRedo.value).toBe(true)
history.redo()
expect(history.canRedo.value).toBe(false)
})
it('should not redo beyond last state', () => {
const alertSpy = vi.spyOn(window, 'alert')
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
history.redo()
history.redo()
expect(alertSpy).toHaveBeenCalled()
})
})
describe('clearStates', () => {
it('should clear all states', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.saveState()
history.clearStates()
expect(history.canUndo.value).toBe(false)
expect(history.canRedo.value).toBe(false)
})
it('should allow saving initial state after clear', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.clearStates()
mockMaskCtx.getImageData.mockClear()
mockRgbCtx.getImageData.mockClear()
history.saveInitialState()
expect(mockMaskCtx.getImageData).toHaveBeenCalled()
expect(mockRgbCtx.getImageData).toHaveBeenCalled()
})
})
describe('canUndo computed', () => {
it('should be false with no states', () => {
const history = useCanvasHistory()
expect(history.canUndo.value).toBe(false)
})
it('should be false with only initial state', () => {
const history = useCanvasHistory()
history.saveInitialState()
expect(history.canUndo.value).toBe(false)
})
it('should be true after saving a state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
expect(history.canUndo.value).toBe(true)
})
it('should be false after undoing to first state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
expect(history.canUndo.value).toBe(false)
})
})
describe('canRedo computed', () => {
it('should be false with no undo', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
expect(history.canRedo.value).toBe(false)
})
it('should be true after undo', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
expect(history.canRedo.value).toBe(true)
})
it('should be false after redo to last state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
history.redo()
expect(history.canRedo.value).toBe(false)
})
it('should be false after saving new state', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.undo()
expect(history.canRedo.value).toBe(true)
history.saveState()
expect(history.canRedo.value).toBe(false)
})
})
describe('restoreState', () => {
it('should not restore if context is missing', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
mockStore.maskCtx = null
mockMaskCtx.putImageData.mockClear()
mockRgbCtx.putImageData.mockClear()
history.undo()
expect(mockMaskCtx.putImageData).not.toHaveBeenCalled()
mockStore.maskCtx = mockMaskCtx
})
})
describe('edge cases', () => {
it('should handle rapid state saves', async () => {
const history = useCanvasHistory()
history.saveInitialState()
for (let i = 0; i < 10; i++) {
history.saveState()
await nextTick()
}
expect(history.canUndo.value).toBe(true)
})
it('should handle maxStates of 1', () => {
const history = useCanvasHistory(1)
history.saveInitialState()
history.saveState()
expect(history.canUndo.value).toBe(false)
})
it('should handle undo/redo cycling', () => {
const history = useCanvasHistory()
history.saveInitialState()
history.saveState()
history.saveState()
history.undo()
history.redo()
history.undo()
history.redo()
history.undo()
expect(history.canRedo.value).toBe(true)
expect(history.canUndo.value).toBe(true)
})
it('should handle zero-sized canvas', () => {
mockMaskCanvas.width = 0
mockMaskCanvas.height = 0
const history = useCanvasHistory()
history.saveInitialState()
expect(window.requestAnimationFrame).toHaveBeenCalled()
})
})
})