Compare commits

..

9 Commits

Author SHA1 Message Date
dante01yoon
f4a73737e4 test: regenerate screenshot expectations 2026-03-13 15:23:59 +09:00
Dante
c318cc4c14 feat: replace PrimeVue ColorPicker with custom component (#9647)
## Summary
- Replace PrimeVue `ColorPicker` with a custom component built on Reka
UI Popover
- New `ColorPicker` supports HSV saturation-value picking, hue/alpha
sliders, hex/rgba display toggle
- Simplify `WidgetColorPicker` by removing PrimeVue-specific
normalization logic
- Add Storybook stories for both `ColorPicker` and `WidgetColorPicker`

## Test plan
- [x] Unit tests pass (9 widget tests, 47 colorUtil tests)
- [x] Typecheck passes
- [x] Lint passes
- [ ] Verify color picker visually in Storybook
- [ ] Test color picking in node widgets with hex/rgb/hsb formats

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9647-feat-replace-PrimeVue-ColorPicker-with-custom-component-31e6d73d36508114bc54d958ff8d0448)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-13 12:45:10 +09:00
Jin Yi
bfabf128ce fix: inline splash CSS to prevent SPA fallback breakage on cloud environments (#9849)
## Summary

Inline splash screen CSS into `index.html` to fix broken loading
animation on cloud/ephemeral environments.

## Changes

- **What**: On cloud/ephemeral environments (e.g.
`fe-pr-*.testenvs.comfy.org`), SPA fallback serves `index.html` for
unknown paths. The `<link href="splash.css">` request resolves to
`/cloud/splash.css`, which the server does not find as a static file —
so it returns `index.html` with `200 OK`. The browser receives HTML
instead of CSS, the CSS parser silently ignores it, and the splash
screen renders without any styles or animations.
- Inlined `splash.css` directly into `index.html` `<style>` block —
eliminates the external request entirely
- Moved `splash.css` to `src/assets/` for content-hashed Vite processing
as source of truth
  - Removed `public/splash.css`

## Review Focus

- The inline CSS is byte-for-byte identical to the original
`public/splash.css`
- `src/assets/splash.css` preserved as canonical source for future
changes

[screen-capture
(1).webm](https://github.com/user-attachments/assets/06729641-d1fd-47aa-9dd4-4acd28c2cfcf)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9849-fix-inline-splash-CSS-to-prevent-SPA-fallback-breakage-on-cloud-environments-3226d73d365081418741eb0944a74977)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-13 02:42:46 +00:00
Jin Yi
6a8f3ef1a1 fix: show download icon alongside file size in missing models dialog (#9850)
## Summary

Fix download icon not appearing when file size is successfully fetched
in the missing models dialog.

## Changes

- **What**: Restructured the `v-if/v-else-if` chain in
`MissingModelsContent.vue` so that file size and download icon render
together instead of being mutually exclusive. Previously, a successful
file size fetch would prevent the download button from rendering.

## Review Focus

The file size span and download/gated-link are now inside a shared
`<template v-else-if="model.isDownloadable">` block. File size uses
`v-if` (independent), while gated link and download button remain
`v-if/v-else` (mutually exclusive with each other).


[screen-capture.webm](https://github.com/user-attachments/assets/f2f04d52-265b-4d05-992e-0ffe9bf64026)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9850-fix-show-download-icon-alongside-file-size-in-missing-models-dialog-3226d73d365081fd943bcfdedda87c73)
by [Unito](https://www.unito.io)
2026-03-13 11:30:54 +09:00
Dante
649b9b3fe3 fix: standardize i18n pluralization to two-part format (#9371)
## Summary

- Converts redundant three-part pluralization patterns (`zero | singular
| plural`) to standard two-part format (`singular | plural`) across 11
locale files
- Only converts patterns where the zero form duplicates the singular or
plural form
- Retains three-part patterns where the zero form provides distinct
content (e.g. `"No items selected"`) or where the language
linguistically requires all three forms (Russian singular/paucal/plural)

## Context

Vue i18n selects plural forms based on the number of choices:

**3-part** `"{count} nodes | {count} node | {count} nodes"`:
| count | index | result |
|-------|-------|--------|
| 0 | 0 (zero) | "0 nodes" |
| 1 | 1 (singular) | "1 node" |
| 2+ | 2 (plural) | "N nodes" |

**2-part** `"{count} node | {count} nodes"`:
| count | index | result |
|-------|-------|--------|
| 0 | 1 (plural) | "0 nodes" |
| 1 | 0 (singular) | "1 node" |
| 2+ | 1 (plural) | "N nodes" |

Output is identical — the zero form was always a duplicate of the plural
form. This PR removes that redundancy.

## Changes

| Locale | Keys changed |
|--------|-------------|
| en, es, fr, pt-BR, ar, tr | 5 (`nodesCount`, `asset`, `errorCount`,
`downloadsFailed`, `exportFailed`) |
| ja, ko, fa | 3 (`asset`, `nodesCount`, `downloadsFailed`) |
| ru | 2 (`downloadsFailed`, `exportFailed`) |
| zh-TW | 2 (`nodesCount`, `downloadsFailed`) |

## Test plan

- [ ] Verify pluralization renders correctly for count=0, count=1,
count=2+ in affected UI areas
- [ ] Spot-check non-English locales (especially Russian which retains
3-part for linguistically distinct forms)

- Fixes #9277

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-03-13 10:16:59 +09:00
Hunter
d82bce90ea feat: bake frontend commit hash into build (#9832)
## Summary

Bake the frontend git commit hash into the build so it no longer needs
to be fetched from the server via `/api/system_stats`.

## Changes

- **What**: Add `__COMFYUI_FRONTEND_COMMIT__` build-time constant (via
Vite `define`) sourced from `git rev-parse HEAD` at build time. Falls
back to `"unknown"` if git is unavailable. `SystemStatsPanel` uses this
baked-in value for the "Frontend Version" row in cloud mode instead of
the server-provided `comfyui_frontend_version` field.

## Testing
Confirmed to display the actual commit.
<img width="1448" height="908" alt="Screenshot 2026-03-12 at 7 09 52 PM"
src="https://github.com/user-attachments/assets/2b42348a-5c3e-4509-aa84-1a259bba5f3f"
/>


## Review Focus

- The `getDisplayValue` override for `comfyui_frontend_version` —
cleanest way to swap the data source without restructuring the column
system.
- No cloud-side changes needed: the `sync-frontend-build` workflow
already checks out the frontend repo at the exact commit ref, so `git
rev-parse HEAD` returns the correct hash.
2026-03-12 21:07:07 -04:00
Luke Mino-Altherr
91e429a62f fix: use order-independent tag matching in asset browser categories (#9843)
## Summary

Fix asset browser sidebar missing categories because `typeCategories`
assumed a fixed tag order from the API.

## Changes

- **What**: Replace `tags[0] === 'models'` / `tags[1]` with
`tags.includes(MODELS_TAG)` and `flatMap`+`filter`, matching the pattern
used by `getAssetModelFolders` and `filterByCategory`.

## Review Focus

The API returns tags in arbitrary order (e.g. `['checkpoints',
'models']` instead of `['models', 'checkpoints']`). The old code
filtered out most assets, resulting in an empty sidebar. New test
validates arbitrary tag ordering.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9843-fix-use-order-independent-tag-matching-in-asset-browser-categories-3216d73d365081b886f3d5ab1790e19d)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-12 17:59:05 -07:00
John Haugeland
21edbd3ee5 fix: cap nodeProgressStatesByJob to prevent unbounded memory growth (#9249)
This has semi-significant performance impact if you use the same
workflow for more than a day. I left Comfy running with a mouse jiggler
and this reduced my instance to a crawl after about an hour.

`nodeProgressStatesByJob` accumulated an entry for every job that ever
executed during a session. In long-running sessions this grew without
bound, since entries were only removed for the active job on
`resetExecutionState`.

Add `MAX_PROGRESS_JOBS` (1000) and `evictOldProgressJobs()`, called
after each `handleProgressState` update. When the map exceeds the limit,
the oldest entries (by ES2015+ insertion order) are pruned — keeping
only the most recent 1000. This mirrors the pattern used by
assetsStore's `MAX_HISTORY_ITEMS`.

Also adds tests for:
- nodeLocationProgressStates computed reactivity (recomputes on
  wholesale replacement, produces new references)
- Eviction behavior (retains below limit, evicts oldest above limit,
  preserves most recent, no-op when updating existing job)
- API event handler wiring via captured apiEventHandlers map

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9249-fix-cap-nodeProgressStatesByJob-to-prevent-unbounded-memory-growth-3136d73d365081e49b36d8ade0d4dd6e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-12 17:51:14 -07:00
Christian Byrne
f5363e4028 fix: return undefined for muted node output resolution (#9302)
## Summary

Muted (NEVER mode) subgraph nodes throw "No inner node DTO found" during
prompt serialization because `resolveOutput()` falls through to subgraph
resolution for nodes whose inner DTOs were never registered.

## Changes

- **What**: Add early return in `ExecutableNodeDTO.resolveOutput()` for
`NEVER` mode nodes, matching the existing `BYPASS` mode guard. Add 5
tests covering muted, bypassed, and normal mode resolution.

## Review Focus

The fix is a single-line early return. The key insight is that
`graphToPrompt` in `executionUtil.ts` correctly skips `getInnerNodes()`
for muted/bypassed nodes, so their inner DTOs are never in the map — but
`resolveOutput()` was missing the corresponding guard for `NEVER` mode.

Fixes #8986

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9302-fix-return-undefined-for-muted-node-output-resolution-3156d73d3650811e9697c7281f11cf96)
by [Unito](https://www.unito.io)
2026-03-12 17:44:32 -07:00
35 changed files with 1272 additions and 322 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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>

View File

@@ -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)
}

View File

@@ -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"

View 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" />'
})
}

View 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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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()

View File

@@ -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

View File

@@ -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": "الكل",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "همه",

View File

@@ -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 lexportation",
"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 lexport 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 dexé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",

View File

@@ -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": "すべて",

View File

@@ -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": "전체",

View File

@@ -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",

View File

@@ -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ü",

View File

@@ -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": "全部",

View File

@@ -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'] }),

View File

@@ -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))

View File

@@ -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" />'
})
}

View File

@@ -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)
})
})

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View 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()
})
})

View File

@@ -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

View File

@@ -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)
),