mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-27 10:14:06 +00:00
feat: add Painter Node (#8521)
## Summary Add PainterNode widget for freehand mask drawing directly on the canvas, with brush/eraser tools, opacity, hardness, and background color controls. need BE changes https://github.com/Comfy-Org/ComfyUI/pull/12294 ## Screenshots (if applicable) https://github.com/user-attachments/assets/7222063a-0e40-40bb-b72e-b42c8984beb9 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8521-feat-add-Painter-Node-2fa6d73d36508124ab2ede449a0cc67a) by [Unito](https://www.unito.io)
This commit is contained in:
359
src/components/painter/WidgetPainter.vue
Normal file
359
src/components/painter/WidgetPainter.vue
Normal file
@@ -0,0 +1,359 @@
|
||||
<template>
|
||||
<div
|
||||
class="widget-expands flex h-full w-full flex-col gap-1"
|
||||
@pointerdown.stop
|
||||
@pointermove.stop
|
||||
@pointerup.stop
|
||||
>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 items-center justify-center overflow-hidden rounded-lg bg-node-component-surface"
|
||||
>
|
||||
<div class="relative max-h-full w-full" :style="canvasContainerStyle">
|
||||
<img
|
||||
v-if="inputImageUrl"
|
||||
:src="inputImageUrl"
|
||||
class="absolute inset-0 size-full"
|
||||
draggable="false"
|
||||
@load="handleInputImageLoad"
|
||||
@dragstart.prevent
|
||||
/>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
class="absolute inset-0 size-full cursor-none touch-none"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointerenter="handlePointerEnter"
|
||||
@pointerleave="handlePointerLeave"
|
||||
/>
|
||||
<div
|
||||
v-show="cursorVisible"
|
||||
class="pointer-events-none absolute left-0 top-0 rounded-full border border-black/60 shadow-[0_0_0_1px_rgba(255,255,255,0.8)]"
|
||||
:style="cursorStyle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isImageInputConnected"
|
||||
class="text-center text-xs text-muted-foreground"
|
||||
>
|
||||
{{ canvasWidth }} x {{ canvasHeight }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="controlsEl"
|
||||
:class="
|
||||
cn(
|
||||
'grid shrink-0 gap-x-1 gap-y-1',
|
||||
compact ? 'grid-cols-1' : 'grid-cols-[auto_1fr]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.tool') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center gap-1 rounded-sm bg-component-node-widget-background p-1"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 self-stretch px-2 text-xs transition-colors',
|
||||
tool === PAINTER_TOOLS.BRUSH
|
||||
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
|
||||
: 'text-node-text-muted hover:text-node-text'
|
||||
)
|
||||
"
|
||||
@click="tool = PAINTER_TOOLS.BRUSH"
|
||||
>
|
||||
{{ $t('painter.brush') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 self-stretch px-2 text-xs transition-colors',
|
||||
tool === PAINTER_TOOLS.ERASER
|
||||
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
|
||||
: 'text-node-text-muted hover:text-node-text'
|
||||
)
|
||||
"
|
||||
@click="tool = PAINTER_TOOLS.ERASER"
|
||||
>
|
||||
{{ $t('painter.eraser') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.size') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[brushSize]"
|
||||
:min="1"
|
||||
:max="200"
|
||||
:step="1"
|
||||
class="flex-1"
|
||||
@update:model-value="(v) => v?.length && (brushSize = v[0])"
|
||||
/>
|
||||
<span class="w-8 text-center text-xs text-node-text-muted">{{
|
||||
brushSize
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="tool === PAINTER_TOOLS.BRUSH">
|
||||
<div
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.color') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 w-full items-center gap-2 rounded-lg bg-component-node-widget-background px-4"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
:value="brushColorDisplay"
|
||||
class="h-4 w-8 cursor-pointer appearance-none overflow-hidden rounded-full border-none bg-transparent [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:border-none [&::-webkit-color-swatch]:rounded-full [&::-moz-color-swatch]:border-none [&::-moz-color-swatch]:rounded-full"
|
||||
@input="
|
||||
(e) => (brushColorDisplay = (e.target as HTMLInputElement).value)
|
||||
"
|
||||
/>
|
||||
<span class="min-w-[4ch] truncate text-xs">{{
|
||||
brushColorDisplay
|
||||
}}</span>
|
||||
<span class="ml-auto flex items-center text-xs text-node-text-muted">
|
||||
<input
|
||||
type="number"
|
||||
:value="brushOpacityPercent"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
class="w-7 appearance-none border-0 bg-transparent text-right text-xs text-node-text-muted outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [-moz-appearance:textfield]"
|
||||
@click.prevent
|
||||
@change="
|
||||
(e) => {
|
||||
const val = Math.min(
|
||||
100,
|
||||
Math.max(0, Number((e.target as HTMLInputElement).value))
|
||||
)
|
||||
brushOpacityPercent = val
|
||||
;(e.target as HTMLInputElement).value = String(val)
|
||||
}
|
||||
"
|
||||
/>%</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.hardness') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[brushHardnessPercent]"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
class="flex-1"
|
||||
@update:model-value="
|
||||
(v) => v?.length && (brushHardnessPercent = v[0])
|
||||
"
|
||||
/>
|
||||
<span class="w-8 text-center text-xs text-node-text-muted"
|
||||
>{{ brushHardnessPercent }}%</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="!isImageInputConnected">
|
||||
<div
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.width') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[canvasWidth]"
|
||||
:min="64"
|
||||
:max="4096"
|
||||
:step="64"
|
||||
class="flex-1"
|
||||
@update:model-value="(v) => v?.length && (canvasWidth = v[0])"
|
||||
/>
|
||||
<span class="w-10 text-center text-xs text-node-text-muted">{{
|
||||
canvasWidth
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.height') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[canvasHeight]"
|
||||
:min="64"
|
||||
:max="4096"
|
||||
:step="64"
|
||||
class="flex-1"
|
||||
@update:model-value="(v) => v?.length && (canvasHeight = v[0])"
|
||||
/>
|
||||
<span class="w-10 text-center text-xs text-node-text-muted">{{
|
||||
canvasHeight
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex w-28 items-center truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('painter.background') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 w-full items-center gap-2 rounded-lg bg-component-node-widget-background px-4"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
:value="backgroundColorDisplay"
|
||||
class="h-4 w-8 cursor-pointer appearance-none overflow-hidden rounded-full border-none bg-transparent [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:border-none [&::-webkit-color-swatch]:rounded-full [&::-moz-color-swatch]:border-none [&::-moz-color-swatch]:rounded-full"
|
||||
@input="
|
||||
(e) =>
|
||||
(backgroundColorDisplay = (e.target as HTMLInputElement).value)
|
||||
"
|
||||
/>
|
||||
<span class="min-w-[4ch] truncate text-xs">{{
|
||||
backgroundColorDisplay
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
:class="
|
||||
cn(
|
||||
'gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground',
|
||||
!compact && 'col-span-2'
|
||||
)
|
||||
"
|
||||
@click="handleClear"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2]" />
|
||||
{{ $t('painter.clear') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import { PAINTER_TOOLS, usePainter } from '@/composables/painter/usePainter'
|
||||
import { toHexFromFormat } from '@/utils/colorUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { nodeId } = defineProps<{
|
||||
nodeId: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '' })
|
||||
|
||||
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
|
||||
const controlsEl = useTemplateRef<HTMLDivElement>('controlsEl')
|
||||
const { width: controlsWidth } = useElementSize(controlsEl)
|
||||
const compact = computed(
|
||||
() => controlsWidth.value > 0 && controlsWidth.value < 350
|
||||
)
|
||||
|
||||
const {
|
||||
tool,
|
||||
brushSize,
|
||||
brushColor,
|
||||
brushOpacity,
|
||||
brushHardness,
|
||||
backgroundColor,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
cursorX,
|
||||
cursorY,
|
||||
cursorVisible,
|
||||
displayBrushSize,
|
||||
inputImageUrl,
|
||||
isImageInputConnected,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handlePointerEnter,
|
||||
handlePointerLeave,
|
||||
handleInputImageLoad,
|
||||
handleClear
|
||||
} = usePainter(nodeId, { canvasEl, modelValue })
|
||||
|
||||
const canvasContainerStyle = computed(() => ({
|
||||
aspectRatio: `${canvasWidth.value} / ${canvasHeight.value}`,
|
||||
backgroundColor: isImageInputConnected.value
|
||||
? undefined
|
||||
: backgroundColor.value
|
||||
}))
|
||||
|
||||
const cursorStyle = computed(() => {
|
||||
const size = displayBrushSize.value
|
||||
const x = cursorX.value - size / 2
|
||||
const y = cursorY.value - size / 2
|
||||
return {
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
transform: `translate(${x}px, ${y}px)`
|
||||
}
|
||||
})
|
||||
|
||||
const brushOpacityPercent = computed({
|
||||
get: () => Math.round(brushOpacity.value * 100),
|
||||
set: (val: number) => {
|
||||
brushOpacity.value = val / 100
|
||||
}
|
||||
})
|
||||
|
||||
const brushHardnessPercent = computed({
|
||||
get: () => Math.round(brushHardness.value * 100),
|
||||
set: (val: number) => {
|
||||
brushHardness.value = val / 100
|
||||
}
|
||||
})
|
||||
|
||||
const brushColorDisplay = computed({
|
||||
get: () => toHexFromFormat(brushColor.value, 'hex'),
|
||||
set: (val: unknown) => {
|
||||
brushColor.value = toHexFromFormat(val, 'hex')
|
||||
}
|
||||
})
|
||||
|
||||
const backgroundColorDisplay = computed({
|
||||
get: () => toHexFromFormat(backgroundColor.value, 'hex'),
|
||||
set: (val: unknown) => {
|
||||
backgroundColor.value = toHexFromFormat(val, 'hex')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
780
src/composables/painter/usePainter.ts
Normal file
780
src/composables/painter/usePainter.ts
Normal file
@@ -0,0 +1,780 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
getEffectiveBrushSize,
|
||||
getEffectiveHardness
|
||||
} from '@/composables/maskeditor/brushUtils'
|
||||
import { StrokeProcessor } from '@/composables/maskeditor/StrokeProcessor'
|
||||
import { hexToRgb } from '@/utils/colorUtil'
|
||||
import type { Point } from '@/extensions/core/maskeditor/types'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
type PainterTool = 'brush' | 'eraser'
|
||||
|
||||
export const PAINTER_TOOLS: Record<string, PainterTool> = {
|
||||
BRUSH: 'brush',
|
||||
ERASER: 'eraser'
|
||||
} as const
|
||||
|
||||
interface UsePainterOptions {
|
||||
canvasEl: Ref<HTMLCanvasElement | null>
|
||||
modelValue: Ref<string>
|
||||
}
|
||||
|
||||
export function usePainter(nodeId: string, options: UsePainterOptions) {
|
||||
const { canvasEl, modelValue } = options
|
||||
const { t } = useI18n()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const isDirty = ref(false)
|
||||
|
||||
const canvasWidth = ref(512)
|
||||
const canvasHeight = ref(512)
|
||||
|
||||
const cursorX = ref(0)
|
||||
const cursorY = ref(0)
|
||||
const cursorVisible = ref(false)
|
||||
|
||||
const inputImageUrl = ref<string | null>(null)
|
||||
const isImageInputConnected = ref(false)
|
||||
|
||||
let isDrawing = false
|
||||
let strokeProcessor: StrokeProcessor | null = null
|
||||
let lastPoint: Point | null = null
|
||||
|
||||
let cachedRect: DOMRect | null = null
|
||||
|
||||
let mainCtx: CanvasRenderingContext2D | null = null
|
||||
|
||||
let strokeCanvas: HTMLCanvasElement | null = null
|
||||
let strokeCtx: CanvasRenderingContext2D | null = null
|
||||
|
||||
let baseCanvas: HTMLCanvasElement | null = null
|
||||
let baseCtx: CanvasRenderingContext2D | null = null
|
||||
let hasBaseSnapshot = false
|
||||
let hasStrokes = false
|
||||
|
||||
let dirtyX0 = 0
|
||||
let dirtyY0 = 0
|
||||
let dirtyX1 = 0
|
||||
let dirtyY1 = 0
|
||||
let hasDirtyRect = false
|
||||
|
||||
let strokeBrush: {
|
||||
radius: number
|
||||
effectiveRadius: number
|
||||
effectiveHardness: number
|
||||
hardness: number
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
} | null = null
|
||||
|
||||
const litegraphNode = computed(() => {
|
||||
if (!nodeId || !app.canvas.graph) return null
|
||||
return app.canvas.graph.getNodeById(nodeId) as LGraphNode | null
|
||||
})
|
||||
|
||||
function getWidgetByName(name: string): IBaseWidget | undefined {
|
||||
return litegraphNode.value?.widgets?.find(
|
||||
(w: IBaseWidget) => w.name === name
|
||||
)
|
||||
}
|
||||
|
||||
const tool = ref<PainterTool>(PAINTER_TOOLS.BRUSH)
|
||||
const brushSize = ref(20)
|
||||
const brushColor = ref('#ffffff')
|
||||
const brushOpacity = ref(1)
|
||||
const brushHardness = ref(1)
|
||||
const backgroundColor = ref('#000000')
|
||||
|
||||
function restoreSettingsFromProperties() {
|
||||
const node = litegraphNode.value
|
||||
if (!node) return
|
||||
|
||||
const props = node.properties
|
||||
if (props.painterTool != null) tool.value = props.painterTool as PainterTool
|
||||
if (props.painterBrushSize != null)
|
||||
brushSize.value = props.painterBrushSize as number
|
||||
if (props.painterBrushColor != null)
|
||||
brushColor.value = props.painterBrushColor as string
|
||||
if (props.painterBrushOpacity != null)
|
||||
brushOpacity.value = props.painterBrushOpacity as number
|
||||
if (props.painterBrushHardness != null)
|
||||
brushHardness.value = props.painterBrushHardness as number
|
||||
|
||||
const bgColorWidget = getWidgetByName('bg_color')
|
||||
if (bgColorWidget) backgroundColor.value = bgColorWidget.value as string
|
||||
}
|
||||
|
||||
function saveSettingsToProperties() {
|
||||
const node = litegraphNode.value
|
||||
if (!node) return
|
||||
|
||||
node.properties.painterTool = tool.value
|
||||
node.properties.painterBrushSize = brushSize.value
|
||||
node.properties.painterBrushColor = brushColor.value
|
||||
node.properties.painterBrushOpacity = brushOpacity.value
|
||||
node.properties.painterBrushHardness = brushHardness.value
|
||||
}
|
||||
|
||||
function syncCanvasSizeToWidgets() {
|
||||
const widthWidget = getWidgetByName('width')
|
||||
const heightWidget = getWidgetByName('height')
|
||||
|
||||
if (widthWidget && widthWidget.value !== canvasWidth.value) {
|
||||
widthWidget.value = canvasWidth.value
|
||||
widthWidget.callback?.(canvasWidth.value)
|
||||
}
|
||||
if (heightWidget && heightWidget.value !== canvasHeight.value) {
|
||||
heightWidget.value = canvasHeight.value
|
||||
heightWidget.callback?.(canvasHeight.value)
|
||||
}
|
||||
}
|
||||
|
||||
function syncBackgroundColorToWidget() {
|
||||
const bgColorWidget = getWidgetByName('bg_color')
|
||||
if (bgColorWidget && bgColorWidget.value !== backgroundColor.value) {
|
||||
bgColorWidget.value = backgroundColor.value
|
||||
bgColorWidget.callback?.(backgroundColor.value)
|
||||
}
|
||||
}
|
||||
|
||||
function updateInputImageUrl() {
|
||||
const node = litegraphNode.value
|
||||
if (!node) {
|
||||
inputImageUrl.value = null
|
||||
isImageInputConnected.value = false
|
||||
return
|
||||
}
|
||||
|
||||
isImageInputConnected.value = node.isInputConnected(0)
|
||||
|
||||
const inputNode = node.getInputNode(0)
|
||||
if (!inputNode) {
|
||||
inputImageUrl.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const urls = nodeOutputStore.getNodeImageUrls(inputNode)
|
||||
inputImageUrl.value = urls?.length ? urls[0] : null
|
||||
}
|
||||
|
||||
function syncCanvasSizeFromWidgets() {
|
||||
const w = getWidgetByName('width')
|
||||
const h = getWidgetByName('height')
|
||||
canvasWidth.value = (w?.value as number) ?? 512
|
||||
canvasHeight.value = (h?.value as number) ?? 512
|
||||
}
|
||||
|
||||
function activeHardness(): number {
|
||||
return tool.value === PAINTER_TOOLS.ERASER ? 1 : brushHardness.value
|
||||
}
|
||||
|
||||
const { width: canvasDisplayWidth } = useElementSize(canvasEl)
|
||||
|
||||
const displayBrushSize = computed(() => {
|
||||
if (!canvasDisplayWidth.value || !canvasWidth.value) return brushSize.value
|
||||
|
||||
const radius = brushSize.value / 2
|
||||
const effectiveRadius = getEffectiveBrushSize(radius, activeHardness())
|
||||
const effectiveDiameter = effectiveRadius * 2
|
||||
return effectiveDiameter * (canvasDisplayWidth.value / canvasWidth.value)
|
||||
})
|
||||
|
||||
function getCtx() {
|
||||
if (!mainCtx && canvasEl.value) {
|
||||
mainCtx = canvasEl.value.getContext('2d') ?? null
|
||||
}
|
||||
return mainCtx
|
||||
}
|
||||
|
||||
function cacheCanvasRect() {
|
||||
const el = canvasEl.value
|
||||
if (el) cachedRect = el.getBoundingClientRect()
|
||||
}
|
||||
|
||||
function getCanvasPoint(e: PointerEvent): Point | null {
|
||||
const el = canvasEl.value
|
||||
if (!el) return null
|
||||
const rect = cachedRect ?? el.getBoundingClientRect()
|
||||
return {
|
||||
x: ((e.clientX - rect.left) / rect.width) * el.width,
|
||||
y: ((e.clientY - rect.top) / rect.height) * el.height
|
||||
}
|
||||
}
|
||||
|
||||
function expandDirtyRect(cx: number, cy: number, r: number) {
|
||||
const x0 = cx - r
|
||||
const y0 = cy - r
|
||||
const x1 = cx + r
|
||||
const y1 = cy + r
|
||||
if (!hasDirtyRect) {
|
||||
dirtyX0 = x0
|
||||
dirtyY0 = y0
|
||||
dirtyX1 = x1
|
||||
dirtyY1 = y1
|
||||
hasDirtyRect = true
|
||||
} else {
|
||||
if (x0 < dirtyX0) dirtyX0 = x0
|
||||
if (y0 < dirtyY0) dirtyY0 = y0
|
||||
if (x1 > dirtyX1) dirtyX1 = x1
|
||||
if (y1 > dirtyY1) dirtyY1 = y1
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotBrush() {
|
||||
const radius = brushSize.value / 2
|
||||
const hardness = activeHardness()
|
||||
const effectiveRadius = getEffectiveBrushSize(radius, hardness)
|
||||
strokeBrush = {
|
||||
radius,
|
||||
effectiveRadius,
|
||||
effectiveHardness: getEffectiveHardness(
|
||||
radius,
|
||||
hardness,
|
||||
effectiveRadius
|
||||
),
|
||||
hardness,
|
||||
...hexToRgb(brushColor.value)
|
||||
}
|
||||
}
|
||||
|
||||
function drawCircle(ctx: CanvasRenderingContext2D, point: Point) {
|
||||
const b = strokeBrush!
|
||||
|
||||
expandDirtyRect(point.x, point.y, b.effectiveRadius)
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(point.x, point.y, b.effectiveRadius, 0, Math.PI * 2)
|
||||
|
||||
if (b.hardness < 1) {
|
||||
const gradient = ctx.createRadialGradient(
|
||||
point.x,
|
||||
point.y,
|
||||
0,
|
||||
point.x,
|
||||
point.y,
|
||||
b.effectiveRadius
|
||||
)
|
||||
gradient.addColorStop(0, `rgba(${b.r}, ${b.g}, ${b.b}, 1)`)
|
||||
gradient.addColorStop(
|
||||
b.effectiveHardness,
|
||||
`rgba(${b.r}, ${b.g}, ${b.b}, 1)`
|
||||
)
|
||||
gradient.addColorStop(1, `rgba(${b.r}, ${b.g}, ${b.b}, 0)`)
|
||||
ctx.fillStyle = gradient
|
||||
}
|
||||
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
function drawSegment(ctx: CanvasRenderingContext2D, from: Point, to: Point) {
|
||||
const b = strokeBrush!
|
||||
|
||||
if (b.hardness < 1) {
|
||||
const dx = to.x - from.x
|
||||
const dy = to.y - from.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const step = Math.max(1, b.effectiveRadius / 2)
|
||||
|
||||
if (dist > 0) {
|
||||
const steps = Math.ceil(dist / step)
|
||||
const dabPoint: Point = { x: 0, y: 0 }
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = i / steps
|
||||
dabPoint.x = from.x + dx * t
|
||||
dabPoint.y = from.y + dy * t
|
||||
drawCircle(ctx, dabPoint)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
expandDirtyRect(from.x, from.y, b.effectiveRadius)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(from.x, from.y)
|
||||
ctx.lineTo(to.x, to.y)
|
||||
ctx.stroke()
|
||||
drawCircle(ctx, to)
|
||||
}
|
||||
}
|
||||
|
||||
function applyBrushStyle(ctx: CanvasRenderingContext2D) {
|
||||
const b = strokeBrush!
|
||||
const color = `rgb(${b.r}, ${b.g}, ${b.b})`
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
ctx.globalAlpha = 1
|
||||
ctx.fillStyle = color
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = b.effectiveRadius * 2
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
}
|
||||
|
||||
function ensureStrokeCanvas() {
|
||||
const el = canvasEl.value
|
||||
if (!el) return null
|
||||
|
||||
if (
|
||||
!strokeCanvas ||
|
||||
strokeCanvas.width !== el.width ||
|
||||
strokeCanvas.height !== el.height
|
||||
) {
|
||||
strokeCanvas = document.createElement('canvas')
|
||||
strokeCanvas.width = el.width
|
||||
strokeCanvas.height = el.height
|
||||
strokeCtx = strokeCanvas.getContext('2d')
|
||||
}
|
||||
|
||||
strokeCtx?.clearRect(0, 0, strokeCanvas.width, strokeCanvas.height)
|
||||
return strokeCtx
|
||||
}
|
||||
|
||||
function ensureBaseCanvas() {
|
||||
const el = canvasEl.value
|
||||
if (!el) return null
|
||||
|
||||
if (
|
||||
!baseCanvas ||
|
||||
baseCanvas.width !== el.width ||
|
||||
baseCanvas.height !== el.height
|
||||
) {
|
||||
baseCanvas = document.createElement('canvas')
|
||||
baseCanvas.width = el.width
|
||||
baseCanvas.height = el.height
|
||||
baseCtx = baseCanvas.getContext('2d')
|
||||
}
|
||||
|
||||
return baseCtx
|
||||
}
|
||||
|
||||
function compositeStrokeToMain(isPreview: boolean = false) {
|
||||
const el = canvasEl.value
|
||||
const ctx = getCtx()
|
||||
if (!el || !ctx || !strokeCanvas) return
|
||||
|
||||
const useDirty = hasDirtyRect
|
||||
const sx = Math.max(0, Math.floor(dirtyX0))
|
||||
const sy = Math.max(0, Math.floor(dirtyY0))
|
||||
const sr = Math.min(el.width, Math.ceil(dirtyX1))
|
||||
const sb = Math.min(el.height, Math.ceil(dirtyY1))
|
||||
const sw = sr - sx
|
||||
const sh = sb - sy
|
||||
hasDirtyRect = false
|
||||
|
||||
if (hasBaseSnapshot && baseCanvas) {
|
||||
if (useDirty && sw > 0 && sh > 0) {
|
||||
ctx.clearRect(sx, sy, sw, sh)
|
||||
ctx.drawImage(baseCanvas, sx, sy, sw, sh, sx, sy, sw, sh)
|
||||
} else {
|
||||
ctx.clearRect(0, 0, el.width, el.height)
|
||||
ctx.drawImage(baseCanvas, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.save()
|
||||
const isEraser = tool.value === PAINTER_TOOLS.ERASER
|
||||
ctx.globalAlpha = isEraser ? 1 : brushOpacity.value
|
||||
ctx.globalCompositeOperation = isEraser ? 'destination-out' : 'source-over'
|
||||
if (useDirty && sw > 0 && sh > 0) {
|
||||
ctx.drawImage(strokeCanvas, sx, sy, sw, sh, sx, sy, sw, sh)
|
||||
} else {
|
||||
ctx.drawImage(strokeCanvas, 0, 0)
|
||||
}
|
||||
ctx.restore()
|
||||
|
||||
if (!isPreview) {
|
||||
hasBaseSnapshot = false
|
||||
}
|
||||
}
|
||||
|
||||
function startStroke(e: PointerEvent) {
|
||||
const point = getCanvasPoint(e)
|
||||
if (!point) return
|
||||
|
||||
const el = canvasEl.value
|
||||
if (!el) return
|
||||
|
||||
const bCtx = ensureBaseCanvas()
|
||||
if (bCtx) {
|
||||
bCtx.clearRect(0, 0, el.width, el.height)
|
||||
bCtx.drawImage(el, 0, 0)
|
||||
hasBaseSnapshot = true
|
||||
}
|
||||
|
||||
isDrawing = true
|
||||
isDirty.value = true
|
||||
hasStrokes = true
|
||||
snapshotBrush()
|
||||
strokeProcessor = new StrokeProcessor(Math.max(1, strokeBrush!.radius / 2))
|
||||
strokeProcessor.addPoint(point)
|
||||
lastPoint = point
|
||||
|
||||
const ctx = ensureStrokeCanvas()
|
||||
if (!ctx) return
|
||||
ctx.save()
|
||||
applyBrushStyle(ctx)
|
||||
drawCircle(ctx, point)
|
||||
ctx.restore()
|
||||
|
||||
compositeStrokeToMain(true)
|
||||
}
|
||||
|
||||
function continueStroke(e: PointerEvent) {
|
||||
if (!isDrawing || !strokeProcessor || !strokeCtx) return
|
||||
|
||||
const point = getCanvasPoint(e)
|
||||
if (!point) return
|
||||
|
||||
const points = strokeProcessor.addPoint(point)
|
||||
if (points.length === 0 && lastPoint) {
|
||||
points.push(point)
|
||||
}
|
||||
|
||||
if (points.length === 0) return
|
||||
|
||||
strokeCtx.save()
|
||||
applyBrushStyle(strokeCtx)
|
||||
|
||||
let prev = lastPoint ?? points[0]
|
||||
for (const p of points) {
|
||||
drawSegment(strokeCtx, prev, p)
|
||||
prev = p
|
||||
}
|
||||
lastPoint = prev
|
||||
|
||||
strokeCtx.restore()
|
||||
|
||||
compositeStrokeToMain(true)
|
||||
}
|
||||
|
||||
function endStroke() {
|
||||
if (!isDrawing || !strokeProcessor) return
|
||||
|
||||
const points = strokeProcessor.endStroke()
|
||||
if (strokeCtx && points.length > 0) {
|
||||
strokeCtx.save()
|
||||
applyBrushStyle(strokeCtx)
|
||||
let prev = lastPoint ?? points[0]
|
||||
for (const p of points) {
|
||||
drawSegment(strokeCtx, prev, p)
|
||||
prev = p
|
||||
}
|
||||
strokeCtx.restore()
|
||||
}
|
||||
|
||||
compositeStrokeToMain()
|
||||
|
||||
isDrawing = false
|
||||
strokeProcessor = null
|
||||
strokeBrush = null
|
||||
lastPoint = null
|
||||
}
|
||||
|
||||
function resizeCanvas() {
|
||||
const el = canvasEl.value
|
||||
if (!el) return
|
||||
|
||||
let tmp: HTMLCanvasElement | null = null
|
||||
if (el.width > 0 && el.height > 0) {
|
||||
tmp = document.createElement('canvas')
|
||||
tmp.width = el.width
|
||||
tmp.height = el.height
|
||||
tmp.getContext('2d')!.drawImage(el, 0, 0)
|
||||
}
|
||||
|
||||
el.width = canvasWidth.value
|
||||
el.height = canvasHeight.value
|
||||
mainCtx = null
|
||||
|
||||
if (tmp) {
|
||||
getCtx()?.drawImage(tmp, 0, 0)
|
||||
}
|
||||
|
||||
strokeCanvas = null
|
||||
strokeCtx = null
|
||||
baseCanvas = null
|
||||
baseCtx = null
|
||||
hasBaseSnapshot = false
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
const el = canvasEl.value
|
||||
const ctx = getCtx()
|
||||
if (!el || !ctx) return
|
||||
ctx.clearRect(0, 0, el.width, el.height)
|
||||
isDirty.value = true
|
||||
hasStrokes = false
|
||||
}
|
||||
|
||||
function updateCursorPos(e: PointerEvent) {
|
||||
cursorX.value = e.offsetX
|
||||
cursorY.value = e.offsetY
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||
cacheCanvasRect()
|
||||
updateCursorPos(e)
|
||||
startStroke(e)
|
||||
}
|
||||
|
||||
let pendingMoveEvent: PointerEvent | null = null
|
||||
let rafId: number | null = null
|
||||
|
||||
function flushPendingStroke() {
|
||||
if (pendingMoveEvent) {
|
||||
continueStroke(pendingMoveEvent)
|
||||
pendingMoveEvent = null
|
||||
}
|
||||
rafId = null
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
updateCursorPos(e)
|
||||
if (!isDrawing) return
|
||||
|
||||
pendingMoveEvent = e
|
||||
if (!rafId) {
|
||||
rafId = requestAnimationFrame(flushPendingStroke)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerUp(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
flushPendingStroke()
|
||||
}
|
||||
;(e.target as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
endStroke()
|
||||
}
|
||||
|
||||
function handlePointerLeave() {
|
||||
cursorVisible.value = false
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
flushPendingStroke()
|
||||
}
|
||||
endStroke()
|
||||
}
|
||||
|
||||
function handlePointerEnter() {
|
||||
cursorVisible.value = true
|
||||
}
|
||||
|
||||
function handleInputImageLoad(e: Event) {
|
||||
const img = e.target as HTMLImageElement
|
||||
const widthWidget = getWidgetByName('width')
|
||||
const heightWidget = getWidgetByName('height')
|
||||
if (widthWidget) {
|
||||
widthWidget.value = img.naturalWidth
|
||||
widthWidget.callback?.(img.naturalWidth)
|
||||
}
|
||||
if (heightWidget) {
|
||||
heightWidget.value = img.naturalHeight
|
||||
heightWidget.callback?.(img.naturalHeight)
|
||||
}
|
||||
canvasWidth.value = img.naturalWidth
|
||||
canvasHeight.value = img.naturalHeight
|
||||
}
|
||||
|
||||
function parseMaskFilename(value: string): {
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
} | null {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const typeMatch = trimmed.match(/^(.+?) \[([^\]]+)\]$/)
|
||||
const pathPart = typeMatch ? typeMatch[1] : trimmed
|
||||
const type = typeMatch ? typeMatch[2] : 'input'
|
||||
|
||||
const lastSlash = pathPart.lastIndexOf('/')
|
||||
const subfolder = lastSlash !== -1 ? pathPart.substring(0, lastSlash) : ''
|
||||
const filename =
|
||||
lastSlash !== -1 ? pathPart.substring(lastSlash + 1) : pathPart
|
||||
|
||||
return { filename, subfolder, type }
|
||||
}
|
||||
|
||||
function isCanvasEmpty(): boolean {
|
||||
return !hasStrokes
|
||||
}
|
||||
|
||||
async function serializeValue(): Promise<string> {
|
||||
const el = canvasEl.value
|
||||
if (!el) return ''
|
||||
|
||||
if (isCanvasEmpty()) return ''
|
||||
|
||||
if (!isDirty.value) return modelValue.value
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
el.toBlob(resolve, 'image/png')
|
||||
)
|
||||
if (!blob) return modelValue.value
|
||||
|
||||
const name = `painter-${nodeId}-${Date.now()}.png`
|
||||
const body = new FormData()
|
||||
body.append('image', blob, name)
|
||||
if (!isCloud) body.append('subfolder', 'painter')
|
||||
body.append('type', isCloud ? 'input' : 'temp')
|
||||
|
||||
let resp: Response
|
||||
try {
|
||||
resp = await api.fetchApi('/upload/image', {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
} catch (e) {
|
||||
const err = t('painter.uploadError', {
|
||||
status: 0,
|
||||
statusText: e instanceof Error ? e.message : String(e)
|
||||
})
|
||||
toastStore.addAlert(err)
|
||||
throw new Error(err)
|
||||
}
|
||||
|
||||
if (resp.status !== 200) {
|
||||
const err = t('painter.uploadError', {
|
||||
status: resp.status,
|
||||
statusText: resp.statusText
|
||||
})
|
||||
toastStore.addAlert(err)
|
||||
throw new Error(err)
|
||||
}
|
||||
|
||||
let data: { name: string }
|
||||
try {
|
||||
data = await resp.json()
|
||||
} catch (e) {
|
||||
const err = t('painter.uploadError', {
|
||||
status: resp.status,
|
||||
statusText: e instanceof Error ? e.message : String(e)
|
||||
})
|
||||
toastStore.addAlert(err)
|
||||
throw new Error(err)
|
||||
}
|
||||
|
||||
const result = isCloud
|
||||
? `${data.name} [input]`
|
||||
: `painter/${data.name} [temp]`
|
||||
modelValue.value = result
|
||||
isDirty.value = false
|
||||
return result
|
||||
}
|
||||
|
||||
function registerWidgetSerialization() {
|
||||
const node = litegraphNode.value
|
||||
if (!node?.widgets) return
|
||||
const targetWidget = node.widgets.find(
|
||||
(w: IBaseWidget) => w.name === 'mask'
|
||||
)
|
||||
if (targetWidget) {
|
||||
targetWidget.serializeValue = serializeValue
|
||||
}
|
||||
}
|
||||
|
||||
function restoreCanvas() {
|
||||
const parsed = parseMaskFilename(modelValue.value)
|
||||
if (!parsed) return
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('filename', parsed.filename)
|
||||
if (parsed.subfolder) params.set('subfolder', parsed.subfolder)
|
||||
params.set('type', parsed.type)
|
||||
|
||||
const url = api.apiURL('/view?' + params.toString())
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => {
|
||||
const el = canvasEl.value
|
||||
if (!el) return
|
||||
canvasWidth.value = img.naturalWidth
|
||||
canvasHeight.value = img.naturalHeight
|
||||
mainCtx = null
|
||||
getCtx()?.drawImage(img, 0, 0)
|
||||
isDirty.value = false
|
||||
hasStrokes = true
|
||||
}
|
||||
img.onerror = () => {
|
||||
modelValue.value = ''
|
||||
}
|
||||
img.src = url
|
||||
}
|
||||
|
||||
watch(() => nodeOutputStore.nodeOutputs, updateInputImageUrl, { deep: true })
|
||||
watch(() => nodeOutputStore.nodePreviewImages, updateInputImageUrl, {
|
||||
deep: true
|
||||
})
|
||||
watch([canvasWidth, canvasHeight], resizeCanvas)
|
||||
|
||||
watch(
|
||||
[tool, brushSize, brushColor, brushOpacity, brushHardness],
|
||||
saveSettingsToProperties
|
||||
)
|
||||
|
||||
watch([canvasWidth, canvasHeight], syncCanvasSizeToWidgets)
|
||||
|
||||
watch(backgroundColor, syncBackgroundColorToWidget)
|
||||
|
||||
function initialize() {
|
||||
syncCanvasSizeFromWidgets()
|
||||
resizeCanvas()
|
||||
registerWidgetSerialization()
|
||||
restoreSettingsFromProperties()
|
||||
updateInputImageUrl()
|
||||
restoreCanvas()
|
||||
}
|
||||
|
||||
onMounted(initialize)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
tool,
|
||||
brushSize,
|
||||
brushColor,
|
||||
brushOpacity,
|
||||
brushHardness,
|
||||
backgroundColor,
|
||||
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
|
||||
cursorX,
|
||||
cursorY,
|
||||
cursorVisible,
|
||||
displayBrushSize,
|
||||
|
||||
inputImageUrl,
|
||||
isImageInputConnected,
|
||||
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handlePointerEnter,
|
||||
handlePointerLeave,
|
||||
|
||||
handleInputImageLoad,
|
||||
handleClear
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ if (!isCloud) {
|
||||
await import('./nodeTemplates')
|
||||
}
|
||||
import './noteNode'
|
||||
import './painter'
|
||||
import './previewAny'
|
||||
import './rerouteNode'
|
||||
import './saveImageExtraOutput'
|
||||
|
||||
22
src/extensions/core/painter.ts
Normal file
22
src/extensions/core/painter.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
const HIDDEN_WIDGETS = new Set(['width', 'height', 'bg_color'])
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Painter',
|
||||
|
||||
nodeCreated(node) {
|
||||
if (node.constructor.comfyClass !== 'Painter') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
node.setSize([Math.max(oldWidth, 450), Math.max(oldHeight, 550)])
|
||||
|
||||
node.hideOutputImages = true
|
||||
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (HIDDEN_WIDGETS.has(widget.name)) {
|
||||
widget.hidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -137,6 +137,7 @@ export type IWidget =
|
||||
| IImageCropWidget
|
||||
| IBoundingBoxWidget
|
||||
| ICurveWidget
|
||||
| IPainterWidget
|
||||
|
||||
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
|
||||
type: 'toggle'
|
||||
@@ -336,6 +337,11 @@ export interface ICurveWidget extends IBaseWidget<CurvePoint[], 'curve'> {
|
||||
value: CurvePoint[]
|
||||
}
|
||||
|
||||
export interface IPainterWidget extends IBaseWidget<string, 'painter'> {
|
||||
type: 'painter'
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
|
||||
* Override linkedWidgets[]
|
||||
|
||||
22
src/lib/litegraph/src/widgets/PainterWidget.ts
Normal file
22
src/lib/litegraph/src/widgets/PainterWidget.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { IPainterWidget } from '../types/widgets'
|
||||
import { BaseWidget } from './BaseWidget'
|
||||
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
|
||||
|
||||
/**
|
||||
* Widget for the Painter node canvas drawing tool.
|
||||
* This is a widget that only has a Vue widgets implementation.
|
||||
*/
|
||||
export class PainterWidget
|
||||
extends BaseWidget<IPainterWidget>
|
||||
implements IPainterWidget
|
||||
{
|
||||
override type = 'painter' as const
|
||||
|
||||
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
|
||||
this.drawVueOnlyWarning(ctx, options, 'Painter')
|
||||
}
|
||||
|
||||
onClick(_options: WidgetEventOptions): void {
|
||||
// This is a widget that only has a Vue widgets implementation
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { FileUploadWidget } from './FileUploadWidget'
|
||||
import { GalleriaWidget } from './GalleriaWidget'
|
||||
import { GradientSliderWidget } from './GradientSliderWidget'
|
||||
import { ImageCompareWidget } from './ImageCompareWidget'
|
||||
import { PainterWidget } from './PainterWidget'
|
||||
import { ImageCropWidget } from './ImageCropWidget'
|
||||
import { KnobWidget } from './KnobWidget'
|
||||
import { LegacyWidget } from './LegacyWidget'
|
||||
@@ -58,6 +59,7 @@ export type WidgetTypeMap = {
|
||||
imagecrop: ImageCropWidget
|
||||
boundingbox: BoundingBoxWidget
|
||||
curve: CurveWidget
|
||||
painter: PainterWidget
|
||||
[key: string]: BaseWidget
|
||||
}
|
||||
|
||||
@@ -136,6 +138,8 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
|
||||
return toClass(BoundingBoxWidget, narrowedWidget, node)
|
||||
case 'curve':
|
||||
return toClass(CurveWidget, narrowedWidget, node)
|
||||
case 'painter':
|
||||
return toClass(PainterWidget, narrowedWidget, node)
|
||||
default: {
|
||||
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
|
||||
}
|
||||
|
||||
@@ -1888,6 +1888,19 @@
|
||||
"unlockRatio": "Unlock aspect ratio",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"painter": {
|
||||
"tool": "Tool",
|
||||
"brush": "Brush",
|
||||
"eraser": "Eraser",
|
||||
"size": "Cursor Size",
|
||||
"color": "Color Picker",
|
||||
"hardness": "Hardness",
|
||||
"width": "Width",
|
||||
"height": "Height",
|
||||
"background": "Background",
|
||||
"clear": "Clear",
|
||||
"uploadError": "Failed to upload painter image: {status} - {statusText}"
|
||||
},
|
||||
"boundingBox": {
|
||||
"x": "X",
|
||||
"y": "Y",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
export const usePainterWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): IBaseWidget => {
|
||||
return node.addWidget(
|
||||
'painter',
|
||||
inputSpec.name,
|
||||
(inputSpec.default as string) ?? '',
|
||||
null,
|
||||
{ serialize: true, canvasOnly: false }
|
||||
) as IBaseWidget
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,9 @@ const WidgetBoundingBox = defineAsyncComponent(
|
||||
const WidgetCurve = defineAsyncComponent(
|
||||
() => import('@/components/curve/WidgetCurve.vue')
|
||||
)
|
||||
const WidgetPainter = defineAsyncComponent(
|
||||
() => import('@/components/painter/WidgetPainter.vue')
|
||||
)
|
||||
|
||||
export const FOR_TESTING = {
|
||||
WidgetButton,
|
||||
@@ -186,6 +189,14 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
aliases: ['CURVE'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'painter',
|
||||
{
|
||||
component: WidgetPainter,
|
||||
aliases: ['PAINTER'],
|
||||
essential: false
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -217,7 +228,13 @@ export const shouldRenderAsVue = (widget: Partial<SafeWidgetData>): boolean => {
|
||||
return !widget.options?.canvasOnly && !!widget.type
|
||||
}
|
||||
|
||||
const EXPANDING_TYPES = ['textarea', 'markdown', 'load3D', 'curve'] as const
|
||||
const EXPANDING_TYPES = [
|
||||
'textarea',
|
||||
'markdown',
|
||||
'load3D',
|
||||
'curve',
|
||||
'painter'
|
||||
] as const
|
||||
|
||||
export function shouldExpand(type: string): boolean {
|
||||
const canonicalType = getCanonicalType(type)
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useImageCompareWidget } from '@/renderer/extensions/vueNodes/widgets/co
|
||||
import { useImageUploadWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget'
|
||||
import { useIntWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useIntWidget'
|
||||
import { useMarkdownWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget'
|
||||
import { usePainterWidget } from '@/renderer/extensions/vueNodes/widgets/composables/usePainterWidget'
|
||||
import { useStringWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useStringWidget'
|
||||
import { useTextareaWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useTextareaWidget'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
@@ -306,6 +307,7 @@ export const ComfyWidgets = {
|
||||
BOUNDING_BOX: transformWidgetConstructorV2ToV1(useBoundingBoxWidget()),
|
||||
CHART: transformWidgetConstructorV2ToV1(useChartWidget()),
|
||||
GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()),
|
||||
PAINTER: transformWidgetConstructorV2ToV1(usePainterWidget()),
|
||||
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),
|
||||
CURVE: transformWidgetConstructorV2ToV1(useCurveWidget()),
|
||||
...dynamicWidgets
|
||||
|
||||
Reference in New Issue
Block a user