mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-02 03:30:04 +00:00
## 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)
230 lines
5.9 KiB
Vue
230 lines
5.9 KiB
Vue
<template>
|
|
<div
|
|
ref="containerRef"
|
|
class="maskEditor-dialog-root flex h-full w-full flex-col"
|
|
@contextmenu.prevent
|
|
@dragstart="handleDragStart"
|
|
>
|
|
<div
|
|
id="maskEditorCanvasContainer"
|
|
ref="canvasContainerRef"
|
|
@contextmenu.prevent
|
|
>
|
|
<canvas
|
|
ref="imgCanvasRef"
|
|
class="absolute top-0 left-0 w-full h-full z-0"
|
|
@contextmenu.prevent
|
|
/>
|
|
<canvas
|
|
ref="rgbCanvasRef"
|
|
class="absolute top-0 left-0 w-full h-full z-10"
|
|
@contextmenu.prevent
|
|
/>
|
|
<canvas
|
|
ref="maskCanvasRef"
|
|
class="absolute top-0 left-0 w-full h-full z-30"
|
|
@contextmenu.prevent
|
|
/>
|
|
<!-- GPU Preview Canvas -->
|
|
<canvas
|
|
ref="gpuCanvasRef"
|
|
class="absolute top-0 left-0 w-full h-full pointer-events-none"
|
|
:class="{
|
|
'z-20': store.activeLayer === 'rgb',
|
|
'z-40': store.activeLayer === 'mask'
|
|
}"
|
|
/>
|
|
<div ref="canvasBackgroundRef" class="bg-white w-full h-full" />
|
|
</div>
|
|
|
|
<div class="maskEditor-ui-container flex min-h-0 flex-1 flex-col">
|
|
<div class="flex min-h-0 flex-1 overflow-hidden">
|
|
<ToolPanel
|
|
v-if="initialized"
|
|
ref="toolPanelRef"
|
|
:tool-manager="toolManager!"
|
|
/>
|
|
|
|
<PointerZone
|
|
v-if="initialized"
|
|
:tool-manager="toolManager!"
|
|
:pan-zoom="panZoom!"
|
|
/>
|
|
|
|
<SidePanel
|
|
v-if="initialized"
|
|
ref="sidePanelRef"
|
|
:tool-manager="toolManager!"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<BrushCursor v-if="initialized" :container-ref="containerRef" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
|
|
|
import { useImageLoader } from '@/composables/maskeditor/useImageLoader'
|
|
import { useKeyboard } from '@/composables/maskeditor/useKeyboard'
|
|
import { useMaskEditorLoader } from '@/composables/maskeditor/useMaskEditorLoader'
|
|
import { usePanAndZoom } from '@/composables/maskeditor/usePanAndZoom'
|
|
import { useToolManager } from '@/composables/maskeditor/useToolManager'
|
|
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|
import { useDialogStore } from '@/stores/dialogStore'
|
|
import { useMaskEditorDataStore } from '@/stores/maskEditorDataStore'
|
|
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
|
|
|
import BrushCursor from './BrushCursor.vue'
|
|
import PointerZone from './PointerZone.vue'
|
|
import SidePanel from './SidePanel.vue'
|
|
import ToolPanel from './ToolPanel.vue'
|
|
|
|
const { node } = defineProps<{
|
|
node: LGraphNode
|
|
}>()
|
|
|
|
const store = useMaskEditorStore()
|
|
const dataStore = useMaskEditorDataStore()
|
|
const dialogStore = useDialogStore()
|
|
|
|
const loader = useMaskEditorLoader()
|
|
|
|
const containerRef = ref<HTMLElement>()
|
|
const canvasContainerRef = ref<HTMLDivElement>()
|
|
const imgCanvasRef = ref<HTMLCanvasElement>()
|
|
const maskCanvasRef = ref<HTMLCanvasElement>()
|
|
const rgbCanvasRef = ref<HTMLCanvasElement>()
|
|
const gpuCanvasRef = ref<HTMLCanvasElement>()
|
|
const canvasBackgroundRef = ref<HTMLDivElement>()
|
|
|
|
const toolPanelRef = ref<InstanceType<typeof ToolPanel>>()
|
|
const sidePanelRef = ref<InstanceType<typeof SidePanel>>()
|
|
|
|
const initialized = ref(false)
|
|
|
|
const keyboard = useKeyboard()
|
|
const panZoom = usePanAndZoom()
|
|
|
|
const toolManager = useToolManager(keyboard, panZoom)
|
|
|
|
let resizeObserver: ResizeObserver | null = null
|
|
|
|
const handleDragStart = (event: DragEvent) => {
|
|
if (event.ctrlKey) {
|
|
event.preventDefault()
|
|
}
|
|
}
|
|
|
|
const initUI = async () => {
|
|
if (!containerRef.value) {
|
|
console.error(
|
|
'[MaskEditorContent] Cannot initialize - missing required refs'
|
|
)
|
|
return
|
|
}
|
|
|
|
if (
|
|
!imgCanvasRef.value ||
|
|
!maskCanvasRef.value ||
|
|
!rgbCanvasRef.value ||
|
|
!canvasContainerRef.value ||
|
|
!canvasBackgroundRef.value
|
|
) {
|
|
console.error('[MaskEditorContent] Cannot initialize - missing canvas refs')
|
|
return
|
|
}
|
|
|
|
store.maskCanvas = maskCanvasRef.value
|
|
store.rgbCanvas = rgbCanvasRef.value
|
|
store.imgCanvas = imgCanvasRef.value
|
|
store.canvasContainer = canvasContainerRef.value
|
|
store.canvasBackground = canvasBackgroundRef.value
|
|
|
|
try {
|
|
await loader.loadFromNode(node)
|
|
|
|
const imageLoader = useImageLoader()
|
|
const image = await imageLoader.loadImages()
|
|
|
|
await panZoom.initializeCanvasPanZoom(
|
|
image,
|
|
containerRef.value,
|
|
toolPanelRef.value?.$el as HTMLElement | undefined,
|
|
sidePanelRef.value?.$el as HTMLElement | undefined
|
|
)
|
|
|
|
store.canvasHistory.saveInitialState()
|
|
|
|
// Initialize GPU resources
|
|
if (toolManager.brushDrawing) {
|
|
await toolManager.brushDrawing.initGPUResources()
|
|
if (gpuCanvasRef.value && toolManager.brushDrawing.initPreviewCanvas) {
|
|
// Match preview canvas resolution to mask canvas
|
|
gpuCanvasRef.value.width = maskCanvasRef.value.width
|
|
gpuCanvasRef.value.height = maskCanvasRef.value.height
|
|
|
|
toolManager.brushDrawing.initPreviewCanvas(gpuCanvasRef.value)
|
|
}
|
|
}
|
|
|
|
initialized.value = true
|
|
} catch (error) {
|
|
console.error('[MaskEditorContent] Initialization failed:', error)
|
|
dialogStore.closeDialog()
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
keyboard.addListeners()
|
|
|
|
if (containerRef.value) {
|
|
resizeObserver = new ResizeObserver(async () => {
|
|
if (panZoom) {
|
|
await panZoom.invalidatePanZoom()
|
|
}
|
|
})
|
|
resizeObserver.observe(containerRef.value)
|
|
}
|
|
|
|
void initUI()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
toolManager.brushDrawing.saveBrushSettings()
|
|
|
|
keyboard?.removeListeners()
|
|
|
|
if (resizeObserver) {
|
|
resizeObserver.disconnect()
|
|
resizeObserver = null
|
|
}
|
|
|
|
store.canvasHistory.clearStates()
|
|
store.resetState()
|
|
dataStore.reset()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.maskEditor-dialog-root {
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.maskEditor-ui-container {
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
:deep(#maskEditorCanvasContainer) {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
z-index: 0;
|
|
}
|
|
</style>
|