mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 09:48:09 +00:00
Compare commits
1 Commits
v1.41.3
...
search-imp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
436798d23d |
118
.github/workflows/ci-oss-assets-validation.yaml
vendored
118
.github/workflows/ci-oss-assets-validation.yaml
vendored
@@ -1,118 +0,0 @@
|
||||
name: 'CI: OSS Assets Validation'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
push:
|
||||
branches: [main, dev*]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate-fonts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build project
|
||||
run: pnpm build
|
||||
env:
|
||||
DISTRIBUTION: localhost
|
||||
|
||||
- name: Check for proprietary fonts in dist
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Checking dist for proprietary ABCROM fonts...'
|
||||
|
||||
if [ ! -d "dist" ] || [ -z "$(ls -A dist)" ]; then
|
||||
echo '❌ ERROR: dist/ directory missing or empty!'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for ABCROM font files
|
||||
if find dist/ -type f -iname '*abcrom*' \
|
||||
\( -name '*.woff' -o -name '*.woff2' -o -name '*.ttf' -o -name '*.otf' \) \
|
||||
-print -quit | grep -q .; then
|
||||
echo ''
|
||||
echo '❌ ERROR: Found proprietary ABCROM font files in dist!'
|
||||
echo ''
|
||||
find dist/ -type f -iname '*abcrom*' \
|
||||
\( -name '*.woff' -o -name '*.woff2' -o -name '*.ttf' -o -name '*.otf' \)
|
||||
echo ''
|
||||
echo 'ABCROM fonts are proprietary and should not ship to OSS builds.'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Use conditional font loading based on isCloud'
|
||||
echo '2. Ensure fonts are dynamically imported, not bundled'
|
||||
echo '3. Check vite config for font handling'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo '✅ No proprietary fonts found in dist'
|
||||
|
||||
validate-licenses:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@9fd676a19091d4595eefd76e4bd31c97133911f1 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Validate production dependency licenses
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Checking production dependency licenses...'
|
||||
|
||||
# Use license-checker-rseidelsohn (actively maintained fork, handles monorepos)
|
||||
# Exclude internal @comfyorg packages from license check
|
||||
# Run in if condition to capture exit code
|
||||
if npx license-checker-rseidelsohn@4 \
|
||||
--production \
|
||||
--summary \
|
||||
--excludePackages '@comfyorg/comfyui-frontend;@comfyorg/design-system;@comfyorg/registry-types;@comfyorg/shared-frontend-utils;@comfyorg/tailwind-utils;@comfyorg/comfyui-electron-types' \
|
||||
--onlyAllow 'MIT;MIT*;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;BlueOak-1.0.0;Python-2.0;CC0-1.0;Unlicense;(MIT OR Apache-2.0);(MIT OR GPL-3.0);(Apache-2.0 OR MIT);(MPL-2.0 OR Apache-2.0);CC-BY-4.0;CC-BY-3.0;GPL-3.0-only'; then
|
||||
echo ''
|
||||
echo '✅ All production dependency licenses are approved!'
|
||||
else
|
||||
echo ''
|
||||
echo '❌ ERROR: Found dependencies with non-approved licenses!'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Check the license of the problematic package'
|
||||
echo '2. Find an alternative package with an approved license'
|
||||
echo '3. If the license is safe and OSI-approved, add it to the --onlyAllow list'
|
||||
echo ''
|
||||
echo 'For more info on OSI-approved licenses:'
|
||||
echo 'https://opensource.org/licenses'
|
||||
exit 1
|
||||
fi
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -64,7 +64,6 @@ browser_tests/local/
|
||||
dist.zip
|
||||
|
||||
/temp/
|
||||
/tmp/
|
||||
|
||||
# Generated JSON Schemas
|
||||
/schemas/
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Widget Serialization: `widget.serialize` vs `widget.options.serialize`
|
||||
|
||||
Two properties named `serialize` exist at different levels of a widget object. They control different serialization layers and are checked by completely different code paths.
|
||||
|
||||
**`widget.serialize`** — Controls **workflow persistence**. Checked by `LGraphNode.serialize()` and `configure()` when reading/writing `widgets_values` in the workflow JSON. When `false`, the widget is skipped in both serialization and deserialization. Used for UI-only widgets (image previews, progress text, audio players). Typed as `IBaseWidget.serialize` in `src/lib/litegraph/src/types/widgets.ts`.
|
||||
|
||||
**`widget.options.serialize`** — Controls **prompt/API serialization**. Checked by `executionUtil.ts` when building the API payload sent to the backend. When `false`, the widget is excluded from prompt inputs. Used for client-side-only controls (`control_after_generate`, combo filter lists) that the server doesn't need. Typed as `IWidgetOptions.serialize` in `src/lib/litegraph/src/types/widgets.ts`.
|
||||
|
||||
These correspond to the two data formats in `ComfyMetadata` embedded in output files (PNG, GLTF, WebM, AVIF, etc.): `widget.serialize` → `ComfyMetadataTags.WORKFLOW`, `widget.options.serialize` → `ComfyMetadataTags.PROMPT`.
|
||||
|
||||
## Permutation table
|
||||
|
||||
| `widget.serialize` | `widget.options.serialize` | In workflow? | In prompt? | Examples |
|
||||
| ------------------ | -------------------------- | ------------ | ---------- | -------------------------------------------------------------------- |
|
||||
| ✅ default | ✅ default | Yes | Yes | seed, cfg, sampler_name |
|
||||
| ✅ default | ❌ false | Yes | No | control_after_generate, combo filter list |
|
||||
| ❌ false | ✅ default | No | Yes | No current usage (would be a transient value computed at queue time) |
|
||||
| ❌ false | ❌ false | No | No | Image/video previews, audio players, progress text |
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `addWidget('combo', name, value, cb, { serialize: false })` puts `serialize` into `widget.options`, **not** onto `widget` directly. These are different properties consumed by different systems.
|
||||
- `LGraphNode.serialize()` checks `widget.serialize === false` (line 967). It does **not** check `widget.options.serialize`. A widget with `options.serialize = false` is still included in `widgets_values`.
|
||||
- `LGraphNode.serialize()` only writes `widgets_values` if `this.widgets` is truthy. Nodes that create widgets dynamically (like `PrimitiveNode`) will have no `widgets_values` in serialized output if serialized before widget creation — even if `this.widgets_values` exists on the instance from a prior `configure()` call.
|
||||
- `widget.options.serialize` is typed as `IWidgetOptions.serialize` — both properties share the name `serialize` but live at different levels of the widget object.
|
||||
|
||||
## Code references
|
||||
|
||||
- `widget.serialize` checked: `src/lib/litegraph/src/LGraphNode.ts` serialize() and configure()
|
||||
- `widget.options.serialize` checked: `src/utils/executionUtil.ts`
|
||||
- `widget.options.serialize` set: `src/scripts/widgets.ts` addValueControlWidgets()
|
||||
- `widget.serialize` set: `src/composables/node/useNodeImage.ts`, `src/extensions/core/previewAny.ts`, etc.
|
||||
- Metadata types: `src/types/metadataTypes.ts`
|
||||
@@ -39,9 +39,7 @@ const config: KnipConfig = {
|
||||
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
// Used by a custom node (that should move off of this)
|
||||
'src/scripts/ui/components/splitButton.ts',
|
||||
// Workflow files contain license names that knip misinterprets as binaries
|
||||
'.github/workflows/ci-oss-assets-validation.yaml'
|
||||
'src/scripts/ui/components/splitButton.ts'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.41.3",
|
||||
"version": "1.41.1",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import VirtualGrid from './VirtualGrid.vue'
|
||||
|
||||
type TestItem = { key: string; name: string }
|
||||
|
||||
let mockedWidth: Ref<number>
|
||||
let mockedHeight: Ref<number>
|
||||
let mockedScrollY: Ref<number>
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>('@vueuse/core')
|
||||
return {
|
||||
...actual,
|
||||
useElementSize: () => ({ width: mockedWidth, height: mockedHeight }),
|
||||
useScroll: () => ({ y: mockedScrollY })
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mockedWidth = ref(400)
|
||||
mockedHeight = ref(200)
|
||||
mockedScrollY = ref(0)
|
||||
})
|
||||
|
||||
function createItems(count: number): TestItem[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
key: `item-${i}`,
|
||||
name: `Item ${i}`
|
||||
}))
|
||||
}
|
||||
|
||||
describe('VirtualGrid', () => {
|
||||
const defaultGridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
gap: '1rem'
|
||||
}
|
||||
|
||||
it('renders items within the visible range', async () => {
|
||||
const items = createItems(100)
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
defaultItemHeight: 100,
|
||||
defaultItemWidth: 100,
|
||||
maxColumns: 4,
|
||||
bufferRows: 1
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const renderedItems = wrapper.findAll('.test-item')
|
||||
expect(renderedItems.length).toBeGreaterThan(0)
|
||||
expect(renderedItems.length).toBeLessThan(items.length)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('provides correct index in slot props', async () => {
|
||||
const items = createItems(20)
|
||||
const receivedIndices: number[] = []
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
defaultItemHeight: 50,
|
||||
defaultItemWidth: 100,
|
||||
maxColumns: 1,
|
||||
bufferRows: 0
|
||||
},
|
||||
slots: {
|
||||
item: ({ index }: { index: number }) => {
|
||||
receivedIndices.push(index)
|
||||
return null
|
||||
}
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(receivedIndices.length).toBeGreaterThan(0)
|
||||
expect(receivedIndices[0]).toBe(0)
|
||||
for (let i = 1; i < receivedIndices.length; i++) {
|
||||
expect(receivedIndices[i]).toBe(receivedIndices[i - 1] + 1)
|
||||
}
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('respects maxColumns prop', async () => {
|
||||
const items = createItems(10)
|
||||
mockedWidth.value = 400
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
maxColumns: 2
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const gridElement = wrapper.find('[style*="display: grid"]')
|
||||
expect(gridElement.exists()).toBe(true)
|
||||
|
||||
const gridEl = gridElement.element as HTMLElement
|
||||
expect(gridEl.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders empty when no items provided', async () => {
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items: [],
|
||||
gridStyle: defaultGridStyle
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const renderedItems = wrapper.findAll('.test-item')
|
||||
expect(renderedItems.length).toBe(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('forces cols to maxColumns when maxColumns is finite', async () => {
|
||||
mockedWidth.value = 100
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const items = createItems(20)
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
defaultItemHeight: 50,
|
||||
defaultItemWidth: 200,
|
||||
maxColumns: 4,
|
||||
bufferRows: 0
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const renderedItems = wrapper.findAll('.test-item')
|
||||
expect(renderedItems.length).toBeGreaterThan(0)
|
||||
expect(renderedItems.length % 4).toBe(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,17 @@
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable] scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
|
||||
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
|
||||
>
|
||||
<div :style="topSpacerStyle" />
|
||||
<div :style="mergedGridStyle">
|
||||
<div
|
||||
v-for="(item, i) in renderedItems"
|
||||
v-for="item in renderedItems"
|
||||
:key="item.key"
|
||||
class="transition-[width] duration-150 ease-out"
|
||||
data-virtual-grid-item
|
||||
>
|
||||
<slot name="item" :item :index="state.start + i" />
|
||||
<slot name="item" :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
<div :style="bottomSpacerStyle" />
|
||||
@@ -65,10 +66,9 @@ const { y: scrollY } = useScroll(container, {
|
||||
eventListenerOptions: { passive: true }
|
||||
})
|
||||
|
||||
const cols = computed(() => {
|
||||
if (maxColumns !== Infinity) return maxColumns
|
||||
return Math.floor(width.value / itemWidth.value) || 1
|
||||
})
|
||||
const cols = computed(() =>
|
||||
Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns)
|
||||
)
|
||||
|
||||
const mergedGridStyle = computed<CSSProperties>(() => {
|
||||
if (maxColumns === Infinity) return gridStyle
|
||||
@@ -101,9 +101,8 @@ const renderedItems = computed(() =>
|
||||
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
|
||||
)
|
||||
|
||||
function rowsToHeight(itemsCount: number): string {
|
||||
const rows = Math.ceil(itemsCount / cols.value)
|
||||
return `${rows * itemHeight.value}px`
|
||||
function rowsToHeight(rows: number): string {
|
||||
return `${(rows / cols.value) * itemHeight.value}px`
|
||||
}
|
||||
const topSpacerStyle = computed<CSSProperties>(() => ({
|
||||
height: rowsToHeight(state.value.start)
|
||||
@@ -119,10 +118,11 @@ whenever(
|
||||
}
|
||||
)
|
||||
|
||||
function updateItemSize(): void {
|
||||
const updateItemSize = () => {
|
||||
if (container.value) {
|
||||
const firstItem = container.value.querySelector('[data-virtual-grid-item]')
|
||||
|
||||
// Don't update item size if the first item is not rendered yet
|
||||
if (!firstItem?.clientHeight || !firstItem?.clientWidth) return
|
||||
|
||||
if (itemHeight.value !== firstItem.clientHeight) {
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
</p>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="flex flex-wrap gap-2 empty:hidden">
|
||||
<NodePricingBadge :node-def="nodeDef" />
|
||||
<div class="flex flex-wrap gap-2 empty:hidden overflow-hidden">
|
||||
<NodePricingBadge class="max-w-full truncate" :node-def="nodeDef" />
|
||||
<NodeProviderBadge :node-def="nodeDef" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
vi.mock('@/composables/queue/useJobMenu', () => ({
|
||||
useJobMenu: () => ({ jobMenuEntries: [] })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
wrapWithErrorHandlingAsync: <T extends (...args: never[]) => unknown>(
|
||||
fn: T
|
||||
) => fn
|
||||
})
|
||||
}))
|
||||
|
||||
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
|
||||
|
||||
const QueueOverlayHeaderStub = {
|
||||
template: '<div />'
|
||||
}
|
||||
|
||||
const JobFiltersBarStub = {
|
||||
template: '<div />'
|
||||
}
|
||||
|
||||
const JobAssetsListStub = {
|
||||
name: 'JobAssetsList',
|
||||
template: '<div class="job-assets-list-stub" />'
|
||||
}
|
||||
|
||||
const JobContextMenuStub = {
|
||||
template: '<div />'
|
||||
}
|
||||
|
||||
const createJob = (): JobListItem => ({
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: 'meta',
|
||||
state: 'pending'
|
||||
})
|
||||
|
||||
const mountComponent = () =>
|
||||
mount(QueueOverlayExpanded, {
|
||||
props: {
|
||||
headerTitle: 'Jobs',
|
||||
queuedCount: 1,
|
||||
selectedJobTab: 'All',
|
||||
selectedWorkflowFilter: 'all',
|
||||
selectedSortMode: 'mostRecent',
|
||||
displayedJobGroups: [],
|
||||
hasFailedJobs: false
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
QueueOverlayHeader: QueueOverlayHeaderStub,
|
||||
JobFiltersBar: JobFiltersBarStub,
|
||||
JobAssetsList: JobAssetsListStub,
|
||||
JobContextMenu: JobContextMenuStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('QueueOverlayExpanded', () => {
|
||||
it('renders JobAssetsList', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.find('.job-assets-list-stub').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('re-emits list item actions from JobAssetsList', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const job = createJob()
|
||||
const jobAssetsList = wrapper.findComponent({ name: 'JobAssetsList' })
|
||||
|
||||
jobAssetsList.vm.$emit('cancel-item', job)
|
||||
jobAssetsList.vm.$emit('delete-item', job)
|
||||
jobAssetsList.vm.$emit('view-item', job)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.emitted('cancelItem')?.[0]).toEqual([job])
|
||||
expect(wrapper.emitted('deleteItem')?.[0]).toEqual([job])
|
||||
expect(wrapper.emitted('viewItem')?.[0]).toEqual([job])
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,8 @@
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<QueueOverlayHeader
|
||||
:header-title="headerTitle"
|
||||
:show-concurrent-indicator="showConcurrentIndicator"
|
||||
:concurrent-workflow-count="concurrentWorkflowCount"
|
||||
:queued-count="queuedCount"
|
||||
@clear-history="$emit('clearHistory')"
|
||||
@clear-queued="$emit('clearQueued')"
|
||||
@@ -21,7 +23,7 @@
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
<JobAssetsList
|
||||
<JobGroupsList
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
@cancel-item="onCancelItemEvent"
|
||||
@delete-item="onDeleteItemEvent"
|
||||
@@ -53,11 +55,13 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
|
||||
import QueueOverlayHeader from './QueueOverlayHeader.vue'
|
||||
import JobContextMenu from './job/JobContextMenu.vue'
|
||||
import JobAssetsList from './job/JobAssetsList.vue'
|
||||
import JobFiltersBar from './job/JobFiltersBar.vue'
|
||||
import JobGroupsList from './job/JobGroupsList.vue'
|
||||
|
||||
defineProps<{
|
||||
headerTitle: string
|
||||
showConcurrentIndicator: boolean
|
||||
concurrentWorkflowCount: number
|
||||
queuedCount: number
|
||||
selectedJobTab: JobTab
|
||||
selectedWorkflowFilter: 'all' | 'current'
|
||||
|
||||
@@ -51,10 +51,9 @@ const i18n = createI18n({
|
||||
g: { more: 'More' },
|
||||
sideToolbar: {
|
||||
queueProgressOverlay: {
|
||||
running: 'running',
|
||||
queuedSuffix: 'queued',
|
||||
clearQueued: 'Clear queued',
|
||||
clearQueueTooltip: 'Clear queue',
|
||||
clearAllJobsTooltip: 'Cancel all running jobs',
|
||||
moreOptions: 'More options',
|
||||
clearHistory: 'Clear history',
|
||||
dockedJobHistory: 'Docked Job History'
|
||||
@@ -68,6 +67,8 @@ const mountHeader = (props = {}) =>
|
||||
mount(QueueOverlayHeader, {
|
||||
props: {
|
||||
headerTitle: 'Job queue',
|
||||
showConcurrentIndicator: true,
|
||||
concurrentWorkflowCount: 2,
|
||||
queuedCount: 3,
|
||||
...props
|
||||
},
|
||||
@@ -83,28 +84,40 @@ describe('QueueOverlayHeader', () => {
|
||||
mockSetSetting.mockClear()
|
||||
})
|
||||
|
||||
it('renders header title', () => {
|
||||
const wrapper = mountHeader()
|
||||
it('renders header title and concurrent indicator when enabled', () => {
|
||||
const wrapper = mountHeader({ concurrentWorkflowCount: 3 })
|
||||
|
||||
expect(wrapper.text()).toContain('Job queue')
|
||||
const indicator = wrapper.find('.inline-flex.items-center.gap-1')
|
||||
expect(indicator.exists()).toBe(true)
|
||||
expect(indicator.text()).toContain('3')
|
||||
expect(indicator.text()).toContain('running')
|
||||
})
|
||||
|
||||
it('shows clear queue text and emits clear queued', async () => {
|
||||
it('hides concurrent indicator when flag is false', () => {
|
||||
const wrapper = mountHeader({ showConcurrentIndicator: false })
|
||||
|
||||
expect(wrapper.text()).toContain('Job queue')
|
||||
expect(wrapper.find('.inline-flex.items-center.gap-1').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows queued summary and emits clear queued', async () => {
|
||||
const wrapper = mountHeader({ queuedCount: 4 })
|
||||
|
||||
expect(wrapper.text()).toContain('Clear queue')
|
||||
expect(wrapper.text()).not.toContain('4 queued')
|
||||
expect(wrapper.text()).toContain('4')
|
||||
expect(wrapper.text()).toContain('queued')
|
||||
|
||||
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
|
||||
await clearQueuedButton.trigger('click')
|
||||
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('disables clear queued button when queued count is zero', () => {
|
||||
it('hides clear queued button when queued count is zero', () => {
|
||||
const wrapper = mountHeader({ queuedCount: 0 })
|
||||
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
|
||||
|
||||
expect(clearQueuedButton.attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).toContain('Clear queue')
|
||||
expect(wrapper.find('button[aria-label="Clear queued"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('emits clear history from the menu', async () => {
|
||||
|
||||
@@ -4,19 +4,34 @@
|
||||
>
|
||||
<div class="min-w-0 flex-1 px-2 text-[14px] font-normal text-text-primary">
|
||||
<span>{{ headerTitle }}</span>
|
||||
<span
|
||||
v-if="showConcurrentIndicator"
|
||||
class="ml-4 inline-flex items-center gap-1 text-blue-100"
|
||||
>
|
||||
<span class="inline-block size-2 rounded-full bg-blue-100" />
|
||||
<span>
|
||||
<span class="font-bold">{{ concurrentWorkflowCount }}</span>
|
||||
<span class="ml-1">{{
|
||||
t('sideToolbar.queueProgressOverlay.running')
|
||||
}}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="inline-flex h-6 items-center gap-2 text-[12px] leading-none text-text-primary"
|
||||
>
|
||||
<span :class="{ 'opacity-50': queuedCount === 0 }">{{
|
||||
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
|
||||
}}</span>
|
||||
<span class="opacity-90">
|
||||
<span class="font-bold">{{ queuedCount }}</span>
|
||||
<span class="ml-1">{{
|
||||
t('sideToolbar.queueProgressOverlay.queuedSuffix')
|
||||
}}</span>
|
||||
</span>
|
||||
<Button
|
||||
v-if="queuedCount > 0"
|
||||
v-tooltip.top="clearAllJobsTooltip"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
|
||||
:disabled="queuedCount === 0"
|
||||
@click="$emit('clearQueued')"
|
||||
>
|
||||
<i class="icon-[lucide--list-x] size-4" />
|
||||
@@ -36,6 +51,8 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
|
||||
defineProps<{
|
||||
headerTitle: string
|
||||
showConcurrentIndicator: boolean
|
||||
concurrentWorkflowCount: number
|
||||
queuedCount: number
|
||||
}>()
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
v-model:selected-sort-mode="selectedSortMode"
|
||||
class="flex-1 min-h-0"
|
||||
:header-title="headerTitle"
|
||||
:show-concurrent-indicator="showConcurrentIndicator"
|
||||
:concurrent-workflow-count="concurrentWorkflowCount"
|
||||
:queued-count="queuedCount"
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
:has-failed-jobs="hasFailedJobs"
|
||||
@@ -181,6 +183,13 @@ const headerTitle = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const concurrentWorkflowCount = computed(
|
||||
() => executionStore.runningWorkflowCount
|
||||
)
|
||||
const showConcurrentIndicator = computed(
|
||||
() => concurrentWorkflowCount.value > 1
|
||||
)
|
||||
|
||||
const {
|
||||
selectedJobTab,
|
||||
selectedWorkflowFilter,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-col overflow-y-auto py-2.5">
|
||||
<div ref="sidebarRef" class="flex min-h-0 flex-col overflow-y-auto py-2.5">
|
||||
<!-- Preset categories -->
|
||||
<div class="flex flex-col px-1">
|
||||
<button
|
||||
@@ -38,6 +38,7 @@
|
||||
:node="category"
|
||||
:selected-category="selectedCategory"
|
||||
:selected-collapsed="selectedCollapsed"
|
||||
:show-chevrons="isHovered"
|
||||
@select="selectCategory"
|
||||
/>
|
||||
</div>
|
||||
@@ -45,6 +46,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementHover } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -64,6 +66,9 @@ const selectedCategory = defineModel<string>('selectedCategory', {
|
||||
required: true
|
||||
})
|
||||
|
||||
const sidebarRef = ref<HTMLElement>()
|
||||
const isHovered = useElementHover(sidebarRef)
|
||||
|
||||
const { t } = useI18n()
|
||||
const { flags } = useFeatureFlags()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
@@ -1,32 +1,47 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:data-testid="`category-${node.key}`"
|
||||
:aria-current="selectedCategory === node.key || undefined"
|
||||
:style="{ paddingLeft: `${0.75 + depth * 1.25}rem` }"
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'w-full cursor-pointer rounded border-none bg-transparent py-2.5 pr-3 text-left text-sm transition-colors',
|
||||
selectedCategory === node.key
|
||||
? CATEGORY_SELECTED_CLASS
|
||||
: CATEGORY_UNSELECTED_CLASS
|
||||
isExpanded &&
|
||||
hasChildren &&
|
||||
'flex flex-col rounded-lg bg-secondary-background'
|
||||
)
|
||||
"
|
||||
@click="$emit('select', node.key)"
|
||||
>
|
||||
{{ node.label }}
|
||||
</button>
|
||||
<template v-if="isExpanded && node.children?.length">
|
||||
<NodeSearchCategoryTreeNode
|
||||
v-for="child in node.children"
|
||||
:key="child.key"
|
||||
:node="child"
|
||||
:depth="depth + 1"
|
||||
:selected-category="selectedCategory"
|
||||
:selected-collapsed="selectedCollapsed"
|
||||
@select="$emit('select', $event)"
|
||||
/>
|
||||
</template>
|
||||
<Button
|
||||
type="button"
|
||||
variant="muted-textonly"
|
||||
size="unset"
|
||||
:data-testid="`category-${node.key}`"
|
||||
:aria-current="selectedCategory === node.key || undefined"
|
||||
:class="categoryButtonClass"
|
||||
:style="{ paddingLeft: paddingLeft }"
|
||||
@click="$emit('select', node.key)"
|
||||
>
|
||||
<i
|
||||
v-if="showChevrons && hasChildren"
|
||||
:class="
|
||||
cn(
|
||||
'pi pi-chevron-down shrink-0 text-[10px] transition-transform',
|
||||
!isExpanded && '-rotate-90'
|
||||
)
|
||||
"
|
||||
/>
|
||||
{{ node.label }}
|
||||
</Button>
|
||||
<template v-if="isExpanded && hasChildren">
|
||||
<NodeSearchCategoryTreeNode
|
||||
v-for="child in node.children"
|
||||
:key="child.key"
|
||||
:node="child"
|
||||
:depth="depth + 1"
|
||||
:selected-category="selectedCategory"
|
||||
:selected-collapsed="selectedCollapsed"
|
||||
:show-chevrons="showChevrons"
|
||||
@select="$emit('select', $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -45,26 +60,43 @@ export const CATEGORY_UNSELECTED_CLASS =
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
node,
|
||||
depth = 0,
|
||||
selectedCategory,
|
||||
selectedCollapsed = false
|
||||
selectedCollapsed = false,
|
||||
showChevrons = false
|
||||
} = defineProps<{
|
||||
node: CategoryNode
|
||||
depth?: number
|
||||
selectedCategory: string
|
||||
selectedCollapsed?: boolean
|
||||
showChevrons?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: [key: string]
|
||||
}>()
|
||||
|
||||
const hasChildren = computed(() => (node.children?.length ?? 0) > 0)
|
||||
|
||||
const isExpanded = computed(() => {
|
||||
if (selectedCategory === node.key) return !selectedCollapsed
|
||||
return selectedCategory.startsWith(node.key + '/')
|
||||
})
|
||||
|
||||
const paddingLeft = computed(() => {
|
||||
if (hasChildren.value && showChevrons) return `${0.5 + depth * 1.25}rem`
|
||||
return `${0.75 + depth * 1.25}rem`
|
||||
})
|
||||
|
||||
const categoryButtonClass = computed(() =>
|
||||
cn(
|
||||
'w-full justify-start rounded py-3 pr-3 font-normal hover:text-foreground',
|
||||
selectedCategory === node.key && CATEGORY_SELECTED_CLASS
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<NodeSearchFilterBar
|
||||
class="flex-1"
|
||||
:active-chip-key="activeFilter?.key"
|
||||
:applied-filters="filters"
|
||||
@select-chip="onSelectFilterChip"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 px-2 py-1.5">
|
||||
<button
|
||||
<Button
|
||||
v-for="chip in chips"
|
||||
:key="chip.key"
|
||||
type="button"
|
||||
:variant="chipVariant(chip.key)"
|
||||
size="unset"
|
||||
:aria-pressed="activeChipKey === chip.key"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer rounded-md border px-3 py-1 text-sm transition-colors flex-auto border-secondary-background',
|
||||
activeChipKey === chip.key
|
||||
? 'bg-secondary-background text-foreground'
|
||||
: 'bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
|
||||
)
|
||||
"
|
||||
:class="chipExtraClass(chip.key)"
|
||||
@click="emit('selectChip', chip)"
|
||||
>
|
||||
{{ chip.label }}
|
||||
</button>
|
||||
<span
|
||||
v-if="appliedFilterCounts[chip.key]"
|
||||
class="ml-0.5 text-xs opacity-80"
|
||||
>
|
||||
({{ appliedFilterCounts[chip.key] }})
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { FuseFilter } from '@/utils/fuseUtil'
|
||||
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
export interface FilterChip {
|
||||
@@ -35,11 +36,14 @@ export interface FilterChip {
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button/button.variants'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { activeChipKey = null } = defineProps<{
|
||||
const { activeChipKey = null, appliedFilters = [] } = defineProps<{
|
||||
activeChipKey?: string | null
|
||||
appliedFilters?: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -69,4 +73,29 @@ const chips = computed<FilterChip[]>(() => {
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const appliedFilterCounts = computed(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const filter of appliedFilters) {
|
||||
counts[filter.filterDef.id] = (counts[filter.filterDef.id] ?? 0) + 1
|
||||
}
|
||||
return counts
|
||||
})
|
||||
|
||||
function chipVariant(chipKey: string): ButtonVariants['variant'] {
|
||||
return activeChipKey === chipKey ? 'inverted' : 'outline'
|
||||
}
|
||||
|
||||
function chipExtraClass(chipKey: string) {
|
||||
const isActive = activeChipKey === chipKey
|
||||
const hasApplied = (appliedFilterCounts.value[chipKey] ?? 0) > 0
|
||||
|
||||
return cn(
|
||||
'flex-auto px-3 py-1',
|
||||
isActive && 'border border-solid border-base-foreground',
|
||||
!isActive &&
|
||||
hasApplied &&
|
||||
'bg-base-foreground/10 border-base-foreground text-base-foreground'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -154,7 +154,6 @@ import {
|
||||
render
|
||||
} from 'vue'
|
||||
|
||||
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import type { SearchFilter } from '@/components/common/SearchFilterChip.vue'
|
||||
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
@@ -277,9 +276,7 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
|
||||
return {
|
||||
key: node.key,
|
||||
label: node.leaf
|
||||
? (resolveEssentialsDisplayName(node.data) ?? node.data.display_name)
|
||||
: node.label,
|
||||
label: node.leaf ? node.data.display_name : node.label,
|
||||
leaf: node.leaf,
|
||||
data: node.data,
|
||||
getIcon() {
|
||||
|
||||
@@ -114,7 +114,6 @@ import {
|
||||
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
|
||||
import SearchBox from '@/components/common/SearchBoxV2.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
@@ -231,23 +230,16 @@ function findFirstLeaf(node: TreeNode): TreeNode | undefined {
|
||||
}
|
||||
|
||||
function fillNodeInfo(
|
||||
node: TreeNode,
|
||||
{ useEssentialsLabels = false }: { useEssentialsLabels?: boolean } = {}
|
||||
node: TreeNode
|
||||
): RenderedTreeExplorerNode<ComfyNodeDefImpl> {
|
||||
const children = node.children?.map((child) =>
|
||||
fillNodeInfo(child, { useEssentialsLabels })
|
||||
)
|
||||
const children = node.children?.map(fillNodeInfo)
|
||||
const totalLeaves = node.leaf
|
||||
? 1
|
||||
: (children?.reduce((acc, child) => acc + child.totalLeaves, 0) ?? 0)
|
||||
|
||||
return {
|
||||
key: node.key,
|
||||
label: node.leaf
|
||||
? useEssentialsLabels
|
||||
? (resolveEssentialsDisplayName(node.data) ?? node.data?.display_name)
|
||||
: node.data?.display_name
|
||||
: node.label,
|
||||
label: node.leaf ? node.data?.display_name : node.label,
|
||||
leaf: node.leaf,
|
||||
data: node.data,
|
||||
icon: node.leaf ? 'icon-[comfy--node]' : getFolderIcon(node),
|
||||
@@ -282,7 +274,7 @@ const essentialSections = computed(() => {
|
||||
const renderedEssentialRoot = computed(() => {
|
||||
const section = essentialSections.value[0]
|
||||
return section
|
||||
? fillNodeInfo(applySorting(section.tree), { useEssentialsLabels: true })
|
||||
? fillNodeInfo(applySorting(section.tree))
|
||||
: fillNodeInfo({ key: 'root', label: '', children: [] })
|
||||
})
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('EssentialNodeCard', () => {
|
||||
|
||||
return {
|
||||
key: 'test-key',
|
||||
label: data.display_name,
|
||||
label: 'Test Node',
|
||||
icon: 'icon-[comfy--node]',
|
||||
type: 'node',
|
||||
totalLeaves: 1,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="group relative flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border aspect-square"
|
||||
:data-node-name="node.label"
|
||||
:data-node-name="node.data?.display_name"
|
||||
draggable="true"
|
||||
@click="handleClick"
|
||||
@dragstart="handleDragStart"
|
||||
@@ -16,7 +16,7 @@
|
||||
<TextTickerMultiLine
|
||||
class="shrink-0 h-8 w-full text-xs font-bold text-foreground leading-4"
|
||||
>
|
||||
{{ node.label }}
|
||||
{{ node.data?.display_name }}
|
||||
</TextTickerMultiLine>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ export const buttonVariants = cva({
|
||||
'text-muted-foreground bg-transparent hover:bg-secondary-background-hover',
|
||||
'destructive-textonly':
|
||||
'text-destructive-background bg-transparent hover:bg-destructive-background/10',
|
||||
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90'
|
||||
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
|
||||
outline:
|
||||
'border border-solid border-muted-foreground bg-transparent text-muted-foreground hover:border-base-foreground/60 hover:text-base-foreground/60'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
|
||||
@@ -47,7 +49,8 @@ const variants = [
|
||||
'textonly',
|
||||
'muted-textonly',
|
||||
'destructive-textonly',
|
||||
'overlay-white'
|
||||
'overlay-white',
|
||||
'outline'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
|
||||
ButtonVariants['size']
|
||||
|
||||
@@ -2,9 +2,9 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAssetsSidebarTab } from '@/composables/sidebarTabs/useAssetsSidebarTab'
|
||||
|
||||
const { mockGetSetting, mockUnseenAddedAssetsCount } = vi.hoisted(() => ({
|
||||
const { mockGetSetting, mockActiveJobsCount } = vi.hoisted(() => ({
|
||||
mockGetSetting: vi.fn(),
|
||||
mockUnseenAddedAssetsCount: { value: 0 }
|
||||
mockActiveJobsCount: { value: 0 }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
@@ -17,16 +17,16 @@ vi.mock('@/components/sidebar/tabs/AssetsSidebarTab.vue', () => ({
|
||||
default: {}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/assetsSidebarBadgeStore', () => ({
|
||||
useAssetsSidebarBadgeStore: () => ({
|
||||
unseenAddedAssetsCount: mockUnseenAddedAssetsCount.value
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueStore: () => ({
|
||||
activeJobsCount: mockActiveJobsCount.value
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useAssetsSidebarTab', () => {
|
||||
it('hides icon badge when QPO V2 is disabled', () => {
|
||||
mockGetSetting.mockReturnValue(false)
|
||||
mockUnseenAddedAssetsCount.value = 3
|
||||
mockActiveJobsCount.value = 3
|
||||
|
||||
const sidebarTab = useAssetsSidebarTab()
|
||||
|
||||
@@ -34,9 +34,9 @@ describe('useAssetsSidebarTab', () => {
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows unseen added assets count when QPO V2 is enabled', () => {
|
||||
it('shows active job count when QPO V2 is enabled', () => {
|
||||
mockGetSetting.mockReturnValue(true)
|
||||
mockUnseenAddedAssetsCount.value = 3
|
||||
mockActiveJobsCount.value = 3
|
||||
|
||||
const sidebarTab = useAssetsSidebarTab()
|
||||
|
||||
@@ -44,9 +44,9 @@ describe('useAssetsSidebarTab', () => {
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBe('3')
|
||||
})
|
||||
|
||||
it('hides badge when there are no unseen added assets', () => {
|
||||
it('hides badge when no active jobs', () => {
|
||||
mockGetSetting.mockReturnValue(true)
|
||||
mockUnseenAddedAssetsCount.value = 0
|
||||
mockActiveJobsCount.value = 0
|
||||
|
||||
const sidebarTab = useAssetsSidebarTab()
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { markRaw } from 'vue'
|
||||
|
||||
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useAssetsSidebarBadgeStore } from '@/stores/workspace/assetsSidebarBadgeStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useAssetsSidebarTab = (): SidebarTabExtension => {
|
||||
@@ -21,8 +21,8 @@ export const useAssetsSidebarTab = (): SidebarTabExtension => {
|
||||
return null
|
||||
}
|
||||
|
||||
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
|
||||
const count = assetsSidebarBadgeStore.unseenAddedAssetsCount
|
||||
const queueStore = useQueueStore()
|
||||
const count = queueStore.activeJobsCount
|
||||
return count > 0 ? count.toString() : null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useJobHistorySidebarTab } from '@/composables/sidebarTabs/useJobHistorySidebarTab'
|
||||
|
||||
const { mockActiveJobsCount, mockActiveSidebarTabId } = vi.hoisted(() => ({
|
||||
mockActiveJobsCount: { value: 0 },
|
||||
mockActiveSidebarTabId: { value: null as string | null }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/sidebar/tabs/JobHistorySidebarTab.vue', () => ({
|
||||
default: {}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueStore: () => ({
|
||||
activeJobsCount: mockActiveJobsCount.value
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||
useSidebarTabStore: () => ({
|
||||
activeSidebarTabId: mockActiveSidebarTabId.value
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useJobHistorySidebarTab', () => {
|
||||
beforeEach(() => {
|
||||
mockActiveSidebarTabId.value = null
|
||||
mockActiveJobsCount.value = 0
|
||||
})
|
||||
|
||||
it('shows active jobs count while the panel is closed', () => {
|
||||
mockActiveSidebarTabId.value = 'assets'
|
||||
mockActiveJobsCount.value = 3
|
||||
|
||||
const sidebarTab = useJobHistorySidebarTab()
|
||||
|
||||
expect(typeof sidebarTab.iconBadge).toBe('function')
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBe('3')
|
||||
})
|
||||
|
||||
it('hides badge while the job history panel is open', () => {
|
||||
mockActiveSidebarTabId.value = 'job-history'
|
||||
mockActiveJobsCount.value = 3
|
||||
|
||||
const sidebarTab = useJobHistorySidebarTab()
|
||||
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
|
||||
})
|
||||
|
||||
it('hides badge when there are no active jobs', () => {
|
||||
mockActiveSidebarTabId.value = null
|
||||
mockActiveJobsCount.value = 0
|
||||
|
||||
const sidebarTab = useJobHistorySidebarTab()
|
||||
|
||||
expect((sidebarTab.iconBadge as () => string | null)()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,6 @@
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import JobHistorySidebarTab from '@/components/sidebar/tabs/JobHistorySidebarTab.vue'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useJobHistorySidebarTab = (): SidebarTabExtension => {
|
||||
@@ -13,16 +11,6 @@ export const useJobHistorySidebarTab = (): SidebarTabExtension => {
|
||||
tooltip: 'queue.jobHistory',
|
||||
label: 'queue.jobHistory',
|
||||
component: markRaw(JobHistorySidebarTab),
|
||||
type: 'vue',
|
||||
iconBadge: () => {
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
if (sidebarTabStore.activeSidebarTabId === 'job-history') {
|
||||
return null
|
||||
}
|
||||
|
||||
const queueStore = useQueueStore()
|
||||
const count = queueStore.activeJobsCount
|
||||
return count > 0 ? count.toString() : null
|
||||
}
|
||||
type: 'vue'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: vi.fn((key: string) => key)
|
||||
}))
|
||||
|
||||
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
|
||||
|
||||
describe('resolveEssentialsDisplayName', () => {
|
||||
describe('exact name matches', () => {
|
||||
it.each([
|
||||
['LoadImage', 'essentials.loadImage'],
|
||||
['SaveImage', 'essentials.saveImage'],
|
||||
['PrimitiveStringMultiline', 'essentials.text'],
|
||||
['ImageScale', 'essentials.resizeImage'],
|
||||
['LoraLoader', 'essentials.loadStyleLora'],
|
||||
['OpenAIChatNode', 'essentials.textGenerationLLM'],
|
||||
['RecraftRemoveBackgroundNode', 'essentials.removeBackground'],
|
||||
['ImageCompare', 'essentials.imageCompare'],
|
||||
['StabilityTextToAudio', 'essentials.musicGeneration'],
|
||||
['BatchImagesNode', 'essentials.batchImage'],
|
||||
['Video Slice', 'essentials.extractFrame'],
|
||||
['KlingLipSyncAudioToVideoNode', 'essentials.lipsync'],
|
||||
['KlingLipSyncTextToVideoNode', 'essentials.lipsync']
|
||||
])('%s -> %s', (name, expected) => {
|
||||
expect(resolveEssentialsDisplayName({ name })).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('3D API node alternatives', () => {
|
||||
it.each([
|
||||
['TencentTextToModelNode', 'essentials.textTo3DModel'],
|
||||
['MeshyTextToModelNode', 'essentials.textTo3DModel'],
|
||||
['TripoTextToModelNode', 'essentials.textTo3DModel'],
|
||||
['TencentImageToModelNode', 'essentials.imageTo3DModel'],
|
||||
['MeshyImageToModelNode', 'essentials.imageTo3DModel'],
|
||||
['TripoImageToModelNode', 'essentials.imageTo3DModel']
|
||||
])('%s -> %s', (name, expected) => {
|
||||
expect(resolveEssentialsDisplayName({ name })).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('blueprint prefix matches', () => {
|
||||
it.each([
|
||||
[
|
||||
'SubgraphBlueprint.text_to_image_flux_schnell.json',
|
||||
'essentials.textToImage'
|
||||
],
|
||||
['SubgraphBlueprint.text_to_image_sd15.json', 'essentials.textToImage'],
|
||||
[
|
||||
'SubgraphBlueprint.image_edit_something.json',
|
||||
'essentials.imageToImage'
|
||||
],
|
||||
['SubgraphBlueprint.pose_to_image_v2.json', 'essentials.poseToImage'],
|
||||
[
|
||||
'SubgraphBlueprint.canny_to_image_z_image_turbo.json',
|
||||
'essentials.cannyToImage'
|
||||
],
|
||||
[
|
||||
'SubgraphBlueprint.depth_to_image_z_image_turbo.json',
|
||||
'essentials.depthToImage'
|
||||
],
|
||||
['SubgraphBlueprint.text_to_video_ltx.json', 'essentials.textToVideo'],
|
||||
['SubgraphBlueprint.image_to_video_wan.json', 'essentials.imageToVideo'],
|
||||
[
|
||||
'SubgraphBlueprint.pose_to_video_ltx_2_0.json',
|
||||
'essentials.poseToVideo'
|
||||
],
|
||||
[
|
||||
'SubgraphBlueprint.canny_to_video_ltx_2_0.json',
|
||||
'essentials.cannyToVideo'
|
||||
],
|
||||
[
|
||||
'SubgraphBlueprint.depth_to_video_ltx_2_0.json',
|
||||
'essentials.depthToVideo'
|
||||
],
|
||||
[
|
||||
'SubgraphBlueprint.image_inpainting_qwen_image_instantx.json',
|
||||
'essentials.inpaintImage'
|
||||
],
|
||||
[
|
||||
'SubgraphBlueprint.image_outpainting_qwen_image_instantx.json',
|
||||
'essentials.outpaintImage'
|
||||
]
|
||||
])('%s -> %s', (name, expected) => {
|
||||
expect(resolveEssentialsDisplayName({ name })).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unmapped nodes', () => {
|
||||
it('returns undefined for unknown node names', () => {
|
||||
expect(resolveEssentialsDisplayName({ name: 'SomeRandomNode' })).toBe(
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('returns undefined for unknown blueprint prefixes', () => {
|
||||
expect(
|
||||
resolveEssentialsDisplayName({
|
||||
name: 'SubgraphBlueprint.unknown_workflow.json'
|
||||
})
|
||||
).toBe(undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,108 +0,0 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
|
||||
const BLUEPRINT_PREFIX = 'SubgraphBlueprint.'
|
||||
|
||||
/**
|
||||
* Static mapping of node names to their Essentials tab display name i18n keys.
|
||||
*/
|
||||
const EXACT_NAME_MAP: Record<string, string> = {
|
||||
// Basics
|
||||
LoadImage: 'essentials.loadImage',
|
||||
SaveImage: 'essentials.saveImage',
|
||||
LoadVideo: 'essentials.loadVideo',
|
||||
SaveVideo: 'essentials.saveVideo',
|
||||
Load3D: 'essentials.load3DModel',
|
||||
SaveGLB: 'essentials.save3DModel',
|
||||
PrimitiveStringMultiline: 'essentials.text',
|
||||
|
||||
// Image Tools
|
||||
BatchImagesNode: 'essentials.batchImage',
|
||||
ImageCrop: 'essentials.cropImage',
|
||||
ImageScale: 'essentials.resizeImage',
|
||||
ImageRotate: 'essentials.rotate',
|
||||
ImageInvert: 'essentials.invert',
|
||||
Canny: 'essentials.canny',
|
||||
RecraftRemoveBackgroundNode: 'essentials.removeBackground',
|
||||
ImageCompare: 'essentials.imageCompare',
|
||||
|
||||
// Video Tools
|
||||
'Video Slice': 'essentials.extractFrame',
|
||||
|
||||
// Image Generation
|
||||
LoraLoader: 'essentials.loadStyleLora',
|
||||
|
||||
// Video Generation
|
||||
KlingLipSyncAudioToVideoNode: 'essentials.lipsync',
|
||||
KlingLipSyncTextToVideoNode: 'essentials.lipsync',
|
||||
|
||||
// Text Generation
|
||||
OpenAIChatNode: 'essentials.textGenerationLLM',
|
||||
|
||||
// 3D
|
||||
TencentTextToModelNode: 'essentials.textTo3DModel',
|
||||
TencentImageToModelNode: 'essentials.imageTo3DModel',
|
||||
MeshyTextToModelNode: 'essentials.textTo3DModel',
|
||||
MeshyImageToModelNode: 'essentials.imageTo3DModel',
|
||||
TripoTextToModelNode: 'essentials.textTo3DModel',
|
||||
TripoImageToModelNode: 'essentials.imageTo3DModel',
|
||||
|
||||
// Audio
|
||||
StabilityTextToAudio: 'essentials.musicGeneration',
|
||||
LoadAudio: 'essentials.loadAudio',
|
||||
SaveAudio: 'essentials.saveAudio'
|
||||
}
|
||||
|
||||
/**
|
||||
* Blueprint prefix patterns mapped to display name i18n keys.
|
||||
* Entries are matched by checking if the blueprint filename
|
||||
* (after removing the SubgraphBlueprint. prefix) starts with the key.
|
||||
* Ordered longest-first so more specific prefixes match before shorter ones.
|
||||
*/
|
||||
const BLUEPRINT_PREFIX_MAP: [prefix: string, displayNameKey: string][] = [
|
||||
// Image Generation
|
||||
['image_inpainting_', 'essentials.inpaintImage'],
|
||||
['image_outpainting_', 'essentials.outpaintImage'],
|
||||
['image_edit', 'essentials.imageToImage'],
|
||||
['text_to_image', 'essentials.textToImage'],
|
||||
['pose_to_image', 'essentials.poseToImage'],
|
||||
['canny_to_image', 'essentials.cannyToImage'],
|
||||
['depth_to_image', 'essentials.depthToImage'],
|
||||
|
||||
// Video Generation
|
||||
['text_to_video', 'essentials.textToVideo'],
|
||||
['image_to_video', 'essentials.imageToVideo'],
|
||||
['pose_to_video', 'essentials.poseToVideo'],
|
||||
['canny_to_video', 'essentials.cannyToVideo'],
|
||||
['depth_to_video', 'essentials.depthToVideo']
|
||||
]
|
||||
|
||||
function resolveBlueprintDisplayName(
|
||||
blueprintName: string
|
||||
): string | undefined {
|
||||
for (const [prefix, displayNameKey] of BLUEPRINT_PREFIX_MAP) {
|
||||
if (blueprintName.startsWith(prefix)) {
|
||||
return t(displayNameKey)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Essentials tab display name for a given node definition.
|
||||
* Returns `undefined` if the node has no Essentials display name mapping.
|
||||
*/
|
||||
export function resolveEssentialsDisplayName(
|
||||
nodeDef: Pick<ComfyNodeDefImpl, 'name'> | undefined
|
||||
): string | undefined {
|
||||
if (!nodeDef) return undefined
|
||||
const { name } = nodeDef
|
||||
|
||||
if (name.startsWith(BLUEPRINT_PREFIX)) {
|
||||
const blueprintName = name.slice(BLUEPRINT_PREFIX.length)
|
||||
return resolveBlueprintDisplayName(blueprintName)
|
||||
}
|
||||
|
||||
const key = EXACT_NAME_MAP[name]
|
||||
return key ? t(key) : undefined
|
||||
}
|
||||
@@ -6,10 +6,6 @@
|
||||
- Avoid repetition where possible, but not at expense of legibility
|
||||
- Prefer running single tests, not the whole suite, for performance
|
||||
|
||||
## Widget Serialization
|
||||
|
||||
See `docs/WIDGET_SERIALIZATION.md` for the distinction between `widget.serialize` (workflow persistence) and `widget.options.serialize` (API prompt). These are different properties checked by different code paths — a common source of confusion.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Prefer single line `if` syntax for concise expressions
|
||||
|
||||
@@ -47,19 +47,6 @@ export interface IWidgetOptions<TValues = unknown> {
|
||||
/** Used as a temporary override for determining the asset type in vue mode*/
|
||||
nodeType?: string
|
||||
|
||||
/**
|
||||
* Whether the widget value should be included in the API prompt sent to
|
||||
* the backend for execution. Checked by {@link executionUtil} when
|
||||
* building the prompt payload.
|
||||
*
|
||||
* This is distinct from {@link IBaseWidget.serialize}, which controls
|
||||
* whether the value is persisted in the workflow JSON file.
|
||||
*
|
||||
* @default true
|
||||
* @see IBaseWidget.serialize — workflow persistence
|
||||
*/
|
||||
serialize?: boolean
|
||||
|
||||
values?: TValues
|
||||
/** Optional function to format values for display (e.g., hash → human-readable name) */
|
||||
getOptionLabel?: (value?: string | null) => string
|
||||
@@ -362,15 +349,8 @@ export interface IBaseWidget<
|
||||
vueTrack?: () => void
|
||||
|
||||
/**
|
||||
* Whether the widget value is persisted in the workflow JSON
|
||||
* (`widgets_values`). Checked by {@link LGraphNode.serialize} and
|
||||
* {@link LGraphNode.configure}.
|
||||
*
|
||||
* This is distinct from {@link IWidgetOptions.serialize}, which controls
|
||||
* whether the value is included in the API prompt sent for execution.
|
||||
*
|
||||
* Whether the widget value should be serialized on node serialization.
|
||||
* @default true
|
||||
* @see IWidgetOptions.serialize — API prompt inclusion
|
||||
*/
|
||||
serialize?: boolean
|
||||
|
||||
|
||||
@@ -735,44 +735,6 @@
|
||||
"errorCount": "{count} أخطاء | {count} خطأ | {count} أخطاء",
|
||||
"seeErrors": "عرض الأخطاء"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "معالجة صور دفعة واحدة",
|
||||
"canny": "كانّي",
|
||||
"cannyToImage": "تحويل كانّي إلى صورة",
|
||||
"cannyToVideo": "تحويل كانّي إلى فيديو",
|
||||
"cropImage": "قص الصورة",
|
||||
"depthToImage": "تحويل العمق إلى صورة",
|
||||
"depthToVideo": "تحويل العمق إلى فيديو",
|
||||
"extractFrame": "استخراج إطار",
|
||||
"imageCompare": "مقارنة الصور",
|
||||
"imageTo3DModel": "تحويل الصورة إلى نموذج ثلاثي الأبعاد",
|
||||
"imageToImage": "تحويل صورة إلى صورة",
|
||||
"imageToVideo": "تحويل صورة إلى فيديو",
|
||||
"inpaintImage": "ترميم الصورة",
|
||||
"invert": "عكس",
|
||||
"lipsync": "مزامنة الشفاه",
|
||||
"load3DModel": "تحميل نموذج ثلاثي الأبعاد",
|
||||
"loadAudio": "تحميل صوت",
|
||||
"loadImage": "تحميل صورة",
|
||||
"loadStyleLora": "تحميل نمط (LoRA)",
|
||||
"loadVideo": "تحميل فيديو",
|
||||
"musicGeneration": "توليد موسيقى",
|
||||
"outpaintImage": "توسيع الصورة",
|
||||
"poseToImage": "تحويل وضعية إلى صورة",
|
||||
"poseToVideo": "تحويل وضعية إلى فيديو",
|
||||
"removeBackground": "إزالة الخلفية",
|
||||
"resizeImage": "تغيير حجم الصورة",
|
||||
"rotate": "تدوير",
|
||||
"save3DModel": "حفظ نموذج ثلاثي الأبعاد",
|
||||
"saveAudio": "حفظ صوت",
|
||||
"saveImage": "حفظ صورة",
|
||||
"saveVideo": "حفظ فيديو",
|
||||
"text": "نص",
|
||||
"textGenerationLLM": "توليد نص (LLM)",
|
||||
"textTo3DModel": "تحويل النص إلى نموذج ثلاثي الأبعاد",
|
||||
"textToImage": "تحويل نص إلى صورة",
|
||||
"textToVideo": "تحويل نص إلى فيديو"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "اكتملت جميع عمليات التصدير",
|
||||
"downloadExport": "تحميل التصدير",
|
||||
@@ -2985,7 +2947,6 @@
|
||||
"node2only": "فقط Node 2.0",
|
||||
"selectModel": "اختر نموذج",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "تم الوصول إلى الحد الأقصى للاختيار",
|
||||
"placeholder": "اختر...",
|
||||
"placeholderAudio": "اختر صوت...",
|
||||
"placeholderImage": "اختر صورة...",
|
||||
|
||||
@@ -1604,6 +1604,7 @@
|
||||
"MiniMax": "MiniMax",
|
||||
"model_specific": "model_specific",
|
||||
"Moonvalley Marey": "Moonvalley Marey",
|
||||
"": "",
|
||||
"OpenAI": "OpenAI",
|
||||
"Sora": "Sora",
|
||||
"cond pair": "cond pair",
|
||||
@@ -1625,7 +1626,6 @@
|
||||
"style_model": "style_model",
|
||||
"Tencent": "Tencent",
|
||||
"textgen": "textgen",
|
||||
"": "",
|
||||
"Topaz": "Topaz",
|
||||
"Tripo": "Tripo",
|
||||
"Veo": "Veo",
|
||||
@@ -2459,8 +2459,7 @@
|
||||
"placeholderVideo": "Select video...",
|
||||
"placeholderMesh": "Select mesh...",
|
||||
"placeholderModel": "Select model...",
|
||||
"placeholderUnknown": "Select media...",
|
||||
"maxSelectionReached": "Maximum selection limit reached"
|
||||
"placeholderUnknown": "Select media..."
|
||||
},
|
||||
"valueControl": {
|
||||
"header": {
|
||||
@@ -3152,43 +3151,5 @@
|
||||
"duplicateName": "A secret with this name already exists",
|
||||
"duplicateProvider": "A secret for this provider already exists"
|
||||
}
|
||||
},
|
||||
"essentials": {
|
||||
"loadImage": "Load Image",
|
||||
"saveImage": "Save Image",
|
||||
"loadVideo": "Load Video",
|
||||
"saveVideo": "Save Video",
|
||||
"load3DModel": "Load 3D model",
|
||||
"save3DModel": "Save 3D Model",
|
||||
"text": "Text",
|
||||
"batchImage": "Batch Image",
|
||||
"cropImage": "Crop Image",
|
||||
"resizeImage": "Resize Image",
|
||||
"rotate": "Rotate",
|
||||
"invert": "Invert",
|
||||
"canny": "Canny",
|
||||
"removeBackground": "Remove Background",
|
||||
"imageCompare": "Image compare",
|
||||
"extractFrame": "Extract frame",
|
||||
"loadStyleLora": "Load style (LoRA)",
|
||||
"lipsync": "Lipsync",
|
||||
"textGenerationLLM": "Text generation (LLM)",
|
||||
"textTo3DModel": "Text to 3D model",
|
||||
"imageTo3DModel": "Image to 3D Model",
|
||||
"musicGeneration": "Music generation",
|
||||
"loadAudio": "Load Audio",
|
||||
"saveAudio": "Save Audio",
|
||||
"inpaintImage": "Inpaint image",
|
||||
"outpaintImage": "Outpaint image",
|
||||
"imageToImage": "Image to image",
|
||||
"textToImage": "Text to image",
|
||||
"poseToImage": "Pose to image",
|
||||
"cannyToImage": "Canny to image",
|
||||
"depthToImage": "Depth to image",
|
||||
"textToVideo": "Text to video",
|
||||
"imageToVideo": "Image to video",
|
||||
"poseToVideo": "Pose to video",
|
||||
"cannyToVideo": "Canny to video",
|
||||
"depthToVideo": "Depth to video"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,44 +735,6 @@
|
||||
"errorCount": "{count} ERRORES | {count} ERROR | {count} ERRORES",
|
||||
"seeErrors": "Ver errores"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "Procesar imágenes por lotes",
|
||||
"canny": "Canny",
|
||||
"cannyToImage": "Canny a imagen",
|
||||
"cannyToVideo": "Canny a video",
|
||||
"cropImage": "Recortar imagen",
|
||||
"depthToImage": "Profundidad a imagen",
|
||||
"depthToVideo": "Profundidad a video",
|
||||
"extractFrame": "Extraer fotograma",
|
||||
"imageCompare": "Comparar imágenes",
|
||||
"imageTo3DModel": "Imagen a modelo 3D",
|
||||
"imageToImage": "Imagen a imagen",
|
||||
"imageToVideo": "Imagen a video",
|
||||
"inpaintImage": "Rellenar imagen",
|
||||
"invert": "Invertir",
|
||||
"lipsync": "Lipsync",
|
||||
"load3DModel": "Cargar modelo 3D",
|
||||
"loadAudio": "Cargar audio",
|
||||
"loadImage": "Cargar imagen",
|
||||
"loadStyleLora": "Cargar estilo (LoRA)",
|
||||
"loadVideo": "Cargar video",
|
||||
"musicGeneration": "Generación de música",
|
||||
"outpaintImage": "Expandir imagen",
|
||||
"poseToImage": "Pose a imagen",
|
||||
"poseToVideo": "Pose a video",
|
||||
"removeBackground": "Eliminar fondo",
|
||||
"resizeImage": "Redimensionar imagen",
|
||||
"rotate": "Rotar",
|
||||
"save3DModel": "Guardar modelo 3D",
|
||||
"saveAudio": "Guardar audio",
|
||||
"saveImage": "Guardar imagen",
|
||||
"saveVideo": "Guardar video",
|
||||
"text": "Texto",
|
||||
"textGenerationLLM": "Generación de texto (LLM)",
|
||||
"textTo3DModel": "Texto a modelo 3D",
|
||||
"textToImage": "Texto a imagen",
|
||||
"textToVideo": "Texto a video"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "Todas las exportaciones completadas",
|
||||
"downloadExport": "Descargar exportación",
|
||||
@@ -2985,7 +2947,6 @@
|
||||
"node2only": "Solo Node 2.0",
|
||||
"selectModel": "Seleccionar modelo",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "Se alcanzó el límite máximo de selección",
|
||||
"placeholder": "Seleccionar...",
|
||||
"placeholderAudio": "Seleccionar audio...",
|
||||
"placeholderImage": "Seleccionar imagen...",
|
||||
|
||||
@@ -735,44 +735,6 @@
|
||||
"errorCount": "{count} خطا",
|
||||
"seeErrors": "مشاهده خطاها"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "پردازش دستهای تصویر",
|
||||
"canny": "لبهیابی Canny",
|
||||
"cannyToImage": "تبدیل Canny به تصویر",
|
||||
"cannyToVideo": "تبدیل Canny به ویدیو",
|
||||
"cropImage": "برش تصویر",
|
||||
"depthToImage": "تبدیل عمق به تصویر",
|
||||
"depthToVideo": "تبدیل عمق به ویدیو",
|
||||
"extractFrame": "استخراج فریم",
|
||||
"imageCompare": "مقایسه تصویر",
|
||||
"imageTo3DModel": "تبدیل تصویر به مدل سهبعدی",
|
||||
"imageToImage": "تبدیل تصویر به تصویر",
|
||||
"imageToVideo": "تبدیل تصویر به ویدیو",
|
||||
"inpaintImage": "بازسازی تصویر (Inpaint)",
|
||||
"invert": "معکوس",
|
||||
"lipsync": "همگامسازی لب",
|
||||
"load3DModel": "بارگذاری مدل سهبعدی",
|
||||
"loadAudio": "بارگذاری صوت",
|
||||
"loadImage": "بارگذاری تصویر",
|
||||
"loadStyleLora": "بارگذاری سبک (LoRA)",
|
||||
"loadVideo": "بارگذاری ویدیو",
|
||||
"musicGeneration": "تولید موسیقی",
|
||||
"outpaintImage": "گسترش تصویر (Outpaint)",
|
||||
"poseToImage": "تبدیل ژست به تصویر",
|
||||
"poseToVideo": "تبدیل ژست به ویدیو",
|
||||
"removeBackground": "حذف پسزمینه",
|
||||
"resizeImage": "تغییر اندازه تصویر",
|
||||
"rotate": "چرخش",
|
||||
"save3DModel": "ذخیره مدل سهبعدی",
|
||||
"saveAudio": "ذخیره صوت",
|
||||
"saveImage": "ذخیره تصویر",
|
||||
"saveVideo": "ذخیره ویدیو",
|
||||
"text": "متن",
|
||||
"textGenerationLLM": "تولید متن (LLM)",
|
||||
"textTo3DModel": "تبدیل متن به مدل سهبعدی",
|
||||
"textToImage": "تبدیل متن به تصویر",
|
||||
"textToVideo": "تبدیل متن به ویدیو"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "همه خروجیها تکمیل شد",
|
||||
"downloadExport": "دانلود خروجی",
|
||||
@@ -2997,7 +2959,6 @@
|
||||
"node2only": "فقط Node 2.0",
|
||||
"selectModel": "انتخاب مدل",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "حداکثر تعداد انتخاب مجاز رسید",
|
||||
"placeholder": "انتخاب...",
|
||||
"placeholderAudio": "انتخاب صوت...",
|
||||
"placeholderImage": "انتخاب تصویر...",
|
||||
|
||||
@@ -735,44 +735,6 @@
|
||||
"errorCount": "{count} ERREURS | {count} ERREUR | {count} ERREURS",
|
||||
"seeErrors": "Voir les erreurs"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "Traitement par lot d'images",
|
||||
"canny": "Canny",
|
||||
"cannyToImage": "Canny vers image",
|
||||
"cannyToVideo": "Canny vers vidéo",
|
||||
"cropImage": "Rogner l'image",
|
||||
"depthToImage": "Profondeur vers image",
|
||||
"depthToVideo": "Profondeur vers vidéo",
|
||||
"extractFrame": "Extraire une image",
|
||||
"imageCompare": "Comparer les images",
|
||||
"imageTo3DModel": "Image vers modèle 3D",
|
||||
"imageToImage": "Image vers image",
|
||||
"imageToVideo": "Image vers vidéo",
|
||||
"inpaintImage": "Inpainting d'image",
|
||||
"invert": "Inverser",
|
||||
"lipsync": "Synchronisation labiale",
|
||||
"load3DModel": "Charger un modèle 3D",
|
||||
"loadAudio": "Charger un audio",
|
||||
"loadImage": "Charger une image",
|
||||
"loadStyleLora": "Charger un style (LoRA)",
|
||||
"loadVideo": "Charger une vidéo",
|
||||
"musicGeneration": "Génération de musique",
|
||||
"outpaintImage": "Outpainting d'image",
|
||||
"poseToImage": "Pose vers image",
|
||||
"poseToVideo": "Pose vers vidéo",
|
||||
"removeBackground": "Supprimer l'arrière-plan",
|
||||
"resizeImage": "Redimensionner l'image",
|
||||
"rotate": "Faire pivoter",
|
||||
"save3DModel": "Enregistrer le modèle 3D",
|
||||
"saveAudio": "Enregistrer l'audio",
|
||||
"saveImage": "Enregistrer l'image",
|
||||
"saveVideo": "Enregistrer la vidéo",
|
||||
"text": "Texte",
|
||||
"textGenerationLLM": "Génération de texte (LLM)",
|
||||
"textTo3DModel": "Texte vers modèle 3D",
|
||||
"textToImage": "Texte vers image",
|
||||
"textToVideo": "Texte vers vidéo"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "Toutes les exportations sont terminées",
|
||||
"downloadExport": "Télécharger l’export",
|
||||
@@ -2985,7 +2947,6 @@
|
||||
"node2only": "Node 2.0 uniquement",
|
||||
"selectModel": "Sélectionner un modèle",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "Limite maximale de sélection atteinte",
|
||||
"placeholder": "Sélectionner...",
|
||||
"placeholderAudio": "Sélectionner un audio...",
|
||||
"placeholderImage": "Sélectionner une image...",
|
||||
|
||||
@@ -735,44 +735,6 @@
|
||||
"errorCount": "{count} 件のエラー",
|
||||
"seeErrors": "エラーを表示"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "バッチ画像処理",
|
||||
"canny": "Canny",
|
||||
"cannyToImage": "Cannyから画像へ",
|
||||
"cannyToVideo": "Cannyから動画へ",
|
||||
"cropImage": "画像を切り抜く",
|
||||
"depthToImage": "深度から画像へ",
|
||||
"depthToVideo": "深度から動画へ",
|
||||
"extractFrame": "フレームを抽出",
|
||||
"imageCompare": "画像比較",
|
||||
"imageTo3DModel": "画像から3Dモデル",
|
||||
"imageToImage": "画像から画像へ",
|
||||
"imageToVideo": "画像から動画へ",
|
||||
"inpaintImage": "画像のインペイント",
|
||||
"invert": "反転",
|
||||
"lipsync": "リップシンク",
|
||||
"load3DModel": "3Dモデルを読み込む",
|
||||
"loadAudio": "音声を読み込む",
|
||||
"loadImage": "画像を読み込む",
|
||||
"loadStyleLora": "スタイルを読み込む(LoRA)",
|
||||
"loadVideo": "動画を読み込む",
|
||||
"musicGeneration": "音楽生成",
|
||||
"outpaintImage": "画像のアウトペイント",
|
||||
"poseToImage": "ポーズから画像へ",
|
||||
"poseToVideo": "ポーズから動画へ",
|
||||
"removeBackground": "背景を削除",
|
||||
"resizeImage": "画像のサイズ変更",
|
||||
"rotate": "回転",
|
||||
"save3DModel": "3Dモデルを保存",
|
||||
"saveAudio": "音声を保存",
|
||||
"saveImage": "画像を保存",
|
||||
"saveVideo": "動画を保存",
|
||||
"text": "テキスト",
|
||||
"textGenerationLLM": "テキスト生成(LLM)",
|
||||
"textTo3DModel": "テキストから3Dモデル",
|
||||
"textToImage": "テキストから画像へ",
|
||||
"textToVideo": "テキストから動画へ"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "すべてのエクスポートが完了しました",
|
||||
"downloadExport": "エクスポートをダウンロード",
|
||||
@@ -2985,7 +2947,6 @@
|
||||
"node2only": "Node 2.0専用",
|
||||
"selectModel": "モデルを選択",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "選択の上限に達しました",
|
||||
"placeholder": "選択...",
|
||||
"placeholderAudio": "音声を選択...",
|
||||
"placeholderImage": "画像を選択...",
|
||||
|
||||
@@ -735,44 +735,6 @@
|
||||
"errorCount": "{count}개 오류",
|
||||
"seeErrors": "오류 보기"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "이미지 일괄 처리",
|
||||
"canny": "Canny",
|
||||
"cannyToImage": "Canny → 이미지",
|
||||
"cannyToVideo": "Canny → 비디오",
|
||||
"cropImage": "이미지 자르기",
|
||||
"depthToImage": "깊이 → 이미지",
|
||||
"depthToVideo": "깊이 → 비디오",
|
||||
"extractFrame": "프레임 추출",
|
||||
"imageCompare": "이미지 비교",
|
||||
"imageTo3DModel": "이미지 → 3D 모델",
|
||||
"imageToImage": "이미지 → 이미지",
|
||||
"imageToVideo": "이미지 → 비디오",
|
||||
"inpaintImage": "이미지 인페인팅",
|
||||
"invert": "반전",
|
||||
"lipsync": "립싱크",
|
||||
"load3DModel": "3D 모델 불러오기",
|
||||
"loadAudio": "오디오 불러오기",
|
||||
"loadImage": "이미지 불러오기",
|
||||
"loadStyleLora": "스타일 불러오기 (LoRA)",
|
||||
"loadVideo": "비디오 불러오기",
|
||||
"musicGeneration": "음악 생성",
|
||||
"outpaintImage": "이미지 아웃페인팅",
|
||||
"poseToImage": "포즈 → 이미지",
|
||||
"poseToVideo": "포즈 → 비디오",
|
||||
"removeBackground": "배경 제거",
|
||||
"resizeImage": "이미지 크기 조정",
|
||||
"rotate": "회전",
|
||||
"save3DModel": "3D 모델 저장",
|
||||
"saveAudio": "오디오 저장",
|
||||
"saveImage": "이미지 저장",
|
||||
"saveVideo": "비디오 저장",
|
||||
"text": "텍스트",
|
||||
"textGenerationLLM": "텍스트 생성 (LLM)",
|
||||
"textTo3DModel": "텍스트 → 3D 모델",
|
||||
"textToImage": "텍스트 → 이미지",
|
||||
"textToVideo": "텍스트 → 비디오"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "모든 내보내기 완료",
|
||||
"downloadExport": "내보내기 다운로드",
|
||||
@@ -2985,7 +2947,6 @@
|
||||
"node2only": "Node 2.0 전용",
|
||||
"selectModel": "모델 선택",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "최대 선택 한도에 도달했습니다",
|
||||
"placeholder": "선택...",
|
||||
"placeholderAudio": "오디오 선택...",
|
||||
"placeholderImage": "이미지 선택...",
|
||||
|
||||
@@ -735,44 +735,6 @@
|
||||
"errorCount": "{count} ERROS | {count} ERRO | {count} ERROS",
|
||||
"seeErrors": "Ver erros"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "Imagem em lote",
|
||||
"canny": "Canny",
|
||||
"cannyToImage": "Canny para imagem",
|
||||
"cannyToVideo": "Canny para vídeo",
|
||||
"cropImage": "Cortar imagem",
|
||||
"depthToImage": "Profundidade para imagem",
|
||||
"depthToVideo": "Profundidade para vídeo",
|
||||
"extractFrame": "Extrair quadro",
|
||||
"imageCompare": "Comparar imagens",
|
||||
"imageTo3DModel": "Imagem para modelo 3D",
|
||||
"imageToImage": "Imagem para imagem",
|
||||
"imageToVideo": "Imagem para vídeo",
|
||||
"inpaintImage": "Inpaint na imagem",
|
||||
"invert": "Inverter",
|
||||
"lipsync": "Lipsync",
|
||||
"load3DModel": "Carregar modelo 3D",
|
||||
"loadAudio": "Carregar áudio",
|
||||
"loadImage": "Carregar imagem",
|
||||
"loadStyleLora": "Carregar estilo (LoRA)",
|
||||
"loadVideo": "Carregar vídeo",
|
||||
"musicGeneration": "Geração de música",
|
||||
"outpaintImage": "Outpaint na imagem",
|
||||
"poseToImage": "Pose para imagem",
|
||||
"poseToVideo": "Pose para vídeo",
|
||||
"removeBackground": "Remover fundo",
|
||||
"resizeImage": "Redimensionar imagem",
|
||||
"rotate": "Girar",
|
||||
"save3DModel": "Salvar modelo 3D",
|
||||
"saveAudio": "Salvar áudio",
|
||||
"saveImage": "Salvar imagem",
|
||||
"saveVideo": "Salvar vídeo",
|
||||
"text": "Texto",
|
||||
"textGenerationLLM": "Geração de texto (LLM)",
|
||||
"textTo3DModel": "Texto para modelo 3D",
|
||||
"textToImage": "Texto para imagem",
|
||||
"textToVideo": "Texto para vídeo"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "Todas as exportações concluídas",
|
||||
"downloadExport": "Baixar exportação",
|
||||
@@ -2997,7 +2959,6 @@
|
||||
"node2only": "Apenas Node 2.0",
|
||||
"selectModel": "Selecionar modelo",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "Limite máximo de seleção atingido",
|
||||
"placeholder": "Selecionar...",
|
||||
"placeholderAudio": "Selecionar áudio...",
|
||||
"placeholderImage": "Selecionar imagem...",
|
||||
|
||||
@@ -735,44 +735,6 @@
|
||||
"errorCount": "{count} ОШИБОК | {count} ОШИБКА | {count} ОШИБКИ",
|
||||
"seeErrors": "Посмотреть ошибки"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "Пакетная обработка изображений",
|
||||
"canny": "Canny",
|
||||
"cannyToImage": "Canny в изображение",
|
||||
"cannyToVideo": "Canny в видео",
|
||||
"cropImage": "Обрезать изображение",
|
||||
"depthToImage": "Глубина в изображение",
|
||||
"depthToVideo": "Глубина в видео",
|
||||
"extractFrame": "Извлечь кадр",
|
||||
"imageCompare": "Сравнение изображений",
|
||||
"imageTo3DModel": "Изображение в 3D-модель",
|
||||
"imageToImage": "Изображение в изображение",
|
||||
"imageToVideo": "Изображение в видео",
|
||||
"inpaintImage": "Инпейтинг изображения",
|
||||
"invert": "Инвертировать",
|
||||
"lipsync": "Синхронизация губ",
|
||||
"load3DModel": "Загрузить 3D-модель",
|
||||
"loadAudio": "Загрузить аудио",
|
||||
"loadImage": "Загрузить изображение",
|
||||
"loadStyleLora": "Загрузить стиль (LoRA)",
|
||||
"loadVideo": "Загрузить видео",
|
||||
"musicGeneration": "Генерация музыки",
|
||||
"outpaintImage": "Аутпейтинг изображения",
|
||||
"poseToImage": "Поза в изображение",
|
||||
"poseToVideo": "Поза в видео",
|
||||
"removeBackground": "Удалить фон",
|
||||
"resizeImage": "Изменить размер изображения",
|
||||
"rotate": "Повернуть",
|
||||
"save3DModel": "Сохранить 3D-модель",
|
||||
"saveAudio": "Сохранить аудио",
|
||||
"saveImage": "Сохранить изображение",
|
||||
"saveVideo": "Сохранить видео",
|
||||
"text": "Текст",
|
||||
"textGenerationLLM": "Генерация текста (LLM)",
|
||||
"textTo3DModel": "Текст в 3D-модель",
|
||||
"textToImage": "Текст в изображение",
|
||||
"textToVideo": "Текст в видео"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "Все экспорты завершены",
|
||||
"downloadExport": "Скачать экспорт",
|
||||
@@ -2985,7 +2947,6 @@
|
||||
"node2only": "Только Node 2.0",
|
||||
"selectModel": "Выбрать модель",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "Достигнут максимальный лимит выбора",
|
||||
"placeholder": "Выбрать...",
|
||||
"placeholderAudio": "Выбрать аудио...",
|
||||
"placeholderImage": "Выбрать изображение...",
|
||||
|
||||
@@ -735,44 +735,6 @@
|
||||
"errorCount": "{count} HATA | {count} HATA | {count} HATA",
|
||||
"seeErrors": "Hataları Gör"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "Toplu Görüntü",
|
||||
"canny": "Canny",
|
||||
"cannyToImage": "Canny'den Görüntüye",
|
||||
"cannyToVideo": "Canny'den Videoya",
|
||||
"cropImage": "Görüntüyü Kırp",
|
||||
"depthToImage": "Derinlikten Görüntüye",
|
||||
"depthToVideo": "Derinlikten Videoya",
|
||||
"extractFrame": "Kareyi Çıkar",
|
||||
"imageCompare": "Görüntü Karşılaştır",
|
||||
"imageTo3DModel": "Görüntüden 3D Modele",
|
||||
"imageToImage": "Görüntüden Görüntüye",
|
||||
"imageToVideo": "Görüntüden Videoya",
|
||||
"inpaintImage": "Görüntüyü Tamamla",
|
||||
"invert": "Ters Çevir",
|
||||
"lipsync": "Dudak Senkronizasyonu",
|
||||
"load3DModel": "3D Model Yükle",
|
||||
"loadAudio": "Ses Yükle",
|
||||
"loadImage": "Görüntü Yükle",
|
||||
"loadStyleLora": "Stil Yükle (LoRA)",
|
||||
"loadVideo": "Video Yükle",
|
||||
"musicGeneration": "Müzik Üretimi",
|
||||
"outpaintImage": "Görüntüyü Genişlet",
|
||||
"poseToImage": "Pozdan Görüntüye",
|
||||
"poseToVideo": "Pozdan Videoya",
|
||||
"removeBackground": "Arka Planı Kaldır",
|
||||
"resizeImage": "Görüntüyü Yeniden Boyutlandır",
|
||||
"rotate": "Döndür",
|
||||
"save3DModel": "3D Modeli Kaydet",
|
||||
"saveAudio": "Sesi Kaydet",
|
||||
"saveImage": "Görüntüyü Kaydet",
|
||||
"saveVideo": "Videoyu Kaydet",
|
||||
"text": "Metin",
|
||||
"textGenerationLLM": "Metin Üretimi (LLM)",
|
||||
"textTo3DModel": "Metinden 3D Modele",
|
||||
"textToImage": "Metinden Görüntüye",
|
||||
"textToVideo": "Metinden Videoya"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "Tüm dışa aktarmalar tamamlandı",
|
||||
"downloadExport": "Dışa aktarmayı indir",
|
||||
@@ -2985,7 +2947,6 @@
|
||||
"node2only": "Yalnızca Node 2.0",
|
||||
"selectModel": "Model seç",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "Maksimum seçim sınırına ulaşıldı",
|
||||
"placeholder": "Seç...",
|
||||
"placeholderAudio": "Ses seç...",
|
||||
"placeholderImage": "Görsel seç...",
|
||||
|
||||
@@ -735,44 +735,6 @@
|
||||
"errorCount": "{count} 個錯誤",
|
||||
"seeErrors": "查看錯誤"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "批次圖片",
|
||||
"canny": "Canny 邊緣",
|
||||
"cannyToImage": "Canny 邊緣轉圖片",
|
||||
"cannyToVideo": "Canny 邊緣轉影片",
|
||||
"cropImage": "裁切圖片",
|
||||
"depthToImage": "深度轉圖片",
|
||||
"depthToVideo": "深度轉影片",
|
||||
"extractFrame": "擷取影格",
|
||||
"imageCompare": "圖片比較",
|
||||
"imageTo3DModel": "圖片轉 3D 模型",
|
||||
"imageToImage": "圖片轉圖片",
|
||||
"imageToVideo": "圖片轉影片",
|
||||
"inpaintImage": "圖片修補",
|
||||
"invert": "反相",
|
||||
"lipsync": "唇形同步",
|
||||
"load3DModel": "載入 3D 模型",
|
||||
"loadAudio": "載入音訊",
|
||||
"loadImage": "載入圖片",
|
||||
"loadStyleLora": "載入風格 (LoRA)",
|
||||
"loadVideo": "載入影片",
|
||||
"musicGeneration": "音樂生成",
|
||||
"outpaintImage": "圖片擴展",
|
||||
"poseToImage": "姿勢轉圖片",
|
||||
"poseToVideo": "姿勢轉影片",
|
||||
"removeBackground": "去背",
|
||||
"resizeImage": "調整圖片尺寸",
|
||||
"rotate": "旋轉",
|
||||
"save3DModel": "儲存 3D 模型",
|
||||
"saveAudio": "儲存音訊",
|
||||
"saveImage": "儲存圖片",
|
||||
"saveVideo": "儲存影片",
|
||||
"text": "文字",
|
||||
"textGenerationLLM": "文字生成 (LLM)",
|
||||
"textTo3DModel": "文字轉 3D 模型",
|
||||
"textToImage": "文字轉圖片",
|
||||
"textToVideo": "文字轉影片"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "所有匯出已完成",
|
||||
"downloadExport": "下載匯出檔",
|
||||
@@ -2985,7 +2947,6 @@
|
||||
"node2only": "僅限 Node 2.0",
|
||||
"selectModel": "選擇模型",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "已達到最大選取限制",
|
||||
"placeholder": "選擇...",
|
||||
"placeholderAudio": "選擇音訊...",
|
||||
"placeholderImage": "選擇圖片...",
|
||||
|
||||
@@ -735,44 +735,6 @@
|
||||
"errorCount": "{count}个错误",
|
||||
"seeErrors": "查看错误"
|
||||
},
|
||||
"essentials": {
|
||||
"batchImage": "批量图像",
|
||||
"canny": "Canny",
|
||||
"cannyToImage": "Canny转图像",
|
||||
"cannyToVideo": "Canny转视频",
|
||||
"cropImage": "裁剪图像",
|
||||
"depthToImage": "深度转图像",
|
||||
"depthToVideo": "深度转视频",
|
||||
"extractFrame": "提取帧",
|
||||
"imageCompare": "图像对比",
|
||||
"imageTo3DModel": "图像转3D模型",
|
||||
"imageToImage": "图像转图像",
|
||||
"imageToVideo": "图像转视频",
|
||||
"inpaintImage": "图像修复",
|
||||
"invert": "反转",
|
||||
"lipsync": "唇形同步",
|
||||
"load3DModel": "加载3D模型",
|
||||
"loadAudio": "加载音频",
|
||||
"loadImage": "加载图像",
|
||||
"loadStyleLora": "加载风格(LoRA)",
|
||||
"loadVideo": "加载视频",
|
||||
"musicGeneration": "音乐生成",
|
||||
"outpaintImage": "图像扩展",
|
||||
"poseToImage": "姿态转图像",
|
||||
"poseToVideo": "姿态转视频",
|
||||
"removeBackground": "移除背景",
|
||||
"resizeImage": "调整图像大小",
|
||||
"rotate": "旋转",
|
||||
"save3DModel": "保存3D模型",
|
||||
"saveAudio": "保存音频",
|
||||
"saveImage": "保存图像",
|
||||
"saveVideo": "保存视频",
|
||||
"text": "文本",
|
||||
"textGenerationLLM": "文本生成(LLM)",
|
||||
"textTo3DModel": "文本转3D模型",
|
||||
"textToImage": "文本转图像",
|
||||
"textToVideo": "文本转视频"
|
||||
},
|
||||
"exportToast": {
|
||||
"allExportsCompleted": "全部导出完成",
|
||||
"downloadExport": "下载导出文件",
|
||||
@@ -2997,7 +2959,6 @@
|
||||
"node2only": "仅限 Node 2.0",
|
||||
"selectModel": "选择模型",
|
||||
"uploadSelect": {
|
||||
"maxSelectionReached": "已达到最大选择数量",
|
||||
"placeholder": "请选择...",
|
||||
"placeholderAudio": "请选择音频...",
|
||||
"placeholderImage": "请选择图片...",
|
||||
|
||||
@@ -49,6 +49,7 @@ describe('MediaVideoTop', () => {
|
||||
expect(wrapper.find('video').exists()).toBe(true)
|
||||
expect(wrapper.find('source').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('emits playback events and hides paused overlay while playing', async () => {
|
||||
const wrapper = mount(MediaVideoTop, {
|
||||
props: {
|
||||
|
||||
@@ -161,7 +161,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
sel.clear()
|
||||
sel.add(item.id)
|
||||
} else {
|
||||
toastStore.addAlert(t('widgets.uploadSelect.maxSelectionReached'))
|
||||
toastStore.addAlert(`Maximum selection limit reached`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ const theButtonStyle = computed(() =>
|
||||
<div
|
||||
:class="
|
||||
cn(WidgetInputBaseClass, 'flex text-base leading-none', {
|
||||
'opacity-50 cursor-not-allowed outline-zinc-300/10': disabled
|
||||
'opacity-50 cursor-not-allowed !outline-zinc-300/10': disabled
|
||||
})
|
||||
"
|
||||
>
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import FormDropdownMenu from './FormDropdownMenu.vue'
|
||||
import type { FormDropdownItem, LayoutMode } from './types'
|
||||
|
||||
function createItem(id: string, name: string): FormDropdownItem {
|
||||
return {
|
||||
id,
|
||||
preview_url: '',
|
||||
name,
|
||||
label: name
|
||||
}
|
||||
}
|
||||
|
||||
describe('FormDropdownMenu', () => {
|
||||
const defaultProps = {
|
||||
items: [createItem('1', 'Item 1'), createItem('2', 'Item 2')],
|
||||
isSelected: () => false,
|
||||
filterOptions: [],
|
||||
sortOptions: []
|
||||
}
|
||||
|
||||
it('renders empty state when no items', async () => {
|
||||
const wrapper = mount(FormDropdownMenu, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
items: []
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: true,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: true
|
||||
},
|
||||
mocks: {
|
||||
$t: (key: string) => key
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const emptyIcon = wrapper.find('.icon-\\[lucide--circle-off\\]')
|
||||
expect(emptyIcon.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders VirtualGrid when items exist', async () => {
|
||||
const wrapper = mount(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: true,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
|
||||
expect(virtualGrid.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('transforms items to include key property for VirtualGrid', async () => {
|
||||
const items = [createItem('1', 'Item 1'), createItem('2', 'Item 2')]
|
||||
const wrapper = mount(FormDropdownMenu, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
items
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: true,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
|
||||
const virtualItems = virtualGrid.props('items')
|
||||
|
||||
expect(virtualItems).toHaveLength(2)
|
||||
expect(virtualItems[0]).toHaveProperty('key', '1')
|
||||
expect(virtualItems[1]).toHaveProperty('key', '2')
|
||||
})
|
||||
|
||||
it('uses single column layout for list modes', async () => {
|
||||
const wrapper = mount(FormDropdownMenu, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
layoutMode: 'list' as LayoutMode
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
FormDropdownMenuFilter: true,
|
||||
FormDropdownMenuActions: true,
|
||||
VirtualGrid: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const virtualGrid = wrapper.findComponent({ name: 'VirtualGrid' })
|
||||
expect(virtualGrid.props('maxColumns')).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties, MaybeRefOrGetter } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import type {
|
||||
FilterOption,
|
||||
@@ -31,18 +30,7 @@ interface Props {
|
||||
baseModelOptions?: FilterOption[]
|
||||
}
|
||||
|
||||
const {
|
||||
items,
|
||||
isSelected,
|
||||
filterOptions,
|
||||
sortOptions,
|
||||
searcher,
|
||||
updateKey,
|
||||
showOwnershipFilter,
|
||||
ownershipOptions,
|
||||
showBaseModelFilter,
|
||||
baseModelOptions
|
||||
} = defineProps<Props>()
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'item-click', item: FormDropdownItem, index: number): void
|
||||
}>()
|
||||
@@ -53,48 +41,11 @@ const sortSelected = defineModel<string>('sortSelected')
|
||||
const searchQuery = defineModel<string>('searchQuery')
|
||||
const ownershipSelected = defineModel<OwnershipOption>('ownershipSelected')
|
||||
const baseModelSelected = defineModel<Set<string>>('baseModelSelected')
|
||||
|
||||
type LayoutConfig = {
|
||||
maxColumns: number
|
||||
itemHeight: number
|
||||
itemWidth: number
|
||||
gap: string
|
||||
}
|
||||
|
||||
const LAYOUT_CONFIGS: Record<LayoutMode, LayoutConfig> = {
|
||||
grid: { maxColumns: 4, itemHeight: 120, itemWidth: 89, gap: '1rem 0.5rem' },
|
||||
list: { maxColumns: 1, itemHeight: 64, itemWidth: 380, gap: '0.5rem' },
|
||||
'list-small': {
|
||||
maxColumns: 1,
|
||||
itemHeight: 40,
|
||||
itemWidth: 380,
|
||||
gap: '0.25rem'
|
||||
}
|
||||
}
|
||||
|
||||
const layoutConfig = computed<LayoutConfig>(
|
||||
() => LAYOUT_CONFIGS[layoutMode.value ?? 'grid']
|
||||
)
|
||||
|
||||
const gridStyle = computed<CSSProperties>(() => ({
|
||||
display: 'grid',
|
||||
gap: layoutConfig.value.gap,
|
||||
padding: '1rem',
|
||||
width: '100%'
|
||||
}))
|
||||
|
||||
type VirtualDropdownItem = FormDropdownItem & { key: string }
|
||||
const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
key: String(item.id)
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline outline-offset-[-1px] outline-node-component-border"
|
||||
class="flex max-h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline outline-offset-[-1px] outline-node-component-border"
|
||||
>
|
||||
<FormDropdownMenuFilter
|
||||
v-if="filterOptions.length > 0"
|
||||
@@ -115,30 +66,34 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
:show-base-model-filter
|
||||
:base-model-options
|
||||
/>
|
||||
<div
|
||||
v-if="items.length === 0"
|
||||
class="flex h-50 items-center justify-center"
|
||||
>
|
||||
<i
|
||||
:title="$t('g.noItems')"
|
||||
:aria-label="$t('g.noItems')"
|
||||
class="icon-[lucide--circle-off] size-30 text-muted-foreground/20"
|
||||
/>
|
||||
</div>
|
||||
<VirtualGrid
|
||||
v-else
|
||||
:key="layoutMode"
|
||||
:items="virtualItems"
|
||||
:grid-style
|
||||
:max-columns="layoutConfig.maxColumns"
|
||||
:default-item-height="layoutConfig.itemHeight"
|
||||
:default-item-width="layoutConfig.itemWidth"
|
||||
:buffer-rows="2"
|
||||
class="mt-2 min-h-0 flex-1"
|
||||
>
|
||||
<template #item="{ item, index }">
|
||||
<div class="relative flex h-full mt-2 overflow-y-scroll">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'h-full max-h-full grid gap-x-2 gap-y-4 overflow-y-auto px-4 pt-4 pb-4 w-full',
|
||||
{
|
||||
'grid-cols-4': layoutMode === 'grid',
|
||||
'grid-cols-1 gap-y-2': layoutMode === 'list',
|
||||
'grid-cols-1 gap-y-1': layoutMode === 'list-small'
|
||||
}
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="pointer-events-none absolute inset-x-3 top-0 z-10 h-5" />
|
||||
<div
|
||||
v-if="items.length === 0"
|
||||
class="h-50 col-span-full flex items-center justify-center"
|
||||
>
|
||||
<i
|
||||
:title="$t('g.noItems')"
|
||||
:aria-label="$t('g.noItems')"
|
||||
class="icon-[lucide--circle-off] size-30 text-zinc-500/20"
|
||||
/>
|
||||
</div>
|
||||
<FormDropdownMenuItem
|
||||
:index
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
:index="index"
|
||||
:selected="isSelected(item, index)"
|
||||
:preview-url="item.preview_url ?? ''"
|
||||
:name="item.name"
|
||||
@@ -146,7 +101,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
:layout="layoutMode"
|
||||
@click="emit('item-click', item, index)"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue'
|
||||
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { AssetKindKey } from './types'
|
||||
@@ -56,7 +57,7 @@ function handleVideoLoad(event: Event) {
|
||||
:class="
|
||||
cn(
|
||||
'flex gap-1 select-none group/item cursor-pointer bg-component-node-widget-background',
|
||||
'transition-[transform,box-shadow,background-color] duration-150',
|
||||
'transition-all duration-150',
|
||||
{
|
||||
'flex-col text-center': layout === 'grid',
|
||||
'flex-row text-left max-h-16 rounded-lg hover:scale-102 active:scale-98':
|
||||
@@ -78,7 +79,7 @@ function handleVideoLoad(event: Event) {
|
||||
cn(
|
||||
'relative',
|
||||
'w-full aspect-square overflow-hidden outline-1 outline-offset-[-1px] outline-interface-stroke',
|
||||
'transition-[transform,box-shadow] duration-150',
|
||||
'transition-all duration-150',
|
||||
{
|
||||
'min-w-16 max-w-16 rounded-l-lg': layout === 'list',
|
||||
'rounded-sm group-hover/item:scale-108 group-active/item:scale-95':
|
||||
@@ -107,12 +108,11 @@ function handleVideoLoad(event: Event) {
|
||||
muted
|
||||
@loadeddata="handleVideoLoad"
|
||||
/>
|
||||
<img
|
||||
<LazyImage
|
||||
v-else-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
:alt="name"
|
||||
draggable="false"
|
||||
class="size-full object-cover"
|
||||
image-class="size-full object-cover"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -17,11 +17,7 @@ import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
|
||||
|
||||
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
|
||||
const cloudOnboardingRoutes = isCloud
|
||||
? (await import('./platform/cloud/onboarding/onboardingCloudRoutes'))
|
||||
.cloudOnboardingRoutes
|
||||
: []
|
||||
import { cloudOnboardingRoutes } from './platform/cloud/onboarding/onboardingCloudRoutes'
|
||||
|
||||
const isFileProtocol = window.location.protocol === 'file:'
|
||||
|
||||
|
||||
@@ -535,7 +535,6 @@ describe('useQueueStore', () => {
|
||||
|
||||
await store.update()
|
||||
const initialTask = store.historyTasks[0]
|
||||
const initialHistoryTasks = store.historyTasks
|
||||
|
||||
// Same job with same outputs_count
|
||||
mockGetHistory.mockResolvedValue([{ ...job }])
|
||||
@@ -544,8 +543,6 @@ describe('useQueueStore', () => {
|
||||
|
||||
// Should reuse the same instance
|
||||
expect(store.historyTasks[0]).toBe(initialTask)
|
||||
// Should preserve array identity when history is unchanged
|
||||
expect(store.historyTasks).toBe(initialHistoryTasks)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -479,7 +479,6 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
const runningTasks = shallowRef<TaskItemImpl[]>([])
|
||||
const pendingTasks = shallowRef<TaskItemImpl[]>([])
|
||||
const historyTasks = shallowRef<TaskItemImpl[]>([])
|
||||
const hasFetchedHistorySnapshot = ref(false)
|
||||
const maxHistoryItems = ref(64)
|
||||
const isLoading = ref(false)
|
||||
|
||||
@@ -558,7 +557,7 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
currentHistory.map((impl) => [impl.jobId, impl])
|
||||
)
|
||||
|
||||
const nextHistoryTasks = sortedHistory.map((job) => {
|
||||
historyTasks.value = sortedHistory.map((job) => {
|
||||
const existing = existingByJobId.get(job.id)
|
||||
if (!existing) return new TaskItemImpl(job)
|
||||
// Recreate if outputs_count changed to ensure lazy loading works
|
||||
@@ -567,15 +566,6 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
}
|
||||
return existing
|
||||
})
|
||||
|
||||
const isHistoryUnchanged =
|
||||
nextHistoryTasks.length === currentHistory.length &&
|
||||
nextHistoryTasks.every((task, index) => task === currentHistory[index])
|
||||
|
||||
if (!isHistoryUnchanged) {
|
||||
historyTasks.value = nextHistoryTasks
|
||||
}
|
||||
hasFetchedHistorySnapshot.value = true
|
||||
} finally {
|
||||
// Only clear loading if this is the latest request.
|
||||
// A stale request completing (success or error) should not touch loading state
|
||||
@@ -605,7 +595,6 @@ export const useQueueStore = defineStore('queue', () => {
|
||||
runningTasks,
|
||||
pendingTasks,
|
||||
historyTasks,
|
||||
hasFetchedHistorySnapshot,
|
||||
maxHistoryItems,
|
||||
isLoading,
|
||||
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||
import { useAssetsSidebarBadgeStore } from '@/stores/workspace/assetsSidebarBadgeStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
const createHistoryTask = ({
|
||||
id,
|
||||
outputsCount,
|
||||
hasPreview = true
|
||||
}: {
|
||||
id: string
|
||||
outputsCount?: number
|
||||
hasPreview?: boolean
|
||||
}) =>
|
||||
new TaskItemImpl({
|
||||
id,
|
||||
status: 'completed',
|
||||
create_time: Date.now(),
|
||||
priority: 1,
|
||||
outputs_count: outputsCount,
|
||||
preview_output: hasPreview
|
||||
? {
|
||||
filename: `${id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
: undefined
|
||||
} as JobListItem)
|
||||
|
||||
describe('useAssetsSidebarBadgeStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('does not count initial fetched history when store starts before hydration', async () => {
|
||||
const queueStore = useQueueStore()
|
||||
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-1', outputsCount: 2 })
|
||||
]
|
||||
await nextTick()
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
|
||||
queueStore.hasFetchedHistorySnapshot = true
|
||||
await nextTick()
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
})
|
||||
|
||||
it('counts new history items after baseline hydration while assets tab is closed', async () => {
|
||||
const queueStore = useQueueStore()
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-1', outputsCount: 2 })
|
||||
]
|
||||
queueStore.hasFetchedHistorySnapshot = true
|
||||
|
||||
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
|
||||
await nextTick()
|
||||
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-2', hasPreview: true }),
|
||||
...queueStore.historyTasks
|
||||
]
|
||||
await nextTick()
|
||||
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(1)
|
||||
})
|
||||
|
||||
it('does not count preview fallback when server outputsCount is zero', async () => {
|
||||
const queueStore = useQueueStore()
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-1', outputsCount: 2 })
|
||||
]
|
||||
queueStore.hasFetchedHistorySnapshot = true
|
||||
|
||||
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
|
||||
await nextTick()
|
||||
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-2', outputsCount: 0, hasPreview: true }),
|
||||
...queueStore.historyTasks
|
||||
]
|
||||
await nextTick()
|
||||
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
})
|
||||
|
||||
it('adds only delta when a seen job gains more outputs', async () => {
|
||||
const queueStore = useQueueStore()
|
||||
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-1', hasPreview: true })
|
||||
]
|
||||
queueStore.hasFetchedHistorySnapshot = true
|
||||
await nextTick()
|
||||
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-1', outputsCount: 3 })
|
||||
]
|
||||
await nextTick()
|
||||
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(2)
|
||||
})
|
||||
|
||||
it('treats a reappearing job as unseen after it aged out of history', async () => {
|
||||
const queueStore = useQueueStore()
|
||||
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-1', outputsCount: 2 })
|
||||
]
|
||||
queueStore.hasFetchedHistorySnapshot = true
|
||||
await nextTick()
|
||||
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-2', outputsCount: 1 })
|
||||
]
|
||||
await nextTick()
|
||||
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(1)
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-1', outputsCount: 1 }),
|
||||
createHistoryTask({ id: 'job-2', outputsCount: 1 })
|
||||
]
|
||||
await nextTick()
|
||||
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(2)
|
||||
})
|
||||
|
||||
it('clears and suppresses count while assets tab is open', async () => {
|
||||
const queueStore = useQueueStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore()
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-1', outputsCount: 2 })
|
||||
]
|
||||
queueStore.hasFetchedHistorySnapshot = true
|
||||
await nextTick()
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-2', outputsCount: 4 }),
|
||||
...queueStore.historyTasks
|
||||
]
|
||||
await nextTick()
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(4)
|
||||
|
||||
sidebarTabStore.activeSidebarTabId = 'assets'
|
||||
await nextTick()
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-3', outputsCount: 4 }),
|
||||
...queueStore.historyTasks
|
||||
]
|
||||
await nextTick()
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0)
|
||||
|
||||
sidebarTabStore.activeSidebarTabId = 'node-library'
|
||||
await nextTick()
|
||||
|
||||
queueStore.historyTasks = [
|
||||
createHistoryTask({ id: 'job-4', outputsCount: 1 }),
|
||||
...queueStore.historyTasks
|
||||
]
|
||||
await nextTick()
|
||||
expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -1,99 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { TaskItemImpl } from '@/stores/queueStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
const getAddedAssetCount = (task: TaskItemImpl): number => {
|
||||
if (typeof task.outputsCount === 'number') {
|
||||
return Math.max(task.outputsCount, 0)
|
||||
}
|
||||
|
||||
return task.previewOutput ? 1 : 0
|
||||
}
|
||||
|
||||
export const useAssetsSidebarBadgeStore = defineStore(
|
||||
'assetsSidebarBadge',
|
||||
() => {
|
||||
const queueStore = useQueueStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
|
||||
const unseenAddedAssetsCount = ref(0)
|
||||
const countedHistoryAssetsByJobId = ref(new Map<string, number>())
|
||||
const hasInitializedHistory = ref(false)
|
||||
|
||||
const markCurrentHistoryAsSeen = () => {
|
||||
countedHistoryAssetsByJobId.value = new Map(
|
||||
queueStore.historyTasks.map((task) => [
|
||||
task.jobId,
|
||||
getAddedAssetCount(task)
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
queueStore.historyTasks,
|
||||
queueStore.hasFetchedHistorySnapshot
|
||||
] as const,
|
||||
([historyTasks, hasFetchedHistorySnapshot]) => {
|
||||
if (!hasFetchedHistorySnapshot) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasInitializedHistory.value) {
|
||||
hasInitializedHistory.value = true
|
||||
markCurrentHistoryAsSeen()
|
||||
return
|
||||
}
|
||||
|
||||
const isAssetsTabOpen = sidebarTabStore.activeSidebarTabId === 'assets'
|
||||
const previousCountedAssetsByJobId = countedHistoryAssetsByJobId.value
|
||||
const nextCountedAssetsByJobId = new Map<string, number>()
|
||||
|
||||
for (const task of historyTasks) {
|
||||
const jobId = task.jobId
|
||||
if (!jobId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const countedAssets = previousCountedAssetsByJobId.get(jobId) ?? 0
|
||||
const currentAssets = getAddedAssetCount(task)
|
||||
const hasSeenJob = previousCountedAssetsByJobId.has(jobId)
|
||||
|
||||
if (!isAssetsTabOpen && !hasSeenJob) {
|
||||
unseenAddedAssetsCount.value += currentAssets
|
||||
} else if (!isAssetsTabOpen && currentAssets > countedAssets) {
|
||||
unseenAddedAssetsCount.value += currentAssets - countedAssets
|
||||
}
|
||||
|
||||
nextCountedAssetsByJobId.set(
|
||||
jobId,
|
||||
Math.max(countedAssets, currentAssets)
|
||||
)
|
||||
}
|
||||
|
||||
countedHistoryAssetsByJobId.value = nextCountedAssetsByJobId
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => sidebarTabStore.activeSidebarTabId,
|
||||
(activeSidebarTabId) => {
|
||||
if (activeSidebarTabId !== 'assets') {
|
||||
return
|
||||
}
|
||||
|
||||
unseenAddedAssetsCount.value = 0
|
||||
markCurrentHistoryAsSeen()
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
unseenAddedAssetsCount
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -92,9 +92,7 @@ export const graphToPrompt = async (
|
||||
const inputs: ComfyApiWorkflow[string]['inputs'] = {}
|
||||
const { widgets } = node
|
||||
|
||||
// Store all widget values in the API prompt.
|
||||
// Note: widget.options.serialize controls prompt inclusion (checked here).
|
||||
// widget.serialize controls workflow persistence (checked by LGraphNode).
|
||||
// Store all widget values
|
||||
if (widgets) {
|
||||
for (const [i, widget] of widgets.entries()) {
|
||||
if (!widget.name || widget.options?.serialize === false) continue
|
||||
|
||||
@@ -242,20 +242,6 @@ export default defineConfig({
|
||||
tailwindcss(),
|
||||
typegpuPlugin({}),
|
||||
comfyAPIPlugin(IS_DEV),
|
||||
// Exclude proprietary ABCROM fonts from non-cloud builds
|
||||
{
|
||||
name: 'exclude-proprietary-fonts',
|
||||
generateBundle(_options, bundle) {
|
||||
if (DISTRIBUTION !== 'cloud') {
|
||||
// Remove ABCROM font files from bundle
|
||||
for (const [fileName] of Object.entries(bundle)) {
|
||||
if (/ABCROM.*\.(woff2?|ttf|otf)$/i.test(fileName)) {
|
||||
delete bundle[fileName]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Inject legacy user stylesheet links for desktop/localhost only
|
||||
{
|
||||
name: 'inject-user-stylesheet-links',
|
||||
|
||||
Reference in New Issue
Block a user