Compare commits

...

10 Commits

Author SHA1 Message Date
Alexander Brown
d6d540c6c0 refactor: replace inline skeletonGridStyle with Tailwind utility class
Amp-Thread-ID: https://ampcode.com/threads/T-019c739d-19f4-76ff-9dad-568b4922bdab
Co-authored-by: Amp <amp@ampcode.com>
2026-02-18 17:59:57 -08:00
Alexander Brown
611a1e2ece fix: address review feedback for folder loading state
- Gate folderLoading by isInFolderView so it only affects UI in folder view
- Surface useAsyncState errors via toast and exit folder on failure
- Use design token (bg-secondary-background) in Skeleton component
- Add i18n keys for folder view error messages

Amp-Thread-ID: https://ampcode.com/threads/T-019c71fd-6654-7410-a3e1-e6e9915c9a88
Co-authored-by: Amp <amp@ampcode.com>
2026-02-18 17:55:06 -08:00
Alexander Brown
9c313c66a3 fix: show skeleton loading state in asset folder view
Replace empty "No generated files found" flash with skeleton cards
when opening a multi-output job's folder view. The async resolution
of outputs via `resolveOutputAssetItems` now tracks loading state
using VueUse's `useAsyncState`, and the skeleton count matches the
expected output count from metadata.

- Add shadcn/vue Skeleton component
- Replace ProgressSpinner with skeleton grid matching asset card layout
- Use `useAsyncState` for folder asset resolution
- Wire `folderLoading` into `showLoadingState` / `showEmptyState`

Amp-Thread-ID: https://ampcode.com/threads/T-019c71fd-6654-7410-a3e1-e6e9915c9a88
Co-authored-by: Amp <amp@ampcode.com>
2026-02-18 17:55:05 -08:00
Alexander Brown
8099cce232 feat: bulk asset export with ZIP download (#8712)
## Summary

Adds bulk asset export with ZIP download for cloud users. When selecting
2+ assets and clicking download, the frontend now requests a server-side
ZIP export instead of triggering individual file downloads.

## Changes

### New files
- **`AssetExportProgressDialog.vue`** — HoneyToast-based progress dialog
showing per-job export status with progress percentages, error
indicators, and a manual re-download button for completed exports
- **`assetExportStore.ts`** — Pinia store that tracks export jobs,
handles `asset_export` WebSocket events for real-time progress, polls
stale exports via the task API as a fallback, and auto-triggers ZIP
download on completion

### Modified files
- **`useMediaAssetActions.ts`** — `downloadMultipleAssets` now routes to
ZIP export (via `createAssetExport`) in cloud mode when 2+ assets are
selected; single assets and OSS mode still use direct download
- **`assetService.ts`** — Added `createAssetExport()` and
`getExportDownloadUrl()` endpoints
- **`apiSchema.ts`** — Added `AssetExportWsMessage` type for the
WebSocket event
- **`api.ts`** — Wired up `asset_export` WebSocket event
- **`GraphView.vue`** — Mounted `AssetExportProgressDialog`
- **`main.json`** — Added i18n keys for export toast UI

## How it works

1. User selects multiple assets and clicks download
2. Frontend calls `POST /assets/export` with asset/job IDs
3. Backend creates a ZIP task and streams progress via `asset_export`
WebSocket events
4. `AssetExportProgressDialog` shows real-time progress
5. On completion, the ZIP is auto-downloaded via a presigned URL from
`GET /assets/exports/{name}`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8712-feat-bulk-asset-export-with-ZIP-download-3006d73d365081839ec3dd3e7b0d3b77)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-18 16:36:59 -08:00
Christian Byrne
27d4a34435 fix: sync node.imgs for legacy context menu in Vue Nodes mode (#8143)
## Summary

Fixes missing "Copy Image", "Open Image", "Save Image", and "Open in
Mask Editor" context menu options on SaveImage nodes when Vue Nodes mode
is enabled.

## Changes

- Add `syncLegacyNodeImgs` store method to sync loaded image elements to
`node.imgs`
- Call sync on image load in ImagePreview component
- Simplify mask editor handling to call composable directly

## Technical Details

- Only runs when `vueNodesMode` is enabled (no impact on legacy mode)
- Reuses already-loaded `<img>` element from Vue (no duplicate network
requests)
- Store owns the sync logic, component just hands off the element

Supersedes #7416

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8143-fix-sync-node-imgs-for-legacy-context-menu-in-Vue-Nodes-mode-2ec6d73d365081c59d42cd1722779b61)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-02-18 16:34:45 -08:00
Terry Jia
e1e560403e feat: reuse WidgetInputNumberInput for BoundingBox numeric inputs (#8895)
## Summary
Make WidgetInputNumberInput usable without the widget system by making
the widget prop optional and adding simple min/max/step/disabled props.

BoundingBox now uses this component instead of a separate
ScrubableNumberInput

## Screenshots (if applicable)
<img width="828" height="1393" alt="image"
src="https://github.com/user-attachments/assets/68e012cf-baae-4a53-b4f8-70917cf05554"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8895-feat-add-scrub-drag-to-adjust-to-BoundingBox-numeric-inputs-3086d73d36508194b4b5e9bc823b34d1)
by [Unito](https://www.unito.io)
2026-02-18 16:32:03 -08:00
Johnpaul Chiwetelu
aff0ebad50 fix: reload template workflows when locale changes (#8963)
## Summary
- Templates were fetched once with the initial locale and cached behind
an `isLoaded` guard. Changing language updated i18n UI strings but never
re-fetched locale-specific template data (names, descriptions) from the
server.
- Extracts core template fetching into `fetchCoreTemplates()` and adds a
`watch` on `i18n.global.locale` to re-fetch when the language changes.

## Test plan
- [ ] Open the templates panel
- [ ] Change language in settings (e.g. English -> French)
- [ ] Verify template names and descriptions update without a page
refresh
- [ ] Verify initial load still works correctly

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8963-fix-reload-template-workflows-when-locale-changes-30b6d73d36508178a2f8c2c8947b5955)
by [Unito](https://www.unito.io)
2026-02-18 15:59:37 -08:00
Christian Byrne
44dc208339 fix: app mode gets stale assets history (#8918)
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8918-app-mode-fix-stale-assets-history-3096d73d36508114b81df071d7289c23)
by [Unito](https://www.unito.io)
2026-02-18 15:29:00 -08:00
Christian Byrne
388c21a88d fix: lint-format CI failing on fork PRs due to missing secret (#8948)
## Summary

Fall back to `github.token` when `PR_GH_TOKEN` secret is unavailable on
fork PRs, fixing checkout failure.

## Changes

- **What**: The `ci-lint-format` workflow uses `secrets.PR_GH_TOKEN` for
checkout. Repository secrets are not available to workflows triggered by
fork PRs, causing `Input required and not supplied: token` errors (e.g.
[run
22124451916](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/22124451916)).
The fix uses `github.token` as a fallback for fork PRs — this is always
available with read-only access, which is sufficient since the workflow
already skips commit/push for forks.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8948-fix-lint-format-CI-failing-on-fork-PRs-due-to-missing-secret-30b6d73d365081dfb78cf05362a6653c)
by [Unito](https://www.unito.io)
2026-02-18 15:28:48 -08:00
Alexander Brown
b28f46d237 Regenerate images (#8959)
```



```

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8959-Regenerate-image-30b6d73d3650811e9116cb7c6c9002cb)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-02-18 11:28:47 -08:00
23 changed files with 1129 additions and 275 deletions

View File

@@ -21,7 +21,7 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ !github.event.pull_request.head.repo.fork && github.head_ref || github.ref }}
token: ${{ secrets.PR_GH_TOKEN }}
token: ${{ !github.event.pull_request.head.repo.fork && secrets.PR_GH_TOKEN || github.token }}
- name: Setup frontend
uses: ./.github/actions/setup-frontend

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -0,0 +1,55 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
test.describe('Vue Nodes Image Preview', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
})
async function loadImageOnNode(
comfyPage: Awaited<
ReturnType<(typeof test)['info']>
>['fixtures']['comfyPage']
) {
const loadImageNode = (await comfyPage.getNodeRefsByType('LoadImage'))[0]
const { x, y } = await loadImageNode.getPosition()
await comfyPage.dragAndDropFile('image64x64.webp', {
dropPosition: { x, y }
})
const imagePreview = comfyPage.page.locator('.image-preview')
await expect(imagePreview).toBeVisible()
await expect(imagePreview.locator('img')).toBeVisible()
await expect(imagePreview).toContainText('x')
return imagePreview
}
test('opens mask editor from image preview button', async ({ comfyPage }) => {
const imagePreview = await loadImageOnNode(comfyPage)
await imagePreview.locator('[role="img"]').hover()
await comfyPage.page.getByLabel('Edit or mask image').click()
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
})
test('shows image context menu options', async ({ comfyPage }) => {
await loadImageOnNode(comfyPage)
const nodeHeader = comfyPage.vueNodes.getNodeByTitle('Load Image')
await nodeHeader.click()
await nodeHeader.click({ button: 'right' })
const contextMenu = comfyPage.page.locator('.p-contextmenu')
await expect(contextMenu).toBeVisible()
await expect(contextMenu.getByText('Open Image')).toBeVisible()
await expect(contextMenu.getByText('Copy Image')).toBeVisible()
await expect(contextMenu.getByText('Save Image')).toBeVisible()
await expect(contextMenu.getByText('Open in Mask Editor')).toBeVisible()
})
})

View File

@@ -3,49 +3,26 @@
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.x') }}
</label>
<input
v-model.number="x"
type="number"
:min="0"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<ScrubableNumberInput v-model="x" :min="0" :step="1" />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.y') }}
</label>
<input
v-model.number="y"
type="number"
:min="0"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<ScrubableNumberInput v-model="y" :min="0" :step="1" />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.width') }}
</label>
<input
v-model.number="width"
type="number"
:min="1"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<ScrubableNumberInput v-model="width" :min="1" :step="1" />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.height') }}
</label>
<input
v-model.number="height"
type="number"
:min="1"
step="1"
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
/>
<ScrubableNumberInput v-model="height" :min="1" :step="1" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import type { Bounds } from '@/renderer/core/layout/types'
const modelValue = defineModel<Bounds>({

View File

@@ -0,0 +1,175 @@
<template>
<div
ref="container"
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
>
<slot name="background" />
<Button
v-if="!hideButtons"
:aria-label="t('g.ariaLabel.decrement')"
data-testid="decrement"
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canDecrement"
tabindex="-1"
@click="modelValue = clamp(modelValue - step)"
>
<i class="pi pi-minus" />
</Button>
<div class="relative min-w-[4ch] flex-1 py-1.5 my-0.25">
<input
ref="inputField"
v-bind="inputAttrs"
:value="displayValue ?? modelValue"
:disabled
:class="
cn(
'bg-transparent border-0 focus:outline-0 p-1 truncate text-sm absolute inset-0'
)
"
inputmode="decimal"
autocomplete="off"
autocorrect="off"
spellcheck="false"
@blur="handleBlur"
@keyup.enter="handleBlur"
@dragstart.prevent
/>
<div
:class="
cn(
'absolute inset-0 z-10 cursor-ew-resize',
textEdit && 'pointer-events-none hidden'
)
"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointercancel="resetDrag"
/>
</div>
<slot />
<Button
v-if="!hideButtons"
:aria-label="t('g.ariaLabel.increment')"
data-testid="increment"
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canIncrement"
tabindex="-1"
@click="modelValue = clamp(modelValue + step)"
>
<i class="pi pi-plus" />
</Button>
</div>
</template>
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const {
min,
max,
step = 1,
disabled = false,
hideButtons = false,
displayValue,
parseValue
} = defineProps<{
min?: number
max?: number
step?: number
disabled?: boolean
hideButtons?: boolean
displayValue?: string
parseValue?: (raw: string) => number | undefined
inputAttrs?: Record<string, unknown>
}>()
const { t } = useI18n()
const modelValue = defineModel<number>({ default: 0 })
const container = useTemplateRef<HTMLDivElement>('container')
const inputField = useTemplateRef<HTMLInputElement>('inputField')
const textEdit = ref(false)
onClickOutside(container, () => {
if (textEdit.value) textEdit.value = false
})
function clamp(value: number): number {
const lo = min ?? -Infinity
const hi = max ?? Infinity
return Math.min(hi, Math.max(lo, value))
}
const canDecrement = computed(
() => modelValue.value > (min ?? -Infinity) && !disabled
)
const canIncrement = computed(
() => modelValue.value < (max ?? Infinity) && !disabled
)
const dragging = ref(false)
const dragDelta = ref(0)
const hasDragged = ref(false)
function handleBlur(e: Event) {
const target = e.target as HTMLInputElement
const raw = target.value.trim()
const parsed = parseValue
? parseValue(raw)
: raw === ''
? undefined
: Number(raw)
if (parsed != null && !isNaN(parsed)) {
modelValue.value = clamp(parsed)
} else {
target.value = displayValue ?? String(modelValue.value)
}
textEdit.value = false
}
function handlePointerDown(e: PointerEvent) {
if (e.button !== 0) return
if (disabled) return
const target = e.target as HTMLElement
target.setPointerCapture(e.pointerId)
dragging.value = true
dragDelta.value = 0
hasDragged.value = false
}
function handlePointerMove(e: PointerEvent) {
if (!dragging.value) return
dragDelta.value += e.movementX
const steps = (dragDelta.value / 10) | 0
if (steps === 0) return
hasDragged.value = true
const unclipped = modelValue.value + steps * step
dragDelta.value %= 10
modelValue.value = clamp(unclipped)
}
function handlePointerUp() {
if (!dragging.value) return
if (!hasDragged.value) {
textEdit.value = true
inputField.value?.focus()
inputField.value?.select()
}
resetDrag()
}
function resetDrag() {
dragging.value = false
dragDelta.value = 0
}
</script>

View File

@@ -67,18 +67,6 @@ describe('HoneyToast', () => {
wrapper.unmount()
})
it('applies collapsed max-height class when collapsed', async () => {
const wrapper = mountComponent({ visible: true, expanded: false })
await nextTick()
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-0')).toBe(true)
wrapper.unmount()
})
it('has aria-live="polite" for accessibility', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
@@ -127,11 +115,6 @@ describe('HoneyToast', () => {
expect(content?.textContent).toBe('expanded')
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true)
wrapper.unmount()
})
})

View File

@@ -26,13 +26,13 @@ function toggle() {
v-if="visible"
role="status"
aria-live="polite"
class="fixed inset-x-0 bottom-6 z-9999 mx-auto w-4/5 max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
class="fixed inset-x-0 bottom-6 z-9999 mx-auto max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg min-w-0 w-min transition-all duration-300"
>
<div
:class="
cn(
'overflow-hidden transition-all duration-300',
isExpanded ? 'max-h-[400px]' : 'max-h-0'
'overflow-hidden transition-all duration-300 min-w-0 max-w-full',
isExpanded ? 'w-[max(400px,40vw)] max-h-100' : 'w-0 max-h-0'
)
"
>

View File

@@ -79,8 +79,21 @@
<Divider v-else type="dashed" class="my-2" />
</template>
<template #body>
<div v-if="showLoadingState">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
<div
v-if="showLoadingState"
class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2 px-2"
>
<div
v-for="n in skeletonCount"
:key="`skeleton-${n}`"
class="flex flex-col gap-2 p-2"
>
<Skeleton class="aspect-square w-full rounded-lg" />
<div class="flex flex-col gap-1">
<Skeleton class="h-4 w-3/4" />
<Skeleton class="h-3 w-1/2" />
</div>
</div>
</div>
<div v-else-if="showEmptyState">
<NoResultsPlaceholder
@@ -206,6 +219,7 @@
<script setup lang="ts">
import {
useAsyncState,
useDebounceFn,
useElementHover,
useResizeObserver,
@@ -213,7 +227,6 @@ import {
} from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -225,6 +238,7 @@ const Load3dViewerContent = () =>
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
@@ -237,6 +251,7 @@ import { useAssetSelection } from '@/platform/assets/composables/useAssetSelecti
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
@@ -260,6 +275,7 @@ const settingStore = useSettingStore()
const activeTab = ref<'input' | 'output'>('output')
const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const expectedFolderCount = ref(0)
const isInFolderView = computed(() => folderPromptId.value !== null)
const viewMode = useStorage<'list' | 'grid'>(
'Comfy.Assets.Sidebar.ViewMode',
@@ -376,7 +392,24 @@ const mediaAssets = computed(() => currentAssets.value.media.value)
const galleryActiveIndex = ref(-1)
const currentGalleryAssetId = ref<string | null>(null)
const folderAssets = ref<AssetItem[]>([])
const DEFAULT_SKELETON_COUNT = 6
const skeletonCount = computed(() =>
expectedFolderCount.value > 0
? expectedFolderCount.value
: DEFAULT_SKELETON_COUNT
)
const {
state: folderAssets,
isLoading: folderLoading,
error: folderError,
execute: loadFolderAssets
} = useAsyncState(
(metadata: OutputAssetMetadata, options: { createdAt?: string } = {}) =>
resolveOutputAssetItems(metadata, options),
[] as AssetItem[],
{ immediate: false, resetOnExecute: true }
)
// Base assets before search filtering
const baseAssets = computed(() => {
@@ -414,9 +447,13 @@ const isBulkMode = computed(
() => hasSelection.value && selectedAssets.value.length > 1
)
const isFolderLoading = computed(
() => isInFolderView.value && folderLoading.value
)
const showLoadingState = computed(
() =>
loading.value &&
(loading.value || isFolderLoading.value) &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
)
@@ -424,6 +461,7 @@ const showLoadingState = computed(
const showEmptyState = computed(
() =>
!loading.value &&
!isFolderLoading.value &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
)
@@ -599,27 +637,25 @@ const enterFolderView = async (asset: AssetItem) => {
folderPromptId.value = promptId
folderExecutionTime.value = executionTimeInSeconds
expectedFolderCount.value = metadata.outputCount ?? 0
let folderItems: AssetItem[] = []
try {
folderItems = await resolveOutputAssetItems(metadata, {
createdAt: asset.created_at
await loadFolderAssets(0, metadata, { createdAt: asset.created_at })
if (folderError.value) {
toast.add({
severity: 'error',
summary: t('sideToolbar.folderView.errorSummary'),
detail: t('sideToolbar.folderView.errorDetail'),
life: 5000
})
} catch (error) {
console.error('Failed to resolve outputs for folder view:', error)
exitFolderView()
}
if (folderItems.length === 0) {
console.warn('No outputs available for folder view')
return
}
folderAssets.value = folderItems
}
const exitFolderView = () => {
folderPromptId.value = null
folderExecutionTime.value = undefined
expectedFolderCount.value = 0
folderAssets.value = []
searchQuery.value = ''
}

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="cn('animate-pulse rounded-md bg-secondary-background', className)"
/>
</template>

View File

@@ -743,6 +743,10 @@
"filterText": "Text"
},
"backToAssets": "Back to all assets",
"folderView": {
"errorSummary": "Failed to load outputs",
"errorDetail": "Could not retrieve outputs for this job. Please try again."
},
"searchAssets": "Search Assets",
"labels": {
"queue": "Queue",
@@ -2817,9 +2821,10 @@
"insertAllAssetsAsNodes": "Insert all assets as nodes",
"openWorkflowAll": "Open all workflows",
"exportWorkflowAll": "Export all workflows",
"downloadStarted": "Downloading {count} files...",
"downloadsStarted": "Started downloading {count} file(s)",
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
"downloadStarted": "Downloading {count} file... | Downloading {count} files...",
"downloadsStarted": "Started downloading {count} file | Started downloading {count} files",
"exportStarted": "Preparing ZIP export for {count} file | Preparing ZIP export for {count} files",
"assetsDeletedSuccessfully": "{count} asset deleted successfully | {count} assets deleted successfully",
"failedToDeleteAssets": "Failed to delete selected assets",
"partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed",
"nodesAddedToWorkflow": "{count} node(s) added to workflow",
@@ -3015,6 +3020,20 @@
"failed": "Failed"
}
},
"exportToast": {
"exportingAssets": "Exporting Assets",
"preparingExport": "Preparing export...",
"exportError": "Export failed",
"exportFailed": "{count} export failed | {count} export failed | {count} exports failed",
"allExportsCompleted": "All exports completed",
"noExportsInQueue": "No {filter} exports in queue",
"exportStarted": "Preparing ZIP download...",
"exportCompleted": "ZIP download ready",
"exportFailedSingle": "Failed to create ZIP export",
"downloadExport": "Download export",
"downloadFailed": "Failed to download \"{name}\"",
"retryDownload": "Retry download"
},
"workspace": {
"unsavedChanges": {
"title": "Unsaved Changes",

View File

@@ -0,0 +1,254 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import HoneyToast from '@/components/honeyToast/HoneyToast.vue'
import Button from '@/components/ui/button/Button.vue'
import type { AssetExport } from '@/stores/assetExportStore'
import { useAssetExportStore } from '@/stores/assetExportStore'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const assetExportStore = useAssetExportStore()
const visible = computed(() => assetExportStore.hasExports)
const isExpanded = ref(false)
const exportJobs = computed(() => assetExportStore.exportList)
const failedJobs = computed(() =>
assetExportStore.finishedExports.filter((e) => e.status === 'failed')
)
const isInProgress = computed(() => assetExportStore.hasActiveExports)
const currentJobName = computed(() => {
const activeJob = exportJobs.value.find((job) => job.status === 'running')
return activeJob?.exportName || t('exportToast.preparingExport')
})
function jobDisplayName(job: AssetExport): string {
if (job.status === 'failed') return job.error || t('exportToast.exportError')
return job.exportName || t('exportToast.preparingExport')
}
const completedCount = computed(() => assetExportStore.finishedExports.length)
const totalCount = computed(() => exportJobs.value.length)
const footerLabel = computed(() => {
if (isInProgress.value) return currentJobName.value
if (failedJobs.value.length > 0)
return t('exportToast.exportFailed', { count: failedJobs.value.length })
return t('exportToast.allExportsCompleted')
})
const footerIconClass = computed(() => {
if (isInProgress.value)
return 'icon-[lucide--loader-circle] animate-spin text-muted-foreground'
if (failedJobs.value.length > 0)
return 'icon-[lucide--circle-alert] text-destructive-background'
return 'icon-[lucide--check-circle] text-jade-600'
})
const tooltipConfig = computed(() => ({
value: footerLabel.value,
disabled: isExpanded.value,
pt: { root: { class: 'z-10000!' } }
}))
function progressPercent(job: AssetExport): number {
return Math.round(job.progress * 100)
}
function closeDialog() {
assetExportStore.clearFinishedExports()
isExpanded.value = false
}
</script>
<template>
<HoneyToast v-model:expanded="isExpanded" :visible>
<template #default>
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h3 class="text-sm font-bold text-base-foreground">
{{ t('exportToast.exportingAssets') }}
</h3>
</div>
<div class="relative max-h-75 overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<div
v-for="job in exportJobs"
:key="job.taskId"
:class="
cn(
'flex items-center justify-between rounded-lg bg-modal-card-background px-4 py-3',
job.status === 'completed' && 'opacity-50'
)
"
>
<div class="min-w-0 flex-1">
<span
:class="
cn(
'block truncate text-sm',
job.status === 'failed'
? 'text-destructive-background'
: 'text-base-foreground'
)
"
>
{{ jobDisplayName(job) }}
</span>
<span
v-if="job.assetsTotal > 0"
class="text-xs text-muted-foreground"
>
{{ job.assetsAttempted }}/{{ job.assetsTotal }}
</span>
</div>
<div class="flex items-center gap-2">
<template v-if="job.status === 'failed'">
<i
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
/>
</template>
<template
v-else-if="job.status === 'completed' && job.downloadError"
>
<span
class="text-xs text-destructive-background truncate max-w-32"
>
{{ job.downloadError }}
</span>
<Button
variant="muted-textonly"
size="icon"
:aria-label="t('exportToast.retryDownload')"
@click.stop="assetExportStore.triggerDownload(job, true)"
>
<i
class="icon-[lucide--rotate-ccw] size-4 text-destructive-background"
/>
</Button>
</template>
<template v-else-if="job.status === 'completed'">
<Button
variant="muted-textonly"
size="icon"
:aria-label="t('exportToast.downloadExport')"
@click.stop="assetExportStore.triggerDownload(job, true)"
>
<i
class="icon-[lucide--download] size-4 text-success-background"
/>
</Button>
</template>
<template v-else-if="job.status === 'running'">
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-base-foreground"
/>
<span class="text-xs text-base-foreground">
{{ progressPercent(job) }}%
</span>
</template>
<template v-else>
<span class="text-xs text-muted-foreground">
{{ t('progressToast.pending') }}
</span>
</template>
</div>
</div>
</div>
<div
v-if="exportJobs.length === 0"
class="flex flex-col items-center justify-center py-6 text-center"
>
<span class="text-sm text-muted-foreground">
{{
t('exportToast.noExportsInQueue', {
filter: t('progressToast.filter.all')
})
}}
</span>
</div>
</div>
</template>
<template #footer="{ toggle }">
<div
class="flex flex-1 min-w-0 h-12 items-center justify-between gap-2 border-t border-border-default px-4"
>
<div class="flex min-w-0 flex-1 items-center gap-2 text-sm">
<i
v-tooltip.top="tooltipConfig"
:class="cn('size-4 shrink-0', footerIconClass)"
/>
<span
:class="
cn(
'truncate font-bold text-base-foreground transition-all duration-300 overflow-hidden',
isExpanded ? 'min-w-0 flex-1' : 'w-0'
)
"
>
{{ footerLabel }}
</span>
</div>
<div class="flex items-center gap-2">
<span
v-if="isInProgress"
:class="
cn(
'text-sm text-muted-foreground transition-all duration-300 overflow-hidden',
isExpanded ? 'whitespace-nowrap' : 'w-0'
)
"
>
{{
t('progressToast.progressCount', {
completed: completedCount,
total: totalCount
})
}}
</span>
<div class="flex items-center">
<Button
variant="muted-textonly"
size="icon"
:aria-label="
isExpanded ? t('contextMenu.Collapse') : t('contextMenu.Expand')
"
@click.stop="toggle"
>
<i
:class="
cn(
'size-4',
isExpanded
? 'icon-[lucide--chevron-down]'
: 'icon-[lucide--chevron-up]'
)
"
/>
</Button>
<Button
v-if="!isInProgress"
variant="muted-textonly"
size="icon"
:aria-label="t('g.close')"
@click.stop="closeDialog"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</div>
</template>
</HoneyToast>
</template>

View File

@@ -20,6 +20,8 @@ import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
import { isResultItemType } from '@/utils/typeGuardUtil'
import { useAssetExportStore } from '@/stores/assetExportStore'
import type { AssetItem } from '../schemas/assetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import { assetService } from '../services/assetService'
@@ -73,7 +75,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.selection.downloadsStarted', { count: 1 }),
detail: t('mediaAsset.selection.downloadsStarted', 1),
life: 2000
})
} catch (error) {
@@ -87,16 +89,26 @@ export function useMediaAssetActions() {
}
/**
* Download multiple assets at once
* @param assets Array of assets to download
* Download multiple assets at once.
* In cloud mode with 2+ assets, creates a ZIP export via the backend.
* Falls back to individual downloads in OSS mode or for single assets.
*/
const downloadMultipleAssets = (assets: AssetItem[]) => {
if (!assets || assets.length === 0) return
const hasMultiOutputJobs = assets.some((a) => {
const count = getOutputAssetMetadata(a.user_metadata)?.outputCount
return typeof count === 'number' && count > 1
})
if (isCloud && (assets.length > 1 || hasMultiOutputJobs)) {
void downloadMultipleAssetsAsZip(assets)
return
}
try {
assets.forEach((asset) => {
const filename = asset.name
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
const downloadUrl = asset.preview_url || getAssetUrl(asset)
downloadFile(downloadUrl, filename)
})
@@ -104,9 +116,7 @@ export function useMediaAssetActions() {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.selection.downloadsStarted', {
count: assets.length
}),
detail: t('mediaAsset.selection.downloadsStarted', assets.length),
life: 2000
})
} catch (error) {
@@ -120,6 +130,62 @@ export function useMediaAssetActions() {
}
}
async function downloadMultipleAssetsAsZip(assets: AssetItem[]) {
const assetExportStore = useAssetExportStore()
try {
const jobIds: string[] = []
const assetIds: string[] = []
const jobAssetNameFilters: Record<string, string[]> = {}
for (const asset of assets) {
if (getAssetType(asset) === 'output') {
const metadata = getOutputAssetMetadata(asset.user_metadata)
const promptId = metadata?.promptId || asset.id
if (!jobIds.includes(promptId)) {
jobIds.push(promptId)
}
if (metadata?.promptId && asset.name) {
if (!jobAssetNameFilters[metadata.promptId]) {
jobAssetNameFilters[metadata.promptId] = []
}
if (!jobAssetNameFilters[metadata.promptId].includes(asset.name)) {
jobAssetNameFilters[metadata.promptId].push(asset.name)
}
}
} else {
assetIds.push(asset.id)
}
}
const result = await assetService.createAssetExport({
...(jobIds.length > 0 ? { job_ids: jobIds } : {}),
...(assetIds.length > 0 ? { asset_ids: assetIds } : {}),
...(Object.keys(jobAssetNameFilters).length > 0
? { job_asset_name_filters: jobAssetNameFilters }
: {}),
naming_strategy: 'preserve'
})
assetExportStore.trackExport(result.task_id)
toast.add({
severity: 'info',
summary: t('exportToast.exportStarted'),
detail: t('mediaAsset.selection.exportStarted', assets.length),
life: 3000
})
} catch (error) {
console.error('Failed to create asset export:', error)
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('exportToast.exportFailedSingle'),
life: 3000
})
}
}
const copyJobId = async (asset?: AssetItem) => {
const targetAsset = asset ?? mediaContext?.asset.value
if (!targetAsset) return
@@ -580,9 +646,10 @@ export function useMediaAssetActions() {
summary: t('g.success'),
detail: isSingle
? t('mediaAsset.assetDeletedSuccessfully')
: t('mediaAsset.selection.assetsDeletedSuccessfully', {
count: succeeded
}),
: t(
'mediaAsset.selection.assetsDeletedSuccessfully',
succeeded
),
life: 2000
})
} else if (succeeded === 0) {

View File

@@ -31,6 +31,17 @@ interface AssetRequestOptions extends PaginationOptions {
includePublic?: boolean
}
interface AssetExportOptions {
job_ids?: string[]
asset_ids?: string[]
naming_strategy?:
| 'group_by_job_id'
| 'prepend_job_id'
| 'preserve'
| 'asset_id'
job_asset_name_filters?: Record<string, string[]>
}
/**
* Maps CivitAI validation error codes to localized error messages
*/
@@ -153,6 +164,7 @@ function getLocalizedErrorMessage(errorCode: string): string {
const ASSETS_ENDPOINT = '/assets'
const ASSETS_DOWNLOAD_ENDPOINT = '/assets/download'
const ASSETS_EXPORT_ENDPOINT = '/assets/export'
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
const DEFAULT_LIMIT = 500
@@ -689,6 +701,34 @@ function createAssetService() {
return result.data
}
async function createAssetExport(
params: AssetExportOptions
): Promise<{ task_id: string; status: string; message?: string }> {
const res = await api.fetchApi(ASSETS_EXPORT_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
})
if (!res.ok) {
throw new Error(`Failed to create asset export: ${res.status}`)
}
return await res.json()
}
async function getExportDownloadUrl(
exportName: string
): Promise<{ url: string; expires_at?: string }> {
const res = await api.fetchApi(`/assets/exports/${exportName}`)
if (!res.ok) {
throw new Error(`Failed to get export download URL: ${res.status}`)
}
return await res.json()
}
return {
getAssetModelFolders,
getAssetModels,
@@ -703,7 +743,9 @@ function createAssetService() {
getAssetMetadata,
uploadAssetFromUrl,
uploadAssetFromBase64,
uploadAssetAsync
uploadAssetAsync,
createAssetExport,
getExportDownloadUrl
}
}

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import { i18n, st } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
@@ -472,31 +472,32 @@ export const useWorkflowTemplatesStore = defineStore(
return items
})
async function fetchCoreTemplates() {
const locale = i18n.global.locale.value
const [coreResult, englishResult, logoIndexResult] = await Promise.all([
api.getCoreWorkflowTemplates(locale),
isCloud && locale !== 'en'
? api.getCoreWorkflowTemplates('en')
: Promise.resolve([]),
fetchLogoIndex()
])
coreTemplates.value = coreResult
englishTemplates.value = englishResult
logoIndex.value = logoIndexResult
const coreNames = coreTemplates.value.flatMap((category) =>
category.templates.map((template) => template.name)
)
const customNames = Object.values(customTemplates.value).flat()
knownTemplateNames.value = new Set([...coreNames, ...customNames])
}
async function loadWorkflowTemplates() {
try {
if (!isLoaded.value) {
customTemplates.value = await api.getWorkflowTemplates()
const locale = i18n.global.locale.value
const [coreResult, englishResult, logoIndexResult] =
await Promise.all([
api.getCoreWorkflowTemplates(locale),
isCloud && locale !== 'en'
? api.getCoreWorkflowTemplates('en')
: Promise.resolve([]),
fetchLogoIndex()
])
coreTemplates.value = coreResult
englishTemplates.value = englishResult
logoIndex.value = logoIndexResult
const coreNames = coreTemplates.value.flatMap((category) =>
category.templates.map((template) => template.name)
)
const customNames = Object.values(customTemplates.value).flat()
knownTemplateNames.value = new Set([...coreNames, ...customNames])
await fetchCoreTemplates()
isLoaded.value = true
}
} catch (error) {
@@ -504,6 +505,18 @@ export const useWorkflowTemplatesStore = defineStore(
}
}
watch(
() => i18n.global.locale.value,
async () => {
if (!isLoaded.value) return
try {
await fetchCoreTemplates()
} catch (error) {
console.error('Error reloading templates for new locale:', error)
}
}
)
async function fetchLogoIndex(): Promise<LogoIndex> {
try {
const response = await fetch(api.fileURL('/templates/index_logo.json'))

View File

@@ -40,7 +40,6 @@
<!-- Main Image -->
<img
v-if="!imageError"
ref="currentImageEl"
:src="currentImageUrl"
:alt="imageAltText"
class="block size-full object-contain pointer-events-none contain-size"
@@ -128,8 +127,8 @@ import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
interface ImagePreviewProps {
@@ -142,7 +141,7 @@ interface ImagePreviewProps {
const props = defineProps<ImagePreviewProps>()
const { t } = useI18n()
const commandStore = useCommandStore()
const maskEditor = useMaskEditor()
const nodeOutputStore = useNodeOutputStore()
const actionButtonClass =
@@ -156,7 +155,6 @@ const actualDimensions = ref<string | null>(null)
const imageError = ref(false)
const showLoader = ref(false)
const currentImageEl = ref<HTMLImageElement>()
const imageWrapperEl = ref<HTMLDivElement>()
const { start: startDelayedLoader, stop: stopDelayedLoader } = useTimeoutFn(
@@ -209,6 +207,10 @@ const handleImageLoad = (event: Event) => {
if (img.naturalWidth && img.naturalHeight) {
actualDimensions.value = `${img.naturalWidth} x ${img.naturalHeight}`
}
if (props.nodeId) {
nodeOutputStore.syncLegacyNodeImgs(props.nodeId, img, currentIndex.value)
}
}
const handleImageError = () => {
@@ -218,19 +220,11 @@ const handleImageError = () => {
actualDimensions.value = null
}
// In vueNodes mode, we need to set them manually before opening the mask editor.
const setupNodeForMaskEditor = () => {
if (!props.nodeId || !currentImageEl.value) return
const node = app.rootGraph?.getNodeById(props.nodeId)
if (!node) return
node.imageIndex = currentIndex.value
node.imgs = [currentImageEl.value]
app.canvas?.select(node)
}
const handleEditMask = () => {
setupNodeForMaskEditor()
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
if (!props.nodeId) return
const node = app.rootGraph?.getNodeById(Number(props.nodeId))
if (!node) return
maskEditor.openMaskEditor(node)
}
const handleDownload = () => {

View File

@@ -1,9 +1,8 @@
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import { evaluateInput } from '@/lib/litegraph/src/utils/widget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
@@ -21,15 +20,6 @@ const props = defineProps<{
widget: SimplifiedWidget<number>
}>()
const widgetContainer = useTemplateRef<HTMLDivElement>('widgetContainer')
const inputField = useTemplateRef<HTMLInputElement>('inputField')
const textEdit = ref(false)
onClickOutside(widgetContainer, () => {
if (textEdit.value) {
textEdit.value = false
}
})
function formatNumber(value: number, options?: Intl.NumberFormatOptions) {
return new Intl.NumberFormat(locale.value, options).format(value)
}
@@ -49,9 +39,8 @@ function unformatValue(value: string) {
const modelValue = defineModel<number>({ default: 0 })
const formattedValue = computed(() => {
const unformattedValue = dragValue.value ?? modelValue.value
if ((unformattedValue as unknown) === '' || !isFinite(unformattedValue))
return `${unformattedValue}`
const value = modelValue.value
if ((value as unknown) === '' || !isFinite(value)) return `${value}`
const options: Intl.NumberFormatOptions = {
useGrouping: useGrouping.value
@@ -60,20 +49,11 @@ const formattedValue = computed(() => {
options.minimumFractionDigits = precision.value
options.maximumFractionDigits = precision.value
}
return formatNumber(unformattedValue, options)
return formatNumber(value, options)
})
function updateValue(e: UIEvent) {
const { target } = e
if (!(target instanceof HTMLInputElement)) return
const parsed = evaluateInput(unformatValue(target.value))
if (parsed !== undefined) {
const max = filteredProps.value.max ?? Number.MAX_VALUE
const min = filteredProps.value.min ?? -Number.MAX_VALUE
modelValue.value = Math.min(max, Math.max(min, parsed))
} else target.value = formattedValue.value
textEdit.value = false
function parseWidgetValue(raw: string): number | undefined {
return evaluateInput(unformatValue(raw))
}
interface NumericWidgetOptions {
@@ -93,15 +73,6 @@ const filteredProps = computed(() => {
const isDisabled = computed(() => props.widget.options?.disabled ?? false)
const canDecrement = computed(() => {
const min = filteredProps.value.min ?? -Number.MAX_VALUE
return modelValue.value > min && !isDisabled.value
})
const canIncrement = computed(() => {
const max = filteredProps.value.max ?? Number.MAX_VALUE
return modelValue.value < max && !isDisabled.value
})
// Get the precision value for proper number formatting
const precision = computed(() => {
const p = props.widget.options?.precision
@@ -155,42 +126,6 @@ function updateValueBy(delta: number) {
modelValue.value = Math.min(max, Math.max(min, modelValue.value + delta))
}
const dragValue = ref<number>()
const dragDelta = ref(0)
function handleMouseDown(e: PointerEvent) {
if (e.button > 0) return
if (isDisabled.value) return
const { target } = e
if (!(target instanceof HTMLElement)) return
target.setPointerCapture(e.pointerId)
dragValue.value = modelValue.value
dragDelta.value = 0
}
function handleMouseMove(e: PointerEvent) {
if (dragValue.value === undefined) return
dragDelta.value += e.movementX
const unclippedValue =
dragValue.value + ((dragDelta.value / 10) | 0) * stepValue.value
dragDelta.value %= 10
const max = filteredProps.value.max ?? Number.MAX_VALUE
const min = filteredProps.value.min ?? -Number.MAX_VALUE
dragValue.value = Math.min(max, Math.max(min, unclippedValue))
}
function handleMouseUp() {
const newValue = dragValue.value
if (newValue === undefined) return
if (newValue === modelValue.value) {
textEdit.value = true
inputField.value?.focus()
inputField.value?.setSelectionRange(0, -1)
}
modelValue.value = newValue
dragValue.value = undefined
dragDelta.value = 0
}
const buttonTooltip = computed(() => {
if (buttonsDisabled.value) {
return 'Increment/decrement disabled: value exceeds JavaScript precision limit (±2^53)'
@@ -207,98 +142,50 @@ const sliderWidth = computed(() => {
(max - min) / step >= 100
)
return 0
const value = dragValue.value ?? modelValue.value
const ratio = (value - min) / (max - min)
const ratio = (modelValue.value - min) / (max - min)
return (ratio * 100).toFixed(0)
})
const inputAriaAttrs = computed(() => ({
'aria-valuenow': modelValue.value,
'aria-valuemin': filteredProps.value.min,
'aria-valuemax': filteredProps.value.max,
role: 'spinbutton',
tabindex: 0
}))
</script>
<template>
<WidgetLayoutField :widget>
<div
ref="widgetContainer"
<ScrubableNumberInput
v-model="modelValue"
v-tooltip="buttonTooltip"
v-bind="filteredProps"
:aria-label="widget.name"
:min="filteredProps.min"
:max="filteredProps.max"
:step="stepValue"
:display-value="formattedValue"
:disabled="isDisabled"
:hide-buttons="buttonsDisabled"
:parse-value="parseWidgetValue"
:input-attrs="inputAriaAttrs"
:class="cn(WidgetInputBaseClass, 'grow text-xs flex h-7 relative')"
@keydown.up.prevent="updateValueBy(stepValue)"
@keydown.down.prevent="updateValueBy(-stepValue)"
@keydown.page-up.prevent="updateValueBy(10 * stepValue)"
@keydown.page-down.prevent="updateValueBy(-10 * stepValue)"
>
<div
class="absolute size-full rounded-lg pointer-events-none overflow-clip"
>
<template #background>
<div
class="bg-primary-background/15 size-full"
:style="{ width: `${sliderWidth}%` }"
/>
</div>
<Button
v-if="!buttonsDisabled"
data-testid="decrement"
class="h-full w-8 rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canDecrement"
tabindex="-1"
@click="modelValue -= stepValue"
>
<i class="pi pi-minus" />
</Button>
<div class="relative min-w-[4ch] flex-1 py-1.5 my-0.25">
<input
ref="inputField"
:aria-valuenow="dragValue ?? modelValue"
:aria-valuemin="filteredProps.min"
:aria-valuemax="filteredProps.max"
:class="
cn(
'bg-transparent border-0 focus:outline-0 p-1 truncate text-sm absolute inset-0'
)
"
inputmode="decimal"
:value="formattedValue"
role="spinbutton"
tabindex="0"
:disabled="isDisabled"
autocomplete="off"
autocorrect="off"
spellcheck="false"
@blur="updateValue"
@keyup.enter="updateValue"
@keydown.up.prevent="updateValueBy(stepValue)"
@keydown.down.prevent="updateValueBy(-stepValue)"
@keydown.page-up.prevent="updateValueBy(10 * stepValue)"
@keydown.page-down.prevent="updateValueBy(-10 * stepValue)"
@dragstart.prevent
/>
<div
:class="
cn(
'absolute inset-0 z-10 cursor-ew-resize',
textEdit && 'hidden pointer-events-none'
)
"
@pointerdown="handleMouseDown"
@pointermove="handleMouseMove"
@pointerup="handleMouseUp"
@pointercancel="
() => {
dragValue = undefined
dragDelta = 0
}
"
/>
</div>
class="absolute size-full rounded-lg pointer-events-none overflow-clip"
>
<div
class="bg-primary-background/15 size-full"
:style="{ width: `${sliderWidth}%` }"
/>
</div>
</template>
<slot />
<Button
v-if="!buttonsDisabled"
data-testid="increment"
class="h-full w-8 rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canIncrement"
tabindex="-1"
@click="modelValue += stepValue"
>
<i class="pi pi-plus" />
</Button>
</div>
</ScrubableNumberInput>
</WidgetLayoutField>
</template>

View File

@@ -148,6 +148,19 @@ const zAssetDownloadWsMessage = z.object({
error: z.string().optional()
})
const zAssetExportWsMessage = z.object({
task_id: z.string(),
export_name: z.string().optional(),
assets_total: z.number(),
assets_attempted: z.number(),
assets_failed: z.number(),
bytes_total: z.number(),
bytes_processed: z.number(),
progress: z.number(),
status: z.enum(['created', 'running', 'completed', 'failed']),
error: z.string().optional()
})
export type StatusWsMessageStatus = z.infer<typeof zStatusWsMessageStatus>
export type StatusWsMessage = z.infer<typeof zStatusWsMessage>
export type ProgressWsMessage = z.infer<typeof zProgressWsMessage>
@@ -168,6 +181,7 @@ export type NodeProgressState = z.infer<typeof zNodeProgressState>
export type ProgressStateWsMessage = z.infer<typeof zProgressStateWsMessage>
export type FeatureFlagsWsMessage = z.infer<typeof zFeatureFlagsWsMessage>
export type AssetDownloadWsMessage = z.infer<typeof zAssetDownloadWsMessage>
export type AssetExportWsMessage = z.infer<typeof zAssetExportWsMessage>
// End of ws messages
export type NotificationWsMessage = z.infer<typeof zNotificationWsMessage>

View File

@@ -22,6 +22,7 @@ import type {
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
AssetDownloadWsMessage,
AssetExportWsMessage,
CustomNodesI18n,
EmbeddingsResponse,
ExecutedWsMessage,
@@ -169,6 +170,7 @@ interface BackendApiCalls {
progress_state: ProgressStateWsMessage
feature_flags: FeatureFlagsWsMessage
asset_download: AssetDownloadWsMessage
asset_export: AssetExportWsMessage
}
/** Dictionary of all api calls */

View File

@@ -0,0 +1,199 @@
import { useIntervalFn } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { assetService } from '@/platform/assets/services/assetService'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { taskService } from '@/platform/tasks/services/taskService'
import type { AssetExportWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { t } from '@/i18n'
export interface AssetExport {
taskId: string
exportName: string
assetsTotal: number
assetsAttempted: number
assetsFailed: number
bytesTotal: number
bytesProcessed: number
progress: number
status: 'created' | 'running' | 'completed' | 'failed'
error?: string
downloadError?: string
lastUpdate: number
downloadTriggered: boolean
}
const STALE_THRESHOLD_MS = 10_000
const POLL_INTERVAL_MS = 10_000
export const useAssetExportStore = defineStore('assetExport', () => {
const exports = ref<Map<string, AssetExport>>(new Map())
const exportList = computed(() => Array.from(exports.value.values()))
const activeExports = computed(() =>
exportList.value.filter(
(e) => e.status === 'created' || e.status === 'running'
)
)
const finishedExports = computed(() =>
exportList.value.filter(
(e) => e.status === 'completed' || e.status === 'failed'
)
)
const hasActiveExports = computed(() => activeExports.value.length > 0)
const hasExports = computed(() => exports.value.size > 0)
function trackExport(taskId: string) {
if (exports.value.has(taskId)) return
exports.value.set(taskId, {
taskId,
exportName: '',
assetsTotal: 0,
assetsAttempted: 0,
assetsFailed: 0,
bytesTotal: 0,
bytesProcessed: 0,
progress: 0,
status: 'created',
lastUpdate: Date.now(),
downloadTriggered: false
})
}
async function triggerDownload(exp: AssetExport, force = false) {
if (!force && (exp.downloadTriggered || !exp.exportName)) return
exp.downloadTriggered = true
try {
exp.downloadError = undefined
const { url } = await assetService.getExportDownloadUrl(exp.exportName)
const link = document.createElement('a')
link.href = url
link.download = exp.exportName
link.style.display = 'none'
link.target = '_blank'
link.rel = 'noopener noreferrer'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
exp.downloadError = message
exp.downloadTriggered = false
useToastStore().add({
severity: 'error',
summary: t('exportToast.downloadFailed', {
name: exp.exportName
}),
detail: message
})
}
}
function handleAssetExport(data: AssetExportWsMessage) {
const existing = exports.value.get(data.task_id)
if (
(existing?.status === 'completed' || existing?.status === 'failed') &&
existing?.downloadTriggered
) {
return
}
const exp: AssetExport = {
taskId: data.task_id,
exportName: data.export_name ?? existing?.exportName ?? '',
assetsTotal: data.assets_total,
assetsAttempted: data.assets_attempted,
assetsFailed: data.assets_failed,
bytesTotal: data.bytes_total,
bytesProcessed: data.bytes_processed,
progress: data.progress,
status: data.status,
error: data.error,
lastUpdate: Date.now(),
downloadTriggered: existing?.downloadTriggered ?? false
}
exports.value.set(data.task_id, exp)
if (data.status === 'completed') {
void triggerDownload(exp)
}
}
async function pollStaleExports() {
const now = Date.now()
const staleExports = activeExports.value.filter(
(e) => now - e.lastUpdate >= STALE_THRESHOLD_MS
)
if (staleExports.length === 0) return
async function pollSingleExport(exp: AssetExport) {
try {
const task = await taskService.getTask(exp.taskId)
if (task.status === 'completed' || task.status === 'failed') {
const result = task.result as Record<string, unknown> | undefined
handleAssetExport({
task_id: exp.taskId,
export_name: (result?.export_name as string) ?? exp.exportName,
assets_total: (result?.assets_total as number) ?? exp.assetsTotal,
assets_attempted:
(result?.assets_attempted as number) ?? exp.assetsAttempted,
assets_failed:
(result?.assets_failed as number) ?? exp.assetsFailed,
bytes_total: exp.bytesTotal,
bytes_processed: exp.bytesTotal,
progress: task.status === 'completed' ? 1 : exp.progress,
status: task.status as 'completed' | 'failed',
error: task.error_message ?? (result?.error as string)
})
}
} catch {
// Task not ready or not found
}
}
await Promise.all(staleExports.map(pollSingleExport))
}
const { pause, resume } = useIntervalFn(
() => void pollStaleExports(),
POLL_INTERVAL_MS,
{ immediate: false }
)
watch(
hasActiveExports,
(hasActive) => {
if (hasActive) resume()
else pause()
},
{ immediate: true }
)
api.addEventListener('asset_export', (e) => handleAssetExport(e.detail))
function clearFinishedExports() {
for (const exp of finishedExports.value) {
exports.value.delete(exp.taskId)
}
}
return {
activeExports,
finishedExports,
hasActiveExports,
hasExports,
exportList,
trackExport,
triggerDownload,
clearFinishedExports
}
})

View File

@@ -3,6 +3,7 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
@@ -13,20 +14,25 @@ vi.mock('@/utils/litegraphUtil', () => ({
isVideoNode: vi.fn()
}))
const mockGetNodeById = vi.fn()
vi.mock('@/scripts/app', () => ({
app: {
getPreviewFormatParam: vi.fn(() => '&format=test_webp'),
rootGraph: {
getNodeById: (...args: unknown[]) => mockGetNodeById(...args)
},
nodeOutputs: {} as Record<string, unknown>,
nodePreviewImages: {} as Record<string, string[]>
}
}))
const createMockNode = (overrides: Partial<LGraphNode> = {}): LGraphNode =>
const createMockNode = (overrides: Record<string, unknown> = {}): LGraphNode =>
({
id: 1,
type: 'TestNode',
...overrides
}) as LGraphNode
}) as unknown as LGraphNode
const createMockOutputs = (
images?: ExecutedWsMessage['output']['images']
@@ -216,3 +222,89 @@ describe('imagePreviewStore getPreviewParam', () => {
expect(vi.mocked(app).getPreviewFormatParam).toHaveBeenCalledTimes(1)
})
})
describe('imagePreviewStore syncLegacyNodeImgs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
LiteGraph.vueNodesMode = false
})
it('should not sync when vueNodesMode is disabled', () => {
const store = useNodeOutputStore()
const mockNode = createMockNode({ id: 1 })
const mockImg = document.createElement('img')
mockGetNodeById.mockReturnValue(mockNode)
store.syncLegacyNodeImgs(1, mockImg, 0)
expect(mockNode.imgs).toBeUndefined()
expect(mockNode.imageIndex).toBeUndefined()
})
it('should sync node.imgs when vueNodesMode is enabled', () => {
LiteGraph.vueNodesMode = true
const store = useNodeOutputStore()
const mockNode = createMockNode({ id: 1 })
const mockImg = document.createElement('img')
mockGetNodeById.mockReturnValue(mockNode)
store.syncLegacyNodeImgs(1, mockImg, 0)
expect(mockNode.imgs).toEqual([mockImg])
expect(mockNode.imageIndex).toBe(0)
})
it('should sync with correct activeIndex', () => {
LiteGraph.vueNodesMode = true
const store = useNodeOutputStore()
const mockNode = createMockNode({ id: 42 })
const mockImg = document.createElement('img')
mockGetNodeById.mockReturnValue(mockNode)
store.syncLegacyNodeImgs(42, mockImg, 3)
expect(mockNode.imgs).toEqual([mockImg])
expect(mockNode.imageIndex).toBe(3)
})
it('should handle string nodeId', () => {
LiteGraph.vueNodesMode = true
const store = useNodeOutputStore()
const mockNode = createMockNode({ id: 123 })
const mockImg = document.createElement('img')
mockGetNodeById.mockReturnValue(mockNode)
store.syncLegacyNodeImgs('123', mockImg, 0)
expect(mockGetNodeById).toHaveBeenCalledWith(123)
expect(mockNode.imgs).toEqual([mockImg])
})
it('should not throw when node is not found', () => {
LiteGraph.vueNodesMode = true
const store = useNodeOutputStore()
const mockImg = document.createElement('img')
mockGetNodeById.mockReturnValue(undefined)
expect(() => store.syncLegacyNodeImgs(999, mockImg, 0)).not.toThrow()
})
it('should default activeIndex to 0', () => {
LiteGraph.vueNodesMode = true
const store = useNodeOutputStore()
const mockNode = createMockNode({ id: 1 })
const mockImg = document.createElement('img')
mockGetNodeById.mockReturnValue(mockNode)
store.syncLegacyNodeImgs(1, mockImg)
expect(mockNode.imageIndex).toBe(0)
})
})

View File

@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type {
ExecutedWsMessage,
@@ -394,6 +395,32 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
revokeAllPreviews()
}
/**
* Sync legacy node.imgs property for backwards compatibility.
*
* In Vue Nodes mode, legacy systems (Copy Image, Open Image, Save Image,
* Open in Mask Editor) rely on `node.imgs` containing HTMLImageElement
* references. Since Vue handles image rendering, we need to sync the
* already-loaded element from the Vue component to the node.
*
* @param nodeId - The node ID
* @param element - The loaded HTMLImageElement from the Vue component
* @param activeIndex - The current image index (for multi-image outputs)
*/
function syncLegacyNodeImgs(
nodeId: string | number,
element: HTMLImageElement,
activeIndex: number = 0
) {
if (!LiteGraph.vueNodesMode) return
const node = app.rootGraph?.getNodeById(Number(nodeId))
if (!node) return
node.imgs = [element]
node.imageIndex = activeIndex
}
return {
// Getters
getNodeOutputs,
@@ -407,6 +434,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
setNodePreviewsByExecutionId,
setNodePreviewsByNodeId,
updateNodeImages,
syncLegacyNodeImgs,
// Cleanup
revokePreviewsByExecutionId,

View File

@@ -19,6 +19,7 @@
<InviteAcceptedToast />
<RerouteMigrationToast />
<ModelImportProgressDialog />
<AssetExportProgressDialog />
<ManagerProgressToast />
<UnloadWindowConfirmDialog v-if="!isDesktop" />
<MenuHamburger />
@@ -54,6 +55,7 @@ import { useProgressFavicon } from '@/composables/useProgressFavicon'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
import type { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
import { i18n, loadLocale } from '@/i18n'
import AssetExportProgressDialog from '@/platform/assets/components/AssetExportProgressDialog.vue'
import ModelImportProgressDialog from '@/platform/assets/components/ModelImportProgressDialog.vue'
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -220,7 +222,7 @@ const onExecutionSuccess = async () => {
await queueStore.update()
// Only update assets if the assets sidebar is currently open
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
if (sidebarTabStore.activeSidebarTabId === 'assets') {
if (sidebarTabStore.activeSidebarTabId === 'assets' || linearMode.value) {
await assetsStore.updateHistory()
}
}