Compare commits

..

18 Commits

Author SHA1 Message Date
bymyself
0821f4b443 test: increase HUD-on maxDiffPixels to account for FPS variance
The HUD displays dynamic FPS values that change between runs,
causing ~300 pixel variance in the 180×160 clip region (~1%).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 17:16:37 -07:00
bymyself
1af4b8efc6 test: update renderInfo test for new lineCount after T:/I: removal
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 16:56:43 -07:00
bymyself
6ba54935ce test: update HUD-on snapshot to match CI rendering
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 16:43:38 -07:00
bymyself
b0947ee834 docs: update LGraph class JSDoc after executor removal
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/12233#pullrequestreview-4286031194

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 16:37:59 -07:00
bymyself
74c09f31ef test: update HUD-on snapshot from CI actual
The HUD now shows N/V/FPS without the removed T:/I: lines.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 16:18:20 -07:00
bymyself
173293b919 fix(litegraph): update renderInfo lineCount after T:/I: removal
Reverts incorrect local snapshots; fixes lineCount from 5 to 3 to
match the actual number of lines displayed (N, V, FPS).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 16:01:44 -07:00
bymyself
fc6a0c8491 test: update HUD snapshots after removing T:/I: lines
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 15:42:45 -07:00
bymyself
699824f1e4 fix: remove lingering LGraph.start() call
Missed in initial deletion - also clean README example.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-14 15:26:44 -07:00
Alexander Brown
1f8e2c71d3 Merge branch 'main' into litegraph/prune-executor-cluster 2026-05-13 18:40:35 -07:00
Alexis Rolland
5738c7a539 Add SaveAudioAdvanced to whitelisted nodes (#12213)
## Summary

Add `SaveAudioAdvanced` to whitelisted nodes in order to display the
audio player. This PR goes with the core PR:
https://github.com/Comfy-Org/ComfyUI/pull/13871

## Changes

- **What**: Display audio player on new node `SaveAudioAdvanced`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12213-Add-SaveAudioAdvanced-to-whitelisted-nodes-35f6d73d3650815783fac52d3d37e1e1)
by [Unito](https://www.unito.io)
2026-05-14 00:10:06 +00:00
Connor Byrne
2dadcde05d refactor(litegraph): delete dead LGraph executor cluster
Delete LGraph.start(), .stop(), .runStep(), .sendEventToAllNodes(), the
runtime state fields they own (iteration, globaltime, runningtime,
fixedtime, fixedtime_lapse, elapsed_time, last_update_time, starttime,
catch_errors, execution_timer_id, errors_in_execution, execution_time,
status), and the LGraph.STATUS_RUNNING/STATUS_STOPPED constants.

The host methods were already `@deprecated 'Will be removed in 0.9'`.
AUDIT-LG.9 confirmed zero internal and zero external callers across
src/, browser_tests/, packages/. Stacks on top of #12228 which deleted
the 6 stepping hooks fired from these methods.

Transitive cleanup folded in:
- LGraph.getTime/getFixedTime/getElapsedTime accessors (read deleted fields, zero callers)
- LGraphNode.doExecute now drops 'this.exec_version = this.graph.iteration'
- LGraphCanvas.renderInfo drops the T:/I: debug overlay lines
- useCoreCommands.test.ts subgraph mock drops start/stop/runStep stubs
- Unused LGraphEventMode import in LGraph.ts

Preserves nodes_executing/nodes_actioning/nodes_executedAction - those
pair with the trigger cluster being removed separately.
2026-05-13 16:48:27 -07:00
Christian Byrne
8abfa678a3 fix: harden e2e coverage workflow and fix GH Pages deploy (#11381)
## Problem

The GH Pages coverage deploy has been failing since #11291 merged —
every `CI: E2E Coverage` workflow run errors out and
https://comfy-org.github.io/ComfyUI_frontend/ returns 404.

Additionally, two correctness/security issues were identified in the
workflow (filed as #11374 and #11375).

## Changes

1. **`--ignore-errors source` on genhtml** — merged LCOV data includes
paths like `localhost-8188/assets/main-BRkC1B8m.js` from Playwright V8
coverage instrumented runtime bundles that don't exist as source files
in CI, causing genhtml to error out
2. **Pin checkout to `workflow_run.head_sha`** — in `workflow_run`
context, the default checkout ref points to the default branch, not the
commit that triggered the upstream run; genhtml could annotate against
wrong source files (#11375)
3. **Gate deploy on `event == 'push'`** — a fork branch named `main`
could satisfy the branch check and overwrite production coverage; adding
the event guard prevents this (#11375)
4. **Include workflow run link in placeholder HTML** — when no coverage
data is available, the placeholder page now links back to the workflow
run for debugging (#11374)

## Fixes

- Fixes the GH Pages 404 caused by #11291
- Fixes #11374
- Fixes #11375
2026-05-13 23:06:36 +00:00
Christian Byrne
b36b601a1c refactor(litegraph): prune dead surfaces (AUDIT-LG implementation, draft) (#12228)
Implements the AUDIT-LG verdicts (#12223, #12224, #12225) as a single
deletion PR off main.

> **DRAFT — sequencing.** Per the AUDIT-LG framing doc, deletions land
**after Phase B ECS migration** (Alex's #11939 + #11811). Open early to
capture the diff and let CI cascade-flag any unused-export fallout. Flip
to ready-for-review post-Alex-rebase.

## Status

| commit | scope | status |
|---|---|---|
| 1 | Delete 6 LGraph stepping hooks (`onAfterStep`, `onBeforeStep`,
`onPlayEvent`, `onStopEvent`, `onAfterExecute`, `onExecuteStep`) + their
dispatch sites in `start()`/`stop()`/`runStep()` |  landed (this
commit) |
| 2 | Delete the rest of the dead executor cluster
(`start()`/`stop()`/`runStep()`/`sendEventToAllNodes()` + state fields +
`STATUS_*` constants) | follow-up |
| 3 | Delete `LGraphNode` dead event hooks (~22 fields per #12224) |
follow-up |
| 4 | Delete trigger/action subsystem (~22 symbols, #12223) | follow-up
|
| 5 | `ON_EVENT` deprecation cycle, release N (#12225) | follow-up |

## Verification (commit 1)

```
$ pnpm lint && pnpm format:check && pnpm knip
✓ format: All matched files use the correct format
✓ lint: pre-existing icon-name warnings only
✓ knip: no new unused exports flagged
```

## Verdicts the deletion is grounded in

- AUDIT-LG.7 master verdict table (146 surfaces classified DELETE-NOW /
DEPRECATE / KEEP)
- AUDIT-LG.9 per-symbol attribution sweep (confirmed zero functional
callers for the trigger cluster + the dead hooks)

For each surface in this PR:

- **`internal_count` from rg over `src/`, `browser_tests/`, `packages/`
excluding `lib/litegraph/`:** 0
- **External use from touch-points DB:** 0 (per AUDIT-LG.3 + AUDIT-LG.9
per-symbol attribution)
- **Host methods (`start()`/`stop()`/`runStep()`):** `@deprecated 'Will
be removed in 0.9'` already

The dispatch sites (`this.onAfterStep?.()` etc.) are inside the
deprecated host methods — removing the dispatchers does not change
observable behaviour because no listener is attached to begin with.

## Why batched, why draft

Per AUDIT-LG framing, the deletion sequences behind Alex's PR #11939
(ECS world-combo). Opening as draft lets CI run early and flags any
unused-export cascades the audit script missed. Each follow-up commit
will be its own atomic deletion (one feature per commit) so any single
one can be reverted in isolation if needed.

cc @drjkl @christian-byrne

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12228-refactor-litegraph-prune-dead-surfaces-AUDIT-LG-implementation-draft-35f6d73d365081e0b72cd292228e2ca6)
by [Unito](https://www.unito.io)

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
2026-05-13 23:03:32 +00: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
76 changed files with 1984 additions and 415 deletions

View File

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

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

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

@@ -8,12 +8,16 @@ export class Topbar {
private readonly menuTrigger: Locator
readonly newWorkflowButton: Locator
readonly workflowTabs: Locator
readonly integratedTabBarActions: Locator
constructor(public readonly page: Page) {
this.menuLocator = page.locator('.comfy-command-menu')
this.menuTrigger = page.locator('.comfy-menu-button-wrapper')
this.newWorkflowButton = page.locator('.new-blank-workflow-button')
this.workflowTabs = page.getByTestId(TestIds.topbar.workflowTabs)
this.integratedTabBarActions = this.workflowTabs.getByTestId(
TestIds.topbar.integratedTabBarActions
)
}
async getTabNames(): Promise<string[]> {

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'
},
@@ -92,6 +93,7 @@ export const TestIds = {
loginButtonPopover: 'login-button-popover',
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
workflowTabs: 'topbar-workflow-tabs',
integratedTabBarActions: 'integrated-tab-bar-actions',
actionBarButtons: 'action-bar-buttons'
},
nodeLibrary: {
@@ -102,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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -78,6 +78,22 @@ test.describe('Layout & sidebar settings', { tag: ['@settings'] }, () => {
})
})
test.describe('Comfy.UI.TabBarLayout', () => {
test('"Default" renders integrated tab bar actions container', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UI.TabBarLayout', 'Default')
await expect(comfyPage.menu.topbar.integratedTabBarActions).toBeAttached()
})
test('"Legacy" does not render integrated tab bar actions container', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UI.TabBarLayout', 'Legacy')
await expect(comfyPage.menu.topbar.integratedTabBarActions).toHaveCount(0)
})
})
test.describe('Comfy.TreeExplorer.ItemPadding', () => {
// The setting writes a CSS var consumed by .p-tree-node-content,
// which only renders in the legacy PrimeVue Tree.

View File

@@ -47,19 +47,6 @@ test.describe('Login Button', { tag: ['@ui'] }, () => {
comfyPage.page.getByTestId(TestIds.topbar.loginButton)
).toBeVisible()
})
test('button falls back to TopMenuSection when workflow tabs are in sidebar', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
await enableLoginButtonFlag(comfyPage.page)
await expect(
comfyPage.page.getByTestId(TestIds.topbar.loginButton)
).toBeVisible()
})
})
test.describe('ARIA', () => {

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

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

@@ -24,7 +24,6 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
const mockData = vi.hoisted(() => ({
isLoggedIn: false,
isDesktop: false,
isCloud: false,
setShowConflictRedDot: (_value: boolean) => {}
}))
@@ -37,9 +36,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockData.isCloud
},
isCloud: false,
isNightly: false,
get isDesktop() {
return mockData.isDesktop
@@ -196,41 +193,54 @@ describe('TopMenuSection', () => {
localStorage.clear()
mockData.isDesktop = false
mockData.isLoggedIn = false
mockData.isCloud = false
mockData.setShowConflictRedDot(false)
})
describe('auth fallback when workflow tabs are not in topbar', () => {
function createSidebarTabsWrapper() {
describe('authentication state', () => {
function createLegacyTabBarWrapper() {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) =>
key === 'Comfy.Workflow.WorkflowTabsPosition' ? 'Sidebar' : undefined
key === 'Comfy.UI.TabBarLayout' ? 'Legacy' : undefined
)
return createWrapper({ pinia })
}
it('should display CurrentUserButton when user is logged in', () => {
mockData.isLoggedIn = true
const { container } = createSidebarTabsWrapper()
expect(container.querySelector('current-user-button-stub')).not.toBeNull()
expect(container.querySelector('login-button-stub')).toBeNull()
describe('when user is logged in', () => {
beforeEach(() => {
mockData.isLoggedIn = true
})
it('should display CurrentUserButton and not display LoginButton', () => {
const { container } = createLegacyTabBarWrapper()
expect(
container.querySelector('current-user-button-stub')
).not.toBeNull()
expect(container.querySelector('login-button-stub')).toBeNull()
})
})
it('should display LoginButton when user is not logged in on desktop', () => {
mockData.isLoggedIn = false
mockData.isDesktop = true
const { container } = createSidebarTabsWrapper()
expect(container.querySelector('login-button-stub')).not.toBeNull()
expect(container.querySelector('current-user-button-stub')).toBeNull()
})
describe('when user is not logged in', () => {
beforeEach(() => {
mockData.isLoggedIn = false
})
it('should display CurrentUserButton when user is logged out on cloud', () => {
mockData.isLoggedIn = false
mockData.isCloud = true
const { container } = createSidebarTabsWrapper()
expect(container.querySelector('current-user-button-stub')).not.toBeNull()
expect(container.querySelector('login-button-stub')).toBeNull()
describe('on desktop platform', () => {
it('should display LoginButton and not display CurrentUserButton', () => {
mockData.isDesktop = true
const { container } = createLegacyTabBarWrapper()
expect(container.querySelector('login-button-stub')).not.toBeNull()
expect(container.querySelector('current-user-button-stub')).toBeNull()
})
})
describe('on web platform', () => {
it('should not display CurrentUserButton and not display LoginButton', () => {
const { container } = createLegacyTabBarWrapper()
expect(container.querySelector('current-user-button-stub')).toBeNull()
expect(container.querySelector('login-button-stub')).toBeNull()
})
})
})
})
@@ -547,7 +557,7 @@ describe('TopMenuSection', () => {
const settingStore = useSettingStore(pinia)
vi.mocked(settingStore.get).mockImplementation((key) => {
if (key === 'Comfy.UseNewMenu') return 'Top'
if (key === 'Comfy.UI.TabBarLayout') return 'Default'
if (key === 'Comfy.UI.TabBarLayout') return 'Integrated'
if (key === 'Comfy.RightSidePanel.IsOpen') return true
return undefined
})

View File

@@ -49,12 +49,10 @@
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
v-if="showCurrentUser && !isWorkflowTabsInTopbar"
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton
v-else-if="showLoginButton && !isWorkflowTabsInTopbar"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.bottom="shareTooltipConfig"
@@ -191,13 +189,6 @@ const isActionbarEnabled = computed(
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
const isWorkflowTabsInTopbar = computed(
() => settingStore.get('Comfy.Workflow.WorkflowTabsPosition') === 'Topbar'
)
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
const showLoginButton = computed(
() => !showCurrentUser.value && (flags.showSignInButton ?? isDesktop)
)
/**
* Whether the actionbar container has any visible docked buttons
* (excluding ComfyActionbar, which uses position:fixed when floating
@@ -206,8 +197,8 @@ const showLoginButton = computed(
const hasDockedButtons = computed(() => {
if (actionBarButtonStore.buttons.length > 0) return true
if (hasLegacyContent.value) return true
if (showCurrentUser.value && !isWorkflowTabsInTopbar.value) return true
if (showLoginButton.value && !isWorkflowTabsInTopbar.value) return true
if (isLoggedIn.value && !isIntegratedTabBar.value) return true
if (isDesktop && !isIntegratedTabBar.value) return true
if (isCloud && flags.workflowSharingEnabled) return true
if (!isRightSidePanelOpen.value) return true
return false
@@ -230,6 +221,9 @@ const actionbarContainerClass = computed(() => {
return cn(base, 'px-2', 'border-interface-stroke')
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()
const isQueueProgressOverlayEnabled = computed(

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

@@ -14,6 +14,8 @@ const distribution = vi.hoisted(() => ({
isNightly: false
}))
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
@@ -26,6 +28,13 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
key === 'Comfy.UI.TabBarLayout' ? tabBarLayout.value : undefined
})
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ isLoggedIn: { value: false } })
}))
@@ -125,6 +134,7 @@ describe('WorkflowTabs feedback button', () => {
distribution.isCloud = false
distribution.isDesktop = false
distribution.isNightly = false
tabBarLayout.value = 'Default'
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
})
@@ -164,4 +174,13 @@ describe('WorkflowTabs feedback button', () => {
screen.queryByRole('button', { name: 'Feedback' })
).not.toBeInTheDocument()
})
it('does not render the feedback button when the legacy tab bar is active', () => {
distribution.isCloud = true
tabBarLayout.value = 'Legacy'
renderComponent()
expect(
screen.queryByRole('button', { name: 'Feedback' })
).not.toBeInTheDocument()
})
})

View File

@@ -79,7 +79,11 @@
>
<i class="pi pi-plus" />
</Button>
<div class="ml-auto flex shrink-0 items-center gap-2 px-2">
<div
v-if="isIntegratedTabBar"
data-testid="integrated-tab-bar-actions"
class="ml-auto flex shrink-0 items-center gap-2 px-2"
>
<Button
v-if="isCloud || isNightly"
v-tooltip="{ value: $t('actionbar.feedbackTooltip'), showDelay: 300 }"
@@ -114,6 +118,7 @@ import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
@@ -134,6 +139,7 @@ const props = defineProps<{
class?: string
}>()
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
@@ -141,6 +147,9 @@ const commandStore = useCommandStore()
const { isLoggedIn } = useCurrentUser()
const { flags } = useFeatureFlags()
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
function openFeedback() {

View File

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

View File

@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ActionBarButton } from '@/types/comfy'
const distribution = vi.hoisted(() => ({ isCloud: false, isNightly: false }))
const tabBarLayout = vi.hoisted(() => ({ value: 'Default' }))
const registerExtension = vi.hoisted(() => vi.fn())
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
key === 'Comfy.UI.TabBarLayout' ? tabBarLayout.value : undefined
})
}))
vi.mock('@/services/extensionService', () => ({
useExtensionService: () => ({
registerExtension
})
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
},
get isNightly() {
return distribution.isNightly
}
}))
describe('cloudFeedbackTopbarButton', () => {
let openSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.resetModules()
registerExtension.mockReset()
distribution.isCloud = false
distribution.isNightly = false
openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
})
afterEach(() => {
openSpy.mockRestore()
})
function getRegisteredButtons(): ActionBarButton[] {
expect(registerExtension).toHaveBeenCalledTimes(1)
const extension = registerExtension.mock.calls[0]?.[0] as {
actionBarButtons: ActionBarButton[]
}
return extension.actionBarButtons
}
it('opens the Typeform survey tagged with action-bar source on Cloud', async () => {
tabBarLayout.value = 'Legacy'
distribution.isCloud = true
await import('./cloudFeedbackTopbarButton')
const buttons = getRegisteredButtons()
expect(buttons).toHaveLength(1)
buttons[0].onClick?.()
expect(openSpy).toHaveBeenCalledTimes(1)
const [url, target, features] = openSpy.mock.calls[0]
expect(url).toBe(
'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=action-bar'
)
expect(target).toBe('_blank')
expect(features).toBe('noopener,noreferrer')
})
it('only registers the action bar button when the tab bar is Legacy', async () => {
tabBarLayout.value = 'Default'
await import('./cloudFeedbackTopbarButton')
expect(getRegisteredButtons()).toEqual([])
})
})

View File

@@ -0,0 +1,29 @@
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackTypeformUrl } from '@/platform/support/config'
import { useExtensionService } from '@/services/extensionService'
import type { ActionBarButton } from '@/types/comfy'
const buttons: ActionBarButton[] = [
{
icon: 'icon-[lucide--message-square-text]',
label: t('actionbar.feedback'),
tooltip: t('actionbar.feedbackTooltip'),
onClick: () => {
window.open(
buildFeedbackTypeformUrl('action-bar'),
'_blank',
'noopener,noreferrer'
)
}
}
]
useExtensionService().registerExtension({
name: 'Comfy.FeedbackButton',
get actionBarButtons() {
return useSettingStore().get('Comfy.UI.TabBarLayout') === 'Legacy'
? buttons
: []
}
})

View File

@@ -43,6 +43,11 @@ if (isCloud) {
}
}
// Feedback button for cloud and nightly builds
if (isCloud || isNightly) {
await import('./cloudFeedbackTopbarButton')
}
// Nightly-only extensions
if (isNightly && !isCloud) {
await import('./nightlyBadges')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -584,9 +584,12 @@ export const CORE_SETTINGS: SettingParams[] = [
id: 'Comfy.UI.TabBarLayout',
category: ['Appearance', 'General'],
name: 'Tab Bar Layout',
type: 'hidden',
type: 'combo',
options: ['Default', 'Legacy'],
tooltip: 'Controls the elements contained in the integrated tab bar.',
defaultValue: 'Default',
migrateDeprecatedValue: () => 'Default'
migrateDeprecatedValue: (value: unknown) =>
value === 'Integrated' ? 'Default' : value
},
{
id: 'Comfy.Appearance.DisableAnimations',

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

@@ -301,7 +301,7 @@ const zSettings = z.object({
'Comfy.ConfirmClear': z.boolean(),
'Comfy.DevMode': z.boolean(),
'Comfy.Appearance.DisableAnimations': z.boolean(),
'Comfy.UI.TabBarLayout': z.literal('Default'),
'Comfy.UI.TabBarLayout': z.enum(['Default', 'Legacy']),
'Comfy.Workflow.ShowMissingModelsWarning': z.boolean(),
'Comfy.Workflow.WarnBlueprintOverwrite': z.boolean(),
'Comfy.Desktop.CloudNotificationShown': z.boolean(),

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 {
@@ -946,8 +947,6 @@ export class ComfyApp {
}
)
this.rootGraph.start()
// Ensure the canvas fills the window
useResizeObserver(this.canvasElRef, ([canvasEl]) => {
if (canvasEl.target instanceof HTMLCanvasElement) {
@@ -1091,7 +1090,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 +1801,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 +1832,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

@@ -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": {}
}