Add Vue Image Preview widget (#4116)

This commit is contained in:
Christian Byrne
2025-06-09 03:52:17 -07:00
committed by GitHub
parent 33e99da325
commit 20e4427602
16 changed files with 649 additions and 373 deletions

View File

@@ -0,0 +1,210 @@
<template>
<div class="image-preview-widget relative w-full">
<!-- Single image or grid view -->
<div
v-if="images.length > 0"
class="relative rounded-lg overflow-hidden bg-gray-100 dark-theme:bg-gray-800"
:style="{ minHeight: `${minHeight}px` }"
>
<!-- Single image view -->
<div
v-if="selectedImageIndex !== null && images[selectedImageIndex]"
class="relative flex items-center justify-center w-full h-full"
>
<img
:src="images[selectedImageIndex].src"
:alt="`Preview ${selectedImageIndex + 1}`"
class="max-w-full max-h-full object-contain"
@error="handleImageError"
/>
<!-- Action buttons overlay -->
<div class="absolute top-2 right-2 flex gap-1">
<Button
v-if="images.length > 1"
icon="pi pi-times"
size="small"
severity="secondary"
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
@click="showGrid"
/>
<Button
icon="pi pi-pencil"
size="small"
severity="secondary"
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
@click="handleEdit"
/>
<Button
icon="pi pi-sun"
size="small"
severity="secondary"
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
@click="handleBrightness"
/>
<Button
icon="pi pi-download"
size="small"
severity="secondary"
class="w-8 h-8 rounded-lg bg-black/60 text-white border-none hover:bg-black/80"
@click="handleSave"
/>
</div>
<!-- Navigation for multiple images -->
<div
v-if="images.length > 1"
class="absolute bottom-2 right-2 bg-black/60 text-white px-2 py-1 rounded text-sm cursor-pointer hover:bg-black/80"
@click="nextImage"
>
{{ selectedImageIndex + 1 }}/{{ images.length }}
</div>
</div>
<!-- Grid view for multiple images -->
<div
v-else-if="allowBatch && images.length > 1"
class="grid gap-1 p-2"
:style="gridStyle"
>
<div
v-for="(image, index) in images"
:key="index"
class="relative aspect-square bg-gray-200 dark-theme:bg-gray-700 rounded cursor-pointer overflow-hidden hover:ring-2 hover:ring-blue-500"
@click="selectImage(index)"
>
<img
:src="image.src"
:alt="`Thumbnail ${index + 1}`"
class="w-full h-full object-cover"
@error="handleImageError"
/>
</div>
</div>
<!-- Single image in grid mode -->
<div v-else-if="images.length === 1" class="p-2">
<div
class="relative bg-gray-200 dark-theme:bg-gray-700 rounded cursor-pointer overflow-hidden"
@click="selectImage(0)"
>
<img
:src="images[0].src"
:alt="'Preview'"
class="w-full h-auto object-contain"
@error="handleImageError"
/>
</div>
</div>
</div>
<!-- Empty state -->
<div
v-else
class="flex items-center justify-center w-full bg-gray-100 dark-theme:bg-gray-800 rounded-lg"
:style="{ minHeight: `${minHeight}px` }"
>
<div class="text-gray-500 text-sm">No images to preview</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import type { ComponentWidget } from '@/scripts/domWidget'
interface ImageData {
src: string
width?: number
height?: number
}
const modelValue = defineModel<string | string[]>({ required: true })
const { widget } = defineProps<{
widget: ComponentWidget<string | string[]>
}>()
// Widget configuration
const inputSpec = widget.inputSpec
const allowBatch = computed(() => Boolean(inputSpec.allow_batch))
const imageFolder = computed(() => inputSpec.image_folder || 'input')
// State
const selectedImageIndex = ref<number | null>(null)
const minHeight = 320
// Convert model value to image data
const images = computed<ImageData[]>(() => {
const value = modelValue.value
if (!value) return []
const paths = Array.isArray(value) ? value : [value]
return paths.map((path) => ({
src: path.startsWith('http')
? path
: `api/view?filename=${encodeURIComponent(path)}&type=${imageFolder.value}`, // TODO: add subfolder
width: undefined,
height: undefined
}))
})
// Grid layout for batch images
const gridStyle = computed(() => {
const count = images.value.length
if (count <= 1) return {}
const cols = Math.ceil(Math.sqrt(count))
return {
gridTemplateColumns: `repeat(${cols}, 1fr)`
}
})
// Methods
const selectImage = (index: number) => {
selectedImageIndex.value = index
}
const showGrid = () => {
selectedImageIndex.value = null
}
const nextImage = () => {
if (images.value.length === 0) return
const current = selectedImageIndex.value ?? -1
const next = (current + 1) % images.value.length
selectedImageIndex.value = next
}
const handleImageError = (event: Event) => {
const img = event.target as HTMLImageElement
img.style.display = 'none'
console.warn('Failed to load image:', img.src)
}
// Stub button handlers for now
const handleEdit = () => {
console.log('Edit button clicked - functionality to be implemented')
}
const handleBrightness = () => {
console.log('Brightness button clicked - functionality to be implemented')
}
const handleSave = () => {
console.log('Save button clicked - functionality to be implemented')
}
// Initialize to show first image if available
if (images.value.length === 1) {
selectedImageIndex.value = 0
}
</script>
<style scoped>
.image-preview-widget {
/* Ensure proper dark theme styling */
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="media-loader-widget w-full px-2">
<div class="media-loader-widget w-full px-2 max-h-44">
<div
class="upload-area border-2 border-dashed border-surface-300 dark-theme:border-surface-600 rounded-lg p-6 text-center bg-surface-50 dark-theme:bg-surface-800 hover:bg-surface-100 dark-theme:hover:bg-surface-700 transition-colors cursor-pointer"
:class="{

View File

@@ -0,0 +1,77 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { useImagePreviewWidget } from '@/composables/widgets/useImagePreviewWidget'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
const IMAGE_PREVIEW_WIDGET_NAME = '$$node-image-preview'
/**
* Composable for handling node-level operations for ImagePreview widget
*/
export function useNodeImagePreview() {
const imagePreviewWidget = useImagePreviewWidget()
const findImagePreviewWidget = (node: LGraphNode) =>
node.widgets?.find((w) => w.name === IMAGE_PREVIEW_WIDGET_NAME)
const addImagePreviewWidget = (
node: LGraphNode,
inputSpec?: Partial<InputSpec>
) =>
imagePreviewWidget(node, {
name: IMAGE_PREVIEW_WIDGET_NAME,
type: 'IMAGEPREVIEW',
allow_batch: true,
image_folder: 'input',
...inputSpec
} as InputSpec)
/**
* Shows image preview widget for a node
* @param node The graph node to show the widget for
* @param images The images to display (can be single image or array)
* @param options Configuration options
*/
function showImagePreview(
node: LGraphNode,
images: string | string[],
options: {
allow_batch?: boolean
image_folder?: string
imageInputName?: string
} = {}
) {
const widget =
findImagePreviewWidget(node) ??
addImagePreviewWidget(node, {
allow_batch: options.allow_batch,
image_folder: options.image_folder || 'input'
})
// Set the widget value
widget.value = images
node.setDirtyCanvas?.(true)
}
/**
* Removes image preview widget from a node
* @param node The graph node to remove the widget from
*/
function removeImagePreview(node: LGraphNode) {
if (!node.widgets) return
const widgetIdx = node.widgets.findIndex(
(w) => w.name === IMAGE_PREVIEW_WIDGET_NAME
)
if (widgetIdx > -1) {
node.widgets[widgetIdx].onRemove?.()
node.widgets.splice(widgetIdx, 1)
}
}
return {
showImagePreview,
removeImagePreview
}
}

View File

@@ -2,7 +2,6 @@ import type { LGraphNode } from '@comfyorg/litegraph'
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop'
import { useNodeFileInput } from '@/composables/node/useNodeFileInput'
import { useNodeMediaUpload } from '@/composables/node/useNodeMediaUpload'
import { useNodePaste } from '@/composables/node/useNodePaste'
import { api } from '@/scripts/api'
import { useToastStore } from '@/stores/toastStore'
@@ -37,28 +36,16 @@ interface ImageUploadOptions {
* @example 'image/png,image/jpeg,image/webp,video/webm,video/mp4'
*/
accept?: string
/**
* Whether to use the new Vue MediaLoader widget instead of traditional drag/drop/paste
* @default true
*/
useMediaLoaderWidget?: boolean
}
/**
* Adds image upload to a node via drag & drop, paste, and file input.
* Optionally can use the new Vue MediaLoader widget.
*/
export const useNodeImageUpload = (
node: LGraphNode,
options: ImageUploadOptions
) => {
const {
fileFilter,
onUploadComplete,
allow_batch,
accept,
useMediaLoaderWidget = true
} = options
const { fileFilter, onUploadComplete, allow_batch, accept } = options
const isPastedFile = (file: File): boolean =>
file.name === 'image.png' &&
@@ -81,21 +68,8 @@ export const useNodeImageUpload = (
return validPaths
}
// If using the new MediaLoader widget, set it up and return early
if (useMediaLoaderWidget) {
const { showMediaLoader } = useNodeMediaUpload()
const widget = showMediaLoader(node, {
fileFilter,
onUploadComplete,
allow_batch,
accept
})
return {
openFileSelection: () => {},
handleUpload,
mediaLoaderWidget: widget
}
}
// Note: MediaLoader widget functionality is handled directly by
// useImageUploadMediaWidget.ts to avoid circular dependencies
// Traditional approach: Handle drag & drop
useNodeDragAndDrop(node, {

View File

@@ -1,10 +1,31 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
import { useMediaLoaderWidget } from '@/composables/widgets/useMediaLoaderWidget'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { api } from '@/scripts/api'
import { useToastStore } from '@/stores/toastStore'
const MEDIA_LOADER_WIDGET_NAME = '$$node-media-loader'
const PASTED_IMAGE_EXPIRY_MS = 2000
const uploadFile = async (file: File, isPasted: boolean) => {
const body = new FormData()
body.append('image', file)
if (isPasted) body.append('subfolder', 'pasted')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
return
}
const data = await resp.json()
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
}
interface MediaUploadOptions {
fileFilter?: (file: File) => boolean
@@ -26,8 +47,19 @@ export function useNodeMediaUpload() {
node: LGraphNode,
options: MediaUploadOptions
) => {
// Set up the file upload handling using existing logic
const { handleUpload } = useNodeImageUpload(node, options)
const isPastedFile = (file: File): boolean =>
file.name === 'image.png' &&
file.lastModified - Date.now() < PASTED_IMAGE_EXPIRY_MS
const handleUpload = async (file: File) => {
try {
const path = await uploadFile(file, isPastedFile(file))
if (!path) return
return path
} catch (error) {
useToastStore().addAlert(String(error))
}
}
// Create the MediaLoader widget
const widget = mediaLoaderWidget(node, {
@@ -38,7 +70,11 @@ export function useNodeMediaUpload() {
// Connect the widget to the upload handler
if (widget.options) {
;(widget.options as any).onFilesSelected = async (files: File[]) => {
const paths = await Promise.all(files.map(handleUpload))
const filteredFiles = options.fileFilter
? files.filter(options.fileFilter)
: files
const paths = await Promise.all(filteredFiles.map(handleUpload))
const validPaths = paths.filter((p): p is string => !!p)
if (validPaths.length) {
options.onUploadComplete(validPaths)

View File

@@ -43,7 +43,7 @@ export const useBadgedNumberInput = (
const {
defaultValue = 0,
disabled = false,
minHeight = 40,
minHeight = 32,
serialize = true,
mode = 'int'
} = options
@@ -117,6 +117,8 @@ export const useBadgedNumberInput = (
// Optional: minimum height for the widget
getMinHeight: () => minHeight + PADDING,
// Lock maximum height to prevent oversizing
getMaxHeight: () => 45,
// Optional: whether to serialize this widget's value
serialize

View File

@@ -29,7 +29,13 @@ const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
getValue: () => widgetValue.value,
setValue: (value: string[]) => {
widgetValue.value = value
}
},
// Optional: minimum height for the widget (multiselect needs minimal height)
getMinHeight: () => 32,
// Lock maximum height to prevent oversizing
getMaxHeight: () => 45,
// Optional: whether to serialize this widget's value
serialize: true
}
})
addWidget(node, widget as BaseDOMWidget<object | string>)

View File

@@ -13,8 +13,6 @@ import { addValueControlWidgets } from '@/scripts/widgets'
import { useRemoteWidget } from './useRemoteWidget'
const PADDING = 8
const getDefaultValue = (inputSpec: ComboInputSpec) => {
if (inputSpec.default) return inputSpec.default
if (inputSpec.options?.length) return inputSpec.options[0]
@@ -51,8 +49,10 @@ export const useDropdownComboWidget = (
widgetValue.value = value
},
// Optional: minimum height for the widget (dropdown needs some height)
getMinHeight: () => 48 + PADDING,
// Optional: minimum height for the widget (dropdown needs minimal height)
getMinHeight: () => 48,
// Lock maximum height to prevent oversizing
getMaxHeight: () => 64,
// Optional: whether to serialize this widget's value
serialize: true

View File

@@ -1,317 +1,53 @@
import {
BaseWidget,
type CanvasPointer,
type LGraphNode,
LiteGraph
} from '@comfyorg/litegraph'
import type {
IBaseWidget,
IWidgetOptions
} from '@comfyorg/litegraph/dist/types/widgets'
import type { LGraphNode } from '@comfyorg/litegraph'
import { ref } from 'vue'
import ImagePreviewWidget from '@/components/graph/widgets/ImagePreviewWidget.vue'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
import { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
const renderPreview = (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
shiftY: number
const PADDING = 8
export const useImagePreviewWidget = (
options: { defaultValue?: string | string[] } = {}
) => {
const canvas = useCanvasStore().getCanvas()
const mouse = canvas.graph_mouse
if (!canvas.pointer_is_down && node.pointerDown) {
if (
mouse[0] === node.pointerDown.pos[0] &&
mouse[1] === node.pointerDown.pos[1]
) {
node.imageIndex = node.pointerDown.index
}
node.pointerDown = null
}
const imgs = node.imgs ?? []
let { imageIndex } = node
const numImages = imgs.length
if (numImages === 1 && !imageIndex) {
// This skips the thumbnail render section below
node.imageIndex = imageIndex = 0
}
const settingStore = useSettingStore()
const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw')
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0
const dw = node.size[0]
const dh = node.size[1] - shiftY - IMAGE_TEXT_SIZE_TEXT_HEIGHT
if (imageIndex == null) {
// No image selected; draw thumbnails of all
let cellWidth: number
let cellHeight: number
let shiftX: number
let cell_padding: number
let cols: number
const compact_mode = is_all_same_aspect_ratio(imgs)
if (!compact_mode) {
// use rectangle cell style and border line
cell_padding = 2
// Prevent infinite canvas2d scale-up
const largestDimension = imgs.reduce(
(acc, current) =>
Math.max(acc, current.naturalWidth, current.naturalHeight),
0
)
const fakeImgs = []
fakeImgs.length = imgs.length
fakeImgs[0] = {
naturalWidth: largestDimension,
naturalHeight: largestDimension
}
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
fakeImgs,
dw,
dh
))
} else {
cell_padding = 0
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
imgs,
dw,
dh
))
}
let anyHovered = false
node.imageRects = []
for (let i = 0; i < numImages; i++) {
const img = imgs[i]
const row = Math.floor(i / cols)
const col = i % cols
const x = col * cellWidth + shiftX
const y = row * cellHeight + shiftY
if (!anyHovered) {
anyHovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + node.pos[0],
y + node.pos[1],
cellWidth,
cellHeight
)
if (anyHovered) {
node.overIndex = i
let value = 110
if (canvas.pointer_is_down) {
if (!node.pointerDown || node.pointerDown.index !== i) {
node.pointerDown = { index: i, pos: [...mouse] }
}
value = 125
}
ctx.filter = `contrast(${value}%) brightness(${value}%)`
canvas.canvas.style.cursor = 'pointer'
}
}
node.imageRects.push([x, y, cellWidth, cellHeight])
const wratio = cellWidth / img.width
const hratio = cellHeight / img.height
const ratio = Math.min(wratio, hratio)
const imgHeight = ratio * img.height
const imgY = row * cellHeight + shiftY + (cellHeight - imgHeight) / 2
const imgWidth = ratio * img.width
const imgX = col * cellWidth + shiftX + (cellWidth - imgWidth) / 2
ctx.drawImage(
img,
imgX + cell_padding,
imgY + cell_padding,
imgWidth - cell_padding * 2,
imgHeight - cell_padding * 2
)
if (!compact_mode) {
// rectangle cell and border line style
ctx.strokeStyle = '#8F8F8F'
ctx.lineWidth = 1
ctx.strokeRect(
x + cell_padding,
y + cell_padding,
cellWidth - cell_padding * 2,
cellHeight - cell_padding * 2
)
}
ctx.filter = 'none'
}
if (!anyHovered) {
node.pointerDown = null
node.overIndex = null
}
return
}
// Draw individual
const img = imgs[imageIndex]
let w = img.naturalWidth
let h = img.naturalHeight
const scaleX = dw / w
const scaleY = dh / h
const scale = Math.min(scaleX, scaleY, 1)
w *= scale
h *= scale
const x = (dw - w) / 2
const y = (dh - h) / 2 + shiftY
ctx.drawImage(img, x, y, w, h)
// Draw image size text below the image
if (allowImageSizeDraw) {
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
ctx.textAlign = 'center'
ctx.font = '10px sans-serif'
const sizeText = `${Math.round(img.naturalWidth)} × ${Math.round(img.naturalHeight)}`
const textY = y + h + 10
ctx.fillText(sizeText, x + w / 2, textY)
}
const drawButton = (
x: number,
y: number,
sz: number,
text: string
): boolean => {
const hovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + node.pos[0],
y + node.pos[1],
sz,
sz
)
let fill = '#333'
let textFill = '#fff'
let isClicking = false
if (hovered) {
canvas.canvas.style.cursor = 'pointer'
if (canvas.pointer_is_down) {
fill = '#1e90ff'
isClicking = true
} else {
fill = '#eee'
textFill = '#000'
}
}
ctx.fillStyle = fill
ctx.beginPath()
ctx.roundRect(x, y, sz, sz, [4])
ctx.fill()
ctx.fillStyle = textFill
ctx.font = '12px Arial'
ctx.textAlign = 'center'
ctx.fillText(text, x + 15, y + 20)
return isClicking
}
if (!(numImages > 1)) return
const imageNum = (node.imageIndex ?? 0) + 1
if (drawButton(dw - 40, dh + shiftY - 40, 30, `${imageNum}/${numImages}`)) {
const i = imageNum >= numImages ? 0 : imageNum
if (!node.pointerDown || node.pointerDown.index !== i) {
node.pointerDown = { index: i, pos: [...mouse] }
}
}
if (drawButton(dw - 40, shiftY + 10, 30, `x`)) {
if (!node.pointerDown || node.pointerDown.index !== null) {
node.pointerDown = { index: null, pos: [...mouse] }
}
}
}
class ImagePreviewWidget extends BaseWidget {
constructor(
node: LGraphNode,
name: string,
options: IWidgetOptions<string | object>
) {
const widget: IBaseWidget = {
name,
options,
type: 'custom',
/** Dummy value to satisfy type requirements. */
value: '',
y: 0
}
super(widget, node)
// Don't serialize the widget value
this.serialize = false
}
override drawWidget(ctx: CanvasRenderingContext2D): void {
renderPreview(ctx, this.node, this.y)
}
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {
pointer.onDragStart = () => {
const { canvas } = app
const { graph } = canvas
canvas.emitBeforeChange()
graph?.beforeChange()
// Ensure that dragging is properly cleaned up, on success or failure.
pointer.finally = () => {
canvas.isDragging = false
graph?.afterChange()
canvas.emitAfterChange()
}
canvas.processSelect(node, pointer.eDown)
canvas.isDragging = true
}
pointer.onDragEnd = (e) => {
const { canvas } = app
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
canvas.graph?.snapToGrid(canvas.selectedItems)
canvas.setDirty(true, true)
}
return true
}
override onClick(): void {}
override computeLayoutSize() {
return {
minHeight: 220,
minWidth: 1
}
}
}
export const useImagePreviewWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
return node.addCustomWidget(
new ImagePreviewWidget(node, inputSpec.name, {
serialize: false
})
// Initialize widget value
const widgetValue = ref<string | string[]>(
options.defaultValue ?? (inputSpec.allow_batch ? [] : '')
)
// Create the Vue-based widget instance
const widget = new ComponentWidgetImpl<string | string[]>({
node,
name: inputSpec.name,
component: ImagePreviewWidget,
inputSpec,
options: {
// Required: getter for widget value
getValue: () => widgetValue.value,
// Required: setter for widget value
setValue: (value: string | string[]) => {
widgetValue.value = value
},
// Optional: minimum height for the widget
getMinHeight: () => 320 + PADDING,
getMaxHeight: () => 512 + PADDING,
// Optional: whether to serialize this widget's value
serialize: false
}
})
// Register the widget with the node
addWidget(node, widget as any)
return widget
}
return widgetConstructor

View File

@@ -1,21 +1,44 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { ref } from 'vue'
import MediaLoaderWidget from '@/components/graph/widgets/MediaLoaderWidget.vue'
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
import { useNodeImagePreview } from '@/composables/node/useNodeImagePreview'
import { useValueTransform } from '@/composables/useValueTransform'
import type { ResultItem } from '@/schemas/apiSchema'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useToastStore } from '@/stores/toastStore'
import { createAnnotatedPath } from '@/utils/formatUtil'
import { addToComboValues } from '@/utils/litegraphUtil'
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
const PASTED_IMAGE_EXPIRY_MS = 2000
const uploadFile = async (file: File, isPasted: boolean) => {
const body = new FormData()
body.append('image', file)
if (isPasted) body.append('subfolder', 'pasted')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
return
}
const data = await resp.json()
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
}
type InternalFile = string | ResultItem
type InternalValue = InternalFile | InternalFile[]
@@ -43,6 +66,7 @@ export const useImageUploadMediaWidget = () => {
const isVideo = !!inputOptions.video_upload
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
const { showImagePreview } = useNodeImagePreview()
const fileFilter = isVideo ? isVideoFile : isImageFile
// @ts-expect-error InputSpec is not typed correctly
@@ -72,7 +96,8 @@ export const useImageUploadMediaWidget = () => {
// Handle widget dimensions based on input options
const getMinHeight = () => {
let baseHeight = 200
// Use smaller height for MediaLoader upload widget
let baseHeight = 176
// Handle multiline attribute for expanded height
if (inputOptions.multiline) {
@@ -92,6 +117,19 @@ export const useImageUploadMediaWidget = () => {
return baseHeight + 8 // Add padding
}
const getMaxHeight = () => {
// Lock maximum height to prevent oversizing of upload widget
if (inputOptions.multiline || inputOptions.min_height) {
// Allow more height for special cases
return Math.max(200, getMinHeight())
}
// Lock standard upload widget to ~80px max
return 80
}
// State for MediaLoader widget
const uploadedFiles = ref<string[]>([])
// Create the MediaLoader widget directly
const uploadWidget = new ComponentWidgetImpl<string[], { accept?: string }>(
{
@@ -103,33 +141,48 @@ export const useImageUploadMediaWidget = () => {
accept
},
options: {
getValue: () => [],
setValue: () => {},
getValue: () => uploadedFiles.value,
setValue: (value: string[]) => {
uploadedFiles.value = value
},
getMinHeight,
getMaxHeight, // Lock maximum height to prevent oversizing
serialize: false,
onFilesSelected: async (files: File[]) => {
// Use the existing upload infrastructure
const { handleUpload } = useNodeImageUpload(node, {
// @ts-expect-error InputSpec is not typed correctly
allow_batch,
fileFilter,
accept,
onUploadComplete: (output) => {
output.forEach((path) =>
addToComboValues(fileComboWidget, path)
)
// @ts-expect-error litegraph combo value type does not support arrays yet
fileComboWidget.value = output
fileComboWidget.callback?.(output)
}
})
const isPastedFile = (file: File): boolean =>
file.name === 'image.png' &&
file.lastModified - Date.now() < PASTED_IMAGE_EXPIRY_MS
// Handle each file
for (const file of files) {
if (fileFilter(file)) {
await handleUpload(file)
const handleUpload = async (file: File) => {
try {
const path = await uploadFile(file, isPastedFile(file))
if (!path) return
return path
} catch (error) {
useToastStore().addAlert(String(error))
}
}
// Filter and upload files
const filteredFiles = files.filter(fileFilter)
const paths = await Promise.all(filteredFiles.map(handleUpload))
const validPaths = paths.filter((p): p is string => !!p)
if (validPaths.length) {
validPaths.forEach((path) =>
addToComboValues(fileComboWidget, path)
)
const output = allow_batch ? validPaths : validPaths[0]
// @ts-expect-error litegraph combo value type does not support arrays yet
fileComboWidget.value = output
// Update widget value to show file names
uploadedFiles.value = Array.isArray(output) ? output : [output]
// Trigger the combo widget callback to update all dependent widgets
fileComboWidget.callback?.(output)
}
}
} as any
}
@@ -138,11 +191,27 @@ export const useImageUploadMediaWidget = () => {
// Register the widget with the node
addWidget(node, uploadWidget as any)
// Store the original callback if it exists
const originalCallback = fileComboWidget.callback
// Add our own callback to the combo widget to render an image when it changes
fileComboWidget.callback = function () {
fileComboWidget.callback = function (value?: any) {
// Call original callback first if it exists
originalCallback?.call(this, value)
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
isAnimated
})
// Use Vue widget for image preview, fallback to DOM widget for video
if (!isVideo) {
showImagePreview(node, fileComboWidget.value, {
allow_batch: allow_batch as boolean,
image_folder: image_folder as string,
imageInputName: imageInputName as string
})
}
node.graph?.setDirtyCanvas(true)
}
@@ -153,7 +222,17 @@ export const useImageUploadMediaWidget = () => {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
isAnimated
})
showPreview({ block: false })
// Use appropriate preview method
if (isVideo) {
showPreview({ block: false })
} else {
showImagePreview(node, fileComboWidget.value, {
allow_batch: allow_batch as boolean,
image_folder: image_folder as string,
imageInputName: imageInputName as string
})
}
})
return { widget: uploadWidget }

View File

@@ -2,6 +2,7 @@ import type { LGraphNode } from '@comfyorg/litegraph'
import { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
import { useNodeImagePreview } from '@/composables/node/useNodeImagePreview'
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
import { useValueTransform } from '@/composables/useValueTransform'
import { t } from '@/i18n'
@@ -41,6 +42,7 @@ export const useImageUploadWidget = () => {
const isVideo = !!inputOptions.video_upload
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
const { showImagePreview } = useNodeImagePreview()
const fileFilter = isVideo ? isVideoFile : isImageFile
// @ts-expect-error InputSpec is not typed correctly
@@ -96,6 +98,16 @@ export const useImageUploadWidget = () => {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
isAnimated
})
// Use Vue widget for image preview, fallback to DOM widget for video
if (!isVideo) {
showImagePreview(node, fileComboWidget.value, {
allow_batch: allow_batch as boolean,
image_folder: image_folder as string,
imageInputName: imageInputName as string
})
}
node.graph?.setDirtyCanvas(true)
}
@@ -106,7 +118,17 @@ export const useImageUploadWidget = () => {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
isAnimated
})
showPreview({ block: false })
// Use appropriate preview method
if (isVideo) {
showPreview({ block: false })
} else {
showImagePreview(node, fileComboWidget.value, {
allow_batch: allow_batch as boolean,
image_folder: image_folder as string,
imageInputName: imageInputName as string
})
}
})
return { widget: uploadWidget }

View File

@@ -50,7 +50,9 @@ export const useMediaLoaderWidget = (options: MediaLoaderOptions = {}) => {
},
// Optional: minimum height for the widget
getMinHeight: () => (options.minHeight ?? 500) + PADDING,
// getMinHeight: () => (options.minHeight ?? 64) + PADDING,
getMaxHeight: () => 225 + PADDING,
getMinHeight: () => 176 + PADDING,
// Optional: whether to serialize this widget's value
serialize: true,

View File

@@ -9,6 +9,7 @@ import { useBadgedNumberInput } from '@/composables/widgets/useBadgedNumberInput
import { useBooleanWidget } from '@/composables/widgets/useBooleanWidget'
import { useColorPickerWidget } from '@/composables/widgets/useColorPickerWidget'
import { useComboWidget } from '@/composables/widgets/useComboWidget'
import { useImagePreviewWidget } from '@/composables/widgets/useImagePreviewWidget'
import { useImageUploadMediaWidget } from '@/composables/widgets/useImageUploadMediaWidget'
import { useMarkdownWidget } from '@/composables/widgets/useMarkdownWidget'
import { useMediaLoaderWidget } from '@/composables/widgets/useMediaLoaderWidget'
@@ -292,5 +293,6 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
COLOR: transformWidgetConstructorV2ToV1(useColorPickerWidget()),
IMAGEUPLOAD: useImageUploadMediaWidget(),
MEDIA_LOADER: transformWidgetConstructorV2ToV1(useMediaLoaderWidget()),
IMAGEPREVIEW: transformWidgetConstructorV2ToV1(useImagePreviewWidget()),
BADGED_NUMBER: transformWidgetConstructorV2ToV1(useBadgedNumberInput())
}

View File

@@ -552,7 +552,13 @@ export const useLitegraphService = () => {
showAnimatedPreview(this)
} else {
removeAnimatedPreview(this)
showCanvasImagePreview(this)
// Only show canvas image preview if we don't already have a Vue image preview widget
const hasVueImagePreview = this.widgets?.some(
(w) => w.name === '$$node-image-preview' || w.type === 'IMAGEPREVIEW'
)
if (!hasVueImagePreview) {
showCanvasImagePreview(this)
}
}
}

View File

@@ -0,0 +1,55 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { describe, expect, it, vi } from 'vitest'
import { useImagePreviewWidget } from '@/composables/widgets/useImagePreviewWidget'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
// Mock dependencies
vi.mock('@/scripts/domWidget', () => ({
ComponentWidgetImpl: vi.fn().mockImplementation(() => ({
name: 'test-widget',
value: ''
})),
addWidget: vi.fn()
}))
describe('useImagePreviewWidget', () => {
const mockNode = {
id: 'test-node',
widgets: []
} as unknown as LGraphNode
const mockInputSpec: InputSpec = {
name: 'image_preview',
type: 'IMAGEPREVIEW',
allow_batch: true,
image_folder: 'input'
}
it('creates widget constructor with default options', () => {
const constructor = useImagePreviewWidget()
expect(constructor).toBeDefined()
expect(typeof constructor).toBe('function')
})
it('creates widget with custom default value', () => {
const constructor = useImagePreviewWidget({
defaultValue: 'test-image.png'
})
expect(constructor).toBeDefined()
})
it('creates widget with array default value for batch mode', () => {
const constructor = useImagePreviewWidget({
defaultValue: ['image1.png', 'image2.png']
})
expect(constructor).toBeDefined()
})
it('calls constructor with node and inputSpec', () => {
const constructor = useImagePreviewWidget()
const widget = constructor(mockNode, mockInputSpec)
expect(widget).toBeDefined()
})
})

View File

@@ -0,0 +1,69 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { describe, expect, it, vi } from 'vitest'
import { useNodeImagePreview } from '@/composables/node/useNodeImagePreview'
// Mock dependencies
vi.mock('@/composables/widgets/useImagePreviewWidget', () => ({
useImagePreviewWidget: vi.fn(() =>
vi.fn(() => ({
name: '$$node-image-preview',
value: '',
onRemove: vi.fn()
}))
)
}))
describe('useNodeImagePreview', () => {
const mockNode = {
id: 'test-node',
widgets: [],
setDirtyCanvas: vi.fn()
} as unknown as LGraphNode
it('provides showImagePreview and removeImagePreview functions', () => {
const { showImagePreview, removeImagePreview } = useNodeImagePreview()
expect(showImagePreview).toBeDefined()
expect(removeImagePreview).toBeDefined()
expect(typeof showImagePreview).toBe('function')
expect(typeof removeImagePreview).toBe('function')
})
it('shows image preview for single image', () => {
const { showImagePreview } = useNodeImagePreview()
showImagePreview(mockNode, 'test-image.png')
expect(mockNode.setDirtyCanvas).toHaveBeenCalledWith(true)
})
it('shows image preview for multiple images', () => {
const { showImagePreview } = useNodeImagePreview()
showImagePreview(mockNode, ['image1.png', 'image2.png'], {
allow_batch: true
})
expect(mockNode.setDirtyCanvas).toHaveBeenCalledWith(true)
})
it('removes image preview widget', () => {
const mockWidget = {
name: '$$node-image-preview',
onRemove: vi.fn()
}
const nodeWithWidget = {
...mockNode,
widgets: [mockWidget]
} as unknown as LGraphNode
const { removeImagePreview } = useNodeImagePreview()
removeImagePreview(nodeWithWidget)
expect(mockWidget.onRemove).toHaveBeenCalled()
expect(nodeWithWidget.widgets).toHaveLength(0)
})
})