Compare commits

..

7 Commits

Author SHA1 Message Date
bymyself
a724bd9b43 refactor: centralize NodeExecutionOutput → ResultItemImpl parsing
Extract shared isResultItemLike guard and flattenNodeExecutionOutput/
flattenTaskOutputs into resultItemParsing.ts, replacing three separate
implementations that disagreed on validation strictness:

- flattenNodeOutput.ts: was strict (required filename+subfolder)
- jobOutputCache.ts: was permissive (accepted partial objects)
- queueStore.ts: had no validation (cast blindly to ResultItem[])

The shared guard requires filename and subfolder as strings (strict
domain boundary) while the wire schema (zOutputs) remains permissive
via .passthrough() to accept arbitrary custom node output keys.
2026-03-12 00:08:11 -07:00
bymyself
45acf75393 fix: tighten isResultItemLike to require filename and subfolder strings 2026-03-08 17:51:54 -07:00
bymyself
c6c3e69241 fix: support non-standard output keys in app mode preview
Replace hardcoded allowlist of 5 output keys (images, audio, video,
gifs, 3d) with dynamic iteration over all output entries, validating
each item with isResultItemLike. Nodes like ImageCompare that output
non-standard keys (a_images, b_images) now preview correctly.
2026-03-08 17:46:39 -07:00
pythongosssss
892a9cf2c5 fix: prevent showing outputs in app mode when no output nodes configured (#9625)
## Summary

After a user runs the workflow once in graph mode, switching to app mode
with no app built, incorrectly showed the app mode outputs view instead
of the intro screen

## Changes

- **What**: don't try and select outputs if no outputs & filter out all
outputs when nothing chosen
2026-03-08 17:36:15 -07:00
Comfy Org PR Bot
308c22efc6 1.42.2 (#9629)
Patch version increment to 1.42.2

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9629-1-42-2-31e6d73d365081faa106d97ae431e2e6)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-03-08 17:24:52 -07:00
Hunter
5728d240da fix: restore backend outputs_count for asset sidebar multi-output badge (#9627)
## Summary

Fix regression from PR #9535 where the multi-output count badge stopped
appearing in the asset sidebar.

## Root Cause

PR #9535 changed `outputCount` in `mapHistoryToAssets` from
`job.outputs_count` (backend-provided total) to
`task.previewableOutputs.length`. However, `TaskItemImpl` constructed
from a job listing only has the single `preview_output`, so
`previewableOutputs.length` is always **1** — the multi-output badge
never appears.

## Fix

Use the backend-provided `outputs_count` (via `task.outputsCount`) with
fallback to `task.previewableOutputs.length` when unavailable. This
restores the correct count while preserving the fallback for jobs that
don't have `outputs_count` from the server.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9627-fix-restore-backend-outputs_count-for-asset-sidebar-multi-output-badge-31d6d73d36508160b93fd03af4a01aa3)
by [Unito](https://www.unito.io)
2026-03-08 13:17:22 -07:00
Kelly Yang
acf2f4280c fix(maskeditor): make brush size slider logarithmic (#8097) (#9534)
## Summary
fix #8097.

This PR shifts the Mask Editor Brush Size slider from a linear scale to
a logarithmic (exponential) scale. Previously, the linear 1-250 range
heavily clumped the usable, small "fine-detail" brush sizes (e.g., 1px
to 20px) into the very first 10% of the slider, making it extremely
difficult to select precise sizes with the mouse.

This update borrows UX paradigms from other standard image editors like
Photoshop and GIMP, which map their scale entry widgets on an
exponential curve.

## GIMP Source
By inspecting the official **GIMP** source code under
`libgimpwidgets/gimpscaleentry.c`, we can see this exact mathematical
relationship being utilized when the logarithmic property is marked TRUE
on a brush radius adjustment widget:

```
// Mapping visual slider to internal value
value = gtk_adjustment_get_lower(...) + exp(t);
// Mapping internal value to visual slider
t = log (value - gtk_adjustment_get_lower(...) + 0.1);
```


https://github.com/user-attachments/assets/6d59ff12-f623-42cc-a52b-84147e9bb90b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9534-fix-maskeditor-make-brush-size-slider-logarithmic-8097-31c6d73d365081118508e8363e0c5312)
by [Unito](https://www.unito.io)
2026-03-08 09:11:19 -07:00
20 changed files with 376 additions and 591 deletions

View File

@@ -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() {

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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({

View File

@@ -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')
})
})

View File

@@ -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()
})
}
)

View File

@@ -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()
})
})

View File

@@ -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",

View File

@@ -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)

View File

@@ -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()
}
}
)

View File

@@ -1,85 +1,40 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
function makeOutput(
overrides: Partial<NodeExecutionOutput> = {}
): NodeExecutionOutput {
return { ...overrides }
}
vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn((path: string) => `/api${path}`),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
describe(flattenNodeOutput, () => {
it('returns empty array for output with no known media types', () => {
const result = flattenNodeOutput(['1', makeOutput({ text: 'hello' })])
expect(result).toEqual([])
})
it('flattens images into ResultItemImpl instances', () => {
const output = makeOutput({
images: [
{ filename: 'a.png', subfolder: '', type: 'output' },
{ filename: 'b.png', subfolder: 'sub', type: 'output' }
]
})
it('delegates to shared parser and returns ResultItemImpl instances', () => {
const output: NodeExecutionOutput = {
images: [{ filename: 'a.png', subfolder: '', type: 'output' }]
}
const result = flattenNodeOutput(['42', output])
expect(result).toHaveLength(2)
expect(result).toHaveLength(1)
expect(result[0].filename).toBe('a.png')
expect(result[0].nodeId).toBe('42')
expect(result[0].mediaType).toBe('images')
expect(result[1].filename).toBe('b.png')
expect(result[1].subfolder).toBe('sub')
})
it('flattens audio outputs', () => {
const output = makeOutput({
audio: [{ filename: 'sound.wav', subfolder: '', type: 'output' }]
})
it('supports non-standard output keys', () => {
const output = {
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
} as unknown as NodeExecutionOutput
const result = flattenNodeOutput([7, output])
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('audio')
expect(result[0].nodeId).toBe(7)
})
it('flattens multiple media types in a single output', () => {
const output = makeOutput({
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
video: [{ filename: 'vid.mp4', subfolder: '', type: 'output' }]
})
const result = flattenNodeOutput(['1', output])
const result = flattenNodeOutput(['10', output])
expect(result).toHaveLength(2)
const types = result.map((r) => r.mediaType)
expect(types).toContain('images')
expect(types).toContain('video')
})
it('handles gifs and 3d output types', () => {
const output = makeOutput({
gifs: [
{ filename: 'anim.gif', subfolder: '', type: 'output' }
] as NodeExecutionOutput['gifs'],
'3d': [
{ filename: 'model.glb', subfolder: '', type: 'output' }
] as NodeExecutionOutput['3d']
})
const result = flattenNodeOutput(['5', output])
expect(result).toHaveLength(2)
const types = result.map((r) => r.mediaType)
expect(types).toContain('gifs')
expect(types).toContain('3d')
})
it('ignores empty arrays', () => {
const output = makeOutput({ images: [], audio: [] })
const result = flattenNodeOutput(['1', output])
expect(result).toEqual([])
expect(result.map((r) => r.filename)).toContain('before.png')
expect(result.map((r) => r.filename)).toContain('after.png')
})
})

View File

@@ -1,20 +1,10 @@
import type { NodeExecutionOutput, ResultItem } from '@/schemas/apiSchema'
import { ResultItemImpl } from '@/stores/queueStore'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
import { flattenNodeExecutionOutput } from '@/stores/resultItemParsing'
import type { ResultItemImpl } from '@/stores/queueStore'
export function flattenNodeOutput([nodeId, nodeOutput]: [
string | number,
NodeExecutionOutput
]): ResultItemImpl[] {
const knownOutputs: Record<string, ResultItem[]> = {}
if (nodeOutput.audio) knownOutputs.audio = nodeOutput.audio
if (nodeOutput.images) knownOutputs.images = nodeOutput.images
if (nodeOutput.video) knownOutputs.video = nodeOutput.video
if (nodeOutput.gifs) knownOutputs.gifs = nodeOutput.gifs as ResultItem[]
if (nodeOutput['3d']) knownOutputs['3d'] = nodeOutput['3d'] as ResultItem[]
return Object.entries(knownOutputs).flatMap(([mediaType, outputs]) =>
outputs.map(
(output) => new ResultItemImpl({ ...output, mediaType, nodeId })
)
)
return flattenNodeExecutionOutput(nodeId, nodeOutput)
}

View File

@@ -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,

View File

@@ -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))
)

View File

@@ -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'
})
}

View File

@@ -11,10 +11,10 @@ import QuickLRU from '@alloc/quick-lru'
import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { resultItemType } from '@/schemas/apiSchema'
import type { ResultItem, TaskOutput } from '@/schemas/apiSchema'
import type { TaskOutput } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { ResultItemImpl } from '@/stores/queueStore'
import { flattenTaskOutputs } from '@/stores/resultItemParsing'
import type { TaskItemImpl } from '@/stores/queueStore'
const MAX_TASK_CACHE_SIZE = 50
@@ -78,66 +78,7 @@ export async function getOutputsForTask(
}
function getPreviewableOutputs(outputs?: TaskOutput): ResultItemImpl[] {
if (!outputs) return []
const resultItems = Object.entries(outputs).flatMap(([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs)
.filter(([mediaType, _]) => mediaType !== 'animated')
.flatMap(([mediaType, items]) => {
if (!Array.isArray(items)) {
return []
}
return items.filter(isResultItemLike).map(
(item) =>
new ResultItemImpl({
...item,
nodeId,
mediaType
})
)
})
)
return ResultItemImpl.filterPreviewable(resultItems)
}
function isResultItemLike(item: unknown): item is ResultItem {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return false
}
const candidate = item as Record<string, unknown>
if (
candidate.filename !== undefined &&
typeof candidate.filename !== 'string'
) {
return false
}
if (
candidate.subfolder !== undefined &&
typeof candidate.subfolder !== 'string'
) {
return false
}
if (
candidate.type !== undefined &&
!resultItemType.safeParse(candidate.type).success
) {
return false
}
if (
candidate.filename === undefined &&
candidate.subfolder === undefined &&
candidate.type === undefined
) {
return false
}
return true
return ResultItemImpl.filterPreviewable(flattenTaskOutputs(outputs))
}
export function getPreviewableOutputsFromJobDetail(

View File

@@ -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
}

View File

@@ -14,6 +14,7 @@ import type {
StatusWsMessageStatus,
TaskOutput
} from '@/schemas/apiSchema'
import { flattenTaskOutputs } from '@/stores/resultItemParsing'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { api } from '@/scripts/api'
import type { ComfyApp } from '@/scripts/app'
@@ -259,21 +260,7 @@ export class TaskItemImpl {
}
calculateFlatOutputs(): ReadonlyArray<ResultItemImpl> {
if (!this.outputs) {
return []
}
return Object.entries(this.outputs).flatMap(([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
(items as ResultItem[]).map(
(item: ResultItem) =>
new ResultItemImpl({
...item,
nodeId,
mediaType
})
)
)
)
return flattenTaskOutputs(this.outputs)
}
/** All outputs that support preview (images, videos, audio, 3D) */

View File

@@ -0,0 +1,229 @@
import { describe, expect, it, vi } from 'vitest'
import type { NodeExecutionOutput, TaskOutput } from '@/schemas/apiSchema'
import {
flattenNodeExecutionOutput,
flattenTaskOutputs,
isResultItemLike
} from '@/stores/resultItemParsing'
vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn((path: string) => `/api${path}`),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
describe(isResultItemLike, () => {
it('accepts valid result items', () => {
expect(
isResultItemLike({ filename: 'a.png', subfolder: '', type: 'output' })
).toBe(true)
})
it('accepts items without type', () => {
expect(isResultItemLike({ filename: 'a.png', subfolder: '' })).toBe(true)
})
it('rejects null/undefined/primitives', () => {
expect(isResultItemLike(null)).toBe(false)
expect(isResultItemLike(undefined)).toBe(false)
expect(isResultItemLike('string')).toBe(false)
expect(isResultItemLike(42)).toBe(false)
})
it('rejects arrays', () => {
expect(isResultItemLike([1, 2, 3])).toBe(false)
})
it('rejects objects missing filename', () => {
expect(isResultItemLike({ subfolder: '', type: 'output' })).toBe(false)
})
it('rejects objects missing subfolder', () => {
expect(isResultItemLike({ filename: 'a.png', type: 'output' })).toBe(false)
})
it('rejects objects with non-string filename', () => {
expect(isResultItemLike({ filename: 123, subfolder: '' })).toBe(false)
})
it('rejects objects with non-string subfolder', () => {
expect(isResultItemLike({ filename: 'a.png', subfolder: 42 })).toBe(false)
})
it('rejects objects with invalid type', () => {
expect(
isResultItemLike({
filename: 'a.png',
subfolder: '',
type: 'invalid_type'
})
).toBe(false)
})
it('rejects objects with only type (no filename/subfolder)', () => {
expect(isResultItemLike({ type: 'output' })).toBe(false)
})
it('rejects empty objects', () => {
expect(isResultItemLike({})).toBe(false)
})
})
describe(flattenNodeExecutionOutput, () => {
it('flattens standard image outputs', () => {
const output: NodeExecutionOutput = {
images: [
{ filename: 'a.png', subfolder: '', type: 'output' },
{ filename: 'b.png', subfolder: 'sub', type: 'output' }
]
}
const result = flattenNodeExecutionOutput('42', output)
expect(result).toHaveLength(2)
expect(result[0].filename).toBe('a.png')
expect(result[0].nodeId).toBe('42')
expect(result[0].mediaType).toBe('images')
expect(result[1].subfolder).toBe('sub')
})
it('flattens non-standard output keys', () => {
const output = {
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
} as unknown as NodeExecutionOutput
const result = flattenNodeExecutionOutput('10', output)
expect(result).toHaveLength(2)
expect(result.map((r) => r.filename)).toContain('before.png')
expect(result.map((r) => r.filename)).toContain('after.png')
})
it('flattens multiple media types', () => {
const output: NodeExecutionOutput = {
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
video: [{ filename: 'vid.mp4', subfolder: '', type: 'output' }]
}
const result = flattenNodeExecutionOutput('1', output)
expect(result).toHaveLength(2)
expect(result.map((r) => r.mediaType)).toContain('images')
expect(result.map((r) => r.mediaType)).toContain('video')
})
it('excludes animated key', () => {
const output: NodeExecutionOutput = {
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
animated: [true]
}
const result = flattenNodeExecutionOutput('1', output)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('images')
})
it('skips non-array values like text strings', () => {
const output: NodeExecutionOutput = {
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
text: 'hello'
}
const result = flattenNodeExecutionOutput('1', output)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('images')
})
it('filters out non-ResultItem array items', () => {
const output = {
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
custom_data: [{ randomKey: 123 }]
} as unknown as NodeExecutionOutput
const result = flattenNodeExecutionOutput('1', output)
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('images')
})
it('returns empty array for output with no valid media', () => {
const result = flattenNodeExecutionOutput('1', { text: 'hello' })
expect(result).toEqual([])
})
it('returns empty array for empty arrays', () => {
const output: NodeExecutionOutput = { images: [], audio: [] }
const result = flattenNodeExecutionOutput('1', output)
expect(result).toEqual([])
})
it('accepts numeric nodeId', () => {
const output: NodeExecutionOutput = {
audio: [{ filename: 'sound.wav', subfolder: '', type: 'output' }]
}
const result = flattenNodeExecutionOutput(7, output)
expect(result).toHaveLength(1)
expect(result[0].nodeId).toBe(7)
})
})
describe(flattenTaskOutputs, () => {
it('returns empty array for undefined outputs', () => {
expect(flattenTaskOutputs(undefined)).toEqual([])
})
it('flattens outputs from multiple nodes', () => {
const outputs: TaskOutput = {
'node-1': {
images: [{ filename: 'a.png', subfolder: '', type: 'output' }]
},
'node-2': {
video: [{ filename: 'b.mp4', subfolder: '', type: 'output' }]
}
}
const result = flattenTaskOutputs(outputs)
expect(result).toHaveLength(2)
expect(result[0].nodeId).toBe('node-1')
expect(result[1].nodeId).toBe('node-2')
})
it('filters animated and non-ResultItem values across nodes', () => {
const outputs: TaskOutput = {
'node-1': {
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
animated: [true],
text: 'hello'
}
}
const result = flattenTaskOutputs(outputs)
expect(result).toHaveLength(1)
expect(result[0].filename).toBe('img.png')
})
it('supports non-standard output keys across nodes', () => {
const outputs = {
'node-1': {
a_images: [{ filename: 'before.png', subfolder: '', type: 'output' }],
b_images: [{ filename: 'after.png', subfolder: '', type: 'output' }]
}
} as unknown as TaskOutput
const result = flattenTaskOutputs(outputs)
expect(result).toHaveLength(2)
expect(result.map((r) => r.filename)).toContain('before.png')
expect(result.map((r) => r.filename)).toContain('after.png')
})
})

View File

@@ -0,0 +1,74 @@
import type {
NodeExecutionOutput,
ResultItem,
TaskOutput
} from '@/schemas/apiSchema'
import { resultItemType } from '@/schemas/apiSchema'
import { ResultItemImpl } from '@/stores/queueStore'
const EXCLUDED_KEYS = new Set(['animated'])
/**
* Strict domain guard for result items.
*
* The wire-format schema (zOutputs) is intentionally permissive via
* `.passthrough()` to accept arbitrary keys from custom nodes. This guard
* is strict: it requires the fields needed to construct a valid UI model
* (ResultItemImpl) that can build preview URLs.
*/
export function isResultItemLike(item: unknown): item is ResultItem {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return false
}
const candidate = item as Record<string, unknown>
if (typeof candidate.filename !== 'string') {
return false
}
if (typeof candidate.subfolder !== 'string') {
return false
}
if (
candidate.type !== undefined &&
!resultItemType.safeParse(candidate.type).success
) {
return false
}
return true
}
/**
* Flattens a single node's execution output into ResultItemImpl instances.
*
* Iterates all output keys dynamically (to support custom node keys like
* `a_images`, `b_images`, `gifs`, etc.) and validates each item with the
* strict domain guard before constructing ResultItemImpl.
*/
export function flattenNodeExecutionOutput(
nodeId: string | number,
nodeOutput: NodeExecutionOutput
): ResultItemImpl[] {
return Object.entries(nodeOutput)
.filter(([key, value]) => !EXCLUDED_KEYS.has(key) && Array.isArray(value))
.flatMap(([mediaType, items]) =>
(items as unknown[])
.filter(isResultItemLike)
.map((item) => new ResultItemImpl({ ...item, mediaType, nodeId }))
)
}
/**
* Flattens all nodes' outputs from a TaskOutput into ResultItemImpl instances.
*/
export function flattenTaskOutputs(
outputs?: TaskOutput
): ReadonlyArray<ResultItemImpl> {
if (!outputs) return []
return Object.entries(outputs).flatMap(([nodeId, nodeOutput]) =>
flattenNodeExecutionOutput(nodeId, nodeOutput)
)
}