Compare commits

...

6 Commits

6 changed files with 336 additions and 32 deletions

View File

@@ -3,6 +3,7 @@ import { useEventListener } from '@vueuse/core'
import { computed, provide, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { buildDropIndicator } from '@/components/builder/dropIndicatorUtil'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
@@ -12,15 +13,11 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
@@ -101,30 +98,11 @@ const mappedSelections = computed((): WidgetEntry[] => {
})
function getDropIndicator(node: LGraphNode) {
if (node.type !== 'LoadImage') return undefined
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
const { filename, subfolder, type } = stringValue
? parseImageWidgetValue(stringValue)
: { filename: '', subfolder: '', type: 'input' }
const buildImageUrl = () => {
if (!filename) return undefined
const params = new URLSearchParams({ filename, subfolder, type })
appendCloudResParam(params, filename)
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
}
const imageUrl = buildImageUrl()
return {
iconClass: 'icon-[lucide--image]',
imageUrl,
label: mobile ? undefined : t('linearMode.dragAndDropImage'),
onClick: () => node.widgets?.[1]?.callback?.(undefined),
onMaskEdit: imageUrl ? () => maskEditor.openMaskEditor(node) : undefined
}
return buildDropIndicator(node, {
imageLabel: mobile ? undefined : t('linearMode.dragAndDropImage'),
videoLabel: mobile ? undefined : t('linearMode.dragAndDropVideo'),
openMaskEditor: maskEditor.openMaskEditor
})
}
function nodeToNodeData(node: LGraphNode) {

View File

@@ -0,0 +1,138 @@
import { describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { buildDropIndicator } from './dropIndicatorUtil'
vi.mock('@/scripts/api', () => ({
api: { apiURL: (path: string) => `http://localhost:8188${path}` }
}))
vi.mock('@/scripts/app', () => ({
app: { getPreviewFormatParam: () => '&format=webp' }
}))
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
appendCloudResParam: vi.fn()
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn()
}))
function makeNode(type: string, widgetValue?: unknown): LGraphNode {
return {
type,
widgets:
widgetValue !== undefined
? [{ value: widgetValue }, { callback: vi.fn() }]
: undefined
} as unknown as LGraphNode
}
describe('buildDropIndicator', () => {
it('returns undefined for unsupported node types', () => {
expect(buildDropIndicator(makeNode('KSampler'), {})).toBeUndefined()
expect(buildDropIndicator(makeNode('CLIPTextEncode'), {})).toBeUndefined()
})
it('returns image indicator for LoadImage node with filename', () => {
const result = buildDropIndicator(makeNode('LoadImage', 'photo.png'), {
imageLabel: 'Upload'
})
expect(result).toBeDefined()
expect(result!.iconClass).toBe('icon-[lucide--image]')
expect(result!.imageUrl).toContain('/view?')
expect(result!.imageUrl).toContain('filename=photo.png')
expect(result!.label).toBe('Upload')
})
it('downloads the original image, not the preview rendition', async () => {
const { downloadFile } = vi.mocked(
await import('@/base/common/downloadUtil')
)
const result = buildDropIndicator(makeNode('LoadImage', 'photo.png'), {})
expect(result!.imageUrl).toContain('&format=webp')
expect(result!.onDownload).toBeDefined()
result!.onDownload!()
expect(downloadFile).toHaveBeenCalledWith(
expect.not.stringContaining('format=webp')
)
})
it('returns image indicator with no imageUrl when widget has no value', () => {
const result = buildDropIndicator(makeNode('LoadImage', ''), {})
expect(result).toBeDefined()
expect(result!.imageUrl).toBeUndefined()
})
it('returns image indicator with no imageUrl when widgets are missing', () => {
const node = { type: 'LoadImage' } as unknown as LGraphNode
const result = buildDropIndicator(node, {})
expect(result).toBeDefined()
expect(result!.imageUrl).toBeUndefined()
})
it('includes onMaskEdit when imageUrl exists and openMaskEditor is provided', () => {
const openMaskEditor = vi.fn()
const node = makeNode('LoadImage', 'photo.png')
const result = buildDropIndicator(node, { openMaskEditor })
expect(result!.onMaskEdit).toBeDefined()
result!.onMaskEdit!()
expect(openMaskEditor).toHaveBeenCalledWith(node)
})
it('omits onMaskEdit when no imageUrl', () => {
const openMaskEditor = vi.fn()
const result = buildDropIndicator(makeNode('LoadImage', ''), {
openMaskEditor
})
expect(result!.onMaskEdit).toBeUndefined()
})
it('returns video indicator for LoadVideo node with filename', () => {
const result = buildDropIndicator(makeNode('LoadVideo', 'clip.mp4'), {
videoLabel: 'Upload Video'
})
expect(result).toBeDefined()
expect(result!.iconClass).toBe('icon-[lucide--video]')
expect(result!.videoUrl).toContain('/view?')
expect(result!.videoUrl).toContain('filename=clip.mp4')
expect(result!.label).toBe('Upload Video')
expect(result!.onMaskEdit).toBeUndefined()
})
it('returns video indicator with no videoUrl when widget has no value', () => {
const result = buildDropIndicator(makeNode('LoadVideo', ''), {})
expect(result).toBeDefined()
expect(result!.videoUrl).toBeUndefined()
})
it('parses subfolder and type from widget value', () => {
const result = buildDropIndicator(
makeNode('LoadImage', 'sub/folder/image.png [output]'),
{}
)
expect(result!.imageUrl).toContain('filename=image.png')
expect(result!.imageUrl).toContain('subfolder=sub%2Ffolder')
expect(result!.imageUrl).toContain('type=output')
})
it('invokes widget callback on onClick', () => {
const node = makeNode('LoadImage', 'photo.png')
const result = buildDropIndicator(node, {})
result!.onClick!({} as MouseEvent)
expect(node.widgets![1].callback).toHaveBeenCalledWith(undefined)
})
})

View File

@@ -0,0 +1,125 @@
import { downloadFile } from '@/base/common/downloadUtil'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { parseImageWidgetValue } from '@/utils/imageUtil'
export interface DropIndicatorData {
iconClass: string
imageUrl?: string
videoUrl?: string
label?: string
onClick?: (e: MouseEvent) => void
onMaskEdit?: () => void
onDownload?: () => void
onRemove?: () => void
}
function parseNodeMediaValue(node: LGraphNode) {
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
return stringValue
? parseImageWidgetValue(stringValue)
: { filename: '', subfolder: '', type: 'input' }
}
/**
* Build a DropZone indicator for LoadImage or LoadVideo nodes.
* Returns undefined for other node types.
*/
export function buildDropIndicator(
node: LGraphNode,
options: {
imageLabel?: string
videoLabel?: string
openMaskEditor?: (node: LGraphNode) => void
}
): DropIndicatorData | undefined {
if (node.type === 'LoadImage') {
return buildImageDropIndicator(node, options)
}
if (node.type === 'LoadVideo') {
return buildVideoDropIndicator(node, options)
}
return undefined
}
/** Build indicator data for a LoadImage node, including preview URL,
* mask editor action, and download/remove using the original asset URL. */
function buildImageDropIndicator(
node: LGraphNode,
options: {
imageLabel?: string
openMaskEditor?: (node: LGraphNode) => void
}
): DropIndicatorData {
const { filename, subfolder, type } = parseNodeMediaValue(node)
const rawParams = filename
? new URLSearchParams({ filename, subfolder, type })
: undefined
const imageUrl = rawParams
? (() => {
const params = new URLSearchParams(rawParams)
appendCloudResParam(params, filename)
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
})()
: undefined
const originalUrl = rawParams ? api.apiURL(`/view?${rawParams}`) : undefined
return {
iconClass: 'icon-[lucide--image]',
imageUrl,
label: options.imageLabel,
onClick: () => node.widgets?.[1]?.callback?.(undefined),
onMaskEdit:
imageUrl && options.openMaskEditor
? () => options.openMaskEditor!(node)
: undefined,
onDownload: originalUrl ? () => downloadFile(originalUrl) : undefined,
onRemove: imageUrl
? () => {
const imageWidget = node.widgets?.find((w) => w.name === 'image')
if (imageWidget) {
imageWidget.value = ''
imageWidget.callback?.(undefined)
}
}
: undefined
}
}
/** Build indicator data for a LoadVideo node with video preview URL
* and download/remove actions. */
function buildVideoDropIndicator(
node: LGraphNode,
options: { videoLabel?: string }
): DropIndicatorData {
const { filename, subfolder, type } = parseNodeMediaValue(node)
const videoUrl = filename
? api.apiURL(`/view?${new URLSearchParams({ filename, subfolder, type })}`)
: undefined
return {
iconClass: 'icon-[lucide--video]',
videoUrl,
label: options.videoLabel,
onClick: () => node.widgets?.[1]?.callback?.(undefined),
onDownload: videoUrl ? () => downloadFile(videoUrl) : undefined,
onRemove: videoUrl
? () => {
const videoWidget = node.widgets?.find((w) => w.name === 'video')
if (videoWidget) {
videoWidget.value = ''
videoWidget.callback?.(undefined)
}
}
: undefined
}
}

View File

@@ -9,6 +9,7 @@ import {
DialogTitle,
VisuallyHidden
} from 'reka-ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -20,6 +21,16 @@ const { src, alt = '' } = defineProps<{
alt?: string
}>()
const isVideo = computed(() => {
const videoExt = /\.(mp4|webm|mov)/i
return (
videoExt.test(src) ||
videoExt.test(
new URL(src, location.href).searchParams.get('filename') ?? ''
)
)
})
const { t } = useI18n()
</script>
<template>
@@ -46,7 +57,15 @@ const { t } = useI18n()
<i class="icon-[lucide--x] size-5" />
</Button>
</DialogClose>
<video
v-if="isVideo"
:src
controls
autoplay
class="max-h-[90vh] max-w-[90vw] rounded-sm object-contain"
/>
<img
v-else
:src
:alt
class="max-h-[90vh] max-w-[90vw] rounded-sm object-contain"

View File

@@ -3282,6 +3282,7 @@
"giveFeedback": "Give feedback",
"graphMode": "Graph Mode",
"dragAndDropImage": "Click to browse or drag an image",
"dragAndDropVideo": "Click to browse or drag a video",
"mobileControls": "Edit & Run",
"runCount": "Number of runs",
"rerun": "Rerun",

View File

@@ -22,9 +22,12 @@ const {
dropIndicator?: {
iconClass?: string
imageUrl?: string
videoUrl?: string
label?: string
onClick?: (e: MouseEvent) => void
onMaskEdit?: () => void
onDownload?: () => void
onRemove?: () => void
}
forceHovered?: boolean
}>()
@@ -92,7 +95,8 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
data-slot="drop-zone-indicator"
:class="
cn(
'm-3 block h-25 w-[calc(100%-1.5rem)] resize-y appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
'm-3 block w-[calc(100%-1.5rem)] resize-y appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
dropIndicator.videoUrl ? 'h-52' : 'h-25',
dropIndicator.onClick && 'cursor-pointer'
)
"
@@ -104,18 +108,35 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
cn(
'flex h-full max-w-full flex-col items-center justify-center gap-2 overflow-hidden rounded-[7px] p-3 text-center text-sm/tight transition-colors',
isHovered &&
!dropIndicator.imageUrl &&
!(dropIndicator.imageUrl || dropIndicator.videoUrl) &&
'border border-dashed border-component-node-foreground-secondary bg-component-node-widget-background-hovered'
)
"
>
<div v-if="dropIndicator.imageUrl" class="max-h-full max-w-full">
<div
v-if="dropIndicator.imageUrl"
class="flex size-full items-center justify-center overflow-hidden"
>
<img
class="max-h-full max-w-full rounded-md object-contain"
:alt="dropIndicator.label ?? ''"
:src="dropIndicator.imageUrl"
/>
</div>
<div
v-else-if="dropIndicator.videoUrl"
class="flex size-full items-center justify-center overflow-hidden"
@click.stop
>
<video
class="max-h-full max-w-full rounded-md object-contain"
:src="dropIndicator.videoUrl"
preload="metadata"
controls
loop
playsinline
/>
</div>
<template v-else>
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
<i
@@ -130,7 +151,7 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
</template>
</div>
</component>
<template v-if="dropIndicator.imageUrl">
<template v-if="dropIndicator.imageUrl || dropIndicator.videoUrl">
<div
class="absolute top-2 right-5 z-10 flex gap-1 opacity-0 transition-opacity duration-200 group-focus-within/dropzone:opacity-100 group-hover/dropzone:opacity-100"
>
@@ -145,6 +166,7 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
<i class="icon-[comfy--mask] size-4" />
</button>
<button
v-if="dropIndicator.imageUrl"
type="button"
:aria-label="t('mediaAsset.actions.zoom')"
:title="t('mediaAsset.actions.zoom')"
@@ -153,8 +175,29 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
>
<i class="icon-[lucide--zoom-in] size-4" />
</button>
<button
v-if="dropIndicator.onDownload"
type="button"
:aria-label="t('g.downloadImage')"
:title="t('g.downloadImage')"
class="flex cursor-pointer items-center justify-center rounded-lg bg-base-foreground p-2 text-base-background transition-colors hover:bg-base-foreground/90"
@click.stop="dropIndicator.onDownload()"
>
<i class="icon-[lucide--download] size-4" />
</button>
<button
v-if="dropIndicator.onRemove"
type="button"
:aria-label="t('g.removeImage')"
:title="t('g.removeImage')"
class="flex cursor-pointer items-center justify-center rounded-lg bg-base-foreground p-2 text-base-background transition-colors hover:bg-base-foreground/90"
@click.stop="dropIndicator.onRemove()"
>
<i class="icon-[lucide--x] size-4" />
</button>
</div>
<ImageLightbox
v-if="dropIndicator.imageUrl"
v-model="lightboxOpen"
:src="dropIndicator.imageUrl"
:alt="dropIndicator.label ?? ''"