From be8916b4ce534216a1afab65feed86ef1b0d5231 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Sat, 17 Jan 2026 17:09:16 -0500 Subject: [PATCH] feat: Add visual crop preview widget for ImageCrop node - widget ImageCrop (#7825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- .../boundingbox/WidgetBoundingBox.vue | 82 +++ src/components/imagecrop/WidgetImageCrop.vue | 100 ++++ src/composables/useImageCrop.ts | 469 ++++++++++++++++++ src/extensions/core/imageCrop.ts | 12 + src/extensions/core/index.ts | 1 + src/lib/litegraph/src/types/widgets.ts | 16 + src/lib/litegraph/src/widgets/BaseWidget.ts | 36 ++ .../src/widgets/BoundingBoxWidget.ts | 22 + .../litegraph/src/widgets/ImageCropWidget.ts | 22 + .../litegraph/src/widgets/TextareaWidget.ts | 35 +- .../litegraph/src/widgets/TreeSelectWidget.ts | 35 +- src/lib/litegraph/src/widgets/widgetMap.ts | 8 + src/locales/en/main.json | 11 + .../composables/useBoundingBoxWidget.ts | 103 ++++ .../widgets/registry/widgetRegistry.ts | 24 +- src/schemas/nodeDef/nodeDefSchemaV2.ts | 17 + src/scripts/widgets.ts | 2 + 17 files changed, 932 insertions(+), 63 deletions(-) create mode 100644 src/components/boundingbox/WidgetBoundingBox.vue create mode 100644 src/components/imagecrop/WidgetImageCrop.vue create mode 100644 src/composables/useImageCrop.ts create mode 100644 src/extensions/core/imageCrop.ts create mode 100644 src/lib/litegraph/src/widgets/BoundingBoxWidget.ts create mode 100644 src/lib/litegraph/src/widgets/ImageCropWidget.ts create mode 100644 src/renderer/extensions/vueNodes/widgets/composables/useBoundingBoxWidget.ts diff --git a/src/components/boundingbox/WidgetBoundingBox.vue b/src/components/boundingbox/WidgetBoundingBox.vue new file mode 100644 index 000000000..b4c10dd71 --- /dev/null +++ b/src/components/boundingbox/WidgetBoundingBox.vue @@ -0,0 +1,82 @@ + + + diff --git a/src/components/imagecrop/WidgetImageCrop.vue b/src/components/imagecrop/WidgetImageCrop.vue new file mode 100644 index 000000000..4a1c39ef6 --- /dev/null +++ b/src/components/imagecrop/WidgetImageCrop.vue @@ -0,0 +1,100 @@ + + + diff --git a/src/composables/useImageCrop.ts b/src/composables/useImageCrop.ts new file mode 100644 index 000000000..da637ba0a --- /dev/null +++ b/src/composables/useImageCrop.ts @@ -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 + containerEl: Ref + modelValue: Ref +} + +export function useImageCrop(nodeId: NodeId, options: UseImageCropOptions) { + const { imageEl, containerEl, modelValue } = options + const nodeOutputStore = useNodeOutputStore() + + const node = ref(null) + + const imageUrl = ref(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(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(() => { + 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 + } +} diff --git a/src/extensions/core/imageCrop.ts b/src/extensions/core/imageCrop.ts new file mode 100644 index 000000000..38cf6692a --- /dev/null +++ b/src/extensions/core/imageCrop.ts @@ -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)]) + } +}) diff --git a/src/extensions/core/index.ts b/src/extensions/core/index.ts index ba36f847a..f50493d82 100644 --- a/src/extensions/core/index.ts +++ b/src/extensions/core/index.ts @@ -10,6 +10,7 @@ import './groupNode' import './groupNodeManage' import './groupOptions' import './imageCompare' +import './imageCrop' import './load3d' import './maskeditor' import './nodeTemplates' diff --git a/src/lib/litegraph/src/types/widgets.ts b/src/lib/litegraph/src/types/widgets.ts index 1003981b4..d3c249ba3 100644 --- a/src/lib/litegraph/src/types/widgets.ts +++ b/src/lib/litegraph/src/types/widgets.ts @@ -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 { type: 'toggle' @@ -259,6 +263,18 @@ export interface IAssetWidget extends IBaseWidget< value: string } +/** Image crop widget for cropping image */ +export interface IImageCropWidget extends IBaseWidget { + type: 'imagecrop' + value: Bounds +} + +/** Bounding box widget for defining regions with numeric inputs */ +export interface IBoundingBoxWidget extends IBaseWidget { + type: 'boundingbox' + value: Bounds +} + /** * Valid widget types. TS cannot provide easily extensible type safety for this at present. * Override linkedWidgets[] diff --git a/src/lib/litegraph/src/widgets/BaseWidget.ts b/src/lib/litegraph/src/widgets/BaseWidget.ts index a580fc69a..ee32e71d8 100644 --- a/src/lib/litegraph/src/widgets/BaseWidget.ts +++ b/src/lib/litegraph/src/widgets/BaseWidget.ts @@ -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. diff --git a/src/lib/litegraph/src/widgets/BoundingBoxWidget.ts b/src/lib/litegraph/src/widgets/BoundingBoxWidget.ts new file mode 100644 index 000000000..d571f744f --- /dev/null +++ b/src/lib/litegraph/src/widgets/BoundingBoxWidget.ts @@ -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 + 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 + } +} diff --git a/src/lib/litegraph/src/widgets/ImageCropWidget.ts b/src/lib/litegraph/src/widgets/ImageCropWidget.ts new file mode 100644 index 000000000..a81cd580c --- /dev/null +++ b/src/lib/litegraph/src/widgets/ImageCropWidget.ts @@ -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 + 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 + } +} diff --git a/src/lib/litegraph/src/widgets/TextareaWidget.ts b/src/lib/litegraph/src/widgets/TextareaWidget.ts index f29ccc5e5..c6b83e13f 100644 --- a/src/lib/litegraph/src/widgets/TextareaWidget.ts +++ b/src/lib/litegraph/src/widgets/TextareaWidget.ts @@ -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 @@ -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 } } diff --git a/src/lib/litegraph/src/widgets/TreeSelectWidget.ts b/src/lib/litegraph/src/widgets/TreeSelectWidget.ts index ee78d5919..78586cb6f 100644 --- a/src/lib/litegraph/src/widgets/TreeSelectWidget.ts +++ b/src/lib/litegraph/src/widgets/TreeSelectWidget.ts @@ -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 @@ -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 } } diff --git a/src/lib/litegraph/src/widgets/widgetMap.ts b/src/lib/litegraph/src/widgets/widgetMap.ts index 0e6a34fe5..37b906efb 100644 --- a/src/lib/litegraph/src/widgets/widgetMap.ts +++ b/src/lib/litegraph/src/widgets/widgetMap.ts @@ -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( 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) } diff --git a/src/locales/en/main.json b/src/locales/en/main.json index e8d3b19f5..fa557ee96 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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", diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useBoundingBoxWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useBoundingBoxWidget.ts new file mode 100644 index 000000000..ad1c25d80 --- /dev/null +++ b/src/renderer/extensions/vueNodes/widgets/composables/useBoundingBoxWidget.ts @@ -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 + } +} diff --git a/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts b/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts index 14ea96daa..c82a4d395 100644 --- a/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts +++ b/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts @@ -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 => { diff --git a/src/schemas/nodeDef/nodeDefSchemaV2.ts b/src/schemas/nodeDef/nodeDefSchemaV2.ts index 9e319c163..4743164a1 100644 --- a/src/schemas/nodeDef/nodeDefSchemaV2.ts +++ b/src/schemas/nodeDef/nodeDefSchemaV2.ts @@ -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 export type ComboInputSpec = z.infer export type ColorInputSpec = z.infer export type ImageCompareInputSpec = z.infer +export type BoundingBoxInputSpec = z.infer export type ChartInputSpec = z.infer export type GalleriaInputSpec = z.infer export type TextareaInputSpec = z.infer diff --git a/src/scripts/widgets.ts b/src/scripts/widgets.ts index 505c88a6c..408555a19 100644 --- a/src/scripts/widgets.ts +++ b/src/scripts/widgets.ts @@ -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()),