mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-22 15:29:44 +00:00
Compare commits
9 Commits
v1.42.4
...
regen-scre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4a73737e4 | ||
|
|
c318cc4c14 | ||
|
|
bfabf128ce | ||
|
|
6a8f3ef1a1 | ||
|
|
649b9b3fe3 | ||
|
|
d82bce90ea | ||
|
|
91e429a62f | ||
|
|
21edbd3ee5 | ||
|
|
f5363e4028 |
@@ -24,6 +24,7 @@ const extraFileExtensions = ['.vue']
|
||||
const commonGlobals = {
|
||||
...globals.browser,
|
||||
__COMFYUI_FRONTEND_VERSION__: 'readonly',
|
||||
__COMFYUI_FRONTEND_COMMIT__: 'readonly',
|
||||
__DISTRIBUTION__: 'readonly',
|
||||
__IS_NIGHTLY__: 'readonly'
|
||||
} as const
|
||||
|
||||
1
global.d.ts
vendored
1
global.d.ts
vendored
@@ -1,4 +1,5 @@
|
||||
declare const __COMFYUI_FRONTEND_VERSION__: string
|
||||
declare const __COMFYUI_FRONTEND_COMMIT__: string
|
||||
declare const __SENTRY_ENABLED__: boolean
|
||||
declare const __SENTRY_DSN__: string
|
||||
declare const __ALGOLIA_APP_ID__: string
|
||||
|
||||
55
index.html
55
index.html
@@ -47,7 +47,60 @@
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="splash.css" />
|
||||
<style>
|
||||
/* Pre-Vue splash loader — inlined to avoid SPA fallback serving
|
||||
index.html instead of CSS on cloud/ephemeral environments */
|
||||
#splash-loader {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
contain: strict;
|
||||
}
|
||||
#splash-loader svg {
|
||||
width: min(200px, 50vw);
|
||||
height: auto;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
#splash-loader .wave-group {
|
||||
animation: splash-rise 4s ease-in-out infinite alternate;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
#splash-loader .wave-path {
|
||||
animation: splash-wave 1.2s linear infinite;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
@keyframes splash-rise {
|
||||
from {
|
||||
transform: translateY(280px);
|
||||
}
|
||||
to {
|
||||
transform: translateY(-80px);
|
||||
}
|
||||
}
|
||||
@keyframes splash-wave {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-880px);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#splash-loader .wave-group,
|
||||
#splash-loader .wave-path {
|
||||
animation: none;
|
||||
}
|
||||
#splash-loader .wave-group {
|
||||
transform: translateY(-80px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
</head>
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ import type { SystemStats } from '@/schemas/apiSchema'
|
||||
import { formatCommitHash, formatSize } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const frontendCommit = __COMFYUI_FRONTEND_COMMIT__
|
||||
|
||||
const props = defineProps<{
|
||||
stats: SystemStats
|
||||
}>()
|
||||
@@ -77,6 +79,7 @@ type SystemInfoKey = keyof SystemStats['system']
|
||||
type ColumnDef = {
|
||||
field: SystemInfoKey
|
||||
header: string
|
||||
getValue?: () => string
|
||||
format?: (value: string) => string
|
||||
formatNumber?: (value: number) => string
|
||||
}
|
||||
@@ -104,6 +107,7 @@ const cloudColumns: ColumnDef[] = [
|
||||
{
|
||||
field: 'comfyui_frontend_version',
|
||||
header: 'Frontend Version',
|
||||
getValue: () => frontendCommit,
|
||||
format: formatCommitHash
|
||||
},
|
||||
{ field: 'workflow_templates_version', header: 'Templates Version' }
|
||||
@@ -119,7 +123,9 @@ function isOutdated(column: ColumnDef): boolean {
|
||||
}
|
||||
|
||||
function getDisplayValue(column: ColumnDef) {
|
||||
const value = systemInfo.value[column.field]
|
||||
const value = column.getValue
|
||||
? column.getValue()
|
||||
: systemInfo.value[column.field]
|
||||
if (column.formatNumber && typeof value === 'number') {
|
||||
return column.formatNumber(value)
|
||||
}
|
||||
|
||||
@@ -30,31 +30,33 @@
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<Skeleton v-if="showSkeleton(model)" class="ml-1.5 h-4 w-12" />
|
||||
<span
|
||||
v-else-if="model.isDownloadable && fileSizes.get(model.url)"
|
||||
class="pl-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ formatSize(fileSizes.get(model.url)) }}
|
||||
</span>
|
||||
<a
|
||||
v-else-if="gatedModelUrls.has(model.url)"
|
||||
:href="gatedModelUrls.get(model.url)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-primary hover:underline"
|
||||
>
|
||||
{{ $t('missingModelsDialog.acceptTerms') }}
|
||||
</a>
|
||||
<Button
|
||||
v-else-if="model.isDownloadable"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:title="model.url"
|
||||
:aria-label="$t('g.download')"
|
||||
@click="downloadModel(model, paths)"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
<template v-else-if="model.isDownloadable">
|
||||
<span
|
||||
v-if="fileSizes.get(model.url)"
|
||||
class="pl-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ formatSize(fileSizes.get(model.url)) }}
|
||||
</span>
|
||||
<a
|
||||
v-if="gatedModelUrls.has(model.url)"
|
||||
:href="gatedModelUrls.get(model.url)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-primary hover:underline"
|
||||
>
|
||||
{{ $t('missingModelsDialog.acceptTerms') }}
|
||||
</a>
|
||||
<Button
|
||||
v-else
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:title="model.url"
|
||||
:aria-label="$t('g.download')"
|
||||
@click="downloadModel(model, paths)"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<Button
|
||||
v-else
|
||||
variant="textonly"
|
||||
|
||||
68
src/components/ui/color-picker/ColorPicker.stories.ts
Normal file
68
src/components/ui/color-picker/ColorPicker.stories.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ColorPicker from './ColorPicker.vue'
|
||||
|
||||
const meta: Meta<ComponentPropsAndSlots<typeof ColorPicker>> = {
|
||||
title: 'Components/ColorPicker',
|
||||
component: ColorPicker,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'padded' },
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template: '<div class="w-60"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { ColorPicker },
|
||||
setup() {
|
||||
const color = ref('#e06cbd')
|
||||
return { color }
|
||||
},
|
||||
template: '<ColorPicker v-model="color" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Red: Story = {
|
||||
render: () => ({
|
||||
components: { ColorPicker },
|
||||
setup() {
|
||||
const color = ref('#ff0000')
|
||||
return { color }
|
||||
},
|
||||
template: '<ColorPicker v-model="color" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Black: Story = {
|
||||
render: () => ({
|
||||
components: { ColorPicker },
|
||||
setup() {
|
||||
const color = ref('#000000')
|
||||
return { color }
|
||||
},
|
||||
template: '<ColorPicker v-model="color" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const White: Story = {
|
||||
render: () => ({
|
||||
components: { ColorPicker },
|
||||
setup() {
|
||||
const color = ref('#ffffff')
|
||||
return { color }
|
||||
},
|
||||
template: '<ColorPicker v-model="color" />'
|
||||
})
|
||||
}
|
||||
125
src/components/ui/color-picker/ColorPicker.vue
Normal file
125
src/components/ui/color-picker/ColorPicker.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverRoot,
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { HSVA } from '@/utils/colorUtil'
|
||||
import { hexToHsva, hsbToRgb, hsvaToHex, rgbToHex } from '@/utils/colorUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ColorPickerPanel from './ColorPickerPanel.vue'
|
||||
|
||||
defineProps<{
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '#000000' })
|
||||
|
||||
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
|
||||
const displayMode = ref<'hex' | 'rgba'>('hex')
|
||||
|
||||
watch(modelValue, (newVal) => {
|
||||
const current = hsvaToHex(hsva.value)
|
||||
if (newVal !== current) {
|
||||
hsva.value = hexToHsva(newVal || '#000000')
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
hsva,
|
||||
(newHsva) => {
|
||||
const hex = hsvaToHex(newHsva)
|
||||
if (hex !== modelValue.value) {
|
||||
modelValue.value = hex
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
const baseRgb = computed(() =>
|
||||
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
|
||||
)
|
||||
|
||||
const previewColor = computed(() => {
|
||||
const hex = rgbToHex(baseRgb.value)
|
||||
const a = hsva.value.a / 100
|
||||
if (a < 1) {
|
||||
const alphaHex = Math.round(a * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
return `${hex}${alphaHex}`
|
||||
}
|
||||
return hex
|
||||
})
|
||||
|
||||
const displayHex = computed(() => rgbToHex(baseRgb.value).toLowerCase())
|
||||
|
||||
const isOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot v-model:open="isOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-node-component-surface pr-2 outline-none hover:bg-component-node-widget-background-hovered',
|
||||
isOpen && 'border-node-stroke',
|
||||
$props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<div class="relative size-4 overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '4px 4px'
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{ backgroundColor: previewColor }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-between pl-1 text-xs text-node-component-slot-text"
|
||||
>
|
||||
<template v-if="displayMode === 'hex'">
|
||||
<span>{{ displayHex }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex gap-2">
|
||||
<span>{{ baseRgb.r }}</span>
|
||||
<span>{{ baseRgb.g }}</span>
|
||||
<span>{{ baseRgb.b }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span>{{ hsva.a }}%</span>
|
||||
</div>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="7"
|
||||
:collision-padding="10"
|
||||
class="z-1700"
|
||||
>
|
||||
<ColorPickerPanel
|
||||
v-model:hsva="hsva"
|
||||
v-model:display-mode="displayMode"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
81
src/components/ui/color-picker/ColorPickerPanel.vue
Normal file
81
src/components/ui/color-picker/ColorPickerPanel.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import type { HSVA } from '@/utils/colorUtil'
|
||||
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
|
||||
|
||||
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
|
||||
import ColorPickerSlider from './ColorPickerSlider.vue'
|
||||
|
||||
const hsva = defineModel<HSVA>('hsva', { required: true })
|
||||
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
|
||||
required: true
|
||||
})
|
||||
|
||||
const rgb = computed(() =>
|
||||
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
|
||||
)
|
||||
const hexString = computed(() => rgbToHex(rgb.value).toLowerCase())
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex w-[211px] flex-col gap-2 rounded-lg border border-border-subtle bg-base-background p-2 shadow-md"
|
||||
>
|
||||
<ColorPickerSaturationValue
|
||||
v-model:saturation="hsva.s"
|
||||
v-model:value="hsva.v"
|
||||
:hue="hsva.h"
|
||||
/>
|
||||
<ColorPickerSlider v-model="hsva.h" type="hue" />
|
||||
<ColorPickerSlider
|
||||
v-model="hsva.a"
|
||||
type="alpha"
|
||||
:hue="hsva.h"
|
||||
:saturation="hsva.s"
|
||||
:brightness="hsva.v"
|
||||
/>
|
||||
<div class="flex items-center gap-1">
|
||||
<Select v-model="displayMode">
|
||||
<SelectTrigger
|
||||
class="h-6 w-[58px] shrink-0 gap-0.5 overflow-clip rounded-sm border-0 px-1.5 py-0 text-xs [&>span]:overflow-visible"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent class="min-w-16 p-1">
|
||||
<SelectItem value="hex" class="px-2 py-1 text-xs">
|
||||
{{ t('color.hex') }}
|
||||
</SelectItem>
|
||||
<SelectItem value="rgba" class="px-2 py-1 text-xs">
|
||||
{{ t('color.rgba') }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div
|
||||
class="flex h-6 min-w-0 flex-1 items-center gap-1 rounded-sm bg-secondary-background px-1 text-xs text-node-component-slot-text"
|
||||
>
|
||||
<template v-if="displayMode === 'hex'">
|
||||
<span class="min-w-0 flex-1 truncate text-center">{{
|
||||
hexString
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.r }}</span>
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.g }}</span>
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.b }}</span>
|
||||
</template>
|
||||
<span class="shrink-0 border-l border-border-subtle pl-1"
|
||||
>{{ hsva.a }}%</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hue } = defineProps<{
|
||||
hue: number
|
||||
}>()
|
||||
|
||||
const saturation = defineModel<number>('saturation', { required: true })
|
||||
const value = defineModel<number>('value', { required: true })
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const hueBackground = computed(() => `hsl(${hue}, 100%, 50%)`)
|
||||
|
||||
const handleStyle = computed(() => ({
|
||||
left: `${saturation.value}%`,
|
||||
top: `${100 - value.value}%`
|
||||
}))
|
||||
|
||||
function updateFromPointer(e: PointerEvent) {
|
||||
const el = containerRef.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
|
||||
saturation.value = Math.round(x * 100)
|
||||
value.value = Math.round((1 - y) * 100)
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
updateFromPointer(e)
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
|
||||
updateFromPointer(e)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
role="slider"
|
||||
:aria-label="t('color.saturationBrightness')"
|
||||
:aria-valuetext="`${saturation}%, ${value}%`"
|
||||
class="relative aspect-square w-full cursor-crosshair rounded-sm"
|
||||
:style="{ backgroundColor: hueBackground, touchAction: 'none' }"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 rounded-sm bg-linear-to-r from-white to-transparent"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 rounded-sm bg-linear-to-b from-transparent to-black"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute size-3.5 -translate-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
|
||||
:style="handleStyle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
91
src/components/ui/color-picker/ColorPickerSlider.vue
Normal file
91
src/components/ui/color-picker/ColorPickerSlider.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
type,
|
||||
hue = 0,
|
||||
saturation = 100,
|
||||
brightness = 100
|
||||
} = defineProps<{
|
||||
type: 'hue' | 'alpha'
|
||||
hue?: number
|
||||
saturation?: number
|
||||
brightness?: number
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ required: true })
|
||||
|
||||
const max = computed(() => (type === 'hue' ? 360 : 100))
|
||||
|
||||
const fraction = computed(() => modelValue.value / max.value)
|
||||
|
||||
const trackBackground = computed(() => {
|
||||
if (type === 'hue') {
|
||||
return 'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)'
|
||||
}
|
||||
const rgb = hsbToRgb({ h: hue, s: saturation, b: brightness })
|
||||
const hex = rgbToHex(rgb)
|
||||
return `linear-gradient(to right, transparent, ${hex})`
|
||||
})
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
if (type === 'alpha') {
|
||||
return {
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '8px 8px',
|
||||
touchAction: 'none'
|
||||
}
|
||||
}
|
||||
return {
|
||||
background: trackBackground.value,
|
||||
touchAction: 'none'
|
||||
}
|
||||
})
|
||||
|
||||
function updateFromPointer(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const rect = el.getBoundingClientRect()
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
modelValue.value = Math.round(x * max.value)
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
updateFromPointer(e)
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
|
||||
updateFromPointer(e)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="slider"
|
||||
:aria-label="type === 'hue' ? t('color.hue') : t('color.alpha')"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="max"
|
||||
:aria-valuenow="modelValue"
|
||||
class="relative flex h-4 cursor-pointer items-center rounded-full p-px"
|
||||
:style="containerStyle"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
>
|
||||
<div
|
||||
v-if="type === 'alpha'"
|
||||
class="absolute inset-0 rounded-full"
|
||||
:style="{ background: trackBackground }"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute aspect-square h-full -translate-x-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
|
||||
:style="{ left: `${fraction * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,9 +1,12 @@
|
||||
// TODO: Fix these tests after migration
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LGraphEventMode,
|
||||
ExecutableNodeDTO
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
@@ -249,6 +252,136 @@ describe.skip('ExecutableNodeDTO Output Resolution', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Muted node output resolution', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('should return undefined for NEVER mode nodes', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Muted Node')
|
||||
node.addOutput('out', 'string')
|
||||
node.mode = LGraphEventMode.NEVER
|
||||
graph.add(node)
|
||||
|
||||
const dto = new ExecutableNodeDTO(node, [], new Map(), undefined)
|
||||
const resolved = dto.resolveOutput(0, 'string', new Set())
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for muted subgraph nodes without throwing', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
outputs: [{ name: 'out', type: 'IMAGE' }],
|
||||
nodeCount: 1
|
||||
})
|
||||
const subgraphNode = createTestSubgraphNode(subgraph)
|
||||
|
||||
subgraphNode.mode = LGraphEventMode.NEVER
|
||||
|
||||
// Empty map simulates executionUtil skipping getInnerNodes() for muted nodes
|
||||
const nodesByExecutionId = new Map()
|
||||
const dto = new ExecutableNodeDTO(
|
||||
subgraphNode,
|
||||
[],
|
||||
nodesByExecutionId,
|
||||
undefined
|
||||
)
|
||||
nodesByExecutionId.set(dto.id, dto)
|
||||
|
||||
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should resolve undefined when input is connected to a muted node', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const mutedNode = new LGraphNode('Muted Node')
|
||||
mutedNode.addOutput('result', 'IMAGE')
|
||||
mutedNode.mode = LGraphEventMode.NEVER
|
||||
graph.add(mutedNode)
|
||||
|
||||
const downstreamNode = new LGraphNode('Downstream')
|
||||
downstreamNode.addInput('input', 'IMAGE')
|
||||
graph.add(downstreamNode)
|
||||
|
||||
mutedNode.connect(0, downstreamNode, 0)
|
||||
|
||||
const nodeDtoMap = new Map()
|
||||
const mutedDto = new ExecutableNodeDTO(mutedNode, [], nodeDtoMap, undefined)
|
||||
nodeDtoMap.set(mutedDto.id, mutedDto)
|
||||
|
||||
const downstreamDto = new ExecutableNodeDTO(
|
||||
downstreamNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(downstreamDto.id, downstreamDto)
|
||||
|
||||
const resolved = downstreamDto.resolveInput(0)
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Bypass node output resolution', () => {
|
||||
it('should still resolve bypass for BYPASS mode nodes', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const upstreamNode = new LGraphNode('Upstream')
|
||||
upstreamNode.addOutput('out', 'IMAGE')
|
||||
graph.add(upstreamNode)
|
||||
|
||||
const bypassedNode = new LGraphNode('Bypassed')
|
||||
bypassedNode.addInput('in', 'IMAGE')
|
||||
bypassedNode.addOutput('out', 'IMAGE')
|
||||
bypassedNode.mode = LGraphEventMode.BYPASS
|
||||
graph.add(bypassedNode)
|
||||
|
||||
upstreamNode.connect(0, bypassedNode, 0)
|
||||
|
||||
const nodeDtoMap = new Map()
|
||||
const upstreamDto = new ExecutableNodeDTO(
|
||||
upstreamNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(upstreamDto.id, upstreamDto)
|
||||
|
||||
const bypassedDto = new ExecutableNodeDTO(
|
||||
bypassedNode,
|
||||
[],
|
||||
nodeDtoMap,
|
||||
undefined
|
||||
)
|
||||
nodeDtoMap.set(bypassedDto.id, bypassedDto)
|
||||
|
||||
const resolved = bypassedDto.resolveOutput(0, 'IMAGE', new Set())
|
||||
expect(resolved).toBeDefined()
|
||||
expect(resolved?.node).toBe(upstreamDto)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ALWAYS mode node output resolution', () => {
|
||||
it('should attempt normal resolution for ALWAYS mode nodes', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Normal Node')
|
||||
node.addOutput('out', 'IMAGE')
|
||||
node.mode = LGraphEventMode.ALWAYS
|
||||
graph.add(node)
|
||||
|
||||
const nodeDtoMap = new Map()
|
||||
const dto = new ExecutableNodeDTO(node, [], nodeDtoMap, undefined)
|
||||
nodeDtoMap.set(dto.id, dto)
|
||||
|
||||
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
|
||||
expect(resolved).toBeDefined()
|
||||
expect(resolved?.node).toBe(dto)
|
||||
expect(resolved?.origin_slot).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skip('ExecutableNodeDTO Properties', () => {
|
||||
it('should provide access to basic properties', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
@@ -266,6 +266,9 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
}
|
||||
visited.add(uniqueId)
|
||||
|
||||
// Muted nodes produce no output
|
||||
if (this.mode === LGraphEventMode.NEVER) return
|
||||
|
||||
// Upstreamed: Bypass nodes are bypassed using the first input with matching type
|
||||
if (this.mode === LGraphEventMode.BYPASS) {
|
||||
// Bypass nodes by finding first input with matching type
|
||||
|
||||
@@ -862,7 +862,7 @@
|
||||
"promptExecutionError": "فشل تنفيذ الطلب"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} أخطاء | {count} خطأ | {count} أخطاء",
|
||||
"errorCount": "{count} خطأ | {count} أخطاء",
|
||||
"seeErrors": "عرض الأخطاء"
|
||||
},
|
||||
"essentials": {
|
||||
@@ -909,7 +909,7 @@
|
||||
"downloadFailed": "فشل تحميل \"{name}\"",
|
||||
"exportCompleted": "تحميل ZIP جاهز",
|
||||
"exportError": "فشل التصدير",
|
||||
"exportFailed": "{count} فشل في التصدير | {count} فشل في التصدير | {count} عمليات تصدير فشلت",
|
||||
"exportFailed": "{count} فشل في التصدير | {count} عمليات تصدير فشلت",
|
||||
"exportFailedSingle": "فشل إنشاء تصدير ZIP",
|
||||
"exportStarted": "يتم تجهيز تحميل ZIP...",
|
||||
"exportingAssets": "جاري تصدير العناصر",
|
||||
@@ -928,7 +928,7 @@
|
||||
"amount": "الكمية",
|
||||
"apply": "تطبيق",
|
||||
"architecture": "الهندسة المعمارية",
|
||||
"asset": "{count} أصل | {count} أصل | {count} أصول",
|
||||
"asset": "{count} أصل | {count} أصول",
|
||||
"audioFailedToLoad": "فشل تحميل الصوت",
|
||||
"audioProgress": "تقدم الصوت",
|
||||
"author": "المؤلف",
|
||||
@@ -1114,7 +1114,7 @@
|
||||
"nodeSlotsError": "خطأ في فتحات العقدة",
|
||||
"nodeWidgetsError": "خطأ في عناصر واجهة العقدة",
|
||||
"nodes": "العُقَد",
|
||||
"nodesCount": "{count} عقدة | {count} عقدة | {count} عقدة",
|
||||
"nodesCount": "{count} عقدة | {count} عقدة",
|
||||
"nodesRunning": "العُقَد قيد التشغيل",
|
||||
"none": "لا شيء",
|
||||
"nothingToCopy": "لا يوجد ما يمكن نسخه",
|
||||
@@ -2295,7 +2295,7 @@
|
||||
"progressToast": {
|
||||
"allDownloadsCompleted": "اكتملت جميع التنزيلات",
|
||||
"downloadingModel": "جاري تنزيل النموذج...",
|
||||
"downloadsFailed": "{count} فشل في التنزيل | {count} فشل في التنزيل | {count} فشل في التنزيل",
|
||||
"downloadsFailed": "{count} فشل في التنزيل | {count} فشل في التنزيل",
|
||||
"failed": "فشل",
|
||||
"filter": {
|
||||
"all": "الكل",
|
||||
|
||||
@@ -496,7 +496,12 @@
|
||||
"cyan": "Cyan",
|
||||
"purple": "Purple",
|
||||
"black": "Black",
|
||||
"custom": "Custom"
|
||||
"custom": "Custom",
|
||||
"hex": "Hex",
|
||||
"rgba": "RGBA",
|
||||
"saturationBrightness": "Color saturation and brightness",
|
||||
"hue": "Hue",
|
||||
"alpha": "Alpha"
|
||||
},
|
||||
"contextMenu": {
|
||||
"Inputs": "Inputs",
|
||||
|
||||
@@ -862,7 +862,7 @@
|
||||
"promptExecutionError": "La ejecución del prompt falló"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERRORES | {count} ERROR | {count} ERRORES",
|
||||
"errorCount": "{count} ERROR | {count} ERRORES",
|
||||
"seeErrors": "Ver errores"
|
||||
},
|
||||
"essentials": {
|
||||
@@ -909,7 +909,7 @@
|
||||
"downloadFailed": "No se pudo descargar \"{name}\"",
|
||||
"exportCompleted": "Descarga ZIP lista",
|
||||
"exportError": "Error en la exportación",
|
||||
"exportFailed": "{count} exportación fallida | {count} exportación fallida | {count} exportaciones fallidas",
|
||||
"exportFailed": "{count} exportación fallida | {count} exportaciones fallidas",
|
||||
"exportFailedSingle": "No se pudo crear la exportación ZIP",
|
||||
"exportStarted": "Preparando descarga ZIP...",
|
||||
"exportingAssets": "Exportando recursos",
|
||||
@@ -928,7 +928,7 @@
|
||||
"amount": "Cantidad",
|
||||
"apply": "Aplicar",
|
||||
"architecture": "Arquitectura",
|
||||
"asset": "{count} recursos | {count} recurso | {count} recursos",
|
||||
"asset": "{count} recurso | {count} recursos",
|
||||
"audioFailedToLoad": "No se pudo cargar el audio",
|
||||
"audioProgress": "Progreso de audio",
|
||||
"author": "Autor",
|
||||
@@ -1114,7 +1114,7 @@
|
||||
"nodeSlotsError": "Error de Ranuras del Nodo",
|
||||
"nodeWidgetsError": "Error de Widgets del Nodo",
|
||||
"nodes": "Nodos",
|
||||
"nodesCount": "{count} nodos | {count} nodo | {count} nodos",
|
||||
"nodesCount": "{count} nodo | {count} nodos",
|
||||
"nodesRunning": "nodos en ejecución",
|
||||
"none": "Ninguno",
|
||||
"nothingToCopy": "Nada para copiar",
|
||||
@@ -2295,7 +2295,7 @@
|
||||
"progressToast": {
|
||||
"allDownloadsCompleted": "Todas las descargas completadas",
|
||||
"downloadingModel": "Descargando modelo...",
|
||||
"downloadsFailed": "{count} descargas fallidas | {count} descarga fallida | {count} descargas fallidas",
|
||||
"downloadsFailed": "{count} descarga fallida | {count} descargas fallidas",
|
||||
"failed": "Fallido",
|
||||
"filter": {
|
||||
"all": "Todo",
|
||||
|
||||
@@ -928,7 +928,7 @@
|
||||
"amount": "مقدار",
|
||||
"apply": "اعمال",
|
||||
"architecture": "معماری",
|
||||
"asset": "{count} دارایی | {count} دارایی | {count} دارایی",
|
||||
"asset": "{count} دارایی | {count} دارایی",
|
||||
"audioFailedToLoad": "بارگذاری صوت ناموفق بود",
|
||||
"audioProgress": "پیشرفت صوت",
|
||||
"author": "نویسنده",
|
||||
@@ -1114,7 +1114,7 @@
|
||||
"nodeSlotsError": "خطا در slotهای node",
|
||||
"nodeWidgetsError": "خطا در ابزارکهای node",
|
||||
"nodes": "nodeها",
|
||||
"nodesCount": "{count} نود | {count} نود | {count} نود",
|
||||
"nodesCount": "{count} نود | {count} نود",
|
||||
"nodesRunning": "nodeها در حال اجرا هستند",
|
||||
"none": "هیچکدام",
|
||||
"nothingToCopy": "موردی برای کپی وجود ندارد",
|
||||
@@ -2295,7 +2295,7 @@
|
||||
"progressToast": {
|
||||
"allDownloadsCompleted": "همه دانلودها تکمیل شدند",
|
||||
"downloadingModel": "در حال دانلود مدل...",
|
||||
"downloadsFailed": "{count} دانلود ناموفق بود | {count} دانلود ناموفق بود | {count} دانلود ناموفق بودند",
|
||||
"downloadsFailed": "{count} دانلود ناموفق بود | {count} دانلود ناموفق بودند",
|
||||
"failed": "ناموفق",
|
||||
"filter": {
|
||||
"all": "همه",
|
||||
|
||||
@@ -862,7 +862,7 @@
|
||||
"promptExecutionError": "L'exécution de l'invite a échoué"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERREURS | {count} ERREUR | {count} ERREURS",
|
||||
"errorCount": "{count} ERREUR | {count} ERREURS",
|
||||
"seeErrors": "Voir les erreurs"
|
||||
},
|
||||
"essentials": {
|
||||
@@ -909,7 +909,7 @@
|
||||
"downloadFailed": "Échec du téléchargement de « {name} »",
|
||||
"exportCompleted": "Téléchargement ZIP prêt",
|
||||
"exportError": "Échec de l’exportation",
|
||||
"exportFailed": "{count} exportation échouée | {count} exportation échouée | {count} exportations échouées",
|
||||
"exportFailed": "{count} exportation échouée | {count} exportations échouées",
|
||||
"exportFailedSingle": "Échec de la création de l’export ZIP",
|
||||
"exportStarted": "Préparation du téléchargement ZIP...",
|
||||
"exportingAssets": "Exportation des ressources",
|
||||
@@ -928,7 +928,7 @@
|
||||
"amount": "Quantité",
|
||||
"apply": "Appliquer",
|
||||
"architecture": "Architecture",
|
||||
"asset": "{count} ressources | {count} ressource | {count} ressources",
|
||||
"asset": "{count} ressource | {count} ressources",
|
||||
"audioFailedToLoad": "Échec du chargement de l'audio",
|
||||
"audioProgress": "Progression audio",
|
||||
"author": "Auteur",
|
||||
@@ -1114,7 +1114,7 @@
|
||||
"nodeSlotsError": "Erreur d'emplacements du nœud",
|
||||
"nodeWidgetsError": "Erreur de widgets du nœud",
|
||||
"nodes": "Nœuds",
|
||||
"nodesCount": "{count} nœuds | {count} nœud | {count} nœuds",
|
||||
"nodesCount": "{count} nœud | {count} nœuds",
|
||||
"nodesRunning": "nœuds en cours d’exécution",
|
||||
"none": "Aucun",
|
||||
"nothingToCopy": "Rien à copier",
|
||||
@@ -2295,7 +2295,7 @@
|
||||
"progressToast": {
|
||||
"allDownloadsCompleted": "Tous les téléchargements sont terminés",
|
||||
"downloadingModel": "Téléchargement du modèle...",
|
||||
"downloadsFailed": "{count} téléchargements échoués | {count} téléchargement échoué | {count} téléchargements échoués",
|
||||
"downloadsFailed": "{count} téléchargement échoué | {count} téléchargements échoués",
|
||||
"failed": "Échec",
|
||||
"filter": {
|
||||
"all": "Tous",
|
||||
|
||||
@@ -928,7 +928,7 @@
|
||||
"amount": "量",
|
||||
"apply": "適用する",
|
||||
"architecture": "アーキテクチャ",
|
||||
"asset": "{count} 個のアセット | {count} 個のアセット | {count} 個のアセット",
|
||||
"asset": "{count} 個のアセット | {count} 個のアセット",
|
||||
"audioFailedToLoad": "オーディオの読み込みに失敗しました",
|
||||
"audioProgress": "オーディオの進捗",
|
||||
"author": "作者",
|
||||
@@ -1114,7 +1114,7 @@
|
||||
"nodeSlotsError": "ノードスロットエラー",
|
||||
"nodeWidgetsError": "ノードウィジェットエラー",
|
||||
"nodes": "ノード",
|
||||
"nodesCount": "{count} ノード | {count} ノード | {count} ノード",
|
||||
"nodesCount": "{count} ノード | {count} ノード",
|
||||
"nodesRunning": "ノードが実行中",
|
||||
"none": "なし",
|
||||
"nothingToCopy": "コピーするものがありません",
|
||||
@@ -2295,7 +2295,7 @@
|
||||
"progressToast": {
|
||||
"allDownloadsCompleted": "すべてのダウンロードが完了しました",
|
||||
"downloadingModel": "モデルをダウンロード中...",
|
||||
"downloadsFailed": "{count}件のダウンロードに失敗 | {count}件のダウンロードに失敗 | {count}件のダウンロードに失敗",
|
||||
"downloadsFailed": "{count}件のダウンロードに失敗 | {count}件のダウンロードに失敗",
|
||||
"failed": "失敗",
|
||||
"filter": {
|
||||
"all": "すべて",
|
||||
|
||||
@@ -928,7 +928,7 @@
|
||||
"amount": "수량",
|
||||
"apply": "적용",
|
||||
"architecture": "아키텍처",
|
||||
"asset": "{count}개 에셋 | {count}개 에셋 | {count}개 에셋",
|
||||
"asset": "{count}개 에셋 | {count}개 에셋",
|
||||
"audioFailedToLoad": "오디오를 불러오지 못했습니다",
|
||||
"audioProgress": "오디오 진행률",
|
||||
"author": "작성자",
|
||||
@@ -1114,7 +1114,7 @@
|
||||
"nodeSlotsError": "노드 슬롯 오류",
|
||||
"nodeWidgetsError": "노드 위젯 오류",
|
||||
"nodes": "노드",
|
||||
"nodesCount": "{count}개 노드 | {count}개 노드 | {count}개 노드",
|
||||
"nodesCount": "{count}개 노드 | {count}개 노드",
|
||||
"nodesRunning": "노드 실행 중",
|
||||
"none": "없음",
|
||||
"nothingToCopy": "복사할 항목 없음",
|
||||
@@ -2295,7 +2295,7 @@
|
||||
"progressToast": {
|
||||
"allDownloadsCompleted": "모든 다운로드가 완료되었습니다",
|
||||
"downloadingModel": "모델 다운로드 중...",
|
||||
"downloadsFailed": "{count}개 다운로드 실패 | {count}개 다운로드 실패 | {count}개 다운로드 실패",
|
||||
"downloadsFailed": "{count}개 다운로드 실패 | {count}개 다운로드 실패",
|
||||
"failed": "실패",
|
||||
"filter": {
|
||||
"all": "전체",
|
||||
|
||||
@@ -862,7 +862,7 @@
|
||||
"promptExecutionError": "Falha na execução do prompt"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} ERROS | {count} ERRO | {count} ERROS",
|
||||
"errorCount": "{count} ERRO | {count} ERROS",
|
||||
"seeErrors": "Ver erros"
|
||||
},
|
||||
"essentials": {
|
||||
@@ -909,7 +909,7 @@
|
||||
"downloadFailed": "Falha ao baixar \"{name}\"",
|
||||
"exportCompleted": "Download ZIP pronto",
|
||||
"exportError": "Falha na exportação",
|
||||
"exportFailed": "{count} exportação falhou | {count} exportação falhou | {count} exportações falharam",
|
||||
"exportFailed": "{count} exportação falhou | {count} exportações falharam",
|
||||
"exportFailedSingle": "Falha ao criar exportação ZIP",
|
||||
"exportStarted": "Preparando download ZIP...",
|
||||
"exportingAssets": "Exportando ativos",
|
||||
@@ -928,7 +928,7 @@
|
||||
"amount": "Quantidade",
|
||||
"apply": "Aplicar",
|
||||
"architecture": "Arquitetura",
|
||||
"asset": "{count} ativos | {count} ativo | {count} ativos",
|
||||
"asset": "{count} ativo | {count} ativos",
|
||||
"audioFailedToLoad": "Falha ao carregar áudio",
|
||||
"audioProgress": "Progresso do áudio",
|
||||
"author": "Autor",
|
||||
@@ -1114,7 +1114,7 @@
|
||||
"nodeSlotsError": "Erro nos slots do nó",
|
||||
"nodeWidgetsError": "Erro nos widgets do nó",
|
||||
"nodes": "Nós",
|
||||
"nodesCount": "{count} nós | {count} nó | {count} nós",
|
||||
"nodesCount": "{count} nó | {count} nós",
|
||||
"nodesRunning": "nós em execução",
|
||||
"none": "Nenhum",
|
||||
"nothingToCopy": "Nada para copiar",
|
||||
@@ -2295,7 +2295,7 @@
|
||||
"progressToast": {
|
||||
"allDownloadsCompleted": "Todos os downloads concluídos",
|
||||
"downloadingModel": "Baixando modelo...",
|
||||
"downloadsFailed": "{count} downloads falharam | {count} download falhou | {count} downloads falharam",
|
||||
"downloadsFailed": "{count} download falhou | {count} downloads falharam",
|
||||
"failed": "Falhou",
|
||||
"filter": {
|
||||
"all": "Todos",
|
||||
|
||||
@@ -862,7 +862,7 @@
|
||||
"promptExecutionError": "İstem yürütmesi başarısız oldu"
|
||||
},
|
||||
"errorOverlay": {
|
||||
"errorCount": "{count} HATA | {count} HATA | {count} HATA",
|
||||
"errorCount": "{count} HATA | {count} HATA",
|
||||
"seeErrors": "Hataları Gör"
|
||||
},
|
||||
"essentials": {
|
||||
@@ -909,7 +909,7 @@
|
||||
"downloadFailed": "\"{name}\" indirilemedi",
|
||||
"exportCompleted": "ZIP indirme hazır",
|
||||
"exportError": "Dışa aktarma başarısız",
|
||||
"exportFailed": "{count} dışa aktarma başarısız oldu | {count} dışa aktarma başarısız oldu | {count} dışa aktarma başarısız oldu",
|
||||
"exportFailed": "{count} dışa aktarma başarısız oldu | {count} dışa aktarma başarısız oldu",
|
||||
"exportFailedSingle": "ZIP dışa aktarımı oluşturulamadı",
|
||||
"exportStarted": "ZIP indirme hazırlanıyor...",
|
||||
"exportingAssets": "Varlıklar dışa aktarılıyor",
|
||||
@@ -928,7 +928,7 @@
|
||||
"amount": "Miktar",
|
||||
"apply": "Uygula",
|
||||
"architecture": "Mimari",
|
||||
"asset": "{count} varlık | {count} varlık | {count} varlık",
|
||||
"asset": "{count} varlık | {count} varlık",
|
||||
"audioFailedToLoad": "Ses yüklenemedi",
|
||||
"audioProgress": "Ses ilerlemesi",
|
||||
"author": "Yazar",
|
||||
@@ -1114,7 +1114,7 @@
|
||||
"nodeSlotsError": "Düğüm Yuva Hatası",
|
||||
"nodeWidgetsError": "Düğüm Widget Hatası",
|
||||
"nodes": "Düğümler",
|
||||
"nodesCount": "{count} düğüm | {count} düğüm | {count} düğüm",
|
||||
"nodesCount": "{count} düğüm | {count} düğüm",
|
||||
"nodesRunning": "düğüm çalışıyor",
|
||||
"none": "Hiçbiri",
|
||||
"nothingToCopy": "Kopyalanacak bir şey yok",
|
||||
@@ -2295,7 +2295,7 @@
|
||||
"progressToast": {
|
||||
"allDownloadsCompleted": "Tüm indirmeler tamamlandı",
|
||||
"downloadingModel": "Model indiriliyor...",
|
||||
"downloadsFailed": "{count} indirme başarısız oldu | {count} indirme başarısız oldu | {count} indirme başarısız oldu",
|
||||
"downloadsFailed": "{count} indirme başarısız oldu | {count} indirme başarısız oldu",
|
||||
"failed": "Başarısız",
|
||||
"filter": {
|
||||
"all": "Tümü",
|
||||
|
||||
@@ -1114,7 +1114,7 @@
|
||||
"nodeSlotsError": "節點插槽錯誤",
|
||||
"nodeWidgetsError": "節點小工具錯誤",
|
||||
"nodes": "節點",
|
||||
"nodesCount": "{count} 個節點 | {count} 個節點 | {count} 個節點",
|
||||
"nodesCount": "{count} 個節點 | {count} 個節點",
|
||||
"nodesRunning": "節點執行中",
|
||||
"none": "無",
|
||||
"nothingToCopy": "沒有可複製的項目",
|
||||
@@ -2295,7 +2295,7 @@
|
||||
"progressToast": {
|
||||
"allDownloadsCompleted": "所有下載已完成",
|
||||
"downloadingModel": "正在下載模型...",
|
||||
"downloadsFailed": "{count} 個下載失敗 | {count} 個下載失敗 | {count} 個下載失敗",
|
||||
"downloadsFailed": "{count} 個下載失敗 | {count} 個下載失敗",
|
||||
"failed": "失敗",
|
||||
"filter": {
|
||||
"all": "全部",
|
||||
|
||||
@@ -692,6 +692,25 @@ describe('useAssetBrowser', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('extracts categories regardless of tag order', () => {
|
||||
const assets = [
|
||||
createApiAsset({ tags: ['checkpoints', 'models'] }),
|
||||
createApiAsset({ tags: ['loras', 'models'] }),
|
||||
createApiAsset({ tags: ['models', 'vae'] })
|
||||
]
|
||||
|
||||
const { navItems } = useAssetBrowser(ref(assets))
|
||||
|
||||
const typeGroup = navItems.value[2] as {
|
||||
items: { id: string }[]
|
||||
}
|
||||
expect(typeGroup.items.map((i) => i.id)).toEqual([
|
||||
'checkpoints',
|
||||
'loras',
|
||||
'vae'
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores non-models root tags', () => {
|
||||
const assets = [
|
||||
createApiAsset({ tags: ['input', 'images'] }),
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getAssetBaseModels,
|
||||
getAssetFilename
|
||||
} from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { MODELS_TAG } from '@/platform/assets/services/assetService'
|
||||
import { sortAssets } from '@/platform/assets/utils/assetSortUtils'
|
||||
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
@@ -123,9 +124,10 @@ export function useAssetBrowser(
|
||||
|
||||
const typeCategories = computed<NavItemData[]>(() => {
|
||||
const categories = assets.value
|
||||
.filter((asset) => asset.tags[0] === 'models')
|
||||
.map((asset) => asset.tags[1])
|
||||
.filter((tag): tag is string => typeof tag === 'string' && tag.length > 0)
|
||||
.filter((asset) => asset.tags.includes(MODELS_TAG))
|
||||
.flatMap((asset) =>
|
||||
asset.tags.filter((tag) => tag !== MODELS_TAG && tag.length > 0)
|
||||
)
|
||||
.map((tag) => tag.split('/')[0])
|
||||
|
||||
return Array.from(new Set(categories))
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type {
|
||||
ComponentPropsAndSlots,
|
||||
Meta,
|
||||
StoryObj
|
||||
} from '@storybook/vue3-vite'
|
||||
import { computed, ref, toRefs } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { ColorFormat } from '@/utils/colorUtil'
|
||||
|
||||
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import WidgetColorPicker from './WidgetColorPicker.vue'
|
||||
|
||||
type WidgetOptions = IWidgetOptions & { format?: ColorFormat }
|
||||
|
||||
interface StoryArgs extends ComponentPropsAndSlots<typeof WidgetColorPicker> {
|
||||
format: ColorFormat
|
||||
}
|
||||
|
||||
const meta: Meta<StoryArgs> = {
|
||||
title: 'Widgets/WidgetColorPicker',
|
||||
component: WidgetColorPicker,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' },
|
||||
argTypes: {
|
||||
format: {
|
||||
control: 'select',
|
||||
options: ['hex', 'rgb', 'hsb']
|
||||
}
|
||||
},
|
||||
args: {
|
||||
format: 'hex'
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="grid grid-cols-[auto_1fr] gap-1 w-80"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { WidgetColorPicker },
|
||||
setup() {
|
||||
const { format } = toRefs(args)
|
||||
const value = ref('#E06CBD')
|
||||
const widget = computed<SimplifiedWidget<string, WidgetOptions>>(() => ({
|
||||
name: 'color',
|
||||
type: 'STRING',
|
||||
value: '',
|
||||
options: { format: format.value }
|
||||
}))
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const RGBFormat: Story = {
|
||||
args: { format: 'rgb' },
|
||||
render: (args) => ({
|
||||
components: { WidgetColorPicker },
|
||||
setup() {
|
||||
const { format } = toRefs(args)
|
||||
const value = ref('#3498DB')
|
||||
const widget = computed<SimplifiedWidget<string, WidgetOptions>>(() => ({
|
||||
name: 'color',
|
||||
type: 'STRING',
|
||||
value: '',
|
||||
options: { format: format.value }
|
||||
}))
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const HSBFormat: Story = {
|
||||
args: { format: 'hsb' },
|
||||
render: (args) => ({
|
||||
components: { WidgetColorPicker },
|
||||
setup() {
|
||||
const { format } = toRefs(args)
|
||||
const value = ref('#2ECC71')
|
||||
const widget = computed<SimplifiedWidget<string, WidgetOptions>>(() => ({
|
||||
name: 'color',
|
||||
type: 'STRING',
|
||||
value: '',
|
||||
options: { format: format.value }
|
||||
}))
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const CustomColor: Story = {
|
||||
render: (args) => ({
|
||||
components: { WidgetColorPicker },
|
||||
setup() {
|
||||
const { format } = toRefs(args)
|
||||
const value = ref('#FF5733')
|
||||
const widget = computed<SimplifiedWidget<string, WidgetOptions>>(() => ({
|
||||
name: 'accent_color',
|
||||
type: 'STRING',
|
||||
value: '',
|
||||
options: { format: format.value }
|
||||
}))
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const WithLabel: Story = {
|
||||
render: () => ({
|
||||
components: { WidgetColorPicker },
|
||||
setup() {
|
||||
const value = ref('#9B59B6')
|
||||
const widget: SimplifiedWidget<string, WidgetOptions> = {
|
||||
name: 'background',
|
||||
type: 'STRING',
|
||||
value: '',
|
||||
label: 'Background Color',
|
||||
options: { format: 'hex' }
|
||||
}
|
||||
return { value, widget }
|
||||
},
|
||||
template: '<WidgetColorPicker :widget="widget" v-model="value" />'
|
||||
})
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import type { ColorPickerProps } from 'primevue/colorpicker'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
|
||||
|
||||
import WidgetColorPicker from './WidgetColorPicker.vue'
|
||||
import { createMockWidget } from './widgetTestUtils'
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
@@ -13,7 +12,7 @@ import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
describe('WidgetColorPicker Value Binding', () => {
|
||||
const createColorWidget = (
|
||||
value: string = '#000000',
|
||||
options: Partial<ColorPickerProps> = {},
|
||||
options: Record<string, unknown> = {},
|
||||
callback?: (value: string) => void
|
||||
) =>
|
||||
createMockWidget<string>({
|
||||
@@ -26,12 +25,10 @@ describe('WidgetColorPicker Value Binding', () => {
|
||||
|
||||
const mountComponent = (
|
||||
widget: SimplifiedWidget<string>,
|
||||
modelValue: string,
|
||||
readonly = false
|
||||
modelValue: string
|
||||
) => {
|
||||
return mount(WidgetColorPicker, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: {
|
||||
ColorPicker,
|
||||
WidgetLayoutField
|
||||
@@ -39,93 +36,35 @@ describe('WidgetColorPicker Value Binding', () => {
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
readonly
|
||||
modelValue
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setColorPickerValue = async (
|
||||
wrapper: ReturnType<typeof mount>,
|
||||
value: unknown
|
||||
) => {
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
await colorPicker.setValue(value)
|
||||
return wrapper.emitted('update:modelValue')
|
||||
}
|
||||
|
||||
describe('Vue Event Emission', () => {
|
||||
it('emits Vue event when color changes', async () => {
|
||||
const widget = createColorWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '#00ff00')
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
await colorPicker.vm.$emit('update:modelValue', '#00ff00')
|
||||
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#00ff00')
|
||||
})
|
||||
|
||||
it('handles different color formats', async () => {
|
||||
const widget = createColorWidget('#ffffff')
|
||||
const wrapper = mountComponent(widget, '#ffffff')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '#123abc')
|
||||
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#123abc')
|
||||
})
|
||||
|
||||
it('handles missing callback gracefully', async () => {
|
||||
const widget = createColorWidget('#000000', {}, undefined)
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '#ff00ff')
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
await colorPicker.vm.$emit('update:modelValue', '#ff00ff')
|
||||
|
||||
// Should still emit Vue event
|
||||
const emitted = wrapper.emitted('update:modelValue')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#ff00ff')
|
||||
})
|
||||
|
||||
it('normalizes bare hex without # to #hex on emit', async () => {
|
||||
const widget = createColorWidget('ff0000')
|
||||
const wrapper = mountComponent(widget, 'ff0000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, '00ff00')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#00ff00')
|
||||
})
|
||||
|
||||
it('normalizes rgb() strings to #hex on emit', async (context) => {
|
||||
context.skip('needs diagnosis')
|
||||
const widget = createColorWidget('#000000')
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, 'rgb(255, 0, 0)')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#ff0000')
|
||||
})
|
||||
|
||||
it('normalizes hsb() strings to #hex on emit', async () => {
|
||||
const widget = createColorWidget('#000000', { format: 'hsb' })
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, 'hsb(120, 100, 100)')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#00ff00')
|
||||
})
|
||||
|
||||
it('normalizes HSB object values to #hex on emit', async () => {
|
||||
const widget = createColorWidget('#000000', { format: 'hsb' })
|
||||
const wrapper = mountComponent(widget, '#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, {
|
||||
h: 240,
|
||||
s: 100,
|
||||
b: 100
|
||||
})
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#0000ff')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
@@ -133,110 +72,37 @@ describe('WidgetColorPicker Value Binding', () => {
|
||||
const widget = createColorWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes display to a single leading #', () => {
|
||||
// Case 1: model value already includes '#'
|
||||
let widget = createColorWidget('#ff0000')
|
||||
let wrapper = mountComponent(widget, '#ff0000')
|
||||
let colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect.soft(colorText.text()).toBe('#ff0000')
|
||||
|
||||
// Case 2: model value missing '#'
|
||||
widget = createColorWidget('ff0000')
|
||||
wrapper = mountComponent(widget, 'ff0000')
|
||||
colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect.soft(colorText.text()).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('renders layout field wrapper', () => {
|
||||
const widget = createColorWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
const layoutField = wrapper.findComponent({
|
||||
name: 'WidgetLayoutField'
|
||||
})
|
||||
expect(layoutField.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays current color value as text', () => {
|
||||
const widget = createColorWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect(colorText.text()).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('updates color text when value changes', async () => {
|
||||
const widget = createColorWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
await setColorPickerValue(wrapper, '#00ff00')
|
||||
|
||||
// Need to check the local state update
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
// Be specific about the displayed value including the leading '#'
|
||||
expect.soft(colorText.text()).toBe('#00ff00')
|
||||
})
|
||||
|
||||
it('uses default color when no value provided', () => {
|
||||
const widget = createColorWidget('')
|
||||
const wrapper = mountComponent(widget, '')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
// Should use the default value from the composable
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Color Formats', () => {
|
||||
it('handles valid hex colors', async () => {
|
||||
const validHexColors = [
|
||||
'#000000',
|
||||
'#ffffff',
|
||||
'#ff0000',
|
||||
'#00ff00',
|
||||
'#0000ff',
|
||||
'#123abc'
|
||||
]
|
||||
|
||||
for (const color of validHexColors) {
|
||||
const widget = createColorWidget(color)
|
||||
const wrapper = mountComponent(widget, color)
|
||||
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect.soft(colorText.text()).toBe(color)
|
||||
}
|
||||
})
|
||||
|
||||
it('handles short hex colors', () => {
|
||||
const widget = createColorWidget('#fff')
|
||||
const wrapper = mountComponent(widget, '#fff')
|
||||
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect(colorText.text()).toBe('#fff')
|
||||
})
|
||||
|
||||
it('passes widget options to color picker', () => {
|
||||
const colorOptions = {
|
||||
format: 'hex' as const,
|
||||
inline: true
|
||||
}
|
||||
const widget = createColorWidget('#ff0000', colorOptions)
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
expect(colorPicker.props('format')).toBe('hex')
|
||||
expect(colorPicker.props('inline')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Widget Layout Integration', () => {
|
||||
it('passes widget to layout field', () => {
|
||||
const widget = createColorWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
const layoutField = wrapper.findComponent({
|
||||
name: 'WidgetLayoutField'
|
||||
})
|
||||
expect(layoutField.props('widget')).toEqual(widget)
|
||||
})
|
||||
|
||||
@@ -244,16 +110,13 @@ describe('WidgetColorPicker Value Binding', () => {
|
||||
const widget = createColorWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
// Should have layout field containing label with color picker and text
|
||||
const layoutField = wrapper.findComponent({ name: 'WidgetLayoutField' })
|
||||
const label = wrapper.find('label')
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
const colorText = wrapper.find('span')
|
||||
const layoutField = wrapper.findComponent({
|
||||
name: 'WidgetLayoutField'
|
||||
})
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
|
||||
expect(layoutField.exists()).toBe(true)
|
||||
expect(label.exists()).toBe(true)
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
expect(colorText.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -262,27 +125,15 @@ describe('WidgetColorPicker Value Binding', () => {
|
||||
const widget = createColorWidget('')
|
||||
const wrapper = mountComponent(widget, '')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles invalid color formats gracefully', async () => {
|
||||
const widget = createColorWidget('invalid-color')
|
||||
const wrapper = mountComponent(widget, 'invalid-color')
|
||||
|
||||
const colorText = wrapper.find('[data-testid="widget-color-text"]')
|
||||
expect(colorText.text()).toBe('#000000')
|
||||
|
||||
const emitted = await setColorPickerValue(wrapper, 'invalid-color')
|
||||
expect(emitted).toBeDefined()
|
||||
expect(emitted![0]).toContain('#000000')
|
||||
})
|
||||
|
||||
it('handles widget with no options', () => {
|
||||
const widget = createColorWidget('#ff0000')
|
||||
const wrapper = mountComponent(widget, '#ff0000')
|
||||
|
||||
const colorPicker = wrapper.findComponent({ name: 'ColorPicker' })
|
||||
const colorPicker = wrapper.findComponent(ColorPicker)
|
||||
expect(colorPicker.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,90 +1,42 @@
|
||||
<!-- Needs custom color picker for alpha support -->
|
||||
<template>
|
||||
<WidgetLayoutField :widget="widget">
|
||||
<label
|
||||
:class="
|
||||
cn(WidgetInputBaseClass, 'flex w-full items-center gap-2 px-4 py-2')
|
||||
"
|
||||
>
|
||||
<ColorPicker
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
class="h-4 w-8 overflow-hidden rounded-full! border-none"
|
||||
:aria-label="widget.name"
|
||||
:pt="{
|
||||
preview: '!w-full !h-full !border-none'
|
||||
}"
|
||||
@update:model-value="onPickerUpdate"
|
||||
/>
|
||||
<span
|
||||
class="min-w-[4ch] truncate text-xs"
|
||||
data-testid="widget-color-text"
|
||||
>{{ toHexFromFormat(localValue, format) }}</span
|
||||
>
|
||||
</label>
|
||||
<ColorPicker v-model="localValue" @update:model-value="onUpdate" />
|
||||
</WidgetLayoutField>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { isColorFormat, toHexFromFormat } from '@/utils/colorUtil'
|
||||
import type { ColorFormat, HSB } from '@/utils/colorUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import {
|
||||
PANEL_EXCLUDED_PROPS,
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
import type { ColorFormat } from '@/utils/colorUtil'
|
||||
|
||||
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
|
||||
|
||||
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
|
||||
|
||||
type WidgetOptions = IWidgetOptions & { format?: ColorFormat }
|
||||
|
||||
const props = defineProps<{
|
||||
const { widget } = defineProps<{
|
||||
widget: SimplifiedWidget<string, WidgetOptions>
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
const format = computed<ColorFormat>(() => {
|
||||
const optionFormat = props.widget.options?.format
|
||||
return isColorFormat(optionFormat) ? optionFormat : 'hex'
|
||||
const format = isColorFormat(widget.options?.format)
|
||||
? widget.options.format
|
||||
: 'hex'
|
||||
|
||||
const localValue = ref(toHexFromFormat(modelValue.value || '#000000', format))
|
||||
|
||||
watch(modelValue, (newVal) => {
|
||||
localValue.value = toHexFromFormat(newVal || '#000000', format)
|
||||
})
|
||||
|
||||
type PickerValue = string | HSB
|
||||
const localValue = ref<PickerValue>(
|
||||
toHexFromFormat(
|
||||
props.modelValue || '#000000',
|
||||
isColorFormat(props.widget.options?.format)
|
||||
? props.widget.options.format
|
||||
: 'hex'
|
||||
)
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
localValue.value = toHexFromFormat(newVal || '#000000', format.value)
|
||||
}
|
||||
)
|
||||
|
||||
function onPickerUpdate(val: unknown) {
|
||||
localValue.value = val as PickerValue
|
||||
emit('update:modelValue', toHexFromFormat(val, format.value))
|
||||
function onUpdate(val: string) {
|
||||
localValue.value = val
|
||||
modelValue.value = val
|
||||
}
|
||||
|
||||
// ColorPicker specific excluded props include panel/overlay classes
|
||||
const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const
|
||||
|
||||
const filteredProps = computed(() =>
|
||||
filterWidgetProps(props.widget.options, COLOR_PICKER_EXCLUDED_PROPS)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
@@ -11,6 +11,7 @@ const mockNodeIdToNodeLocatorId = vi.fn()
|
||||
const mockNodeLocatorIdToNodeExecutionId = vi.fn()
|
||||
|
||||
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { NodeProgressState } from '@/schemas/apiSchema'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
|
||||
@@ -40,6 +41,37 @@ vi.mock('@/composables/node/useNodeProgressText', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
/**
|
||||
* Captures event handlers registered via api.addEventListener so tests
|
||||
* can invoke them directly (e.g. to simulate WebSocket progress events).
|
||||
*/
|
||||
type EventHandler = (...args: unknown[]) => void
|
||||
const apiEventHandlers = new Map<string, EventHandler>()
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
addEventListener: vi.fn((event: string, handler: EventHandler) => {
|
||||
apiEventHandlers.set(event, handler)
|
||||
}),
|
||||
removeEventListener: vi.fn((event: string) => {
|
||||
apiEventHandlers.delete(event)
|
||||
}),
|
||||
clientId: 'test-client',
|
||||
apiURL: vi.fn((path: string) => `/api${path}`)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/imagePreviewStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
revokePreviewsByExecutionId: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/jobPreviewStore', () => ({
|
||||
useJobPreviewStore: () => ({
|
||||
clearPreview: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock the app import with proper implementation
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
@@ -270,6 +302,92 @@ describe('useExecutionStore - nodeLocationProgressStates caching', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - nodeProgressStatesByJob eviction', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
function makeProgressNodes(
|
||||
nodeId: string,
|
||||
jobId: string
|
||||
): Record<string, NodeProgressState> {
|
||||
return {
|
||||
[nodeId]: {
|
||||
value: 5,
|
||||
max: 10,
|
||||
state: 'running',
|
||||
node_id: nodeId,
|
||||
prompt_id: jobId,
|
||||
display_node_id: nodeId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fireProgressState(
|
||||
jobId: string,
|
||||
nodes: Record<string, NodeProgressState>
|
||||
) {
|
||||
const handler = apiEventHandlers.get('progress_state')
|
||||
if (!handler) throw new Error('progress_state handler not bound')
|
||||
handler(
|
||||
new CustomEvent('progress_state', { detail: { nodes, prompt_id: jobId } })
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
apiEventHandlers.clear()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = useExecutionStore()
|
||||
store.bindExecutionEvents()
|
||||
})
|
||||
|
||||
it('should retain entries below the limit', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
fireProgressState(`job-${i}`, makeProgressNodes(`${i}`, `job-${i}`))
|
||||
}
|
||||
|
||||
expect(Object.keys(store.nodeProgressStatesByJob)).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('should evict oldest entries when exceeding MAX_PROGRESS_JOBS', () => {
|
||||
for (let i = 0; i < MAX_PROGRESS_JOBS + 10; i++) {
|
||||
fireProgressState(`job-${i}`, makeProgressNodes(`${i}`, `job-${i}`))
|
||||
}
|
||||
|
||||
const keys = Object.keys(store.nodeProgressStatesByJob)
|
||||
expect(keys).toHaveLength(MAX_PROGRESS_JOBS)
|
||||
// Oldest jobs (0-9) should be evicted; newest should remain
|
||||
expect(keys).not.toContain('job-0')
|
||||
expect(keys).not.toContain('job-9')
|
||||
expect(keys).toContain(`job-${MAX_PROGRESS_JOBS + 9}`)
|
||||
expect(keys).toContain(`job-${MAX_PROGRESS_JOBS}`)
|
||||
})
|
||||
|
||||
it('should keep the most recently added job after eviction', () => {
|
||||
for (let i = 0; i < MAX_PROGRESS_JOBS + 1; i++) {
|
||||
fireProgressState(`job-${i}`, makeProgressNodes(`${i}`, `job-${i}`))
|
||||
}
|
||||
|
||||
const lastJobId = `job-${MAX_PROGRESS_JOBS}`
|
||||
expect(store.nodeProgressStatesByJob).toHaveProperty(lastJobId)
|
||||
})
|
||||
|
||||
it('should not evict when updating an existing job', () => {
|
||||
for (let i = 0; i < MAX_PROGRESS_JOBS; i++) {
|
||||
fireProgressState(`job-${i}`, makeProgressNodes(`${i}`, `job-${i}`))
|
||||
}
|
||||
expect(Object.keys(store.nodeProgressStatesByJob)).toHaveLength(
|
||||
MAX_PROGRESS_JOBS
|
||||
)
|
||||
|
||||
// Update an existing job — should not trigger eviction
|
||||
fireProgressState('job-0', makeProgressNodes('0', 'job-0'))
|
||||
expect(Object.keys(store.nodeProgressStatesByJob)).toHaveLength(
|
||||
MAX_PROGRESS_JOBS
|
||||
)
|
||||
expect(store.nodeProgressStatesByJob).toHaveProperty('job-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useExecutionStore - reconcileInitializingJobs', () => {
|
||||
let store: ReturnType<typeof useExecutionStore>
|
||||
|
||||
|
||||
@@ -46,6 +46,13 @@ interface QueuedJob {
|
||||
workflow?: ComfyWorkflow
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum number of job entries retained in {@link nodeProgressStatesByJob}.
|
||||
* When exceeded, the oldest entries (by insertion order) are evicted to
|
||||
* prevent unbounded memory growth in long-running sessions.
|
||||
*/
|
||||
export const MAX_PROGRESS_JOBS = 1000
|
||||
|
||||
export const useExecutionStore = defineStore('execution', () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
@@ -297,6 +304,34 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evicts the oldest entries from {@link nodeProgressStatesByJob} when the
|
||||
* map exceeds {@link MAX_PROGRESS_JOBS}, preventing unbounded memory
|
||||
* growth in long-running sessions.
|
||||
*
|
||||
* Relies on ES2015+ object key insertion order: the first keys returned
|
||||
* by `Object.keys` are the oldest entries.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Given 105 entries, evicts the 5 oldest:
|
||||
* evictOldProgressJobs()
|
||||
* Object.keys(nodeProgressStatesByJob.value).length // => 100
|
||||
* ```
|
||||
*/
|
||||
function evictOldProgressJobs() {
|
||||
const current = nodeProgressStatesByJob.value
|
||||
const keys = Object.keys(current)
|
||||
if (keys.length <= MAX_PROGRESS_JOBS) return
|
||||
|
||||
const pruned: Record<string, Record<string, NodeProgressState>> = {}
|
||||
const keysToKeep = keys.slice(keys.length - MAX_PROGRESS_JOBS)
|
||||
for (const key of keysToKeep) {
|
||||
pruned[key] = current[key]
|
||||
}
|
||||
nodeProgressStatesByJob.value = pruned
|
||||
}
|
||||
|
||||
function handleProgressState(e: CustomEvent<ProgressStateWsMessage>) {
|
||||
const { nodes, prompt_id: jobId } = e.detail
|
||||
|
||||
@@ -319,6 +354,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
...nodeProgressStatesByJob.value,
|
||||
[jobId]: nodes
|
||||
}
|
||||
evictOldProgressJobs()
|
||||
nodeProgressStates.value = nodes
|
||||
|
||||
// If we have progress for the currently executing node, update it for backwards compatibility
|
||||
|
||||
@@ -3,8 +3,10 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ColorAdjustOptions } from '@/utils/colorUtil'
|
||||
import {
|
||||
adjustColor,
|
||||
hexToHsva,
|
||||
hexToRgb,
|
||||
hsbToRgb,
|
||||
hsvaToHex,
|
||||
parseToRgb,
|
||||
rgbToHex
|
||||
} from '@/utils/colorUtil'
|
||||
@@ -132,6 +134,65 @@ describe('colorUtil conversions', () => {
|
||||
expect(rgbToHex(rgb)).toBe('#7f0000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hexToHsva / hsvaToHex', () => {
|
||||
it('round-trips hex -> hsva -> hex for primary colors', () => {
|
||||
expect(hsvaToHex(hexToHsva('#ff0000'))).toBe('#ff0000')
|
||||
expect(hsvaToHex(hexToHsva('#00ff00'))).toBe('#00ff00')
|
||||
expect(hsvaToHex(hexToHsva('#0000ff'))).toBe('#0000ff')
|
||||
})
|
||||
|
||||
it('handles black (v=0)', () => {
|
||||
const hsva = hexToHsva('#000000')
|
||||
expect(hsva.v).toBe(0)
|
||||
expect(hsvaToHex(hsva)).toBe('#000000')
|
||||
})
|
||||
|
||||
it('handles white (s=0, v=100)', () => {
|
||||
const hsva = hexToHsva('#ffffff')
|
||||
expect(hsva.s).toBe(0)
|
||||
expect(hsva.v).toBe(100)
|
||||
expect(hsvaToHex(hsva)).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('handles pure hues', () => {
|
||||
const red = hexToHsva('#ff0000')
|
||||
expect(red.h).toBeCloseTo(0)
|
||||
expect(red.s).toBeCloseTo(100)
|
||||
expect(red.v).toBeCloseTo(100)
|
||||
|
||||
const green = hexToHsva('#00ff00')
|
||||
expect(green.h).toBeCloseTo(120)
|
||||
|
||||
const blue = hexToHsva('#0000ff')
|
||||
expect(blue.h).toBeCloseTo(240)
|
||||
})
|
||||
|
||||
it('preserves alpha=100 (no alpha suffix in hex)', () => {
|
||||
const hsva = hexToHsva('#ff0000')
|
||||
expect(hsva.a).toBe(100)
|
||||
expect(hsvaToHex(hsva)).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('preserves alpha=0', () => {
|
||||
const hsva = hexToHsva('#ff000000')
|
||||
expect(hsva.a).toBe(0)
|
||||
expect(hsvaToHex(hsva)).toBe('#ff000000')
|
||||
})
|
||||
|
||||
it('round-trips hex with alpha', () => {
|
||||
const hex = '#ff000080'
|
||||
const hsva = hexToHsva(hex)
|
||||
expect(hsva.a).toBe(50)
|
||||
expect(hsvaToHex(hsva)).toBe(hex)
|
||||
})
|
||||
|
||||
it('handles 5-char hex with alpha', () => {
|
||||
const hsva = hexToHsva('#f008')
|
||||
expect(hsva.a).toBe(53)
|
||||
expect(hsvaToHex(hsva)).toMatch(/^#ff0000/)
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('colorUtil - adjustColor', () => {
|
||||
const runAdjustColorTests = (
|
||||
@@ -170,8 +231,7 @@ describe('colorUtil - adjustColor', () => {
|
||||
'xyz(255, 255, 255)',
|
||||
'hsl(100, 50, 50%)',
|
||||
'hsl(100, 50%, 50)',
|
||||
'#GGGGGG',
|
||||
'#3333'
|
||||
'#GGGGGG'
|
||||
]
|
||||
|
||||
invalidColors.forEach((color) => {
|
||||
@@ -183,6 +243,15 @@ describe('colorUtil - adjustColor', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('treats 5-char hex as valid color with alpha', () => {
|
||||
const result = adjustColor('#f008', {
|
||||
lightness: targetLightness,
|
||||
opacity: targetOpacity
|
||||
})
|
||||
expect(result).not.toBe('#f008')
|
||||
expect(result).toMatch(/^hsla\(/)
|
||||
})
|
||||
|
||||
it('returns the original value for null or undefined inputs', () => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
expect(adjustColor(null, { opacity: targetOpacity })).toBe(null)
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { memoize } from 'es-toolkit/compat'
|
||||
|
||||
type RGB = { r: number; g: number; b: number }
|
||||
export interface HSB {
|
||||
interface HSB {
|
||||
h: number
|
||||
s: number
|
||||
b: number
|
||||
}
|
||||
export interface HSVA {
|
||||
h: number
|
||||
s: number
|
||||
v: number
|
||||
a: number
|
||||
}
|
||||
type HSL = { h: number; s: number; l: number }
|
||||
type HSLA = { h: number; s: number; l: number; a: number }
|
||||
type ColorFormatInternal = 'hex' | 'rgb' | 'rgba' | 'hsl' | 'hsla'
|
||||
@@ -64,14 +70,11 @@ export function hexToRgb(hex: string): RGB {
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0
|
||||
// 3 digits
|
||||
if (hex.length == 4) {
|
||||
if (hex.length === 4 || hex.length === 5) {
|
||||
r = parseInt(hex[1] + hex[1], 16)
|
||||
g = parseInt(hex[2] + hex[2], 16)
|
||||
b = parseInt(hex[3] + hex[3], 16)
|
||||
}
|
||||
// 6 digits
|
||||
else if (hex.length == 7) {
|
||||
} else if (hex.length === 7 || hex.length === 9) {
|
||||
r = parseInt(hex.slice(1, 3), 16)
|
||||
g = parseInt(hex.slice(3, 5), 16)
|
||||
b = parseInt(hex.slice(5, 7), 16)
|
||||
@@ -193,7 +196,13 @@ export function parseToRgb(color: string): RGB {
|
||||
|
||||
const identifyColorFormat = (color: string): ColorFormatInternal | null => {
|
||||
if (!color) return null
|
||||
if (color.startsWith('#') && (color.length === 4 || color.length === 7))
|
||||
if (
|
||||
color.startsWith('#') &&
|
||||
(color.length === 4 ||
|
||||
color.length === 5 ||
|
||||
color.length === 7 ||
|
||||
color.length === 9)
|
||||
)
|
||||
return 'hex'
|
||||
if (/rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*/.test(color))
|
||||
return color.includes('rgba') ? 'rgba' : 'rgb'
|
||||
@@ -246,10 +255,12 @@ export function toHexFromFormat(val: unknown, format: ColorFormat): string {
|
||||
if (format === 'hex' && typeof val === 'string') {
|
||||
const raw = val.trim().toLowerCase()
|
||||
if (!raw) return '#000000'
|
||||
if (/^[0-9a-f]{3}$/.test(raw)) return `#${raw}`
|
||||
if (/^#[0-9a-f]{3}$/.test(raw)) return raw
|
||||
if (/^[0-9a-f]{3,4}$/.test(raw)) return `#${raw}`
|
||||
if (/^#[0-9a-f]{3,4}$/.test(raw)) return raw
|
||||
if (/^[0-9a-f]{6}$/.test(raw)) return `#${raw}`
|
||||
if (/^#[0-9a-f]{6}$/.test(raw)) return raw
|
||||
if (/^[0-9a-f]{8}$/.test(raw)) return `#${raw}`
|
||||
if (/^#[0-9a-f]{8}$/.test(raw)) return raw
|
||||
return '#000000'
|
||||
}
|
||||
|
||||
@@ -283,12 +294,22 @@ function parseToHSLA(color: string, format: ColorFormatInternal): HSLA | null {
|
||||
|
||||
switch (format) {
|
||||
case 'hex': {
|
||||
const hsl = rgbToHsl(hexToRgb(color))
|
||||
let a = 1
|
||||
let hexColor = color
|
||||
if (color.length === 9) {
|
||||
a = parseInt(color.slice(7, 9), 16) / 255
|
||||
hexColor = color.slice(0, 7)
|
||||
} else if (color.length === 5) {
|
||||
const aChar = color[4]
|
||||
a = parseInt(aChar + aChar, 16) / 255
|
||||
hexColor = color.slice(0, 4)
|
||||
}
|
||||
const hsl = rgbToHsl(hexToRgb(hexColor))
|
||||
return {
|
||||
h: Math.round(hsl.h * 360),
|
||||
s: +(hsl.s * 100).toFixed(1),
|
||||
l: +(hsl.l * 100).toFixed(1),
|
||||
a: 1
|
||||
a
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +343,66 @@ function parseToHSLA(color: string, format: ColorFormatInternal): HSLA | null {
|
||||
}
|
||||
}
|
||||
|
||||
function rgbToHsv({ r, g, b }: RGB): {
|
||||
h: number
|
||||
s: number
|
||||
v: number
|
||||
} {
|
||||
r /= 255
|
||||
g /= 255
|
||||
b /= 255
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
const d = max - min
|
||||
let h = 0
|
||||
const s = max === 0 ? 0 : (d / max) * 100
|
||||
const v = max * 100
|
||||
|
||||
if (d !== 0) {
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) * 60
|
||||
break
|
||||
case g:
|
||||
h = ((b - r) / d + 2) * 60
|
||||
break
|
||||
case b:
|
||||
h = ((r - g) / d + 4) * 60
|
||||
break
|
||||
}
|
||||
}
|
||||
return { h, s, v }
|
||||
}
|
||||
|
||||
export function hexToHsva(hex: string): HSVA {
|
||||
const normalized = hex.startsWith('#') ? hex : `#${hex}`
|
||||
let a = 100
|
||||
let hexColor = normalized
|
||||
|
||||
if (normalized.length === 9) {
|
||||
a = Math.round((parseInt(normalized.slice(7, 9), 16) / 255) * 100)
|
||||
hexColor = normalized.slice(0, 7)
|
||||
} else if (normalized.length === 5) {
|
||||
const aChar = normalized[4]
|
||||
a = Math.round((parseInt(aChar + aChar, 16) / 255) * 100)
|
||||
hexColor = normalized.slice(0, 4)
|
||||
}
|
||||
|
||||
const rgb = hexToRgb(hexColor)
|
||||
const hsv = rgbToHsv(rgb)
|
||||
return { ...hsv, a }
|
||||
}
|
||||
|
||||
export function hsvaToHex(hsva: HSVA): string {
|
||||
const rgb = hsbToRgb({ h: hsva.h, s: hsva.s, b: hsva.v })
|
||||
const hex = rgbToHex(rgb)
|
||||
if (hsva.a >= 100) return hex.toLowerCase()
|
||||
const alphaHex = Math.round((hsva.a / 100) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
return `${hex}${alphaHex}`.toLowerCase()
|
||||
}
|
||||
|
||||
const applyColorAdjustments = (
|
||||
color: string,
|
||||
options: ColorAdjustOptions
|
||||
|
||||
30
src/utils/executableGroupNodeDto.test.ts
Normal file
30
src/utils/executableGroupNodeDto.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LGraphEventMode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { ExecutableGroupNodeDTO } from './executableGroupNodeDto'
|
||||
|
||||
describe('Muted group node output resolution', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('should return undefined for NEVER mode group nodes', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('Muted Group')
|
||||
node.addOutput('out', 'IMAGE')
|
||||
node.mode = LGraphEventMode.NEVER
|
||||
graph.add(node)
|
||||
|
||||
const dto = new ExecutableGroupNodeDTO(node, [], new Map(), undefined)
|
||||
const resolved = dto.resolveOutput(0, 'IMAGE', new Set())
|
||||
|
||||
expect(resolved).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,9 @@ export class ExecutableGroupNodeDTO extends ExecutableNodeDTO {
|
||||
}
|
||||
|
||||
override resolveOutput(slot: number, type: ISlotType, visited: Set<string>) {
|
||||
// Muted nodes produce no output
|
||||
if (this.mode === LGraphEventMode.NEVER) return
|
||||
|
||||
// Temporary duplication: Bypass nodes are bypassed using the first input with matching type
|
||||
if (this.mode === LGraphEventMode.BYPASS) {
|
||||
const { inputs } = this
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sentryVitePlugin } from '@sentry/vite-plugin'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { execSync } from 'child_process'
|
||||
import { config as dotenvConfig } from 'dotenv'
|
||||
import type { IncomingMessage, ServerResponse } from 'http'
|
||||
import { Readable } from 'stream'
|
||||
@@ -55,6 +56,21 @@ const DISTRIBUTION: 'desktop' | 'localhost' | 'cloud' =
|
||||
// Can be overridden via IS_NIGHTLY env var for testing
|
||||
const IS_NIGHTLY = process.env.IS_NIGHTLY === 'true'
|
||||
|
||||
// Resolve the frontend git commit hash at build time.
|
||||
// Priority: FRONTEND_COMMIT_HASH env var → git rev-parse HEAD → 'unknown'
|
||||
// FRONTEND_COMMIT_HASH is an escape hatch for non-git environments (e.g. Docker
|
||||
// build containers without .git) where the commit hash can be injected externally.
|
||||
let GIT_COMMIT = process.env.FRONTEND_COMMIT_HASH || ''
|
||||
if (!GIT_COMMIT) {
|
||||
try {
|
||||
GIT_COMMIT = execSync('git rev-parse HEAD', { timeout: 5000 })
|
||||
.toString()
|
||||
.trim()
|
||||
} catch {
|
||||
GIT_COMMIT = 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
// Disable Vue DevTools for production cloud distribution
|
||||
const DISABLE_VUE_PLUGINS =
|
||||
process.env.DISABLE_VUE_PLUGINS === 'true' ||
|
||||
@@ -586,6 +602,7 @@ export default defineConfig({
|
||||
__COMFYUI_FRONTEND_VERSION__: JSON.stringify(
|
||||
process.env.npm_package_version
|
||||
),
|
||||
__COMFYUI_FRONTEND_COMMIT__: JSON.stringify(GIT_COMMIT),
|
||||
__SENTRY_ENABLED__: JSON.stringify(
|
||||
!(process.env.NODE_ENV === 'development' || !process.env.SENTRY_DSN)
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user