Compare commits

..

3 Commits

Author SHA1 Message Date
Glary-Bot
46cd604961 feat: use Claude glyph for Anthropic partner icon
The backend Anthropic node (Comfy-Org/ComfyUI#13867) renders Claude
specifically, so swap the brand-mark to Claude's sunburst glyph
(sourced from lobehub/lobe-icons) while keeping the icon filename
'anthropic.svg' so the existing category='api node/text/Anthropic'
→ icon-[comfy--anthropic] lookup keeps working. Brand color stays
#D97757 since Claude shares Anthropic's coral.
2026-05-15 10:32:38 +00:00
Glary-Bot
84ea0d3b14 fix: safelist Anthropic icon class and add regression tests
Address code review:
- Add 'anthropic' to the dynamic comfy icon safelist in style.css so
  Tailwind/Iconify emits CSS for icon-[comfy--anthropic] in production
  builds.
- Add test coverage for getProviderIcon('Anthropic') and
  getProviderBorderStyle('Anthropic').
2026-05-13 13:25:14 +00:00
Glary-Bot
b8b8a6056d feat: add Anthropic partner icon
Adds the Anthropic logo to the partner-node icon set so nodes with
category 'api node/text/Anthropic' (e.g. the Claude node from
Comfy-Org/ComfyUI#13867) render the correct provider badge in the
node library.

- packages/design-system/src/icons/anthropic.svg (auto-discovered)
- categoryUtil.ts: register brand coral (#D97757) border color
2026-05-13 13:19:40 +00:00
79 changed files with 398 additions and 2219 deletions

View File

@@ -109,16 +109,14 @@ jobs:
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
mkdir -p coverage/html
WORKFLOW_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}"
echo "<html><body><h1>No E2E coverage data available for this run.</h1><p><a href=\"${WORKFLOW_URL}\">View workflow run</a></p></body></html>" > coverage/html/index.html
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
exit 0
fi
genhtml coverage/playwright/coverage.lcov \
-o coverage/html \
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source
--precision 1
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'
@@ -132,8 +130,7 @@ jobs:
needs: merge
if: >
github.event.workflow_run.head_branch == 'main' &&
needs.merge.outputs.has-coverage == 'true' &&
github.event.workflow_run.event == 'push'
needs.merge.outputs.has-coverage == 'true'
runs-on: ubuntu-latest
permissions:
pages: write

View File

@@ -1,45 +0,0 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [50, 120],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png[output]",
"image"
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -1,84 +0,0 @@
{
"last_node_id": 3,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [50, 120],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["ComfyUI_00001_.png [output]", "image"]
},
{
"id": 2,
"type": "LoadVideo",
"pos": [430, 120],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VIDEO",
"type": "VIDEO",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadVideo"
},
"widgets_values": ["clip.mp4 [output]", "image"]
},
{
"id": 3,
"type": "LoadAudio",
"pos": [810, 120],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "AUDIO",
"type": "AUDIO",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadAudio"
},
"widgets_values": ["sound.wav [output]", null, ""]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

Binary file not shown.

View File

@@ -1,90 +0,0 @@
{
"id": "06e5b524-5a40-40b9-b561-199dfab18cf0",
"revision": 0,
"last_node_id": 12,
"last_link_id": 10,
"nodes": [
{
"id": 10,
"type": "KSampler",
"pos": [230, 110],
"size": [270, 317.5666809082031],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"name": "denoise",
"type": "FLOAT",
"widget": {
"name": "denoise"
},
"link": 10
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 11,
"type": "PrimitiveFloat",
"pos": [-80.55032348632812, 375.2260443115233],
"size": [270, 80.23332977294922],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "FLOAT",
"type": "FLOAT",
"links": [10]
}
],
"properties": {
"Node name for S&R": "PrimitiveFloat"
},
"widgets_values": [0]
}
],
"links": [[10, 11, 0, 10, 4, "FLOAT"]],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.8264462809917354,
"offset": [1335.8909766107738, 692.7345403667316]
},
"frontendVersion": "1.45.4"
},
"version": 0.4
}

View File

@@ -285,12 +285,10 @@ export class ComfyPage {
async setup({
clearStorage = true,
mockReleases = true,
url
mockReleases = true
}: {
clearStorage?: boolean
mockReleases?: boolean
url?: string
} = {}) {
// Mock release endpoint to prevent changelog popups (before navigation)
if (mockReleases) {
@@ -322,7 +320,7 @@ export class ComfyPage {
}, this.id)
}
await this.goto({ url })
await this.goto()
await this.page.waitForFunction(() => document.fonts.ready)
await this.waitForAppReady()
@@ -349,8 +347,8 @@ export class ComfyPage {
return assetPath(fileName)
}
async goto({ url }: { url?: string } = {}) {
await this.page.goto(url ? new URL(url, this.url).toString() : this.url)
async goto() {
await this.page.goto(this.url)
}
async nextFrame() {

View File

@@ -246,18 +246,4 @@ export class VueNodeHelpers {
position: { x: box.width / 2, y: box.height * 0.75 }
})
}
async isSlotConnected(slot: Locator) {
const key = await slot.getByTestId('slot-dot').getAttribute('data-slot-key')
if (!key) return false
return await this.page.evaluate((key) => {
const [nodeId, type, slotId] = key.split('-')
const node = app?.canvas?.graph?.getNodeById(nodeId)
if (!node) return false
return type === 'in'
? node.inputs[Number(slotId)]?.link !== null
: !!node.outputs[Number(slotId)].links?.length
}, key)
}
}

View File

@@ -6,16 +6,12 @@ export class ContextMenu {
public readonly litegraphMenu: Locator
public readonly litegraphContextMenu: Locator
public readonly menuItems: Locator
protected readonly anyMenu: Locator
constructor(public readonly page: Page) {
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
this.litegraphMenu = page.locator('.litemenu')
this.litegraphContextMenu = page.locator('.litecontextmenu')
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
this.anyMenu = this.primeVueMenu
.or(this.litegraphMenu)
.or(this.litegraphContextMenu)
}
async clickMenuItem(name: string): Promise<void> {
@@ -40,7 +36,16 @@ export class ContextMenu {
}
async isVisible(): Promise<boolean> {
return await this.anyMenu.isVisible()
const primeVueVisible = await this.primeVueMenu
.isVisible()
.catch(() => false)
const litegraphVisible = await this.litegraphMenu
.isVisible()
.catch(() => false)
const litegraphContextVisible = await this.litegraphContextMenu
.isVisible()
.catch(() => false)
return primeVueVisible || litegraphVisible || litegraphContextVisible
}
async assertHasItems(items: string[]): Promise<void> {
@@ -53,7 +58,7 @@ export class ContextMenu {
async openFor(locator: Locator): Promise<this> {
await locator.click({ button: 'right' })
await expect(this.anyMenu).toBeVisible()
await expect.poll(() => this.isVisible()).toBe(true)
return this
}

View File

@@ -95,7 +95,6 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
public readonly allTab: Locator
public readonly blueprintsTab: Locator
public readonly sortButton: Locator
public readonly nodePreview: Locator
constructor(public override readonly page: Page) {
super(page, 'node-library')
@@ -104,7 +103,6 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
this.allTab = this.getTab('All')
this.blueprintsTab = this.getTab('Blueprints')
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
}
getTab(name: string) {

View File

@@ -1,81 +0,0 @@
import type { Locator } from '@playwright/test'
import { comfyExpect as expect } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { dragByIndex } from '@e2e/fixtures/utils/dragAndDrop'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
export class SubgraphEditor {
public readonly root: Locator
public readonly promotionItems: Locator
constructor(protected readonly comfyPage: ComfyPage) {
this.root = this.comfyPage.menu.propertiesPanel.root
this.promotionItems = this.root.getByTestId(
TestIds.subgraphEditor.widgetItem
)
}
async open(subgraphNode: Locator) {
await new VueNodeFixture(subgraphNode).select()
const menu = await this.comfyPage.contextMenu.openFor(subgraphNode)
await menu.clickMenuItemExact('Edit Subgraph Widgets')
await expect(this.root, 'Open Properties Panel').toBeVisible()
}
resolveItem(options: {
nodeName?: string
nodeId?: string
widgetName: string
}): Locator {
const nodeItems =
options.nodeId !== undefined
? this.comfyPage.page.locator(`[data-nodeid="${options.nodeId}"]`)
: options.nodeName !== undefined
? this.promotionItems.filter({
has: this.comfyPage.page
.getByTestId(TestIds.subgraphEditor.nodeName)
.filter({ hasText: options.nodeName })
})
: this.promotionItems
return nodeItems.filter({
has: this.comfyPage.page
.getByTestId(TestIds.subgraphEditor.widgetLabel)
.filter({ hasText: options.widgetName })
})
}
getToggleButton(item: Locator) {
return item.getByTestId(TestIds.subgraphEditor.widgetToggle)
}
async togglePromotionOnItem(item: Locator, toState?: boolean) {
const toggleIcon = item.getByTestId(TestIds.subgraphEditor.iconEye)
if (toState !== undefined) {
const expectedIcon = `icon-[lucide--eye${toState ? '-off' : ''}]`
await expect(toggleIcon).toContainClass(expectedIcon)
}
await toggleIcon.click()
}
async togglePromotion(
subgraphNode: Locator,
options: {
nodeName?: string
nodeId?: string
widgetName: string
toState?: boolean
}
) {
await this.open(subgraphNode)
const item = this.resolveItem(options)
await this.togglePromotionOnItem(item, options.toState)
}
async dragItem(fromIndex: number, toIndex: number) {
await dragByIndex(this.promotionItems, fromIndex, toIndex)
await this.comfyPage.nextFrame()
}
}

View File

@@ -2,7 +2,34 @@ import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { dragByIndex } from '@e2e/fixtures/utils/dragAndDrop'
/**
* Drag an element from one index to another within a list of locators.
* Uses mousedown/mousemove/mouseup to trigger the DraggableList library.
*
* DraggableList toggles position when the dragged item's center crosses
* past an idle item's center. To reliably land at the target position,
* we overshoot slightly past the target's far edge.
*/
async function dragByIndex(items: Locator, fromIndex: number, toIndex: number) {
const fromBox = await items.nth(fromIndex).boundingBox()
const toBox = await items.nth(toIndex).boundingBox()
if (!fromBox || !toBox) throw new Error('Item not visible for drag')
const draggingDown = toIndex > fromIndex
const targetY = draggingDown
? toBox.y + toBox.height * 0.9
: toBox.y + toBox.height * 0.1
const page = items.page()
await page.mouse.move(
fromBox.x + fromBox.width / 2,
fromBox.y + fromBox.height / 2
)
await page.mouse.down()
await page.mouse.move(toBox.x + toBox.width / 2, targetY, { steps: 10 })
await page.mouse.up()
}
export class BuilderSelectHelper {
/** All IoItem locators in the current step sidebar. */

View File

@@ -9,17 +9,12 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyW
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
import { TestIds } from '@e2e/fixtures/selectors'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
export class SubgraphHelper {
public readonly editor: SubgraphEditor
constructor(private readonly comfyPage: ComfyPage) {
this.editor = new SubgraphEditor(comfyPage)
}
constructor(private readonly comfyPage: ComfyPage) {}
private get page(): Page {
return this.comfyPage.page
@@ -332,23 +327,6 @@ export class SubgraphHelper {
await this.comfyPage.nextFrame()
}
async promoteWidget(nodeLocator: Locator, widgetName: string): Promise<void> {
const widget = nodeLocator.getByLabel(widgetName, { exact: true })
await this.comfyPage.contextMenu
.openFor(widget)
.then((m) => m.clickMenuItemExact(`Promote Widget: ${widgetName}`))
}
async unpromoteWidget(
nodeLocator: Locator,
widgetName: string
): Promise<void> {
const widget = nodeLocator.getByLabel(widgetName, { exact: true })
await this.comfyPage.contextMenu
.openFor(widget)
.then((m) => m.clickMenuItemExact(`Un-Promote Widget: ${widgetName}`))
}
async isInSubgraph(): Promise<boolean> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph

View File

@@ -8,7 +8,6 @@ export const TestIds = {
toolbar: 'side-toolbar',
nodeLibrary: 'node-library-tree',
nodeLibrarySearch: 'node-library-search',
nodePreviewCard: 'node-preview-card',
workflows: 'workflows-sidebar',
modeToggle: 'mode-toggle'
},
@@ -104,16 +103,14 @@ export const TestIds = {
errorsTab: 'panel-tab-errors'
},
subgraphEditor: {
hiddenSection: 'subgraph-editor-hidden-section',
iconEye: 'icon-eye',
iconLink: 'icon-link',
nodeName: 'subgraph-widget-node-name',
shownSection: 'subgraph-editor-shown-section',
toggle: 'subgraph-editor-toggle',
widgetActionsMenuButton: 'widget-actions-menu-button',
widgetItem: 'subgraph-widget-item',
shownSection: 'subgraph-editor-shown-section',
hiddenSection: 'subgraph-editor-hidden-section',
widgetToggle: 'subgraph-widget-toggle',
widgetLabel: 'subgraph-widget-label',
widgetToggle: 'subgraph-widget-toggle'
iconLink: 'icon-link',
iconEye: 'icon-eye',
widgetActionsMenuButton: 'widget-actions-menu-button'
},
node: {
titleInput: 'node-title-input',

View File

@@ -1,33 +0,0 @@
import type { Locator } from '@playwright/test'
/**
* Drag an element from one index to another within a list of locators.
* Uses mousedown/mousemove/mouseup to trigger the DraggableList library.
*
* DraggableList toggles position when the dragged item's center crosses
* past an idle item's center. To reliably land at the target position,
* we overshoot slightly past the target's far edge.
*/
export async function dragByIndex(
items: Locator,
fromIndex: number,
toIndex: number
) {
const fromBox = await items.nth(fromIndex).boundingBox()
const toBox = await items.nth(toIndex).boundingBox()
if (!fromBox || !toBox) throw new Error('Item not visible for drag')
const draggingDown = toIndex > fromIndex
const targetY = draggingDown
? toBox.y + toBox.height * 0.9
: toBox.y + toBox.height * 0.1
const page = items.page()
await page.mouse.move(
fromBox.x + fromBox.width / 2,
fromBox.y + fromBox.height / 2
)
await page.mouse.down()
await page.mouse.move(toBox.x + toBox.width / 2, targetY, { steps: 10 })
await page.mouse.up()
}

View File

@@ -15,7 +15,6 @@ export class VueNodeFixture {
public readonly root: Locator
public readonly widgets: Locator
public readonly imagePreview: Locator
public readonly content: Locator
constructor(private readonly locator: Locator) {
this.header = locator.locator('[data-testid^="node-header-"]')
@@ -28,7 +27,6 @@ export class VueNodeFixture {
this.root = locator
this.widgets = this.locator.locator('.lg-node-widget')
this.imagePreview = locator.locator('.image-preview')
this.content = locator.locator('.lg-node-content')
}
async getTitle(): Promise<string> {
@@ -41,10 +39,6 @@ export class VueNodeFixture {
await this.titleEditor.setTitle(value)
}
async select() {
await this.header.click()
}
async toggleCollapse(): Promise<void> {
await this.collapseButton.click()
}
@@ -66,15 +60,4 @@ export class VueNodeFixture {
boundingBox(): ReturnType<Locator['boundingBox']> {
return this.locator.boundingBox()
}
getSlot(nameOrLocator: string | Locator) {
const slotLocators = this.root
.getByTestId('node-widget')
.or(this.root.locator('.lg-slot'))
const filteredLocator =
typeof nameOrLocator === 'string'
? slotLocators.filter({ hasText: nameOrLocator })
: slotLocators.filter({ has: nameOrLocator })
return filteredLocator.getByTestId('slot-dot').locator('..')
}
}

View File

@@ -59,10 +59,9 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
await test.step('Capture HUD region with setting on', async () => {
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', true)
await comfyPage.canvasOps.moveMouseToEmptyArea()
// FPS value varies per run; allow ~1% pixel variance in the 180×160 clip
await expect(comfyPage.page).toHaveScreenshot(
'canvas-info-hud-on.png',
{ clip: hudClip, maxDiffPixels: 350 }
{ clip: hudClip, maxDiffPixels: 50 }
)
})
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -25,21 +25,6 @@ const FIXTURES: readonly MetadataFixture[] = [
{ fileName: 'with_metadata.webm', parser: 'ebml (webm)' }
] as const
// NaN-variant fixtures embed only an API-format prompt containing bare
// `NaN`/`Infinity` tokens (Python's `json.dumps` default). The loader must
// tolerate Python generated JSON for these to import successfully.
const NAN_FIXTURES: readonly MetadataFixture[] = [
{ fileName: 'with_nan_metadata.json', parser: 'json' },
{ fileName: 'with_nan_metadata.png', parser: 'png' },
{ fileName: 'with_nan_metadata.avif', parser: 'avif' },
{ fileName: 'with_nan_metadata.webp', parser: 'webp' },
{ fileName: 'with_nan_metadata.flac', parser: 'flac' },
{ fileName: 'with_nan_metadata.mp3', parser: 'mp3' },
{ fileName: 'with_nan_metadata.opus', parser: 'ogg' },
{ fileName: 'with_nan_metadata.mp4', parser: 'isobmff' },
{ fileName: 'with_nan_metadata.webm', parser: 'ebml (webm)' }
] as const
test.describe(
'Metadata drop-to-load workflow import',
{ tag: ['@workflow'] },
@@ -73,42 +58,5 @@ test.describe(
})
})
}
for (const { fileName, parser } of NAN_FIXTURES) {
test(`loads Python JSON prompt with NaN/Infinity from ${fileName} (${parser})`, async ({
comfyPage
}) => {
await test.step(`drop ${fileName} on canvas`, async () => {
await comfyPage.dragDrop.dragAndDropFilePath(
metadataFixturePath(fileName)
)
})
await test.step('graph contains only the embedded KSampler', async () => {
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(1)
const ksamplers =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
expect(
ksamplers,
'exactly one KSampler should have been loaded from the NaN-laden prompt'
).toHaveLength(1)
})
await test.step('NaN-coerced widget values are 0', async () => {
const [ksampler] =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
for (const widgetName of ['cfg', 'denoise']) {
const widget = await ksampler.getWidgetByName(widgetName)
expect(
await widget.getValue(),
`${widgetName} should be 0 after NaN coercion to null`
).toBe(0)
}
})
})
}
}
)

View File

@@ -549,7 +549,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
expect(uploadCount, 'should upload exactly once').toBe(1)
})
test('Empty canvas uploads a transparent placeholder on serialization', async ({
test('Empty canvas does not upload on serialization', async ({
comfyPage
}) => {
let uploadCount = 0
@@ -566,10 +566,7 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await triggerSerialization(comfyPage.page)
expect(
uploadCount,
'empty canvas should upload a transparent PNG so the backend receives a valid asset reference (Painter.execute treats painter_alpha=0 as no-mask)'
).toBe(1)
expect(uploadCount, 'empty canvas should not upload').toBe(0)
})
test('Upload failure shows error toast', async ({ comfyPage }) => {

View File

@@ -1,357 +0,0 @@
import { expect, mergeTests } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import {
assetRequestIncludesTag,
createCloudAssetsFixture
} from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
import { TestIds } from '@e2e/fixtures/selectors'
import {
createMockJob,
createMockJobRecords
} from '@e2e/fixtures/utils/jobFixtures'
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
const ossTest = mergeTests(comfyPageFixture, jobsApiMockFixture)
const outputHash =
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
const plainVideoFileName = 'plain_video.mp4'
const graphDropPosition = { x: 500, y: 300 }
const missingMediaUploadObservationMs = 1_000
const missingMediaUploadPollMs = 100
const cloudOutputAsset: Asset = {
id: 'test-output-hash-001',
name: 'ComfyUI_00001_.png',
asset_hash: outputHash,
size: 4_194_304,
mime_type: 'image/png',
tags: ['output'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const cloudUploadedVideoAsset: Asset = {
id: 'test-uploaded-video-001',
name: plainVideoFileName,
asset_hash: plainVideoFileName,
size: 1_024,
mime_type: 'video/mp4',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
// The Cloud test app starts with a default LoadImage node. Keep that baseline
// input resolvable so this spec only observes the media it creates.
const cloudDefaultGraphInputAsset: Asset = {
id: 'test-default-input-001',
name: '00000000000000000000000Aexample.png',
asset_hash: '00000000000000000000000Aexample.png',
size: 1_024,
mime_type: 'image/png',
tags: ['input'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
interface CloudUploadAssetState {
isUploadedAssetAvailable: boolean
}
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset])
const cloudUploadAssetStateByPage = new WeakMap<Page, CloudUploadAssetState>()
const cloudUploadRaceTest = comfyPageFixture.extend<{
markUploadedCloudAssetAvailable: () => void
}>({
page: async ({ page }, use) => {
const state: CloudUploadAssetState = {
isUploadedAssetAvailable: false
}
cloudUploadAssetStateByPage.set(page, state)
const assetsRouteHandler = async (route: Route) => {
const allAssets = [
cloudDefaultGraphInputAsset,
...(state.isUploadedAssetAvailable ? [cloudUploadedVideoAsset] : [])
]
const includeTags =
new URL(route.request().url()).searchParams
.get('include_tags')
?.split(',')
.filter(Boolean) ?? []
const assets = includeTags.length
? allAssets.filter((asset) =>
asset.tags?.some((tag) => includeTags.includes(tag))
)
: allAssets
const response: ListAssetsResponse = {
assets,
total: assets.length,
has_more: false
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response)
})
}
await page.route(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
await use(page)
await page.unroute(/\/api\/assets(?:\?.*)?$/, assetsRouteHandler)
cloudUploadAssetStateByPage.delete(page)
},
markUploadedCloudAssetAvailable: async ({ page }, use) => {
await use(() => {
const state = cloudUploadAssetStateByPage.get(page)
if (state) state.isUploadedAssetAvailable = true
})
}
})
async function enableErrorsTab(comfyPage: ComfyPage) {
await comfyPage.settings.setSetting(
'Comfy.RightSidePanel.ShowErrorsTab',
true
)
}
function getErrorOverlay(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
}
async function expectNoErrorsTab(comfyPage: ComfyPage) {
await expect(getErrorOverlay(comfyPage)).toBeHidden()
const panel = new PropertiesPanelHelper(comfyPage.page)
await panel.open(comfyPage.actionbar.propertiesButton)
await expect(
panel.root.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeHidden()
}
async function delayNextUpload(comfyPage: ComfyPage) {
let releaseUpload!: () => void
let resolveUploadStarted!: () => void
const uploadStarted = new Promise<void>((resolve) => {
resolveUploadStarted = resolve
})
const release = new Promise<void>((resolve) => {
releaseUpload = resolve
})
const uploadRouteHandler = async (route: Route) => {
resolveUploadStarted()
await release
await route.continue()
}
await comfyPage.page.route('**/upload/image', uploadRouteHandler)
return {
waitForUploadStarted: () => uploadStarted,
finishUpload: async () => {
const uploadResponse = comfyPage.page.waitForResponse(
(response) =>
response.url().includes('/upload/image') && response.status() === 200,
{ timeout: 10_000 }
)
releaseUpload()
try {
await uploadResponse
} finally {
await comfyPage.page.unroute('**/upload/image', uploadRouteHandler)
}
}
}
}
async function expectLoadVideoUploading(comfyPage: ComfyPage) {
await expect
.poll(
() =>
comfyPage.page.evaluate(() =>
window.app!.graph.nodes.some(
(node) => node.type === 'LoadVideo' && node.isUploading
)
),
{ timeout: 5_000 }
)
.toBe(true)
}
async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
await comfyPage.nextFrame()
await comfyPage.nextFrame()
let sawErrorOverlay = false
const startedAt = Date.now()
await expect
.poll(
async () => {
sawErrorOverlay =
sawErrorOverlay || (await getErrorOverlay(comfyPage).isVisible())
return (
!sawErrorOverlay &&
Date.now() - startedAt >= missingMediaUploadObservationMs
)
},
{
timeout: missingMediaUploadObservationMs + missingMediaUploadPollMs * 5,
intervals: [missingMediaUploadPollMs]
}
)
.toBe(true)
}
function outputHistoryJobs() {
return createMockJobRecords([
createMockJob({
id: 'history-output-image',
preview_output: {
filename: 'ComfyUI_00001_.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
}),
createMockJob({
id: 'history-output-video',
preview_output: {
filename: 'clip.mp4',
subfolder: '',
type: 'output',
nodeId: '2',
mediaType: 'video'
}
}),
createMockJob({
id: 'history-output-audio',
preview_output: {
filename: 'sound.wav',
subfolder: '',
type: 'output',
nodeId: '3',
mediaType: 'audio'
}
})
])
}
ossTest.describe(
'Errors tab - OSS missing media runtime sources',
{ tag: '@ui' },
() => {
ossTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
})
ossTest(
'resolves annotated output media from job history',
async ({ comfyPage, jobsApi }) => {
await jobsApi.mockJobs(outputHistoryJobs())
await comfyPage.workflow.loadWorkflow(
'missing/missing_media_output_annotations'
)
await expectNoErrorsTab(comfyPage)
}
)
ossTest(
'does not surface missing media while dropped video upload is in progress',
async ({ comfyFiles, comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
const delayedUpload = await delayNextUpload(comfyPage)
await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, {
dropPosition: graphDropPosition
})
await delayedUpload.waitForUploadStarted()
comfyFiles.deleteAfterTest({
filename: plainVideoFileName,
type: 'input'
})
await expectLoadVideoUploading(comfyPage)
await expectNoMissingMediaDuringUpload(comfyPage)
await delayedUpload.finishUpload()
await expect(getErrorOverlay(comfyPage)).toBeHidden()
}
)
}
)
cloudOutputTest.describe(
'Errors tab - Cloud missing media runtime sources',
{ tag: '@cloud' },
() => {
cloudOutputTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
})
cloudOutputTest(
'resolves compact annotated output media from output assets',
async ({ cloudAssetRequests, comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_media_cloud_output_annotation'
)
await expect
.poll(() =>
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'output')
)
)
.toBe(true)
await expectNoErrorsTab(comfyPage)
}
)
}
)
cloudUploadRaceTest.describe(
'Errors tab - Cloud missing media upload race',
{ tag: '@cloud' },
() => {
cloudUploadRaceTest.beforeEach(async ({ comfyPage }) => {
await enableErrorsTab(comfyPage)
})
cloudUploadRaceTest(
'does not surface missing media while dropped video upload is in progress',
async ({ comfyFiles, comfyPage, markUploadedCloudAssetAvailable }) => {
await comfyPage.nodeOps.clearGraph()
const delayedUpload = await delayNextUpload(comfyPage)
await comfyPage.dragDrop.dragAndDropFile(plainVideoFileName, {
dropPosition: graphDropPosition
})
await delayedUpload.waitForUploadStarted()
comfyFiles.deleteAfterTest({
filename: plainVideoFileName,
type: 'input'
})
await expectLoadVideoUploading(comfyPage)
await expectNoMissingMediaDuringUpload(comfyPage)
markUploadedCloudAssetAvailable()
await delayedUpload.finishUpload()
await expect(getErrorOverlay(comfyPage)).toBeHidden()
}
)
}
)

View File

@@ -120,13 +120,4 @@ test.describe('Node library sidebar V2', () => {
await expect(options.first()).toBeVisible()
await expect.poll(() => options.count()).toBeGreaterThanOrEqual(2)
})
test('Blueprint previews include description', async ({ comfyPage }) => {
const tab = comfyPage.menu.nodeLibraryTabV2
await tab.blueprintsTab.click()
await tab.getNode('test blueprint').hover()
await expect(tab.nodePreview, 'Preview displays on hover').toBeVisible()
await expect(tab.nodePreview).toContainText('Inverts the image')
})
})

View File

@@ -607,218 +607,3 @@ test.describe(
)
}
)
test('Promote/Demote by Context Menu @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(steps).toBeVisible()
})
await test.step('Un-promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(subgraphNode).toBeVisible()
await expect(steps).toBeHidden()
})
})
test('Properties panel operations @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'cfg',
toState: true
})
await expect(cfg, 'Promote widget').toBeVisible()
await test.step('widgets display in order promoted', async () => {
await expect(editor.promotionItems.first()).toContainText('steps')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'steps'
)
})
await test.step('Reorder widgets', async () => {
await editor.dragItem(0, 1)
await expect(editor.promotionItems.first()).toContainText('cfg')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'cfg'
)
})
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: false
})
await expect(steps, 'Un-promote widget').toBeHidden()
})
test('Can intermix linked and proxy @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await comfyPage.subgraph.promoteWidget(ksampler.root, 'cfg')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await expect(
subgraphNode.locator('.lg-node-widget').first(),
'linked widgets are first by default'
).toHaveText('steps')
await editor.open(subgraphNode)
await editor.dragItem(0, 1)
await expect(
editor.promotionItems.first(),
'Swap widget order'
).toContainText('cfg')
// FIXME: solve actual bug and remove the not
await expect(
subgraphNode.locator('.lg-node-widget').first(),
'Linked widget is first on node'
).not.toHaveText('cfg')
})
test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await expect(steps).toHaveCount(1)
})
test('Can promote multiple previews @vue-nodes', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await test.step('Add and rename a Load Image node', async () => {
await comfyPage.page.mouse.dblclick(300, 300, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadImage.setTitle('Character Reference')
})
await test.step('Add a second Load Image node', async () => {
await comfyPage.page.mouse.dblclick(600, 300, { delay: 5 })
await comfyPage.searchBox.fillAndSelectFirstNode('Load Image')
})
await test.step('Convert both nodes to subgraph', async () => {
await comfyPage.canvas.focus()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.contextMenu
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
})
const { editor } = comfyPage.subgraph
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
await test.step('Promote both image previews', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: true
})
await expect(subgraph.content).toHaveCount(1)
await editor.togglePromotion(subgraph.root, {
nodeId: '2',
widgetName: '$$canvas-image-preview',
toState: true
})
await expect(subgraph.content).toHaveCount(2)
})
// FUTURE: Add test for re-ordering previews?
await test.step('Demote image', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: false
})
await expect(subgraph.content).toHaveCount(1)
})
})
test('Linked widgets can not be demoted @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await editor.open(subgraphNode)
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
})

View File

@@ -106,49 +106,6 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(comfyPage.templates.content).toBeVisible()
})
test('dialog should not be shown when first-time user opens a shared workflow link', async ({
comfyPage
}) => {
await comfyPage.page.route(
'**/workflows/published/test-share-id',
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
share_id: 'test-share-id',
workflow_id: 'wf-1',
name: 'Shared Workflow',
listed: true,
publish_time: new Date().toISOString(),
workflow_json: {
version: 0.4,
nodes: [],
links: [],
groups: [],
config: {},
extra: {}
},
assets: []
})
})
}
)
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
await comfyPage.setup({
clearStorage: true,
url: '/?share=test-share-id'
})
await expect(
comfyPage.page.getByRole('heading', { name: 'Open shared workflow' })
).toBeVisible()
await expect(comfyPage.templates.content).toBeHidden()
})
test('Uses proper locale files for templates', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Locale', 'fr')

View File

@@ -1133,108 +1133,3 @@ test.describe(
})
}
)
test.describe('Vue Node Widget Link Position', { tag: '@vue-nodes' }, () => {
test('should keep widget-input link aligned after persisted-workflow reload', async ({
comfyPage
}) => {
test.setTimeout(30000)
await comfyPage.workflow.loadWorkflow(
'vueNodes/ksampler-denoise-widget-link'
)
await comfyPage.vueNodes.waitForNodes(2)
await comfyPage.workflow.waitForDraftPersisted()
await comfyPage.workflow.reloadAndWaitForApp()
await comfyPage.vueNodes.waitForNodes(2)
const ksampler = await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find((n) => n.type === 'KSampler')
if (!node) return null
const findIndex = (name: string) =>
node.inputs.findIndex(
(input) => input.name === name || input.widget?.name === name
)
return {
id: node.id,
denoiseIndex: findIndex('denoise'),
schedulerIndex: findIndex('scheduler')
}
})
if (!ksampler) {
throw new Error('KSampler should be present in fixture')
}
expect(
ksampler.denoiseIndex,
'denoise input slot not found'
).toBeGreaterThanOrEqual(0)
expect(
ksampler.schedulerIndex,
'scheduler input slot not found'
).toBeGreaterThanOrEqual(0)
const denoiseSlot = slotLocator(
comfyPage.page,
ksampler.id,
ksampler.denoiseIndex,
true
)
const schedulerSlot = slotLocator(
comfyPage.page,
ksampler.id,
ksampler.schedulerIndex,
true
)
await expectVisibleAll(denoiseSlot, schedulerSlot)
await expect
.poll(() =>
getInputLinkDetails(comfyPage.page, ksampler.id, ksampler.denoiseIndex)
)
.toMatchObject({
targetId: ksampler.id,
targetSlot: ksampler.denoiseIndex
})
// If the regression returns, getInputPos stays stale relative to the
// grown slot DOM and the endpoint drifts toward scheduler. Re-read
// positions each retry so layout settle doesn't cause flakes.
await expect(async () => {
const linkEnd = await comfyPage.page.evaluate(
([nodeId, targetSlotIndex]) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) return null
const slotPos = node.getInputPos(targetSlotIndex)
const [cx, cy] = window.app!.canvas.ds.convertOffsetToCanvas([
slotPos[0],
slotPos[1]
])
const rect = window.app!.canvas.canvas.getBoundingClientRect()
return { x: cx + rect.left, y: cy + rect.top }
},
[ksampler.id, ksampler.denoiseIndex] as const
)
expect(linkEnd, 'link endpoint should resolve').not.toBeNull()
const denoiseCenter = await getCenter(denoiseSlot)
const schedulerCenter = await getCenter(schedulerSlot)
const distToDenoise = Math.hypot(
linkEnd!.x - denoiseCenter.x,
linkEnd!.y - denoiseCenter.y
)
const rowGap = Math.hypot(
denoiseCenter.x - schedulerCenter.x,
denoiseCenter.y - schedulerCenter.y
)
// Bound at rowGap / 4 - half the inter-slot midpoint, so any drift
// toward scheduler fails well before reaching it.
expect(
distToDenoise,
`Link endpoint (${linkEnd!.x.toFixed(1)}, ${linkEnd!.y.toFixed(1)}) is ` +
`${distToDenoise.toFixed(1)}px from denoise — should be within ` +
`${(rowGap / 4).toFixed(1)}px (quarter of inter-slot gap ${rowGap.toFixed(1)}px)`
).toBeLessThan(rowGap / 4)
}).toPass({ timeout: 5000 })
})
})

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"/></svg>

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -36,19 +36,8 @@ WORKFLOW = {
}
PROMPT = {'1': {'class_type': 'KSampler', 'inputs': {}}}
# API-format prompt with bare NaN/Infinity tokens (as Python's json.dumps emits
# by default). The NaN variant fixtures omit the workflow field so the loader
# must route through prompt-parsing, which trips JSON.parse on bare NaN.
PROMPT_NAN = {
'1': {
'class_type': 'KSampler',
'inputs': {'cfg': float('nan'), 'denoise': float('inf')},
}
}
WORKFLOW_JSON = json.dumps(WORKFLOW, separators=(',', ':'))
PROMPT_JSON = json.dumps(PROMPT, separators=(',', ':'))
PROMPT_NAN_JSON = json.dumps(PROMPT_NAN, separators=(',', ':'))
def out(name: str) -> str:
@@ -64,21 +53,15 @@ def make_1x1_image() -> Image.Image:
return Image.new('RGB', (1, 1), (255, 0, 0))
def build_exif_bytes(
workflow_str: str | None = WORKFLOW_JSON,
prompt_str: str | None = PROMPT_JSON,
) -> bytes:
def build_exif_bytes() -> bytes:
"""Build EXIF bytes matching the backend's tag assignments.
Backend: 0x010F (Make) = "workflow:<json>", 0x0110 (Model) = "prompt:<json>"
Pass ``None`` to omit a tag.
"""
img = make_1x1_image()
exif = img.getexif()
if workflow_str is not None:
exif[0x010F] = f'workflow:{workflow_str}'
if prompt_str is not None:
exif[0x0110] = f'prompt:{prompt_str}'
exif[0x010F] = f'workflow:{WORKFLOW_JSON}'
exif[0x0110] = f'prompt:{PROMPT_JSON}'
return exif.tobytes()
@@ -110,9 +93,6 @@ def generate_av_fixture(
codec: str,
rate: int = 44100,
options: dict | None = None,
*,
prompt_json: str | None = PROMPT_JSON,
workflow_json: str | None = WORKFLOW_JSON,
):
"""Generate an audio fixture via PyAV container.metadata[], matching the backend."""
path = out(name)
@@ -120,10 +100,8 @@ def generate_av_fixture(
stream = container.add_stream(codec, rate=rate)
stream.layout = 'mono'
if prompt_json is not None:
container.metadata['prompt'] = prompt_json
if workflow_json is not None:
container.metadata['workflow'] = workflow_json
container.metadata['prompt'] = PROMPT_JSON
container.metadata['workflow'] = WORKFLOW_JSON
sample_fmt = stream.codec_context.codec.audio_formats[0].name
samples = stream.codec_context.frame_size or 1024
@@ -197,63 +175,6 @@ def generate_webm():
generate_av_fixture('with_metadata.webm', 'webm', 'libvorbis')
def generate_nan_variants():
"""Per-format fixtures carrying ONLY a NaN/Infinity-laden API prompt.
These force the loader through the prompt-parsing path, where Python's
bare NaN/Infinity tokens trip JSON.parse.
"""
img = make_1x1_image()
info = PngInfo()
info.add_text('prompt', PROMPT_NAN_JSON)
img.save(out('with_nan_metadata.png'), 'PNG', pnginfo=info)
report('with_nan_metadata.png')
exif_nan = build_exif_bytes(workflow_str=None, prompt_str=PROMPT_NAN_JSON)
img = make_1x1_image()
img.save(out('with_nan_metadata.webp'), 'WEBP', exif=exif_nan)
report('with_nan_metadata.webp')
img = make_1x1_image()
img.save(out('with_nan_metadata.avif'), 'AVIF', exif=exif_nan)
report('with_nan_metadata.avif')
generate_av_fixture(
'with_nan_metadata.flac', 'flac', 'flac',
prompt_json=PROMPT_NAN_JSON, workflow_json=None,
)
generate_av_fixture(
'with_nan_metadata.opus', 'opus', 'libopus', rate=48000,
prompt_json=PROMPT_NAN_JSON, workflow_json=None,
)
generate_av_fixture(
'with_nan_metadata.mp3', 'mp3', 'libmp3lame',
prompt_json=PROMPT_NAN_JSON, workflow_json=None,
)
generate_av_fixture(
'with_nan_metadata.webm', 'webm', 'libvorbis',
prompt_json=PROMPT_NAN_JSON, workflow_json=None,
)
path = out('with_nan_metadata.mp4')
subprocess.run([
'ffmpeg', '-y', '-loglevel', 'error',
'-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono',
'-t', '0.01', '-c:a', 'aac', '-b:a', '32k',
'-movflags', 'use_metadata_tags',
'-metadata', f'prompt={PROMPT_NAN_JSON}',
path,
], check=True)
report('with_nan_metadata.mp4')
# Direct JSON file containing API-format prompt with bare NaN/Infinity.
json_path = out('with_nan_metadata.json')
with open(json_path, 'w', encoding='utf-8') as f:
f.write(PROMPT_NAN_JSON)
report('with_nan_metadata.json')
if __name__ == '__main__':
print('Generating fixtures...')
generate_png()
@@ -264,5 +185,4 @@ if __name__ == '__main__':
generate_mp3()
generate_mp4()
generate_webm()
generate_nan_variants()
print('Done.')

View File

@@ -141,21 +141,6 @@ describe('PointerZone', () => {
y: 45
})
})
it('should preventDefault on wheel to block browser zoom on ctrl+wheel', () => {
renderZone()
const zone = getZone()
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY: -1,
ctrlKey: true
})
zone.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
})
})
describe('isPanning watcher', () => {

View File

@@ -11,7 +11,7 @@
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@wheel.prevent="handleWheel"
@wheel="handleWheel"
@contextmenu.prevent
/>
</template>

View File

@@ -2,7 +2,6 @@
<div
class="flex flex-col overflow-hidden rounded-lg border border-border-default bg-base-background"
:style="{ width: `${BASE_WIDTH_PX * (scaleFactor / BASE_SCALE)}px` }"
data-testid="node-preview-card"
>
<div ref="previewContainerRef" class="overflow-hidden p-3">
<div

View File

@@ -263,7 +263,6 @@ onMounted(() => {
<SubgraphNodeWidget
v-for="[node, widget] in filteredActive"
:key="toKey([node, widget])"
:data-nodeid="node.id"
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
:node-title="node.title"
:widget-name="widget.label || widget.name"
@@ -296,7 +295,6 @@ onMounted(() => {
<SubgraphNodeWidget
v-for="[node, widget] in filteredCandidates"
:key="toKey([node, widget])"
:data-nodeid="node.id"
class="bg-comfy-menu-bg"
:node-title="node.title"
:widget-name="widget.name"

View File

@@ -41,13 +41,9 @@ const icon = computed(() =>
className
)
"
data-testid="subgraph-widget-item"
>
<div class="pointer-events-none flex-1">
<div
class="line-clamp-1 text-xs text-text-secondary"
data-testid="subgraph-widget-node-name"
>
<div class="line-clamp-1 text-xs text-text-secondary">
{{ nodeTitle }}
</div>
<div class="line-clamp-1 text-sm/8" data-testid="subgraph-widget-label">

View File

@@ -349,75 +349,25 @@ describe('usePainter', () => {
})
describe('serializeValue', () => {
it('returns existing modelValue when not dirty (preserves workflow-restored mask reference across WidgetPainter remount)', async () => {
it('returns empty string when canvas has no strokes', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
mountPainter('test-node', 'painter/existing.png [temp]')
mountPainter()
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('painter/existing.png [temp]')
expect(result).toBe('')
})
it('uploads the current canvas when no cached modelValue is present, even if nothing has been painted yet', async () => {
it('returns empty string when canvas has no strokes even if modelValue is set', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const fetchApiMock = vi.mocked(api.fetchApi)
fetchApiMock.mockResolvedValueOnce({
status: 200,
json: async () => ({ name: 'uploaded.png' })
} as Response)
const fakeCanvas = {
width: 4,
height: 4,
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
} as unknown as HTMLCanvasElement
const { canvasEl } = mountPainter('test-node', '')
canvasEl.value = fakeCanvas
await nextTick()
const { modelValue } = mountPainter()
modelValue.value = 'painter/existing.png [temp]'
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(fetchApiMock).toHaveBeenCalledWith(
'/upload/image',
expect.objectContaining({ method: 'POST' })
)
expect(result).toBe('painter/uploaded.png [temp]')
})
it('returns existing modelValue when canvas element is unmounted at serialize time', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
mountPainter('test-node', 'painter/cached.png [temp]')
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('painter/cached.png [temp]')
})
it('clears the cached upload reference when the user clears the canvas', () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const fakeCanvas = {
width: 4,
height: 4,
getContext: vi.fn(() => ({
clearRect: vi.fn()
}))
} as unknown as HTMLCanvasElement
const { painter, canvasEl, modelValue } = mountPainter(
'test-node',
'painter/old-upload.png [temp]'
)
canvasEl.value = fakeCanvas
painter.handleClear()
expect(modelValue.value).toBe('')
expect(result).toBe('')
})
})

View File

@@ -61,6 +61,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
let baseCanvas: HTMLCanvasElement | null = null
let baseCtx: CanvasRenderingContext2D | null = null
let hasBaseSnapshot = false
let hasStrokes = false
let dirtyX0 = 0
let dirtyY0 = 0
@@ -412,6 +413,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
isDrawing = true
isDirty.value = true
hasStrokes = true
snapshotBrush()
strokeProcessor = new StrokeProcessor(Math.max(1, strokeBrush!.radius / 2))
strokeProcessor.addPoint(point)
@@ -511,7 +513,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
if (!el || !ctx) return
ctx.clearRect(0, 0, el.width, el.height)
isDirty.value = true
modelValue.value = ''
hasStrokes = false
}
function updateCursorPos(e: PointerEvent) {
@@ -617,11 +619,17 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
return { filename, subfolder, type }
}
function isCanvasEmpty(): boolean {
return !hasStrokes
}
async function serializeValue(): Promise<string> {
const el = canvasEl.value
if (!el) return modelValue.value
if (!el) return ''
if (!isDirty.value && modelValue.value) return modelValue.value
if (isCanvasEmpty()) return ''
if (!isDirty.value) return modelValue.value
const blob = await new Promise<Blob | null>((resolve) =>
el.toBlob(resolve, 'image/png')
@@ -709,6 +717,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
mainCtx = null
getCtx()?.drawImage(img, 0, 0)
isDirty.value = false
hasStrokes = true
}
img.onerror = () => {
modelValue.value = ''

View File

@@ -212,6 +212,9 @@ describe('useCoreCommands', () => {
clear: vi.fn(),
serialize: vi.fn(),
configure: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
runStep: vi.fn(),
findNodeByTitle: vi.fn(),
findNodesByTitle: vi.fn(),
findNodesByType: vi.fn(),

View File

@@ -102,8 +102,7 @@ app.registerExtension({
'SaveAudio',
'PreviewAudio',
'SaveAudioMP3',
'SaveAudioOpus',
'SaveAudioAdvanced'
'SaveAudioOpus'
].includes(
// @ts-expect-error fixme ts strict error
nodeType.prototype.comfyClass

View File

@@ -104,6 +104,8 @@ const secondNode = LiteGraph.createNode('basic/sum')
graph.add(secondNode)
firstNode.connect(0, secondNode, 1)
graph.start()
```
## Projects using it

View File

@@ -73,7 +73,7 @@ import {
multiClone,
splitPositionables
} from './subgraph/subgraphUtils'
import { Alignment } from './types/globalEnums'
import { Alignment, LGraphEventMode } from './types/globalEnums'
import type {
LGraphTriggerAction,
LGraphTriggerEvent,
@@ -173,10 +173,8 @@ export interface BaseLGraph {
}
/**
* LGraph contains a full graph. Instantiate it, add nodes/groups, and use it
* for editing, traversal, and serialisation.
*
* Supported callbacks:
* LGraph is the class that contain a full graph. We instantiate one and add nodes to it, and then we can run the execution loop.
* supported callbacks:
* + onNodeAdded: when a new node is added to the graph
* + onNodeRemoved: when a node inside this graph is removed
*/
@@ -185,6 +183,9 @@ export class LGraph
{
static serialisedSchemaVersion = 1 as const
static STATUS_STOPPED = 1
static STATUS_RUNNING = 2
/** List of LGraph properties that are manually handled by {@link LGraph.configure}. */
static readonly ConfigureProperties = new Set([
'nodes',
@@ -223,6 +224,7 @@ export class LGraph
*/
links: Map<LinkId, LLink> & Record<LinkId, LLink>
list_of_graphcanvas: LGraphCanvas[] | null
status: number = LGraph.STATUS_STOPPED
private _state: LGraphState = {
lastGroupId: 0,
@@ -247,6 +249,20 @@ export class LGraph
_nodes_in_order: LGraphNode[] = []
_nodes_executable: LGraphNode[] | null = null
_groups: LGraphGroup[] = []
iteration: number = 0
globaltime: number = 0
/** @deprecated Unused */
runningtime: number = 0
fixedtime: number = 0
fixedtime_lapse: number = 0.01
elapsed_time: number = 0.01
last_update_time: number = 0
starttime: number = 0
catch_errors: boolean = true
execution_timer_id?: number | null
errors_in_execution?: boolean
/** @deprecated Unused */
execution_time!: number
_last_trigger_time?: number
filter?: string
/** Must contain serialisable values, e.g. primitive types */
@@ -313,6 +329,12 @@ export class LGraph
this.state.lastLinkId = value
}
onAfterStep?(): void
onBeforeStep?(): void
onPlayEvent?(): void
onStopEvent?(): void
onAfterExecute?(): void
onExecuteStep?(): void
onNodeAdded?(node: LGraphNode): void
onNodeRemoved?(node: LGraphNode): void
onTrigger?: LGraphTriggerHandler
@@ -352,6 +374,9 @@ export class LGraph
* Removes all nodes from this graph
*/
clear(): void {
this.stop()
this.status = LGraph.STATUS_STOPPED
const graphId = this.id
if (this.isRootGraph && graphId !== zeroUuid) {
usePromotionStore().clearGraph(graphId)
@@ -397,12 +422,26 @@ export class LGraph
// other scene stuff
this._groups = []
// iterations
this.iteration = 0
// custom data
this.config = {}
this.vars = {}
// to store custom data
this.extra = {}
// timing
this.globaltime = 0
this.runningtime = 0
this.fixedtime = 0
this.fixedtime_lapse = 0.01
this.elapsed_time = 0.01
this.last_update_time = 0
this.starttime = 0
this.catch_errors = true
this.nodes_executing = []
this.nodes_actioning = []
this.nodes_executedAction = []
@@ -459,6 +498,146 @@ export class LGraph
}
}
/**
* @deprecated Will be removed in 0.9
* Starts running this graph every interval milliseconds.
* @param interval amount of milliseconds between executions, if 0 then it renders to the monitor refresh rate
*/
start(interval?: number): void {
if (this.status == LGraph.STATUS_RUNNING) return
this.status = LGraph.STATUS_RUNNING
this.onPlayEvent?.()
this.sendEventToAllNodes('onStart')
// launch
this.starttime = LiteGraph.getTime()
this.last_update_time = this.starttime
interval ||= 0
// execute once per frame
if (
interval == 0 &&
typeof window != 'undefined' &&
window.requestAnimationFrame
) {
const on_frame = () => {
if (this.execution_timer_id != -1) return
window.requestAnimationFrame(on_frame)
this.onBeforeStep?.()
this.runStep(1, !this.catch_errors)
this.onAfterStep?.()
}
this.execution_timer_id = -1
on_frame()
} else {
// execute every 'interval' ms
// @ts-expect-error - Timer ID type mismatch needs fixing
this.execution_timer_id = setInterval(() => {
// execute
this.onBeforeStep?.()
this.runStep(1, !this.catch_errors)
this.onAfterStep?.()
}, interval)
}
}
/**
* @deprecated Will be removed in 0.9
* Stops the execution loop of the graph
*/
stop(): void {
if (this.status == LGraph.STATUS_STOPPED) return
this.status = LGraph.STATUS_STOPPED
this.onStopEvent?.()
if (this.execution_timer_id != null) {
if (this.execution_timer_id != -1) {
clearInterval(this.execution_timer_id)
}
this.execution_timer_id = null
}
this.sendEventToAllNodes('onStop')
}
/**
* Run N steps (cycles) of the graph
* @param num number of steps to run, default is 1
* @param do_not_catch_errors [optional] if you want to try/catch errors
* @param limit max number of nodes to execute (used to execute from start to a node)
*/
runStep(num: number, do_not_catch_errors: boolean, limit?: number): void {
num = num || 1
const start = LiteGraph.getTime()
this.globaltime = 0.001 * (start - this.starttime)
const nodes = this._nodes_executable || this._nodes
if (!nodes) return
limit = limit || nodes.length
if (do_not_catch_errors) {
// iterations
for (let i = 0; i < num; i++) {
for (let j = 0; j < limit; ++j) {
const node = nodes[j]
// FIXME: Looks like copy/paste broken logic - checks for "on", executes "do"
if (node.mode == LGraphEventMode.ALWAYS && node.onExecute) {
// wrap node.onExecute();
node.doExecute?.()
}
}
this.fixedtime += this.fixedtime_lapse
this.onExecuteStep?.()
}
this.onAfterExecute?.()
} else {
try {
// iterations
for (let i = 0; i < num; i++) {
for (let j = 0; j < limit; ++j) {
const node = nodes[j]
if (node.mode == LGraphEventMode.ALWAYS) {
node.onExecute?.()
}
}
this.fixedtime += this.fixedtime_lapse
this.onExecuteStep?.()
}
this.onAfterExecute?.()
this.errors_in_execution = false
} catch (error) {
this.errors_in_execution = true
if (LiteGraph.throw_errors) throw error
if (LiteGraph.debug) console.error('Error during execution:', error)
this.stop()
}
}
const now = LiteGraph.getTime()
let elapsed = now - start
if (elapsed == 0) elapsed = 1
this.execution_time = 0.001 * elapsed
this.globaltime += 0.001 * elapsed
this.iteration += 1
this.elapsed_time = (now - this.last_update_time) * 0.001
this.last_update_time = now
this.nodes_executing = []
this.nodes_actioning = []
this.nodes_executedAction = []
}
/**
* Updates the graph execution order according to relevance of the nodes (nodes with only outputs have more relevance than
* nodes with only inputs.
@@ -645,6 +824,33 @@ export class LGraph
this.setDirtyCanvas(true, true)
}
/**
* Returns the amount of time the graph has been running in milliseconds
* @returns number of milliseconds the graph has been running
*/
getTime(): number {
return this.globaltime
}
/**
* Returns the amount of time accumulated using the fixedtime_lapse var.
* This is used in context where the time increments should be constant
* @returns number of milliseconds the graph has been running
*/
getFixedTime(): number {
return this.fixedtime
}
/**
* Returns the amount of time it took to compute the latest iteration.
* Take into account that this number could be not correct
* if the nodes are using graphical actions
* @returns number of milliseconds it took the last cycle
*/
getElapsedTime(): number {
return this.elapsed_time
}
/**
* Increments the internal version counter.
* Currently only read for debug display in {@link LGraphCanvas.renderInfo}.
@@ -654,6 +860,39 @@ export class LGraph
this._version++
}
/**
* @deprecated Will be removed in 0.9
* Sends an event to all the nodes, useful to trigger stuff
* @param eventname the name of the event (function to be called)
* @param params parameters in array format
*/
sendEventToAllNodes(
eventname: string,
params?: object | object[],
mode?: LGraphEventMode
): void {
mode = mode || LGraphEventMode.ALWAYS
const nodes = this._nodes_in_order || this._nodes
if (!nodes) return
for (const node of nodes) {
// @ts-expect-error deprecated
if (!node[eventname] || node.mode != mode) continue
if (params === undefined) {
// @ts-expect-error deprecated
node[eventname]()
} else if (params && params.constructor === Array) {
// @ts-expect-error deprecated
// eslint-disable-next-line prefer-spread
node[eventname].apply(node, params)
} else {
// @ts-expect-error deprecated
node[eventname](params)
}
}
}
/**
* Runs an action on every canvas registered to this graph.
* @param action Action to run for every canvas

View File

@@ -48,9 +48,9 @@ describe('LGraphCanvas.renderInfo', () => {
try {
lgCanvas.renderInfo(ctx, 10, 0)
// lineCount = 3 (graph present, no info_text), lineHeight = 13
// lineCount = 5 (graph present, no info_text), lineHeight = 13
// y = canvas.height / DPR - (lineCount + 1) * lineHeight
expect(ctx.translate).toHaveBeenCalledWith(10, 2160 / 2 - 4 * 13)
expect(ctx.translate).toHaveBeenCalledWith(10, 2160 / 2 - 6 * 13)
} finally {
Object.defineProperty(window, 'devicePixelRatio', {
value: originalDPR,

View File

@@ -1837,7 +1837,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// this.offset = [0,0];
this.dragging_rectangle = null
for (const item of this.selectedItems.keys()) item.selected = undefined
this.selected_nodes = {}
this.selected_group = null
this.selectedItems.clear()
@@ -5404,7 +5403,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
*/
renderInfo(ctx: CanvasRenderingContext2D, x: number, y: number): void {
const lineHeight = 13
const lineCount = (this.graph ? 3 : 1) + (this.info_text ? 1 : 0)
const lineCount = (this.graph ? 5 : 1) + (this.info_text ? 1 : 0)
x = x || 10
y =
y ||
@@ -5421,6 +5420,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctx.textAlign = 'left'
let line = 1
if (this.graph) {
ctx.fillText(
`T: ${this.graph.globaltime.toFixed(2)}s`,
5,
lineHeight * line++
)
ctx.fillText(`I: ${this.graph.iteration}`, 5, lineHeight * line++)
ctx.fillText(
`N: ${this.graph._nodes.length} [${this.visible_nodes.length}]`,
5,

View File

@@ -1422,6 +1422,8 @@ export class LGraphNode
// @ts-expect-error deprecated
this.graph.nodes_executing[this.id] = false
// save execution/action ref
this.exec_version = this.graph.iteration
if (options?.action_call) {
this.action_call = options.action_call
// @ts-expect-error deprecated

View File

@@ -76,25 +76,15 @@ vi.mock(
})
)
const commandStoreMocks = vi.hoisted(() => ({
execute: vi.fn()
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: commandStoreMocks.execute
execute: vi.fn()
})
}))
const routeMocks = vi.hoisted(() => ({
query: {} as Record<string, unknown>
}))
vi.mock('vue-router', () => ({
useRoute: () => ({
get query() {
return routeMocks.query
}
query: {}
}),
useRouter: () => ({
replace: vi.fn()
@@ -107,30 +97,13 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
})
}))
const preservedQueryMocks = vi.hoisted(() => ({
payloads: {} as Record<string, Record<string, string> | undefined>
}))
vi.mock('@/platform/navigation/preservedQueryManager', () => ({
hydratePreservedQuery: vi.fn(),
mergePreservedQueryIntoQuery: vi.fn(
(namespace: string, query: Record<string, unknown> = {}) => {
const payload = preservedQueryMocks.payloads[namespace]
if (!payload) return undefined
const next: Record<string, unknown> = { ...query }
let changed = false
for (const [key, value] of Object.entries(payload)) {
if (typeof next[key] === 'string') continue
next[key] = value
changed = true
}
return changed ? next : undefined
}
)
mergePreservedQueryIntoQuery: vi.fn(() => null)
}))
vi.mock('@/platform/navigation/preservedQueryNamespaces', () => ({
PRESERVED_QUERY_NAMESPACES: { TEMPLATE: 'template', SHARE: 'share' }
PRESERVED_QUERY_NAMESPACES: { TEMPLATE: 'template' }
}))
vi.mock('@/platform/distribution/types', () => ({
@@ -205,9 +178,6 @@ describe('useWorkflowPersistenceV2', () => {
mocks.apiMock.removeEventListener.mockImplementation(() => {})
openWorkflowMock.mockReset()
loadBlankWorkflowMock.mockReset()
commandStoreMocks.execute.mockReset()
routeMocks.query = {}
preservedQueryMocks.payloads = {}
})
afterEach(() => {
@@ -387,43 +357,4 @@ describe('useWorkflowPersistenceV2', () => {
expect(openWorkflowMock).not.toHaveBeenCalled()
})
})
describe('loadDefaultWorkflow', () => {
it('opens templates browser for first-time users', async () => {
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('does not open templates browser when share param is in URL', async () => {
routeMocks.query = { share: 'test-share-id' }
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('does not open templates browser when share intent is preserved across /user-select redirect', async () => {
// No-local-user flow: ?share=... was captured into sessionStorage and the
// URL query was dropped during the /user-select redirect before
// initializeWorkflow() runs.
preservedQueryMocks.payloads.share = { share: 'test-share-id' }
const { initializeWorkflow } = useWorkflowPersistenceV2()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
})
})

View File

@@ -48,7 +48,6 @@ export function useWorkflowPersistenceV2() {
const sharedWorkflowUrlLoader = useSharedWorkflowUrlLoader()
const templateUrlLoader = useTemplateUrlLoader()
const TEMPLATE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.TEMPLATE
const SHARE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE
const draftStore = useWorkflowDraftStoreV2()
const tabState = useWorkflowTabState()
const toast = useToast()
@@ -161,20 +160,11 @@ export function useWorkflowPersistenceV2() {
})
}
const hasSharedWorkflowIntent = () => {
if (typeof route.query.share === 'string') return true
hydratePreservedQuery(SHARE_NAMESPACE)
const merged = mergePreservedQueryIntoQuery(SHARE_NAMESPACE, route.query)
return typeof merged?.share === 'string'
}
const loadDefaultWorkflow = async () => {
if (!settingStore.get('Comfy.TutorialCompleted')) {
await settingStore.set('Comfy.TutorialCompleted', true)
await useWorkflowService().loadBlankWorkflow()
if (!hasSharedWorkflowIntent()) {
await useCommandStore().execute('Comfy.BrowseTemplates')
}
await useCommandStore().execute('Comfy.BrowseTemplates')
} else {
await comfyApp.loadGraphData()
}

View File

@@ -9,7 +9,6 @@ import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataS
import { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
import { getJobWorkflow } from '@/services/jobOutputCache'
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
/**
* Extract workflow from AssetItem using jobs API
@@ -52,11 +51,11 @@ export async function extractWorkflowFromAsset(asset: AssetItem): Promise<{
// Handle both string and object workflow data
const workflow =
typeof workflowData.workflow === 'string'
? parseJsonWithNonFinite<ComfyWorkflowJSON>(workflowData.workflow)
: (workflowData.workflow as ComfyWorkflowJSON)
? JSON.parse(workflowData.workflow)
: workflowData.workflow
return {
workflow,
workflow: workflow as ComfyWorkflowJSON,
filename: baseFilename
}
}

View File

@@ -90,7 +90,6 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { useProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { cn } from '@comfyorg/tailwind-utils'
import InputSlot from './InputSlot.vue'
@@ -135,9 +134,4 @@ const {
processedWidgets,
showAdvanced
} = useProcessedWidgets(() => nodeData)
// Tracks widget-row growth that the node-level RO can't see
if (nodeData?.id != null) {
useVueElementTracking(String(nodeData.id), 'widgets-grid')
}
</script>

View File

@@ -29,10 +29,7 @@ const raf = createRafBatch(() => {
flushScheduledSlotLayoutSync()
})
export function scheduleSlotLayoutSync(nodeId: string) {
// Drop signals for unregistered nodes (e.g. preview nodes with synthetic
// ids from LGraphNodePreview) - they'd otherwise pump setDirty per RAF.
if (!useNodeSlotRegistryStore().getNode(nodeId)) return
function scheduleSlotLayoutSync(nodeId: string) {
pendingNodes.add(nodeId)
raf.schedule()
}

View File

@@ -43,8 +43,7 @@ const testState = vi.hoisted(() => ({
nodeLayouts: new Map<NodeId, NodeLayout>(),
batchUpdateNodeBounds: vi.fn(),
setSource: vi.fn(),
syncNodeSlotLayoutsFromDOM: vi.fn(),
scheduleSlotLayoutSync: vi.fn()
syncNodeSlotLayoutsFromDOM: vi.fn()
}))
vi.mock('@vueuse/core', () => ({
@@ -74,7 +73,6 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
}))
vi.mock('./useSlotElementTracking', () => ({
scheduleSlotLayoutSync: testState.scheduleSlotLayoutSync,
syncNodeSlotLayoutsFromDOM: testState.syncNodeSlotLayoutsFromDOM
}))
@@ -161,7 +159,6 @@ describe('useVueNodeResizeTracking', () => {
testState.batchUpdateNodeBounds.mockReset()
testState.setSource.mockReset()
testState.syncNodeSlotLayoutsFromDOM.mockReset()
testState.scheduleSlotLayoutSync.mockReset()
resizeObserverState.observe.mockReset()
resizeObserverState.unobserve.mockReset()
resizeObserverState.disconnect.mockReset()
@@ -320,25 +317,4 @@ describe('useVueNodeResizeTracking', () => {
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
expect(testState.batchUpdateNodeBounds).toHaveBeenCalled()
})
it('widgets-grid resize schedules a slot resync without writing node bounds', () => {
const parentNodeId: NodeId = 'parent-node'
const element = document.createElement('div')
element.dataset.widgetsGridNodeId = parentNodeId
const boxSizes = [{ inlineSize: 200, blockSize: 80 }]
const entry = {
target: element,
borderBoxSize: boxSizes,
contentBoxSize: boxSizes,
devicePixelContentBoxSize: boxSizes,
contentRect: new DOMRect(0, 0, 200, 80)
} satisfies ResizeEntryLike
resizeObserverState.callback?.([entry], createObserverMock())
expect(testState.scheduleSlotLayoutSync).toHaveBeenCalledWith(parentNodeId)
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
expect(testState.setSource).not.toHaveBeenCalled()
expect(testState.syncNodeSlotLayoutsFromDOM).not.toHaveBeenCalled()
})
})

View File

@@ -24,10 +24,7 @@ import {
} from '@/renderer/core/layout/utils/geometry'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import {
scheduleSlotLayoutSync,
syncNodeSlotLayoutsFromDOM
} from './useSlotElementTracking'
import { syncNodeSlotLayoutsFromDOM } from './useSlotElementTracking'
/**
* Generic update item for element bounds tracking
@@ -50,14 +47,14 @@ interface CachedNodeMeasurement {
interface ElementTrackingConfig {
/** Data attribute name (e.g., 'nodeId') */
dataAttribute: string
/** Handler for processing bounds updates. Omit for signal-only entries. */
updateHandler?: (updates: ElementBoundsUpdate[]) => void
/** Handler for processing bounds updates */
updateHandler: (updates: ElementBoundsUpdate[]) => void
}
/**
* Registry of tracking configurations by element type
*/
const trackingConfigs = new Map<string, ElementTrackingConfig>([
const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
[
'node',
{
@@ -70,10 +67,7 @@ const trackingConfigs = new Map<string, ElementTrackingConfig>([
layoutStore.batchUpdateNodeBounds(nodeUpdates)
}
}
],
// Signal-only: outer node stays at its persisted min-h floor during
// widget hydration, so the inner grid's RO is the only slot-drift signal.
['widgets-grid', { dataAttribute: 'widgetsGridNodeId' }]
]
])
// Elements whose ResizeObserver fired while the tab was hidden
@@ -127,14 +121,6 @@ const resizeObserver = new ResizeObserver((entries) => {
if (!(entry.target instanceof HTMLElement)) continue
const element = entry.target
// Signal-only widgets-grid resize - route the parent node through the
// slot-layout pipeline and skip bounds processing entirely.
const widgetsGridParentNodeId = element.dataset.widgetsGridNodeId
if (widgetsGridParentNodeId) {
scheduleSlotLayoutSync(widgetsGridParentNodeId as NodeId)
continue
}
// Find which type this element belongs to
let elementType: string | undefined
let elementId: string | undefined
@@ -252,7 +238,7 @@ const resizeObserver = new ResizeObserver((entries) => {
// Flush per-type
for (const [type, updates] of updatesByType) {
const config = trackingConfigs.get(type)
if (config?.updateHandler && updates.length) config.updateHandler(updates)
if (config && updates.length) config.updateHandler(updates)
}
}

View File

@@ -84,7 +84,6 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import type { ExtensionManager } from '@/types/extensionTypes'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { graphToPrompt } from '@/utils/executionUtil'
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
import { getCnrIdFromProperties } from '@/platform/nodeReplacement/cnrIdUtil'
import { rescanAndSurfaceMissingNodes } from '@/platform/nodeReplacement/missingNodeScan'
import {
@@ -947,6 +946,8 @@ export class ComfyApp {
}
)
this.rootGraph.start()
// Ensure the canvas fills the window
useResizeObserver(this.canvasElRef, ([canvasEl]) => {
if (canvasEl.target instanceof HTMLCanvasElement) {
@@ -1090,7 +1091,7 @@ export class ComfyApp {
}
// Check for old clipboard format
const data = parseJsonWithNonFinite<{ reroutes?: unknown }>(template.data)
const data = JSON.parse(template.data)
if (!data.reroutes) {
deserialiseAndCreate(template.data, app.canvas)
} else {
@@ -1801,9 +1802,7 @@ export class ComfyApp {
let workflowObj: ComfyWorkflowJSON | undefined = undefined
try {
workflowObj =
typeof workflow === 'string'
? parseJsonWithNonFinite<ComfyWorkflowJSON>(workflow)
: (workflow as ComfyWorkflowJSON)
typeof workflow === 'string' ? JSON.parse(workflow) : workflow
// Only load workflow if parsing succeeded AND validation passed
if (
@@ -1832,9 +1831,7 @@ export class ComfyApp {
if (prompt) {
try {
const promptObj =
typeof prompt === 'string'
? parseJsonWithNonFinite<ComfyApiWorkflow>(prompt)
: prompt
typeof prompt === 'string' ? JSON.parse(prompt) : prompt
if (this.isApiJson(promptObj)) {
this.loadApiJson(promptObj, fileName)
return

View File

@@ -8,12 +8,6 @@ export const EXPECTED_PROMPT = {
'1': { class_type: 'KSampler', inputs: {} }
}
// API prompt as parsed from the `with_nan_metadata.*` fixtures, after the
// loader coerces bare NaN/Infinity tokens to null.
export const EXPECTED_PROMPT_NAN_COERCED = {
'1': { class_type: 'KSampler', inputs: { cfg: null, denoise: null } }
}
type ReadMethod = 'readAsText' | 'readAsArrayBuffer'
export function mockFileReaderError(method: ReadMethod): void {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 486 B

View File

@@ -1 +0,0 @@
{"1":{"class_type":"KSampler","inputs":{"cfg":NaN,"denoise":Infinity}}}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 B

View File

@@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
EXPECTED_PROMPT,
EXPECTED_PROMPT_NAN_COERCED,
EXPECTED_WORKFLOW,
mockFileReaderAbort,
mockFileReaderError
@@ -12,10 +11,6 @@ import {
import { getFromAvifFile } from './avif'
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.avif')
const nanFixturePath = path.resolve(
__dirname,
'__fixtures__/with_nan_metadata.avif'
)
afterEach(() => vi.restoreAllMocks())
@@ -30,16 +25,6 @@ describe('AVIF metadata', () => {
expect(JSON.parse(result.prompt)).toEqual(EXPECTED_PROMPT)
})
it('parses Python generated prompt with bare NaN/Infinity tokens', async () => {
const bytes = fs.readFileSync(nanFixturePath)
const file = new File([bytes], 'nan.avif', { type: 'image/avif' })
const result = await getFromAvifFile(file)
expect(result.workflow).toBeUndefined()
expect(JSON.parse(result.prompt)).toEqual(EXPECTED_PROMPT_NAN_COERCED)
})
it('returns empty for non-AVIF data', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const file = new File([new Uint8Array(16)], 'fake.avif')

View File

@@ -1,7 +1,3 @@
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON
} from '@/platform/workflow/validation/schemas/workflowSchema'
import {
type AvifIinfBox,
type AvifIlocBox,
@@ -10,7 +6,6 @@ import {
ComfyMetadataTags,
type IsobmffBoxContentRange
} from '@/types/metadataTypes'
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
const readNullTerminatedString = (
dataView: DataView,
@@ -286,10 +281,7 @@ function parseAvifMetadata(buffer: ArrayBuffer): ComfyMetadata {
if (typeof value === 'string') {
if (key === 'usercomment') {
try {
const metadataJson = parseJsonWithNonFinite<{
prompt?: ComfyApiWorkflow
workflow?: ComfyWorkflowJSON
}>(value)
const metadataJson = JSON.parse(value)
if (metadataJson.prompt) {
metadata[ComfyMetadataTags.PROMPT] = metadataJson.prompt
}
@@ -309,9 +301,7 @@ function parseAvifMetadata(buffer: ArrayBuffer): ComfyMetadata {
ComfyMetadataTags.WORKFLOW.toLowerCase()
) {
try {
const jsonValue = parseJsonWithNonFinite<
ComfyApiWorkflow | ComfyWorkflowJSON
>(metadataValue)
const jsonValue = JSON.parse(metadataValue)
metadata[metadataKey.toLowerCase() as keyof ComfyMetadata] =
jsonValue
} catch (e) {

View File

@@ -4,7 +4,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
EXPECTED_PROMPT,
EXPECTED_PROMPT_NAN_COERCED,
EXPECTED_WORKFLOW,
mockFileReaderAbort,
mockFileReaderError
@@ -12,10 +11,6 @@ import {
import { getFromWebmFile } from './ebml'
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.webm')
const nanFixturePath = path.resolve(
__dirname,
'__fixtures__/with_nan_metadata.webm'
)
describe('WebM/EBML metadata', () => {
it('extracts workflow and prompt from EBML SimpleTag elements', async () => {
@@ -28,16 +23,6 @@ describe('WebM/EBML metadata', () => {
expect(result.prompt).toEqual(EXPECTED_PROMPT)
})
it('parses Python generated prompt with bare NaN/Infinity tokens', async () => {
const bytes = fs.readFileSync(nanFixturePath)
const file = new File([bytes], 'nan.webm', { type: 'video/webm' })
const result = await getFromWebmFile(file)
expect(result.workflow).toBeUndefined()
expect(result.prompt).toEqual(EXPECTED_PROMPT_NAN_COERCED)
})
it('returns empty for non-WebM data', async () => {
const file = new File([new Uint8Array(16)], 'fake.webm')

View File

@@ -10,7 +10,6 @@ import {
type TextRange,
type VInt
} from '@/types/metadataTypes'
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
const WEBM_SIGNATURE = [0x1a, 0x45, 0xdf, 0xa3]
const MAX_READ_BYTES = 2 * 1024 * 1024
@@ -246,9 +245,7 @@ const parseJsonText = (
if (jsonEndPos === null) return null
try {
return parseJsonWithNonFinite<ComfyWorkflowJSON | ComfyApiWorkflow>(
jsonText.substring(0, jsonEndPos)
)
return JSON.parse(jsonText.substring(0, jsonEndPos))
} catch {
return null
}

View File

@@ -3,7 +3,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { ASCII, GltfSizeBytes } from '@/types/metadataTypes'
import {
EXPECTED_PROMPT_NAN_COERCED,
mockFileReaderAbort,
mockFileReaderError
} from './__fixtures__/helpers'
@@ -16,6 +15,12 @@ describe('GLTF binary metadata parser', () => {
return { header, headerView }
}
const jsonToBinary = (json: object) => {
const jsonString = JSON.stringify(json)
const jsonData = new TextEncoder().encode(jsonString)
return jsonData
}
const createJSONChunk = (jsonData: ArrayBuffer) => {
const chunkHeader = new ArrayBuffer(GltfSizeBytes.CHUNK_HEADER)
const chunkView = new DataView(chunkHeader)
@@ -46,14 +51,7 @@ describe('GLTF binary metadata parser', () => {
}
function createMockGltfFile(jsonContent: object): File {
return createMockGltfFileFromText(JSON.stringify(jsonContent))
}
// Builds a GLB whose JSON chunk is the literal text passed in - used to
// embed Python generated bare NaN/Infinity tokens that JSON.stringify
// would otherwise coerce to null.
function createMockGltfFileFromText(jsonText: string): File {
const jsonData = new TextEncoder().encode(jsonText)
const jsonData = jsonToBinary(jsonContent)
const { header, headerView } = createGLTFFileStructure()
setHeaders(headerView, jsonData.buffer)
@@ -161,18 +159,6 @@ describe('GLTF binary metadata parser', () => {
expect(workflow.nodes[0].type).toBe('StringifiedNode')
})
it('parses Python generated prompt with bare NaN/Infinity tokens', async () => {
const pythonJsonText =
'{"asset":{"version":"2.0","extras":{"prompt":' +
'{"1":{"class_type":"KSampler","inputs":{"cfg":NaN,"denoise":Infinity}}}' +
'}}}'
const mockFile = createMockGltfFileFromText(pythonJsonText)
const metadata = await getGltfBinaryMetadata(mockFile)
expect(metadata.prompt).toEqual(EXPECTED_PROMPT_NAN_COERCED)
})
it('should handle invalid GLTF binary files gracefully', async () => {
const invalidEmptyFile = new File([], 'invalid.glb')
const metadata = await getGltfBinaryMetadata(invalidEmptyFile)

View File

@@ -11,7 +11,6 @@ import {
type GltfJsonData,
GltfSizeBytes
} from '@/types/metadataTypes'
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
const MAX_READ_BYTES = 1 << 20
@@ -82,17 +81,19 @@ const extractJsonChunkData = (buffer: ArrayBuffer): Uint8Array | null => {
return new Uint8Array(buffer, chunkLocation.start, chunkLocation.length)
}
const parseJson = <T = unknown>(text: string): T | null => {
const parseJson = (text: string): ReturnType<typeof JSON.parse> | null => {
try {
return parseJsonWithNonFinite<T>(text)
return JSON.parse(text)
} catch {
return null
}
}
const parseJsonBytes = <T = unknown>(bytes: Uint8Array): T | null => {
const parseJsonBytes = (
bytes: Uint8Array
): ReturnType<typeof JSON.parse> | null => {
const jsonString = byteArrayToString(bytes)
return parseJson<T>(jsonString)
return parseJson(jsonString)
}
const parseMetadataValue = (
@@ -101,7 +102,10 @@ const parseMetadataValue = (
if (typeof value !== 'string')
return value as ComfyWorkflowJSON | ComfyApiWorkflow
return parseJson<ComfyWorkflowJSON | ComfyApiWorkflow>(value) ?? undefined
const parsed = parseJson(value)
if (!parsed) return undefined
return parsed as ComfyWorkflowJSON | ComfyApiWorkflow
}
const extractComfyMetadata = (jsonData: GltfJsonData): ComfyMetadata => {
@@ -132,7 +136,7 @@ const processGltfFileBuffer = (buffer: ArrayBuffer): ComfyMetadata => {
const jsonChunk = extractJsonChunkData(buffer)
if (!jsonChunk) return {}
const parsedJson = parseJsonBytes<GltfJsonData>(jsonChunk)
const parsedJson = parseJsonBytes(jsonChunk)
if (!parsedJson) return {}
return extractComfyMetadata(parsedJson)

View File

@@ -4,7 +4,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
EXPECTED_PROMPT,
EXPECTED_PROMPT_NAN_COERCED,
EXPECTED_WORKFLOW,
mockFileReaderAbort,
mockFileReaderError
@@ -12,10 +11,6 @@ import {
import { getFromIsobmffFile } from './isobmff'
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.mp4')
const nanFixturePath = path.resolve(
__dirname,
'__fixtures__/with_nan_metadata.mp4'
)
describe('ISOBMFF (MP4) metadata', () => {
it('extracts workflow and prompt from QuickTime keys/ilst boxes', async () => {
@@ -28,16 +23,6 @@ describe('ISOBMFF (MP4) metadata', () => {
expect(result.prompt).toEqual(EXPECTED_PROMPT)
})
it('parses Python generated prompt with bare NaN/Infinity tokens', async () => {
const bytes = fs.readFileSync(nanFixturePath)
const file = new File([bytes], 'nan.mp4', { type: 'video/mp4' })
const result = await getFromIsobmffFile(file)
expect(result.workflow).toBeUndefined()
expect(result.prompt).toEqual(EXPECTED_PROMPT_NAN_COERCED)
})
it('returns empty for non-ISOBMFF data', async () => {
const file = new File([new Uint8Array(16)], 'fake.mp4', {
type: 'video/mp4'

View File

@@ -8,7 +8,6 @@ import {
ComfyMetadataTags,
type IsobmffBoxContentRange
} from '@/types/metadataTypes'
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
// Set max read high, as atoms are stored near end of file
// while search is made to be efficient.
@@ -86,9 +85,7 @@ const extractJson = (
try {
const jsonText = new TextDecoder().decode(data.slice(jsonStart, end))
return parseJsonWithNonFinite<ComfyWorkflowJSON | ComfyApiWorkflow>(
jsonText
)
return JSON.parse(jsonText)
} catch {
return null
}

View File

@@ -1,20 +1,12 @@
import fs from 'fs'
import path from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
EXPECTED_PROMPT_NAN_COERCED,
mockFileReaderAbort,
mockFileReaderError,
mockFileReaderResult
} from './__fixtures__/helpers'
import { getDataFromJSON } from './json'
const nanFixturePath = path.resolve(
__dirname,
'__fixtures__/with_nan_metadata.json'
)
function jsonFile(content: object): File {
return new File([JSON.stringify(content)], 'test.json', {
type: 'application/json'
@@ -49,15 +41,6 @@ describe('getDataFromJSON', () => {
expect(result).toEqual({ templates })
})
it('parses Python generated API prompt with bare NaN/Infinity tokens', async () => {
const bytes = fs.readFileSync(nanFixturePath, 'utf-8')
const file = new File([bytes], 'nan.json', { type: 'application/json' })
const result = await getDataFromJSON(file)
expect(result).toEqual({ prompt: EXPECTED_PROMPT_NAN_COERCED })
})
it('returns undefined for non-JSON content', async () => {
const file = new File(['not valid json'], 'bad.json', {
type: 'application/json'

View File

@@ -1,7 +1,5 @@
import { isObject } from 'es-toolkit/compat'
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
export function getDataFromJSON(
file: File
): Promise<Record<string, object> | undefined> {
@@ -13,11 +11,9 @@ export function getDataFromJSON(
resolve(undefined)
return
}
const jsonContent = parseJsonWithNonFinite<Record<string, unknown>>(
reader.result
)
const jsonContent = JSON.parse(reader.result)
if (jsonContent?.templates) {
resolve({ templates: jsonContent.templates as object })
resolve({ templates: jsonContent.templates })
return
}
if (isApiJson(jsonContent)) {

View File

@@ -4,7 +4,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
EXPECTED_PROMPT,
EXPECTED_PROMPT_NAN_COERCED,
EXPECTED_WORKFLOW,
mockFileReaderAbort,
mockFileReaderError
@@ -12,10 +11,6 @@ import {
import { getMp3Metadata } from './mp3'
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.mp3')
const nanFixturePath = path.resolve(
__dirname,
'__fixtures__/with_nan_metadata.mp3'
)
afterEach(() => vi.restoreAllMocks())
@@ -68,16 +63,6 @@ describe('MP3 metadata', () => {
expect(errorSpy).not.toHaveBeenCalled()
})
it('parses Python generated prompt with bare NaN/Infinity tokens', async () => {
const bytes = fs.readFileSync(nanFixturePath)
const file = new File([bytes], 'nan.mp3', { type: 'audio/mpeg' })
const result = await getMp3Metadata(file)
expect(result.workflow).toBeUndefined()
expect(result.prompt).toEqual(EXPECTED_PROMPT_NAN_COERCED)
})
it('extracts metadata that spans the 4096-byte page boundary', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const metadata =
@@ -99,31 +84,6 @@ describe('MP3 metadata', () => {
expect(result.prompt).toEqual(EXPECTED_PROMPT)
})
it('logs and skips when embedded JSON is malformed', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const metadata = `prompt\0{not json}\0workflow\0{also bad}\0`
const buf = new Uint8Array(64 + metadata.length)
buf[0] = 0xff
buf[1] = 0xfb
for (let i = 0; i < metadata.length; i++) {
buf[16 + i] = metadata.charCodeAt(i)
}
const file = new File([buf], 'malformed.mp3', { type: 'audio/mpeg' })
const result = await getMp3Metadata(file)
expect(result.prompt).toBeUndefined()
expect(result.workflow).toBeUndefined()
expect(errorSpy).toHaveBeenCalledWith(
'Failed to parse MP3 prompt metadata',
expect.any(SyntaxError)
)
expect(errorSpy).toHaveBeenCalledWith(
'Failed to parse MP3 workflow metadata',
expect.any(SyntaxError)
)
})
describe('FileReader failure modes', () => {
const file = new File([new Uint8Array(16)], 'test.mp3')

View File

@@ -1,9 +1,3 @@
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
export async function getMp3Metadata(file: File) {
const reader = new FileReader()
const read_process = new Promise<ArrayBuffer | null>((r) => {
@@ -33,23 +27,10 @@ export async function getMp3Metadata(file: File) {
header += page
if (page.match('\u00ff\u00fb')) break
}
let workflow: ComfyWorkflowJSON | undefined
let prompt: ComfyApiWorkflow | undefined
let workflow, prompt
let prompt_s = header.match(/prompt\u0000(\{.*?\})\u0000/s)?.[1]
if (prompt_s) {
try {
prompt = parseJsonWithNonFinite<ComfyApiWorkflow>(prompt_s)
} catch (e) {
console.error('Failed to parse MP3 prompt metadata', e)
}
}
if (prompt_s) prompt = JSON.parse(prompt_s)
let workflow_s = header.match(/workflow\u0000(\{.*?\})\u0000/s)?.[1]
if (workflow_s) {
try {
workflow = parseJsonWithNonFinite<ComfyWorkflowJSON>(workflow_s)
} catch (e) {
console.error('Failed to parse MP3 workflow metadata', e)
}
}
if (workflow_s) workflow = JSON.parse(workflow_s)
return { prompt, workflow }
}

View File

@@ -4,7 +4,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
EXPECTED_PROMPT,
EXPECTED_PROMPT_NAN_COERCED,
EXPECTED_WORKFLOW,
mockFileReaderAbort,
mockFileReaderError
@@ -12,10 +11,6 @@ import {
import { getOggMetadata } from './ogg'
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.opus')
const nanFixturePath = path.resolve(
__dirname,
'__fixtures__/with_nan_metadata.opus'
)
afterEach(() => vi.restoreAllMocks())
@@ -30,16 +25,6 @@ describe('OGG/Opus metadata', () => {
expect(result.prompt).toEqual(EXPECTED_PROMPT)
})
it('parses Python generated prompt with bare NaN/Infinity tokens', async () => {
const bytes = fs.readFileSync(nanFixturePath)
const file = new File([bytes], 'nan.opus', { type: 'audio/ogg' })
const result = await getOggMetadata(file)
expect(result.workflow).toBeUndefined()
expect(result.prompt).toEqual(EXPECTED_PROMPT_NAN_COERCED)
})
it('returns undefined fields for non-OGG data', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const file = new File([new Uint8Array(16)], 'fake.ogg', {
@@ -67,32 +52,6 @@ describe('OGG/Opus metadata', () => {
expect(result.prompt).toBeUndefined()
})
it('logs and skips when embedded JSON is malformed', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const metadata = `prompt={not json}\0workflow={also bad}\0`
const oggs = new TextEncoder().encode('OggS\0')
const buf = new Uint8Array(128)
buf.set(oggs, 0)
for (let i = 0; i < metadata.length; i++) {
buf[16 + i] = metadata.charCodeAt(i)
}
buf.set(oggs, 16 + metadata.length + 8)
const file = new File([buf], 'malformed.opus', { type: 'audio/ogg' })
const result = await getOggMetadata(file)
expect(result.prompt).toBeUndefined()
expect(result.workflow).toBeUndefined()
expect(errorSpy).toHaveBeenCalledWith(
'Failed to parse Ogg prompt metadata',
expect.any(SyntaxError)
)
expect(errorSpy).toHaveBeenCalledWith(
'Failed to parse Ogg workflow metadata',
expect.any(SyntaxError)
)
})
describe('FileReader failure modes', () => {
const file = new File([new Uint8Array(16)], 'test.ogg')

View File

@@ -1,9 +1,3 @@
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
export async function getOggMetadata(file: File) {
const reader = new FileReader()
const read_process = new Promise<ArrayBuffer | null>((r) => {
@@ -30,27 +24,14 @@ export async function getOggMetadata(file: File) {
header += page
if (oggs > 1) break
}
let workflow: ComfyWorkflowJSON | undefined
let prompt: ComfyApiWorkflow | undefined
let workflow, prompt
let prompt_s = header
.match(/prompt=(\{.*?(\}.*?\u0000))/s)?.[1]
?.match(/\{.*\}/)?.[0]
if (prompt_s) {
try {
prompt = parseJsonWithNonFinite<ComfyApiWorkflow>(prompt_s)
} catch (e) {
console.error('Failed to parse Ogg prompt metadata', e)
}
}
if (prompt_s) prompt = JSON.parse(prompt_s)
let workflow_s = header
.match(/workflow=(\{.*?(\}.*?\u0000))/s)?.[1]
?.match(/\{.*\}/)?.[0]
if (workflow_s) {
try {
workflow = parseJsonWithNonFinite<ComfyWorkflowJSON>(workflow_s)
} catch (e) {
console.error('Failed to parse Ogg workflow metadata', e)
}
}
if (workflow_s) workflow = JSON.parse(workflow_s)
return { prompt, workflow }
}

View File

@@ -39,18 +39,4 @@ describe('getSvgMetadata', () => {
expect(result).toEqual({})
})
it('coerces bare NaN/Infinity tokens to null (Python json.dumps output)', async () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg">
<metadata><![CDATA[{"prompt": {"1": {"class_type": "KSampler", "inputs": {"cfg": NaN, "denoise": Infinity}}}}]]></metadata>
</svg>`
const result = await getSvgMetadata(svgFile(svg))
expect(result).toEqual({
prompt: {
'1': { class_type: 'KSampler', inputs: { cfg: null, denoise: null } }
}
})
})
})

View File

@@ -1,5 +1,4 @@
import { type ComfyMetadata } from '@/types/metadataTypes'
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
export async function getSvgMetadata(file: File): Promise<ComfyMetadata> {
const text = await file.text()
@@ -8,7 +7,7 @@ export async function getSvgMetadata(file: File): Promise<ComfyMetadata> {
if (metadataMatch && metadataMatch[1]) {
try {
return parseJsonWithNonFinite<ComfyMetadata>(metadataMatch[1].trim())
return JSON.parse(metadataMatch[1].trim())
} catch (error) {
console.error('Error parsing SVG metadata:', error)
return {}

View File

@@ -289,9 +289,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
)
const workflowExtra = workflow.initialState.extra
const description =
workflowExtra?.BlueprintDescription ??
workflow.initialState?.definitions?.subgraphs[0].description ??
'User generated subgraph blueprint'
workflowExtra?.BlueprintDescription ?? 'User generated subgraph blueprint'
const search_aliases = workflowExtra?.BlueprintSearchAliases
const subgraphDefCategory =
workflow.initialState.definitions?.subgraphs?.[0]?.category

View File

@@ -1,242 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('parseJsonWithNonFinite', () => {
it('parses standard JSON unchanged', () => {
expect(
parseJsonWithNonFinite(
'{"x": 1, "y": "hello", "z": [1, 2, null, true, false]}'
)
).toEqual({ x: 1, y: 'hello', z: [1, 2, null, true, false] })
})
it('handles compact Python separators with no spaces', () => {
expect(parseJsonWithNonFinite('{"a":NaN,"b":Infinity}')).toEqual({
a: null,
b: null
})
})
it('coerces NaN as the last value before object close', () => {
expect(parseJsonWithNonFinite('{"a":1,"b":NaN}')).toEqual({
a: 1,
b: null
})
})
it('handles multi-line pretty-printed Python output', () => {
expect(
parseJsonWithNonFinite('{\n "x": NaN,\n "y": Infinity\n}')
).toEqual({
x: null,
y: null
})
})
it('coerces NaN deeply nested across object and array levels', () => {
expect(
parseJsonWithNonFinite(
'{"a": {"b": {"c": [1, {"d": [NaN, [Infinity, {"e": -Infinity}]]}]}}}'
)
).toEqual({
a: { b: { c: [1, { d: [null, [null, { e: null }]] }] } }
})
})
it.for([
['NaN', null],
['Infinity', null],
['-Infinity', null],
['null', null],
['true', true],
['false', false],
['{}', {}],
['[]', []]
] as const)('parses bare top-level value: %s', ([input, expected]) => {
expect(parseJsonWithNonFinite(input)).toEqual(expected)
})
it.for([
['[NaN]', [null]],
['[Infinity]', [null]],
['[-Infinity]', [null]]
] as const)(
'coerces token at right-boundary of array: %s',
([input, expected]) => {
expect(parseJsonWithNonFinite(input)).toEqual(expected)
}
)
it.for([
['tab', '{"x":\tNaN}'],
['newline', '{"x":\nNaN}'],
['carriage return', '{"x":\rNaN}'],
['runs of spaces', '{"x": NaN}']
])('treats %s as a delimiter', ([, input]) => {
expect(parseJsonWithNonFinite(input)).toEqual({ x: null })
})
it('preserves NaN appearing inside string values', () => {
expect(parseJsonWithNonFinite('{"desc": "value is NaN here"}')).toEqual({
desc: 'value is NaN here'
})
})
it('preserves Infinity appearing inside string values', () => {
expect(parseJsonWithNonFinite('{"x": "to Infinity and beyond"}')).toEqual({
x: 'to Infinity and beyond'
})
})
it('preserves NaN appearing as a string key', () => {
expect(parseJsonWithNonFinite('{"NaN": 1, "Infinity": 2}')).toEqual({
NaN: 1,
Infinity: 2
})
})
it('preserves token-like substrings inside strings with escaped quotes', () => {
expect(
parseJsonWithNonFinite('{"x": "say \\"NaN\\" loud", "y": NaN}')
).toEqual({
x: 'say "NaN" loud',
y: null
})
})
it('handles escaped backslash immediately before a closing quote', () => {
expect(parseJsonWithNonFinite('{"x": "a\\\\", "y": NaN}')).toEqual({
x: 'a\\',
y: null
})
})
it('preserves token-like text after escape sequences inside strings', () => {
expect(
parseJsonWithNonFinite('{"x": "a\\nNaN", "y": "\\u0022Infinity\\u0022"}')
).toEqual({ x: 'a\nNaN', y: '"Infinity"' })
})
it('throws SyntaxError on otherwise-invalid JSON', () => {
expect(() => parseJsonWithNonFinite('{not json}')).toThrow(SyntaxError)
})
it.for([
['NaN with trailing digits', '{"x": NaN123}'],
['Infinity with trailing letter', '{"x": Infinityy}'],
['-Infinity with trailing digit', '{"x": -Infinity0}'],
['adjacent NaNs', '{"x": NaNNaN}'],
['-Infinity with trailing letter', '{"x": -Infinityy}']
])('throws on partial token match: %s', ([, input]) => {
expect(() => parseJsonWithNonFinite(input)).toThrow(SyntaxError)
})
it.for([
['after digit', '{"x": 1-Infinity}'],
['after decimal float', '{"x": 1.5-Infinity}']
])(
'throws when -Infinity is not delimiter-bounded on the left: %s',
([, input]) => {
expect(() => parseJsonWithNonFinite(input)).toThrow(SyntaxError)
}
)
it('throws on unterminated string ending in a lone backslash', () => {
expect(() => parseJsonWithNonFinite('{"x": "abc\\')).toThrow(SyntaxError)
})
it('throws on unsupported +Infinity prefix', () => {
expect(() => parseJsonWithNonFinite('{"x": +Infinity}')).toThrow(
SyntaxError
)
})
it('does not treat non-ASCII letters as a token boundary (throws)', () => {
expect(() => parseJsonWithNonFinite('{"x": éNaN}')).toThrow(SyntaxError)
})
it.for([
['top-level JSON string of NaN', '"NaN"', 'NaN'],
['token alone as string value', '{"x": "NaN"}', { x: 'NaN' }],
[
'-Infinity alone as string value',
'{"x": "-Infinity"}',
{ x: '-Infinity' }
],
[
'multiple tokens in one string',
'{"x": "NaN Infinity -Infinity"}',
{ x: 'NaN Infinity -Infinity' }
],
[
'token as prefix of identifier in string',
'{"s": "NaNny"}',
{ s: 'NaNny' }
],
[
'hyphen-bracketed Infinity in string',
'{"s": "pre-Infinity-post"}',
{ s: 'pre-Infinity-post' }
]
] as const)(
'preserves token text inside string contexts: %s',
([, input, expected]) => {
expect(parseJsonWithNonFinite(input)).toEqual(expected)
}
)
it('preserves numeric exponents (does not match Infinity prefix)', () => {
expect(parseJsonWithNonFinite('[1e10, -1.5e-3]')).toEqual([1e10, -1.5e-3])
})
it.for([
['token embedded in identifier', '{"x": fooNaNbar}'],
['Infinity followed by decimal point', '{"x": Infinity.123}'],
['trailing garbage after valid JSON', '{"a": 1} extra'],
['bare unknown identifier', '{"a": Foo}']
])('throws on invalid JSON: %s', ([, input]) => {
expect(() => parseJsonWithNonFinite(input)).toThrow(SyntaxError)
})
it('handles 10k-element array of mixed tokens without backtracking', () => {
const items = Array.from({ length: 10000 }, (_, i) =>
i % 3 === 0 ? 'NaN' : i % 3 === 1 ? 'Infinity' : '-Infinity'
).join(',')
const result = parseJsonWithNonFinite<null[]>(`[${items}]`)
expect(result).toHaveLength(10000)
expect(result[0]).toBeNull()
expect(result[9999]).toBeNull()
})
describe('fallback warning', () => {
it('does not warn when strict parse succeeds', () => {
parseJsonWithNonFinite('{"a": 1}')
expect(console.warn).not.toHaveBeenCalled()
})
it('warns once per call regardless of how many tokens are replaced', () => {
parseJsonWithNonFinite('{"a": NaN, "b": Infinity, "c": [-Infinity, NaN]}')
expect(console.warn).toHaveBeenCalledTimes(1)
})
it('warns again on a separate call', () => {
parseJsonWithNonFinite('{"a": NaN}')
parseJsonWithNonFinite('{"b": Infinity}')
expect(console.warn).toHaveBeenCalledTimes(2)
})
it('does not warn when the relaxed parse itself throws on input with no tokens', () => {
expect(() => parseJsonWithNonFinite('{not json}')).toThrow(SyntaxError)
expect(console.warn).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,37 +0,0 @@
/**
* Parse JSON that may contain bare `NaN`/`Infinity`/`-Infinity` tokens
* (which Python's `json.dumps` emits) by replacing them with `null` on
* fallback. Coercion is lossy; a one-time warning is logged when it fires.
*/
export function parseJsonWithNonFinite<T = unknown>(text: string): T {
try {
return JSON.parse(text) as T
} catch {
return JSON.parse(replaceNonFiniteTokens(text)) as T
}
}
// Match a JSON string OR a non-finite token outside string.
// - `"(?:\\.|[^"\\])*"` a quoted string - matched first so anything that
// looks like a token inside a string is skipped
// - `(?<![\w.-])` skip non bare tokens (e.g. 1NaN)
// - `(-?Infinity|NaN)` capture the non-finite token
// - `(?![\w.])` skip non bare suffix tokens (e.g. NaN1)
const NON_FINITE_TOKEN =
/"(?:\\.|[^"\\])*"|(?<![\w.-])(-?Infinity|NaN)(?![\w.])/g
function replaceNonFiniteTokens(text: string): string {
let hasWarned = false
return text.replace(NON_FINITE_TOKEN, (match, token) => {
if (token) {
if (!hasWarned) {
console.warn(
'JSON contained non-finite numeric tokens (NaN/Infinity); they were replaced with null.'
)
hasWarned = true
}
return 'null'
}
return match
})
}

View File

@@ -1,7 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import ProgressSpinner from 'primevue/progressspinner'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { render, screen } from '@testing-library/vue'
@@ -57,8 +56,7 @@ vi.mock('@vueuse/core', () => ({
createSharedComposable: vi.fn((fn) => {
let cached: ReturnType<typeof fn>
return (...args: Parameters<typeof fn>) => (cached ??= fn(...args))
}),
useDocumentVisibility: vi.fn(() => ref<'visible' | 'hidden'>('visible'))
})
}))
vi.mock('@/config', () => ({

View File

@@ -1,133 +0,0 @@
{
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "d0056772-3aca-4bc2-9971-8781df454c2b",
"pos": [470.93671874999995, 461],
"size": [225, 100],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"proxyWidgets": []
},
"widgets_values": [],
"title": "test blueprint"
}
],
"links": [],
"version": 0.4,
"definitions": {
"subgraphs": [
{
"id": "d0056772-3aca-4bc2-9971-8781df454c2b",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 2,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "test blueprint",
"inputNode": {
"id": -10,
"bounding": [240.43671874999995, 435, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [713.43671875, 435, 128, 68]
},
"inputs": [
{
"id": "d291ee9e-b1a6-4a9b-8163-ae7980ad312d",
"name": "image",
"type": "IMAGE",
"linkIds": [1],
"pos": [344.43671874999995, 459]
}
],
"outputs": [
{
"id": "4836730c-14a8-487d-9009-594b7e745076",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [2],
"pos": [737.43671875, 459]
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "ImageInvert",
"pos": [428.43671874999995, 438],
"size": [225, 72],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [2]
}
],
"properties": {
"Node name for S&R": "ImageInvert"
}
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {},
"description": "Inverts the image"
}
]
},
"extra": {}
}