mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
Merge remote-tracking branch 'origin/main' into refactor/model-node-mappings-data
# Conflicts: # src/stores/modelToNodeStore.ts
This commit is contained in:
@@ -10,6 +10,8 @@ import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
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'
|
||||
@@ -17,6 +19,7 @@ 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'
|
||||
@@ -38,6 +41,7 @@ const { mobile = false, builderMode = false } = defineProps<{
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const maskEditor = useMaskEditor()
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
@@ -97,21 +101,27 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
function getDropIndicator(node: LGraphNode) {
|
||||
if (node.type !== 'LoadImage') return undefined
|
||||
|
||||
const filename = node.widgets?.[0]?.value
|
||||
const resultItem = { type: 'input', filename: `${filename}` }
|
||||
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(resultItem)
|
||||
appendCloudResParam(params, resultItem.filename)
|
||||
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: buildImageUrl(),
|
||||
imageUrl,
|
||||
label: mobile ? undefined : t('linearMode.dragAndDropImage'),
|
||||
onClick: () => node.widgets?.[1]?.callback?.(undefined)
|
||||
onClick: () => node.widgets?.[1]?.callback?.(undefined),
|
||||
onMaskEdit: imageUrl ? () => maskEditor.openMaskEditor(node) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -23,6 +24,7 @@ const { source, align = 'start' } = defineProps<{
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const dropdownOpen = ref(false)
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(
|
||||
@@ -43,6 +45,16 @@ function handleOpen(open: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleModeTooltip() {
|
||||
const label = canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.enterNodeGraph')
|
||||
: t('breadcrumbsMenu.enterAppMode')
|
||||
const shortcut = keybindingStore
|
||||
.getKeybindingByCommandId('Comfy.ToggleLinear')
|
||||
?.combo.toString()
|
||||
return label + (shortcut ? t('g.shortcutSuffix', { shortcut }) : '')
|
||||
}
|
||||
|
||||
function toggleLinearMode() {
|
||||
dropdownOpen.value = false
|
||||
void useCommandStore().execute('Comfy.ToggleLinear', {
|
||||
@@ -52,7 +64,14 @@ function toggleLinearMode() {
|
||||
|
||||
const tooltipPt = {
|
||||
root: {
|
||||
style: { transform: 'translateX(calc(50% - 16px))' }
|
||||
style: {
|
||||
transform: 'translateX(calc(50% - 16px))',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 'none'
|
||||
}
|
||||
},
|
||||
text: {
|
||||
style: { whiteSpace: 'nowrap' }
|
||||
},
|
||||
arrow: {
|
||||
class: '!left-[16px]'
|
||||
@@ -68,9 +87,7 @@ const tooltipPt = {
|
||||
>
|
||||
<Button
|
||||
v-tooltip.bottom="{
|
||||
value: canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.enterNodeGraph')
|
||||
: t('breadcrumbsMenu.enterAppMode'),
|
||||
value: toggleModeTooltip(),
|
||||
showDelay: 300,
|
||||
hideDelay: 300,
|
||||
pt: tooltipPt
|
||||
|
||||
@@ -5,6 +5,19 @@ import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
|
||||
export function extractWidgetStringValue(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return value
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
'filename' in value &&
|
||||
typeof value.filename === 'string'
|
||||
)
|
||||
return value.filename
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Private image utility functions
|
||||
interface ImageLayerFilenames {
|
||||
@@ -84,62 +97,23 @@ export function useMaskEditorLoader() {
|
||||
|
||||
let nodeImageRef = parseImageRef(nodeImageUrl)
|
||||
|
||||
let widgetFilename: string | undefined
|
||||
if (node.widgets) {
|
||||
const imageWidget = node.widgets.find((w) => w.name === 'image')
|
||||
if (imageWidget) {
|
||||
if (typeof imageWidget.value === 'string') {
|
||||
widgetFilename = imageWidget.value
|
||||
} else if (
|
||||
typeof imageWidget.value === 'object' &&
|
||||
imageWidget.value &&
|
||||
'filename' in imageWidget.value &&
|
||||
typeof imageWidget.value.filename === 'string'
|
||||
) {
|
||||
widgetFilename = imageWidget.value.filename
|
||||
}
|
||||
}
|
||||
}
|
||||
const imageWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
const widgetFilename = imageWidget
|
||||
? extractWidgetStringValue(imageWidget.value)
|
||||
: undefined
|
||||
|
||||
// If we have a widget filename, we should prioritize it over the node image
|
||||
// because the node image might be stale (e.g. from a previous save)
|
||||
// while the widget value reflects the current selection.
|
||||
// Skip internal reference formats (e.g. "$35-0" used by some plugins like Impact-Pack)
|
||||
if (widgetFilename && !widgetFilename.startsWith('$')) {
|
||||
try {
|
||||
// Parse the widget value which might be in format "subfolder/filename [type]" or just "filename"
|
||||
let filename = widgetFilename
|
||||
let subfolder: string | undefined = undefined
|
||||
let type: string | undefined = 'input' // Default to input for widget values
|
||||
|
||||
// Check for type in brackets at the end
|
||||
const typeMatch = filename.match(/ \[([^\]]+)\]$/)
|
||||
if (typeMatch) {
|
||||
type = typeMatch[1]
|
||||
filename = filename.substring(
|
||||
0,
|
||||
filename.length - typeMatch[0].length
|
||||
)
|
||||
}
|
||||
|
||||
// Check for subfolder (forward slash separator)
|
||||
const lastSlashIndex = filename.lastIndexOf('/')
|
||||
if (lastSlashIndex !== -1) {
|
||||
subfolder = filename.substring(0, lastSlashIndex)
|
||||
filename = filename.substring(lastSlashIndex + 1)
|
||||
}
|
||||
|
||||
nodeImageRef = {
|
||||
filename,
|
||||
type,
|
||||
subfolder
|
||||
}
|
||||
|
||||
// We also need to update nodeImageUrl to match this new ref so subsequent logic works
|
||||
nodeImageUrl = mkFileUrl({ ref: nodeImageRef })
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse widget filename as ref', e)
|
||||
const parsed = parseImageWidgetValue(widgetFilename)
|
||||
nodeImageRef = {
|
||||
filename: parsed.filename,
|
||||
type: parsed.type || 'input',
|
||||
subfolder: parsed.subfolder || undefined
|
||||
}
|
||||
nodeImageUrl = mkFileUrl({ ref: nodeImageRef })
|
||||
}
|
||||
|
||||
const fileToQuery = widgetFilename || nodeImageRef.filename
|
||||
|
||||
@@ -1158,6 +1158,7 @@
|
||||
},
|
||||
"maskEditor": {
|
||||
"title": "Mask Editor",
|
||||
"openMaskEditor": "Open in Mask Editor",
|
||||
"invert": "Invert",
|
||||
"clear": "Clear",
|
||||
"undo": "Undo",
|
||||
|
||||
@@ -56,9 +56,8 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
ctrl: true,
|
||||
shift: true,
|
||||
key: 'a'
|
||||
alt: true,
|
||||
key: 'm'
|
||||
},
|
||||
commandId: 'Comfy.ToggleLinear'
|
||||
},
|
||||
@@ -179,7 +178,8 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
{
|
||||
combo: {
|
||||
key: 'm',
|
||||
alt: true
|
||||
alt: true,
|
||||
shift: true
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ToggleMinimap'
|
||||
},
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 shrink-0 rounded-lg text-sm"
|
||||
@click="copyToClipboard(model.representative.url!)"
|
||||
@click="copyToClipboard(toBrowsableUrl(model.representative.url!))"
|
||||
>
|
||||
{{ t('rightSidePanel.missingModels.copyUrl') }}
|
||||
</Button>
|
||||
@@ -201,7 +201,8 @@ import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
downloadModel,
|
||||
isModelDownloadable
|
||||
isModelDownloadable,
|
||||
toBrowsableUrl
|
||||
} from '@/platform/missingModel/missingModelDownload'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
|
||||
import { fetchModelMetadata } from './missingModelDownload'
|
||||
import { fetchModelMetadata, toBrowsableUrl } from './missingModelDownload'
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
@@ -140,3 +140,41 @@ describe('fetchModelMetadata', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toBrowsableUrl', () => {
|
||||
it('replaces /resolve/ with /blob/ in HuggingFace URLs', () => {
|
||||
expect(
|
||||
toBrowsableUrl(
|
||||
'https://huggingface.co/org/model/resolve/main/file.safetensors'
|
||||
)
|
||||
).toBe('https://huggingface.co/org/model/blob/main/file.safetensors')
|
||||
})
|
||||
|
||||
it('returns non-HuggingFace URLs unchanged', () => {
|
||||
const url =
|
||||
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
|
||||
expect(toBrowsableUrl(url)).toBe(url)
|
||||
})
|
||||
|
||||
it('preserves query params in HuggingFace URLs', () => {
|
||||
expect(
|
||||
toBrowsableUrl(
|
||||
'https://huggingface.co/bfl/FLUX.1/resolve/main/model.safetensors?download=true'
|
||||
)
|
||||
).toBe(
|
||||
'https://huggingface.co/bfl/FLUX.1/blob/main/model.safetensors?download=true'
|
||||
)
|
||||
})
|
||||
|
||||
it('converts Civitai api/download URL to model page', () => {
|
||||
expect(
|
||||
toBrowsableUrl('https://civitai.com/api/download/models/12345')
|
||||
).toBe('https://civitai.com/models/12345')
|
||||
})
|
||||
|
||||
it('converts Civitai api/v1 URL to model page', () => {
|
||||
expect(toBrowsableUrl('https://civitai.com/api/v1/models/12345')).toBe(
|
||||
'https://civitai.com/models/12345'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,21 @@ interface ModelWithUrl {
|
||||
directory: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a model download URL to a browsable page URL.
|
||||
* - HuggingFace: `/resolve/` → `/blob/` (file page with model info)
|
||||
* - Civitai: strips `/api/download` or `/api/v1` prefix (model page)
|
||||
*/
|
||||
export function toBrowsableUrl(url: string): string {
|
||||
if (isCivitaiModelUrl(url)) {
|
||||
return url.replace('/api/download/', '/').replace('/api/v1/', '/')
|
||||
}
|
||||
if (url.includes('huggingface.co')) {
|
||||
return url.replace('/resolve/', '/blob/')
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
export function isModelDownloadable(model: ModelWithUrl): boolean {
|
||||
if (WHITE_LISTED_URLS.has(model.url)) return true
|
||||
if (!ALLOWED_SOURCES.some((source) => model.url.startsWith(source)))
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { useDropZone } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
onDragOver,
|
||||
onDragDrop,
|
||||
@@ -17,6 +20,7 @@ const {
|
||||
imageUrl?: string
|
||||
label?: string
|
||||
onClick?: (e: MouseEvent) => void
|
||||
onMaskEdit?: () => void
|
||||
}
|
||||
forceHovered?: boolean
|
||||
}>()
|
||||
@@ -91,7 +95,7 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
|
||||
data-slot="drop-zone-indicator"
|
||||
:class="
|
||||
cn(
|
||||
'm-3 block h-42 min-h-32 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',
|
||||
'group/dropzone m-3 block h-42 min-h-32 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?.onClick && 'cursor-pointer'
|
||||
)
|
||||
"
|
||||
@@ -108,12 +112,26 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
|
||||
)
|
||||
"
|
||||
>
|
||||
<img
|
||||
<div
|
||||
v-if="dropIndicator?.imageUrl"
|
||||
class="max-h-full max-w-full rounded-md object-contain"
|
||||
:alt="dropIndicator?.label ?? ''"
|
||||
:src="dropIndicator?.imageUrl"
|
||||
/>
|
||||
class="relative max-h-full max-w-full"
|
||||
>
|
||||
<img
|
||||
class="max-h-full max-w-full rounded-md object-contain"
|
||||
:alt="dropIndicator?.label ?? ''"
|
||||
:src="dropIndicator?.imageUrl"
|
||||
/>
|
||||
<button
|
||||
v-if="dropIndicator?.onMaskEdit"
|
||||
type="button"
|
||||
:aria-label="t('maskEditor.openMaskEditor')"
|
||||
:title="t('maskEditor.openMaskEditor')"
|
||||
class="absolute top-2 right-2 flex cursor-pointer items-center justify-center rounded-lg bg-base-foreground p-2 text-base-background opacity-0 transition-colors duration-200 group-hover/dropzone:opacity-100 hover:bg-base-foreground/90"
|
||||
@click.stop="dropIndicator?.onMaskEdit?.()"
|
||||
>
|
||||
<i class="icon-[comfy--mask] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<template v-else>
|
||||
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
|
||||
<i
|
||||
|
||||
@@ -140,9 +140,9 @@ export const MODEL_NODE_MAPPINGS: ReadonlyArray<
|
||||
],
|
||||
|
||||
// ---- CogVideoX (comfyui-cogvideoxwrapper) ----
|
||||
['CogVideo', 'DownloadAndLoadCogVideoModel', 'model'],
|
||||
['CogVideo', 'DownloadAndLoadCogVideoModel', ''],
|
||||
['CogVideo/GGUF', 'DownloadAndLoadCogVideoGGUFModel', 'model'],
|
||||
['CogVideo/ControlNet', 'DownloadAndLoadCogVideoControlNet', 'model'],
|
||||
['CogVideo/ControlNet', 'DownloadAndLoadCogVideoControlNet', ''],
|
||||
|
||||
// ---- DynamiCrafter (ComfyUI-DynamiCrafterWrapper) ----
|
||||
['checkpoints/dynamicrafter', 'DownloadAndLoadDynamiCrafterModel', 'model'],
|
||||
|
||||
@@ -178,6 +178,71 @@ describe('useModelToNodeStore', () => {
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('progressive hierarchical fallback', () => {
|
||||
it('should resolve 1-level path via exact match', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.quickRegister('level1', 'UNETLoader', 'key1')
|
||||
|
||||
const provider = modelToNodeStore.getNodeProvider('level1')
|
||||
expect(provider?.nodeDef?.name).toBe('UNETLoader')
|
||||
})
|
||||
|
||||
it('should resolve 2-level path to registered parent', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.quickRegister('level1', 'UNETLoader', 'key1')
|
||||
|
||||
const provider = modelToNodeStore.getNodeProvider('level1/child')
|
||||
expect(provider?.nodeDef?.name).toBe('UNETLoader')
|
||||
})
|
||||
|
||||
it('should resolve 3-level path to nearest registered ancestor', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.quickRegister('level1', 'UNETLoader', 'key1')
|
||||
modelToNodeStore.quickRegister('level1/level2', 'VAELoader', 'key2')
|
||||
|
||||
// 3 levels: should match level1/level2 (nearest), not level1
|
||||
const provider = modelToNodeStore.getNodeProvider('level1/level2/child')
|
||||
expect(provider?.nodeDef?.name).toBe('VAELoader')
|
||||
})
|
||||
|
||||
it('should resolve 4-level path to nearest registered ancestor', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.quickRegister('a', 'UNETLoader', 'k1')
|
||||
modelToNodeStore.quickRegister('a/b', 'VAELoader', 'k2')
|
||||
modelToNodeStore.quickRegister('a/b/c', 'StyleModelLoader', 'k3')
|
||||
|
||||
// 4 levels: should match a/b/c (nearest), not a/b or a
|
||||
const provider = modelToNodeStore.getNodeProvider('a/b/c/d')
|
||||
expect(provider?.nodeDef?.name).toBe('StyleModelLoader')
|
||||
})
|
||||
|
||||
it('should skip intermediate unregistered levels', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.quickRegister('a', 'UNETLoader', 'k1')
|
||||
// a/b is NOT registered
|
||||
|
||||
// 3 levels: a/b not found, falls back to a
|
||||
const provider = modelToNodeStore.getNodeProvider('a/b/c')
|
||||
expect(provider?.nodeDef?.name).toBe('UNETLoader')
|
||||
})
|
||||
|
||||
it('should prefer exact match over any fallback', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.quickRegister('a', 'UNETLoader', 'k1')
|
||||
modelToNodeStore.quickRegister('a/b/c', 'VAELoader', 'k2')
|
||||
|
||||
const provider = modelToNodeStore.getNodeProvider('a/b/c')
|
||||
expect(provider?.nodeDef?.name).toBe('VAELoader')
|
||||
})
|
||||
|
||||
it('should return undefined when no ancestor is registered', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.quickRegister('x', 'UNETLoader', 'k1')
|
||||
|
||||
expect(modelToNodeStore.getNodeProvider('y/z/w')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return provider for chatterbox nodes with empty key', () => {
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
modelToNodeStore.registerDefaults()
|
||||
|
||||
@@ -76,8 +76,8 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
|
||||
|
||||
/**
|
||||
* Find providers for modelType with hierarchical fallback.
|
||||
* Tries exact match first, then falls back to top-level segment (e.g., "parent/child" → "parent").
|
||||
* Note: Only falls back one level; "a/b/c" tries "a/b/c" then "a", not "a/b".
|
||||
* Tries exact match first, then progressively shorter parent paths.
|
||||
* e.g., "a/b/c" tries "a/b/c" → "a/b" → "a".
|
||||
*/
|
||||
function findProvidersWithFallback(
|
||||
modelType: string
|
||||
@@ -86,15 +86,12 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const exactMatch = modelToNodeMap.value[modelType]
|
||||
if (exactMatch && exactMatch.length > 0) return exactMatch
|
||||
|
||||
const topLevel = modelType.split('/')[0]
|
||||
if (topLevel === modelType) return undefined
|
||||
|
||||
const fallback = modelToNodeMap.value[topLevel]
|
||||
|
||||
if (fallback && fallback.length > 0) return fallback
|
||||
const segments = modelType.split('/')
|
||||
for (let i = segments.length; i >= 1; i--) {
|
||||
const path = segments.slice(0, i).join('/')
|
||||
const providers = modelToNodeMap.value[path]
|
||||
if (providers && providers.length > 0) return providers
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
57
src/utils/imageUtil.test.ts
Normal file
57
src/utils/imageUtil.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseImageWidgetValue } from './imageUtil'
|
||||
|
||||
describe('parseImageWidgetValue', () => {
|
||||
it('parses a plain filename', () => {
|
||||
expect(parseImageWidgetValue('example.png')).toEqual({
|
||||
filename: 'example.png',
|
||||
subfolder: '',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses filename with type suffix', () => {
|
||||
expect(parseImageWidgetValue('example.png [output]')).toEqual({
|
||||
filename: 'example.png',
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses subfolder and filename', () => {
|
||||
expect(parseImageWidgetValue('clipspace/mask-123.png')).toEqual({
|
||||
filename: 'mask-123.png',
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses subfolder, filename, and type', () => {
|
||||
expect(
|
||||
parseImageWidgetValue(
|
||||
'clipspace/clipspace-painted-masked-123.png [input]'
|
||||
)
|
||||
).toEqual({
|
||||
filename: 'clipspace-painted-masked-123.png',
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses nested subfolders', () => {
|
||||
expect(parseImageWidgetValue('a/b/c/image.png [temp]')).toEqual({
|
||||
filename: 'image.png',
|
||||
subfolder: 'a/b/c',
|
||||
type: 'temp'
|
||||
})
|
||||
})
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(parseImageWidgetValue('')).toEqual({
|
||||
filename: '',
|
||||
subfolder: '',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,27 @@
|
||||
/**
|
||||
* Parses an image widget value like "subfolder/filename [type]" into its parts.
|
||||
*/
|
||||
export function parseImageWidgetValue(raw: string): {
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
} {
|
||||
let value = raw
|
||||
let type = 'input'
|
||||
const typeMatch = value.match(/ \[([^\]]+)\]$/)
|
||||
if (typeMatch) {
|
||||
type = typeMatch[1]
|
||||
value = value.slice(0, -typeMatch[0].length)
|
||||
}
|
||||
let subfolder = ''
|
||||
const slashIndex = value.lastIndexOf('/')
|
||||
if (slashIndex !== -1) {
|
||||
subfolder = value.slice(0, slashIndex)
|
||||
value = value.slice(slashIndex + 1)
|
||||
}
|
||||
return { filename: value, subfolder, type }
|
||||
}
|
||||
|
||||
export const is_all_same_aspect_ratio = (imgs: HTMLImageElement[]): boolean => {
|
||||
if (!imgs.length || imgs.length === 1) return true
|
||||
|
||||
|
||||
Reference in New Issue
Block a user