mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-02 19:40:03 +00:00
Compare commits
10 Commits
fix/folder
...
fix/folder
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6d540c6c0 | ||
|
|
611a1e2ece | ||
|
|
9c313c66a3 | ||
|
|
8099cce232 | ||
|
|
27d4a34435 | ||
|
|
e1e560403e | ||
|
|
aff0ebad50 | ||
|
|
44dc208339 | ||
|
|
388c21a88d | ||
|
|
b28f46d237 |
2
.github/workflows/ci-lint-format.yaml
vendored
2
.github/workflows/ci-lint-format.yaml
vendored
@@ -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 |
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>({
|
||||
|
||||
175
src/components/common/ScrubableNumberInput.vue
Normal file
175
src/components/common/ScrubableNumberInput.vue
Normal 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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
@@ -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 = ''
|
||||
}
|
||||
|
||||
15
src/components/ui/skeleton/Skeleton.vue
Normal file
15
src/components/ui/skeleton/Skeleton.vue
Normal 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>
|
||||
@@ -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",
|
||||
|
||||
254
src/platform/assets/components/AssetExportProgressDialog.vue
Normal file
254
src/platform/assets/components/AssetExportProgressDialog.vue
Normal 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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
199
src/stores/assetExportStore.ts
Normal file
199
src/stores/assetExportStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user