feat: Add visual crop preview widget for ImageCrop node - widget ImageCrop (#7825)

## Summary

Another implementation for image crop node, alternative for
https://github.com/Comfy-Org/ComfyUI_frontend/pull/7014
As discussed with @christian-byrne and @DrJKL we could have single
widget - IMAGECROP with 4 ints and UI preview.

However, this solution requires changing the definition of image crop
node in BE (sent
[here](https://github.com/comfyanonymous/ComfyUI/pull/11594)), which
will break the exsiting workflow, also it would not allow connect
separate int node as input, I am not sure it is a good idea.

So I keep two PRs openned for references

## Screenshots


https://github.com/user-attachments/assets/fde6938c-4395-48f6-ac05-6282c5eb8157

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7825-feat-Add-visual-crop-preview-widget-for-ImageCrop-node-widget-ImageCrop-2dc6d73d3650812bb8a2cdff4615032b)
by [Unito](https://www.unito.io)
This commit is contained in:
Terry Jia
2026-01-17 17:09:16 -05:00
committed by GitHub
parent de2e37ec8e
commit be8916b4ce
17 changed files with 932 additions and 63 deletions

View File

@@ -0,0 +1,82 @@
<template>
<div class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1">
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.x') }}
</label>
<input
v-model.number="x"
type="number"
:min="0"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.y') }}
</label>
<input
v-model.number="y"
type="number"
:min="0"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.width') }}
</label>
<input
v-model.number="width"
type="number"
:min="1"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.height') }}
</label>
<input
v-model.number="height"
type="number"
:min="1"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Bounds } from '@/renderer/core/layout/types'
const modelValue = defineModel<Bounds>({
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
})
const x = computed({
get: () => modelValue.value.x,
set: (x) => {
modelValue.value = { ...modelValue.value, x }
}
})
const y = computed({
get: () => modelValue.value.y,
set: (y) => {
modelValue.value = { ...modelValue.value, y }
}
})
const width = computed({
get: () => modelValue.value.width,
set: (width) => {
modelValue.value = { ...modelValue.value, width }
}
})
const height = computed({
get: () => modelValue.value.height,
set: (height) => {
modelValue.value = { ...modelValue.value, height }
}
})
</script>

View File

@@ -0,0 +1,100 @@
<template>
<div
class="widget-expands relative flex h-full w-full flex-col gap-1"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<!-- Image preview container -->
<div
ref="containerEl"
class="relative min-h-0 flex-1 overflow-hidden rounded-[5px] bg-node-component-surface"
>
<div v-if="isLoading" class="flex size-full items-center justify-center">
<span class="text-sm">{{ $t('imageCrop.loading') }}</span>
</div>
<div
v-else-if="!imageUrl"
class="flex size-full flex-col items-center justify-center text-center"
>
<i class="mb-2 icon-[lucide--image] h-12 w-12" />
<p class="text-sm">{{ $t('imageCrop.noInputImage') }}</p>
</div>
<img
v-else
ref="imageEl"
:src="imageUrl"
:alt="$t('imageCrop.cropPreviewAlt')"
draggable="false"
class="block size-full object-contain select-none brightness-50"
@load="handleImageLoad"
@error="handleImageError"
@dragstart.prevent
/>
<div
v-if="imageUrl && !isLoading"
class="absolute box-content cursor-move overflow-hidden border-2 border-white"
:style="cropBoxStyle"
@pointerdown="handleDragStart"
@pointermove="handleDragMove"
@pointerup="handleDragEnd"
>
<div class="pointer-events-none size-full" :style="cropImageStyle" />
</div>
<div
v-for="handle in resizeHandles"
v-show="imageUrl && !isLoading"
:key="handle.direction"
:class="['absolute', handle.class]"
:style="handle.style"
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
@pointermove="handleResizeMove"
@pointerup="handleResizeEnd"
/>
</div>
<WidgetBoundingBox v-model="modelValue" class="shrink-0" />
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import WidgetBoundingBox from '@/components/boundingbox/WidgetBoundingBox.vue'
import { useImageCrop } from '@/composables/useImageCrop'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { Bounds } from '@/renderer/core/layout/types'
const props = defineProps<{
nodeId: NodeId
}>()
const modelValue = defineModel<Bounds>({
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
})
const imageEl = useTemplateRef<HTMLImageElement>('imageEl')
const containerEl = useTemplateRef<HTMLDivElement>('containerEl')
const {
imageUrl,
isLoading,
cropBoxStyle,
cropImageStyle,
resizeHandles,
handleImageLoad,
handleImageError,
handleDragStart,
handleDragMove,
handleDragEnd,
handleResizeStart,
handleResizeMove,
handleResizeEnd
} = useImageCrop(props.nodeId, { imageEl, containerEl, modelValue })
</script>

View File

@@ -0,0 +1,469 @@
import { useResizeObserver } from '@vueuse/core'
import type { Ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { Bounds } from '@/renderer/core/layout/types'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
type ResizeDirection =
| 'top'
| 'bottom'
| 'left'
| 'right'
| 'nw'
| 'ne'
| 'sw'
| 'se'
const HANDLE_SIZE = 8
const CORNER_SIZE = 10
const MIN_CROP_SIZE = 16
const CROP_BOX_BORDER = 2
interface UseImageCropOptions {
imageEl: Ref<HTMLImageElement | null>
containerEl: Ref<HTMLDivElement | null>
modelValue: Ref<Bounds>
}
export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) {
const { imageEl, containerEl, modelValue } = options
const nodeOutputStore = useNodeOutputStore()
const node = ref<LGraphNode | null>(null)
const imageUrl = ref<string | null>(null)
const isLoading = ref(false)
const naturalWidth = ref(0)
const naturalHeight = ref(0)
const displayedWidth = ref(0)
const displayedHeight = ref(0)
const scaleFactor = ref(1)
const imageOffsetX = ref(0)
const imageOffsetY = ref(0)
const cropX = computed({
get: () => modelValue.value.x,
set: (v: number) => {
modelValue.value.x = v
}
})
const cropY = computed({
get: () => modelValue.value.y,
set: (v: number) => {
modelValue.value.y = v
}
})
const cropWidth = computed({
get: () => modelValue.value.width || 512,
set: (v: number) => {
modelValue.value.width = v
}
})
const cropHeight = computed({
get: () => modelValue.value.height || 512,
set: (v: number) => {
modelValue.value.height = v
}
})
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
const dragStartCropX = ref(0)
const dragStartCropY = ref(0)
const isResizing = ref(false)
const resizeDirection = ref<ResizeDirection | null>(null)
const resizeStartX = ref(0)
const resizeStartY = ref(0)
const resizeStartCropX = ref(0)
const resizeStartCropY = ref(0)
const resizeStartCropWidth = ref(0)
const resizeStartCropHeight = ref(0)
useResizeObserver(containerEl, () => {
if (imageEl.value && imageUrl.value) {
updateDisplayedDimensions()
}
})
const getInputImageUrl = (): string | null => {
if (!node.value) return null
const inputNode = node.value.getInputNode(0)
if (!inputNode) return null
const urls = nodeOutputStore.getNodeImageUrls(inputNode)
if (urls?.length) {
return urls[0]
}
return null
}
const updateImageUrl = () => {
imageUrl.value = getInputImageUrl()
}
const updateDisplayedDimensions = () => {
if (!imageEl.value || !containerEl.value) return
const img = imageEl.value
const container = containerEl.value
naturalWidth.value = img.naturalWidth
naturalHeight.value = img.naturalHeight
if (naturalWidth.value <= 0 || naturalHeight.value <= 0) {
scaleFactor.value = 1
return
}
const containerWidth = container.clientWidth
const containerHeight = container.clientHeight
const imageAspect = naturalWidth.value / naturalHeight.value
const containerAspect = containerWidth / containerHeight
if (imageAspect > containerAspect) {
displayedWidth.value = containerWidth
displayedHeight.value = containerWidth / imageAspect
imageOffsetX.value = 0
imageOffsetY.value = (containerHeight - displayedHeight.value) / 2
} else {
displayedHeight.value = containerHeight
displayedWidth.value = containerHeight * imageAspect
imageOffsetX.value = (containerWidth - displayedWidth.value) / 2
imageOffsetY.value = 0
}
if (naturalWidth.value <= 0 || displayedWidth.value <= 0) {
scaleFactor.value = 1
} else {
scaleFactor.value = displayedWidth.value / naturalWidth.value
}
}
const getEffectiveScale = (): number => {
const container = containerEl.value
if (!container || naturalWidth.value <= 0 || displayedWidth.value <= 0) {
return 1
}
const rect = container.getBoundingClientRect()
const clientWidth = container.clientWidth
if (!clientWidth || !rect.width) return 1
const renderedDisplayedWidth =
(displayedWidth.value / clientWidth) * rect.width
return renderedDisplayedWidth / naturalWidth.value
}
const cropBoxStyle = computed(() => ({
left: `${imageOffsetX.value + cropX.value * scaleFactor.value - CROP_BOX_BORDER}px`,
top: `${imageOffsetY.value + cropY.value * scaleFactor.value - CROP_BOX_BORDER}px`,
width: `${cropWidth.value * scaleFactor.value}px`,
height: `${cropHeight.value * scaleFactor.value}px`
}))
const cropImageStyle = computed(() => {
if (!imageUrl.value) return {}
return {
backgroundImage: `url(${imageUrl.value})`,
backgroundSize: `${displayedWidth.value}px ${displayedHeight.value}px`,
backgroundPosition: `-${cropX.value * scaleFactor.value}px -${cropY.value * scaleFactor.value}px`,
backgroundRepeat: 'no-repeat'
}
})
interface ResizeHandle {
direction: ResizeDirection
class: string
style: {
left: string
top: string
width?: string
height?: string
}
}
const resizeHandles = computed<ResizeHandle[]>(() => {
const x = imageOffsetX.value + cropX.value * scaleFactor.value
const y = imageOffsetY.value + cropY.value * scaleFactor.value
const w = cropWidth.value * scaleFactor.value
const h = cropHeight.value * scaleFactor.value
return [
{
direction: 'top',
class: 'h-2 cursor-ns-resize',
style: {
left: `${x + HANDLE_SIZE}px`,
top: `${y - HANDLE_SIZE / 2}px`,
width: `${Math.max(0, w - HANDLE_SIZE * 2)}px`
}
},
{
direction: 'bottom',
class: 'h-2 cursor-ns-resize',
style: {
left: `${x + HANDLE_SIZE}px`,
top: `${y + h - HANDLE_SIZE / 2}px`,
width: `${Math.max(0, w - HANDLE_SIZE * 2)}px`
}
},
{
direction: 'left',
class: 'w-2 cursor-ew-resize',
style: {
left: `${x - HANDLE_SIZE / 2}px`,
top: `${y + HANDLE_SIZE}px`,
height: `${Math.max(0, h - HANDLE_SIZE * 2)}px`
}
},
{
direction: 'right',
class: 'w-2 cursor-ew-resize',
style: {
left: `${x + w - HANDLE_SIZE / 2}px`,
top: `${y + HANDLE_SIZE}px`,
height: `${Math.max(0, h - HANDLE_SIZE * 2)}px`
}
},
{
direction: 'nw',
class: 'cursor-nwse-resize rounded-sm bg-white/80',
style: {
left: `${x - CORNER_SIZE / 2}px`,
top: `${y - CORNER_SIZE / 2}px`,
width: `${CORNER_SIZE}px`,
height: `${CORNER_SIZE}px`
}
},
{
direction: 'ne',
class: 'cursor-nesw-resize rounded-sm bg-white/80',
style: {
left: `${x + w - CORNER_SIZE / 2}px`,
top: `${y - CORNER_SIZE / 2}px`,
width: `${CORNER_SIZE}px`,
height: `${CORNER_SIZE}px`
}
},
{
direction: 'sw',
class: 'cursor-nesw-resize rounded-sm bg-white/80',
style: {
left: `${x - CORNER_SIZE / 2}px`,
top: `${y + h - CORNER_SIZE / 2}px`,
width: `${CORNER_SIZE}px`,
height: `${CORNER_SIZE}px`
}
},
{
direction: 'se',
class: 'cursor-nwse-resize rounded-sm bg-white/80',
style: {
left: `${x + w - CORNER_SIZE / 2}px`,
top: `${y + h - CORNER_SIZE / 2}px`,
width: `${CORNER_SIZE}px`,
height: `${CORNER_SIZE}px`
}
}
]
})
const handleImageLoad = () => {
isLoading.value = false
updateDisplayedDimensions()
}
const handleImageError = () => {
isLoading.value = false
imageUrl.value = null
}
const capturePointer = (e: PointerEvent) =>
(e.target as HTMLElement).setPointerCapture(e.pointerId)
const releasePointer = (e: PointerEvent) =>
(e.target as HTMLElement).releasePointerCapture(e.pointerId)
const handleDragStart = (e: PointerEvent) => {
if (!imageUrl.value) return
isDragging.value = true
dragStartX.value = e.clientX
dragStartY.value = e.clientY
dragStartCropX.value = cropX.value
dragStartCropY.value = cropY.value
capturePointer(e)
}
const handleDragMove = (e: PointerEvent) => {
if (!isDragging.value) return
const effectiveScale = getEffectiveScale()
if (effectiveScale === 0) return
const deltaX = (e.clientX - dragStartX.value) / effectiveScale
const deltaY = (e.clientY - dragStartY.value) / effectiveScale
const maxX = naturalWidth.value - cropWidth.value
const maxY = naturalHeight.value - cropHeight.value
cropX.value = Math.round(
Math.max(0, Math.min(maxX, dragStartCropX.value + deltaX))
)
cropY.value = Math.round(
Math.max(0, Math.min(maxY, dragStartCropY.value + deltaY))
)
}
const handleDragEnd = (e: PointerEvent) => {
if (!isDragging.value) return
isDragging.value = false
releasePointer(e)
}
const handleResizeStart = (e: PointerEvent, direction: ResizeDirection) => {
if (!imageUrl.value) return
e.stopPropagation()
isResizing.value = true
resizeDirection.value = direction
resizeStartX.value = e.clientX
resizeStartY.value = e.clientY
resizeStartCropX.value = cropX.value
resizeStartCropY.value = cropY.value
resizeStartCropWidth.value = cropWidth.value
resizeStartCropHeight.value = cropHeight.value
capturePointer(e)
}
const handleResizeMove = (e: PointerEvent) => {
if (!isResizing.value || !resizeDirection.value) return
const effectiveScale = getEffectiveScale()
if (effectiveScale === 0) return
const dir = resizeDirection.value
const deltaX = (e.clientX - resizeStartX.value) / effectiveScale
const deltaY = (e.clientY - resizeStartY.value) / effectiveScale
const affectsLeft = dir === 'left' || dir === 'nw' || dir === 'sw'
const affectsRight = dir === 'right' || dir === 'ne' || dir === 'se'
const affectsTop = dir === 'top' || dir === 'nw' || dir === 'ne'
const affectsBottom = dir === 'bottom' || dir === 'sw' || dir === 'se'
let newX = resizeStartCropX.value
let newY = resizeStartCropY.value
let newWidth = resizeStartCropWidth.value
let newHeight = resizeStartCropHeight.value
if (affectsLeft) {
const maxDeltaX = resizeStartCropWidth.value - MIN_CROP_SIZE
const minDeltaX = -resizeStartCropX.value
const clampedDeltaX = Math.max(minDeltaX, Math.min(maxDeltaX, deltaX))
newX = resizeStartCropX.value + clampedDeltaX
newWidth = resizeStartCropWidth.value - clampedDeltaX
} else if (affectsRight) {
const maxWidth = naturalWidth.value - resizeStartCropX.value
newWidth = Math.max(
MIN_CROP_SIZE,
Math.min(maxWidth, resizeStartCropWidth.value + deltaX)
)
}
if (affectsTop) {
const maxDeltaY = resizeStartCropHeight.value - MIN_CROP_SIZE
const minDeltaY = -resizeStartCropY.value
const clampedDeltaY = Math.max(minDeltaY, Math.min(maxDeltaY, deltaY))
newY = resizeStartCropY.value + clampedDeltaY
newHeight = resizeStartCropHeight.value - clampedDeltaY
} else if (affectsBottom) {
const maxHeight = naturalHeight.value - resizeStartCropY.value
newHeight = Math.max(
MIN_CROP_SIZE,
Math.min(maxHeight, resizeStartCropHeight.value + deltaY)
)
}
if (affectsLeft || affectsRight) {
cropX.value = Math.round(newX)
cropWidth.value = Math.round(newWidth)
}
if (affectsTop || affectsBottom) {
cropY.value = Math.round(newY)
cropHeight.value = Math.round(newHeight)
}
}
const handleResizeEnd = (e: PointerEvent) => {
if (!isResizing.value) return
isResizing.value = false
resizeDirection.value = null
releasePointer(e)
}
const initialize = () => {
if (nodeId != null) {
node.value = app.rootGraph?.getNodeById(nodeId) || null
}
updateImageUrl()
}
watch(
() => nodeOutputStore.nodeOutputs,
() => updateImageUrl(),
{ deep: true }
)
watch(
() => nodeOutputStore.nodePreviewImages,
() => updateImageUrl(),
{ deep: true }
)
onMounted(initialize)
return {
imageUrl,
isLoading,
cropX,
cropY,
cropWidth,
cropHeight,
cropBoxStyle,
cropImageStyle,
resizeHandles,
handleImageLoad,
handleImageError,
handleDragStart,
handleDragMove,
handleDragEnd,
handleResizeStart,
handleResizeMove,
handleResizeEnd
}
}

View File

@@ -0,0 +1,12 @@
import { useExtensionService } from '@/services/extensionService'
useExtensionService().registerExtension({
name: 'Comfy.ImageCrop',
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'ImageCrop') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 450)])
}
})

View File

@@ -10,6 +10,7 @@ import './groupNode'
import './groupNodeManage'
import './groupOptions'
import './imageCompare'
import './imageCrop'
import './load3d'
import './maskeditor'
import './nodeTemplates'

View File

@@ -1,3 +1,5 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CanvasColour, Point, RequiredProps, Size } from '../interfaces'
import type { CanvasPointer, LGraphCanvas, LGraphNode } from '../litegraph'
import type { CanvasPointerEvent } from './events'
@@ -88,6 +90,8 @@ export type IWidget =
| ISelectButtonWidget
| ITextareaWidget
| IAssetWidget
| IImageCropWidget
| IBoundingBoxWidget
export interface IBooleanWidget extends IBaseWidget<boolean, 'toggle'> {
type: 'toggle'
@@ -259,6 +263,18 @@ export interface IAssetWidget extends IBaseWidget<
value: string
}
/** Image crop widget for cropping image */
export interface IImageCropWidget extends IBaseWidget<Bounds, 'imagecrop'> {
type: 'imagecrop'
value: Bounds
}
/** Bounding box widget for defining regions with numeric inputs */
export interface IBoundingBoxWidget extends IBaseWidget<Bounds, 'boundingbox'> {
type: 'boundingbox'
value: Bounds
}
/**
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
* Override linkedWidgets[]

View File

@@ -1,3 +1,4 @@
import { t } from '@/i18n'
import { drawTextInArea } from '@/lib/litegraph/src/draw'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type { Point } from '@/lib/litegraph/src/interfaces'
@@ -227,6 +228,41 @@ export abstract class BaseWidget<
if (showText && !this.computedDisabled) ctx.stroke()
}
/**
* Draws a placeholder for widgets that only have a Vue implementation.
* @param ctx The canvas context
* @param options The options for drawing the widget
* @param label The label to display (e.g., "ImageCrop", "BoundingBox")
*/
protected drawVueOnlyWarning(
ctx: CanvasRenderingContext2D,
{ width }: DrawWidgetOptions,
label: string
): void {
const { y, height } = this
ctx.save()
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.outline_color
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(
`${label}: ${t('widgets.node2only')}`,
width / 2,
y + height / 2
)
ctx.restore()
}
/**
* A shared routine for drawing a label and value as text, truncated
* if they exceed the available width.

View File

@@ -0,0 +1,22 @@
import type { IBoundingBoxWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for defining bounding box regions.
* This widget only has a Vue implementation.
*/
export class BoundingBoxWidget
extends BaseWidget<IBoundingBoxWidget>
implements IBoundingBoxWidget
{
override type = 'boundingbox' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'BoundingBox')
}
onClick(_options: WidgetEventOptions): void {
// This widget only has a Vue implementation
}
}

View File

@@ -0,0 +1,22 @@
import type { IImageCropWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for displaying an image crop preview.
* This widget only has a Vue implementation.
*/
export class ImageCropWidget
extends BaseWidget<IImageCropWidget>
implements IImageCropWidget
{
override type = 'imagecrop' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'ImageCrop')
}
onClick(_options: WidgetEventOptions): void {
// This widget only has a Vue implementation
}
}

View File

@@ -1,12 +1,10 @@
import { t } from '@/i18n'
import type { ITextareaWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for multi-line text input
* This is a widget that only has a Vue widgets implementation
* Widget for multi-line text input.
* This widget only has a Vue implementation.
*/
export class TextareaWidget
extends BaseWidget<ITextareaWidget>
@@ -15,35 +13,10 @@ export class TextareaWidget
override type = 'textarea' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.outline_color
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `Textarea: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
this.drawVueOnlyWarning(ctx, options, 'Textarea')
}
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
// This widget only has a Vue implementation
}
}

View File

@@ -1,12 +1,10 @@
import { t } from '@/i18n'
import type { ITreeSelectWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
/**
* Widget for hierarchical tree selection
* This is a widget that only has a Vue widgets implementation
* Widget for hierarchical tree selection.
* This widget only has a Vue implementation.
*/
export class TreeSelectWidget
extends BaseWidget<ITreeSelectWidget>
@@ -15,35 +13,10 @@ export class TreeSelectWidget
override type = 'treeselect' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
const { width } = options
const { y, height } = this
const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.outline_color
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const text = `TreeSelect: ${t('widgets.node2only')}`
ctx.fillText(text, width / 2, y + height / 2)
Object.assign(ctx, {
fillStyle,
strokeStyle,
textAlign,
textBaseline,
font
})
this.drawVueOnlyWarning(ctx, options, 'TreeSelect')
}
onClick(_options: WidgetEventOptions): void {
// This is a widget that only has a Vue widgets implementation
// This widget only has a Vue implementation
}
}

View File

@@ -11,6 +11,7 @@ import { toClass } from '@/lib/litegraph/src/utils/type'
import { AssetWidget } from './AssetWidget'
import { BaseWidget } from './BaseWidget'
import { BooleanWidget } from './BooleanWidget'
import { BoundingBoxWidget } from './BoundingBoxWidget'
import { ButtonWidget } from './ButtonWidget'
import { ChartWidget } from './ChartWidget'
import { ColorWidget } from './ColorWidget'
@@ -18,6 +19,7 @@ import { ComboWidget } from './ComboWidget'
import { FileUploadWidget } from './FileUploadWidget'
import { GalleriaWidget } from './GalleriaWidget'
import { ImageCompareWidget } from './ImageCompareWidget'
import { ImageCropWidget } from './ImageCropWidget'
import { KnobWidget } from './KnobWidget'
import { LegacyWidget } from './LegacyWidget'
import { MarkdownWidget } from './MarkdownWidget'
@@ -50,6 +52,8 @@ export type WidgetTypeMap = {
selectbutton: SelectButtonWidget
textarea: TextareaWidget
asset: AssetWidget
imagecrop: ImageCropWidget
boundingbox: BoundingBoxWidget
[key: string]: BaseWidget
}
@@ -120,6 +124,10 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
return toClass(TextareaWidget, narrowedWidget, node)
case 'asset':
return toClass(AssetWidget, narrowedWidget, node)
case 'imagecrop':
return toClass(ImageCropWidget, narrowedWidget, node)
case 'boundingbox':
return toClass(BoundingBoxWidget, narrowedWidget, node)
default: {
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
}

View File

@@ -1718,6 +1718,17 @@
"unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl)",
"uploadingModel": "Uploading 3D model..."
},
"imageCrop": {
"loading": "Loading...",
"noInputImage": "No input image connected",
"cropPreviewAlt": "Crop preview"
},
"boundingBox": {
"x": "X",
"y": "Y",
"width": "Width",
"height": "Height"
},
"toastMessages": {
"nothingToQueue": "Nothing to queue",
"pleaseSelectOutputNodes": "Please select output nodes",

View File

@@ -0,0 +1,103 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IBaseWidget,
IBoundingBoxWidget,
IImageCropWidget,
INumericWidget
} from '@/lib/litegraph/src/types/widgets'
import type { Bounds } from '@/renderer/core/layout/types'
import type {
BoundingBoxInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
function isBoundingBoxLikeWidget(
widget: IBaseWidget
): widget is IBoundingBoxWidget | IImageCropWidget {
return widget.type === 'boundingbox' || widget.type === 'imagecrop'
}
function isNumericWidget(widget: IBaseWidget): widget is INumericWidget {
return widget.type === 'number'
}
export const useBoundingBoxWidget = (): ComfyWidgetConstructorV2 => {
return (
node: LGraphNode,
inputSpec: InputSpecV2
): IBoundingBoxWidget | IImageCropWidget => {
const spec = inputSpec as BoundingBoxInputSpec
const { name, component } = spec
const defaultValue: Bounds = spec.default ?? {
x: 0,
y: 0,
width: 512,
height: 512
}
const widgetType = component === 'ImageCrop' ? 'imagecrop' : 'boundingbox'
const fields: (keyof Bounds)[] = ['x', 'y', 'width', 'height']
const subWidgets: INumericWidget[] = []
const rawWidget = node.addWidget(
widgetType,
name,
{ ...defaultValue },
null,
{
serialize: true,
canvasOnly: false
}
)
if (!isBoundingBoxLikeWidget(rawWidget)) {
throw new Error(`Unexpected widget type: ${rawWidget.type}`)
}
const widget = rawWidget
widget.callback = () => {
for (let i = 0; i < fields.length; i++) {
const field = fields[i]
const subWidget = subWidgets[i]
if (subWidget) {
subWidget.value = widget.value[field]
}
}
}
for (const field of fields) {
const subWidget = node.addWidget(
'number',
field,
defaultValue[field],
function (this: INumericWidget, v: number) {
this.value = Math.round(v)
widget.value[field] = this.value
widget.callback?.(widget.value)
},
{
min: field === 'width' || field === 'height' ? 1 : 0,
max: 8192,
step: 10,
step2: 1,
precision: 0,
serialize: false,
canvasOnly: true
}
)
if (!isNumericWidget(subWidget)) {
throw new Error(`Unexpected widget type: ${subWidget.type}`)
}
subWidgets.push(subWidget)
}
widget.linkedWidgets = subWidgets
return widget
}
}

View File

@@ -54,6 +54,12 @@ const WidgetAudioUI = defineAsyncComponent(
const Load3D = defineAsyncComponent(
() => import('@/components/load3d/Load3D.vue')
)
const WidgetImageCrop = defineAsyncComponent(
() => import('@/components/imagecrop/WidgetImageCrop.vue')
)
const WidgetBoundingBox = defineAsyncComponent(
() => import('@/components/boundingbox/WidgetBoundingBox.vue')
)
export const FOR_TESTING = {
WidgetAudioUI,
@@ -157,7 +163,23 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
essential: false
}
],
['load3D', { component: Load3D, aliases: ['LOAD_3D'], essential: false }]
['load3D', { component: Load3D, aliases: ['LOAD_3D'], essential: false }],
[
'imagecrop',
{
component: WidgetImageCrop,
aliases: ['IMAGECROP'],
essential: false
}
],
[
'boundingbox',
{
component: WidgetBoundingBox,
aliases: ['BOUNDINGBOX'],
essential: false
}
]
]
const getComboWidgetAdditions = (): Map<string, Component> => {

View File

@@ -64,6 +64,21 @@ const zImageCompareInputSpec = zBaseInputOptions.extend({
options: z.record(z.unknown()).optional()
})
const zBoundingBoxInputSpec = zBaseInputOptions.extend({
type: z.literal('BOUNDINGBOX'),
name: z.string(),
isOptional: z.boolean().optional(),
component: z.enum(['ImageCrop']).optional(),
default: z
.object({
x: z.number(),
y: z.number(),
width: z.number(),
height: z.number()
})
.optional()
})
const zMarkdownInputSpec = zBaseInputOptions.extend({
type: z.literal('MARKDOWN'),
name: z.string(),
@@ -126,6 +141,7 @@ const zInputSpec = z.union([
zColorInputSpec,
zImageInputSpec,
zImageCompareInputSpec,
zBoundingBoxInputSpec,
zMarkdownInputSpec,
zChartInputSpec,
zGalleriaInputSpec,
@@ -169,6 +185,7 @@ type StringInputSpec = z.infer<typeof zStringInputSpec>
export type ComboInputSpec = z.infer<typeof zComboInputSpec>
export type ColorInputSpec = z.infer<typeof zColorInputSpec>
export type ImageCompareInputSpec = z.infer<typeof zImageCompareInputSpec>
export type BoundingBoxInputSpec = z.infer<typeof zBoundingBoxInputSpec>
export type ChartInputSpec = z.infer<typeof zChartInputSpec>
export type GalleriaInputSpec = z.infer<typeof zGalleriaInputSpec>
export type TextareaInputSpec = z.infer<typeof zTextareaInputSpec>

View File

@@ -8,6 +8,7 @@ import type {
import { useSettingStore } from '@/platform/settings/settingStore'
import { dynamicWidgets } from '@/core/graph/widgets/dynamicWidgets'
import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget'
import { useBoundingBoxWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBoundingBoxWidget'
import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget'
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget'
@@ -301,6 +302,7 @@ export const ComfyWidgets = {
IMAGEUPLOAD: useImageUploadWidget(),
COLOR: transformWidgetConstructorV2ToV1(useColorWidget()),
IMAGECOMPARE: transformWidgetConstructorV2ToV1(useImageCompareWidget()),
BOUNDINGBOX: transformWidgetConstructorV2ToV1(useBoundingBoxWidget()),
CHART: transformWidgetConstructorV2ToV1(useChartWidget()),
GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()),
TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget()),