Compare commits

...

7 Commits

Author SHA1 Message Date
Connor Byrne
5ce1ce923e refactor(litegraph): delete LiteGraph trigger/action subsystem
Delete the in-browser trigger/action event subsystem (~22 symbols)
confirmed dead by AUDIT-LG.5 + AUDIT-LG.9. ComfyUI's graph mutation
event bus on LGraph.onTrigger / LGraph.trigger(action,param) is
preserved — those are the production event hooks used by
useGraphNodeManager, useErrorClearingHooks, and useMinimapGraph.

LGraphNode: triggerSlot, actionDo, trigger, doExecute,
addOnTriggerInput, addOnExecutedOutput, clearTriggeredSlot,
onAfterExecuteNode, onAction, connect()'s LiteGraph.EVENT branch,
findConnectByTypeSlot's createEventInCase block, ON_TRIGGER case in
changeMode(), and the now-dead-on-arrival readers/writers for
execute_triggered, action_triggered, exec_version, action_call.

LGraph: triggerInput, setCallback, clearTriggeredSlots,
_last_trigger_time, nodes_executing, nodes_actioning,
nodes_executedAction, doExecute call site in runStep().
PRESERVES onTrigger field, trigger() dispatcher, and LGraphTrigger*
types.

globalEnums: LGraphEventMode.ON_TRIGGER.
LiteGraphGlobal: LiteGraph.ON_TRIGGER, LiteGraph.do_add_triggers_slots,
'On Trigger' entry in NODE_MODES.

LLink: _last_time field + LGraphCanvas fade-out reader (dead-on-arrival
per AUDIT-LG.5).

LGraphCanvas: do_add_triggers_slots menu block, _last_trigger_time
dirty-bg-canvas reader, _last_time link fade-out reader,
execute_triggered/action_triggered decrement counters; unused 'now'
locals/params in _renderAllLinkSegments and _renderFloatingLinks
removed in cascade.

CARVE-OUT: LiteGraph.EVENT = -1 and LiteGraph.ACTION = -1 are NOT
deleted. The user's instructions described these as living in
'3 vendored canvas bundles' but in this layout they are referenced
by 12 live TypeScript callers in LGraphCanvas.ts and linkColors.ts
(slot-type filtering, link colors, type-filter menu options) which
the user explicitly directed not to touch. Removing the constants
would break TS compilation. The constants now point to slot types
that no LGraphNode method can produce — the canvas branches are
dead-on-arrival per AUDIT-LG.9.

Closes #12223.
2026-05-13 16:31:41 -07:00
jaeone94
ad63f7cb9b test: cover missing media runtime sources (#12126)
## Summary

Adds browser coverage for the missing-media runtime paths introduced by
#12069 and #12111:

- OSS: annotated `[output]` media is resolved from job history.
- Cloud: compact `[output]` media is resolved from output assets.
- OSS and Cloud: dropped video uploads do not surface a missing-media
error while the upload is still in progress.

This PR is now rebased directly onto `main`; the parent fix PRs have
been squash-merged, so this branch only contains the E2E coverage
commit.

## Test Fixtures

- Adds workflow fixtures for OSS spaced output annotations and Cloud
compact output annotations.
- Adds a small plain MP4 fixture for video drag/drop upload coverage.

## Validation

- `pnpm exec oxfmt --check
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
browser_tests/assets/missing/missing_media_cloud_output_annotation.json
browser_tests/assets/missing/missing_media_output_annotations.json`
- `pnpm typecheck:browser`
- `pnpm exec oxlint
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
--type-aware`
- `git diff --check origin/main..HEAD`

Note: before the rebase, the Cloud project target for this spec passed
locally. Local OSS project execution against the currently running Cloud
dist did not reach the test body because `ComfyPage.waitForAppReady`
timed out in `beforeEach`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12126-test-cover-missing-media-runtime-sources-35d6d73d365081f0a981c02f33c0ff84)
by [Unito](https://www.unito.io)
2026-05-13 22:46:47 +00:00
Terry Jia
f7ef563b46 FE-657: prevent browser zoom on ctrl+wheel in mask editor (#12215)
## Summary

Wheel events on the mask editor pointer zone now call preventDefault,
matching the main canvas behavior so ctrl+wheel only zooms the mask
canvas instead of also triggering page zoom.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12215-FE-657-prevent-browser-zoom-on-ctrl-wheel-in-mask-editor-35f6d73d36508131a9b8dbf2f6640d72)
by [Unito](https://www.unito.io)
2026-05-13 18:24:22 -04:00
AustinMroz
9cc09cd46c Add additional subgraph test fixtures and tests (#11806)
- Adds functions to SubgraphHelper to perform widget promotion by
standard user means
  - Right Click -> Promote
  - Properties Panel
- Adds new slot fixture code that works with simple `locator.dragTo`
operations.
- Adds multiple subgraph tests with a focus on historically difficult
operations.
- Fixes a bug where the litegraph `node.selected` state would not be
unset when switching graphs. This made it so 'Selecting a node ->
leaving subgraph -> re-enter subgraph -> right click on node' would fail
to select the node because it is marked as already selected.

┆Issue is synchronized with this [Notion
page](https://app.notion.com/p/PR-11806-Add-helper-functions-for-widget-promotion-3536d73d365081f58dd9cd730c1a91a9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-13 20:35:57 +00:00
pythongosssss
de1c1ee1f2 fix: add support for parsing python generated json with NaN/infinite (#12217)
## Summary

API and other legacy JSON generated by python `json.dumps` can contain
`NaN` and `Infinity` which cannot be parsed with JS `JSON.parse`. This
adds regex to replace these invalid tokens with `null`.

## Changes

- **What**: 
- add regex replace on bare NaN/infinity tokens after JSON.parse fails
- update call sites
- tests

## Review Focus
- The regex should only rewrite bare NaN/-Infinity/Infinity and not
touch string values or other invalid tokens.
- A small regex was chosen over JSON5 due to package size (30.3kB
Minified, 9kB Minified + Gzipped) or a manual parser due to the
unnecessarily complexity vs a single regex replace.
- The happy path is run first, the safe parse is only executed if that
failed, meaning no overhead the vast majority of the time and no
possiblity of corrupting valid workflows due to a bug in the fallback
parser
- Multiple call sites had to be updated due to pre-existing architecture
of the various parsers, an issue for unifying these is logged for future
cleanup
- New binary fixtures added for validating e2e import using real files

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12217-fix-add-support-for-parsing-python-generated-json-with-NaN-infinite-35f6d73d365081889fc7f4af823f29c1)
by [Unito](https://www.unito.io)
2026-05-13 20:33:19 +00:00
AustinMroz
86b1e1a965 Fix descriptions on core blueprints (#12220)
Core blueprints were storing the description under a different key than
expected, which resulted in them displaying a placeholder description.
When initializing the description for a subgraph, this alternative field
is also checked.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/ed51c4a8-00cf-4927-9cba-880532a9e926"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/f19bf80d-adcc-4e9b-a9ba-a5ac8e089e2d"
/>|

Resolves FE-681

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12220-Austin-blueprint-descriptions-35f6d73d3650812fa04df48c203bebd1)
by [Unito](https://www.unito.io)
2026-05-13 20:30:45 +00:00
pythongosssss
4321013798 fix: resolve widget input link position drift on reload (#12214)
## Summary
The position of a link relative to its slot was able to drift on load,
due to widgets inside a node being able to resize without triggering an
node-level resize event (min-height node with space at the bottom could
have widgets expand into free space, causing misalignment).

Recreation:
1. Add KSampler
2. Add Float
3. Connect Float to KSamper.denoise
4. Reload workflow (F5)
5. Observe misalignment

## Changes

- **What**: 
- track widget grid element as signal only that triggers resync
- node bound calculations skipped for widget signals
- prevent setDirty on non-graph nodes (e.g. LGraphNodePreview)
- tests

## Review Focus
This is a small focused approach to fix the reported issue - it does not
address the underlying issue of the layout not being a SSOT. This fix is
a small bandaid and investigation into resolving the layout SOT issue is
not impacted by this.

## Screenshots (if applicable)

Before:
<img width="673" height="374" alt="image"
src="https://github.com/user-attachments/assets/2d34b8e3-0731-4fd2-8553-4dd429010ced"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12214-fix-resolve-widget-input-link-position-drift-on-reload-35f6d73d3650814eb31bebb3042ff58b)
by [Unito](https://www.unito.io)
2026-05-13 20:00:50 +00:00
68 changed files with 2008 additions and 453 deletions

View File

@@ -0,0 +1,45 @@
{
"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

@@ -0,0 +1,84 @@
{
"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

@@ -0,0 +1,90 @@
{
"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

@@ -246,4 +246,18 @@ 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,12 +6,16 @@ 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> {
@@ -36,16 +40,7 @@ export class ContextMenu {
}
async isVisible(): Promise<boolean> {
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
return await this.anyMenu.isVisible()
}
async assertHasItems(items: string[]): Promise<void> {
@@ -58,7 +53,7 @@ export class ContextMenu {
async openFor(locator: Locator): Promise<this> {
await locator.click({ button: 'right' })
await expect.poll(() => this.isVisible()).toBe(true)
await expect(this.anyMenu).toBeVisible()
return this
}

View File

@@ -95,6 +95,7 @@ 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')
@@ -103,6 +104,7 @@ 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

@@ -0,0 +1,81 @@
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,34 +2,7 @@ import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
/**
* 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()
}
import { dragByIndex } from '@e2e/fixtures/utils/dragAndDrop'
export class BuilderSelectHelper {
/** All IoItem locators in the current step sidebar. */

View File

@@ -9,12 +9,17 @@ 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 {
constructor(private readonly comfyPage: ComfyPage) {}
public readonly editor: SubgraphEditor
constructor(private readonly comfyPage: ComfyPage) {
this.editor = new SubgraphEditor(comfyPage)
}
private get page(): Page {
return this.comfyPage.page
@@ -327,6 +332,23 @@ 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,6 +8,7 @@ export const TestIds = {
toolbar: 'side-toolbar',
nodeLibrary: 'node-library-tree',
nodeLibrarySearch: 'node-library-search',
nodePreviewCard: 'node-preview-card',
workflows: 'workflows-sidebar',
modeToggle: 'mode-toggle'
},
@@ -103,14 +104,16 @@ export const TestIds = {
errorsTab: 'panel-tab-errors'
},
subgraphEditor: {
toggle: 'subgraph-editor-toggle',
shownSection: 'subgraph-editor-shown-section',
hiddenSection: 'subgraph-editor-hidden-section',
widgetToggle: 'subgraph-widget-toggle',
widgetLabel: 'subgraph-widget-label',
iconLink: 'icon-link',
iconEye: 'icon-eye',
widgetActionsMenuButton: 'widget-actions-menu-button'
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',
widgetLabel: 'subgraph-widget-label',
widgetToggle: 'subgraph-widget-toggle'
},
node: {
titleInput: 'node-title-input',

View File

@@ -0,0 +1,33 @@
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,6 +15,7 @@ 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-"]')
@@ -27,6 +28,7 @@ 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> {
@@ -39,6 +41,10 @@ export class VueNodeFixture {
await this.titleEditor.setTitle(value)
}
async select() {
await this.header.click()
}
async toggleCollapse(): Promise<void> {
await this.collapseButton.click()
}
@@ -60,4 +66,15 @@ 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

@@ -25,6 +25,21 @@ 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'] },
@@ -58,5 +73,42 @@ 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

@@ -0,0 +1,357 @@
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,4 +120,13 @@ 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,3 +607,218 @@ 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

@@ -1133,3 +1133,108 @@ 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

@@ -36,8 +36,19 @@ 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:
@@ -53,15 +64,21 @@ def make_1x1_image() -> Image.Image:
return Image.new('RGB', (1, 1), (255, 0, 0))
def build_exif_bytes() -> bytes:
def build_exif_bytes(
workflow_str: str | None = WORKFLOW_JSON,
prompt_str: str | None = PROMPT_JSON,
) -> 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()
exif[0x010F] = f'workflow:{WORKFLOW_JSON}'
exif[0x0110] = f'prompt:{PROMPT_JSON}'
if workflow_str is not None:
exif[0x010F] = f'workflow:{workflow_str}'
if prompt_str is not None:
exif[0x0110] = f'prompt:{prompt_str}'
return exif.tobytes()
@@ -93,6 +110,9 @@ 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)
@@ -100,8 +120,10 @@ def generate_av_fixture(
stream = container.add_stream(codec, rate=rate)
stream.layout = 'mono'
container.metadata['prompt'] = PROMPT_JSON
container.metadata['workflow'] = WORKFLOW_JSON
if prompt_json is not None:
container.metadata['prompt'] = prompt_json
if workflow_json is not None:
container.metadata['workflow'] = workflow_json
sample_fmt = stream.codec_context.codec.audio_formats[0].name
samples = stream.codec_context.frame_size or 1024
@@ -175,6 +197,63 @@ 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()
@@ -185,4 +264,5 @@ if __name__ == '__main__':
generate_mp3()
generate_mp4()
generate_webm()
generate_nan_variants()
print('Done.')

View File

@@ -141,6 +141,21 @@ 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="handleWheel"
@wheel.prevent="handleWheel"
@contextmenu.prevent
/>
</template>

View File

@@ -2,6 +2,7 @@
<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,6 +263,7 @@ 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"
@@ -295,6 +296,7 @@ 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,9 +41,13 @@ 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">
<div
class="line-clamp-1 text-xs text-text-secondary"
data-testid="subgraph-widget-node-name"
>
{{ nodeTitle }}
</div>
<div class="line-clamp-1 text-sm/8" data-testid="subgraph-widget-label">

View File

@@ -263,14 +263,10 @@ export class LGraph
errors_in_execution?: boolean
/** @deprecated Unused */
execution_time!: number
_last_trigger_time?: number
filter?: string
/** Must contain serialisable values, e.g. primitive types */
config: LGraphConfig = {}
vars: Dictionary<unknown> = {}
nodes_executing: boolean[] = []
nodes_actioning: (string | boolean)[] = []
nodes_executedAction: string[] = []
extra: LGraphExtra = {}
/** @deprecated Deserialising a workflow sets this unused property. */
@@ -442,10 +438,6 @@ export class LGraph
this.catch_errors = true
this.nodes_executing = []
this.nodes_actioning = []
this.nodes_executedAction = []
// notify canvas to redraw
this.change()
@@ -586,10 +578,8 @@ export class LGraph
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?.()
if (node.mode == LGraphEventMode.ALWAYS) {
node.onExecute?.()
}
}
@@ -633,9 +623,6 @@ export class LGraph
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 = []
}
/**
@@ -1372,24 +1359,6 @@ export class LGraph
// Don't handle unknown events - just ignore them
}
/** @todo Clean up - never implemented. */
triggerInput(name: string, value: unknown): void {
const nodes = this.findNodesByTitle(name)
for (const node of nodes) {
// @ts-expect-error - onTrigger method may not exist on all node types
node.onTrigger(value)
}
}
/** @todo Clean up - never implemented. */
setCallback(name: string, func?: () => void): void {
const nodes = this.findNodesByTitle(name)
for (const node of nodes) {
// @ts-expect-error - setTrigger method may not exist on all node types
node.setTrigger(func)
}
}
// used for undo, called before any change is made to the graph
beforeChange(info?: LGraphNode): void {
this.onBeforeChange?.(this, info)
@@ -1402,17 +1371,6 @@ export class LGraph
this.canvasAction((c) => c.onAfterChange?.(this))
}
/**
* clears the triggered slot animation in all links (stop visual animation)
*/
clearTriggeredSlots(): void {
for (const link_info of this._links.values()) {
if (!link_info) continue
if (link_info._last_time) link_info._last_time = 0
}
}
/* Called when something visually changed (not the graph!) */
change(): void {
this.canvasAction((c) => c.setDirty(true, true))

View File

@@ -1283,16 +1283,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
let entries: (IContextMenuValue<INodeSlotContextItem> | null)[] = []
if (
LiteGraph.do_add_triggers_slots &&
node.findOutputSlot('onExecuted') == -1
) {
entries.push({
content: 'On Executed',
value: ['onExecuted', LiteGraph.EVENT, { nameLocked: true }],
className: 'event'
})
}
// add callback for modifying the menu elements onMenuNodeOutputs
const retEntries = node.onMenuNodeOutputs?.(entries)
if (retEntries) entries = retEntries
@@ -1837,6 +1827,7 @@ 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()
@@ -5021,9 +5012,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (
this.dirty_bgcanvas ||
force_bgcanvas ||
this.always_render_background ||
(this.graph?._last_trigger_time &&
now - this.graph._last_trigger_time < 1000)
this.always_render_background
) {
this.drawBackCanvas()
}
@@ -5905,12 +5894,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
node.drawProgressBar(ctx)
// these counter helps in conditioning drawing based on if the node has been executed or an action occurred
if (node.execute_triggered != null && node.execute_triggered > 0)
node.execute_triggered--
if (node.action_triggered != null && node.action_triggered > 0)
node.action_triggered--
}
/**
@@ -5984,7 +5967,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const visibleReroutes: Reroute[] = []
const now = LiteGraph.getTime()
const { visible_area } = this
margin_area[0] = visible_area[0] - 20
margin_area[1] = visible_area[1] - 20
@@ -6049,7 +6031,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
startPos,
endPos,
visibleReroutes,
now,
output.dir,
input.dir
)
@@ -6078,7 +6059,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
output.pos,
endPos,
visibleReroutes,
now,
input.dir,
input.dir
)
@@ -6105,7 +6085,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
startPos,
input.pos,
visibleReroutes,
now,
output.dir,
input.dir
)
@@ -6113,7 +6092,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
if (graph.floatingLinks.size > 0) {
this._renderFloatingLinks(ctx, graph, visibleReroutes, now)
this._renderFloatingLinks(ctx, graph, visibleReroutes)
}
const rerouteSet = this._visibleReroutes
@@ -6159,8 +6138,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
private _renderFloatingLinks(
ctx: CanvasRenderingContext2D,
graph: LGraph,
visibleReroutes: Reroute[],
now: number
visibleReroutes: Reroute[]
) {
// Render floating links with 3/4 current alpha
const { globalAlpha } = ctx
@@ -6191,7 +6169,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
startPos,
endPos,
visibleReroutes,
now,
LinkDirection.CENTER,
endDirection,
true
@@ -6213,7 +6190,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
startPos,
endPos,
visibleReroutes,
now,
startDirection,
LinkDirection.CENTER,
true
@@ -6229,7 +6205,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
startPos: Point,
endPos: Point,
visibleReroutes: Reroute[],
now: number,
startDirection?: LinkDirection,
endDirection?: LinkDirection,
disabled: boolean = false
@@ -6350,25 +6325,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
)
}
renderedPaths.add(link)
// event triggered rendered on top
if (link?._last_time && now - link._last_time < 1000) {
const f = 2.0 - (now - link._last_time) * 0.002
const tmp = ctx.globalAlpha
ctx.globalAlpha = tmp * f
this.renderLink(
ctx,
startPos,
endPos,
link,
true,
f,
'white',
start_dir,
end_dir
)
ctx.globalAlpha = tmp
}
}
/**

View File

@@ -214,7 +214,6 @@ supported callbacks:
+ onDropFile : file dropped over the node
+ onConnectInput : if returns false the incoming connection will be canceled
+ onConnectionsChange : a connection changed (new one or removed) (NodeSlotType.INPUT or NodeSlotType.OUTPUT, slot, true if connected, link_info, input_info )
+ onAction: action slot triggered
+ getExtraMenuOptions: to add option to context menu
*/
@@ -353,11 +352,6 @@ export class LGraphNode
get renderingBoxColor(): string {
if (this.boxcolor) return this.boxcolor
if (LiteGraph.node_box_coloured_when_on) {
if (this.action_triggered) return '#FFF'
if (this.execute_triggered) return '#AAA'
}
if (LiteGraph.node_box_coloured_by_mode) {
const modeColour =
LiteGraph.NODE_MODES_COLORS[this.mode ?? LGraphEventMode.ALWAYS]
@@ -401,10 +395,6 @@ export class LGraphNode
*/
progress?: number
exec_version?: number
action_call?: string
execute_triggered?: number
action_triggered?: number
/**
* @deprecated This property is unsupported and will be removed in a future release.
* Use `widgets_start_y` or a custom `arrange()` override instead.
@@ -631,12 +621,6 @@ export class LGraphNode
param?: unknown,
options?: { action_call?: string }
): void
onAction?(
this: LGraphNode,
action: string,
param: unknown,
options: { action_call?: string }
): void
onDrawBackground?(this: LGraphNode, ctx: CanvasRenderingContext2D): void
onNodeCreated?(this: LGraphNode): void
/**
@@ -1349,45 +1333,11 @@ export class LGraphNode
return r
}
addOnTriggerInput(): number {
const trigS = this.findInputSlot('onTrigger')
if (trigS == -1) {
this.addInput('onTrigger', LiteGraph.EVENT, {
nameLocked: true
})
return this.findInputSlot('onTrigger')
}
return trigS
}
addOnExecutedOutput(): number {
const trigS = this.findOutputSlot('onExecuted')
if (trigS == -1) {
this.addOutput('onExecuted', LiteGraph.ACTION, {
nameLocked: true
})
return this.findOutputSlot('onExecuted')
}
return trigS
}
onAfterExecuteNode(param: unknown, options?: { action_call?: string }) {
const trigS = this.findOutputSlot('onExecuted')
if (trigS != -1) {
this.triggerSlot(trigS, param, null, options)
}
}
changeMode(modeTo: number): boolean {
switch (modeTo) {
case LGraphEventMode.ON_EVENT:
break
case LGraphEventMode.ON_TRIGGER:
this.addOnTriggerInput()
this.addOnExecutedOutput()
break
case LGraphEventMode.NEVER:
break
@@ -1406,189 +1356,6 @@ export class LGraphNode
return true
}
/**
* Triggers the node code execution, place a boolean/counter to mark the node as being executed
*/
doExecute(param?: unknown, options?: { action_call?: string }): void {
options = options || {}
if (this.onExecute) {
// enable this to give the event an ID
options.action_call ||= `${this.id}_exec_${Math.floor(Math.random() * 9999)}`
if (!this.graph) throw new NullGraphError()
// @ts-expect-error Technically it works when id is a string. Array gets props.
this.graph.nodes_executing[this.id] = true
this.onExecute(param, options)
// @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
this.graph.nodes_executedAction[this.id] = options.action_call
}
}
// the nFrames it will be used (-- each step), means "how old" is the event
this.execute_triggered = 2
this.onAfterExecuteNode?.(param, options)
}
/**
* Triggers an action, wrapped by logics to control execution flow
* @param action name
*/
actionDo(
action: string,
param: unknown,
options: { action_call?: string }
): void {
options = options || {}
if (this.onAction) {
// enable this to give the event an ID
options.action_call ||= `${this.id}_${action || 'action'}_${Math.floor(Math.random() * 9999)}`
if (!this.graph) throw new NullGraphError()
// @ts-expect-error deprecated
this.graph.nodes_actioning[this.id] = action || 'actioning'
this.onAction(action, param, options)
// @ts-expect-error deprecated
this.graph.nodes_actioning[this.id] = false
// save execution/action ref
if (options?.action_call) {
this.action_call = options.action_call
// @ts-expect-error deprecated
this.graph.nodes_executedAction[this.id] = options.action_call
}
}
// the nFrames it will be used (-- each step), means "how old" is the event
this.action_triggered = 2
this.onAfterExecuteNode?.(param, options)
}
/**
* Triggers an event in this node, this will trigger any output with the same name
* @param action name ( "on_play", ... ) if action is equivalent to false then the event is send to all
*/
trigger(
action: string,
param: unknown,
options: { action_call?: string }
): void {
const { outputs } = this
if (!outputs || !outputs.length) {
return
}
if (this.graph) this.graph._last_trigger_time = LiteGraph.getTime()
for (const [i, output] of outputs.entries()) {
if (
!output ||
output.type !== LiteGraph.EVENT ||
(action && output.name != action)
) {
continue
}
this.triggerSlot(i, param, null, options)
}
}
/**
* Triggers a slot event in this node: cycle output slots and launch execute/action on connected nodes
* @param slot the index of the output slot
* @param link_id [optional] in case you want to trigger and specific output link in a slot
*/
triggerSlot(
slot: number,
param: unknown,
link_id: number | null,
options?: { action_call?: string }
): void {
options = options || {}
if (!this.outputs) return
if (slot == null) {
console.error('slot must be a number')
return
}
if (typeof slot !== 'number')
console.warn(
"slot must be a number, use node.trigger('name') if you want to use a string"
)
const output = this.outputs[slot]
if (!output) return
const links = output.links
if (!links || !links.length) return
if (!this.graph) throw new NullGraphError()
this.graph._last_trigger_time = LiteGraph.getTime()
// for every link attached here
for (const id of links) {
// to skip links
if (link_id != null && link_id != id) continue
const link_info = this.graph._links.get(id)
// not connected
if (!link_info) continue
link_info._last_time = LiteGraph.getTime()
const node = this.graph.getNodeById(link_info.target_id)
// node not found?
if (!node) continue
if (node.mode === LGraphEventMode.ON_TRIGGER) {
// generate unique trigger ID if not present
if (!options.action_call)
options.action_call = `${this.id}_trigg_${Math.floor(Math.random() * 9999)}`
// -- wrapping node.onExecute(param); --
node.doExecute?.(param, options)
} else if (node.onAction) {
// generate unique action ID if not present
if (!options.action_call)
options.action_call = `${this.id}_act_${Math.floor(Math.random() * 9999)}`
// pass the action name
const target_connection = node.inputs[link_info.target_slot]
node.actionDo(target_connection.name, param, options)
}
}
}
/**
* clears the trigger slot animation
* @param slot the index of the output slot
* @param link_id [optional] in case you want to trigger and specific output link in a slot
*/
clearTriggeredSlot(slot: number, link_id: number): void {
if (!this.outputs) return
const output = this.outputs[slot]
if (!output) return
const links = output.links
if (!links || !links.length) return
if (!this.graph) throw new NullGraphError()
// for every link attached here
for (const id of links) {
// to skip links
if (link_id != null && link_id != id) continue
const link_info = this.graph._links.get(id)
// not connected
if (!link_info) continue
link_info._last_time = 0
}
}
/**
* changes node size and triggers callback
*/
@@ -2614,12 +2381,6 @@ export class LGraphNode
const slot = node.findSlotByType(findInputs, slotType, false, true)
if (slot >= 0 && slot !== null) return slot
// TODO: Remove or reimpl. events. WILL CREATE THE onTrigger IN SLOT
if (opts.createEventInCase && slotType == LiteGraph.EVENT) {
if (findInputs) return -1
if (LiteGraph.do_add_triggers_slots) return node.addOnExecutedOutput()
}
// connect to the first general output slot if not found a specific type and
if (opts.typedToWildcard) {
const generalSlot = node.findSlotByType(findInputs, 0, false, true, true)
@@ -2813,14 +2574,6 @@ export class LGraphNode
console.error(`Connect: Error, no slot of name ${targetIndex}`)
return null
}
} else if (target_slot === LiteGraph.EVENT) {
// TODO: Events
if (LiteGraph.do_add_triggers_slots) {
target_node.changeMode(LGraphEventMode.ON_TRIGGER)
targetIndex = target_node.findInputSlot('onTrigger')
} else {
return null
}
} else if (typeof target_slot === 'number') {
targetIndex = target_slot
} else {

View File

@@ -109,8 +109,6 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
_data?: unknown
/** Centre point of the link, calculated during render only - can be inaccurate */
_pos: Point
/** @todo Clean up - never implemented in comfy. */
_last_time?: number
/** The last canvas 2D path that was used to render this link */
path?: Path2D
/** @inheritdoc */

View File

@@ -118,13 +118,12 @@ export class LiteGraphGlobal {
ACTION = -1 as const
/** helper, will add "On Request" and more in the future */
NODE_MODES = ['Always', 'On Event', 'Never', 'On Trigger']
NODE_MODES = ['Always', 'On Event', 'Never']
/** use with node_box_coloured_by_mode */
NODE_MODES_COLORS = ['#666', '#422', '#333', '#224', '#626']
ALWAYS = LGraphEventMode.ALWAYS
ON_EVENT = LGraphEventMode.ON_EVENT
NEVER = LGraphEventMode.NEVER
ON_TRIGGER = LGraphEventMode.ON_TRIGGER
UP = LinkDirection.UP
DOWN = LinkDirection.DOWN
@@ -243,12 +242,6 @@ export class LiteGraphGlobal {
/** [true!] very handy, ALT click to clone and drag the new node */
alt_drag_do_clone_nodes = false
/**
* [true!] will create and connect event slots when using action/events connections,
* !WILL CHANGE node mode when using onTrigger (enable mode colors), onExecuted does not need this
*/
do_add_triggers_slots = false
/** [false!] being events, it is strongly recommended to use them sequentially, one by one */
allow_multi_output_for_events = true

View File

@@ -70,7 +70,6 @@ LiteGraphGlobal {
"Always",
"On Event",
"Never",
"On Trigger",
],
"NODE_MODES_COLORS": [
"#666",
@@ -95,7 +94,6 @@ LiteGraphGlobal {
"NO_TITLE": 1,
"Nodes": {},
"ON_EVENT": 1,
"ON_TRIGGER": 3,
"OUTPUT": 2,
"RIGHT": 4,
"ROUND_RADIUS": 8,
@@ -154,7 +152,6 @@ LiteGraphGlobal {
"dialog_close_on_mouse_leave": false,
"dialog_close_on_mouse_leave_delay": 500,
"distance": [Function],
"do_add_triggers_slots": false,
"highlight_selected_group": true,
"isInsideRectangle": [Function],
"leftMouseClickBehavior": "panning",

View File

@@ -84,7 +84,6 @@ export enum LGraphEventMode {
ALWAYS = 0,
ON_EVENT = 1,
NEVER = 2,
ON_TRIGGER = 3,
BYPASS = 4
}

View File

@@ -9,6 +9,7 @@ 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
@@ -51,11 +52,11 @@ export async function extractWorkflowFromAsset(asset: AssetItem): Promise<{
// Handle both string and object workflow data
const workflow =
typeof workflowData.workflow === 'string'
? JSON.parse(workflowData.workflow)
: workflowData.workflow
? parseJsonWithNonFinite<ComfyWorkflowJSON>(workflowData.workflow)
: (workflowData.workflow as ComfyWorkflowJSON)
return {
workflow: workflow as ComfyWorkflowJSON,
workflow,
filename: baseFilename
}
}

View File

@@ -90,6 +90,7 @@ 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'
@@ -134,4 +135,9 @@ 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,7 +29,10 @@ const raf = createRafBatch(() => {
flushScheduledSlotLayoutSync()
})
function scheduleSlotLayoutSync(nodeId: string) {
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
pendingNodes.add(nodeId)
raf.schedule()
}

View File

@@ -43,7 +43,8 @@ const testState = vi.hoisted(() => ({
nodeLayouts: new Map<NodeId, NodeLayout>(),
batchUpdateNodeBounds: vi.fn(),
setSource: vi.fn(),
syncNodeSlotLayoutsFromDOM: vi.fn()
syncNodeSlotLayoutsFromDOM: vi.fn(),
scheduleSlotLayoutSync: vi.fn()
}))
vi.mock('@vueuse/core', () => ({
@@ -73,6 +74,7 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
}))
vi.mock('./useSlotElementTracking', () => ({
scheduleSlotLayoutSync: testState.scheduleSlotLayoutSync,
syncNodeSlotLayoutsFromDOM: testState.syncNodeSlotLayoutsFromDOM
}))
@@ -159,6 +161,7 @@ describe('useVueNodeResizeTracking', () => {
testState.batchUpdateNodeBounds.mockReset()
testState.setSource.mockReset()
testState.syncNodeSlotLayoutsFromDOM.mockReset()
testState.scheduleSlotLayoutSync.mockReset()
resizeObserverState.observe.mockReset()
resizeObserverState.unobserve.mockReset()
resizeObserverState.disconnect.mockReset()
@@ -317,4 +320,25 @@ 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,7 +24,10 @@ import {
} from '@/renderer/core/layout/utils/geometry'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { syncNodeSlotLayoutsFromDOM } from './useSlotElementTracking'
import {
scheduleSlotLayoutSync,
syncNodeSlotLayoutsFromDOM
} from './useSlotElementTracking'
/**
* Generic update item for element bounds tracking
@@ -47,14 +50,14 @@ interface CachedNodeMeasurement {
interface ElementTrackingConfig {
/** Data attribute name (e.g., 'nodeId') */
dataAttribute: string
/** Handler for processing bounds updates */
updateHandler: (updates: ElementBoundsUpdate[]) => void
/** Handler for processing bounds updates. Omit for signal-only entries. */
updateHandler?: (updates: ElementBoundsUpdate[]) => void
}
/**
* Registry of tracking configurations by element type
*/
const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
const trackingConfigs = new Map<string, ElementTrackingConfig>([
[
'node',
{
@@ -67,7 +70,10 @@ const trackingConfigs: Map<string, ElementTrackingConfig> = new Map([
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
@@ -121,6 +127,14 @@ 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
@@ -238,7 +252,7 @@ const resizeObserver = new ResizeObserver((entries) => {
// Flush per-type
for (const [type, updates] of updatesByType) {
const config = trackingConfigs.get(type)
if (config && updates.length) config.updateHandler(updates)
if (config?.updateHandler && updates.length) config.updateHandler(updates)
}
}

View File

@@ -84,6 +84,7 @@ 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 {
@@ -1091,7 +1092,7 @@ export class ComfyApp {
}
// Check for old clipboard format
const data = JSON.parse(template.data)
const data = parseJsonWithNonFinite<{ reroutes?: unknown }>(template.data)
if (!data.reroutes) {
deserialiseAndCreate(template.data, app.canvas)
} else {
@@ -1802,7 +1803,9 @@ export class ComfyApp {
let workflowObj: ComfyWorkflowJSON | undefined = undefined
try {
workflowObj =
typeof workflow === 'string' ? JSON.parse(workflow) : workflow
typeof workflow === 'string'
? parseJsonWithNonFinite<ComfyWorkflowJSON>(workflow)
: (workflow as ComfyWorkflowJSON)
// Only load workflow if parsing succeeded AND validation passed
if (
@@ -1831,7 +1834,9 @@ export class ComfyApp {
if (prompt) {
try {
const promptObj =
typeof prompt === 'string' ? JSON.parse(prompt) : prompt
typeof prompt === 'string'
? parseJsonWithNonFinite<ComfyApiWorkflow>(prompt)
: prompt
if (this.isApiJson(promptObj)) {
this.loadApiJson(promptObj, fileName)
return

View File

@@ -8,6 +8,12 @@ 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.

After

Width:  |  Height:  |  Size: 486 B

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

View File

@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
EXPECTED_PROMPT,
EXPECTED_PROMPT_NAN_COERCED,
EXPECTED_WORKFLOW,
mockFileReaderAbort,
mockFileReaderError
@@ -11,6 +12,10 @@ 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())
@@ -25,6 +30,16 @@ 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,3 +1,7 @@
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON
} from '@/platform/workflow/validation/schemas/workflowSchema'
import {
type AvifIinfBox,
type AvifIlocBox,
@@ -6,6 +10,7 @@ import {
ComfyMetadataTags,
type IsobmffBoxContentRange
} from '@/types/metadataTypes'
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
const readNullTerminatedString = (
dataView: DataView,
@@ -281,7 +286,10 @@ function parseAvifMetadata(buffer: ArrayBuffer): ComfyMetadata {
if (typeof value === 'string') {
if (key === 'usercomment') {
try {
const metadataJson = JSON.parse(value)
const metadataJson = parseJsonWithNonFinite<{
prompt?: ComfyApiWorkflow
workflow?: ComfyWorkflowJSON
}>(value)
if (metadataJson.prompt) {
metadata[ComfyMetadataTags.PROMPT] = metadataJson.prompt
}
@@ -301,7 +309,9 @@ function parseAvifMetadata(buffer: ArrayBuffer): ComfyMetadata {
ComfyMetadataTags.WORKFLOW.toLowerCase()
) {
try {
const jsonValue = JSON.parse(metadataValue)
const jsonValue = parseJsonWithNonFinite<
ComfyApiWorkflow | ComfyWorkflowJSON
>(metadataValue)
metadata[metadataKey.toLowerCase() as keyof ComfyMetadata] =
jsonValue
} catch (e) {

View File

@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
EXPECTED_PROMPT,
EXPECTED_PROMPT_NAN_COERCED,
EXPECTED_WORKFLOW,
mockFileReaderAbort,
mockFileReaderError
@@ -11,6 +12,10 @@ 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 () => {
@@ -23,6 +28,16 @@ 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,6 +10,7 @@ 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
@@ -245,7 +246,9 @@ const parseJsonText = (
if (jsonEndPos === null) return null
try {
return JSON.parse(jsonText.substring(0, jsonEndPos))
return parseJsonWithNonFinite<ComfyWorkflowJSON | ComfyApiWorkflow>(
jsonText.substring(0, jsonEndPos)
)
} catch {
return null
}

View File

@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { ASCII, GltfSizeBytes } from '@/types/metadataTypes'
import {
EXPECTED_PROMPT_NAN_COERCED,
mockFileReaderAbort,
mockFileReaderError
} from './__fixtures__/helpers'
@@ -15,12 +16,6 @@ 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)
@@ -51,7 +46,14 @@ describe('GLTF binary metadata parser', () => {
}
function createMockGltfFile(jsonContent: object): File {
const jsonData = jsonToBinary(jsonContent)
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 { header, headerView } = createGLTFFileStructure()
setHeaders(headerView, jsonData.buffer)
@@ -159,6 +161,18 @@ 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,6 +11,7 @@ import {
type GltfJsonData,
GltfSizeBytes
} from '@/types/metadataTypes'
import { parseJsonWithNonFinite } from '@/utils/jsonUtil'
const MAX_READ_BYTES = 1 << 20
@@ -81,19 +82,17 @@ const extractJsonChunkData = (buffer: ArrayBuffer): Uint8Array | null => {
return new Uint8Array(buffer, chunkLocation.start, chunkLocation.length)
}
const parseJson = (text: string): ReturnType<typeof JSON.parse> | null => {
const parseJson = <T = unknown>(text: string): T | null => {
try {
return JSON.parse(text)
return parseJsonWithNonFinite<T>(text)
} catch {
return null
}
}
const parseJsonBytes = (
bytes: Uint8Array
): ReturnType<typeof JSON.parse> | null => {
const parseJsonBytes = <T = unknown>(bytes: Uint8Array): T | null => {
const jsonString = byteArrayToString(bytes)
return parseJson(jsonString)
return parseJson<T>(jsonString)
}
const parseMetadataValue = (
@@ -102,10 +101,7 @@ const parseMetadataValue = (
if (typeof value !== 'string')
return value as ComfyWorkflowJSON | ComfyApiWorkflow
const parsed = parseJson(value)
if (!parsed) return undefined
return parsed as ComfyWorkflowJSON | ComfyApiWorkflow
return parseJson<ComfyWorkflowJSON | ComfyApiWorkflow>(value) ?? undefined
}
const extractComfyMetadata = (jsonData: GltfJsonData): ComfyMetadata => {
@@ -136,7 +132,7 @@ const processGltfFileBuffer = (buffer: ArrayBuffer): ComfyMetadata => {
const jsonChunk = extractJsonChunkData(buffer)
if (!jsonChunk) return {}
const parsedJson = parseJsonBytes(jsonChunk)
const parsedJson = parseJsonBytes<GltfJsonData>(jsonChunk)
if (!parsedJson) return {}
return extractComfyMetadata(parsedJson)

View File

@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
EXPECTED_PROMPT,
EXPECTED_PROMPT_NAN_COERCED,
EXPECTED_WORKFLOW,
mockFileReaderAbort,
mockFileReaderError
@@ -11,6 +12,10 @@ 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 () => {
@@ -23,6 +28,16 @@ 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,6 +8,7 @@ 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.
@@ -85,7 +86,9 @@ const extractJson = (
try {
const jsonText = new TextDecoder().decode(data.slice(jsonStart, end))
return JSON.parse(jsonText)
return parseJsonWithNonFinite<ComfyWorkflowJSON | ComfyApiWorkflow>(
jsonText
)
} catch {
return null
}

View File

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

View File

@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
EXPECTED_PROMPT,
EXPECTED_PROMPT_NAN_COERCED,
EXPECTED_WORKFLOW,
mockFileReaderAbort,
mockFileReaderError
@@ -11,6 +12,10 @@ 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())
@@ -63,6 +68,16 @@ 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 =
@@ -84,6 +99,31 @@ 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,3 +1,9 @@
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) => {
@@ -27,10 +33,23 @@ export async function getMp3Metadata(file: File) {
header += page
if (page.match('\u00ff\u00fb')) break
}
let workflow, prompt
let workflow: ComfyWorkflowJSON | undefined
let prompt: ComfyApiWorkflow | undefined
let prompt_s = header.match(/prompt\u0000(\{.*?\})\u0000/s)?.[1]
if (prompt_s) prompt = JSON.parse(prompt_s)
if (prompt_s) {
try {
prompt = parseJsonWithNonFinite<ComfyApiWorkflow>(prompt_s)
} catch (e) {
console.error('Failed to parse MP3 prompt metadata', e)
}
}
let workflow_s = header.match(/workflow\u0000(\{.*?\})\u0000/s)?.[1]
if (workflow_s) workflow = JSON.parse(workflow_s)
if (workflow_s) {
try {
workflow = parseJsonWithNonFinite<ComfyWorkflowJSON>(workflow_s)
} catch (e) {
console.error('Failed to parse MP3 workflow metadata', e)
}
}
return { prompt, workflow }
}

View File

@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
EXPECTED_PROMPT,
EXPECTED_PROMPT_NAN_COERCED,
EXPECTED_WORKFLOW,
mockFileReaderAbort,
mockFileReaderError
@@ -11,6 +12,10 @@ 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())
@@ -25,6 +30,16 @@ 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', {
@@ -52,6 +67,32 @@ 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,3 +1,9 @@
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) => {
@@ -24,14 +30,27 @@ export async function getOggMetadata(file: File) {
header += page
if (oggs > 1) break
}
let workflow, prompt
let workflow: ComfyWorkflowJSON | undefined
let prompt: ComfyApiWorkflow | undefined
let prompt_s = header
.match(/prompt=(\{.*?(\}.*?\u0000))/s)?.[1]
?.match(/\{.*\}/)?.[0]
if (prompt_s) prompt = JSON.parse(prompt_s)
if (prompt_s) {
try {
prompt = parseJsonWithNonFinite<ComfyApiWorkflow>(prompt_s)
} catch (e) {
console.error('Failed to parse Ogg prompt metadata', e)
}
}
let workflow_s = header
.match(/workflow=(\{.*?(\}.*?\u0000))/s)?.[1]
?.match(/\{.*\}/)?.[0]
if (workflow_s) workflow = JSON.parse(workflow_s)
if (workflow_s) {
try {
workflow = parseJsonWithNonFinite<ComfyWorkflowJSON>(workflow_s)
} catch (e) {
console.error('Failed to parse Ogg workflow metadata', e)
}
}
return { prompt, workflow }
}

View File

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

View File

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

242
src/utils/jsonUtil.test.ts Normal file
View File

@@ -0,0 +1,242 @@
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()
})
})
})

37
src/utils/jsonUtil.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* 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,6 +1,7 @@
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'
@@ -56,7 +57,8 @@ 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

@@ -0,0 +1,133 @@
{
"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": {}
}