mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-02 19:40:03 +00:00
Compare commits
29 Commits
core/1.36
...
bl-move-qu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
003e72406d | ||
|
|
5561efcfd3 | ||
|
|
79af71530d | ||
|
|
f78b6eec47 | ||
|
|
d8e57c60bf | ||
|
|
db3edd522d | ||
|
|
f128c61c53 | ||
|
|
afa4664ad5 | ||
|
|
627db6784e | ||
|
|
85c6825a79 | ||
|
|
f614914fdf | ||
|
|
da4889900e | ||
|
|
5b1456896b | ||
|
|
0c6ea56360 | ||
|
|
2fd9a73b68 | ||
|
|
dc53cbe3c9 | ||
|
|
3d0c0d16ca | ||
|
|
a0dad31e2f | ||
|
|
5ddea4e7b6 | ||
|
|
94884d7a7c | ||
|
|
14528aad6e | ||
|
|
b13aca47cd | ||
|
|
68d1d21865 | ||
|
|
53b1dd282c | ||
|
|
f5e51d0339 | ||
|
|
27caaa38f9 | ||
|
|
3372f455ca | ||
|
|
3ae2b52649 | ||
|
|
91ed58acc9 |
@@ -6,7 +6,6 @@ const { defineConfig } = require('@lobehub/i18n-cli');
|
||||
module.exports = defineConfig({
|
||||
modelName: 'gpt-4.1',
|
||||
splitToken: 1024,
|
||||
saveImmediately: true,
|
||||
entry: 'src/locales/en',
|
||||
entryLocale: 'en',
|
||||
output: 'src/locales',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
@@ -66,7 +66,8 @@ const config: KnipConfig = {
|
||||
},
|
||||
tags: [
|
||||
'-knipIgnoreUnusedButUsedByCustomNodes',
|
||||
'-knipIgnoreUnusedButUsedByVueNodesBranch'
|
||||
'-knipIgnoreUnusedButUsedByVueNodesBranch',
|
||||
'-knipIgnoreUnusedButUsedByStorybook'
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.36.14",
|
||||
"version": "1.37.1",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
851
pnpm-lock.yaml
generated
851
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ catalog:
|
||||
'@nx/playwright': 22.2.6
|
||||
'@nx/storybook': 22.2.4
|
||||
'@nx/vite': 22.2.6
|
||||
'@pinia/testing': ^0.1.5
|
||||
'@pinia/testing': ^1.0.3
|
||||
'@playwright/test': ^1.57.0
|
||||
'@prettier/plugin-oxc': ^0.1.3
|
||||
'@primeuix/forms': 0.0.2
|
||||
@@ -26,7 +26,7 @@ catalog:
|
||||
'@primevue/icons': 4.2.5
|
||||
'@primevue/themes': ^4.2.5
|
||||
'@sentry/vite-plugin': ^4.6.0
|
||||
'@sentry/vue': ^8.48.0
|
||||
'@sentry/vue': ^10.32.1
|
||||
'@sparkjsdev/spark': ^0.1.10
|
||||
'@storybook/addon-docs': ^10.1.9
|
||||
'@storybook/vue3': ^10.1.9
|
||||
@@ -39,8 +39,8 @@ catalog:
|
||||
'@types/semver': ^7.7.0
|
||||
'@types/three': ^0.169.0
|
||||
'@vitejs/plugin-vue': ^6.0.0
|
||||
'@vitest/coverage-v8': ^3.2.4
|
||||
'@vitest/ui': ^3.2.0
|
||||
'@vitest/coverage-v8': ^4.0.16
|
||||
'@vitest/ui': ^4.0.16
|
||||
'@vue/test-utils': ^2.4.6
|
||||
'@vueuse/core': ^11.0.0
|
||||
'@vueuse/integrations': ^13.9.0
|
||||
@@ -59,11 +59,11 @@ catalog:
|
||||
eslint-plugin-unused-imports: ^4.3.0
|
||||
eslint-plugin-vue: ^10.6.2
|
||||
firebase: ^11.6.0
|
||||
globals: ^15.9.0
|
||||
happy-dom: ^15.11.0
|
||||
globals: ^16.5.0
|
||||
happy-dom: ^20.0.11
|
||||
husky: ^9.1.7
|
||||
jiti: 2.6.1
|
||||
jsdom: ^26.1.0
|
||||
jsdom: ^27.4.0
|
||||
knip: ^5.75.1
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
@@ -72,7 +72,7 @@ catalog:
|
||||
oxlint: ^1.33.0
|
||||
oxlint-tsgolint: ^0.9.1
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^2.1.7
|
||||
pinia: ^3.0.4
|
||||
postcss-html: ^1.8.0
|
||||
prettier: ^3.7.4
|
||||
pretty-bytes: ^7.1.0
|
||||
@@ -96,13 +96,13 @@ catalog:
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
vite-plugin-vue-devtools: ^8.0.0
|
||||
vitest: ^3.2.4
|
||||
vitest: ^4.0.16
|
||||
vue: ^3.5.13
|
||||
vue-component-type-helpers: ^3.0.7
|
||||
vue-component-type-helpers: ^3.2.1
|
||||
vue-eslint-parser: ^10.2.0
|
||||
vue-i18n: ^9.14.3
|
||||
vue-router: ^4.4.3
|
||||
vue-tsc: ^3.1.8
|
||||
vue-tsc: ^3.2.1
|
||||
vuefire: ^3.2.1
|
||||
yjs: ^13.6.27
|
||||
zod: ^3.23.8
|
||||
|
||||
@@ -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,7 +92,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useQueueStore } 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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
155
src/components/sidebar/tabs/AssetsSidebarListView.stories.ts
Normal file
155
src/components/sidebar/tabs/AssetsSidebarListView.stories.ts
Normal 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>
|
||||
`
|
||||
}
|
||||
}
|
||||
241
src/components/sidebar/tabs/AssetsSidebarListView.vue
Normal file
241
src/components/sidebar/tabs/AssetsSidebarListView.vue
Normal 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>
|
||||
@@ -47,17 +47,42 @@
|
||||
<MediaAssetFilterBar
|
||||
v-model:search-query="searchQuery"
|
||||
v-model:sort-by="sortBy"
|
||||
v-model:view-mode="viewMode"
|
||||
v-model:media-type-filters="mediaTypeFilters"
|
||||
class="pb-1 px-2 2xl:px-4"
|
||||
:show-generation-time-sort="activeTab === 'output'"
|
||||
/>
|
||||
<Divider type="dashed" class="my-2" />
|
||||
<div
|
||||
v-if="isQueuePanelV2Enabled"
|
||||
class="flex items-center justify-between px-2 py-2 2xl:px-4"
|
||||
>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ activeJobsLabel }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-base-foreground">
|
||||
{{ t('sideToolbar.queueProgressOverlay.clearQueueTooltip') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="
|
||||
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
|
||||
"
|
||||
:disabled="queuedCount === 0"
|
||||
@click="handleClearQueue"
|
||||
>
|
||||
<i class="icon-[lucide--list-x] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<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="
|
||||
@@ -71,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',
|
||||
@@ -87,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>
|
||||
@@ -160,47 +191,71 @@
|
||||
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'
|
||||
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 } from '@/stores/queueStore'
|
||||
import { ResultItemImpl, useQueueStore } from '@/queue/stores/queueStore'
|
||||
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const queueStore = useQueueStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const activeTab = ref<'input' | 'output'>('output')
|
||||
const folderPromptId = ref<string | null>(null)
|
||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||
|
||||
// Track which asset's context menu is open (for single-instance context menu management)
|
||||
const openContextMenuId = ref<string | null>(null)
|
||||
const viewMode = ref<'list' | 'grid'>('grid')
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
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)
|
||||
@@ -209,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
|
||||
@@ -226,6 +289,15 @@ const formattedExecutionTime = computed(() => {
|
||||
return formatDuration(folderExecutionTime.value * 1000)
|
||||
})
|
||||
|
||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
||||
const activeJobsCount = computed(
|
||||
() => queueStore.pendingTasks.length + queueStore.runningTasks.length
|
||||
)
|
||||
const activeJobsLabel = computed(
|
||||
() =>
|
||||
`${activeJobsCount.value} ${t('sideToolbar.queueProgressOverlay.activeJobsSuffix')}`
|
||||
)
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const inputAssets = useMediaAssets('input')
|
||||
@@ -300,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(
|
||||
@@ -371,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)
|
||||
|
||||
@@ -490,6 +588,11 @@ const handleDeleteSelected = async () => {
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleClearQueue = async () => {
|
||||
if (queuedCount.value === 0) return
|
||||
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||
}
|
||||
|
||||
const handleApproachEnd = useDebounceFn(async () => {
|
||||
if (
|
||||
activeTab.value === 'output' &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { VariantProps } from 'cva'
|
||||
import { cva } from 'cva'
|
||||
|
||||
export const buttonVariants = cva({
|
||||
base: 'relative inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
base: 'inline-flex items-center justify-center gap-2 cursor-pointer whitespace-nowrap appearance-none border-none rounded-md text-sm font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
variants: {
|
||||
variant: {
|
||||
secondary:
|
||||
|
||||
@@ -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
|
||||
@@ -75,7 +73,6 @@ export interface VueNodeData {
|
||||
hasErrors?: boolean
|
||||
inputs?: INodeInputSlot[]
|
||||
outputs?: INodeOutputSlot[]
|
||||
resizable?: boolean
|
||||
shape?: number
|
||||
subgraphId?: string | null
|
||||
titleMode?: TitleMode
|
||||
@@ -174,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 {
|
||||
@@ -189,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),
|
||||
@@ -245,10 +236,17 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
})
|
||||
|
||||
// Update only widgets with new slot metadata, keeping other widget data intact
|
||||
for (const widget of currentData.widgets ?? []) {
|
||||
const updatedWidgets = currentData.widgets?.map((widget) => {
|
||||
const slotInfo = slotMetadata.get(widget.name)
|
||||
if (slotInfo) widget.slotMetadata = slotInfo
|
||||
}
|
||||
return slotInfo ? { ...widget, slotMetadata: slotInfo } : widget
|
||||
})
|
||||
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
widgets: updatedWidgets,
|
||||
inputs: nodeRef.inputs ? [...nodeRef.inputs] : undefined,
|
||||
outputs: nodeRef.outputs ? [...nodeRef.outputs] : undefined
|
||||
})
|
||||
}
|
||||
|
||||
// Extract safe data from LiteGraph node for Vue consumption
|
||||
@@ -319,7 +317,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
flags: node.flags ? { ...node.flags } : undefined,
|
||||
color: node.color || undefined,
|
||||
bgcolor: node.bgcolor || undefined,
|
||||
resizable: node.resizable,
|
||||
shape: node.shape
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1823,35 +1823,28 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
|
||||
const model = String(modelWidget.value)
|
||||
|
||||
if (model.includes('gemini-2.5-flash-preview-04-17')) {
|
||||
// Google Veo video generation
|
||||
if (model.includes('veo-2.0')) {
|
||||
return formatCreditsLabel(0.5, { suffix: '/second' })
|
||||
} else if (model.includes('gemini-2.5-flash-preview-04-17')) {
|
||||
return formatCreditsListLabel([0.0003, 0.0025], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gemini-2.5-flash')) {
|
||||
return formatCreditsListLabel([0.0003, 0.0025], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gemini-2.5-pro-preview-05-06')) {
|
||||
return formatCreditsListLabel([0.00125, 0.01], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gemini-2.5-pro')) {
|
||||
return formatCreditsListLabel([0.00125, 0.01], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gemini-3-pro-preview')) {
|
||||
return formatCreditsListLabel([0.002, 0.012], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
}
|
||||
// For other Gemini models, show token-based pricing info
|
||||
@@ -1906,75 +1899,51 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
// Specific pricing for exposed models based on official pricing data (converted to per 1K tokens)
|
||||
if (model.includes('o4-mini')) {
|
||||
return formatCreditsListLabel([0.0011, 0.0044], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('o1-pro')) {
|
||||
return formatCreditsListLabel([0.15, 0.6], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('o1')) {
|
||||
return formatCreditsListLabel([0.015, 0.06], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('o3-mini')) {
|
||||
return formatCreditsListLabel([0.0011, 0.0044], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('o3')) {
|
||||
return formatCreditsListLabel([0.01, 0.04], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gpt-4o')) {
|
||||
return formatCreditsListLabel([0.0025, 0.01], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gpt-4.1-nano')) {
|
||||
return formatCreditsListLabel([0.0001, 0.0004], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gpt-4.1-mini')) {
|
||||
return formatCreditsListLabel([0.0004, 0.0016], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gpt-4.1')) {
|
||||
return formatCreditsListLabel([0.002, 0.008], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gpt-5-nano')) {
|
||||
return formatCreditsListLabel([0.00005, 0.0004], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gpt-5-mini')) {
|
||||
return formatCreditsListLabel([0.00025, 0.002], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
} else if (model.includes('gpt-5')) {
|
||||
return formatCreditsListLabel([0.00125, 0.01], {
|
||||
suffix: ' per 1K tokens',
|
||||
approximate: true,
|
||||
separator: '-'
|
||||
suffix: ' per 1K tokens'
|
||||
})
|
||||
}
|
||||
return 'Token-based'
|
||||
@@ -2132,267 +2101,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
},
|
||||
LtxvApiImageToVideo: {
|
||||
displayPrice: ltxvPricingCalculator
|
||||
},
|
||||
WanReferenceVideoApi: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const sizeWidget = node.widgets?.find(
|
||||
(w) => w.name === 'size'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !sizeWidget) {
|
||||
return formatCreditsRangeLabel(0.7, 1.5, {
|
||||
note: '(varies with size & duration)'
|
||||
})
|
||||
}
|
||||
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
const sizeStr = String(sizeWidget.value).toLowerCase()
|
||||
|
||||
const rate = sizeStr.includes('1080p') ? 0.15 : 0.1
|
||||
const inputMin = 2 * rate
|
||||
const inputMax = 5 * rate
|
||||
const outputPrice = seconds * rate
|
||||
|
||||
const minTotal = inputMin + outputPrice
|
||||
const maxTotal = inputMax + outputPrice
|
||||
|
||||
return formatCreditsRangeLabel(minTotal, maxTotal)
|
||||
}
|
||||
},
|
||||
Vidu2TextToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !resolutionWidget) {
|
||||
return formatCreditsRangeLabel(0.075, 0.6, {
|
||||
note: '(varies with duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const duration = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget.value).toLowerCase()
|
||||
|
||||
// Text-to-Video uses Q2 model only
|
||||
// 720P: Starts at $0.075, +$0.025/sec
|
||||
// 1080P: Starts at $0.10, +$0.05/sec
|
||||
let basePrice: number
|
||||
let pricePerSecond: number
|
||||
|
||||
if (resolution.includes('1080')) {
|
||||
basePrice = 0.1
|
||||
pricePerSecond = 0.05
|
||||
} else {
|
||||
// 720P default
|
||||
basePrice = 0.075
|
||||
pricePerSecond = 0.025
|
||||
}
|
||||
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
return formatCreditsRangeLabel(0.075, 0.6, {
|
||||
note: '(varies with duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const cost = basePrice + pricePerSecond * (duration - 1)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
},
|
||||
Vidu2ImageToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model'
|
||||
) as IComboWidget
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !durationWidget || !resolutionWidget) {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value).toLowerCase()
|
||||
const duration = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget.value).toLowerCase()
|
||||
const is1080p = resolution.includes('1080')
|
||||
|
||||
let basePrice: number
|
||||
let pricePerSecond: number
|
||||
|
||||
if (model.includes('q2-pro-fast')) {
|
||||
// Q2-pro-fast: 720P $0.04+$0.01/sec, 1080P $0.08+$0.02/sec
|
||||
basePrice = is1080p ? 0.08 : 0.04
|
||||
pricePerSecond = is1080p ? 0.02 : 0.01
|
||||
} else if (model.includes('q2-pro')) {
|
||||
// Q2-pro: 720P $0.075+$0.05/sec, 1080P $0.275+$0.075/sec
|
||||
basePrice = is1080p ? 0.275 : 0.075
|
||||
pricePerSecond = is1080p ? 0.075 : 0.05
|
||||
} else if (model.includes('q2-turbo')) {
|
||||
// Q2-turbo: 720P special pricing, 1080P $0.175+$0.05/sec
|
||||
if (is1080p) {
|
||||
basePrice = 0.175
|
||||
pricePerSecond = 0.05
|
||||
} else {
|
||||
// 720P: $0.04 at 1s, $0.05 at 2s, +$0.05/sec beyond 2s
|
||||
if (duration <= 1) {
|
||||
return formatCreditsLabel(0.04)
|
||||
}
|
||||
if (duration <= 2) {
|
||||
return formatCreditsLabel(0.05)
|
||||
}
|
||||
const cost = 0.05 + 0.05 * (duration - 2)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
} else {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const cost = basePrice + pricePerSecond * (duration - 1)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
},
|
||||
Vidu2ReferenceVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
const audioWidget = node.widgets?.find(
|
||||
(w) => w.name === 'audio'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget) {
|
||||
return formatCreditsRangeLabel(0.125, 1.5, {
|
||||
note: '(varies with duration, resolution & audio)'
|
||||
})
|
||||
}
|
||||
|
||||
const duration = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget?.value ?? '').toLowerCase()
|
||||
const is1080p = resolution.includes('1080')
|
||||
|
||||
// Check if audio is enabled (adds $0.75)
|
||||
const audioValue = audioWidget?.value
|
||||
const hasAudio =
|
||||
audioValue !== undefined &&
|
||||
audioValue !== null &&
|
||||
String(audioValue).toLowerCase() !== 'false' &&
|
||||
String(audioValue).toLowerCase() !== 'none' &&
|
||||
audioValue !== ''
|
||||
|
||||
// Reference-to-Video uses Q2 model
|
||||
// 720P: Starts at $0.125, +$0.025/sec
|
||||
// 1080P: Starts at $0.375, +$0.05/sec
|
||||
let basePrice: number
|
||||
let pricePerSecond: number
|
||||
|
||||
if (is1080p) {
|
||||
basePrice = 0.375
|
||||
pricePerSecond = 0.05
|
||||
} else {
|
||||
// 720P default
|
||||
basePrice = 0.125
|
||||
pricePerSecond = 0.025
|
||||
}
|
||||
|
||||
let cost = basePrice
|
||||
if (Number.isFinite(duration) && duration > 0) {
|
||||
cost = basePrice + pricePerSecond * (duration - 1)
|
||||
}
|
||||
|
||||
// Audio adds $0.75 on top
|
||||
if (hasAudio) {
|
||||
cost += 0.075
|
||||
}
|
||||
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
},
|
||||
Vidu2StartEndToVideoNode: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model'
|
||||
) as IComboWidget
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!modelWidget || !durationWidget || !resolutionWidget) {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
const model = String(modelWidget.value).toLowerCase()
|
||||
const duration = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget.value).toLowerCase()
|
||||
const is1080p = resolution.includes('1080')
|
||||
|
||||
let basePrice: number
|
||||
let pricePerSecond: number
|
||||
|
||||
if (model.includes('q2-pro-fast')) {
|
||||
// Q2-pro-fast: 720P $0.04+$0.01/sec, 1080P $0.08+$0.02/sec
|
||||
basePrice = is1080p ? 0.08 : 0.04
|
||||
pricePerSecond = is1080p ? 0.02 : 0.01
|
||||
} else if (model.includes('q2-pro')) {
|
||||
// Q2-pro: 720P $0.075+$0.05/sec, 1080P $0.275+$0.075/sec
|
||||
basePrice = is1080p ? 0.275 : 0.075
|
||||
pricePerSecond = is1080p ? 0.075 : 0.05
|
||||
} else if (model.includes('q2-turbo')) {
|
||||
// Q2-turbo: 720P special pricing, 1080P $0.175+$0.05/sec
|
||||
if (is1080p) {
|
||||
basePrice = 0.175
|
||||
pricePerSecond = 0.05
|
||||
} else {
|
||||
// 720P: $0.04 at 1s, $0.05 at 2s, +$0.05/sec beyond 2s
|
||||
if (!Number.isFinite(duration) || duration <= 1) {
|
||||
return formatCreditsLabel(0.04)
|
||||
}
|
||||
if (duration <= 2) {
|
||||
return formatCreditsLabel(0.05)
|
||||
}
|
||||
const cost = 0.05 + 0.05 * (duration - 2)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
} else {
|
||||
return formatCreditsRangeLabel(0.04, 1.0, {
|
||||
note: '(varies with model, duration & resolution)'
|
||||
})
|
||||
}
|
||||
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
return formatCreditsLabel(basePrice)
|
||||
}
|
||||
|
||||
const cost = basePrice + pricePerSecond * (duration - 1)
|
||||
return formatCreditsLabel(cost)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2546,13 +2254,8 @@ export const useNodePricing = () => {
|
||||
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
|
||||
WanTextToVideoApi: ['duration', 'size'],
|
||||
WanImageToVideoApi: ['duration', 'resolution'],
|
||||
WanReferenceVideoApi: ['duration', 'size'],
|
||||
LtxvApiTextToVideo: ['model', 'duration', 'resolution'],
|
||||
LtxvApiImageToVideo: ['model', 'duration', 'resolution'],
|
||||
Vidu2TextToVideoNode: ['model', 'duration', 'resolution'],
|
||||
Vidu2ImageToVideoNode: ['model', 'duration', 'resolution'],
|
||||
Vidu2ReferenceVideoNode: ['audio', 'duration', 'resolution'],
|
||||
Vidu2StartEndToVideoNode: ['model', 'duration', 'resolution']
|
||||
LtxvApiImageToVideo: ['model', 'duration', 'resolution']
|
||||
}
|
||||
return widgetMap[nodeType] || []
|
||||
}
|
||||
|
||||
@@ -39,7 +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 } 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'
|
||||
@@ -63,7 +63,6 @@ import { useWorkflowTemplateSelectorDialog } from './useWorkflowTemplateSelector
|
||||
const { isActiveSubscription, showSubscriptionDialog } = useSubscription()
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
|
||||
export function useCoreCommands(): ComfyCommand[] {
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -75,6 +74,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
const executionStore = useExecutionStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { staticUrls, buildDocsUrl } = useExternalLink()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
|
||||
@@ -82,6 +82,14 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
useSelectedLiteGraphItems()
|
||||
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
|
||||
|
||||
function isQueuePanelV2Enabled() {
|
||||
return settingStore.get('Comfy.Queue.QPOV2')
|
||||
}
|
||||
|
||||
async function toggleQueuePanelV2() {
|
||||
await settingStore.set('Comfy.Queue.QPOV2', !isQueuePanelV2Enabled())
|
||||
}
|
||||
|
||||
const moveSelectedNodes = (
|
||||
positionUpdater: (pos: Point, gridSize: number) => Point
|
||||
) => {
|
||||
@@ -1175,6 +1183,12 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
await useWorkflowService().reloadCurrentWorkflow() // ensure changes take effect immediately
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ToggleQPOV2',
|
||||
icon: 'pi pi-list',
|
||||
label: 'Toggle Queue Panel V2',
|
||||
function: toggleQueuePanelV2
|
||||
},
|
||||
{
|
||||
id: 'Comfy.ToggleLinear',
|
||||
icon: 'pi pi-database',
|
||||
|
||||
@@ -94,7 +94,7 @@ function dynamicComboWidget(
|
||||
const newSpec = value ? options[value] : undefined
|
||||
|
||||
const removedInputs = remove(node.inputs, isInGroup)
|
||||
for (const widget of remove(node.widgets, isInGroup)) widget.onRemove?.()
|
||||
remove(node.widgets, isInGroup)
|
||||
|
||||
if (!newSpec) return
|
||||
|
||||
@@ -341,16 +341,10 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
|
||||
//TODO: instead apply on output add?
|
||||
//ensure outputs get updated
|
||||
const index = node.inputs.length - 1
|
||||
requestAnimationFrame(() => {
|
||||
const input = node.inputs.at(index)!
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
index,
|
||||
!!input.link,
|
||||
input.link ? node.graph?.links?.[input.link] : undefined,
|
||||
input
|
||||
)
|
||||
})
|
||||
const input = node.inputs.at(-1)!
|
||||
requestAnimationFrame(() =>
|
||||
node.onConnectionsChange(LiteGraph.INPUT, index, false, undefined, input)
|
||||
)
|
||||
}
|
||||
|
||||
function autogrowOrdinalToName(
|
||||
@@ -488,8 +482,7 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
||||
for (const input of toRemove) {
|
||||
const widgetName = input?.widget?.name
|
||||
if (!widgetName) continue
|
||||
for (const widget of remove(node.widgets, (w) => w.name === widgetName))
|
||||
widget.onRemove?.()
|
||||
remove(node.widgets, (w) => w.name === widgetName)
|
||||
}
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -160,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
|
||||
@@ -207,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
|
||||
|
||||
@@ -28,7 +28,6 @@ import type {
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { AssetWidget } from '@/lib/litegraph/src/widgets/AssetWidget'
|
||||
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
||||
|
||||
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
|
||||
@@ -334,8 +333,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
const promotedWidget = toConcreteWidget(widget, this).createCopyForNode(
|
||||
this
|
||||
)
|
||||
if (widget instanceof AssetWidget)
|
||||
promotedWidget.options.nodeType ??= widget.node.type
|
||||
|
||||
Object.assign(promotedWidget, {
|
||||
get name() {
|
||||
|
||||
@@ -27,8 +27,6 @@ export interface IWidgetOptions<TValues = unknown[]> {
|
||||
socketless?: boolean
|
||||
/** If `true`, the widget will not be rendered by the Vue renderer. */
|
||||
canvasOnly?: boolean
|
||||
/** Used as a temporary override for determining the asset type in vue mode*/
|
||||
nodeType?: string
|
||||
|
||||
values?: TValues
|
||||
/** Optional function to format values for display (e.g., hash → human-readable name) */
|
||||
|
||||
@@ -260,6 +260,9 @@
|
||||
"Comfy_ToggleLinear": {
|
||||
"label": "toggle linear mode"
|
||||
},
|
||||
"Comfy_ToggleQPOV2": {
|
||||
"label": "Toggle Queue Panel V2"
|
||||
},
|
||||
"Comfy_ToggleTheme": {
|
||||
"label": "Toggle Theme (Dark/Light)"
|
||||
},
|
||||
@@ -324,4 +327,4 @@
|
||||
"label": "Toggle Workflows Sidebar",
|
||||
"tooltip": "Workflows"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,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",
|
||||
@@ -718,6 +719,8 @@
|
||||
"colonPercent": ": {percent}",
|
||||
"currentNode": "Current node:",
|
||||
"viewAllJobs": "View all jobs",
|
||||
"viewList": "List view",
|
||||
"viewGrid": "Grid view",
|
||||
"running": "running",
|
||||
"preview": "Preview",
|
||||
"interruptAll": "Interrupt all running jobs",
|
||||
@@ -2449,4 +2452,4 @@
|
||||
"recentReleases": "Recent releases",
|
||||
"helpCenterMenu": "Help Center Menu"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,7 +519,7 @@
|
||||
}
|
||||
},
|
||||
"ByteDanceSeedreamNode": {
|
||||
"display_name": "ByteDance Seedream 4",
|
||||
"display_name": "ByteDance Seedream 4.5",
|
||||
"description": "Unified text-to-image generation and precise single-sentence editing at up to 4K resolution.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
|
||||
133
src/platform/assets/components/AssetsListCard.stories.ts
Normal file
133
src/platform/assets/components/AssetsListCard.stories.ts
Normal 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>
|
||||
`
|
||||
}
|
||||
}
|
||||
102
src/platform/assets/components/AssetsListCard.vue
Normal file
102
src/platform/assets/components/AssetsListCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -31,18 +31,26 @@
|
||||
/>
|
||||
</template>
|
||||
</AssetSortButton>
|
||||
<MediaAssetViewModeToggle
|
||||
v-if="isQueuePanelV2Enabled"
|
||||
v-model:view-mode="viewMode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
import MediaAssetFilterButton from './MediaAssetFilterButton.vue'
|
||||
import MediaAssetFilterMenu from './MediaAssetFilterMenu.vue'
|
||||
import AssetSortButton from './MediaAssetSortButton.vue'
|
||||
import MediaAssetSortMenu from './MediaAssetSortMenu.vue'
|
||||
import type { SortBy } from './MediaAssetSortMenu.vue'
|
||||
import MediaAssetViewModeToggle from './MediaAssetViewModeToggle.vue'
|
||||
|
||||
const { showGenerationTimeSort = false } = defineProps<{
|
||||
searchQuery: string
|
||||
@@ -56,6 +64,12 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const sortBy = defineModel<SortBy>('sortBy', { required: true })
|
||||
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const isQueuePanelV2Enabled = computed(() =>
|
||||
settingStore.get('Comfy.Queue.QPOV2')
|
||||
)
|
||||
|
||||
const handleSearchChange = (value: string | undefined) => {
|
||||
emit('update:searchQuery', value ?? '')
|
||||
|
||||
54
src/platform/assets/components/MediaAssetViewModeToggle.vue
Normal file
54
src/platform/assets/components/MediaAssetViewModeToggle.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 rounded-lg bg-secondary-background p-1"
|
||||
role="group"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.viewList')"
|
||||
:aria-pressed="viewMode === 'list'"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg',
|
||||
viewMode === 'list'
|
||||
? 'bg-secondary-background-selected text-text-primary hover:bg-secondary-background-selected'
|
||||
: 'text-text-secondary hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<i class="icon-[lucide--table-of-contents] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.viewGrid')"
|
||||
:aria-pressed="viewMode === 'grid'"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg',
|
||||
viewMode === 'grid'
|
||||
? 'bg-secondary-background-selected text-text-primary hover:bg-secondary-background-selected'
|
||||
: 'text-text-secondary hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
@click="viewMode = 'grid'"
|
||||
>
|
||||
<i class="icon-[lucide--layout-grid] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { inject } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkflowActionsService } from '@/platform/workflow/core/services/workflowActionsService'
|
||||
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
@@ -25,6 +25,7 @@ import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import { assetService } from '../services/assetService'
|
||||
|
||||
export function useMediaAssetActions() {
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const mediaContext = inject(MediaAssetKey, null)
|
||||
@@ -79,7 +80,7 @@ export function useMediaAssetActions() {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('g.downloadStarted'),
|
||||
detail: t('mediaAsset.selection.downloadsStarted', { count: 1 }),
|
||||
life: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
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', () => ({
|
||||
ResultItemImpl: vi.fn().mockImplementation((data) => ({
|
||||
...data,
|
||||
url: ''
|
||||
}))
|
||||
vi.mock('@/queue/stores/queueStore', () => ({
|
||||
ResultItemImpl: vi
|
||||
.fn<typeof ResultItemImpl>()
|
||||
.mockImplementation(function (data) {
|
||||
Object.assign(this, {
|
||||
...data,
|
||||
url: ''
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useMediaAssetGalleryStore', () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@ interface ModelTypeOption {
|
||||
value: string // Actual tag value
|
||||
}
|
||||
|
||||
const DISALLOWED_MODEL_TYPES = ['nlf'] as const
|
||||
|
||||
/**
|
||||
* Composable for fetching and managing model types from the API
|
||||
* Uses shared state to ensure data is only fetched once
|
||||
@@ -51,6 +53,12 @@ export const useModelTypes = createSharedComposable(() => {
|
||||
async (): Promise<ModelTypeOption[]> => {
|
||||
const response = await api.getModelFolders()
|
||||
return response
|
||||
.filter(
|
||||
(folder) =>
|
||||
!DISALLOWED_MODEL_TYPES.includes(
|
||||
folder.name as (typeof DISALLOWED_MODEL_TYPES)[number]
|
||||
)
|
||||
)
|
||||
.map((folder) => ({
|
||||
name: formatDisplayName(folder.name),
|
||||
value: folder.name
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1139,5 +1139,13 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
type: 'hidden',
|
||||
defaultValue: false,
|
||||
versionAdded: '1.34.1'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Queue.QPOV2',
|
||||
name: 'Queue Panel V2',
|
||||
type: 'hidden',
|
||||
tooltip: 'Enable the new Assets Panel design with list/grid view toggle',
|
||||
defaultValue: false,
|
||||
experimental: true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
@@ -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 }>()
|
||||
|
||||
@@ -79,9 +79,9 @@ import type {
|
||||
JobListItem,
|
||||
JobSortMode,
|
||||
JobTab
|
||||
} from '@/composables/queue/useJobList'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
} from '@/queue/composables/useJobList'
|
||||
import type { MenuEntry } from '@/queue/composables/useJobMenu'
|
||||
import { useJobMenu } from '@/queue/composables/useJobMenu'
|
||||
|
||||
import QueueOverlayHeader from './QueueOverlayHeader.vue'
|
||||
import JobContextMenu from './job/JobContextMenu.vue'
|
||||
@@ -63,16 +63,16 @@
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
|
||||
import QueueOverlayEmpty from '@/components/queue/QueueOverlayEmpty.vue'
|
||||
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
|
||||
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
|
||||
import QueueOverlayActive from '@/queue/components/QueueOverlayActive.vue'
|
||||
import QueueOverlayEmpty from '@/queue/components/QueueOverlayEmpty.vue'
|
||||
import QueueOverlayExpanded from '@/queue/components/QueueOverlayExpanded.vue'
|
||||
import QueueClearHistoryDialog from '@/queue/components/dialogs/QueueClearHistoryDialog.vue'
|
||||
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
|
||||
import { useCompletionSummary } from '@/composables/queue/useCompletionSummary'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||
import { useCompletionSummary } from '@/queue/composables/useCompletionSummary'
|
||||
import { useJobList } from '@/queue/composables/useJobList'
|
||||
import type { JobListItem } from '@/queue/composables/useJobList'
|
||||
import { useQueueProgress } from '@/queue/composables/useQueueProgress'
|
||||
import { useResultGallery } from '@/queue/composables/useResultGallery'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
@@ -81,7 +81,7 @@ import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useQueueStore } from '@/queue/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
type OverlayState = 'hidden' | 'empty' | 'active' | 'expanded'
|
||||
@@ -53,7 +53,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useQueueStore } from '@/queue/stores/queueStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
const queueStore = useQueueStore()
|
||||
@@ -47,7 +47,7 @@ import Popover from 'primevue/popover'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
import type { MenuEntry } from '@/queue/composables/useJobMenu'
|
||||
|
||||
defineProps<{ entries: MenuEntry[] }>()
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import type { TaskStatus } from '@/schemas/apiSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { TaskItemImpl, useQueueStore } from '@/queue/stores/queueStore'
|
||||
|
||||
import JobDetailsPopover from './JobDetailsPopover.vue'
|
||||
|
||||
@@ -101,10 +101,10 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import { useQueueStore } from '@/queue/stores/queueStore'
|
||||
import type { TaskItemImpl } from '@/queue/stores/queueStore'
|
||||
import { formatClockTime } from '@/utils/dateTimeUtil'
|
||||
import { jobStateFromTask } from '@/utils/queueUtil'
|
||||
import { jobStateFromTask } from '@/queue/utils/queueUtil'
|
||||
|
||||
import { useJobErrorReporting } from './useJobErrorReporting'
|
||||
import { formatElapsedTime, useQueueEstimates } from './useQueueEstimates'
|
||||
@@ -137,8 +137,8 @@ import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { jobSortModes, jobTabs } from '@/composables/queue/useJobList'
|
||||
import type { JobSortMode, JobTab } from '@/composables/queue/useJobList'
|
||||
import { jobSortModes, jobTabs } from '@/queue/composables/useJobList'
|
||||
import type { JobSortMode, JobTab } from '@/queue/composables/useJobList'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import QueueJobItem from '@/components/queue/job/QueueJobItem.vue'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import QueueJobItem from '@/queue/components/job/QueueJobItem.vue'
|
||||
import type { JobGroup, JobListItem } from '@/queue/composables/useJobList'
|
||||
|
||||
const props = defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
|
||||
@@ -198,12 +198,12 @@
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
|
||||
import JobDetailsPopover from '@/queue/components/job/JobDetailsPopover.vue'
|
||||
import QueueAssetPreview from '@/queue/components/job/QueueAssetPreview.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import type { JobState } from '@/queue/types/queue'
|
||||
import { iconForJobState } from '@/queue/utils/queueDisplay'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -2,7 +2,7 @@ import { computed } from 'vue'
|
||||
import type { ComputedRef } from 'vue'
|
||||
|
||||
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { TaskItemImpl } from '@/queue/stores/queueStore'
|
||||
|
||||
type CopyHandler = (value: string) => void | Promise<void>
|
||||
|
||||
@@ -40,7 +40,7 @@ export const extractExecutionError = (
|
||||
}
|
||||
}
|
||||
|
||||
type UseJobErrorReportingOptions = {
|
||||
export type UseJobErrorReportingOptions = {
|
||||
taskForJob: ComputedRef<TaskItemImpl | null>
|
||||
copyToClipboard: CopyHandler
|
||||
dialog: JobErrorDialogService
|
||||
@@ -1,8 +1,8 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import type { TaskItemImpl } from '@/queue/stores/queueStore'
|
||||
import type { JobState } from '@/queue/types/queue'
|
||||
|
||||
import { formatElapsedTime, useQueueEstimates } from './useQueueEstimates'
|
||||
import type { UseQueueEstimatesOptions } from './useQueueEstimates'
|
||||
@@ -2,8 +2,8 @@ import { computed } from 'vue'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { useExecutionStore } from '@/stores/executionStore'
|
||||
import type { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import type { TaskItemImpl, useQueueStore } from '@/queue/stores/queueStore'
|
||||
import type { JobState } from '@/queue/types/queue'
|
||||
|
||||
type QueueStore = ReturnType<typeof useQueueStore>
|
||||
type ExecutionStore = ReturnType<typeof useExecutionStore>
|
||||
@@ -1,8 +1,8 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { jobStateFromTask } from '@/utils/queueUtil'
|
||||
import { useQueueStore } from '@/queue/stores/queueStore'
|
||||
import { jobStateFromTask } from '@/queue/utils/queueUtil'
|
||||
|
||||
export type CompletionSummaryMode = 'allSuccess' | 'mixed' | 'allFailed'
|
||||
|
||||
56
src/queue/composables/useJobActions.ts
Normal file
56
src/queue/composables/useJobActions.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { JobListItem } from '@/queue/composables/useJobList'
|
||||
import { useJobMenu } from '@/queue/composables/useJobMenu'
|
||||
import type { JobState } from '@/queue/types/queue'
|
||||
|
||||
type JobActionKey = 'cancel'
|
||||
|
||||
export type JobAction = {
|
||||
key: JobActionKey
|
||||
icon: string
|
||||
label: string
|
||||
variant: 'destructive' | 'secondary' | 'textonly'
|
||||
}
|
||||
|
||||
export function useJobActions() {
|
||||
const { t } = useI18n()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
const currentJob = ref<JobListItem | null>(null)
|
||||
const { cancelJob } = useJobMenu(() => currentJob.value)
|
||||
|
||||
const jobActionSets = computed<Partial<Record<JobState, JobAction[]>>>(() => {
|
||||
const cancelAction: JobAction = {
|
||||
key: 'cancel',
|
||||
icon: 'icon-[lucide--x]',
|
||||
label: t('sideToolbar.queueProgressOverlay.cancelJobTooltip'),
|
||||
variant: 'destructive'
|
||||
}
|
||||
|
||||
return {
|
||||
pending: [cancelAction],
|
||||
initialization: [cancelAction],
|
||||
running: [cancelAction]
|
||||
}
|
||||
})
|
||||
|
||||
const getJobActions = (job: JobListItem): JobAction[] =>
|
||||
job.showClear === false ? [] : (jobActionSets.value[job.state] ?? [])
|
||||
|
||||
const runJobAction = wrapWithErrorHandlingAsync(
|
||||
async (action: JobAction, job: JobListItem) => {
|
||||
currentJob.value = job
|
||||
|
||||
if (action.key === 'cancel') {
|
||||
await cancelJob()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
getJobActions,
|
||||
runJobAction
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useQueueProgress } from '@/queue/composables/useQueueProgress'
|
||||
import { st } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import { useQueueStore } from '@/queue/stores/queueStore'
|
||||
import type { TaskItemImpl } from '@/queue/stores/queueStore'
|
||||
import type { JobState } from '@/queue/types/queue'
|
||||
import {
|
||||
dateKey,
|
||||
formatClockTime,
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
isYesterday
|
||||
} from '@/utils/dateTimeUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { buildJobDisplay } from '@/utils/queueDisplay'
|
||||
import { jobStateFromTask } from '@/utils/queueUtil'
|
||||
import { buildJobDisplay } from '@/queue/utils/queueDisplay'
|
||||
import { jobStateFromTask } from '@/queue/utils/queueUtil'
|
||||
|
||||
/** Tabs for job list filtering */
|
||||
export const jobTabs = ['All', 'Completed', 'Failed'] as const
|
||||
@@ -1,7 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem } from '@/queue/composables/useJobList'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { st, t } from '@/i18n'
|
||||
import { mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/assetMappers'
|
||||
@@ -19,8 +19,8 @@ import { downloadBlob } from '@/scripts/utils'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
import { useQueueStore } from '@/queue/stores/queueStore'
|
||||
import type { ResultItemImpl, TaskItemImpl } from '@/queue/stores/queueStore'
|
||||
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref, shallowRef } from 'vue'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import type { JobListItem } from '@/queue/composables/useJobList'
|
||||
import type { ResultItemImpl } from '@/queue/stores/queueStore'
|
||||
|
||||
/**
|
||||
* Manages result gallery state and activation for queue items.
|
||||
@@ -3,7 +3,7 @@ import { app } from '@/scripts/app'
|
||||
import {
|
||||
useQueuePendingTaskCountStore,
|
||||
useQueueSettingsStore
|
||||
} from '@/stores/queueStore'
|
||||
} from '@/queue/stores/queueStore'
|
||||
|
||||
export function setupAutoQueueHandler() {
|
||||
const queueCountStore = useQueuePendingTaskCountStore()
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import type { TaskItemImpl } from '@/queue/stores/queueStore'
|
||||
import type { JobState } from '@/queue/types/queue'
|
||||
import { formatDuration } from '@/utils/formatUtil'
|
||||
import { clampPercentInt, formatPercent0 } from '@/utils/numberUtil'
|
||||
|
||||
type BuildJobDisplayCtx = {
|
||||
export type BuildJobDisplayCtx = {
|
||||
t: (k: string, v?: Record<string, any>) => string
|
||||
locale: string
|
||||
formatClockTimeFn: (ts: number, locale: string) => string
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { JobState } from '@/types/queue'
|
||||
import type { TaskItemImpl } from '@/queue/stores/queueStore'
|
||||
import type { JobState } from '@/queue/types/queue'
|
||||
|
||||
/**
|
||||
* Map a task to a UI job state, including initialization override.
|
||||
@@ -99,10 +99,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<!-- Multiple Images Navigation -->
|
||||
<div
|
||||
v-if="hasMultipleImages"
|
||||
class="flex flex-wrap justify-center gap-1 pt-4"
|
||||
>
|
||||
<div v-if="hasMultipleImages" class="flex justify-center gap-1 pt-4">
|
||||
<button
|
||||
v-for="(_, index) in imageUrls"
|
||||
:key="index"
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
|
||||
<!-- Resize handle (bottom-right only) -->
|
||||
<div
|
||||
v-if="!isCollapsed && nodeData.resizable !== false"
|
||||
v-if="!isCollapsed"
|
||||
role="button"
|
||||
:aria-label="t('g.resizeFromBottomRight')"
|
||||
:class="cn(baseResizeHandleClasses, 'right-0 bottom-0 cursor-se-resize')"
|
||||
@@ -341,7 +341,6 @@ const handleResizePointerDown = (event: PointerEvent) => {
|
||||
if (event.button !== 0) return
|
||||
if (!shouldHandleNodePointerEvents.value) return
|
||||
if (nodeData.flags?.pinned) return
|
||||
if (nodeData.resizable === false) return
|
||||
startResize(event)
|
||||
}
|
||||
|
||||
|
||||
@@ -203,13 +203,10 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
})
|
||||
|
||||
const gridTemplateRows = computed((): string => {
|
||||
if (!nodeData?.widgets) return ''
|
||||
const processedNames = new Set(toValue(processedWidgets).map((w) => w.name))
|
||||
return nodeData.widgets
|
||||
.filter((w) => processedNames.has(w.name) && !w.options?.hidden)
|
||||
.map((w) =>
|
||||
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
|
||||
)
|
||||
const widgets = toValue(processedWidgets)
|
||||
return widgets
|
||||
.filter((w) => !w.simplified.options?.hidden)
|
||||
.map((w) => (shouldExpand(w.type) ? 'auto' : 'min-content'))
|
||||
.join(' ')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -133,7 +133,7 @@ const createMouseEvent = (
|
||||
|
||||
describe('useNodePointerInteractions', () => {
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.resetAllMocks()
|
||||
selectedItemsState.items = []
|
||||
setActivePinia(createTestingPinia())
|
||||
})
|
||||
|
||||
@@ -31,17 +31,10 @@ const precision = computed(() => {
|
||||
|
||||
// Calculate the step value based on precision or widget options
|
||||
const stepValue = computed(() => {
|
||||
// Use step2 (correct input spec value) if available
|
||||
// Use step2 (correct input spec value) instead of step (legacy 10x value)
|
||||
if (props.widget.options?.step2 !== undefined) {
|
||||
return Number(props.widget.options.step2)
|
||||
}
|
||||
// Use step / 10 for custom large step values (> 10) to match litegraph behavior
|
||||
// This is important for extensions like Impact Pack that use custom step values (e.g., 640)
|
||||
// We skip default step values (1, 10) to avoid affecting normal widgets
|
||||
const step = props.widget.options?.step
|
||||
if (step !== undefined && step > 10) {
|
||||
return Number(step) / 10
|
||||
}
|
||||
// Otherwise, derive from precision
|
||||
if (precision.value !== undefined) {
|
||||
if (precision.value === 0) {
|
||||
|
||||
@@ -17,7 +17,6 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const canvasEl = ref()
|
||||
const containerHeight = ref(20)
|
||||
|
||||
const canvas: LGraphCanvas = useCanvasStore().canvas as LGraphCanvas
|
||||
let node: LGraphNode | undefined
|
||||
@@ -53,19 +52,9 @@ onBeforeUnmount(() => {
|
||||
function draw() {
|
||||
if (!widgetInstance || !node) return
|
||||
const width = canvasEl.value.parentElement.clientWidth
|
||||
// Priority: computedHeight (from litegraph) > computeLayoutSize > computeSize
|
||||
let height = 20
|
||||
if (widgetInstance.computedHeight) {
|
||||
height = widgetInstance.computedHeight
|
||||
} else if (widgetInstance.computeLayoutSize) {
|
||||
height = widgetInstance.computeLayoutSize(node).minHeight
|
||||
} else if (widgetInstance.computeSize) {
|
||||
height = widgetInstance.computeSize(width)[1]
|
||||
}
|
||||
containerHeight.value = height
|
||||
// Set node.canvasHeight for legacy widgets that use it (e.g., Impact Pack)
|
||||
// @ts-expect-error canvasHeight is a custom property used by some extensions
|
||||
node.canvasHeight = height
|
||||
const height = widgetInstance.computeSize
|
||||
? widgetInstance.computeSize(width)[1]
|
||||
: 20
|
||||
widgetInstance.y = 0
|
||||
canvasEl.value.height = (height + 2) * scaleFactor
|
||||
canvasEl.value.width = width * scaleFactor
|
||||
@@ -98,13 +87,10 @@ function handleMove(e: PointerEvent) {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="relative mx-[-12px] min-w-0 basis-0"
|
||||
:style="{ minHeight: `${containerHeight}px` }"
|
||||
>
|
||||
<div class="relative mx-[-12px] min-w-0 basis-0">
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
class="absolute w-full cursor-crosshair"
|
||||
class="absolute mt-[-13px] w-full cursor-crosshair"
|
||||
@pointerdown="handleDown"
|
||||
@pointerup="handleUp"
|
||||
@pointermove="handleMove"
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/compo
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useQueueStore } from '@/queue/stores/queueStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import type { AssetKind } from '@/types/widgetTypes'
|
||||
import {
|
||||
@@ -60,9 +60,8 @@ const combinedProps = computed(() => ({
|
||||
}))
|
||||
|
||||
const getAssetData = () => {
|
||||
const nodeType = props.widget.options?.nodeType ?? props.nodeType
|
||||
if (props.isAssetMode && nodeType) {
|
||||
return useAssetWidgetData(toRef(nodeType))
|
||||
if (props.isAssetMode && props.nodeType) {
|
||||
return useAssetWidgetData(toRef(() => props.nodeType))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -368,8 +368,7 @@ export const useImagePreviewWidget = () => {
|
||||
) => {
|
||||
return node.addCustomWidget(
|
||||
new ImagePreviewWidget(node, inputSpec.name, {
|
||||
serialize: false,
|
||||
canvasOnly: true
|
||||
serialize: false
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
interface NumberWidgetOptions {
|
||||
step?: number
|
||||
step2?: number
|
||||
precision?: number
|
||||
}
|
||||
@@ -18,17 +17,10 @@ export function useNumberStepCalculation(
|
||||
) {
|
||||
return computed(() => {
|
||||
const precision = toValue(precisionArg)
|
||||
// Use step2 (correct input spec value) if available
|
||||
// Use step2 (correct input spec value) instead of step (legacy 10x value)
|
||||
if (options?.step2 !== undefined) {
|
||||
return Number(options.step2)
|
||||
}
|
||||
// Use step / 10 for custom large step values (> 10) to match litegraph behavior
|
||||
// This is important for extensions like Impact Pack that use custom step values (e.g., 640)
|
||||
// We skip default step values (1, 10) to avoid affecting normal widgets
|
||||
const step = options?.step
|
||||
if (step !== undefined && step > 10) {
|
||||
return Number(step) / 10
|
||||
}
|
||||
|
||||
if (precision === undefined) {
|
||||
return returnUndefinedForDefault ? undefined : 0
|
||||
@@ -37,9 +29,7 @@ export function useNumberStepCalculation(
|
||||
if (precision === 0) return 1
|
||||
|
||||
// For precision > 0, step = 1 / (10^precision)
|
||||
const calculatedStep = 1 / Math.pow(10, precision)
|
||||
return returnUndefinedForDefault
|
||||
? calculatedStep
|
||||
: Number(calculatedStep.toFixed(precision))
|
||||
const step = 1 / Math.pow(10, precision)
|
||||
return returnUndefinedForDefault ? step : Number(step.toFixed(precision))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -491,6 +491,7 @@ const zSettings = z.object({
|
||||
'Comfy.VueNodes.Enabled': z.boolean(),
|
||||
'Comfy.VueNodes.AutoScaleLayout': z.boolean(),
|
||||
'Comfy.Assets.UseAssetAPI': z.boolean(),
|
||||
'Comfy.Queue.QPOV2': z.boolean(),
|
||||
'Comfy-Desktop.AutoUpdate': z.boolean(),
|
||||
'Comfy-Desktop.SendStatistics': z.boolean(),
|
||||
'Comfy-Desktop.WindowStyle': z.string(),
|
||||
|
||||
@@ -130,9 +130,6 @@ The following table lists ALL 46 store instances in the system as of 2025-09-01:
|
||||
| nodeBookmarkStore.ts | useNodeBookmarkStore | Manages node bookmarks and favorites | Nodes |
|
||||
| nodeDefStore.ts | useNodeDefStore | Manages node definitions and schemas | Nodes |
|
||||
| nodeDefStore.ts | useNodeFrequencyStore | Tracks node usage frequency | Nodes |
|
||||
| queueStore.ts | useQueueStore | Manages execution queue and task history | Execution |
|
||||
| queueStore.ts | useQueuePendingTaskCountStore | Tracks pending task counts | Execution |
|
||||
| queueStore.ts | useQueueSettingsStore | Manages queue execution settings | Execution |
|
||||
| releaseStore.ts | useReleaseStore | Manages application release information | System |
|
||||
| serverConfigStore.ts | useServerConfigStore | Handles server configuration | Config |
|
||||
| settingStore.ts | useSettingStore | Manages application settings | Config |
|
||||
@@ -148,6 +145,8 @@ The following table lists ALL 46 store instances in the system as of 2025-09-01:
|
||||
| workflowTemplatesStore.ts | useWorkflowTemplatesStore | Manages workflow templates | Workflows |
|
||||
| workspaceStore.ts | useWorkspaceStore | Manages overall workspace state | Workspace |
|
||||
|
||||
Note: queue stores live in `src/queue/stores/queueStore.ts`.
|
||||
|
||||
### Workspace Stores
|
||||
Located in `stores/workspace/`:
|
||||
|
||||
@@ -386,4 +385,4 @@ describe('useExampleStore', () => {
|
||||
})
|
||||
```
|
||||
|
||||
For more information on Pinia, refer to the [Pinia documentation](https://pinia.vuejs.org/introduction.html).
|
||||
For more information on Pinia, refer to the [Pinia documentation](https://pinia.vuejs.org/introduction.html).
|
||||
|
||||
@@ -11,7 +11,7 @@ import { isCloud } from '@/platform/distribution/types'
|
||||
import type { TaskItem } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import { TaskItemImpl } from './queueStore'
|
||||
import { TaskItemImpl } from '@/queue/stores/queueStore'
|
||||
|
||||
const INPUT_LIMIT = 100
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { SidebarTabExtension, ToastManager } from '@/types/extensionTypes'
|
||||
import { useApiKeyAuthStore } from './apiKeyAuthStore'
|
||||
import { useCommandStore } from './commandStore'
|
||||
import { useFirebaseAuthStore } from './firebaseAuthStore'
|
||||
import { useQueueSettingsStore } from './queueStore'
|
||||
import { useQueueSettingsStore } from '@/queue/stores/queueStore'
|
||||
import { useBottomPanelStore } from './workspace/bottomPanelStore'
|
||||
import { useSidebarTabStore } from './workspace/sidebarTabStore'
|
||||
|
||||
|
||||
26
src/storybook/mocks/useJobActions.ts
Normal file
26
src/storybook/mocks/useJobActions.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { JobAction } from '@/queue/composables/useJobActions'
|
||||
import type { JobListItem } from '@/queue/composables/useJobList'
|
||||
|
||||
const actionsByJobId = ref<Record<string, JobAction[]>>({})
|
||||
|
||||
export function setMockJobActions(actions: Record<string, JobAction[]>) {
|
||||
actionsByJobId.value = actions
|
||||
}
|
||||
|
||||
/** @knipIgnoreUnusedButUsedByStorybook */
|
||||
export function useJobActions() {
|
||||
function getJobActions(job: JobListItem) {
|
||||
return actionsByJobId.value[job.id] ?? []
|
||||
}
|
||||
|
||||
async function runJobAction() {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
getJobActions,
|
||||
runJobAction
|
||||
}
|
||||
}
|
||||
59
src/storybook/mocks/useJobList.ts
Normal file
59
src/storybook/mocks/useJobList.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { TaskItemImpl } from '@/queue/stores/queueStore'
|
||||
import type {
|
||||
JobGroup,
|
||||
JobListItem,
|
||||
JobSortMode,
|
||||
JobTab
|
||||
} from '@/queue/composables/useJobList'
|
||||
|
||||
const jobItems = ref<JobListItem[]>([])
|
||||
|
||||
function buildGroupedJobItems(): JobGroup[] {
|
||||
return [
|
||||
{
|
||||
key: 'storybook',
|
||||
label: 'Storybook',
|
||||
items: jobItems.value
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const groupedJobItems = computed<JobGroup[]>(buildGroupedJobItems)
|
||||
|
||||
const selectedJobTab = ref<JobTab>('All')
|
||||
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
|
||||
const selectedSortMode = ref<JobSortMode>('mostRecent')
|
||||
const currentNodeName = ref('KSampler')
|
||||
function buildEmptyTasks(): TaskItemImpl[] {
|
||||
return []
|
||||
}
|
||||
|
||||
const allTasksSorted = computed<TaskItemImpl[]>(buildEmptyTasks)
|
||||
const filteredTasks = computed<TaskItemImpl[]>(buildEmptyTasks)
|
||||
|
||||
function buildHasFailedJobs() {
|
||||
return jobItems.value.some((item) => item.state === 'failed')
|
||||
}
|
||||
|
||||
const hasFailedJobs = computed(buildHasFailedJobs)
|
||||
|
||||
export function setMockJobItems(items: JobListItem[]) {
|
||||
jobItems.value = items
|
||||
}
|
||||
|
||||
/** @knipIgnoreUnusedButUsedByStorybook */
|
||||
export function useJobList() {
|
||||
return {
|
||||
selectedJobTab,
|
||||
selectedWorkflowFilter,
|
||||
selectedSortMode,
|
||||
hasFailedJobs,
|
||||
allTasksSorted,
|
||||
filteredTasks,
|
||||
jobItems,
|
||||
groupedJobItems,
|
||||
currentNodeName
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { StatusWsMessageStatus } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { setupAutoQueueHandler } from '@/services/autoQueueService'
|
||||
import { setupAutoQueueHandler } from '@/queue/services/autoQueueService'
|
||||
import { useKeybindingService } from '@/services/keybindingService'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -70,7 +70,7 @@ import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
useQueuePendingTaskCountStore,
|
||||
useQueueStore
|
||||
} from '@/stores/queueStore'
|
||||
} from '@/queue/stores/queueStore'
|
||||
import { useServerConfigStore } from '@/stores/serverConfigStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
@@ -26,7 +26,7 @@ import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/compo
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { useQueueSettingsStore } from '@/queue/stores/queueStore'
|
||||
import { isElectron } from '@/utils/envUtil'
|
||||
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
|
||||
@@ -24,7 +24,9 @@ describe('API Feature Flags', () => {
|
||||
}
|
||||
|
||||
// Mock WebSocket constructor
|
||||
global.WebSocket = vi.fn().mockImplementation(() => mockWebSocket) as any
|
||||
vi.stubGlobal('WebSocket', function (this: WebSocket) {
|
||||
Object.assign(this, mockWebSocket)
|
||||
})
|
||||
|
||||
// Reset API state
|
||||
api.serverFeatureFlags = {}
|
||||
|
||||
@@ -70,7 +70,7 @@ const createWrapper = (props = {}) => {
|
||||
|
||||
describe('ZoomControlsModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it('should execute zoom in command when zoom in button is clicked', async () => {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import JobGroupsList from '@/components/queue/job/JobGroupsList.vue'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import JobGroupsList from '@/queue/components/job/JobGroupsList.vue'
|
||||
import type { JobGroup, JobListItem } from '@/queue/composables/useJobList'
|
||||
|
||||
const QueueJobItemStub = defineComponent({
|
||||
name: 'QueueJobItemStub',
|
||||
@@ -22,7 +22,6 @@ const QueueJobItemStub = defineComponent({
|
||||
runningNodeName: { type: String, default: undefined },
|
||||
activeDetailsId: { type: String, default: null }
|
||||
},
|
||||
emits: ['cancel', 'delete', 'menu', 'view', 'details-enter', 'details-leave'],
|
||||
template: '<div class="queue-job-item-stub"></div>'
|
||||
})
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@ import { computed, ref } from 'vue'
|
||||
import type { ComputedRef } from 'vue'
|
||||
|
||||
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { JobErrorDialogService } from '@/components/queue/job/useJobErrorReporting'
|
||||
import * as jobErrorReporting from '@/components/queue/job/useJobErrorReporting'
|
||||
import type { TaskItemImpl } from '@/queue/stores/queueStore'
|
||||
import type {
|
||||
JobErrorDialogService,
|
||||
UseJobErrorReportingOptions
|
||||
} from '@/queue/components/job/useJobErrorReporting'
|
||||
import * as jobErrorReporting from '@/queue/components/job/useJobErrorReporting'
|
||||
|
||||
const createExecutionErrorMessage = (
|
||||
overrides: Partial<ExecutionErrorWsMessage> = {}
|
||||
@@ -90,9 +93,9 @@ describe('extractExecutionError', () => {
|
||||
describe('useJobErrorReporting', () => {
|
||||
let taskState = ref<TaskItemImpl | null>(null)
|
||||
let taskForJob: ComputedRef<TaskItemImpl | null>
|
||||
let copyToClipboard: ReturnType<typeof vi.fn>
|
||||
let showExecutionErrorDialog: ReturnType<typeof vi.fn>
|
||||
let showErrorDialog: ReturnType<typeof vi.fn>
|
||||
let copyToClipboard: UseJobErrorReportingOptions['copyToClipboard']
|
||||
let showExecutionErrorDialog: JobErrorDialogService['showExecutionErrorDialog']
|
||||
let showErrorDialog: JobErrorDialogService['showErrorDialog']
|
||||
let dialog: JobErrorDialogService
|
||||
let composable: ReturnType<typeof jobErrorReporting.useJobErrorReporting>
|
||||
|
||||
@@ -146,7 +149,7 @@ describe('useJobErrorReporting', () => {
|
||||
expect(copyToClipboard).toHaveBeenCalledTimes(1)
|
||||
expect(copyToClipboard).toHaveBeenCalledWith('Clipboard failure')
|
||||
|
||||
copyToClipboard.mockClear()
|
||||
vi.mocked(copyToClipboard).mockClear()
|
||||
taskState.value = createTaskWithMessages([])
|
||||
composable.copyErrorMessage()
|
||||
expect(copyToClipboard).not.toHaveBeenCalled()
|
||||
@@ -174,7 +177,7 @@ describe('useJobErrorReporting', () => {
|
||||
composable.reportJobError()
|
||||
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||
expect(showErrorDialog).toHaveBeenCalledTimes(1)
|
||||
const [errorArg, optionsArg] = showErrorDialog.mock.calls[0]
|
||||
const [errorArg, optionsArg] = vi.mocked(showErrorDialog).mock.calls[0]
|
||||
expect(errorArg).toBeInstanceOf(Error)
|
||||
expect(errorArg.message).toBe(message)
|
||||
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
setActivePinia(createTestingPinia())
|
||||
|
||||
function createTestGraph() {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('test')
|
||||
node.addInput('input', 'INT')
|
||||
node.addWidget('number', 'testnum', 2, () => undefined, {})
|
||||
graph.add(node)
|
||||
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
const onReactivityUpdate = vi.fn()
|
||||
watch(vueNodeData, onReactivityUpdate)
|
||||
|
||||
return [node, graph, onReactivityUpdate] as const
|
||||
}
|
||||
|
||||
describe('Node Reactivity', () => {
|
||||
it('should trigger on callback', async () => {
|
||||
const [node, , onReactivityUpdate] = createTestGraph()
|
||||
|
||||
node.widgets![0].callback!(2)
|
||||
await nextTick()
|
||||
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should remain reactive after a connection is made', async () => {
|
||||
const [node, graph, onReactivityUpdate] = createTestGraph()
|
||||
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: '1',
|
||||
slotType: NodeSlotType.INPUT
|
||||
})
|
||||
await nextTick()
|
||||
onReactivityUpdate.mockClear()
|
||||
|
||||
node.widgets![0].callback!(2)
|
||||
await nextTick()
|
||||
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user