mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 13:48:49 +00:00
Compare commits
7 Commits
playwright
...
fix/codera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5679af17b1 | ||
|
|
f3af3c90a7 | ||
|
|
63c36d3f2f | ||
|
|
892a9cf2c5 | ||
|
|
308c22efc6 | ||
|
|
5728d240da | ||
|
|
acf2f4280c |
@@ -25,11 +25,9 @@ import {
|
||||
import { Topbar } from './components/Topbar'
|
||||
import { CanvasHelper } from './helpers/CanvasHelper'
|
||||
import { PerformanceHelper } from './helpers/PerformanceHelper'
|
||||
import { QueueHelper } from './helpers/QueueHelper'
|
||||
import { ClipboardHelper } from './helpers/ClipboardHelper'
|
||||
import { CommandHelper } from './helpers/CommandHelper'
|
||||
import { DragDropHelper } from './helpers/DragDropHelper'
|
||||
import { FeatureFlagHelper } from './helpers/FeatureFlagHelper'
|
||||
import { KeyboardHelper } from './helpers/KeyboardHelper'
|
||||
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
|
||||
import { SettingsHelper } from './helpers/SettingsHelper'
|
||||
@@ -186,11 +184,9 @@ export class ComfyPage {
|
||||
public readonly contextMenu: ContextMenu
|
||||
public readonly toast: ToastHelper
|
||||
public readonly dragDrop: DragDropHelper
|
||||
public readonly featureFlags: FeatureFlagHelper
|
||||
public readonly command: CommandHelper
|
||||
public readonly bottomPanel: BottomPanel
|
||||
public readonly perf: PerformanceHelper
|
||||
public readonly queue: QueueHelper
|
||||
|
||||
/** Worker index to test user ID */
|
||||
public readonly userIds: string[] = []
|
||||
@@ -231,11 +227,9 @@ export class ComfyPage {
|
||||
this.contextMenu = new ContextMenu(page)
|
||||
this.toast = new ToastHelper(page)
|
||||
this.dragDrop = new DragDropHelper(page, this.assetPath.bind(this))
|
||||
this.featureFlags = new FeatureFlagHelper(page)
|
||||
this.command = new CommandHelper(page)
|
||||
this.bottomPanel = new BottomPanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
this.queue = new QueueHelper(page)
|
||||
}
|
||||
|
||||
get visibleToasts() {
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
export class FeatureFlagHelper {
|
||||
private featuresRouteHandler: ((route: Route) => void) | null = null
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Seed feature flags via `addInitScript` so they are available in
|
||||
* localStorage before the app JS executes on first load.
|
||||
* Must be called before `comfyPage.setup()` / `page.goto()`.
|
||||
*
|
||||
* Note: Playwright init scripts persist for the page lifetime and
|
||||
* cannot be removed. Call this once per test, before navigation.
|
||||
*/
|
||||
async seedFlags(flags: Record<string, unknown>): Promise<void> {
|
||||
await this.page.addInitScript((flagMap: Record<string, unknown>) => {
|
||||
for (const [key, value] of Object.entries(flagMap)) {
|
||||
localStorage.setItem(`ff:${key}`, JSON.stringify(value))
|
||||
}
|
||||
}, flags)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set feature flags at runtime via localStorage. Uses the `ff:` prefix
|
||||
* that devFeatureFlagOverride.ts reads in dev mode.
|
||||
* For flags needed before page init, use `seedFlags()` instead.
|
||||
*/
|
||||
async setFlags(flags: Record<string, unknown>): Promise<void> {
|
||||
await this.page.evaluate((flagMap: Record<string, unknown>) => {
|
||||
for (const [key, value] of Object.entries(flagMap)) {
|
||||
localStorage.setItem(`ff:${key}`, JSON.stringify(value))
|
||||
}
|
||||
}, flags)
|
||||
}
|
||||
|
||||
async setFlag(name: string, value: unknown): Promise<void> {
|
||||
await this.setFlags({ [name]: value })
|
||||
}
|
||||
|
||||
async clearFlags(): Promise<void> {
|
||||
await this.page.evaluate(() => {
|
||||
const keysToRemove: string[] = []
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (key?.startsWith('ff:')) keysToRemove.push(key)
|
||||
}
|
||||
keysToRemove.forEach((k) => {
|
||||
localStorage.removeItem(k)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock server feature flags via route interception on /api/features.
|
||||
*/
|
||||
async mockServerFeatures(features: Record<string, unknown>): Promise<void> {
|
||||
this.featuresRouteHandler = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(features)
|
||||
})
|
||||
await this.page.route('**/api/features', this.featuresRouteHandler)
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
if (this.featuresRouteHandler) {
|
||||
await this.page.unroute('**/api/features', this.featuresRouteHandler)
|
||||
this.featuresRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
export class QueueHelper {
|
||||
private queueRouteHandler: ((route: Route) => void) | null = null
|
||||
private historyRouteHandler: ((route: Route) => void) | null = null
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Mock the /api/queue endpoint to return specific queue state.
|
||||
*/
|
||||
async mockQueueState(
|
||||
running: number = 0,
|
||||
pending: number = 0
|
||||
): Promise<void> {
|
||||
this.queueRouteHandler = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
queue_running: Array.from({ length: running }, (_, i) => [
|
||||
i,
|
||||
`running-${i}`,
|
||||
{},
|
||||
{},
|
||||
[]
|
||||
]),
|
||||
queue_pending: Array.from({ length: pending }, (_, i) => [
|
||||
i,
|
||||
`pending-${i}`,
|
||||
{},
|
||||
{},
|
||||
[]
|
||||
])
|
||||
})
|
||||
})
|
||||
await this.page.route('**/api/queue', this.queueRouteHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the /api/history endpoint with completed/failed job entries.
|
||||
*/
|
||||
async mockHistory(
|
||||
jobs: Array<{ promptId: string; status: 'success' | 'error' }>
|
||||
): Promise<void> {
|
||||
const history: Record<string, unknown> = {}
|
||||
for (const job of jobs) {
|
||||
history[job.promptId] = {
|
||||
prompt: [0, job.promptId, {}, {}, []],
|
||||
outputs: {},
|
||||
status: {
|
||||
status_str: job.status === 'success' ? 'success' : 'error',
|
||||
completed: true
|
||||
}
|
||||
}
|
||||
}
|
||||
this.historyRouteHandler = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(history)
|
||||
})
|
||||
await this.page.route('**/api/history**', this.historyRouteHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all route mocks set by this helper.
|
||||
*/
|
||||
async clearMocks(): Promise<void> {
|
||||
if (this.queueRouteHandler) {
|
||||
await this.page.unroute('**/api/queue', this.queueRouteHandler)
|
||||
this.queueRouteHandler = null
|
||||
}
|
||||
if (this.historyRouteHandler) {
|
||||
await this.page.unroute('**/api/history**', this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,15 +60,6 @@ test.describe('Graph Canvas Menu', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('Fit view button is present and clickable', async ({ comfyPage }) => {
|
||||
const fitViewButton = comfyPage.page
|
||||
.locator('button')
|
||||
.filter({ has: comfyPage.page.locator('.icon-\\[lucide--focus\\]') })
|
||||
await expect(fitViewButton).toBeVisible()
|
||||
await fitViewButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('Zoom controls popup opens and closes', async ({ comfyPage }) => {
|
||||
// Find the zoom button by its percentage text content
|
||||
const zoomButton = comfyPage.page.locator('button').filter({
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Node Library Essentials Tab', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
|
||||
// Enable the essentials feature flag via the reactive serverFeatureFlags ref.
|
||||
// In production, this flag comes via WebSocket or remoteConfig (cloud only).
|
||||
// The localhost test server has neither, so we set it directly.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
node_library_essentials_enabled: true
|
||||
}
|
||||
})
|
||||
|
||||
// Register a mock essential node so the essentials tab has content.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
return window.app!.registerNodeDef('TestEssentialNode', {
|
||||
name: 'TestEssentialNode',
|
||||
display_name: 'Test Essential Node',
|
||||
category: 'essentials_test',
|
||||
input: { required: {}, optional: {} },
|
||||
output: ['IMAGE'],
|
||||
output_name: ['image'],
|
||||
output_is_list: [false],
|
||||
output_node: false,
|
||||
python_module: 'comfy_essentials.nodes',
|
||||
description: 'Mock essential node for testing',
|
||||
essentials_category: 'Image Generation'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Node library opens via sidebar', async ({ comfyPage }) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const sidebarContent = comfyPage.page.locator(
|
||||
'.comfy-vue-side-bar-container'
|
||||
)
|
||||
await expect(sidebarContent).toBeVisible()
|
||||
})
|
||||
|
||||
test('Essentials tab is visible in node library', async ({ comfyPage }) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
await expect(essentialsTab).toBeVisible()
|
||||
})
|
||||
|
||||
test('Clicking essentials tab shows essential node cards', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
await essentialsTab.click()
|
||||
|
||||
const essentialCards = comfyPage.page.locator('[data-node-name]')
|
||||
await expect(essentialCards.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Essential node cards have node names', async ({ comfyPage }) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
await essentialsTab.click()
|
||||
|
||||
const firstCard = comfyPage.page.locator('[data-node-name]').first()
|
||||
await expect(firstCard).toBeVisible()
|
||||
|
||||
const nodeName = await firstCard.getAttribute('data-node-name')
|
||||
expect(nodeName).toBeTruthy()
|
||||
expect(nodeName!.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Node library can switch between all and essentials tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tabButton = comfyPage.page.locator('.node-library-tab-button')
|
||||
await tabButton.click()
|
||||
|
||||
const essentialsTab = comfyPage.page.getByRole('tab', {
|
||||
name: /essentials/i
|
||||
})
|
||||
const allNodesTab = comfyPage.page.getByRole('tab', { name: /^all$/i })
|
||||
|
||||
await essentialsTab.click()
|
||||
await expect(essentialsTab).toHaveAttribute('aria-selected', 'true')
|
||||
const essentialCards = comfyPage.page.locator('[data-node-name]')
|
||||
await expect(essentialCards.first()).toBeVisible()
|
||||
|
||||
await allNodesTab.click()
|
||||
await expect(allNodesTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(essentialsTab).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
|
||||
async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
|
||||
const nodePos = await nodeRef.getPosition()
|
||||
await comfyPage.page.evaluate((pos) => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
|
||||
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
|
||||
canvas.setDirty(true, true)
|
||||
}, nodePos)
|
||||
await comfyPage.nextFrame()
|
||||
await nodeRef.click('title')
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Selection Toolbox - Flaky CI Tests (manual investigation needed)',
|
||||
{ tag: '@ui' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
// These tests pass locally but fail in CI headless due to SelectionToolbox
|
||||
// CSS transform positioning (useSelectionToolboxPosition composable).
|
||||
// The toolbox uses requestAnimationFrame-based position sync that places
|
||||
// buttons outside the visible area in headless viewport.
|
||||
test.skip('bypass button toggles node bypass state', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeRef = (
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
)[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
const bypassButton = comfyPage.page.locator(
|
||||
'[data-testid="bypass-button"]'
|
||||
)
|
||||
await expect(bypassButton).toBeVisible()
|
||||
await bypassButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(nodeRef).toBeBypassed()
|
||||
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
await expect(bypassButton).toBeVisible()
|
||||
await bypassButton.click({ force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(nodeRef).not.toBeBypassed()
|
||||
})
|
||||
|
||||
test.skip('refresh button is visible when node is selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeRef = (
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
)[0]
|
||||
await selectNodeWithPan(comfyPage, nodeRef)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="refresh-button"]')
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,66 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Widget copy button', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('Vue nodes render with widgets', async ({ comfyPage }) => {
|
||||
const nodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(nodeCount).toBeGreaterThan(0)
|
||||
|
||||
const firstNode = comfyPage.vueNodes.nodes.first()
|
||||
await expect(firstNode).toBeVisible()
|
||||
})
|
||||
|
||||
test('Textarea widgets exist on nodes', async ({ comfyPage }) => {
|
||||
const textareas = comfyPage.page.locator('[data-node-id] textarea')
|
||||
await expect(textareas.first()).toBeVisible()
|
||||
expect(await textareas.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Copy button has correct aria-label', async ({ comfyPage }) => {
|
||||
const copyButton = comfyPage.page
|
||||
.locator('[data-node-id] button[aria-label]')
|
||||
.filter({ has: comfyPage.page.locator('.icon-\\[lucide--copy\\]') })
|
||||
.first()
|
||||
await expect(copyButton).toBeAttached()
|
||||
await expect(copyButton).toHaveAttribute('aria-label', /copy/i)
|
||||
})
|
||||
|
||||
test('Copy icon uses lucide copy class', async ({ comfyPage }) => {
|
||||
const copyIcon = comfyPage.page
|
||||
.locator('[data-node-id] .icon-\\[lucide--copy\\]')
|
||||
.first()
|
||||
await expect(copyIcon).toBeAttached()
|
||||
})
|
||||
|
||||
test('Widget container has group class for hover', async ({ comfyPage }) => {
|
||||
const textarea = comfyPage.page.locator('[data-node-id] textarea').first()
|
||||
await expect(textarea).toBeVisible()
|
||||
const container = textarea.locator('..')
|
||||
await expect(container).toHaveClass(/group/)
|
||||
})
|
||||
|
||||
test('Copy button exists within textarea widget group container', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const container = comfyPage.page
|
||||
.locator('[data-node-id] div.group:has(textarea)')
|
||||
.first()
|
||||
await expect(container).toBeVisible()
|
||||
await container.hover()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const copyButton = container.locator('button').filter({
|
||||
has: comfyPage.page.locator('.icon-\\[lucide--copy\\]')
|
||||
})
|
||||
await expect(copyButton.first()).toBeAttached()
|
||||
})
|
||||
})
|
||||
@@ -46,6 +46,9 @@ const config: KnipConfig = {
|
||||
'.github/workflows/ci-oss-assets-validation.yaml',
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
|
||||
// Pending integration - accessible color picker components
|
||||
'src/components/ui/color-picker/ColorPickerSaturationValue.vue',
|
||||
'src/components/ui/color-picker/ColorPickerSlider.vue',
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js'
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.42.1",
|
||||
"version": "1.42.2",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -72,12 +72,12 @@
|
||||
/>
|
||||
</div>
|
||||
<SliderControl
|
||||
v-model="brushSize"
|
||||
v-model="brushSizeSliderValue"
|
||||
class="flex-1"
|
||||
label=""
|
||||
:min="1"
|
||||
:max="250"
|
||||
:step="1"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -182,6 +182,26 @@ const brushSize = computed({
|
||||
set: (value: number) => store.setBrushSize(value)
|
||||
})
|
||||
|
||||
const rawSliderValue = ref<number | null>(null)
|
||||
|
||||
const brushSizeSliderValue = computed({
|
||||
get: () => {
|
||||
if (rawSliderValue.value !== null) {
|
||||
const cachedSize = Math.round(Math.pow(250, rawSliderValue.value))
|
||||
if (cachedSize === brushSize.value) {
|
||||
return rawSliderValue.value
|
||||
}
|
||||
}
|
||||
|
||||
return Math.log(brushSize.value) / Math.log(250)
|
||||
},
|
||||
set: (value: number) => {
|
||||
rawSliderValue.value = value
|
||||
const size = Math.round(Math.pow(250, value))
|
||||
store.setBrushSize(size)
|
||||
}
|
||||
})
|
||||
|
||||
const brushOpacity = computed({
|
||||
get: () => store.brushSettings.opacity,
|
||||
set: (value: number) => store.setBrushOpacity(value)
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
tabindex="0"
|
||||
:aria-label="
|
||||
t('assetBrowser.ariaLabel.assetCard', {
|
||||
name: item.asset.name,
|
||||
name: getAssetDisplayName(item.asset),
|
||||
type: getAssetMediaType(item.asset)
|
||||
})
|
||||
"
|
||||
@@ -44,7 +44,7 @@
|
||||
)
|
||||
"
|
||||
:preview-url="getAssetPreviewUrl(item.asset)"
|
||||
:preview-alt="item.asset.name"
|
||||
:preview-alt="getAssetDisplayName(item.asset)"
|
||||
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
|
||||
:is-video-preview="isVideoAsset(item.asset)"
|
||||
:primary-text="getAssetPrimaryText(item.asset)"
|
||||
@@ -133,8 +133,12 @@ const listGridStyle = {
|
||||
gap: '0.5rem'
|
||||
}
|
||||
|
||||
function getAssetDisplayName(asset: AssetItem): string {
|
||||
return asset.display_name || asset.name
|
||||
}
|
||||
|
||||
function getAssetPrimaryText(asset: AssetItem): string {
|
||||
return truncateFilename(asset.name)
|
||||
return truncateFilename(getAssetDisplayName(asset))
|
||||
}
|
||||
|
||||
function getAssetMediaType(asset: AssetItem) {
|
||||
|
||||
@@ -569,7 +569,7 @@ const handleZoomClick = (asset: AssetItem) => {
|
||||
const dialogStore = useDialogStore()
|
||||
dialogStore.showDialog({
|
||||
key: 'asset-3d-viewer',
|
||||
title: asset.name,
|
||||
title: asset.display_name || asset.name,
|
||||
component: Load3dViewerContent,
|
||||
props: {
|
||||
modelUrl: asset.preview_url || ''
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { hue } = defineProps<{
|
||||
hue: number
|
||||
}>()
|
||||
|
||||
const saturation = defineModel<number>('saturation', { required: true })
|
||||
const value = defineModel<number>('value', { required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const hueBackground = computed(() => `hsl(${hue}, 100%, 50%)`)
|
||||
|
||||
const handleStyle = computed(() => ({
|
||||
left: `${saturation.value}%`,
|
||||
top: `${100 - value.value}%`
|
||||
}))
|
||||
|
||||
function clamp(v: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, v))
|
||||
}
|
||||
|
||||
function updateFromPointer(e: PointerEvent) {
|
||||
const el = containerRef.value
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
|
||||
saturation.value = Math.round(x * 100)
|
||||
value.value = Math.round((1 - y) * 100)
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
updateFromPointer(e)
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
|
||||
updateFromPointer(e)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const step = e.shiftKey ? 10 : 1
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
saturation.value = clamp(saturation.value - step, 0, 100)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
saturation.value = clamp(saturation.value + step, 0, 100)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
value.value = clamp(value.value + step, 0, 100)
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
value.value = clamp(value.value - step, 0, 100)
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
:aria-label="t('colorPicker.saturationValue')"
|
||||
:aria-valuetext="`${t('colorPicker.saturation')}: ${saturation}%, ${t('colorPicker.brightness')}: ${value}%`"
|
||||
class="relative aspect-square w-full cursor-crosshair rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-highlight"
|
||||
:style="{ backgroundColor: hueBackground, touchAction: 'none' }"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 rounded-sm bg-linear-to-r from-white to-transparent"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 rounded-sm bg-linear-to-b from-transparent to-black"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute size-3.5 -translate-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
|
||||
:style="handleStyle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
115
src/components/ui/color-picker/ColorPickerSlider.vue
Normal file
115
src/components/ui/color-picker/ColorPickerSlider.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
|
||||
|
||||
const {
|
||||
type,
|
||||
hue = 0,
|
||||
saturation = 100,
|
||||
brightness = 100
|
||||
} = defineProps<{
|
||||
type: 'hue' | 'alpha'
|
||||
hue?: number
|
||||
saturation?: number
|
||||
brightness?: number
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<number>({ required: true })
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const max = computed(() => (type === 'hue' ? 360 : 100))
|
||||
|
||||
const fraction = computed(() => modelValue.value / max.value)
|
||||
|
||||
const trackBackground = computed(() => {
|
||||
if (type === 'hue') {
|
||||
return 'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)'
|
||||
}
|
||||
const rgb = hsbToRgb({ h: hue, s: saturation, b: brightness })
|
||||
const hex = rgbToHex(rgb)
|
||||
return `linear-gradient(to right, transparent, ${hex})`
|
||||
})
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
if (type === 'alpha') {
|
||||
return {
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '8px 8px',
|
||||
touchAction: 'none'
|
||||
}
|
||||
}
|
||||
return {
|
||||
background: trackBackground.value,
|
||||
touchAction: 'none'
|
||||
}
|
||||
})
|
||||
|
||||
const ariaLabel = computed(() =>
|
||||
type === 'hue' ? t('colorPicker.hue') : t('colorPicker.alpha')
|
||||
)
|
||||
|
||||
function clamp(v: number, min: number, maxVal: number) {
|
||||
return Math.max(min, Math.min(maxVal, v))
|
||||
}
|
||||
|
||||
function updateFromPointer(e: PointerEvent) {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
const rect = el.getBoundingClientRect()
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
modelValue.value = Math.round(x * max.value)
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
updateFromPointer(e)
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (!(e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) return
|
||||
updateFromPointer(e)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const step = e.shiftKey ? 10 : 1
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
modelValue.value = clamp(modelValue.value - step, 0, max.value)
|
||||
break
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
modelValue.value = clamp(modelValue.value + step, 0, max.value)
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-valuenow="modelValue"
|
||||
:aria-valuemin="0"
|
||||
:aria-valuemax="max"
|
||||
class="relative flex h-4 cursor-pointer items-center rounded-full p-px outline-none focus-visible:ring-2 focus-visible:ring-highlight"
|
||||
:style="containerStyle"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div
|
||||
v-if="type === 'alpha'"
|
||||
class="absolute inset-0 rounded-full"
|
||||
:style="{ background: trackBackground }"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute aspect-square h-full -translate-x-1/2 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)]"
|
||||
:style="{ left: `${fraction * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -483,6 +483,13 @@
|
||||
"black": "Black",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"colorPicker": {
|
||||
"saturationValue": "Saturation and brightness",
|
||||
"saturation": "Saturation",
|
||||
"brightness": "Brightness",
|
||||
"hue": "Hue",
|
||||
"alpha": "Alpha"
|
||||
},
|
||||
"contextMenu": {
|
||||
"Inputs": "Inputs",
|
||||
"Outputs": "Outputs",
|
||||
|
||||
@@ -186,7 +186,7 @@ const tooltipDelay = computed<number>(() =>
|
||||
|
||||
const { isLoading, error } = useImage({
|
||||
src: asset.preview_url ?? '',
|
||||
alt: asset.name
|
||||
alt: asset.display_name || asset.name
|
||||
})
|
||||
|
||||
function handleSelect() {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:aria-label="
|
||||
asset
|
||||
? $t('assetBrowser.ariaLabel.assetCard', {
|
||||
name: asset.name,
|
||||
name: asset.display_name || asset.name,
|
||||
type: fileKind
|
||||
})
|
||||
: $t('assetBrowser.ariaLabel.loadingAsset')
|
||||
@@ -225,7 +225,7 @@ const canInspect = computed(() => isPreviewableMediaType(fileKind.value))
|
||||
|
||||
// Get filename without extension
|
||||
const fileName = computed(() => {
|
||||
return getFilenameDetails(asset?.name || '').filename
|
||||
return getFilenameDetails(asset?.display_name || asset?.name || '').filename
|
||||
})
|
||||
|
||||
// Adapt AssetItem to legacy AssetMeta format for existing components
|
||||
@@ -234,6 +234,7 @@ const adaptedAsset = computed(() => {
|
||||
return {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
display_name: asset.display_name,
|
||||
kind: fileKind.value,
|
||||
src: asset.preview_url || '',
|
||||
size: asset.size,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<img
|
||||
v-if="!error"
|
||||
:src="asset.src"
|
||||
:alt="asset.name"
|
||||
:alt="asset.display_name || asset.name"
|
||||
class="size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
|
||||
/>
|
||||
<div
|
||||
@@ -34,7 +34,7 @@ const emit = defineEmits<{
|
||||
|
||||
const { state, error, isReady } = useImage({
|
||||
src: asset.src ?? '',
|
||||
alt: asset.name
|
||||
alt: asset.display_name || asset.name
|
||||
})
|
||||
|
||||
whenever(
|
||||
|
||||
@@ -39,6 +39,7 @@ export function mapTaskOutputToAssetItem(
|
||||
return {
|
||||
id: taskItem.jobId,
|
||||
name: output.filename,
|
||||
display_name: output.display_name,
|
||||
size: 0,
|
||||
created_at: taskItem.executionStartTimestamp
|
||||
? new Date(taskItem.executionStartTimestamp).toISOString()
|
||||
|
||||
@@ -68,7 +68,7 @@ export function useMediaAssetActions() {
|
||||
if (!targetAsset) return
|
||||
|
||||
try {
|
||||
const filename = targetAsset.name
|
||||
const filename = targetAsset.display_name || targetAsset.name
|
||||
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
|
||||
const downloadUrl = targetAsset.preview_url || getAssetUrl(targetAsset)
|
||||
|
||||
@@ -109,7 +109,7 @@ export function useMediaAssetActions() {
|
||||
|
||||
try {
|
||||
assets.forEach((asset) => {
|
||||
const filename = asset.name
|
||||
const filename = asset.display_name || asset.name
|
||||
const downloadUrl = asset.preview_url || getAssetUrl(asset)
|
||||
downloadFile(downloadUrl, filename)
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ const zAsset = z.object({
|
||||
mime_type: z.string().nullish(),
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
preview_id: z.string().nullable().optional(),
|
||||
display_name: z.string().optional(),
|
||||
preview_url: z.string().optional(),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
|
||||
@@ -20,6 +20,7 @@ type OutputOverrides = Partial<{
|
||||
subfolder: string
|
||||
nodeId: string
|
||||
url: string
|
||||
display_name: string
|
||||
}>
|
||||
|
||||
function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
|
||||
@@ -32,7 +33,8 @@ function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
|
||||
}
|
||||
return {
|
||||
...merged,
|
||||
previewUrl: merged.url
|
||||
previewUrl: merged.url,
|
||||
display_name: merged.display_name
|
||||
} as ResultItemImpl
|
||||
}
|
||||
|
||||
@@ -125,6 +127,48 @@ describe('resolveOutputAssetItems', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('propagates display_name from output to asset item', async () => {
|
||||
const output = createOutput({
|
||||
filename: 'abc123hash.png',
|
||||
nodeId: '1',
|
||||
url: 'https://example.com/abc123hash.png',
|
||||
display_name: 'ComfyUI_00001_.png'
|
||||
})
|
||||
const metadata: OutputAssetMetadata = {
|
||||
jobId: 'job-dn',
|
||||
nodeId: '1',
|
||||
subfolder: 'sub',
|
||||
outputCount: 1,
|
||||
allOutputs: [output]
|
||||
}
|
||||
|
||||
const results = await resolveOutputAssetItems(metadata)
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].name).toBe('abc123hash.png')
|
||||
expect(results[0].display_name).toBe('ComfyUI_00001_.png')
|
||||
})
|
||||
|
||||
it('omits display_name when not present in output', async () => {
|
||||
const output = createOutput({
|
||||
filename: 'file.png',
|
||||
nodeId: '1',
|
||||
url: 'https://example.com/file.png'
|
||||
})
|
||||
const metadata: OutputAssetMetadata = {
|
||||
jobId: 'job-nodn',
|
||||
nodeId: '1',
|
||||
subfolder: 'sub',
|
||||
outputCount: 1,
|
||||
allOutputs: [output]
|
||||
}
|
||||
|
||||
const results = await resolveOutputAssetItems(metadata)
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].display_name).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps root outputs with empty subfolders', async () => {
|
||||
const output = createOutput({
|
||||
filename: 'root.png',
|
||||
|
||||
@@ -69,6 +69,7 @@ function mapOutputsToAssetItems({
|
||||
items.push({
|
||||
id: `${jobId}-${outputKey}`,
|
||||
name: output.filename,
|
||||
display_name: output.display_name,
|
||||
size: 0,
|
||||
created_at: createdAtValue,
|
||||
tags: ['output'],
|
||||
|
||||
@@ -23,7 +23,8 @@ const zPreviewOutput = z.object({
|
||||
subfolder: z.string(),
|
||||
type: resultItemType,
|
||||
nodeId: z.string(),
|
||||
mediaType: z.string()
|
||||
mediaType: z.string(),
|
||||
display_name: z.string().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useInfiniteScroll,
|
||||
useResizeObserver
|
||||
} from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
@@ -26,11 +27,13 @@ import type {
|
||||
import OutputPreviewItem from '@/renderer/extensions/linearMode/OutputPreviewItem.vue'
|
||||
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { outputs, allOutputs, selectFirstHistory, mayBeActiveWorkflowPending } =
|
||||
useOutputHistory()
|
||||
const { hasOutputs } = storeToRefs(useAppModeStore())
|
||||
const queueStore = useQueueStore()
|
||||
const store = useLinearOutputStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -156,8 +159,10 @@ watch(
|
||||
const inProgress = store.activeWorkflowInProgressItems
|
||||
if (inProgress.length > 0) {
|
||||
store.selectAsLatest(`slot:${inProgress[0].id}`)
|
||||
} else {
|
||||
} else if (hasOutputs.value) {
|
||||
selectFirstHistory()
|
||||
} else {
|
||||
store.selectAsLatest(null)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -180,13 +185,13 @@ watch(
|
||||
: undefined
|
||||
|
||||
if (!sv || sv.kind !== 'history') {
|
||||
selectFirstHistory()
|
||||
if (hasOutputs.value) selectFirstHistory()
|
||||
return
|
||||
}
|
||||
|
||||
const wasFirst = sv.assetId === oldAssets[0]?.id
|
||||
if (wasFirst || !newAssets.some((a) => a.id === sv.assetId)) {
|
||||
selectFirstHistory()
|
||||
if (hasOutputs.value) selectFirstHistory()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -219,6 +219,7 @@ describe(useOutputHistory, () => {
|
||||
})
|
||||
|
||||
it('returns outputs from metadata allOutputs when count matches', () => {
|
||||
useAppModeStore().selectedOutputs.push('1')
|
||||
const results = [makeResult('a.png'), makeResult('b.png')]
|
||||
const asset = makeAsset('a1', 'job-1', {
|
||||
allOutputs: results,
|
||||
@@ -255,7 +256,7 @@ describe(useOutputHistory, () => {
|
||||
expect(outputs[0].filename).toBe('b.png')
|
||||
})
|
||||
|
||||
it('returns all outputs when no output nodes are selected', () => {
|
||||
it('returns empty when no output nodes are selected', () => {
|
||||
const results = [makeResult('a.png', '1'), makeResult('b.png', '2')]
|
||||
const asset = makeAsset('a1', 'job-1', {
|
||||
allOutputs: results,
|
||||
@@ -265,7 +266,7 @@ describe(useOutputHistory, () => {
|
||||
const { allOutputs } = useOutputHistory()
|
||||
const outputs = allOutputs(asset)
|
||||
|
||||
expect(outputs).toHaveLength(2)
|
||||
expect(outputs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns consistent filtered outputs across repeated calls', () => {
|
||||
@@ -288,6 +289,7 @@ describe(useOutputHistory, () => {
|
||||
})
|
||||
|
||||
it('returns in-progress outputs for pending resolve jobs', () => {
|
||||
useAppModeStore().selectedOutputs.push('1')
|
||||
pendingResolveRef.value = new Set(['job-1'])
|
||||
inProgressItemsRef.value = [
|
||||
{
|
||||
@@ -314,6 +316,7 @@ describe(useOutputHistory, () => {
|
||||
})
|
||||
|
||||
it('fetches full job detail for multi-output jobs', async () => {
|
||||
useAppModeStore().selectedOutputs.push('1')
|
||||
jobDetailResults.set('job-1', {
|
||||
outputs: {
|
||||
'1': {
|
||||
@@ -342,6 +345,7 @@ describe(useOutputHistory, () => {
|
||||
|
||||
describe('watchEffect resolve loop', () => {
|
||||
it('resolves pending jobs when history outputs load', async () => {
|
||||
useAppModeStore().selectedOutputs.push('1')
|
||||
const results = [makeResult('a.png')]
|
||||
const asset = makeAsset('a1', 'job-1', {
|
||||
allOutputs: results,
|
||||
@@ -360,6 +364,7 @@ describe(useOutputHistory, () => {
|
||||
})
|
||||
|
||||
it('does not select first history when a selection exists', async () => {
|
||||
useAppModeStore().selectedOutputs.push('1')
|
||||
const results = [makeResult('a.png')]
|
||||
const asset = makeAsset('a1', 'job-1', {
|
||||
allOutputs: results,
|
||||
|
||||
@@ -65,7 +65,7 @@ export function useOutputHistory(): {
|
||||
|
||||
function filterByOutputNodes(items: ResultItemImpl[]): ResultItemImpl[] {
|
||||
const nodeIds = appModeStore.selectedOutputs
|
||||
if (!nodeIds.length) return items
|
||||
if (!nodeIds.length) return []
|
||||
return items.filter((r) =>
|
||||
nodeIds.some((id) => String(id) === String(r.nodeId))
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<!-- Video Wrapper -->
|
||||
<div
|
||||
ref="videoWrapperEl"
|
||||
class="relative flex flex-1 overflow-hidden rounded-[5px] bg-node-component-surface"
|
||||
class="relative flex flex-1 overflow-hidden rounded-[5px] bg-transparent"
|
||||
tabindex="0"
|
||||
role="region"
|
||||
:aria-label="$t('g.videoPreview')"
|
||||
@@ -203,7 +203,6 @@ const handleDownload = () => {
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: t('g.failedToDownloadVideo'),
|
||||
life: 3000,
|
||||
group: 'video-preview'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ export type CustomNodesI18n = z.infer<typeof zCustomNodesI18n>
|
||||
const zResultItem = z.object({
|
||||
filename: z.string().optional(),
|
||||
subfolder: z.string().optional(),
|
||||
type: resultItemType.optional()
|
||||
type: resultItemType.optional(),
|
||||
display_name: z.string().optional()
|
||||
})
|
||||
export type ResultItem = z.infer<typeof zResultItem>
|
||||
const zOutputs = z
|
||||
|
||||
@@ -255,6 +255,35 @@ describe('jobOutputCache', () => {
|
||||
expect(video?.mediaType).toBe('video')
|
||||
})
|
||||
|
||||
it('preserves display_name from output items', async () => {
|
||||
const { getPreviewableOutputsFromJobDetail } =
|
||||
await import('@/services/jobOutputCache')
|
||||
const jobDetail: JobDetail = {
|
||||
id: 'job-display-name',
|
||||
status: 'completed',
|
||||
create_time: Date.now(),
|
||||
priority: 0,
|
||||
outputs: {
|
||||
'node-1': {
|
||||
images: [
|
||||
{
|
||||
filename: 'abc123hash.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
display_name: 'ComfyUI_00001_.png'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = getPreviewableOutputsFromJobDetail(jobDetail)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].filename).toBe('abc123hash.png')
|
||||
expect(result[0].display_name).toBe('ComfyUI_00001_.png')
|
||||
})
|
||||
|
||||
it('filters non-previewable outputs and non-object items', async () => {
|
||||
const { getPreviewableOutputsFromJobDetail } =
|
||||
await import('@/services/jobOutputCache')
|
||||
|
||||
@@ -70,7 +70,7 @@ function mapHistoryToAssets(historyItems: JobListItem[]): AssetItem[] {
|
||||
|
||||
assetItem.user_metadata = {
|
||||
...assetItem.user_metadata,
|
||||
outputCount: task.previewableOutputs.length,
|
||||
outputCount: task.outputsCount ?? task.previewableOutputs.length,
|
||||
allOutputs: task.previewableOutputs
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ interface ResultItemInit extends ResultItem {
|
||||
mediaType: string
|
||||
format?: string
|
||||
frame_rate?: number
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export class ResultItemImpl {
|
||||
@@ -48,6 +49,8 @@ export class ResultItemImpl {
|
||||
// 'audio' | 'images' | ...
|
||||
mediaType: string
|
||||
|
||||
display_name?: string
|
||||
|
||||
// VHS output specific fields
|
||||
format?: string
|
||||
frame_rate?: number
|
||||
@@ -60,6 +63,8 @@ export class ResultItemImpl {
|
||||
this.nodeId = obj.nodeId
|
||||
this.mediaType = obj.mediaType
|
||||
|
||||
this.display_name = obj.display_name
|
||||
|
||||
this.format = obj.format
|
||||
this.frame_rate = obj.frame_rate
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user