Compare commits

..

20 Commits

Author SHA1 Message Date
Benjamin Lu
003e72406d refactor: move queue files under domain 2026-01-08 20:00:49 -08:00
Benjamin Lu
5561efcfd3 Add ... context menu to list view 2025-12-31 18:23:22 -08:00
Benjamin Lu
79af71530d fix: avoid unused export in job actions 2025-12-31 18:17:59 -08:00
Benjamin Lu
f78b6eec47 Add and use knipIgnoreUnusedButUsedByStorybook 2025-12-31 18:16:07 -08:00
Benjamin Lu
d8e57c60bf Add stories for list view and general job card 2025-12-31 18:16:07 -08:00
Benjamin Lu
db3edd522d Add list view 2025-12-31 18:14:48 -08:00
Benjamin Lu
f128c61c53 Remove special failed job styling 2025-12-31 18:10:17 -08:00
GitHub Action
afa4664ad5 [automated] Apply ESLint and Prettier fixes 2025-12-31 17:57:26 -08:00
Benjamin Lu
627db6784e knip 2025-12-31 17:56:08 -08:00
Benjamin Lu
85c6825a79 Add list view 2025-12-31 17:56:08 -08:00
Benjamin Lu
f614914fdf Remove view action from AssetsListCard story 2025-12-31 17:52:10 -08:00
Benjamin Lu
da4889900e Add AssetsListCard stories 2025-12-31 17:52:10 -08:00
Benjamin Lu
5b1456896b Add AssetsListCard base template 2025-12-31 17:52:10 -08:00
Benjamin Lu
0c6ea56360 fix: read QPOV2 setting in assets sidebar 2025-12-31 16:54:17 -08:00
Benjamin Lu
2fd9a73b68 Readd divider as v-else 2025-12-31 15:05:30 -08:00
Benjamin Lu
dc53cbe3c9 Add N active jobs and clear queue button 2025-12-31 15:05:30 -08:00
Benjamin Lu
3d0c0d16ca Move feature flag to setting 2025-12-31 14:59:15 -08:00
Benjamin Lu
a0dad31e2f Add feature flag 2025-12-31 14:59:15 -08:00
Benjamin Lu
5ddea4e7b6 Extract to component 2025-12-31 14:59:15 -08:00
Benjamin Lu
94884d7a7c feat: add queue view toggle stub 2025-12-31 14:59:15 -08:00
420 changed files with 3105 additions and 5226 deletions

View File

@@ -69,9 +69,32 @@ const config: StorybookConfig = {
allowedHosts: true
},
resolve: {
alias: {
'@': process.cwd() + '/src'
}
alias: [
{
find: '@/composables/queue/useJobList',
replacement: process.cwd() + '/src/storybook/mocks/useJobList.ts'
},
{
find: '@/composables/queue/useJobActions',
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
},
{
find: '@/utils/formatUtil',
replacement:
process.cwd() +
'/packages/shared-frontend-utils/src/formatUtil.ts'
},
{
find: '@/utils/networkUtil',
replacement:
process.cwd() +
'/packages/shared-frontend-utils/src/networkUtil.ts'
},
{
find: '@',
replacement: process.cwd() + '/src'
}
]
},
esbuild: {
// Prevent minification of identifiers to preserve _sfc_main

View File

@@ -1 +0,0 @@
AMD and the AMD Arrow logo are trademarks of Advanced Micro Devices, Inc.

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/desktop-ui",
"version": "0.0.6",
"version": "0.0.4",
"type": "module",
"nx": {
"tags": [

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,84 +0,0 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import type {
ElectronAPI,
TorchDeviceType
} from '@comfyorg/comfyui-electron-types'
import { ref } from 'vue'
import GpuPicker from './GpuPicker.vue'
type Platform = ReturnType<ElectronAPI['getPlatform']>
type ElectronAPIStub = Pick<ElectronAPI, 'getPlatform'>
type WindowWithElectron = Window & { electronAPI?: ElectronAPIStub }
const meta: Meta<typeof GpuPicker> = {
title: 'Desktop/Components/GpuPicker',
component: GpuPicker,
parameters: {
layout: 'padded',
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' },
{ name: 'neutral-950', value: '#0a0a0a' }
]
}
}
}
export default meta
type Story = StoryObj<typeof meta>
function createElectronDecorator(platform: Platform) {
function getPlatform() {
return platform
}
return function ElectronDecorator() {
const windowWithElectron = window as WindowWithElectron
windowWithElectron.electronAPI = { getPlatform }
return { template: '<story />' }
}
}
function renderWithDevice(device: TorchDeviceType | null) {
return function Render() {
return {
components: { GpuPicker },
setup() {
const selected = ref<TorchDeviceType | null>(device)
return { selected }
},
template: `
<div class="min-h-screen bg-neutral-950 p-8">
<GpuPicker v-model:device="selected" />
</div>
`
}
}
}
const windowsDecorator = createElectronDecorator('win32')
const macDecorator = createElectronDecorator('darwin')
export const WindowsNvidiaSelected: Story = {
decorators: [windowsDecorator],
render: renderWithDevice('nvidia')
}
export const WindowsAmdSelected: Story = {
decorators: [windowsDecorator],
render: renderWithDevice('amd')
}
export const WindowsCpuSelected: Story = {
decorators: [windowsDecorator],
render: renderWithDevice('cpu')
}
export const MacMpsSelected: Story = {
decorators: [macDecorator],
render: renderWithDevice('mps')
}

View File

@@ -11,32 +11,29 @@
<!-- Apple Metal / NVIDIA -->
<HardwareOption
v-if="platform === 'darwin'"
image-path="./assets/images/apple-mps-logo.png"
:image-path="'./assets/images/apple-mps-logo.png'"
placeholder-text="Apple Metal"
subtitle="Apple Metal"
:value="'mps'"
:selected="selected === 'mps'"
:recommended="true"
@click="pickGpu('mps')"
/>
<template v-else>
<HardwareOption
image-path="./assets/images/nvidia-logo-square.jpg"
placeholder-text="NVIDIA"
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
:selected="selected === 'nvidia'"
@click="pickGpu('nvidia')"
/>
<HardwareOption
image-path="./assets/images/amd-rocm-logo.png"
placeholder-text="AMD"
:subtitle="$t('install.gpuPicker.amdSubtitle')"
:selected="selected === 'amd'"
@click="pickGpu('amd')"
/>
</template>
<HardwareOption
v-else
:image-path="'./assets/images/nvidia-logo-square.jpg'"
placeholder-text="NVIDIA"
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
:value="'nvidia'"
:selected="selected === 'nvidia'"
:recommended="true"
@click="pickGpu('nvidia')"
/>
<!-- CPU -->
<HardwareOption
placeholder-text="CPU"
:subtitle="$t('install.gpuPicker.cpuSubtitle')"
:value="'cpu'"
:selected="selected === 'cpu'"
@click="pickGpu('cpu')"
/>
@@ -44,6 +41,7 @@
<HardwareOption
placeholder-text="Manual Install"
:subtitle="$t('install.gpuPicker.manualSubtitle')"
:value="'unsupported'"
:selected="selected === 'unsupported'"
@click="pickGpu('unsupported')"
/>
@@ -83,15 +81,13 @@ const selected = defineModel<TorchDeviceType | null>('device', {
const electron = electronAPI()
const platform = electron.getPlatform()
const recommendedDevices: TorchDeviceType[] = ['mps', 'nvidia', 'amd']
const showRecommendedBadge = computed(() =>
selected.value ? recommendedDevices.includes(selected.value) : false
const showRecommendedBadge = computed(
() => selected.value === 'mps' || selected.value === 'nvidia'
)
const descriptionKeys = {
mps: 'appleMetal',
nvidia: 'nvidia',
amd: 'amd',
cpu: 'cpu',
unsupported: 'manual'
} as const
@@ -101,7 +97,7 @@ const descriptionText = computed(() => {
return st(`install.gpuPicker.${key}Description`, '')
})
function pickGpu(value: TorchDeviceType) {
const pickGpu = (value: TorchDeviceType) => {
selected.value = value
}
</script>

View File

@@ -29,6 +29,7 @@ export const AppleMetalSelected: Story = {
imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal',
subtitle: 'Apple Metal',
value: 'mps',
selected: true
}
}
@@ -38,6 +39,7 @@ export const AppleMetalUnselected: Story = {
imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal',
subtitle: 'Apple Metal',
value: 'mps',
selected: false
}
}
@@ -46,6 +48,7 @@ export const CPUOption: Story = {
args: {
placeholderText: 'CPU',
subtitle: 'Subtitle',
value: 'cpu',
selected: false
}
}
@@ -54,6 +57,7 @@ export const ManualInstall: Story = {
args: {
placeholderText: 'Manual Install',
subtitle: 'Subtitle',
value: 'unsupported',
selected: false
}
}
@@ -63,6 +67,7 @@ export const NvidiaSelected: Story = {
imagePath: '/assets/images/nvidia-logo-square.jpg',
placeholderText: 'NVIDIA',
subtitle: 'NVIDIA',
value: 'nvidia',
selected: true
}
}

View File

@@ -36,13 +36,17 @@
</template>
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { cn } from '@/utils/tailwindUtil'
interface Props {
imagePath?: string
placeholderText: string
subtitle?: string
value: TorchDeviceType
selected?: boolean
recommended?: boolean
}
defineProps<Props>()

View File

@@ -104,8 +104,8 @@
</template>
<script setup lang="ts">
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
import { isInChina } from '@comfyorg/shared-frontend-utils/networkUtil'
import Accordion from 'primevue/accordion'
import AccordionContent from 'primevue/accordioncontent'
@@ -155,7 +155,7 @@ const activeAccordionIndex = ref<string[] | undefined>(undefined)
const electron = electronAPI()
// Mirror configuration logic
function getTorchMirrorItem(device: TorchDeviceType): UVMirror {
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
switch (device) {
case 'mps':
@@ -170,7 +170,6 @@ function getTorchMirrorItem(device: TorchDeviceType): UVMirror {
mirror: TorchMirrorUrl.Cuda,
fallbackMirror: TorchMirrorUrl.Cuda
}
case 'amd':
case 'cpu':
default:
return {

View File

@@ -63,6 +63,7 @@ const taskStore = useMaintenanceTaskStore()
defineProps<{
displayAsList: string
filter: MaintenanceFilter
isRefreshing: boolean
}>()
const executeTask = async (task: MaintenanceTask) => {

View File

@@ -143,8 +143,6 @@ const goToPreviousStep = () => {
const electron = electronAPI()
const router = useRouter()
const install = async () => {
if (!device.value) return
const options: InstallOptions = {
installPath: installPath.value,
autoUpdate: autoUpdate.value,
@@ -154,6 +152,7 @@ const install = async () => {
pythonMirror: pythonMirror.value,
pypiMirror: pypiMirror.value,
torchMirror: torchMirror.value,
// @ts-expect-error fixme ts strict error
device: device.value
}
electron.installComfyUI(options)
@@ -167,11 +166,7 @@ onMounted(async () => {
if (!electron) return
const detectedGpu = await electron.Config.getDetectedGpu()
if (
detectedGpu === 'mps' ||
detectedGpu === 'nvidia' ||
detectedGpu === 'amd'
) {
if (detectedGpu === 'mps' || detectedGpu === 'nvidia') {
device.value = detectedGpu
}

View File

@@ -74,6 +74,7 @@
class="border-neutral-700 border-solid border-x-0 border-y"
:filter
:display-as-list
:is-refreshing
/>
<!-- Actions -->

View File

@@ -1,6 +1,6 @@
import type { Locator, Page } from '@playwright/test'
import type { AutoQueueMode } from '../../src/stores/queueStore'
import type { AutoQueueMode } from '../../src/queue/stores/queueStore'
export class ComfyActionbar {
public readonly root: Locator

View File

@@ -19,7 +19,6 @@ test.describe('Graph', () => {
})
test('Validate workflow links', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Validation.Workflows', true)
await comfyPage.loadWorkflow('links/bad_link')
await expect(comfyPage.getVisibleToastCount()).resolves.toBe(2)
})

View File

@@ -83,7 +83,7 @@ test.describe('Templates', () => {
await comfyPage.page
.locator(
'nav > div:nth-child(3) > div > span:has-text("Getting Started")'
'nav > div:nth-child(2) > div > span:has-text("Getting Started")'
)
.click()
await comfyPage.templates.loadTemplate('default')

View File

@@ -1,66 +0,0 @@
# Template Ranking System
Usage-based ordering for workflow templates with position bias normalization.
Scores are pre-computed and normalized offline and shipped as static JSON (mirrors `sorted-custom-node-map.json` pattern for node search).
## Sort Modes
| Mode | Formula | Description |
| -------------- | ------------------------------------------------ | ---------------------- |
| `recommended` | `usage × 0.5 + internal × 0.3 + freshness × 0.2` | Curated recommendation |
| `popular` | `usage × 0.9 + freshness × 0.1` | Pure user-driven |
| `newest` | Date sort | Existing |
| `alphabetical` | Name sort | Existing |
Freshness computed at runtime from `template.date`: `1.0 / (1 + daysSinceAdded / 90)`, min 0.1.
## Data Files
**Usage scores** (generated from Mixpanel):
```json
// In templates/index.json, add to any template:
{
"name": "some_template",
"usage": 1000,
...
}
```
**Search rank** (set per-template in workflow_templates repo):
```json
// In templates/index.json, add to any template:
{
"name": "some_template",
"searchRank": 8, // Scale 1-10, default 5
...
}
```
| searchRank | Effect |
| ---------- | ---------------------------- |
| 1-4 | Demote (bury in results) |
| 5 | Neutral (default if not set) |
| 6-10 | Promote (boost in results) |
## Position Bias Correction
Raw usage reflects true preference AND UI position bias. We use linear interpolation:
```
correction = 1 + (position - 1) / (maxPosition - 1)
normalizedUsage = rawUsage × correction
```
| Position | Boost |
| -------- | ----- |
| 1 | 1.0× |
| 50 | 1.28× |
| 100 | 1.57× |
| 175 | 2.0× |
Templates buried at the bottom get up to 2× boost to compensate for reduced visibility.
---

View File

@@ -66,7 +66,8 @@ const config: KnipConfig = {
},
tags: [
'-knipIgnoreUnusedButUsedByCustomNodes',
'-knipIgnoreUnusedButUsedByVueNodesBranch'
'-knipIgnoreUnusedButUsedByVueNodesBranch',
'-knipIgnoreUnusedButUsedByStorybook'
]
}

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.37.5",
"version": "1.37.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -42,7 +42,6 @@
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
"storybook": "nx storybook",
"storybook:desktop": "nx run @comfyorg/desktop-ui:storybook",
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
"test:browser": "pnpm exec nx e2e",
@@ -187,7 +186,6 @@
"vue-i18n": "catalog:",
"vue-router": "catalog:",
"vuefire": "catalog:",
"wwobjloader2": "catalog:",
"yjs": "catalog:",
"zod": "catalog:",
"zod-validation-error": "catalog:"

46
pnpm-lock.yaml generated
View File

@@ -10,8 +10,8 @@ catalogs:
specifier: ^5.2.0
version: 5.2.0
'@comfyorg/comfyui-electron-types':
specifier: 0.6.2
version: 0.6.2
specifier: 0.5.5
version: 0.5.5
'@eslint/js':
specifier: ^9.39.1
version: 9.39.1
@@ -309,9 +309,6 @@ catalogs:
vuefire:
specifier: ^3.2.1
version: 3.2.1
wwobjloader2:
specifier: ^6.2.1
version: 6.2.1
yjs:
specifier: ^13.6.27
version: 13.6.27
@@ -340,7 +337,7 @@ importers:
version: 1.3.1
'@comfyorg/comfyui-electron-types':
specifier: 'catalog:'
version: 0.6.2
version: 0.5.5
'@comfyorg/design-system':
specifier: workspace:*
version: link:packages/design-system
@@ -500,9 +497,6 @@ importers:
vuefire:
specifier: 'catalog:'
version: 3.2.1(consola@3.4.2)(firebase@11.6.0)(vue@3.5.13(typescript@5.9.3))
wwobjloader2:
specifier: 'catalog:'
version: 6.2.1(three@0.170.0)
yjs:
specifier: 'catalog:'
version: 13.6.27
@@ -743,7 +737,7 @@ importers:
dependencies:
'@comfyorg/comfyui-electron-types':
specifier: 'catalog:'
version: 0.6.2
version: 0.5.5
'@comfyorg/shared-frontend-utils':
specifier: workspace:*
version: link:../../packages/shared-frontend-utils
@@ -1483,8 +1477,8 @@ packages:
'@cacheable/utils@2.3.2':
resolution: {integrity: sha512-8kGE2P+HjfY8FglaOiW+y8qxcaQAfAhVML+i66XJR3YX5FtyDqn6Txctr3K2FrbxLKixRRYYBWMbuGciOhYNDg==}
'@comfyorg/comfyui-electron-types@0.6.2':
resolution: {integrity: sha512-r3By5Wbizq8jagUrhtcym79HYUTinsvoBnYkFFWbUmrURBWIaC0HduFVkRkI1PNdI76piW+JSOJJnw00YCVXeg==}
'@comfyorg/comfyui-electron-types@0.5.5':
resolution: {integrity: sha512-f3XOXpMsALIwHakz7FekVPm4/Fh2pvJPEi8tRe8jYGBt8edsd4Mkkq31Yjs2Weem3BP7yNwbdNuSiQdP/pxJyg==}
'@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
@@ -8231,19 +8225,6 @@ packages:
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
engines: {node: '>=18'}
wtd-core@3.0.0:
resolution: {integrity: sha512-LSPfAQ5ULSV5vPhipcjdQvV5xOx25QesYK23jwUOF99xOx6fuulk7CMQerERRwA4uoQooNmRd8AT6IPBwORlWQ==}
wtd-three-ext@3.0.0:
resolution: {integrity: sha512-PLZJipCAiinot8D1uB4A7+XHxPAYeZXDhczbbazK7pKdqpE77zMizQH4rSZsaNbzktgnIfpgK/ODqhJTdrUjUw==}
peerDependencies:
three: '>= 0.137.5 < 1'
wwobjloader2@6.2.1:
resolution: {integrity: sha512-/v/sfUX0PMQAI8souzCs6xsO9LR3RyL+ujnOiS/1pngUlakKyHYC5XMQvu77pTeWzY3rzNyt5Q/bg5O3RukA+g==}
peerDependencies:
three: '>= 0.137.5 < 1'
xdg-basedir@5.1.0:
resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==}
engines: {node: '>=12'}
@@ -9198,7 +9179,7 @@ snapshots:
hashery: 1.3.0
keyv: 5.5.5
'@comfyorg/comfyui-electron-types@0.6.2': {}
'@comfyorg/comfyui-electron-types@0.5.5': {}
'@csstools/color-helpers@5.1.0': {}
@@ -17144,19 +17125,6 @@ snapshots:
dependencies:
is-wsl: 3.1.0
wtd-core@3.0.0: {}
wtd-three-ext@3.0.0(three@0.170.0):
dependencies:
three: 0.170.0
wtd-core: 3.0.0
wwobjloader2@6.2.1(three@0.170.0):
dependencies:
three: 0.170.0
wtd-core: 3.0.0
wtd-three-ext: 3.0.0(three@0.170.0)
xdg-basedir@5.1.0: {}
xml-name-validator@4.0.0: {}

View File

@@ -4,7 +4,7 @@ packages:
catalog:
'@alloc/quick-lru': ^5.2.0
'@comfyorg/comfyui-electron-types': 0.6.2
'@comfyorg/comfyui-electron-types': 0.5.5
'@eslint/js': ^9.39.1
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
@@ -104,7 +104,6 @@ catalog:
vue-router: ^4.4.3
vue-tsc: ^3.2.1
vuefire: ^3.2.1
wwobjloader2: ^6.2.1
yjs: ^13.6.27
zod: ^3.23.8
zod-to-json-schema: ^3.24.1

View File

@@ -83,7 +83,7 @@ import { useI18n } from 'vue-i18n'
import ComfyActionbar from '@/components/actionbar/ComfyActionbar.vue'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import QueueProgressOverlay from '@/components/queue/QueueProgressOverlay.vue'
import QueueProgressOverlay from '@/queue/components/QueueProgressOverlay.vue'
import ActionBarButtons from '@/components/topbar/ActionBarButtons.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
@@ -92,8 +92,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
import { useQueueStore } from '@/queue/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isElectron } from '@/utils/envUtil'
@@ -107,10 +106,8 @@ const { isLoggedIn } = useCurrentUser()
const isDesktop = isElectron()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const commandStore = useCommandStore()
const isQueueOverlayExpanded = ref(false)
const queueStore = useQueueStore()
const queueUIStore = useQueueUIStore()
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const isTopMenuHovered = ref(false)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const queueHistoryTooltipConfig = computed(() =>
@@ -136,7 +133,7 @@ onMounted(() => {
})
const toggleQueueOverlay = () => {
commandStore.execute('Comfy.Queue.ToggleOverlay')
isQueueOverlayExpanded.value = !isQueueOverlayExpanded.value
}
const openCustomNodeManager = async () => {

View File

@@ -38,7 +38,7 @@ import InputNumber from 'primevue/inputnumber'
import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useQueueSettingsStore } from '@/queue/stores/queueStore'
const queueSettingsStore = useQueueSettingsStore()
const { batchCount } = storeToRefs(queueSettingsStore)

View File

@@ -48,7 +48,7 @@ import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useQueueSettingsStore } from '@/queue/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'

View File

@@ -175,7 +175,6 @@
<!-- Actual Template Cards -->
<CardContainer
v-for="template in isLoading ? [] : displayTemplates"
v-show="isTemplateVisibleOnDistribution(template)"
:key="template.name"
ref="cardRefs"
size="compact"
@@ -406,8 +405,6 @@ import { useTelemetry } from '@/platform/telemetry'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'
@@ -426,30 +423,6 @@ onMounted(() => {
sessionStartTime.value = Date.now()
})
const systemStatsStore = useSystemStatsStore()
const distributions = computed(() => {
// eslint-disable-next-line no-undef
switch (__DISTRIBUTION__) {
case 'cloud':
return [TemplateIncludeOnDistributionEnum.Cloud]
case 'localhost':
return [TemplateIncludeOnDistributionEnum.Local]
case 'desktop':
default:
if (systemStatsStore.systemStats?.system.os === 'darwin') {
return [
TemplateIncludeOnDistributionEnum.Desktop,
TemplateIncludeOnDistributionEnum.Mac
]
}
return [
TemplateIncludeOnDistributionEnum.Desktop,
TemplateIncludeOnDistributionEnum.Windows
]
}
})
// Wrap onClose to track session end
const onClose = () => {
if (isCloud) {
@@ -538,9 +511,6 @@ const allTemplates = computed(() => {
return workflowTemplatesStore.enhancedTemplates
})
// Navigation
const selectedNavItem = ref<string | null>('all')
// Filter templates based on selected navigation item
const navigationFilteredTemplates = computed(() => {
if (!selectedNavItem.value) {
@@ -566,36 +536,6 @@ const {
resetFilters
} = useTemplateFiltering(navigationFilteredTemplates)
/**
* Coordinates state between the selected navigation item and the sort order to
* create deterministic, predictable behavior.
* @param source The origin of the change ('nav' or 'sort').
*/
const coordinateNavAndSort = (source: 'nav' | 'sort') => {
const isPopularNav = selectedNavItem.value === 'popular'
const isPopularSort = sortBy.value === 'popular'
if (source === 'nav') {
if (isPopularNav && !isPopularSort) {
// When navigating to 'Popular' category, automatically set sort to 'Popular'.
sortBy.value = 'popular'
} else if (!isPopularNav && isPopularSort) {
// When navigating away from 'Popular' category while sort is 'Popular', reset sort to default.
sortBy.value = 'default'
}
} else if (source === 'sort') {
// When sort is changed away from 'Popular' while in the 'Popular' category,
// reset the category to 'All Templates' to avoid a confusing state.
if (isPopularNav && !isPopularSort) {
selectedNavItem.value = 'all'
}
}
}
// Watch for changes from the two sources ('nav' and 'sort') and trigger the coordinator.
watch(selectedNavItem, () => coordinateNavAndSort('nav'))
watch(sortBy, () => coordinateNavAndSort('sort'))
// Convert between string array and object array for MultiSelect component
const selectedModelObjects = computed({
get() {
@@ -638,6 +578,9 @@ const cardRefs = ref<HTMLElement[]>([])
// Force re-render key for templates when sorting changes
const templateListKey = ref(0)
// Navigation
const selectedNavItem = ref<string | null>('all')
// Search text for model filter
const modelSearchText = ref<string>('')
@@ -702,19 +645,11 @@ const runsOnFilterLabel = computed(() => {
// Sort options
const sortOptions = computed(() => [
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
{
name: t('templateWorkflows.sort.default', 'Default'),
value: 'default'
},
{
name: t('templateWorkflows.sort.recommended', 'Recommended'),
value: 'recommended'
},
{
name: t('templateWorkflows.sort.popular', 'Popular'),
value: 'popular'
},
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
{
name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
value: 'vram-low-to-high'
@@ -815,7 +750,7 @@ const pageTitle = computed(() => {
// Initialize templates loading with useAsyncState
const { isLoading } = useAsyncState(
async () => {
// Run all operations in parallel for better performance
// Run both operations in parallel for better performance
await Promise.all([
loadTemplates(),
workflowTemplatesStore.loadWorkflowTemplates()
@@ -828,14 +763,6 @@ const { isLoading } = useAsyncState(
}
)
const isTemplateVisibleOnDistribution = (template: TemplateInfo) => {
return (template.includeOnDistributions?.length ?? 0) > 0
? distributions.value.some((d) =>
template.includeOnDistributions?.includes(d)
)
: true
}
onBeforeUnmount(() => {
cardRefs.value = [] // Release DOM refs
})

View File

@@ -24,7 +24,6 @@
v-model:light-config="lightConfig"
:is-splat-model="isSplatModel"
:is-ply-model="isPlyModel"
:has-skeleton="hasSkeleton"
@update-background-image="handleBackgroundImageUpdate"
@export-model="handleExportModel"
/>
@@ -34,9 +33,6 @@
v-model:playing="playing"
v-model:selected-speed="selectedSpeed"
v-model:selected-animation="selectedAnimation"
v-model:animation-progress="animationProgress"
v-model:animation-duration="animationDuration"
@seek="handleSeek"
/>
</div>
<div
@@ -117,15 +113,12 @@ const {
isPreview,
isSplatModel,
isPlyModel,
hasSkeleton,
hasRecording,
recordingDuration,
animations,
playing,
selectedSpeed,
selectedAnimation,
animationProgress,
animationDuration,
loading,
loadingMessage,
@@ -137,7 +130,6 @@ const {
handleStopRecording,
handleExportRecording,
handleClearRecording,
handleSeek,
handleBackgroundImageUpdate,
handleExportModel,
handleModelDrop,

View File

@@ -58,10 +58,8 @@
v-if="showModelControls"
v-model:material-mode="modelConfig!.materialMode"
v-model:up-direction="modelConfig!.upDirection"
v-model:show-skeleton="modelConfig!.showSkeleton"
:hide-material-mode="isSplatModel"
:is-ply-model="isPlyModel"
:has-skeleton="hasSkeleton"
/>
<CameraControls
@@ -101,14 +99,9 @@ import type {
} from '@/extensions/core/load3d/interfaces'
import { cn } from '@/utils/tailwindUtil'
const {
isSplatModel = false,
isPlyModel = false,
hasSkeleton = false
} = defineProps<{
const { isSplatModel = false, isPlyModel = false } = defineProps<{
isSplatModel?: boolean
isPlyModel?: boolean
hasSkeleton?: boolean
}>()
const sceneConfig = defineModel<SceneConfig>('sceneConfig')

View File

@@ -1,7 +1,7 @@
<template>
<div
ref="container"
class="relative h-full w-full min-h-[200px]"
class="relative h-full w-full"
data-capture-wheel="true"
@pointerdown.stop
@pointermove.stop

View File

@@ -15,16 +15,6 @@
@dragleave.stop="handleDragLeave"
@drop.prevent.stop="handleDrop"
/>
<AnimationControls
v-if="viewer.animations.value && viewer.animations.value.length > 0"
v-model:animations="viewer.animations.value"
v-model:playing="viewer.playing.value"
v-model:selected-speed="viewer.selectedSpeed.value"
v-model:selected-animation="viewer.selectedAnimation.value"
v-model:animation-progress="viewer.animationProgress.value"
v-model:animation-duration="viewer.animationDuration.value"
@seek="viewer.handleSeek"
/>
<div
v-if="isDragging"
class="pointer-events-none absolute inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@@ -95,7 +85,6 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, toRaw } from 'vue'
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
import CameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
import ExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
import LightControls from '@/components/load3d/controls/viewer/ViewerLightControls.vue'

View File

@@ -1,64 +1,42 @@
<template>
<div
v-if="animations && animations.length > 0"
class="pointer-events-auto absolute top-0 left-0 z-10 flex w-full flex-col items-center gap-2 pt-2"
class="pointer-events-auto absolute top-0 left-0 z-10 flex w-full items-center justify-center gap-2 pt-2"
>
<div class="flex items-center justify-center gap-2">
<Button
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('g.playPause')"
@click="togglePlay"
>
<i
:class="[
'pi',
playing ? 'pi-pause' : 'pi-play',
'text-lg text-white'
]"
/>
</Button>
<Select
v-model="selectedSpeed"
:options="speedOptions"
option-label="name"
option-value="value"
class="w-24"
<Button
size="icon"
variant="textonly"
class="rounded-full"
:aria-label="$t('g.playPause')"
@click="togglePlay"
>
<i
:class="['pi', playing ? 'pi-pause' : 'pi-play', 'text-lg text-white']"
/>
</Button>
<Select
v-model="selectedAnimation"
:options="animations"
option-label="name"
option-value="index"
class="w-32"
/>
</div>
<Select
v-model="selectedSpeed"
:options="speedOptions"
option-label="name"
option-value="value"
class="w-24"
/>
<div class="flex w-full max-w-xs items-center gap-2 px-4">
<Slider
:model-value="[animationProgress]"
:min="0"
:max="100"
:step="0.1"
class="flex-1"
@update:model-value="handleSliderChange"
/>
<span class="min-w-16 text-xs text-white">
{{ formatTime(currentTime) }} / {{ formatTime(animationDuration) }}
</span>
</div>
<Select
v-model="selectedAnimation"
:options="animations"
option-label="name"
option-value="index"
class="w-32"
/>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import Slider from '@/components/ui/slider/Slider.vue'
type Animation = { name: string; index: number }
@@ -66,16 +44,6 @@ const animations = defineModel<Animation[]>('animations')
const playing = defineModel<boolean>('playing')
const selectedSpeed = defineModel<number>('selectedSpeed')
const selectedAnimation = defineModel<number>('selectedAnimation')
const animationProgress = defineModel<number>('animationProgress', {
default: 0
})
const animationDuration = defineModel<number>('animationDuration', {
default: 0
})
const emit = defineEmits<{
seek: [progress: number]
}>()
const speedOptions = [
{ name: '0.1x', value: 0.1 },
@@ -85,25 +53,7 @@ const speedOptions = [
{ name: '2x', value: 2 }
]
const currentTime = computed(() => {
if (!animationDuration.value) return 0
return (animationProgress.value / 100) * animationDuration.value
})
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60)
const secs = (seconds % 60).toFixed(1)
return mins > 0 ? `${mins}:${secs.padStart(4, '0')}` : `${secs}s`
}
function togglePlay() {
const togglePlay = () => {
playing.value = !playing.value
}
function handleSliderChange(value: number[] | undefined) {
if (!value) return
const progress = value[0]
animationProgress.value = progress
emit('seek', progress)
}
</script>

View File

@@ -70,22 +70,6 @@
</div>
</div>
</div>
<div v-if="hasSkeleton">
<Button
v-tooltip.right="{
value: t('load3d.showSkeleton'),
showDelay: 300
}"
size="icon"
variant="textonly"
:class="cn('rounded-full', showSkeleton && 'bg-blue-500')"
:aria-label="t('load3d.showSkeleton')"
@click="showSkeleton = !showSkeleton"
>
<i class="pi pi-sitemap text-lg text-white" />
</Button>
</div>
</div>
</template>
@@ -100,19 +84,13 @@ import type {
import { t } from '@/i18n'
import { cn } from '@/utils/tailwindUtil'
const {
hideMaterialMode = false,
isPlyModel = false,
hasSkeleton = false
} = defineProps<{
const { hideMaterialMode = false, isPlyModel = false } = defineProps<{
hideMaterialMode?: boolean
isPlyModel?: boolean
hasSkeleton?: boolean
}>()
const materialMode = defineModel<MaterialMode>('materialMode')
const upDirection = defineModel<UpDirection>('upDirection')
const showSkeleton = defineModel<boolean>('showSkeleton')
const showUpDirection = ref(false)
const showMaterialMode = ref(false)

View File

@@ -1,6 +1,8 @@
<template>
<div class="flex flex-col gap-3 pb-3">
<h3 class="text-center text-[15px] font-sans text-descrip-text mt-2.5">
<h3
class="text-center text-[15px] font-sans text-[var(--descrip-text)] mt-2.5"
>
{{ t('maskEditor.brushSettings') }}
</h3>
@@ -8,211 +10,120 @@
{{ t('maskEditor.resetToDefault') }}
</button>
<!-- Brush Shape -->
<div class="flex flex-col gap-3 pb-3">
<span class="text-left text-xs font-sans text-descrip-text">
{{ t('maskEditor.brushShape') }}
</span>
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
t('maskEditor.brushShape')
}}</span>
<div
class="flex flex-row gap-2.5 items-center h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
class="flex flex-row gap-2.5 items-center min-h-6 relative h-[50px] w-full rounded-[10px] bg-secondary-background-hover"
>
<div
class="maskEditor_sidePanelBrushShapeCircle hover:bg-comfy-menu-bg"
:class="
cn(
class="maskEditor_sidePanelBrushShapeCircle bg-transparent hover:bg-comfy-menu-bg"
:class="{ active: store.brushSettings.type === BrushShape.Arc }"
:style="{
background:
store.brushSettings.type === BrushShape.Arc
? 'bg-[var(--p-button-text-primary-color)] active'
: 'bg-transparent'
)
"
? 'var(--p-button-text-primary-color)'
: ''
}"
@click="setBrushShape(BrushShape.Arc)"
></div>
<div
class="maskEditor_sidePanelBrushShapeSquare hover:bg-comfy-menu-bg"
:class="
cn(
class="maskEditor_sidePanelBrushShapeSquare bg-transparent hover:bg-comfy-menu-bg"
:class="{ active: store.brushSettings.type === BrushShape.Rect }"
:style="{
background:
store.brushSettings.type === BrushShape.Rect
? 'bg-[var(--p-button-text-primary-color)] active'
: 'bg-transparent'
)
"
? 'var(--p-button-text-primary-color)'
: ''
}"
@click="setBrushShape(BrushShape.Rect)"
></div>
</div>
</div>
<!-- Color -->
<div class="flex flex-col gap-3 pb-3">
<span class="text-left text-xs font-sans text-descrip-text">
{{ t('maskEditor.colorSelector') }}
</span>
<input
ref="colorInputRef"
v-model="store.rgbColor"
type="color"
class="h-10 rounded-md cursor-pointer"
/>
<span class="text-left text-xs font-sans text-[var(--descrip-text)]">{{
t('maskEditor.colorSelector')
}}</span>
<input type="color" :value="store.rgbColor" @input="onColorChange" />
</div>
<!-- Thickness -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-left text-xs font-sans text-descrip-text">
{{ t('maskEditor.thickness') }}
</span>
<input
v-model.number="brushSize"
type="number"
class="w-16 px-2 py-1 text-sm text-center border rounded-md bg-comfy-menu-bg border-p-form-field-border-color text-input-text"
:min="1"
:max="250"
:step="1"
/>
</div>
<SliderControl
v-model="brushSize"
class="flex-1"
label=""
:min="1"
:max="250"
:step="1"
/>
</div>
<SliderControl
:label="t('maskEditor.thickness')"
:min="1"
:max="500"
:step="1"
:model-value="store.brushSettings.size"
@update:model-value="onThicknessChange"
/>
<!-- Opacity -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-left text-xs font-sans text-descrip-text">
{{ t('maskEditor.opacity') }}
</span>
<input
v-model.number="brushOpacity"
type="number"
class="w-16 px-2 py-1 text-sm text-center border rounded-md bg-comfy-menu-bg border-p-form-field-border-color text-input-text"
:min="0"
:max="1"
:step="0.01"
/>
</div>
<SliderControl
v-model="brushOpacity"
class="flex-1"
label=""
:min="0"
:max="1"
:step="0.01"
/>
</div>
<SliderControl
:label="t('maskEditor.opacity')"
:min="0"
:max="1"
:step="0.01"
:model-value="store.brushSettings.opacity"
@update:model-value="onOpacityChange"
/>
<!-- Hardness -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-left text-xs font-sans text-descrip-text">
{{ t('maskEditor.hardness') }}
</span>
<input
v-model.number="brushHardness"
type="number"
class="w-16 px-2 py-1 text-sm text-center border rounded-md bg-comfy-menu-bg border-p-form-field-border-color text-input-text"
:min="0"
:max="1"
:step="0.01"
/>
</div>
<SliderControl
v-model="brushHardness"
class="flex-1"
label=""
:min="0"
:max="1"
:step="0.01"
/>
</div>
<SliderControl
:label="t('maskEditor.hardness')"
:min="0"
:max="1"
:step="0.01"
:model-value="store.brushSettings.hardness"
@update:model-value="onHardnessChange"
/>
<!-- Step Size -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-left text-xs font-sans text-descrip-text">
{{ t('maskEditor.stepSize') }}
</span>
<input
v-model.number="brushStepSize"
type="number"
class="w-16 px-2 py-1 text-sm text-center border rounded-md bg-comfy-menu-bg border-p-form-field-border-color text-input-text"
:min="1"
:max="100"
:step="1"
/>
</div>
<SliderControl
v-model="brushStepSize"
class="flex-1"
label=""
:min="1"
:max="100"
:step="1"
/>
</div>
<SliderControl
:label="$t('maskEditor.stepSize')"
:min="1"
:max="100"
:step="1"
:model-value="store.brushSettings.stepSize"
@update:model-value="onStepSizeChange"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { BrushShape } from '@/extensions/core/maskeditor/types'
import { t } from '@/i18n'
import { useMaskEditorStore } from '@/stores/maskEditorStore'
import { cn } from '@/utils/tailwindUtil'
import SliderControl from './controls/SliderControl.vue'
const store = useMaskEditorStore()
const colorInputRef = ref<HTMLInputElement>()
const textButtonClass =
'h-7.5 w-32 rounded-[10px] border border-p-form-field-border-color text-input-text font-sans transition-colors duration-100 bg-comfy-menu-bg hover:bg-secondary-background-hover'
'h-7.5 w-32 rounded-[10px] border border-[var(--p-form-field-border-color)] text-[var(--input-text)] font-sans pointer-events-auto transition-colors duration-100 bg-[var(--comfy-menu-bg)] hover:bg-secondary-background-hover'
/* Computed properties that use store setters for validation */
const brushSize = computed({
get: () => store.brushSettings.size,
set: (value: number) => store.setBrushSize(value)
})
const brushOpacity = computed({
get: () => store.brushSettings.opacity,
set: (value: number) => store.setBrushOpacity(value)
})
const brushHardness = computed({
get: () => store.brushSettings.hardness,
set: (value: number) => store.setBrushHardness(value)
})
const brushStepSize = computed({
get: () => store.brushSettings.stepSize,
set: (value: number) => store.setBrushStepSize(value)
})
/* Brush shape */
const setBrushShape = (shape: BrushShape) => {
store.brushSettings.type = shape
}
/* Reset */
const onColorChange = (event: Event) => {
store.rgbColor = (event.target as HTMLInputElement).value
}
const onThicknessChange = (value: number) => {
store.setBrushSize(value)
}
const onOpacityChange = (value: number) => {
store.setBrushOpacity(value)
}
const onHardnessChange = (value: number) => {
store.setBrushHardness(value)
}
const onStepSizeChange = (value: number) => {
store.setBrushStepSize(value)
}
const resetToDefault = () => {
store.resetBrushToDefault()
}
onMounted(() => {
if (colorInputRef.value) {
store.colorInput = colorInputRef.value
}
})
onBeforeUnmount(() => {
store.colorInput = null
})
</script>

View File

@@ -202,7 +202,6 @@ onBeforeUnmount(() => {
}
store.canvasHistory.clearStates()
store.resetState()
dataStore.reset()
})

View File

@@ -0,0 +1,155 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { JobAction } from '@/queue/composables/useJobActions'
import type { JobListItem } from '@/queue/composables/useJobList'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { setMockJobActions } from '@/storybook/mocks/useJobActions'
import { setMockJobItems } from '@/storybook/mocks/useJobList'
import { iconForJobState } from '@/queue/utils/queueDisplay'
import AssetsSidebarListView from './AssetsSidebarListView.vue'
type StoryArgs = {
assets: AssetItem[]
jobs: JobListItem[]
selectedAssetIds?: string[]
actionsByJobId?: Record<string, JobAction[]>
}
function baseDecorator() {
return {
template: `
<div class="bg-base-background p-6">
<story />
</div>
`
}
}
const meta: Meta<StoryArgs> = {
title: 'Components/Sidebar/AssetsSidebarListView',
component: AssetsSidebarListView,
parameters: {
layout: 'centered'
},
decorators: [baseDecorator]
}
export default meta
type Story = StoryObj<typeof meta>
const baseTimestamp = '2024-01-15T10:00:00Z'
const sampleJobs: JobListItem[] = [
{
id: 'job-pending-1',
title: 'In queue',
meta: '8:59:30pm',
state: 'pending',
iconName: iconForJobState('pending'),
showClear: true
},
{
id: 'job-init-1',
title: 'Initializing...',
meta: '8:59:35pm',
state: 'initialization',
iconName: iconForJobState('initialization'),
showClear: true
},
{
id: 'job-running-1',
title: 'Total: 30%',
meta: 'KSampler: 70%',
state: 'running',
iconName: iconForJobState('running'),
showClear: true,
progressTotalPercent: 30,
progressCurrentPercent: 70
}
]
const sampleAssets: AssetItem[] = [
{
id: 'asset-image-1',
name: 'image-032.png',
created_at: baseTimestamp,
preview_url: '/assets/images/comfy-logo-single.svg',
size: 1887437,
tags: [],
user_metadata: {
promptId: 'job-running-1',
nodeId: 12,
executionTimeInSeconds: 1.84
}
},
{
id: 'asset-video-1',
name: 'clip-01.mp4',
created_at: baseTimestamp,
preview_url: '/assets/images/default-template.png',
size: 8394820,
tags: [],
user_metadata: {
duration: 132000
}
},
{
id: 'asset-audio-1',
name: 'soundtrack-01.mp3',
created_at: baseTimestamp,
size: 5242880,
tags: [],
user_metadata: {
duration: 200000
}
},
{
id: 'asset-3d-1',
name: 'scene-01.glb',
created_at: baseTimestamp,
size: 134217728,
tags: []
}
]
const cancelAction: JobAction = {
key: 'cancel',
icon: 'icon-[lucide--x]',
label: 'Cancel',
variant: 'destructive'
}
export const RunningAndGenerated: Story = {
args: {
assets: sampleAssets,
jobs: sampleJobs,
actionsByJobId: {
'job-pending-1': [cancelAction],
'job-init-1': [cancelAction],
'job-running-1': [cancelAction]
}
},
render: renderAssetsSidebarListView
}
function renderAssetsSidebarListView(args: StoryArgs) {
return {
components: { AssetsSidebarListView },
setup() {
setMockJobItems(args.jobs)
setMockJobActions(args.actionsByJobId ?? {})
const selectedIds = new Set(args.selectedAssetIds ?? [])
function isSelected(assetId: string) {
return selectedIds.has(assetId)
}
return { args, isSelected }
},
template: `
<div class="h-[520px] w-[320px] overflow-hidden rounded-lg border border-panel-border">
<AssetsSidebarListView :assets="args.assets" :is-selected="isSelected" />
</div>
`
}
}

View File

@@ -0,0 +1,241 @@
<template>
<div class="flex h-full flex-col">
<div v-if="activeJobItems.length" class="flex flex-col gap-2 px-2">
<AssetsListCard
v-for="job in activeJobItems"
:key="job.id"
:class="getJobCardClass()"
:preview-url="job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@mouseenter="onJobEnter(job.id)"
@mouseleave="onJobLeave(job.id)"
@click.stop
>
<template
v-if="hoveredJobId === job.id && getJobActions(job).length"
#actions
>
<Button
v-for="action in getJobActions(job)"
:key="action.key"
:variant="action.variant"
size="icon"
:aria-label="action.label"
@click.stop="handleJobAction(action, job)"
>
<i :class="action.icon" class="size-4" />
</Button>
</template>
</AssetsListCard>
</div>
<div
v-if="assets.length"
:class="cn('px-2', activeJobItems.length && 'mt-2')"
>
<div
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
{{ t('sideToolbar.generatedAssetsHeader') }}
</div>
</div>
<VirtualGrid
class="flex-1"
:items="assetItems"
:grid-style="listGridStyle"
@approach-end="emit('approach-end')"
>
<template #item="{ item }">
<AssetsListCard
role="button"
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: item.asset.name,
type: getMediaTypeFromFilename(item.asset.name)
})
"
:class="getAssetCardClass(isSelected(item.asset.id))"
:preview-url="item.asset.preview_url"
:preview-alt="item.asset.name"
:icon-name="getAssetIconName(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
@mouseenter="onAssetEnter(item.asset.id)"
@mouseleave="onAssetLeave(item.asset.id)"
@contextmenu.prevent="handleAssetMenuClick($event, item.asset)"
@click.stop="emit('select-asset', item.asset)"
>
<template v-if="hoveredAssetId === item.asset.id" #actions>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.moreOptions')"
@click.stop="handleAssetMenuClick($event, item.asset)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListCard>
</template>
</VirtualGrid>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue'
import type { JobAction } from '@/queue/composables/useJobActions'
import { useJobActions } from '@/queue/composables/useJobActions'
import type { JobListItem } from '@/queue/composables/useJobList'
import { useJobList } from '@/queue/composables/useJobList'
import AssetsListCard from '@/platform/assets/components/AssetsListCard.vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { JobState } from '@/queue/types/queue'
import {
formatDuration,
formatSize,
getMediaTypeFromFilename,
truncateFilename
} from '@/utils/formatUtil'
import { iconForJobState } from '@/queue/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
const { assets, isSelected } = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
}>()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
}>()
const { t } = useI18n()
const { jobItems } = useJobList()
const { getJobActions, runJobAction } = useJobActions()
const hoveredJobId = ref<string | null>(null)
const hoveredAssetId = ref<string | null>(null)
type AssetListItem = { key: string; asset: AssetItem }
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state))
)
const assetItems = computed<AssetListItem[]>(() =>
assets.map((asset) => ({
key: `asset-${asset.id}`,
asset
}))
)
const listGridStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)',
padding: '0 0.5rem',
gap: '0.5rem'
}
const listCardBaseClass =
'w-full text-text-primary transition-colors hover:bg-secondary-background-hover'
function isActiveJobState(state: JobState): boolean {
return (
state === 'pending' || state === 'initialization' || state === 'running'
)
}
function getAssetPrimaryText(asset: AssetItem): string {
return truncateFilename(asset.name)
}
function getAssetSecondaryText(asset: AssetItem): string {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (typeof metadata?.executionTimeInSeconds === 'number') {
return `${metadata.executionTimeInSeconds.toFixed(2)}s`
}
const duration = asset.user_metadata?.duration
if (typeof duration === 'number') {
return formatDuration(duration)
}
if (typeof asset.size === 'number') {
return formatSize(asset.size)
}
return ''
}
function getAssetIconName(asset: AssetItem): string {
const mediaType = getMediaTypeFromFilename(asset.name)
if (mediaType === 'video') return 'icon-[lucide--video]'
if (mediaType === 'audio') return 'icon-[lucide--music]'
if (mediaType === '3D') return 'icon-[lucide--box]'
return 'icon-[lucide--image]'
}
function getAssetCardClass(selected: boolean): string {
return cn(
listCardBaseClass,
'cursor-pointer',
selected &&
'bg-secondary-background-hover ring-1 ring-inset ring-modal-card-border-highlighted'
)
}
function getJobCardClass(): string {
return cn(listCardBaseClass, 'cursor-default')
}
function onJobEnter(jobId: string) {
hoveredJobId.value = jobId
}
function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
}
function onAssetEnter(assetId: string) {
hoveredAssetId.value = assetId
}
function onAssetLeave(assetId: string) {
if (hoveredAssetId.value === assetId) {
hoveredAssetId.value = null
}
}
function getJobIconClass(job: JobListItem): string | undefined {
const classes = []
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
classes.push('animate-spin')
}
return classes.length ? classes.join(' ') : undefined
}
function handleJobAction(action: JobAction, job: JobListItem) {
void runJobAction(action, job)
}
function handleAssetMenuClick(event: MouseEvent, asset: AssetItem) {
event.stopPropagation()
emit('context-menu', event, asset)
}
</script>

View File

@@ -79,10 +79,10 @@
<Divider v-else type="dashed" class="my-2" />
</template>
<template #body>
<div v-if="loading && !displayAssets.length">
<div v-if="showLoadingState">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
</div>
<div v-else-if="!loading && !displayAssets.length">
<div v-else-if="showEmptyState">
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="
@@ -96,7 +96,16 @@
/>
</div>
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<AssetsSidebarListView
v-if="isListView"
:assets="displayAssets"
:is-selected="isSelected"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
<VirtualGrid
v-else
:items="mediaAssetsWithKey"
:grid-style="{
display: 'grid',
@@ -112,13 +121,10 @@
:selected="isSelected(item.id)"
:show-output-count="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)"
:show-delete-button="shouldShowDeleteButton"
:open-context-menu-id="openContextMenuId"
@click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
@asset-deleted="refreshAssets"
@context-menu-opened="openContextMenuId = item.id"
@context-menu="handleAssetContextMenu($event, item)"
/>
</template>
</VirtualGrid>
@@ -185,41 +191,54 @@
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
<MediaAssetContextMenu
v-if="contextMenuAsset"
ref="contextMenuRef"
:asset="contextMenuAsset"
:asset-type="contextMenuAssetType"
:file-kind="contextMenuFileKind"
:show-delete-button="shouldShowDeleteButton"
@zoom="handleContextMenuZoom"
@asset-deleted="refreshAssets"
/>
</template>
<script setup lang="ts">
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
import { ResultItemImpl, useQueueStore } from '@/queue/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
const { t, n } = useI18n()
const { t } = useI18n()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
const settingStore = useSettingStore()
@@ -232,9 +251,11 @@ const viewMode = ref<'list' | 'grid'>('grid')
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
// Track which asset's context menu is open (for single-instance context menu management)
const openContextMenuId = ref<string | null>(null)
const isListView = computed(
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
)
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
const contextMenuAsset = ref<AssetItem | null>(null)
// Determine if delete button should be shown
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
@@ -243,6 +264,14 @@ const shouldShowDeleteButton = computed(() => {
return true
})
const contextMenuAssetType = computed(() =>
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
)
const contextMenuFileKind = computed<MediaKind>(() =>
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
)
const getOutputCount = (item: AssetItem): number => {
const count = item.user_metadata?.outputCount
return typeof count === 'number' && count > 0 ? count : 1
@@ -264,14 +293,10 @@ const queuedCount = computed(() => queueStore.pendingTasks.length)
const activeJobsCount = computed(
() => queueStore.pendingTasks.length + queueStore.runningTasks.length
)
const activeJobsLabel = computed(() => {
const count = activeJobsCount.value
return t(
'sideToolbar.queueProgressOverlay.activeJobs',
{ count: n(count) },
count
)
})
const activeJobsLabel = computed(
() =>
`${activeJobsCount.value} ${t('sideToolbar.queueProgressOverlay.activeJobsSuffix')}`
)
const toast = useToast()
@@ -347,6 +372,20 @@ const displayAssets = computed(() => {
return filteredAssets.value
})
const showLoadingState = computed(
() =>
loading.value &&
displayAssets.value.length === 0 &&
(!isListView.value || activeJobsCount.value === 0)
)
const showEmptyState = computed(
() =>
!loading.value &&
displayAssets.value.length === 0 &&
(!isListView.value || activeJobsCount.value === 0)
)
watch(displayAssets, (newAssets) => {
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
const newIndex = newAssets.findIndex(
@@ -418,6 +457,18 @@ const handleAssetSelect = (asset: AssetItem) => {
handleAssetClick(asset, index, displayAssets.value)
}
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
contextMenuAsset.value = asset
void nextTick(() => {
contextMenuRef.value?.show(event)
})
}
function handleContextMenuZoom() {
if (!contextMenuAsset.value) return
handleZoomClick(contextMenuAsset.value)
}
const handleZoomClick = (asset: AssetItem) => {
const mediaType = getMediaTypeFromFilename(asset.name)
@@ -538,6 +589,7 @@ const handleDeleteSelected = async () => {
}
const handleClearQueue = async () => {
if (queuedCount.value === 0) return
await commandStore.execute('Comfy.ClearPendingTasks')
}

View File

@@ -8,7 +8,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ResultItemImpl } from '@/stores/queueStore'
import type { ResultItemImpl } from '@/queue/stores/queueStore'
const { result } = defineProps<{
result: ResultItemImpl

View File

@@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick } from 'vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
import type { ResultItemImpl } from '@/queue/stores/queueStore'
import ResultGallery from './ResultGallery.vue'

View File

@@ -45,7 +45,7 @@ import Galleria from 'primevue/galleria'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
import type { ResultItemImpl } from '@/queue/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultVideo from './ResultVideo.vue'

View File

@@ -10,7 +10,7 @@ import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useExtensionStore } from '@/stores/extensionStore'
import type { ResultItemImpl } from '@/stores/queueStore'
import type { ResultItemImpl } from '@/queue/stores/queueStore'
const props = defineProps<{
result: ResultItemImpl

View File

@@ -34,7 +34,6 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
export interface WidgetSlotMetadata {
index: number
@@ -48,7 +47,6 @@ export interface SafeWidgetData {
borderStyle?: string
callback?: ((value: unknown) => void) | undefined
controlWidget?: SafeControlWidget
hasLayoutSize?: boolean
isDOMWidget?: boolean
label?: string
nodeType?: string
@@ -173,12 +171,7 @@ export function safeWidgetMapper(
const callback = (v: unknown) => {
const value = normalizeWidgetValue(v)
widget.value = value ?? undefined
// Match litegraph callback signature: (value, canvas, node, pos, event)
// Some extensions (e.g., Impact Pack) expect node as the 3rd parameter
widget.callback?.(value, app.canvas, node)
// Trigger redraw for all legacy widgets on this node (e.g., mask preview)
// This ensures widgets that depend on other widget values get updated
node.widgets?.forEach((w) => w.triggerDraw?.())
widget.callback?.(value)
}
return {
@@ -188,7 +181,6 @@ export function safeWidgetMapper(
borderStyle,
callback,
controlWidget: getControlWidget(widget),
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: isDOMWidget(widget),
label: widget.label,
nodeType: getNodeType(node, widget),

View File

@@ -39,11 +39,7 @@ import { useLitegraphService } from '@/services/litegraphService'
import type { ComfyCommand } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useHelpCenterStore } from '@/stores/helpCenterStore'
import {
useQueueSettingsStore,
useQueueStore,
useQueueUIStore
} from '@/stores/queueStore'
import { useQueueSettingsStore, useQueueStore } from '@/queue/stores/queueStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
@@ -435,18 +431,6 @@ export function useCoreCommands(): ComfyCommand[] {
},
active: () => useSettingStore().get('Comfy.Minimap.Visible')
},
{
id: 'Comfy.Queue.ToggleOverlay',
icon: 'pi pi-history',
label: () => t('queue.toggleJobHistory'),
menubarLabel: () => t('queue.jobHistory'),
versionAdded: '1.37.0',
category: 'view-controls' as const,
function: () => {
useQueueUIStore().toggleOverlay()
},
active: () => useQueueUIStore().isOverlayExpanded
},
{
id: 'Comfy.QueuePrompt',
icon: 'pi pi-play',

View File

@@ -11,12 +11,10 @@ export enum ServerFeatureFlag {
MAX_UPLOAD_SIZE = 'max_upload_size',
MANAGER_SUPPORTS_V4 = 'extension.manager.supports_v4',
MODEL_UPLOAD_BUTTON_ENABLED = 'model_upload_button_enabled',
ASSET_DELETION_ENABLED = 'asset_deletion_enabled',
ASSET_RENAME_ENABLED = 'asset_rename_enabled',
ASSET_UPDATE_OPTIONS_ENABLED = 'asset_update_options_enabled',
PRIVATE_MODELS_ENABLED = 'private_models_enabled',
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled',
ASYNC_MODEL_UPLOAD_ENABLED = 'async_model_upload_enabled'
HUGGINGFACE_MODEL_IMPORT_ENABLED = 'huggingface_model_import_enabled'
}
/**
@@ -43,16 +41,14 @@ export function useFeatureFlags() {
)
)
},
get assetDeletionEnabled() {
get assetUpdateOptionsEnabled() {
// Check remote config first (from /api/features), fall back to websocket feature flags
return (
remoteConfig.value.asset_deletion_enabled ??
api.getServerFeature(ServerFeatureFlag.ASSET_DELETION_ENABLED, false)
)
},
get assetRenameEnabled() {
return (
remoteConfig.value.asset_rename_enabled ??
api.getServerFeature(ServerFeatureFlag.ASSET_RENAME_ENABLED, false)
remoteConfig.value.asset_update_options_enabled ??
api.getServerFeature(
ServerFeatureFlag.ASSET_UPDATE_OPTIONS_ENABLED,
false
)
)
},
get privateModelsEnabled() {
@@ -69,6 +65,7 @@ export function useFeatureFlags() {
)
},
get huggingfaceModelImportEnabled() {
// Check remote config first (from /api/features), fall back to websocket feature flags
return (
remoteConfig.value.huggingface_model_import_enabled ??
api.getServerFeature(
@@ -76,15 +73,6 @@ export function useFeatureFlags() {
false
)
)
},
get asyncModelUploadEnabled() {
return (
remoteConfig.value.async_model_upload_enabled ??
api.getServerFeature(
ServerFeatureFlag.ASYNC_MODEL_UPLOAD_ENABLED,
false
)
)
}
})

View File

@@ -40,12 +40,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const modelConfig = ref<ModelConfig>({
upDirection: 'original',
materialMode: 'original',
showSkeleton: false
materialMode: 'original'
})
const hasSkeleton = ref(false)
const cameraConfig = ref<CameraConfig>({
cameraType: 'perspective',
fov: 75
@@ -63,8 +60,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const playing = ref(false)
const selectedSpeed = ref(1)
const selectedAnimation = ref(0)
const animationProgress = ref(0)
const animationDuration = ref(0)
const loading = ref(false)
const loadingMessage = ref('')
const isPreview = ref(false)
@@ -276,7 +271,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
nodeRef.value.properties['Model Config'] = newValue
load3d.setUpDirection(newValue.upDirection)
load3d.setMaterialMode(newValue.materialMode)
load3d.setShowSkeleton(newValue.showSkeleton)
}
},
{ deep: true }
@@ -363,13 +357,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
}
const handleSeek = (progress: number) => {
if (load3d && animationDuration.value > 0) {
const time = (progress / 100) * animationDuration.value
load3d.setAnimationTime(time)
}
}
const handleBackgroundImageUpdate = async (file: File | null) => {
if (!file) {
sceneConfig.value.backgroundImage = ''
@@ -507,12 +494,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
loading.value = false
isSplatModel.value = load3d?.isSplatModel() ?? false
isPlyModel.value = load3d?.isPlyModel() ?? false
hasSkeleton.value = load3d?.hasSkeleton() ?? false
// Reset skeleton visibility when loading new model
modelConfig.value.showSkeleton = false
},
skeletonVisibilityChange: (value: boolean) => {
modelConfig.value.showSkeleton = value
},
exportLoadingStart: (message: string) => {
loadingMessage.value = message || t('load3d.exportingModel')
@@ -533,14 +514,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
animationListChange: (newValue: AnimationItem[]) => {
animations.value = newValue
},
animationProgressChange: (data: {
progress: number
currentTime: number
duration: number
}) => {
animationProgress.value = data.progress
animationDuration.value = data.duration
},
cameraChanged: (cameraState: CameraState) => {
const rawNode = toRaw(nodeRef.value)
if (rawNode) {
@@ -594,15 +567,12 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isPreview,
isSplatModel,
isPlyModel,
hasSkeleton,
hasRecording,
recordingDuration,
animations,
playing,
selectedSpeed,
selectedAnimation,
animationProgress,
animationDuration,
loading,
loadingMessage,
@@ -615,7 +585,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
handleStopRecording,
handleExportRecording,
handleClearRecording,
handleSeek,
handleBackgroundImageUpdate,
handleExportModel,
handleModelDrop,

View File

@@ -3,7 +3,6 @@ import { ref, toRaw, watch } from 'vue'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import type {
AnimationItem,
BackgroundRenderModeType,
CameraState,
CameraType,
@@ -50,14 +49,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
const isSplatModel = ref(false)
const isPlyModel = ref(false)
// Animation state
const animations = ref<AnimationItem[]>([])
const playing = ref(false)
const selectedSpeed = ref(1)
const selectedAnimation = ref(0)
const animationProgress = ref(0)
const animationDuration = ref(0)
let load3d: Load3d | null = null
let sourceLoad3d: Load3d | null = null
@@ -183,61 +174,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
}
})
// Animation watches
watch(playing, (newValue) => {
if (load3d) {
load3d.toggleAnimation(newValue)
}
})
watch(selectedSpeed, (newValue) => {
if (load3d && newValue) {
load3d.setAnimationSpeed(newValue)
}
})
watch(selectedAnimation, (newValue) => {
if (load3d && newValue !== undefined) {
load3d.updateSelectedAnimation(newValue)
}
})
const handleSeek = (progress: number) => {
if (load3d && animationDuration.value > 0) {
const time = (progress / 100) * animationDuration.value
load3d.setAnimationTime(time)
}
}
const setupAnimationEvents = () => {
if (!load3d) return
load3d.addEventListener(
'animationListChange',
(newValue: AnimationItem[]) => {
animations.value = newValue
}
)
load3d.addEventListener(
'animationProgressChange',
(data: { progress: number; currentTime: number; duration: number }) => {
animationProgress.value = data.progress
animationDuration.value = data.duration
}
)
// Initialize animation list if animations already exist
if (load3d.hasAnimations()) {
const clips = load3d.animationManager.animationClips
animations.value = clips.map((clip, index) => ({
name: clip.name || `Animation ${index + 1}`,
index
}))
animationDuration.value = load3d.getAnimationDuration()
}
}
/**
* Initialize viewer in node mode (with source Load3d)
*/
@@ -334,8 +270,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
upDirection: upDirection.value,
materialMode: materialMode.value
}
setupAnimationEvents()
} catch (error) {
console.error('Error initializing Load3d viewer:', error)
useToastStore().addAlert(
@@ -376,8 +310,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isPlyModel.value = load3d.isPlyModel()
isPreview.value = true
setupAnimationEvents()
} catch (error) {
console.error('Error initializing standalone 3D viewer:', error)
useToastStore().addAlert('Failed to load 3D model')
@@ -595,14 +527,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
isSplatModel,
isPlyModel,
// Animation state
animations,
playing,
selectedSpeed,
selectedAnimation,
animationProgress,
animationDuration,
// Methods
initializeViewer,
initializeStandaloneViewer,
@@ -615,7 +539,6 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
refreshViewport,
handleBackgroundImageUpdate,
handleModelDrop,
handleSeek,
cleanup
}
}

View File

@@ -6,14 +6,12 @@ import type { Ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { useTemplateRankingStore } from '@/stores/templateRankingStore'
import { debounce } from 'es-toolkit/compat'
export function useTemplateFiltering(
templates: Ref<TemplateInfo[]> | TemplateInfo[]
) {
const settingStore = useSettingStore()
const rankingStore = useTemplateRankingStore()
const searchQuery = ref('')
const selectedModels = ref<string[]>(
@@ -27,8 +25,6 @@ export function useTemplateFiltering(
)
const sortBy = ref<
| 'default'
| 'recommended'
| 'popular'
| 'alphabetical'
| 'newest'
| 'vram-low-to-high'
@@ -155,42 +151,10 @@ export function useTemplateFiltering(
return Number.POSITIVE_INFINITY
}
watch(
filteredByRunsOn,
(templates) => {
rankingStore.largestUsageScore = Math.max(
...templates.map((t) => t.usage || 0)
)
},
{ immediate: true }
)
const sortedTemplates = computed(() => {
const templates = [...filteredByRunsOn.value]
switch (sortBy.value) {
case 'recommended':
// Curated: usage × 0.5 + internal × 0.3 + freshness × 0.2
return templates.sort((a, b) => {
const scoreA = rankingStore.computeDefaultScore(
a.date,
a.searchRank,
a.usage
)
const scoreB = rankingStore.computeDefaultScore(
b.date,
b.searchRank,
b.usage
)
return scoreB - scoreA
})
case 'popular':
// User-driven: usage × 0.9 + freshness × 0.1
return templates.sort((a, b) => {
const scoreA = rankingStore.computePopularScore(a.date, a.usage)
const scoreB = rankingStore.computePopularScore(b.date, b.usage)
return scoreB - scoreA
})
case 'alphabetical':
return templates.sort((a, b) => {
const nameA = a.title || a.name || ''
@@ -220,7 +184,7 @@ export function useTemplateFiltering(
return vramA - vramB
})
case 'model-size-low-to-high':
return templates.sort((a, b) => {
return templates.sort((a: any, b: any) => {
const sizeA =
typeof a.size === 'number' ? a.size : Number.POSITIVE_INFINITY
const sizeB =
@@ -230,6 +194,7 @@ export function useTemplateFiltering(
})
case 'default':
default:
// Keep original order (default order)
return templates
}
})
@@ -241,7 +206,7 @@ export function useTemplateFiltering(
selectedModels.value = []
selectedUseCases.value = []
selectedRunsOn.value = []
sortBy.value = 'default'
sortBy.value = 'newest'
}
const removeModelFilter = (model: string) => {

View File

@@ -196,7 +196,8 @@ export class GroupNodeConfig {
primitiveToWidget: {}
nodeInputs: {}
outputVisibility: any[]
nodeDef: (ComfyNodeDef & { [GROUP]: GroupNodeConfig }) | undefined
// @ts-expect-error fixme ts strict error
nodeDef: ComfyNodeDef
// @ts-expect-error fixme ts strict error
inputs: any[]
// @ts-expect-error fixme ts strict error
@@ -230,7 +231,8 @@ export class GroupNodeConfig {
output: [],
output_name: [],
output_is_list: [],
output_node: false, // This is a lie (to satisfy the interface)
// @ts-expect-error Unused, doesn't exist
output_is_hidden: [],
name: source + SEPARATOR + this.name,
display_name: this.name,
category: 'group nodes' + (SEPARATOR + source),
@@ -259,7 +261,6 @@ export class GroupNodeConfig {
}
// @ts-expect-error fixme ts strict error
this.#convertedToProcess = null
if (!this.nodeDef) return
await app.registerNodeDef(`${PREFIX}${SEPARATOR}` + this.name, this.nodeDef)
useNodeDefStore().addNodeDef(this.nodeDef)
}

View File

@@ -125,13 +125,6 @@ export class AnimationManager implements AnimationManagerInterface {
}
this.animationActions = [action]
// Emit initial progress to set duration
this.eventManager.emitEvent('animationProgressChange', {
progress: 0,
currentTime: 0,
duration: clip.duration
})
}
toggleAnimation(play?: boolean): void {
@@ -157,58 +150,8 @@ export class AnimationManager implements AnimationManagerInterface {
update(delta: number): void {
if (this.currentAnimation && this.isAnimationPlaying) {
this.currentAnimation.update(delta)
if (this.animationActions.length > 0) {
const action = this.animationActions[0]
const clip = action.getClip()
const progress = (action.time / clip.duration) * 100
this.eventManager.emitEvent('animationProgressChange', {
progress,
currentTime: action.time,
duration: clip.duration
})
}
}
}
getAnimationTime(): number {
if (this.animationActions.length === 0) return 0
return this.animationActions[0].time
}
getAnimationDuration(): number {
if (this.animationActions.length === 0) return 0
return this.animationActions[0].getClip().duration
}
setAnimationTime(time: number): void {
if (this.animationActions.length === 0) return
const duration = this.getAnimationDuration()
const clampedTime = Math.max(0, Math.min(time, duration))
// Temporarily unpause to allow time update, then restore
const wasPaused = this.animationActions.map((action) => action.paused)
this.animationActions.forEach((action) => {
action.paused = false
action.time = clampedTime
})
if (this.currentAnimation) {
this.currentAnimation.setTime(clampedTime)
this.currentAnimation.update(0)
}
// Restore paused state
this.animationActions.forEach((action, i) => {
action.paused = wasPaused[i]
})
this.eventManager.emitEvent('animationProgressChange', {
progress: (clampedTime / duration) * 100,
currentTime: clampedTime,
duration
})
}
reset(): void {}
}

View File

@@ -13,6 +13,8 @@ export class CameraManager implements CameraManagerInterface {
orthographicCamera: THREE.OrthographicCamera
activeCamera: THREE.Camera
// @ts-expect-error unused variable
private renderer: THREE.WebGLRenderer
private eventManager: EventManagerInterface
private controls: OrbitControls | null = null
@@ -40,9 +42,10 @@ export class CameraManager implements CameraManagerInterface {
}
constructor(
_renderer: THREE.WebGLRenderer,
renderer: THREE.WebGLRenderer,
eventManager: EventManagerInterface
) {
this.renderer = renderer
this.eventManager = eventManager
this.perspectiveCamera = new THREE.PerspectiveCamera(

View File

@@ -156,9 +156,8 @@ class Load3DConfiguration {
return {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false
}
materialMode: 'original'
} as ModelConfig
}
private applySceneConfig(config: SceneConfig, bgImagePath?: string) {

View File

@@ -392,8 +392,7 @@ class Load3d {
this.STATUS_MOUSE_ON_SCENE ||
this.STATUS_MOUSE_ON_VIEWER ||
this.isRecording() ||
!this.INITIAL_RENDER_DONE ||
this.animationManager.isAnimationPlaying
!this.INITIAL_RENDER_DONE
)
}
@@ -727,32 +726,6 @@ class Load3d {
return this.animationManager.animationClips.length > 0
}
public hasSkeleton(): boolean {
return this.modelManager.hasSkeleton()
}
public setShowSkeleton(show: boolean): void {
this.modelManager.setShowSkeleton(show)
this.forceRender()
}
public getShowSkeleton(): boolean {
return this.modelManager.showSkeleton
}
public getAnimationTime(): number {
return this.animationManager.getAnimationTime()
}
public getAnimationDuration(): number {
return this.animationManager.getAnimationDuration()
}
public setAnimationTime(time: number): void {
this.animationManager.setAnimationTime(time)
this.forceRender()
}
public remove(): void {
if (this.contextMenuAbortController) {
this.contextMenuAbortController.abort()

View File

@@ -34,26 +34,9 @@ class Load3dUtils {
return await resp.json()
}
static readonly MAX_UPLOAD_SIZE_MB = 100
static async uploadFile(file: File, subfolder: string) {
let uploadPath
const fileSizeMB = file.size / 1024 / 1024
if (fileSizeMB > this.MAX_UPLOAD_SIZE_MB) {
const message = t('toastMessages.fileTooLarge', {
size: fileSizeMB.toFixed(1),
maxSize: this.MAX_UPLOAD_SIZE_MB
})
console.warn(
'[Load3D] uploadFile: file too large',
fileSizeMB.toFixed(2),
'MB'
)
useToastStore().addAlert(message)
return undefined
}
try {
const body = new FormData()
body.append('image', file)
@@ -78,7 +61,7 @@ class Load3dUtils {
useToastStore().addAlert(resp.status + ' - ' + resp.statusText)
}
} catch (error) {
console.error('[Load3D] uploadFile: exception', error)
console.error('Upload error:', error)
useToastStore().addAlert(
error instanceof Error
? error.message

View File

@@ -3,10 +3,9 @@ import * as THREE from 'three'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { PLYLoader } from 'three/examples/jsm/loaders/PLYLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { MtlObjBridge, OBJLoader2Parallel } from 'wwobjloader2'
import OBJLoader2WorkerUrl from 'wwobjloader2/worker?url'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -23,7 +22,7 @@ import { FastPLYLoader } from './loader/FastPLYLoader'
export class LoaderManager implements LoaderManagerInterface {
gltfLoader: GLTFLoader
objLoader: OBJLoader2Parallel
objLoader: OBJLoader
mtlLoader: MTLLoader
fbxLoader: FBXLoader
stlLoader: STLLoader
@@ -42,12 +41,7 @@ export class LoaderManager implements LoaderManagerInterface {
this.eventManager = eventManager
this.gltfLoader = new GLTFLoader()
this.objLoader = new OBJLoader2Parallel()
// Set worker URL for Vite compatibility
this.objLoader.setWorkerUrl(
true,
new URL(OBJLoader2WorkerUrl, import.meta.url)
)
this.objLoader = new OBJLoader()
this.mtlLoader = new MTLLoader()
this.fbxLoader = new FBXLoader()
this.stlLoader = new STLLoader()
@@ -166,10 +160,6 @@ export class LoaderManager implements LoaderManagerInterface {
fbxModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
this.modelManager.originalMaterials.set(child, child.material)
if (child instanceof THREE.SkinnedMesh) {
child.frustumCulled = false
}
}
})
break
@@ -183,9 +173,7 @@ export class LoaderManager implements LoaderManagerInterface {
const materials = await this.mtlLoader.loadAsync(mtlFileName)
materials.preload()
const materialsFromMtl =
MtlObjBridge.addMaterialsFromMtlLoader(materials)
this.objLoader.setMaterials(materialsFromMtl)
this.objLoader.setMaterials(materials)
} catch (e) {
console.log(
'No MTL file found or error loading it, continuing without materials'
@@ -193,10 +181,8 @@ export class LoaderManager implements LoaderManagerInterface {
}
}
// OBJLoader2Parallel uses Web Worker for parsing (non-blocking)
const objUrl = path + encodeURIComponent(filename)
model = await this.objLoader.loadAsync(objUrl)
this.objLoader.setPath(path)
model = await this.objLoader.loadAsync(filename)
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
this.modelManager.originalMaterials.set(child, child.material)
@@ -207,6 +193,7 @@ export class LoaderManager implements LoaderManagerInterface {
case 'gltf':
case 'glb':
this.gltfLoader.setPath(path)
const gltf = await this.gltfLoader.loadAsync(filename)
this.modelManager.setOriginalModel(gltf)
@@ -216,10 +203,6 @@ export class LoaderManager implements LoaderManagerInterface {
if (child instanceof THREE.Mesh) {
child.geometry.computeVertexNormals()
this.modelManager.originalMaterials.set(child, child.material)
if (child instanceof THREE.SkinnedMesh) {
child.frustumCulled = false
}
}
})
break

View File

@@ -27,11 +27,13 @@ export class SceneManager implements SceneManagerInterface {
private renderer: THREE.WebGLRenderer
private getActiveCamera: () => THREE.Camera
// @ts-expect-error unused variable
private getControls: () => OrbitControls
constructor(
renderer: THREE.WebGLRenderer,
getActiveCamera: () => THREE.Camera,
_getControls: () => OrbitControls,
getControls: () => OrbitControls,
eventManager: EventManagerInterface
) {
this.renderer = renderer
@@ -39,6 +41,7 @@ export class SceneManager implements SceneManagerInterface {
this.scene = new THREE.Scene()
this.getActiveCamera = getActiveCamera
this.getControls = getControls
this.gridHelper = new THREE.GridHelper(20, 20)
this.gridHelper.position.set(0, 0, 0)

View File

@@ -30,8 +30,6 @@ export class SceneModelManager implements ModelManagerInterface {
originalURL: string | null = null
appliedTexture: THREE.Texture | null = null
textureLoader: THREE.TextureLoader
skeletonHelper: THREE.SkeletonHelper | null = null
showSkeleton: boolean = false
private scene: THREE.Scene
private renderer: THREE.WebGLRenderer
@@ -416,69 +414,9 @@ export class SceneModelManager implements ModelManagerInterface {
this.appliedTexture = null
}
if (this.skeletonHelper) {
this.scene.remove(this.skeletonHelper)
this.skeletonHelper.dispose()
this.skeletonHelper = null
}
this.showSkeleton = false
this.originalMaterials = new WeakMap()
}
hasSkeleton(): boolean {
if (!this.currentModel) return false
let found = false
this.currentModel.traverse((child) => {
if (child instanceof THREE.SkinnedMesh && child.skeleton) {
found = true
}
})
return found
}
setShowSkeleton(show: boolean): void {
this.showSkeleton = show
if (show) {
if (!this.skeletonHelper && this.currentModel) {
let rootBone: THREE.Bone | null = null
this.currentModel.traverse((child) => {
if (child instanceof THREE.Bone && !rootBone) {
if (!(child.parent instanceof THREE.Bone)) {
rootBone = child
}
}
})
if (rootBone) {
this.skeletonHelper = new THREE.SkeletonHelper(rootBone)
this.scene.add(this.skeletonHelper)
} else {
let skinnedMesh: THREE.SkinnedMesh | null = null
this.currentModel.traverse((child) => {
if (child instanceof THREE.SkinnedMesh && !skinnedMesh) {
skinnedMesh = child
}
})
if (skinnedMesh) {
this.skeletonHelper = new THREE.SkeletonHelper(skinnedMesh)
this.scene.add(this.skeletonHelper)
}
}
} else if (this.skeletonHelper) {
this.skeletonHelper.visible = true
}
} else {
if (this.skeletonHelper) {
this.skeletonHelper.visible = false
}
}
this.eventManager.emitEvent('skeletonVisibilityChange', show)
}
addModelToScene(model: THREE.Object3D): void {
this.currentModel = model
model.name = 'MainModel'

View File

@@ -14,13 +14,16 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
private getActiveCamera: () => THREE.Camera
private getControls: () => OrbitControls
private eventManager: EventManagerInterface
// @ts-expect-error unused variable
private renderer: THREE.WebGLRenderer
constructor(
_renderer: THREE.WebGLRenderer,
renderer: THREE.WebGLRenderer,
getActiveCamera: () => THREE.Camera,
getControls: () => OrbitControls,
eventManager: EventManagerInterface
) {
this.renderer = renderer
this.getActiveCamera = getActiveCamera
this.getControls = getControls
this.eventManager = eventManager

View File

@@ -4,8 +4,8 @@ import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { type OBJLoader2Parallel } from 'wwobjloader2'
export type MaterialMode =
| 'original'
@@ -34,7 +34,6 @@ export interface SceneConfig {
export interface ModelConfig {
upDirection: UpDirection
materialMode: MaterialMode
showSkeleton: boolean
}
export interface CameraConfig {
@@ -147,9 +146,6 @@ export interface AnimationManagerInterface extends BaseManager {
updateSelectedAnimation(index: number): void
toggleAnimation(play?: boolean): void
update(delta: number): void
getAnimationTime(): number
getAnimationDuration(): number
setAnimationTime(time: number): void
}
export interface ModelManagerInterface {
@@ -180,7 +176,7 @@ export interface ModelManagerInterface {
export interface LoaderManagerInterface {
gltfLoader: GLTFLoader
objLoader: OBJLoader2Parallel
objLoader: OBJLoader
mtlLoader: MTLLoader
fbxLoader: FBXLoader
stlLoader: STLLoader

View File

@@ -37,15 +37,6 @@ function isOpened(): boolean {
return useDialogStore().isDialogOpen('global-mask-editor')
}
const changeBrushSize = async (sizeChanger: (oldSize: number) => number) => {
if (!isOpened()) return
const store = useMaskEditorStore()
const oldBrushSize = store.brushSettings.size
const newBrushSize = sizeChanger(oldBrushSize)
store.setBrushSize(newBrushSize)
}
app.registerExtension({
name: 'Comfy.MaskEditor',
settings: [
@@ -91,24 +82,13 @@ app.registerExtension({
id: 'Comfy.MaskEditor.BrushSize.Increase',
icon: 'pi pi-plus-circle',
label: 'Increase Brush Size in MaskEditor',
function: () => changeBrushSize((old) => _.clamp(old + 2, 1, 250))
function: () => changeBrushSize((old) => _.clamp(old + 4, 1, 100))
},
{
id: 'Comfy.MaskEditor.BrushSize.Decrease',
icon: 'pi pi-minus-circle',
label: 'Decrease Brush Size in MaskEditor',
function: () => changeBrushSize((old) => _.clamp(old - 2, 1, 250))
},
{
id: 'Comfy.MaskEditor.ColorPicker',
icon: 'pi pi-palette',
label: 'Open Color Picker in MaskEditor',
function: () => {
if (!isOpened()) return
const store = useMaskEditorStore()
store.colorInput?.click()
}
function: () => changeBrushSize((old) => _.clamp(old - 4, 1, 100))
}
],
init() {
@@ -121,3 +101,12 @@ app.registerExtension({
)
}
})
const changeBrushSize = async (sizeChanger: (oldSize: number) => number) => {
if (!isOpened()) return
const store = useMaskEditorStore()
const oldBrushSize = store.brushSettings.size
const newBrushSize = sizeChanger(oldBrushSize)
store.setBrushSize(newBrushSize)
}

View File

@@ -969,8 +969,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static onGroupAdd(
_info: unknown,
_entry: unknown,
// @ts-expect-error - unused parameter
info: unknown,
// @ts-expect-error - unused parameter
entry: unknown,
mouse_event: MouseEvent
): void {
const canvas = LGraphCanvas.active_canvas
@@ -1018,8 +1020,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static onNodeAlign(
_value: IContextMenuValue,
_options: IContextMenuOptions,
// @ts-expect-error - unused parameter
value: IContextMenuValue,
// @ts-expect-error - unused parameter
options: IContextMenuOptions,
event: MouseEvent,
prev_menu: ContextMenu<string>,
node: LGraphNode
@@ -1042,8 +1046,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static onGroupAlign(
_value: IContextMenuValue,
_options: IContextMenuOptions,
// @ts-expect-error - unused parameter
value: IContextMenuValue,
// @ts-expect-error - unused parameter
options: IContextMenuOptions,
event: MouseEvent,
prev_menu: ContextMenu<string>
): void {
@@ -1064,8 +1070,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static createDistributeMenu(
_value: IContextMenuValue,
_options: IContextMenuOptions,
// @ts-expect-error - unused parameter
value: IContextMenuValue,
// @ts-expect-error - unused parameter
options: IContextMenuOptions,
event: MouseEvent,
prev_menu: ContextMenu<string>
): void {
@@ -1087,13 +1095,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static onMenuAdd(
_value: unknown,
_options: unknown,
// @ts-expect-error - unused parameter
value: unknown,
// @ts-expect-error - unused parameter
options: unknown,
e: MouseEvent,
prev_menu?: ContextMenu<string>,
callback?: (node: LGraphNode | null) => void
): boolean | undefined {
const canvas = LGraphCanvas.active_canvas
const ref_window = canvas.getCanvasWindow()
const { graph } = canvas
if (!graph) return
@@ -1144,7 +1155,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
value: category_path,
content: name,
has_submenu: true,
callback: function (value, _event, _mouseEvent, contextMenu) {
callback: function (
value,
// @ts-expect-error - unused parameter
event,
// @ts-expect-error - unused parameter
mouseEvent,
contextMenu
) {
inner_onMenuAdded(value.value, contextMenu)
}
})
@@ -1163,7 +1181,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
value: node.type,
content: node.title,
has_submenu: false,
callback: function (value, _event, _mouseEvent, contextMenu) {
callback: function (
value,
// @ts-expect-error - unused parameter
event,
// @ts-expect-error - unused parameter
mouseEvent,
contextMenu
) {
if (!canvas.graph) throw new NullGraphError()
const first_event = contextMenu.getFirstEvent()
@@ -1188,7 +1213,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
entries.push(entry)
}
new LiteGraph.ContextMenu(entries, { event: e, parentMenu: prev_menu })
new LiteGraph.ContextMenu(
entries,
{ event: e, parentMenu: prev_menu },
// @ts-expect-error - extra parameter
ref_window
)
}
}
@@ -1197,7 +1227,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
/** @param _options Parameter is never used */
static showMenuNodeOptionalOutputs(
_v: unknown,
// @ts-expect-error - unused parameter
v: unknown,
/** Unused - immediately overwritten */
_options: INodeOutputSlot[],
e: MouseEvent,
@@ -1281,7 +1312,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
/** @param value Parameter is never used */
static onShowMenuNodeProperties(
value: NodeProperty | undefined,
_options: unknown,
// @ts-expect-error - unused parameter
options: unknown,
e: MouseEvent,
prev_menu: ContextMenu<string>,
node: LGraphNode
@@ -1289,6 +1321,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (!node || !node.properties) return
const canvas = LGraphCanvas.active_canvas
const ref_window = canvas.getCanvasWindow()
const entries: IContextMenuValue<string>[] = []
for (const i in node.properties) {
@@ -1311,20 +1344,23 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
return
}
new LiteGraph.ContextMenu<string>(entries, {
event: e,
callback: inner_clicked,
parentMenu: prev_menu,
node
})
new LiteGraph.ContextMenu<string>(
entries,
{
event: e,
callback: inner_clicked,
parentMenu: prev_menu,
allow_html: true,
node
},
// @ts-expect-error Unused
ref_window
)
function inner_clicked(
this: ContextMenu<string>,
v?: string | IContextMenuValue<string>
) {
if (!node || typeof v === 'string' || !v?.value) return
function inner_clicked(this: ContextMenuDivElement, v: { value: any }) {
if (!node) return
const rect = this.root.getBoundingClientRect()
const rect = this.getBoundingClientRect()
canvas.showEditPropertyValue(node, v.value, {
position: [rect.left, rect.top]
})
@@ -1341,10 +1377,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static onMenuResizeNode(
_value: IContextMenuValue,
_options: IContextMenuOptions,
_e: MouseEvent,
_menu: ContextMenu,
// @ts-expect-error - unused parameter
value: IContextMenuValue,
// @ts-expect-error - unused parameter
options: IContextMenuOptions,
// @ts-expect-error - unused parameter
e: MouseEvent,
// @ts-expect-error - unused parameter
menu: ContextMenu,
node: LGraphNode
): void {
if (!node) return
@@ -1371,9 +1411,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// TODO refactor :: this is used fot title but not for properties!
static onShowPropertyEditor(
item: { property: keyof LGraphNode; type: string },
_options: IContextMenuOptions<string>,
// @ts-expect-error - unused parameter
options: IContextMenuOptions<string>,
e: MouseEvent,
_menu: ContextMenu<string>,
// @ts-expect-error - unused parameter
menu: ContextMenu<string>,
node: LGraphNode
): void {
const property = item.property || 'title'
@@ -1443,10 +1485,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
input.focus()
let dialogCloseTimer: ReturnType<typeof setTimeout> | undefined
let dialogCloseTimer: number
dialog.addEventListener('mouseleave', function () {
if (LiteGraph.dialog_close_on_mouse_leave) {
if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) {
// @ts-expect-error - setTimeout type
dialogCloseTimer = setTimeout(
dialog.close,
LiteGraph.dialog_close_on_mouse_leave_delay
@@ -1501,10 +1544,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static onMenuNodeCollapse(
_value: IContextMenuValue,
_options: IContextMenuOptions,
_e: MouseEvent,
_menu: ContextMenu,
// @ts-expect-error - unused parameter
value: IContextMenuValue,
// @ts-expect-error - unused parameter
options: IContextMenuOptions,
// @ts-expect-error - unused parameter
e: MouseEvent,
// @ts-expect-error - unused parameter
menu: ContextMenu,
node: LGraphNode
): void {
if (!node.graph) throw new NullGraphError()
@@ -1531,10 +1578,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static onMenuToggleAdvanced(
_value: IContextMenuValue,
_options: IContextMenuOptions,
_e: MouseEvent,
_menu: ContextMenu,
// @ts-expect-error - unused parameter
value: IContextMenuValue,
// @ts-expect-error - unused parameter
options: IContextMenuOptions,
// @ts-expect-error - unused parameter
e: MouseEvent,
// @ts-expect-error - unused parameter
menu: ContextMenu,
node: LGraphNode
): void {
if (!node.graph) throw new NullGraphError()
@@ -1559,8 +1610,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static onMenuNodeMode(
_value: IContextMenuValue,
_options: IContextMenuOptions,
// @ts-expect-error - unused parameter
value: IContextMenuValue,
// @ts-expect-error - unused parameter
options: IContextMenuOptions,
e: MouseEvent,
menu: ContextMenu,
node: LGraphNode
@@ -1604,7 +1657,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
/** @param value Parameter is never used */
static onMenuNodeColors(
value: IContextMenuValue<string | null>,
_options: IContextMenuOptions,
// @ts-expect-error - unused parameter
options: IContextMenuOptions,
e: MouseEvent,
menu: ContextMenu<string | null>,
node: LGraphNode
@@ -1665,8 +1719,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
static onMenuNodeShapes(
_value: IContextMenuValue<(typeof LiteGraph.VALID_SHAPES)[number]>,
_options: IContextMenuOptions<(typeof LiteGraph.VALID_SHAPES)[number]>,
// @ts-expect-error - unused parameter
value: IContextMenuValue<(typeof LiteGraph.VALID_SHAPES)[number]>,
// @ts-expect-error - unused parameter
options: IContextMenuOptions<(typeof LiteGraph.VALID_SHAPES)[number]>,
e: MouseEvent,
menu?: ContextMenu<(typeof LiteGraph.VALID_SHAPES)[number]>,
node?: LGraphNode
@@ -3540,16 +3596,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.node_over?.onMouseUp?.(
e,
[x - this.node_over.pos[0], y - this.node_over.pos[1]],
// @ts-expect-error - extra parameter
this
)
this.node_capturing_input?.onMouseUp?.(
e,
[
x - this.node_capturing_input.pos[0],
y - this.node_capturing_input.pos[1]
],
this
)
this.node_capturing_input?.onMouseUp?.(e, [
x - this.node_capturing_input.pos[0],
y - this.node_capturing_input.pos[1]
])
}
} else if (e.button === 1) {
// middle button
@@ -4546,8 +4599,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
/**
* converts a coordinate from graph coordinates to canvas2D coordinates
*/
convertOffsetToCanvas(pos: Point, _out?: Point): Point {
return this.ds.convertOffsetToCanvas(pos)
convertOffsetToCanvas(pos: Point, out: Point): Point {
// @ts-expect-error Unused param
return this.ds.convertOffsetToCanvas(pos, out)
}
/**
@@ -6090,7 +6144,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
/**
* draws every group area in the background
*/
drawGroups(_canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): void {
drawGroups(
// @ts-expect-error - unused parameter
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D
): void {
if (!this.graph) return
const groups = this.graph._groups
@@ -6184,7 +6242,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
function inner_clicked(
this: LGraphCanvas,
v: string,
_options: unknown,
// @ts-expect-error - unused parameter
options: unknown,
e: MouseEvent
) {
if (!graph) throw new NullGraphError()
@@ -6703,12 +6762,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (this.ds.scale > 1) dialog.style.transform = `scale(${this.ds.scale})`
let dialogCloseTimer: ReturnType<typeof setTimeout> | undefined
let dialogCloseTimer: number
let prevent_timeout = 0
LiteGraph.pointerListenerAdd(dialog, 'leave', function () {
if (prevent_timeout) return
if (LiteGraph.dialog_close_on_mouse_leave) {
if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) {
// @ts-expect-error - setTimeout type
dialogCloseTimer = setTimeout(
dialog.close,
LiteGraph.dialog_close_on_mouse_leave_delay
@@ -6897,7 +6957,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (options.hide_on_mouse_leave) {
// FIXME: Remove "any" kludge
let prevent_timeout: any = false
let timeout_close: ReturnType<typeof setTimeout> | null = null
let timeout_close: number | null = null
LiteGraph.pointerListenerAdd(dialog, 'enter', function () {
if (timeout_close) {
clearTimeout(timeout_close)
@@ -6909,6 +6969,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const hideDelay = options.hide_on_mouse_leave
const delay = typeof hideDelay === 'number' ? hideDelay : 500
// @ts-expect-error - setTimeout type
timeout_close = setTimeout(dialog.close, delay)
})
// if filtering, check focus changed to comboboxes and prevent closing
@@ -6944,7 +7005,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
that.search_box = dialog
let first: string | null = null
let timeout: ReturnType<typeof setTimeout> | null = null
let timeout: number | null = null
let selected: ChildNode | null = null
const maybeInput = dialog.querySelector('input')
@@ -6978,6 +7039,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (timeout) {
clearInterval(timeout)
}
// @ts-expect-error - setTimeout type
timeout = setTimeout(refreshHelper, 10)
return
}
@@ -7252,7 +7314,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
options.show_general_after_typefiltered &&
(sIn.value || sOut.value)
) {
const filtered_extra: string[] = []
// FIXME: Undeclared variable again
// @ts-expect-error Variable declared without type annotation
filtered_extra = []
for (const i in LiteGraph.registered_node_types) {
if (
inner_test_filter(i, {
@@ -7260,9 +7324,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
outTypeOverride: sOut && sOut.value ? '*' : false
})
) {
// @ts-expect-error Variable declared without type annotation
filtered_extra.push(i)
}
}
// @ts-expect-error Variable declared without type annotation
for (const extraItem of filtered_extra) {
addResult(extraItem, 'generic_type')
if (
@@ -7279,11 +7345,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
helper.childNodes.length == 0 &&
options.show_general_if_none_on_typefilter
) {
const filtered_extra: string[] = []
// @ts-expect-error Variable declared without type annotation
filtered_extra = []
for (const i in LiteGraph.registered_node_types) {
if (inner_test_filter(i, { skipFilter: true }))
// @ts-expect-error Variable declared without type annotation
filtered_extra.push(i)
}
// @ts-expect-error Variable declared without type annotation
for (const extraItem of filtered_extra) {
addResult(extraItem, 'not_in_filter')
if (
@@ -7578,12 +7647,13 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
let dialogCloseTimer: ReturnType<typeof setTimeout> | undefined
let dialogCloseTimer: number
let prevent_timeout = 0
dialog.addEventListener('mouseleave', function () {
if (prevent_timeout) return
if (!dialog.is_modified && LiteGraph.dialog_close_on_mouse_leave) {
// @ts-expect-error - setTimeout type
dialogCloseTimer = setTimeout(
dialog.close,
LiteGraph.dialog_close_on_mouse_leave_delay
@@ -7617,6 +7687,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
createPanel(title: string, options: ICreatePanelOptions) {
options = options || {}
const ref_window = options.window || window
// TODO: any kludge
const root: any = document.createElement('div')
root.className = 'litegraph dialog'
@@ -7794,12 +7865,16 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
innerChange(propname, v)
return false
}
new LiteGraph.ContextMenu(values, {
event,
className: 'dark',
// @ts-expect-error fixme ts strict error - callback signature mismatch
callback: inner_clicked
})
new LiteGraph.ContextMenu(
values,
{
event,
className: 'dark',
callback: inner_clicked
},
// @ts-expect-error ref_window parameter unused in ContextMenu constructor
ref_window
)
})
}
@@ -8119,10 +8194,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
{
content: 'Properties Panel',
callback: function (
_item: any,
_options: any,
_e: any,
_menu: any,
// @ts-expect-error - unused parameter
item: any,
// @ts-expect-error - unused parameter
options: any,
// @ts-expect-error - unused parameter
e: any,
// @ts-expect-error - unused parameter
menu: any,
node: LGraphNode
) {
LGraphCanvas.active_canvas.showShowNodePanel(node)
@@ -8233,6 +8312,9 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
node: LGraphNode | undefined,
event: CanvasPointerEvent
): void {
const canvas = LGraphCanvas.active_canvas
const ref_window = canvas.getCanvasWindow()
// TODO: Remove type kludge
let menu_info: (IContextMenuValue | string | null)[]
const options: IContextMenuOptions = {
@@ -8346,7 +8428,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// show menu
if (!menu_info) return
new LiteGraph.ContextMenu(menu_info, options)
// @ts-expect-error Remove param ref_window - unused
new LiteGraph.ContextMenu(menu_info, options, ref_window)
const createDialog = (options: IDialogOptions) =>
this.createDialog(

View File

@@ -679,12 +679,7 @@ export class LGraphNode
this: LGraphNode,
entries: (IContextMenuValue<INodeSlotContextItem> | null)[]
): (IContextMenuValue<INodeSlotContextItem> | null)[]
onMouseUp?(
this: LGraphNode,
e: CanvasPointerEvent,
pos: Point,
canvas: LGraphCanvas
): void
onMouseUp?(this: LGraphNode, e: CanvasPointerEvent, pos: Point): void
onMouseEnter?(this: LGraphNode, e: CanvasPointerEvent): void
/** Blocks drag if return value is truthy. @param pos Offset from {@link LGraphNode.pos}. */
onMouseDown?(
@@ -2774,7 +2769,8 @@ export class LGraphNode
!LiteGraph.allow_multi_output_for_events
) {
graph.beforeChange()
this.disconnectOutput(slot)
// @ts-expect-error Unused param
this.disconnectOutput(slot, false, { doProcessChange: false })
}
}

View File

@@ -338,7 +338,6 @@ export interface INodeFlags {
*/
export interface IWidgetLocator {
name: string
type?: string
}
export interface INodeInputSlot extends INodeSlot {

View File

@@ -111,8 +111,6 @@ export interface ExportedSubgraphInstance extends NodeSubgraphSharedProps {
* @see {@link ExportedSubgraph.subgraphs}
*/
type: UUID
/** Custom properties for this subgraph instance */
properties?: Dictionary<NodeProperty | undefined>
}
/**

View File

@@ -200,9 +200,6 @@
"Comfy_MaskEditor_BrushSize_Increase": {
"label": "Increase Brush Size in MaskEditor"
},
"Comfy_MaskEditor_ColorPicker": {
"label": "Open Color Picker in MaskEditor"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Open Mask Editor for Selected Node"
},
@@ -227,9 +224,6 @@
"Comfy_PublishSubgraph": {
"label": "Publish Subgraph"
},
"Comfy_Queue_ToggleOverlay": {
"label": "Toggle Job History"
},
"Comfy_QueuePrompt": {
"label": "Queue Prompt"
},
@@ -333,4 +327,4 @@
"label": "Toggle Workflows Sidebar",
"tooltip": "Workflows"
}
}
}

View File

@@ -181,7 +181,6 @@
"missing": "Missing",
"inProgress": "In progress",
"completed": "Completed",
"downloading": "Downloading",
"interrupted": "Interrupted",
"queued": "Queued",
"running": "Running",
@@ -509,12 +508,10 @@
"title": "Choose your hardware setup",
"recommended": "RECOMMENDED",
"nvidiaSubtitle": "NVIDIA CUDA",
"amdSubtitle": "AMD ROCm™",
"cpuSubtitle": "CPU Mode",
"manualSubtitle": "Manual Setup",
"appleMetalDescription": "Leverages your Mac's GPU for faster speed and a better overall experience",
"nvidiaDescription": "Use your NVIDIA GPU with CUDA acceleration for the best performance.",
"amdDescription": "Use your AMD GPU with ROCm™ acceleration for the best performance.",
"cpuDescription": "Use CPU mode for compatibility when GPU acceleration is not available",
"manualDescription": "Configure ComfyUI manually for advanced setups or unsupported hardware"
},
@@ -690,6 +687,7 @@
"noFilesFound": "No files found",
"noImportedFiles": "No imported files found",
"noGeneratedFiles": "No generated files found",
"generatedAssetsHeader": "Generated assets",
"noFilesFoundMessage": "Upload files or generate content to see them here",
"browseTemplates": "Browse example templates",
"openWorkflow": "Open workflow in local file system",
@@ -738,7 +736,6 @@
"filterCurrentWorkflow": "Current workflow",
"sortJobs": "Sort jobs",
"sortBy": "Sort by",
"activeJobs": "{count} active job | {count} active jobs",
"activeJobsSuffix": "active jobs",
"jobQueue": "Job Queue",
"expandCollapsedQueue": "Expand job queue",
@@ -877,7 +874,7 @@
"noResultsHint": "Try adjusting your search or filters",
"allTemplates": "All Templates",
"modelFilter": "Model Filter",
"useCaseFilter": "Tasks",
"useCaseFilter": "Use Case",
"licenseFilter": "License",
"modelsSelected": "{count} Models",
"useCasesSelected": "{count} Use Cases",
@@ -886,7 +883,6 @@
"resultsCount": "Showing {count} of {total} templates",
"sort": {
"recommended": "Recommended",
"popular": "Popular",
"alphabetical": "A → Z",
"newest": "Newest",
"searchPlaceholder": "Search...",
@@ -1042,8 +1038,6 @@
"copyErrorMessage": "Copy error message",
"reportError": "Report error"
},
"toggleJobHistory": "Toggle Job History",
"jobHistory": "Job History",
"jobList": {
"undated": "Undated",
"sortMostRecent": "Most recent",
@@ -1146,7 +1140,6 @@
"Toggle the Custom Nodes Manager Progress Bar": "Toggle the Custom Nodes Manager Progress Bar",
"Decrease Brush Size in MaskEditor": "Decrease Brush Size in MaskEditor",
"Increase Brush Size in MaskEditor": "Increase Brush Size in MaskEditor",
"Open Color Picker in MaskEditor": "Open Color Picker in MaskEditor",
"Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node",
"Unload Models": "Unload Models",
"Unload Models and Execution Cache": "Unload Models and Execution Cache",
@@ -1155,7 +1148,6 @@
"Manager": "Manager",
"Open": "Open",
"Publish": "Publish",
"Job History": "Job History",
"Queue Prompt": "Queue Prompt",
"Queue Prompt (Front)": "Queue Prompt (Front)",
"Queue Selected Output Nodes": "Queue Selected Output Nodes",
@@ -1168,7 +1160,6 @@
"Canvas Performance": "Canvas Performance",
"Help Center": "Help Center",
"toggle linear mode": "toggle linear mode",
"Toggle Queue Panel V2": "Toggle Queue Panel V2",
"Toggle Theme (Dark/Light)": "Toggle Theme (Dark/Light)",
"Undo": "Undo",
"Open Sign In Dialog": "Open Sign In Dialog",
@@ -1402,8 +1393,6 @@
"conditioning": "conditioning",
"loaders": "loaders",
"guiders": "guiders",
"latent": "latent",
"mask": "mask",
"api node": "api node",
"video": "video",
"ByteDance": "ByteDance",
@@ -1420,16 +1409,17 @@
"kandinsky5": "kandinsky5",
"hooks": "hooks",
"combine": "combine",
"logic": "logic",
"cond single": "cond single",
"context": "context",
"controlnet": "controlnet",
"inpaint": "inpaint",
"scheduling": "scheduling",
"create": "create",
"mask": "mask",
"deprecated": "deprecated",
"debug": "debug",
"model": "model",
"latent": "latent",
"3d": "3d",
"ltxv": "ltxv",
"qwen": "qwen",
@@ -1498,9 +1488,6 @@
"CLIP_VISION": "CLIP_VISION",
"CLIP_VISION_OUTPUT": "CLIP_VISION_OUTPUT",
"COMBO": "COMBO",
"COMFY_AUTOGROW_V3": "COMFY_AUTOGROW_V3",
"COMFY_DYNAMICCOMBO_V3": "COMFY_DYNAMICCOMBO_V3",
"COMFY_MATCHTYPE_V3": "COMFY_MATCHTYPE_V3",
"CONDITIONING": "CONDITIONING",
"CONTROL_NET": "CONTROL_NET",
"FLOAT": "FLOAT",
@@ -1643,7 +1630,6 @@
"loadingModel": "Loading 3D Model...",
"upDirection": "Up Direction",
"materialMode": "Material Mode",
"showSkeleton": "Show Skeleton",
"scene": "Scene",
"model": "Model",
"camera": "Camera",
@@ -1711,7 +1697,6 @@
"pleaseSelectNodesToGroup": "Please select the nodes (or other groups) to create a group for",
"emptyCanvas": "Empty canvas",
"fileUploadFailed": "File upload failed",
"fileTooLarge": "File too large ({size} MB). Maximum supported size is {maxSize} MB",
"unableToGetModelFilePath": "Unable to get model file path",
"couldNotDetermineFileType": "Could not determine file type",
"errorLoadingModel": "Error loading model",
@@ -2293,16 +2278,14 @@
"noAssetsFound": "No assets found",
"noModelsInFolder": "No {type} available in this folder",
"notSureLeaveAsIs": "Not sure? Just leave this as is",
"noValidSourceDetected": "No valid import source detected",
"onlyCivitaiUrlsSupported": "Only Civitai URLs are supported",
"ownership": "Ownership",
"ownershipAll": "All",
"ownershipMyModels": "My models",
"ownershipPublicModels": "Public models",
"processingModel": "Download started",
"processingModelDescription": "You can close this dialog. The download will continue in the background.",
"providerCivitai": "Civitai",
"providerHuggingFace": "Hugging Face",
"noValidSourceDetected": "No valid import source detected",
"selectFrameworks": "Select Frameworks",
"selectModelType": "Select model type",
"selectProjects": "Select Projects",
@@ -2327,8 +2310,8 @@
"uploadModelDescription1": "Paste a Civitai model download link to add it to your library.",
"uploadModelDescription1Generic": "Paste a model download link to add it to your library.",
"uploadModelDescription2": "Only links from {link} are supported at the moment",
"uploadModelDescription2Generic": "Only URLs from the following providers are supported:",
"uploadModelDescription2Link": "https://civitai.com/models",
"uploadModelDescription2Generic": "Only URLs from the following providers are supported:",
"uploadModelDescription3": "Max file size: {size}",
"uploadModelFailedToRetrieveMetadata": "Failed to retrieve metadata. Please check the link and try again.",
"uploadModelFromCivitai": "Import a model from Civitai",
@@ -2352,11 +2335,6 @@
"complete": "{assetName} has been deleted.",
"failed": "{assetName} could not be deleted."
},
"download": {
"complete": "Download complete",
"failed": "Download failed",
"inProgress": "Downloading {assetName}..."
},
"rename": {
"failed": "Could not rename asset."
}
@@ -2474,4 +2452,4 @@
"recentReleases": "Recent releases",
"helpCenterMenu": "Help Center Menu"
}
}
}

View File

@@ -267,45 +267,6 @@
}
}
},
"BatchImagesNode": {
"display_name": "Batch Images",
"inputs": {
"images": {
"name": "images"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"BatchLatentsNode": {
"display_name": "Batch Latents",
"inputs": {
"latents": {
"name": "latents"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"BatchMasksNode": {
"display_name": "Batch Masks",
"inputs": {
"masks": {
"name": "masks"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"BetaSamplingScheduler": {
"display_name": "BetaSamplingScheduler",
"inputs": {
@@ -1286,26 +1247,6 @@
}
}
},
"ComfySwitchNode": {
"display_name": "Switch",
"inputs": {
"switch": {
"name": "switch"
},
"on_false": {
"name": "on_false"
},
"on_true": {
"name": "on_true"
}
},
"outputs": {
"0": {
"name": "output",
"tooltip": null
}
}
},
"ConditioningAverage": {
"display_name": "ConditioningAverage",
"inputs": {
@@ -2015,20 +1956,6 @@
}
}
},
"CustomCombo": {
"display_name": "Custom Combo",
"inputs": {
"choice": {
"name": "choice"
},
"option0": {}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"DiffControlNetLoader": {
"display_name": "Load ControlNet Model (diff)",
"inputs": {
@@ -4849,9 +4776,6 @@
"reference_images": {
"name": "reference_images",
"tooltip": "Up to 4 additional reference images."
},
"resolution": {
"name": "resolution"
}
},
"outputs": {
@@ -4884,9 +4808,6 @@
"reference_images": {
"name": "reference_images",
"tooltip": "Up to 6 additional reference images."
},
"resolution": {
"name": "resolution"
}
},
"outputs": {
@@ -4943,9 +4864,6 @@
"reference_images": {
"name": "reference_images",
"tooltip": "Up to 7 reference images."
},
"resolution": {
"name": "resolution"
}
},
"outputs": {
@@ -4970,9 +4888,6 @@
},
"duration": {
"name": "duration"
},
"resolution": {
"name": "resolution"
}
},
"outputs": {
@@ -5008,9 +4923,6 @@
"reference_images": {
"name": "reference_images",
"tooltip": "Up to 4 additional reference images."
},
"resolution": {
"name": "resolution"
}
},
"outputs": {
@@ -6079,23 +5991,6 @@
}
}
},
"LTXAVTextEncoderLoader": {
"display_name": "LTXV Audio Text Encoder Loader",
"description": "[Recipes]\n\nltxav: gemma 3 12B",
"inputs": {
"text_encoder": {
"name": "text_encoder"
},
"ckpt_name": {
"name": "ckpt_name"
}
},
"outputs": {
"0": {
"tooltip": null
}
}
},
"LTXVAddGuide": {
"display_name": "LTXVAddGuide",
"inputs": {
@@ -6202,76 +6097,6 @@
}
}
},
"LTXVAudioVAEDecode": {
"display_name": "LTXV Audio VAE Decode",
"inputs": {
"samples": {
"name": "samples",
"tooltip": "The latent to be decoded."
},
"audio_vae": {
"name": "audio_vae",
"tooltip": "The Audio VAE model used for decoding the latent."
}
},
"outputs": {
"0": {
"name": "Audio",
"tooltip": null
}
}
},
"LTXVAudioVAEEncode": {
"display_name": "LTXV Audio VAE Encode",
"inputs": {
"audio": {
"name": "audio",
"tooltip": "The audio to be encoded."
},
"audio_vae": {
"name": "audio_vae",
"tooltip": "The Audio VAE model to use for encoding."
}
},
"outputs": {
"0": {
"name": "Audio Latent",
"tooltip": null
}
}
},
"LTXVAudioVAELoader": {
"display_name": "LTXV Audio VAE Loader",
"inputs": {
"ckpt_name": {
"name": "ckpt_name",
"tooltip": "Audio VAE checkpoint to load."
}
},
"outputs": {
"0": {
"name": "Audio VAE",
"tooltip": null
}
}
},
"LTXVConcatAVLatent": {
"display_name": "LTXVConcatAVLatent",
"inputs": {
"video_latent": {
"name": "video_latent"
},
"audio_latent": {
"name": "audio_latent"
}
},
"outputs": {
"0": {
"name": "latent",
"tooltip": null
}
}
},
"LTXVConditioning": {
"display_name": "LTXVConditioning",
"inputs": {
@@ -6324,33 +6149,6 @@
}
}
},
"LTXVEmptyLatentAudio": {
"display_name": "LTXV Empty Latent Audio",
"inputs": {
"frames_number": {
"name": "frames_number",
"tooltip": "Number of frames."
},
"frame_rate": {
"name": "frame_rate",
"tooltip": "Number of frames per second."
},
"batch_size": {
"name": "batch_size",
"tooltip": "The number of latent audio samples in the batch."
},
"audio_vae": {
"name": "audio_vae",
"tooltip": "The Audio VAE model to get configuration from."
}
},
"outputs": {
"0": {
"name": "Latent",
"tooltip": null
}
}
},
"LTXVImgToVideo": {
"display_name": "LTXVImgToVideo",
"inputs": {
@@ -6397,47 +6195,6 @@
}
}
},
"LTXVImgToVideoInplace": {
"display_name": "LTXVImgToVideoInplace",
"inputs": {
"vae": {
"name": "vae"
},
"image": {
"name": "image"
},
"latent": {
"name": "latent"
},
"strength": {
"name": "strength"
},
"bypass": {
"name": "bypass",
"tooltip": "Bypass the conditioning."
}
},
"outputs": {
"0": {
"name": "latent",
"tooltip": null
}
}
},
"LTXVLatentUpsampler": {
"display_name": "LTXVLatentUpsampler",
"inputs": {
"samples": {
"name": "samples"
},
"upscale_model": {
"name": "upscale_model"
},
"vae": {
"name": "vae"
}
}
},
"LTXVPreprocess": {
"display_name": "LTXVPreprocess",
"inputs": {
@@ -6486,25 +6243,6 @@
}
}
},
"LTXVSeparateAVLatent": {
"display_name": "LTXVSeparateAVLatent",
"description": "LTXV Separate AV Latent",
"inputs": {
"av_latent": {
"name": "av_latent"
}
},
"outputs": {
"0": {
"name": "video_latent",
"tooltip": null
},
"1": {
"name": "audio_latent",
"tooltip": null
}
}
},
"LumaConceptsNode": {
"display_name": "Luma Concepts",
"description": "Camera Concepts for use with Luma Text to Video and Luma Image to Video nodes.",
@@ -6720,7 +6458,7 @@
}
},
"Mahiro": {
"display_name": "Mahiro CFG",
"display_name": "Mahiro is so cute that she deserves a better guidance function!! (。・ω・。)",
"description": "Modify the guidance to scale more on the 'direction' of the positive prompt rather than the difference between the negative prompt.",
"inputs": {
"model": {
@@ -10894,29 +10632,6 @@
}
}
},
"ResizeImageMaskNode": {
"display_name": "Resize Image/Mask",
"inputs": {
"input": {
"name": "input"
},
"resize_type": {
"name": "resize_type"
},
"scale_method": {
"name": "scale_method"
},
"resize_type_multiplier": {
"name": "multiplier"
}
},
"outputs": {
"0": {
"name": "resized",
"tooltip": null
}
}
},
"ResizeImagesByLongerEdge": {
"display_name": "Resize Images by Longer Edge",
"inputs": {

View File

@@ -940,9 +940,6 @@
"inbox": "收件箱",
"star": "星星"
},
"imageCompare": {
"noImages": "没有可以对比的图像"
},
"install": {
"appDataLocationTooltip": "ComfyUI 的应用数据目录。存储:\n- 日志\n- 服务器配置",
"appPathLocationTooltip": "ComfyUI 的应用资产目录。存储 ComfyUI 代码和资产",
@@ -2380,7 +2377,6 @@
"updateFrontend": "更新前端"
},
"vueNodesBanner": {
"desc": " 更灵活的工作流,强力的新组件,为拓展性而生",
"title": "介绍 Nodes 2.0",
"tryItOut": "试试看"
},

File diff suppressed because it is too large Load Diff

View File

@@ -19,11 +19,11 @@
},
"Comfy-Desktop_WindowStyle": {
"name": "窗口样式",
"tooltip": "选择自定义选项以隐藏系统标题栏",
"options": {
"default": "默认",
"custom": "自定义"
}
"custom": "自定义",
"default": "默认"
},
"tooltip": "选择自定义选项以隐藏系统标题栏"
},
"Comfy_Canvas_BackgroundImage": {
"name": "画布背景图像",
@@ -46,9 +46,9 @@
"Comfy_Canvas_NavigationMode": {
"name": "画布导航模式",
"options": {
"Standard (New)": "标准(新)",
"Custom": "自定义",
"Drag Navigation": "拖动画布",
"Custom": "自定义"
"Standard (New)": "标准(新)"
}
},
"Comfy_Canvas_SelectionToolbox": {
@@ -81,14 +81,14 @@
},
"Comfy_Execution_PreviewMethod": {
"name": "实时预览",
"tooltip": "图像生成过程中实时预览。 \"默认\" 使用服务器 CLI 设置。",
"options": {
"default": "默认",
"none": "无",
"auto": "自动",
"default": "默认",
"latent2rgb": "latent2rgb",
"none": "无",
"taesd": "taesd"
}
},
"tooltip": "图像生成过程中实时预览。 \"默认\" 使用服务器 CLI 设置。"
},
"Comfy_FloatRoundingPrecision": {
"name": "浮点组件四舍五入的小数位数 [0 = 自动]。",
@@ -106,9 +106,9 @@
"Comfy_Graph_LinkMarkers": {
"name": "连线中点标记",
"options": {
"None": "",
"Arrow": "箭头",
"Circle": "圆",
"Arrow": "箭头"
"None": ""
}
},
"Comfy_Graph_LiveSelection": {
@@ -128,25 +128,25 @@
"name": "释放连线时的操作",
"options": {
"context menu": "上下文菜单",
"search box": "搜索框",
"no action": "无操作"
"no action": "无操作",
"search box": "搜索框"
}
},
"Comfy_LinkRelease_ActionShift": {
"name": "释放连线时的操作Shift",
"options": {
"context menu": "上下文菜单",
"search box": "搜索框",
"no action": "无操作"
"no action": "无操作",
"search box": "搜索框"
}
},
"Comfy_LinkRenderMode": {
"name": "连线渲染样式",
"options": {
"Straight": "直角线",
"Hidden": "隐藏",
"Linear": "直线",
"Spline": "曲线",
"Hidden": "隐藏"
"Straight": "直角线"
}
},
"Comfy_Load3D_3DViewerEnable": {
@@ -159,11 +159,11 @@
},
"Comfy_Load3D_CameraType": {
"name": "摄像机类型",
"tooltip": "控制创建新的3D小部件时默认的相机是透视还是正交。这个默认设置仍然可以在创建后为每个小部件单独切换。",
"options": {
"perspective": "透视",
"orthographic": "正交"
}
"orthographic": "正交",
"perspective": "透视"
},
"tooltip": "控制创建新的3D小部件时默认的相机是透视还是正交。这个默认设置仍然可以在创建后为每个小部件单独切换。"
},
"Comfy_Load3D_LightAdjustmentIncrement": {
"name": "光照调整步长",
@@ -183,12 +183,12 @@
},
"Comfy_Load3D_PLYEngine": {
"name": "PLY 引擎",
"tooltip": "选择加载 PLY 文件的引擎。 \"threejs\" 使用原生 Three.js PLY 加载器(最适合网格 PLY。 \"fastply\" 使用专用于 ASCII 点云的 PLY 文件加载器。 \"sparkjs\" 使用 Spark.js 加载 3D 高斯泼溅 PLY 文件。",
"options": {
"threejs": "threejs",
"fastply": "fastply",
"sparkjs": "sparkjs"
}
"sparkjs": "sparkjs",
"threejs": "threejs"
},
"tooltip": "选择加载 PLY 文件的引擎。 \"threejs\" 使用原生 Three.js PLY 加载器(最适合网格 PLY。 \"fastply\" 使用专用于 ASCII 点云的 PLY 文件加载器。 \"sparkjs\" 使用 Spark.js 加载 3D 高斯泼溅 PLY 文件。"
},
"Comfy_Load3D_ShowGrid": {
"name": "显示网格",
@@ -211,11 +211,11 @@
},
"Comfy_ModelLibrary_NameFormat": {
"name": "在模型库树视图中显示的名称",
"tooltip": "选择“文件名”以在模型列表中显示原始文件名的简化视图(不带目录和“.safetensors”后缀名。选择“标题”以显示可配置的模型元数据标题。",
"options": {
"filename": "文件名",
"title": "标题"
}
},
"tooltip": "选择“文件名”以在模型列表中显示原始文件名的简化视图(不带目录和“.safetensors”后缀名。选择“标题”以显示可配置的模型元数据标题。"
},
"Comfy_Node_AllowImageSizeDraw": {
"name": "在图像预览下方显示宽度×高度"
@@ -266,9 +266,9 @@
"Comfy_NodeBadge_NodeSourceBadgeMode": {
"name": "节点源标签",
"options": {
"Hide built-in": "仅第三方",
"None": "无",
"Show all": "显示全部",
"Hide built-in": "仅第三方"
"Show all": "显示全部"
}
},
"Comfy_NodeBadge_ShowApiPricing": {
@@ -349,8 +349,8 @@
"Comfy_Sidebar_Style": {
"name": "侧边栏样式",
"options": {
"floating": "浮动式",
"connected": "连接式"
"connected": "连接式",
"floating": "浮动式"
}
},
"Comfy_Sidebar_UnifiedWidth": {
@@ -371,11 +371,11 @@
},
"Comfy_UseNewMenu": {
"name": "使用新菜单",
"tooltip": "选单列位置。在行动装置上,选单始终显示于顶端。",
"options": {
"Disabled": "禁用",
"Top": "顶部"
}
},
"tooltip": "选单列位置。在行动装置上,选单始终显示于顶端。"
},
"Comfy_Validation_Workflows": {
"name": "校验工作流"
@@ -390,11 +390,11 @@
},
"Comfy_WidgetControlMode": {
"name": "组件控制模式",
"tooltip": "控制组件值的更新时机(随机/增加/减少),可以在执行工作流之前或之后。",
"options": {
"before": "之",
"after": "之"
}
"after": "之",
"before": "之"
},
"tooltip": "控制组件值的更新时机(随机/增加/减少),可以在执行工作流之前或之后。"
},
"Comfy_Window_UnloadConfirmation": {
"name": "关闭窗口时显示确认"
@@ -402,8 +402,8 @@
"Comfy_Workflow_AutoSave": {
"name": "自动保存",
"options": {
"off": "关闭",
"after delay": "延迟后"
"after delay": "延迟后",
"off": "关闭"
}
},
"Comfy_Workflow_AutoSaveDelay": {

View File

@@ -83,7 +83,6 @@ import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { OnCloseKey } from '@/types/widgetTypes'
@@ -133,17 +132,6 @@ watch(
{ immediate: true }
)
const assetDownloadStore = useAssetDownloadStore()
watch(
() => assetDownloadStore.hasActiveDownloads,
async (currentlyActive, previouslyActive) => {
if (previouslyActive && !currentlyActive) {
await execute()
}
}
)
const {
searchQuery,
selectedCategory,

View File

@@ -33,7 +33,7 @@
<AssetBadgeGroup :badges="asset.badges" />
<IconGroup
v-if="showAssetOptions"
v-if="flags.assetUpdateOptionsEnabled && !(asset.is_immutable ?? true)"
:class="
cn(
'absolute top-2 right-2 invisible group-hover:visible',
@@ -44,7 +44,6 @@
<MoreButton ref="dropdown-menu-button" size="sm">
<template #default>
<Button
v-if="flags.assetRenameEnabled"
variant="secondary"
size="md"
class="justify-start"
@@ -54,7 +53,6 @@
<span>{{ $t('g.rename') }}</span>
</Button>
<Button
v-if="flags.assetDeletionEnabled"
variant="secondary"
size="md"
class="justify-start"
@@ -162,12 +160,6 @@ const deletedLocal = ref(false)
const displayName = computed(() => newNameRef.value ?? asset.name)
const showAssetOptions = computed(
() =>
(flags.assetDeletionEnabled || flags.assetRenameEnabled) &&
!(asset.is_immutable ?? true)
)
const tooltipDelay = computed<number>(() =>
settingStore.get('LiteGraph.Node.TooltipDelay')
)

View File

@@ -0,0 +1,133 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Button from '@/components/ui/button/Button.vue'
import { iconForJobState } from '@/queue/utils/queueDisplay'
import AssetsListCard from './AssetsListCard.vue'
function centeredDecorator() {
return {
template: `
<div style="width: 380px;">
<story />
</div>
`
}
}
const meta: Meta<typeof AssetsListCard> = {
title: 'Platform/Assets/AssetsListCard',
component: AssetsListCard,
parameters: {
layout: 'centered'
},
decorators: [centeredDecorator]
}
export default meta
type Story = StoryObj<typeof meta>
const IMAGE_PREVIEW = '/assets/images/comfy-logo-single.svg'
const VIDEO_PREVIEW = '/assets/images/default-template.png'
export const PendingJob: Story = {
args: {
iconName: iconForJobState('pending'),
iconClass: 'animate-spin',
primaryText: 'In queue',
secondaryText: '8:59:30pm'
}
}
export const InitializationJob: Story = {
args: {
iconName: iconForJobState('initialization'),
primaryText: 'Initializing...',
secondaryText: '8:59:35pm'
}
}
export const RunningJob: Story = {
args: {
iconName: iconForJobState('running'),
primaryText: 'Total: 30%',
secondaryText: 'CLIP Text Encode: 70%',
progressTotalPercent: 30,
progressCurrentPercent: 70
}
}
export const RunningJobWithActions: Story = {
args: {
iconName: iconForJobState('running'),
primaryText: 'Total: 30%',
secondaryText: 'KSampler: 70%',
progressTotalPercent: 30,
progressCurrentPercent: 70
},
render: renderRunningJobWithActions
}
export const FailedJob: Story = {
args: {
iconName: iconForJobState('failed'),
iconClass: 'text-destructive-background',
iconWrapperClass: 'bg-modal-card-placeholder-background',
primaryText: 'Failed',
secondaryText: '8:59:30pm'
}
}
export const GeneratedImage: Story = {
args: {
previewUrl: IMAGE_PREVIEW,
previewAlt: 'image-032.png',
primaryText: 'image-032.png',
secondaryText: '1.84s'
}
}
export const GeneratedVideo: Story = {
args: {
previewUrl: VIDEO_PREVIEW,
previewAlt: 'clip-01.mp4',
primaryText: 'clip-01.mp4',
secondaryText: '2m 12s'
}
}
export const GeneratedAudio: Story = {
args: {
iconName: 'icon-[lucide--music]',
primaryText: 'soundtrack-01.mp3',
secondaryText: '3m 20s'
}
}
export const Generated3D: Story = {
args: {
iconName: 'icon-[lucide--box]',
primaryText: 'scene-01.glb',
secondaryText: '128 MB'
}
}
type AssetsListCardProps = InstanceType<typeof AssetsListCard>['$props']
function renderRunningJobWithActions(args: AssetsListCardProps) {
return {
components: { AssetsListCard, Button },
setup() {
return { args }
},
template: `
<AssetsListCard v-bind="args">
<template #actions>
<Button variant="destructive" size="icon" aria-label="Cancel">
<i class="icon-[lucide--x] size-4" />
</Button>
</template>
</AssetsListCard>
`
}
}

View File

@@ -0,0 +1,102 @@
<template>
<div class="relative flex items-center gap-2 overflow-hidden rounded-lg p-2">
<div
v-if="
progressTotalPercent !== undefined ||
progressCurrentPercent !== undefined
"
class="absolute inset-0"
>
<div
v-if="progressTotalPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${clampPercent(progressTotalPercent)}%` }"
/>
<div
v-if="progressCurrentPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${clampPercent(progressCurrentPercent)}%` }"
/>
</div>
<div
:class="
cn(
'relative z-1 flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-sm bg-secondary-background',
iconWrapperClass
)
"
:aria-label="iconAriaLabel || undefined"
>
<img
v-if="previewUrl"
:src="previewUrl"
:alt="previewAlt"
class="size-full object-cover"
/>
<div v-else class="flex size-full items-center justify-center">
<i
aria-hidden="true"
:class="
cn(
iconName ?? 'icon-[lucide--image]',
'size-4 text-text-secondary',
iconClass
)
"
/>
</div>
</div>
<div class="relative z-1 flex min-w-0 flex-1 flex-col gap-1">
<div
v-if="$slots.primary || primaryText"
class="text-xs leading-none text-text-primary"
>
<slot name="primary">{{ primaryText }}</slot>
</div>
<div
v-if="$slots.secondary || secondaryText"
class="text-xs leading-none text-text-secondary"
>
<slot name="secondary">{{ secondaryText }}</slot>
</div>
</div>
<div v-if="$slots.actions" class="relative z-1 flex items-center gap-2">
<slot name="actions" />
</div>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
const {
previewUrl,
previewAlt = '',
iconName,
iconAriaLabel,
iconClass,
iconWrapperClass,
primaryText,
secondaryText,
progressTotalPercent,
progressCurrentPercent
} = defineProps<{
previewUrl?: string
previewAlt?: string
iconName?: string
iconAriaLabel?: string
iconClass?: string
iconWrapperClass?: string
primaryText?: string
secondaryText?: string
progressTotalPercent?: number
progressCurrentPercent?: number
}>()
function clampPercent(value: number) {
return Math.min(100, Math.max(0, value))
}
</script>

View File

@@ -110,21 +110,10 @@
</CardBottom>
</template>
</CardContainer>
<MediaAssetContextMenu
v-if="asset"
ref="contextMenu"
:asset="asset"
:asset-type="assetType"
:file-kind="fileKind"
:show-delete-button="showDeleteButton"
@zoom="handleZoomClick"
@asset-deleted="emit('asset-deleted')"
/>
</template>
<script setup lang="ts">
import { useElementHover, whenever } from '@vueuse/core'
import { useElementHover } from '@vueuse/core'
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconGroup from '@/components/button/IconGroup.vue'
@@ -141,7 +130,6 @@ import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetContextMenu from './MediaAssetContextMenu.vue'
const mediaComponents = {
top: {
@@ -166,34 +154,22 @@ function getBottomComponent(kind: MediaKind) {
return mediaComponents.bottom[kind] || mediaComponents.bottom.image
}
const {
asset,
loading,
selected,
showOutputCount,
outputCount,
showDeleteButton,
openContextMenuId
} = defineProps<{
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
asset?: AssetItem
loading?: boolean
selected?: boolean
showOutputCount?: boolean
outputCount?: number
showDeleteButton?: boolean
openContextMenuId?: string | null
}>()
const emit = defineEmits<{
click: []
zoom: [asset: AssetItem]
'output-count-click': []
'asset-deleted': []
'context-menu-opened': []
'context-menu': [event: MouseEvent]
}>()
const cardContainerRef = ref<HTMLElement>()
const contextMenu = ref<InstanceType<typeof MediaAssetContextMenu>>()
const isVideoPlaying = ref(false)
const showVideoControls = ref(false)
@@ -302,15 +278,6 @@ const handleOutputCountClick = () => {
}
const handleContextMenu = (event: MouseEvent) => {
emit('context-menu-opened')
contextMenu.value?.show(event)
emit('context-menu', event)
}
// Close this context menu when another opens
whenever(
() => openContextMenuId && openContextMenuId !== asset?.id,
() => {
contextMenu.value?.hide()
}
)
</script>

View File

@@ -25,8 +25,8 @@
<!-- Step 3: Upload Progress -->
<UploadModelProgress
v-else-if="currentStep === 3 && uploadStatus != null"
:result="uploadStatus"
v-else-if="currentStep === 3"
:status="uploadStatus"
:error="uploadError"
:metadata="wizardData.metadata"
:model-type="selectedModelType"

View File

@@ -81,19 +81,12 @@
<span>{{ $t('assetBrowser.upload') }}</span>
</Button>
<Button
v-else-if="
currentStep === 3 &&
(uploadStatus === 'success' || uploadStatus === 'processing')
"
v-else-if="currentStep === 3 && uploadStatus === 'success'"
variant="secondary"
data-attr="upload-model-step3-finish-button"
@click="emit('close')"
>
{{
uploadStatus === 'processing'
? $t('g.close')
: $t('assetBrowser.finish')
}}
{{ $t('assetBrowser.finish') }}
</Button>
<VideoHelpDialog
v-model="showCivitaiHelp"
@@ -126,7 +119,7 @@ defineProps<{
isUploading: boolean
canFetchMetadata: boolean
canUploadModel: boolean
uploadStatus?: 'processing' | 'success' | 'error'
uploadStatus: 'idle' | 'uploading' | 'success' | 'error'
}>()
const emit = defineEmits<{

View File

@@ -1,36 +1,22 @@
<template>
<div class="flex flex-1 flex-col gap-6 text-sm text-muted-foreground">
<!-- Processing State (202 async download in progress) -->
<div v-if="result === 'processing'" class="flex flex-col gap-2">
<p class="m-0 font-bold">
{{ $t('assetBrowser.processingModel') }}
</p>
<p class="m-0">
{{ $t('assetBrowser.processingModelDescription') }}
</p>
<div
class="flex flex-row items-center gap-3 p-4 bg-modal-card-background rounded-lg"
>
<img
v-if="previewImage"
:src="previewImage"
:alt="metadata?.filename || metadata?.name || 'Model preview'"
class="w-14 h-14 rounded object-cover flex-shrink-0"
/>
<div class="flex flex-col justify-center items-start gap-1 flex-1">
<p class="text-base-foreground m-0">
{{ metadata?.filename || metadata?.name }}
</p>
<p class="text-sm text-muted m-0">
{{ modelType }}
</p>
</div>
<!-- Uploading State -->
<div
v-if="status === 'uploading'"
class="flex flex-1 flex-col items-center justify-center gap-2"
>
<i
class="icon-[lucide--loader-circle] animate-spin text-6xl text-muted-foreground"
/>
<div class="text-center">
<p class="m-0 font-bold">
{{ $t('assetBrowser.uploadingModel') }}
</p>
</div>
</div>
<!-- Success State -->
<div v-else-if="result === 'success'" class="flex flex-col gap-2">
<div v-else-if="status === 'success'" class="flex flex-col gap-2">
<p class="m-0 font-bold">
{{ $t('assetBrowser.modelUploaded') }}
</p>
@@ -61,7 +47,7 @@
<!-- Error State -->
<div
v-else-if="result === 'error'"
v-else-if="status === 'error'"
class="flex flex-1 flex-col items-center justify-center gap-6"
>
<i class="icon-[lucide--x-circle] text-6xl text-error" />
@@ -80,8 +66,8 @@
<script setup lang="ts">
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
const { result } = defineProps<{
result: 'processing' | 'success' | 'error'
defineProps<{
status: 'idle' | 'uploading' | 'success' | 'error'
error?: string
metadata?: AssetMetadata
modelType?: string

View File

@@ -55,7 +55,7 @@
<p v-if="error" class="text-xs text-error">
{{ error }}
</p>
<p v-else-if="!flags.asyncModelUploadEnabled" class="text-foreground">
<p v-else class="text-foreground">
<i18n-t keypath="assetBrowser.maxFileSize" tag="span">
<template #size>
<span class="font-bold italic">{{
@@ -77,10 +77,6 @@
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
const { flags } = useFeatureFlags()
const props = defineProps<{
modelValue: string
error?: string

View File

@@ -18,7 +18,7 @@
</template>
</i18n-t>
</li>
<li v-if="!flags.asyncModelUploadEnabled">
<li>
<i18n-t keypath="assetBrowser.uploadModelDescription3" tag="span">
<template #size>
<span class="font-bold italic">{{
@@ -74,10 +74,6 @@
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
const { flags } = useFeatureFlags()
defineProps<{
error?: string
}>()

View File

@@ -2,7 +2,7 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetContext } from '@/platform/assets/schemas/mediaAssetSchema'
import { api } from '@/scripts/api'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import type { ResultItemImpl, TaskItemImpl } from '@/queue/stores/queueStore'
/**
* Extract asset type from tags array

View File

@@ -1,12 +1,12 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ResultItemImpl } from '@/stores/queueStore'
import { ResultItemImpl } from '@/queue/stores/queueStore'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
import { useMediaAssetGalleryStore } from './useMediaAssetGalleryStore'
vi.mock('@/stores/queueStore', () => ({
vi.mock('@/queue/stores/queueStore', () => ({
ResultItemImpl: vi
.fn<typeof ResultItemImpl>()
.mockImplementation(function (data) {

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { ref, shallowRef } from 'vue'
import { ResultItemImpl } from '@/stores/queueStore'
import { ResultItemImpl } from '@/queue/stores/queueStore'
import type { AssetMeta } from '../schemas/mediaAssetSchema'

View File

@@ -10,7 +10,6 @@ import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import type { ImportSource } from '@/platform/assets/types/importSource'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
@@ -30,13 +29,12 @@ interface ModelTypeOption {
export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
const { t } = useI18n()
const assetsStore = useAssetsStore()
const assetDownloadStore = useAssetDownloadStore()
const modelToNodeStore = useModelToNodeStore()
const { flags } = useFeatureFlags()
const currentStep = ref(1)
const isFetchingMetadata = ref(false)
const isUploading = ref(false)
const uploadStatus = ref<'processing' | 'success' | 'error'>()
const uploadStatus = ref<'idle' | 'uploading' | 'success' | 'error'>('idle')
const uploadError = ref('')
const wizardData = ref<WizardData>({
@@ -156,59 +154,11 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
}
}
async function uploadPreviewImage(
filename: string
): Promise<string | undefined> {
if (!wizardData.value.previewImage) return undefined
try {
const baseFilename = filename.split('.')[0]
let extension = 'png'
const mimeMatch = wizardData.value.previewImage.match(
/^data:image\/([^;]+);/
)
if (mimeMatch) {
extension = mimeMatch[1] === 'jpeg' ? 'jpg' : mimeMatch[1]
}
const previewAsset = await assetService.uploadAssetFromBase64({
data: wizardData.value.previewImage,
name: `${baseFilename}_preview.${extension}`,
tags: ['preview']
})
return previewAsset.id
} catch (error) {
console.error('Failed to upload preview image:', error)
return undefined
}
}
async function refreshModelCaches() {
if (!selectedModelType.value) return
const providers = modelToNodeStore.getAllNodeProviders(
selectedModelType.value
)
const results = await Promise.allSettled(
providers.map((provider) =>
assetsStore.updateModelsForNodeType(provider.nodeDef.name)
)
)
results.forEach((result, index) => {
if (result.status === 'rejected') {
console.error(
`Failed to refresh ${providers[index].nodeDef.name}:`,
result.reason
)
}
})
}
async function uploadModel(): Promise<boolean> {
if (!canUploadModel.value) {
return false
}
async function uploadModel() {
if (!canUploadModel.value) return
// Defensive check: detectedSource should be valid after fetchMetadata validation,
// but guard against edge cases (e.g., URL modified between steps)
const source = detectedSource.value
if (!source) {
uploadError.value = t('assetBrowser.noValidSourceDetected')
@@ -216,6 +166,7 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
}
isUploading.value = true
uploadStatus.value = 'uploading'
try {
const tags = selectedModelType.value
@@ -226,56 +177,72 @@ export function useUploadModelWizard(modelTypes: Ref<ModelTypeOption[]>) {
wizardData.value.metadata?.name ||
'model'
const previewId = await uploadPreviewImage(filename)
const userMetadata = {
source: source.type,
source_url: wizardData.value.url,
model_type: selectedModelType.value
}
let previewId: string | undefined
if (flags.asyncModelUploadEnabled) {
const result = await assetService.uploadAssetAsync({
source_url: wizardData.value.url,
tags,
user_metadata: userMetadata,
preview_id: previewId
})
// Upload preview image first if available
if (wizardData.value.previewImage) {
try {
const baseFilename = filename.split('.')[0]
if (result.type === 'async' && result.task.status !== 'completed') {
if (selectedModelType.value) {
assetDownloadStore.trackDownload(
result.task.task_id,
selectedModelType.value
)
// Extract extension from data URL MIME type
let extension = 'png'
const mimeMatch = wizardData.value.previewImage.match(
/^data:image\/([^;]+);/
)
if (mimeMatch) {
extension = mimeMatch[1] === 'jpeg' ? 'jpg' : mimeMatch[1]
}
uploadStatus.value = 'processing'
} else {
uploadStatus.value = 'success'
await refreshModelCaches()
const previewAsset = await assetService.uploadAssetFromBase64({
data: wizardData.value.previewImage,
name: `${baseFilename}_preview.${extension}`,
tags: ['preview']
})
previewId = previewAsset.id
} catch (error) {
console.error('Failed to upload preview image:', error)
// Continue with model upload even if preview fails
}
currentStep.value = 3
} else {
await assetService.uploadAssetFromUrl({
url: wizardData.value.url,
name: filename,
tags,
user_metadata: userMetadata,
preview_id: previewId
})
uploadStatus.value = 'success'
await refreshModelCaches()
currentStep.value = 3
}
await assetService.uploadAssetFromUrl({
url: wizardData.value.url,
name: filename,
tags,
user_metadata: {
source: source.type,
source_url: wizardData.value.url,
model_type: selectedModelType.value
},
preview_id: previewId
})
uploadStatus.value = 'success'
currentStep.value = 3
// Refresh model caches for all node types that use this model category
if (selectedModelType.value) {
const providers = modelToNodeStore.getAllNodeProviders(
selectedModelType.value
)
await Promise.all(
providers.map((provider) =>
assetsStore.updateModelsForNodeType(provider.nodeDef.name)
)
)
}
return true
} catch (error) {
console.error('Failed to upload asset:', error)
uploadStatus.value = 'error'
uploadError.value =
error instanceof Error ? error.message : 'Failed to upload model'
currentStep.value = 3
return false
} finally {
isUploading.value = false
}
return uploadStatus.value !== 'error'
}
function goToPreviousStep() {

View File

@@ -1,5 +1,5 @@
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
import type { ResultItemImpl } from '@/queue/stores/queueStore'
/**
* Metadata for output assets from queue store

View File

@@ -58,17 +58,6 @@ const zAssetMetadata = z.object({
validation: zValidationResult.optional()
})
const zAsyncUploadTask = z.object({
task_id: z.string(),
status: z.enum(['created', 'running', 'completed', 'failed']),
message: z.string().optional()
})
const zAsyncUploadResponse = z.discriminatedUnion('type', [
z.object({ type: z.literal('sync'), asset: zAsset }),
z.object({ type: z.literal('async'), task: zAsyncUploadTask })
])
// Filename validation schema
export const assetFilenameSchema = z
.string()
@@ -80,13 +69,11 @@ export const assetFilenameSchema = z
// Export schemas following repository patterns
export const assetItemSchema = zAsset
export const assetResponseSchema = zAssetResponse
export const asyncUploadResponseSchema = zAsyncUploadResponse
// Export types derived from Zod schemas
export type AssetItem = z.infer<typeof zAsset>
export type AssetResponse = z.infer<typeof zAssetResponse>
export type AssetMetadata = z.infer<typeof zAssetMetadata>
export type AsyncUploadResponse = z.infer<typeof zAsyncUploadResponse>
export type ModelFolder = z.infer<typeof zModelFolder>
export type ModelFile = z.infer<typeof zModelFile>

View File

@@ -3,14 +3,12 @@ import { fromZodError } from 'zod-validation-error'
import { st } from '@/i18n'
import {
assetItemSchema,
assetResponseSchema,
asyncUploadResponseSchema
assetResponseSchema
} from '@/platform/assets/schemas/assetSchema'
import type {
AssetItem,
AssetMetadata,
AssetResponse,
AsyncUploadResponse,
ModelFile,
ModelFolder
} from '@/platform/assets/schemas/assetSchema'
@@ -48,7 +46,6 @@ function getLocalizedErrorMessage(errorCode: string): string {
}
const ASSETS_ENDPOINT = '/assets'
const ASSETS_DOWNLOAD_ENDPOINT = '/assets/download'
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
const DEFAULT_LIMIT = 500
@@ -448,72 +445,6 @@ function createAssetService() {
return await res.json()
}
/**
* Uploads an asset asynchronously using the /api/assets/download endpoint
* Returns immediately with either the asset (if already exists) or a task to track
*
* @param params - Upload parameters
* @param params.source_url - HTTP/HTTPS URL to download from
* @param params.tags - Optional freeform tags
* @param params.user_metadata - Optional custom metadata object
* @param params.preview_id - Optional UUID for preview asset
* @returns Promise<AsyncUploadResponse> - Either sync asset or async task info
* @throws Error if upload fails
*/
async function uploadAssetAsync(params: {
source_url: string
tags?: string[]
user_metadata?: Record<string, unknown>
preview_id?: string
}): Promise<AsyncUploadResponse> {
const res = await api.fetchApi(ASSETS_DOWNLOAD_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
})
if (!res.ok) {
throw new Error(
st(
'assetBrowser.errorUploadFailed',
'Failed to upload asset. Please try again.'
)
)
}
const data = await res.json()
if (res.status === 202) {
const result = asyncUploadResponseSchema.safeParse({
type: 'async',
task: data
})
if (!result.success) {
throw new Error(
st(
'assetBrowser.errorUploadFailed',
'Failed to parse async upload response. Please try again.'
)
)
}
return result.data
}
const result = asyncUploadResponseSchema.safeParse({
type: 'sync',
asset: data
})
if (!result.success) {
throw new Error(
st(
'assetBrowser.errorUploadFailed',
'Failed to parse sync upload response. Please try again.'
)
)
}
return result.data
}
return {
getAssetModelFolders,
getAssetModels,
@@ -525,8 +456,7 @@ function createAssetService() {
updateAsset,
getAssetMetadata,
uploadAssetFromUrl,
uploadAssetFromBase64,
uploadAssetAsync
uploadAssetFromBase64
}
}

View File

@@ -127,6 +127,53 @@ export async function getSurveyCompletedStatus(): Promise<boolean> {
}
}
// @ts-expect-error - Unused function kept for future use
async function postSurveyStatus(): Promise<void> {
try {
const response = await api.fetchApi(`/settings/${ONBOARDING_SURVEY_KEY}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ [ONBOARDING_SURVEY_KEY]: undefined })
})
if (!response.ok) {
const error = new Error(
`Failed to post survey status: ${response.statusText}`
)
captureApiError(
error,
'/settings/{key}',
'http_error',
response.status,
'post_survey_status',
{
route_template: '/settings/{key}',
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
}
)
throw error
}
} catch (error) {
// Only capture network errors (not HTTP errors we already captured)
if (!isHttpError(error, 'Failed to post survey status:')) {
captureApiError(
error as Error,
'/settings/{key}',
'network_error',
undefined,
'post_survey_status',
{
route_template: '/settings/{key}',
route_actual: `/settings/${ONBOARDING_SURVEY_KEY}`
}
)
}
throw error
}
}
export async function submitSurvey(
survey: Record<string, unknown>
): Promise<void> {

View File

@@ -258,8 +258,6 @@ import type {
TierKey,
TierPricing
} from '@/platform/cloud/subscription/constants/tierPricing'
import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import { isCloud } from '@/platform/distribution/types'
import {
FirebaseAuthStoreError,
@@ -271,6 +269,8 @@ type SubscriptionTier = components['schemas']['SubscriptionTier']
type CheckoutTierKey = Exclude<TierKey, 'founder'>
type CheckoutTier = CheckoutTierKey | `${CheckoutTierKey}-yearly`
type BillingCycle = 'monthly' | 'yearly'
const getCheckoutTier = (
tierKey: CheckoutTierKey,
billingCycle: BillingCycle
@@ -342,15 +342,6 @@ const currentTierKey = computed<TierKey | null>(() =>
subscriptionTier.value ? TIER_TO_KEY[subscriptionTier.value] : null
)
const currentPlanDescriptor = computed(() => {
if (!currentTierKey.value) return null
return {
tierKey: currentTierKey.value,
billingCycle: isYearlySubscription.value ? 'yearly' : 'monthly'
} as const
})
const isCurrentPlan = (tierKey: CheckoutTierKey): boolean => {
if (!currentTierKey.value) return false
@@ -452,23 +443,7 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
if (isActiveSubscription.value) {
// Pass the target tier to create a deep link to subscription update confirmation
const checkoutTier = getCheckoutTier(tierKey, currentBillingCycle.value)
const targetPlan = {
tierKey,
billingCycle: currentBillingCycle.value
}
const downgrade =
currentPlanDescriptor.value &&
isPlanDowngrade({
current: currentPlanDescriptor.value,
target: targetPlan
})
if (downgrade) {
// TODO(COMFY-StripeProration): Remove once backend checkout creation mirrors portal proration ("change at billing end")
await accessBillingPortal()
} else {
await accessBillingPortal(checkoutTier)
}
await accessBillingPortal(checkoutTier)
} else {
const response = await initiateCheckout(tierKey)
if (response.checkout_url) {

View File

@@ -1,49 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getPlanRank, isPlanDowngrade } from './subscriptionTierRank'
describe('subscriptionTierRank', () => {
it('returns consistent order for ranked plans', () => {
const yearlyPro = getPlanRank({ tierKey: 'pro', billingCycle: 'yearly' })
const monthlyStandard = getPlanRank({
tierKey: 'standard',
billingCycle: 'monthly'
})
expect(yearlyPro).toBeLessThan(monthlyStandard)
})
it('identifies downgrades correctly', () => {
const result = isPlanDowngrade({
current: { tierKey: 'pro', billingCycle: 'yearly' },
target: { tierKey: 'creator', billingCycle: 'monthly' }
})
expect(result).toBe(true)
})
it('treats lateral or upgrade moves as non-downgrades', () => {
expect(
isPlanDowngrade({
current: { tierKey: 'standard', billingCycle: 'monthly' },
target: { tierKey: 'creator', billingCycle: 'monthly' }
})
).toBe(false)
expect(
isPlanDowngrade({
current: { tierKey: 'creator', billingCycle: 'monthly' },
target: { tierKey: 'creator', billingCycle: 'monthly' }
})
).toBe(false)
})
it('treats unknown plans (e.g., founder) as non-downgrade cases', () => {
const result = isPlanDowngrade({
current: { tierKey: 'founder', billingCycle: 'monthly' },
target: { tierKey: 'standard', billingCycle: 'monthly' }
})
expect(result).toBe(false)
})
})

View File

@@ -1,58 +0,0 @@
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
export type BillingCycle = 'monthly' | 'yearly'
type RankedTierKey = Exclude<TierKey, 'founder'>
type RankedPlanKey = `${BillingCycle}-${RankedTierKey}`
interface PlanDescriptor {
tierKey: TierKey
billingCycle: BillingCycle
}
const PLAN_ORDER: RankedPlanKey[] = [
'yearly-pro',
'yearly-creator',
'yearly-standard',
'monthly-pro',
'monthly-creator',
'monthly-standard'
]
const PLAN_RANK = PLAN_ORDER.reduce<Map<RankedPlanKey, number>>(
(acc, plan, index) => acc.set(plan, index),
new Map()
)
const toRankedPlanKey = (
tierKey: TierKey,
billingCycle: BillingCycle
): RankedPlanKey | null => {
if (tierKey === 'founder') return null
return `${billingCycle}-${tierKey}` as RankedPlanKey
}
export const getPlanRank = ({
tierKey,
billingCycle
}: PlanDescriptor): number => {
const planKey = toRankedPlanKey(tierKey, billingCycle)
if (!planKey) return Number.POSITIVE_INFINITY
return PLAN_RANK.get(planKey) ?? Number.POSITIVE_INFINITY
}
interface DowngradeCheckParams {
current: PlanDescriptor
target: PlanDescriptor
}
export const isPlanDowngrade = ({
current,
target
}: DowngradeCheckParams): boolean => {
const currentRank = getPlanRank(current)
const targetRank = getPlanRank(target)
return targetRank > currentRank
}

View File

@@ -35,10 +35,8 @@ export type RemoteConfig = {
firebase_config?: FirebaseRuntimeConfig
telemetry_disabled_events?: TelemetryEventName[]
model_upload_button_enabled?: boolean
asset_deletion_enabled?: boolean
asset_rename_enabled?: boolean
asset_update_options_enabled?: boolean
private_models_enabled?: boolean
onboarding_survey_enabled?: boolean
huggingface_model_import_enabled?: boolean
async_model_upload_enabled?: boolean
}

View File

@@ -1095,7 +1095,7 @@ export const CORE_SETTINGS: SettingParams[] = [
id: 'Comfy.Templates.SortBy',
name: 'Template library - Sort preference',
type: 'hidden',
defaultValue: 'default'
defaultValue: 'newest'
},
/**

View File

@@ -197,8 +197,6 @@ export interface TemplateFilterMetadata {
selected_runs_on: string[]
sort_by:
| 'default'
| 'recommended'
| 'popular'
| 'alphabetical'
| 'newest'
| 'vram-low-to-high'

View File

@@ -6,7 +6,7 @@ import { i18n, st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil'
import { getCategoryIcon } from '@/utils/categoryIcons'
import { normalizeI18nKey } from '@/utils/formatUtil'
import type {
@@ -276,18 +276,9 @@ export const useWorkflowTemplatesStore = defineStore(
return enhancedTemplates.value
}
if (categoryId.startsWith('basics-')) {
if (categoryId === 'basics') {
// Filter for templates from categories marked as essential
return enhancedTemplates.value.filter(
(t) =>
t.isEssential &&
t.category?.toLowerCase().replace(/\s+/g, '-') ===
categoryId.replace('basics-', '')
)
}
if (categoryId === 'popular') {
return enhancedTemplates.value
return enhancedTemplates.value.filter((t) => t.isEssential)
}
if (categoryId === 'partner-nodes') {
@@ -342,34 +333,20 @@ export const useWorkflowTemplatesStore = defineStore(
icon: getCategoryIcon('all')
})
// 1.5. Popular categories
items.push({
id: 'popular',
label: st('templateWorkflows.category.Popular', 'Popular'),
icon: 'icon-[lucide--flame]'
})
// 2. Basics (isEssential categories) - always beneath All Templates if they exist
const essentialCats = coreTemplates.value.filter(
// 2. Basics (isEssential categories) - always second if it exists
const essentialCat = coreTemplates.value.find(
(cat) => cat.isEssential && cat.templates.length > 0
)
if (essentialCats.length > 0) {
essentialCats.forEach((essentialCat) => {
const categoryIcon = essentialCat.icon
const categoryTitle = essentialCat.title ?? 'Getting Started'
const categoryId = generateCategoryId('basics', essentialCat.title)
items.push({
id: categoryId,
label: st(
`templateWorkflows.category.${normalizeI18nKey(categoryTitle)}`,
categoryTitle
),
icon:
categoryIcon ||
getCategoryIcon(essentialCat.type || 'getting-started')
})
if (essentialCat) {
const categoryTitle = essentialCat.title ?? 'Getting Started'
items.push({
id: 'basics',
label: st(
`templateWorkflows.category.${normalizeI18nKey(categoryTitle)}`,
categoryTitle
),
icon: 'icon-[lucide--graduation-cap]'
})
}
@@ -398,7 +375,7 @@ export const useWorkflowTemplatesStore = defineStore(
const group = categoryGroups.get(categoryGroup)!
// Generate unique ID for this category
const categoryId = generateCategoryId(categoryGroup, category.title)
const categoryId = `${categoryGroup.toLowerCase().replace(/\s+/g, '-')}-${category.title.toLowerCase().replace(/\s+/g, '-')}`
// Store the filter mapping
categoryFilters.value.set(categoryId, {

View File

@@ -32,29 +32,6 @@ export interface TemplateInfo {
* Templates with this field will be hidden on local installations temporarily.
*/
requiresCustomNodes?: string[]
/**
* Manual ranking boost/demotion for "Recommended" sort. Scale 1-10, default 5.
* Higher values promote the template, lower values demote it.
*/
searchRank?: number
/**
* Usage score based on real world usage statistics.
* Used for popular templates sort and for "Recommended" sort boost.
*/
usage?: number
/**
* Manage template's visibility across different distributions by specifying which distributions it should be included on.
* If not specified, the template will be included on all distributions.
*/
includeOnDistributions?: TemplateIncludeOnDistributionEnum[]
}
export enum TemplateIncludeOnDistributionEnum {
Cloud = 'cloud',
Local = 'local',
Desktop = 'desktop',
Mac = 'mac',
Windows = 'windows'
}
export interface WorkflowTemplates {

View File

@@ -89,7 +89,7 @@ import Button from '@/components/ui/button/Button.vue'
import type {
CompletionSummary,
CompletionSummaryMode
} from '@/composables/queue/useCompletionSummary'
} from '@/queue/composables/useCompletionSummary'
type Props = {
mode: CompletionSummaryMode

View File

@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import QueueOverlayEmpty from './QueueOverlayEmpty.vue'
import type { CompletionSummary } from '@/composables/queue/useCompletionSummary'
import type { CompletionSummary } from '@/queue/composables/useCompletionSummary'
const i18n = createI18n({
legacy: false,

View File

@@ -14,8 +14,8 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import CompletionSummaryBanner from '@/components/queue/CompletionSummaryBanner.vue'
import type { CompletionSummary } from '@/composables/queue/useCompletionSummary'
import CompletionSummaryBanner from '@/queue/components/CompletionSummaryBanner.vue'
import type { CompletionSummary } from '@/queue/composables/useCompletionSummary'
defineProps<{ summary: CompletionSummary }>()

Some files were not shown because too many files have changed in this diff Show More