Compare commits

..

3 Commits

Author SHA1 Message Date
glary-bot
1a1c0de7ff feat(telemetry): capture Rewardful referral on checkout attribution
Mirrors the existing Impact wiring for the new Rewardful affiliate
network: client reads window.Rewardful.referral when getCheckoutAttribution
runs, and emits it as a new optional rewardful_referral field on
CheckoutAttributionMetadata. The Go backend (comfy-api) consumes this
field separately and passes it to Stripe as ClientReferenceID on the
CheckoutSession create call — that wiring lives in a sibling PR on
Comfy-Org/comfy-api and is the path that actually credits affiliate
commissions for Stripe subscriptions (per Rewardful docs, GTM-loaded JS
alone cannot attribute Checkout Sessions; the merchant must pass the
referral UUID server-side).

Why this is the simplest possible client-side change:

- Rewardful's JS (loaded via GTM) owns its own cookie persistence, so
  unlike Impact (where we capture im_ref from URL params and persist
  to localStorage ourselves) we just read window.Rewardful.referral at
  checkout time. No URL fallback, no localStorage handling. If
  Rewardful's script hasn't loaded or the user didn't come from an
  affiliate link, the field is omitted from the payload entirely.
- Adds a narrow RewardfulGlobal interface to global.d.ts (referral plus
  optional affiliate/campaign metadata Rewardful exposes) so window.Rewardful
  is typed everywhere.
- Adds 4 unit tests covering: present, absent, empty-string, and
  alongside Impact attribution. The existing 10 Impact/UTM tests are
  untouched.

Verified (locally on the workspace clone):
- pnpm typecheck — clean
- pnpm test:unit src/platform/telemetry/utils — 14/14 (10 prior + 4 new)
- pnpm test:unit (full repo) — passing
- pnpm lint — 3 warnings, 0 errors (warnings pre-existing on main)
- pnpm format:check — clean
- pnpm knip — clean (1 pre-existing warning unrelated)
- pnpm exec vite build — successful (7.85s)

Cross-PR dependency: needs the sibling comfy-api PR to actually credit
referrals. This PR is safe to ship independently — the field is just
ignored by the existing comfy-api endpoint until that PR lands.
2026-05-16 09:10:05 +00:00
Csongor Czezar
7160a9ee3f fix: QPO progress bar now shows node name in subgraphs (#7688)
## Summary

Resolve the queue progress node label from queued prompt metadata so
subgraph execution IDs show the correct node name without depending on
the live canvas.

## Changes

- **What**: Store a prompt-scoped `executionId -> { title, type }`
lookup from `p.output` when queueing a job, and use that lookup for the
active job's executing node label.
- **What**: Reuse the same job-scoped node info for the browser tab
title so it stays aligned with the queue overlay.
- **What**: Add unit coverage for root and subgraph execution IDs, and
merge the branch forward to current `main`.

## Review Focus

This keeps the fix scoped to the existing singular `activeJobId` path.
It fixes subgraph labels and avoids the workflow-switching regression
from resolving against `app.rootGraph`, but it does not redesign
concurrent multi-job selection yet.

Longer term, the cleaner solution is still prompt-scoped execution
metadata from the backend rather than frontend reconstruction.

## Screenshots (if applicable)

N/A

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-05-15 14:00:33 -07:00
Rizumu Ayaka
71092b2011 fix: stop trackpad pinch/swipe gestures from breaking the UI (#12052)
## Summary

On macOS trackpads, several browser default gestures were leaking
through and breaking the workflow:

- **Pinch-zoom on a focused textarea widget** triggered page-level zoom,
pushing `position: fixed` UI (notably `ComfyActionbar`) off-screen until
a reload.
- **Horizontal swipe on a focused textarea widget** triggered browser
back/forward, leaving the workflow.
- **Pinch / horizontal swipe inside the image picker dropdown** had the
same two issues, because PrimeVue `Popover` teleports content to
`document.body` and the `LGraphNode` wheel handler never sees the
events.

Fixes FE-292.

## Why

- **`overscroll-behavior: none` on `html, body`** — horizontal swipe to
back/forward is decided by the browser at gesture start; JS
preventDefault can't reliably beat it. `overscroll-behavior` is the
standards-track signal for opting out, and ComfyUI is a full-screen
editor that never benefits from native overscroll.
- **`useCanvasInteractions` now treats pinch-zoom and
horizontal-dominant wheel as canvas gestures** that override widget
wheel consumption, so the gesture pans/zooms the canvas instead of
falling through to destructive browser defaults. The check is exported
as `isCanvasGestureWheel` for reuse.
- **`FormDropdownMenu` has its own minimal `onWheel`** that only
preventDefaults destructive gestures and deliberately does not forward
to the canvas. The dropdown is its own scroll container and shouldn't
leak interactions into the editor; vertical scrolling stays native.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12052-fix-stop-trackpad-pinch-swipe-gestures-from-breaking-the-UI-3596d73d3650810aac3fcd8a71b29f9e)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-15 14:35:54 +00:00
29 changed files with 494 additions and 962 deletions

View File

@@ -51,7 +51,6 @@ jobs:
- name: Build website
env:
WEBSITE_GITHUB_STARS_OVERRIDE: 110000
WEBSITE_CLOUD_NODES_FIXTURE: e2e/fixtures/cloud-nodes.fixture.json
run: pnpm --filter @comfyorg/website build
- name: Run Playwright tests

View File

@@ -1,12 +1,12 @@
<template>
<tr
class="border-neutral-700 border-solid border-y"
class="border-y border-solid border-neutral-700"
:class="{
'opacity-50': runner.resolved,
'opacity-75': isLoading && runner.resolved
}"
>
<td class="text-center w-16">
<td class="w-16 text-center">
<TaskListStatusIcon :state="runner.state" :loading="isLoading" />
</td>
<td>
@@ -14,7 +14,7 @@
{{ task.name }}
</p>
<Button
class="inline-block mx-2"
class="mx-2 inline-block"
type="button"
:icon="PrimeIcons.INFO_CIRCLE"
severity="secondary"
@@ -22,11 +22,11 @@
@click="toggle"
/>
<Popover ref="infoPopover" class="block m-1 max-w-64 min-w-32">
<Popover ref="infoPopover" class="m-1 block max-w-64 min-w-32">
<span class="whitespace-pre-line">{{ task.description }}</span>
</Popover>
</td>
<td class="text-right px-4">
<td class="px-4 text-right">
<Button
:icon="task.button?.icon"
:label="task.button?.text"

View File

@@ -1,156 +0,0 @@
{
"fetchedAt": "2026-01-01T00:00:00.000Z",
"packs": [
{
"id": "comfyui-impact-pack",
"registryId": "comfyui-impact-pack",
"displayName": "ComfyUI Impact Pack",
"description": "Production-grade detailer, detector, and SEG (segmentation) tooling. The most-used pack for face restoration, region-based refinement, and iterative upscaling on Comfy Cloud.",
"bannerUrl": "https://media.comfy.org/cloud-nodes/comfyui-impact-pack-banner.webp",
"iconUrl": "https://media.comfy.org/cloud-nodes/comfyui-impact-pack-icon.webp",
"repoUrl": "https://github.com/ltdrdata/ComfyUI-Impact-Pack",
"publisher": {
"id": "drltdata",
"name": "Dr.Lt.Data"
},
"downloads": 2618646,
"githubStars": 3092,
"latestVersion": "8.28.3",
"license": "GPL-3.0",
"lastUpdated": "2026-04-19T17:08:04.993918Z",
"nodes": [
{
"name": "FaceDetailer",
"displayName": "FaceDetailer",
"category": "ImpactPack/Detailer",
"description": "Detect and refine faces with iterative passes."
},
{
"name": "DetailerForEach",
"displayName": "DetailerForEach",
"category": "ImpactPack/Detailer",
"description": "Run iterative detail refinement over detected SEG regions."
},
{
"name": "UltralyticsDetectorProvider",
"displayName": "UltralyticsDetectorProvider",
"category": "ImpactPack/Detector",
"description": "Provide detector models powered by Ultralytics YOLO."
}
]
},
{
"id": "comfyui-crystools",
"registryId": "ComfyUI-Crystools",
"displayName": "ComfyUI-Crystools",
"description": "Live system monitoring (GPU, RAM, disk) and rich image inspection inside your workflow.",
"bannerUrl": "https://media.comfy.org/cloud-nodes/comfyui-crystools-banner.webp",
"iconUrl": "https://media.comfy.org/cloud-nodes/comfyui-crystools-icon.webp",
"repoUrl": "https://github.com/crystian/ComfyUI-Crystools",
"publisher": {
"id": "crystian",
"name": "Crystian"
},
"downloads": 1671447,
"githubStars": 1855,
"latestVersion": "1.27.4",
"license": "MIT",
"lastUpdated": "2025-10-26T19:11:09.943366Z",
"nodes": [
{
"name": "CCrystools_Show_Resources",
"displayName": "CCrystools_Show_Resources",
"category": "crystools/show",
"description": "Display GPU, RAM and disk usage live in the workflow."
},
{
"name": "CCrystools_Show_Image",
"displayName": "CCrystools_Show_Image",
"category": "crystools/show",
"description": "Inspect images at full resolution with metadata overlays."
}
]
},
{
"id": "alpha-test-pack",
"registryId": "alpha-test-pack",
"displayName": "Alpha Test Pack",
"description": "Deterministic fixture pack used to anchor the A\u2192Z sort assertion in Playwright. Its display name starts with 'A' so it surfaces first when sorted alphabetically.",
"bannerUrl": "https://media.comfy.org/cloud-nodes/alpha-test-pack-banner.webp",
"repoUrl": "https://github.com/Comfy-Org/alpha-test-pack",
"publisher": {
"id": "comfy-org",
"name": "Comfy Org"
},
"downloads": 42,
"githubStars": 7,
"latestVersion": "0.1.0",
"license": "MIT",
"lastUpdated": "2026-01-01T00:00:00.000Z",
"nodes": [
{
"name": "AlphaProbe",
"displayName": "AlphaProbe",
"category": "alpha/test",
"description": "Deterministic node used to verify alphabetical ordering."
}
]
},
{
"id": "rgthree-comfy",
"registryId": "rgthree-comfy",
"displayName": "rgthree-comfy",
"description": "Quality-of-life nodes from rgthree: Power Lora Loader, Display Any, Image Comparer, and more.",
"bannerUrl": "https://media.comfy.org/cloud-nodes/rgthree-comfy-banner.webp",
"repoUrl": "https://github.com/rgthree/rgthree-comfy",
"publisher": {
"id": "rgthree",
"name": "rgthree"
},
"downloads": 987654,
"githubStars": 1500,
"latestVersion": "2.0.0",
"license": "MIT",
"lastUpdated": "2026-03-15T00:00:00.000Z",
"nodes": [
{
"name": "PowerLoraLoader",
"displayName": "Power Lora Loader",
"category": "rgthree/loaders",
"description": "Load multiple LoRAs at once with strength control."
},
{
"name": "DisplayAny",
"displayName": "Display Any",
"category": "rgthree/display",
"description": "Display the value of any wire for debugging."
}
]
},
{
"id": "was-node-suite-comfyui",
"registryId": "was-node-suite-comfyui",
"displayName": "WAS Node Suite",
"description": "Large collection of utility nodes for image processing, text manipulation, and workflow control.",
"bannerUrl": "https://media.comfy.org/cloud-nodes/was-node-suite-banner.webp",
"repoUrl": "https://github.com/WASasquatch/was-node-suite-comfyui",
"publisher": {
"id": "wasasquatch",
"name": "WASasquatch"
},
"downloads": 1234567,
"githubStars": 1700,
"latestVersion": "1.0.0",
"license": "MIT",
"lastUpdated": "2026-02-01T00:00:00.000Z",
"nodes": [
{
"name": "WASImageBlend",
"displayName": "Image Blend",
"category": "WAS/image",
"description": "Blend two images using a configurable mode."
}
]
}
]
}

View File

@@ -50,7 +50,7 @@
]
},
{
"id": "comfyui-crystools",
"id": "ComfyUI-Crystools",
"registryId": "ComfyUI-Crystools",
"displayName": "ComfyUI-Crystools",
"description": "Live system monitoring (GPU, RAM, disk) and rich image inspection inside your workflow. The most-installed quality-of-life pack on the registry.",
@@ -201,7 +201,7 @@
"id": "comfyui-easy-use",
"registryId": "comfyui-easy-use",
"displayName": "ComfyUI-Easy-Use",
"description": "Simplified, opinionated nodes that bundle common patterns into single drop-ins \u2014 full loader, pre-sampling, easy KSampler, and XY plotting.",
"description": "Simplified, opinionated nodes that bundle common patterns into single drop-ins full loader, pre-sampling, easy KSampler, and XY plotting.",
"iconUrl": "https://mintlify.s3.us-west-1.amazonaws.com/yolain/images/logo.svg",
"repoUrl": "https://github.com/yolain/ComfyUI-Easy-Use",
"publisher": {
@@ -250,7 +250,7 @@
"id": "comfyui-advanced-controlnet",
"registryId": "comfyui-advanced-controlnet",
"displayName": "ComfyUI-Advanced-ControlNet",
"description": "ControlNet with timestep keyframes, per-frame masks, and advanced strength scheduling \u2014 essential for animation and batched-latent workflows.",
"description": "ControlNet with timestep keyframes, per-frame masks, and advanced strength scheduling essential for animation and batched-latent workflows.",
"repoUrl": "https://github.com/Kosinkadink/ComfyUI-Advanced-ControlNet",
"publisher": {
"id": "kosinkadink",
@@ -298,7 +298,7 @@
"id": "was-node-suite-comfyui",
"registryId": "was-node-suite-comfyui",
"displayName": "WAS Node Suite",
"description": "A broad utility suite covering image adjustments, compositing, text, math, and I/O \u2014 the original \"kitchen sink\" pack still relied on by thousands of workflows.",
"description": "A broad utility suite covering image adjustments, compositing, text, math, and I/O the original \"kitchen sink\" pack still relied on by thousands of workflows.",
"repoUrl": "https://github.com/WASasquatch/was-node-suite-comfyui",
"publisher": {
"id": "was",
@@ -343,10 +343,10 @@
]
},
{
"id": "comfyui-ipadapter-plus",
"id": "comfyui_ipadapter_plus",
"registryId": "comfyui_ipadapter_plus",
"displayName": "ComfyUI_IPAdapter_plus",
"description": "Reference-image conditioning with IPAdapter \u2014 style transfer, Face ID, and multi-image embeddings. The most-installed conditioning pack on the registry, used in countless portrait, product, and animation workflows.",
"description": "Reference-image conditioning with IPAdapter style transfer, Face ID, and multi-image embeddings. The most-installed conditioning pack on the registry, used in countless portrait, product, and animation workflows.",
"repoUrl": "https://github.com/cubiq/ComfyUI_IPAdapter_plus",
"publisher": {
"id": "matteo",

View File

@@ -4,9 +4,7 @@ import type { FetchOutcome } from './cloudNodes'
import type { NodesSnapshot } from '../data/cloudNodes'
const fetchCloudNodesMock = vi.hoisted(() =>
vi.fn<
(options?: { snapshotUrl?: URL; apiKey?: string }) => Promise<FetchOutcome>
>()
vi.fn<() => Promise<FetchOutcome>>()
)
const reportCloudNodesOutcomeMock = vi.hoisted(() => vi.fn())
@@ -35,26 +33,19 @@ const SNAPSHOT: NodesSnapshot = {
describe('loadPacksForBuild', () => {
const savedVercelEnv = process.env.VERCEL_ENV
const savedFixture = process.env.WEBSITE_CLOUD_NODES_FIXTURE
beforeEach(() => {
fetchCloudNodesMock.mockReset()
reportCloudNodesOutcomeMock.mockReset()
delete process.env.VERCEL_ENV
delete process.env.WEBSITE_CLOUD_NODES_FIXTURE
})
afterEach(() => {
if (savedVercelEnv === undefined) {
delete process.env.VERCEL_ENV
} else {
process.env.VERCEL_ENV = savedVercelEnv
}
if (savedFixture === undefined) {
delete process.env.WEBSITE_CLOUD_NODES_FIXTURE
} else {
process.env.WEBSITE_CLOUD_NODES_FIXTURE = savedFixture
return
}
process.env.VERCEL_ENV = savedVercelEnv
})
it('returns packs when fetch is fresh', async () => {
@@ -134,50 +125,4 @@ describe('loadPacksForBuild', () => {
await expect(loadPacksForBuild()).rejects.toThrow()
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
})
it('forwards WEBSITE_CLOUD_NODES_FIXTURE as snapshotUrl with an empty api key', async () => {
process.env.WEBSITE_CLOUD_NODES_FIXTURE =
'e2e/fixtures/cloud-nodes.fixture.json'
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'missing WEBSITE_CLOUD_API_KEY'
})
await loadPacksForBuild()
const call = fetchCloudNodesMock.mock.calls[0]?.[0]
expect(call?.snapshotUrl).toBeInstanceOf(URL)
expect(call?.snapshotUrl?.protocol).toBe('file:')
expect(call?.snapshotUrl?.pathname).toMatch(
/apps\/website\/e2e\/fixtures\/cloud-nodes\.fixture\.json$/
)
expect(call?.apiKey).toBe('')
})
it('accepts an absolute path for WEBSITE_CLOUD_NODES_FIXTURE', async () => {
process.env.WEBSITE_CLOUD_NODES_FIXTURE = '/etc/cloud-nodes.fixture.json'
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'missing WEBSITE_CLOUD_API_KEY'
})
await loadPacksForBuild()
const call = fetchCloudNodesMock.mock.calls[0]?.[0]
expect(call?.snapshotUrl?.pathname).toBe('/etc/cloud-nodes.fixture.json')
})
it('does not throw on stale-in-production when the fixture override is set', async () => {
process.env.VERCEL_ENV = 'production'
process.env.WEBSITE_CLOUD_NODES_FIXTURE =
'e2e/fixtures/cloud-nodes.fixture.json'
fetchCloudNodesMock.mockResolvedValue({
status: 'stale',
snapshot: SNAPSHOT,
reason: 'missing WEBSITE_CLOUD_API_KEY'
})
const packs = await loadPacksForBuild()
expect(packs).toBe(SNAPSHOT.packs)
})
})

View File

@@ -1,6 +1,3 @@
import { isAbsolute, resolve as resolvePath } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import type { Pack } from '../data/cloudNodes'
import { fetchCloudNodesForBuild } from './cloudNodes'
@@ -10,42 +7,25 @@ const REFRESH_HINT =
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot, ' +
'or re-run the `Release: Website` workflow with a valid WEBSITE_CLOUD_API_KEY.'
const WEBSITE_PACKAGE_ROOT = fileURLToPath(new URL('../..', import.meta.url))
/**
* Determine whether the current build is a production Vercel deployment.
*
* @returns `true` if `process.env.VERCEL_ENV` is exactly `'production'`, `false` otherwise.
*/
function isProductionBuild(): boolean {
return process.env.VERCEL_ENV === 'production'
}
/**
* Produces a file:// URL pointing to a local fixture snapshot when WEBSITE_CLOUD_NODES_FIXTURE is set.
*
* @returns A `URL` for the resolved fixture path, or `undefined` if the environment variable is not set.
*/
function fixtureSnapshotUrl(): URL | undefined {
const fixturePath = process.env.WEBSITE_CLOUD_NODES_FIXTURE
if (!fixturePath) return undefined
const absolute = isAbsolute(fixturePath)
? fixturePath
: resolvePath(WEBSITE_PACKAGE_ROOT, fixturePath)
return pathToFileURL(absolute)
}
/**
* Resolve the list of packs to render at build time.
*
* The same resolved snapshot is used to derive both the site index and per-pack detail routes so static pages share a single source of truth. In production builds a stale snapshot causes the build to fail unless a local fixture override is provided via the `WEBSITE_CLOUD_NODES_FIXTURE` environment variable, which forces use of the on-disk snapshot instead of the live cloud API.
* Used by both the index page and the per-pack detail pages so that the
* static index and the static detail routes are always derived from the
* same source. `fetchCloudNodesForBuild` is memoized on a module-level
* `inflight` promise, so repeated calls in the same build process share a
* single network round-trip and the same outcome.
*
* @returns The array of `Pack` objects from the resolved snapshot to render at build time.
* Production builds (VERCEL_ENV=production) fail hard on a stale outcome
* to prevent silently shipping out-of-date snapshot data. Preview and
* local builds continue to use the committed snapshot.
*/
export async function loadPacksForBuild(): Promise<Pack[]> {
const snapshotUrl = fixtureSnapshotUrl()
const options = snapshotUrl ? { snapshotUrl, apiKey: '' } : {}
const outcome = await fetchCloudNodesForBuild(options)
const outcome = await fetchCloudNodesForBuild()
reportCloudNodesOutcome(outcome)
if (outcome.status === 'failed') {
@@ -54,7 +34,7 @@ export async function loadPacksForBuild(): Promise<Pack[]> {
)
}
if (outcome.status === 'stale' && isProductionBuild() && !snapshotUrl) {
if (outcome.status === 'stale' && isProductionBuild()) {
throw new Error(
`Cloud nodes fetch returned stale data in a production build (VERCEL_ENV=production). ` +
`Reason: ${outcome.reason}. ${REFRESH_HINT}`

View File

@@ -87,7 +87,6 @@ function withSnapshotDir(snapshot: NodesSnapshot | null): URL {
describe('fetchCloudNodesForBuild', () => {
const savedCloudApiKey = process.env.WEBSITE_CLOUD_API_KEY
const savedCloudNodesFixture = process.env.WEBSITE_CLOUD_NODES_FIXTURE
beforeEach(() => {
resetCloudNodesFetcherForTests()
@@ -95,21 +94,11 @@ describe('fetchCloudNodesForBuild', () => {
fetchRegistryPacksMock.mockResolvedValue(new Map())
sanitizeCallSpy.mockReset()
delete process.env.WEBSITE_CLOUD_API_KEY
delete process.env.WEBSITE_CLOUD_NODES_FIXTURE
})
afterEach(() => {
vi.restoreAllMocks()
if (savedCloudApiKey === undefined) {
delete process.env.WEBSITE_CLOUD_API_KEY
} else {
process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey
}
if (savedCloudNodesFixture === undefined) {
delete process.env.WEBSITE_CLOUD_NODES_FIXTURE
} else {
process.env.WEBSITE_CLOUD_NODES_FIXTURE = savedCloudNodesFixture
}
process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey
})
it('returns fresh when API succeeds', async () => {
@@ -317,335 +306,4 @@ describe('fetchCloudNodesForBuild', () => {
})
expect(outcome.status).toBe('fresh')
})
it('falls back to the raw upstream id for registryId when registry lookup misses', async () => {
fetchRegistryPacksMock.mockResolvedValue(new Map())
const fetchImpl = vi.fn(async () =>
response({
QwenNode: validNode({
name: 'QwenNode',
python_module: 'custom_nodes.ComfyUI_QwenVL.nodes'
})
})
)
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.packs[0]?.id).toBe('comfyui-qwenvl')
expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI_QwenVL')
})
it('falls back to the raw upstream id for registryId when fetchRegistryPacks throws', async () => {
fetchRegistryPacksMock.mockImplementation(async () => {
throw new Error('registry unreachable')
})
const fetchImpl = vi.fn(async () =>
response({
QwenNode: validNode({
name: 'QwenNode',
python_module: 'custom_nodes.ComfyUI_QwenVL.nodes'
})
})
)
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI_QwenVL')
})
it('slugifies pack ids while querying the registry with the raw id', async () => {
fetchRegistryPacksMock.mockResolvedValue(
new Map([
[
'ComfyUI_QwenVL',
{
id: 'ComfyUI_QwenVL',
name: 'ComfyUI QwenVL',
repository: 'https://github.com/example/ComfyUI_QwenVL'
}
]
])
)
const fetchImpl = vi.fn(async () =>
response({
QwenNode: validNode({
name: 'QwenNode',
python_module: 'custom_nodes.ComfyUI_QwenVL.nodes'
})
})
)
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.packs[0]?.id).toBe('comfyui-qwenvl')
expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI_QwenVL')
expect(fetchRegistryPacksMock).toHaveBeenCalledWith(
['ComfyUI_QwenVL'],
expect.anything()
)
})
it('queries every raw-id alias when packs collide on the same slug and picks the first hit', async () => {
fetchRegistryPacksMock.mockResolvedValue(
new Map<string, unknown>([
['ComfyUI-QwenVL', null],
[
'ComfyUI_QwenVL',
{
id: 'ComfyUI_QwenVL',
name: 'ComfyUI QwenVL',
repository: 'https://github.com/example/ComfyUI_QwenVL'
}
]
])
)
const fetchImpl = vi.fn(async () =>
response({
QwenDash: validNode({
name: 'QwenDash',
python_module: 'custom_nodes.ComfyUI-QwenVL.nodes'
}),
QwenUnder: validNode({
name: 'QwenUnder',
python_module: 'custom_nodes.ComfyUI_QwenVL.nodes'
})
})
)
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.packs).toHaveLength(1)
expect(outcome.snapshot.packs[0]?.id).toBe('comfyui-qwenvl')
expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI_QwenVL')
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
'https://github.com/example/ComfyUI_QwenVL'
)
expect(fetchRegistryPacksMock).toHaveBeenCalledWith(
['ComfyUI-QwenVL', 'ComfyUI_QwenVL'],
expect.anything()
)
})
it('prefers the first non-null registry result when every alias resolves', async () => {
fetchRegistryPacksMock.mockResolvedValue(
new Map<string, unknown>([
[
'ComfyUI-QwenVL',
{
id: 'ComfyUI-QwenVL',
name: 'Dash Variant',
repository: 'https://github.com/example/dash-first'
}
],
[
'ComfyUI_QwenVL',
{
id: 'ComfyUI_QwenVL',
name: 'Underscore Variant',
repository: 'https://github.com/example/underscore-second'
}
]
])
)
const fetchImpl = vi.fn(async () =>
response({
QwenDash: validNode({
name: 'QwenDash',
python_module: 'custom_nodes.ComfyUI-QwenVL.nodes'
}),
QwenUnder: validNode({
name: 'QwenUnder',
python_module: 'custom_nodes.ComfyUI_QwenVL.nodes'
})
})
)
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.packs).toHaveLength(1)
expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI-QwenVL')
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
'https://github.com/example/dash-first'
)
expect(fetchRegistryPacksMock).toHaveBeenCalledWith(
['ComfyUI-QwenVL', 'ComfyUI_QwenVL'],
expect.anything()
)
})
it('normalizes pack ids when reading a fallback snapshot', async () => {
const snapshotUrl = withSnapshotDir({
fetchedAt: '2026-04-01T00:00:00.000Z',
packs: [
{
id: 'ComfyUI-Crystools',
displayName: 'ComfyUI-Crystools',
nodes: [
{
name: 'CrystoolsNode',
displayName: 'Crystools Node',
category: 'x'
}
]
},
{
id: 'basic_data_handling',
displayName: 'basic_data_handling',
nodes: [
{ name: 'BasicNode', displayName: 'Basic Node', category: 'x' }
]
}
]
})
const outcome = await fetchCloudNodesForBuild({
snapshotUrl,
fetchImpl: vi.fn() as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.snapshot.packs.map((p) => p.id)).toEqual([
'comfyui-crystools',
'basic-data-handling'
])
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('merges packs in the fallback snapshot whose ids slugify to the same value', async () => {
const snapshotUrl = withSnapshotDir({
fetchedAt: '2026-04-01T00:00:00.000Z',
packs: [
{
id: 'ComfyUI-QwenVL',
displayName: 'ComfyUI QwenVL',
nodes: [{ name: 'A', displayName: 'A', category: 'x' }]
},
{
id: 'ComfyUI_QwenVL',
displayName: 'ComfyUI QwenVL',
nodes: [{ name: 'B', displayName: 'B', category: 'x' }]
}
]
})
const outcome = await fetchCloudNodesForBuild({
snapshotUrl,
fetchImpl: vi.fn() as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.snapshot.packs).toHaveLength(1)
expect(outcome.snapshot.packs[0]?.id).toBe('comfyui-qwenvl')
expect(outcome.snapshot.packs[0]?.nodes.map((n) => n.name).sort()).toEqual([
'A',
'B'
])
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('preserves optional metadata from later aliases when snapshot packs collide on slug', async () => {
const snapshotUrl = withSnapshotDir({
fetchedAt: '2026-04-01T00:00:00.000Z',
packs: [
{
id: 'ComfyUI-QwenVL',
displayName: 'ComfyUI QwenVL',
nodes: [{ name: 'A', displayName: 'A', category: 'x' }]
},
{
id: 'ComfyUI_QwenVL',
displayName: 'ComfyUI QwenVL',
registryId: 'ComfyUI_QwenVL',
description: 'rich description from the underscore variant',
repoUrl: 'https://github.com/example/ComfyUI_QwenVL',
publisher: { id: 'qwen-team', name: 'Qwen Team' },
downloads: 1234,
githubStars: 7,
license: 'MIT',
nodes: [{ name: 'B', displayName: 'B', category: 'x' }]
}
]
})
const outcome = await fetchCloudNodesForBuild({
snapshotUrl,
fetchImpl: vi.fn() as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
const merged = outcome.snapshot.packs[0]
expect(merged?.id).toBe('comfyui-qwenvl')
expect(merged?.registryId).toBe('ComfyUI_QwenVL')
expect(merged?.description).toBe(
'rich description from the underscore variant'
)
expect(merged?.repoUrl).toBe('https://github.com/example/ComfyUI_QwenVL')
expect(merged?.publisher).toEqual({ id: 'qwen-team', name: 'Qwen Team' })
expect(merged?.downloads).toBe(1234)
expect(merged?.githubStars).toBe(7)
expect(merged?.license).toBe('MIT')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('does not overwrite metadata already present on the first slug-collided pack', async () => {
const snapshotUrl = withSnapshotDir({
fetchedAt: '2026-04-01T00:00:00.000Z',
packs: [
{
id: 'ComfyUI-QwenVL',
displayName: 'first wins',
registryId: 'ComfyUI-QwenVL',
repoUrl: 'https://github.com/example/ComfyUI-QwenVL',
nodes: [{ name: 'A', displayName: 'A', category: 'x' }]
},
{
id: 'ComfyUI_QwenVL',
displayName: 'second loses',
registryId: 'ComfyUI_QwenVL',
repoUrl: 'https://github.com/example/ComfyUI_QwenVL',
nodes: [{ name: 'B', displayName: 'B', category: 'x' }]
}
]
})
const outcome = await fetchCloudNodesForBuild({
snapshotUrl,
fetchImpl: vi.fn() as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
const merged = outcome.snapshot.packs[0]
expect(merged?.displayName).toBe('first wins')
expect(merged?.registryId).toBe('ComfyUI-QwenVL')
expect(merged?.repoUrl).toBe('https://github.com/example/ComfyUI-QwenVL')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
})

View File

@@ -3,7 +3,6 @@ import { readFile } from 'node:fs/promises'
import {
groupNodesByPack,
sanitizeUserContent,
slugifyPackId,
validateComfyNodeDef
} from '@comfyorg/object-info-parser'
@@ -214,13 +213,6 @@ async function callOnce(
}
}
/**
* Parses and validates a raw cloud nodes envelope into domain packs, enriches packs with registry metadata when available, and collects validation failures.
*
* @param envelope - Raw payload object from the cloud API keyed by node class name containing node definitions to validate and parse.
* @param options - Fetch and behavior options used when resolving registry pack metadata (for example, `fetchImpl`).
* @returns The `'ok'` outcome containing `packs` (an array of domain `Pack` objects) and `droppedNodes` (an array of `{ name, reason }` entries for definitions that failed validation).
*/
async function parseCloudNodes(
envelope: Record<string, unknown>,
options: FetchCloudNodesOptions
@@ -245,12 +237,12 @@ async function parseCloudNodes(
)
const grouped = groupNodesByPack(sanitizedDefs)
const allAliases = grouped.flatMap((pack) => pack.rawIds)
let registryMap = new Map<string, RegistryPack | null>()
try {
registryMap = await fetchRegistryPacks(allAliases, {
fetchImpl: options.fetchImpl
})
registryMap = await fetchRegistryPacks(
grouped.map((pack) => pack.id),
{ fetchImpl: options.fetchImpl }
)
} catch {
registryMap = new Map()
}
@@ -258,40 +250,15 @@ async function parseCloudNodes(
const packs = grouped.map((pack) =>
toDomainPack(
pack.id,
pack.rawIds[0],
pack.displayName,
pack.nodes,
pickRegistryPack(registryMap, pack.rawIds)
registryMap.get(pack.id)
)
)
return { kind: 'ok', packs, droppedNodes }
}
/**
* Selects the most appropriate registry pack for a pack using its ordered aliases.
*
* @param registryMap - Map from alias to `RegistryPack` or explicit `null` indicating a known-but-empty entry
* @param aliases - Ordered aliases to probe; earlier aliases have higher priority
* @returns A `RegistryPack` if any alias maps to a non-null value; `null` if no alias had a non-null value but the first alias exists in the map with value `null`; `undefined` if the first alias is absent from the map
*/
function pickRegistryPack(
registryMap: Map<string, RegistryPack | null>,
aliases: readonly string[]
): RegistryPack | null | undefined {
for (const alias of aliases) {
const hit = registryMap.get(alias)
if (hit) return hit
}
return registryMap.get(aliases[0])
}
/**
* Validate and normalize an external URL string.
*
* @param value - The input URL string to validate; may be `undefined`.
* @returns The canonical `http` or `https` URL string if `value` is a valid absolute URL with a host, `undefined` otherwise.
*/
function safeExternalUrl(value: string | undefined): string | undefined {
if (!value) return undefined
try {
@@ -304,19 +271,8 @@ function safeExternalUrl(value: string | undefined): string | undefined {
}
}
/**
* Convert parsed pack data and optional registry metadata into a domain `Pack`.
*
* @param packId - The canonical identifier to use for the pack
* @param fallbackRegistryId - Registry id to use when `registryPack` does not provide one
* @param fallbackDisplayName - Display name to use when `registryPack` does not provide a name
* @param nodes - Array of node entries containing the class name and validated node definition
* @param registryPack - Optional registry metadata for enriching pack fields; may be `null` or `undefined`
* @returns A `Pack` with normalized fields, safe external URLs, optional publisher info, registry-derived metadata when available, and nodes converted to `PackNode` objects sorted by display name
*/
function toDomainPack(
packId: string,
fallbackRegistryId: string | undefined,
fallbackDisplayName: string,
nodes: Array<{
className: string
@@ -332,7 +288,7 @@ function toDomainPack(
): Pack {
return {
id: packId,
registryId: registryPack?.id ?? fallbackRegistryId,
registryId: registryPack?.id,
displayName: registryPack?.name?.trim() || fallbackDisplayName || packId,
description: registryPack?.description?.trim() || undefined,
bannerUrl: safeExternalUrl(registryPack?.banner_url),
@@ -378,82 +334,22 @@ function toDomainNode(
}
}
/**
* Load and validate a nodes snapshot from a provided file URL or from the bundled snapshot, normalizing pack IDs.
*
* If `snapshotUrl` is provided, reads the file, parses JSON, and returns the snapshot after `isNodesSnapshot` validation and `normalizeSnapshotIds` normalization.
* If `snapshotUrl` is omitted, validates and returns the bundled snapshot after normalization.
* Returns `null` if reading, parsing, or validation fails.
*
* @param snapshotUrl - Optional file `URL` pointing to a snapshot JSON; when omitted the bundled snapshot is used
* @returns The normalized `NodesSnapshot` if available and valid, `null` otherwise
*/
async function readSnapshot(
snapshotUrl: URL | undefined
): Promise<NodesSnapshot | null> {
if (!snapshotUrl) {
return isNodesSnapshot(bundledSnapshot)
? normalizeSnapshotIds(bundledSnapshot)
: null
return isNodesSnapshot(bundledSnapshot) ? bundledSnapshot : null
}
try {
const text = await readFile(snapshotUrl, 'utf8')
const parsed: unknown = JSON.parse(text)
if (isNodesSnapshot(parsed)) return normalizeSnapshotIds(parsed)
if (isNodesSnapshot(parsed)) return parsed
return null
} catch {
return null
}
}
/**
* Normalize pack IDs by slugifying each pack's `id`, omitting packs with empty slugs, and merging packs that produce the same slug.
*
* The returned snapshot preserves the original snapshot fields but replaces `packs` with a list whose `id` values are the slugified IDs. When multiple packs map to the same slug, their nodes and non-nullish metadata are merged into a single pack.
*
* @param snapshot - The snapshot whose pack IDs should be normalized and deduplicated
* @returns A new `NodesSnapshot` with pack IDs replaced by slugs, colliding packs merged, and packs with falsy slugs removed
*/
function normalizeSnapshotIds(snapshot: NodesSnapshot): NodesSnapshot {
const bySlug = new Map<string, Pack>()
for (const pack of snapshot.packs) {
const slug = slugifyPackId(pack.id)
if (!slug) continue
const existing = bySlug.get(slug)
if (existing) {
bySlug.set(slug, mergeCollidedPacks(existing, pack))
continue
}
bySlug.set(slug, { ...pack, id: slug })
}
return { ...snapshot, packs: [...bySlug.values()] }
}
/**
* Merge two packs that represent the same logical pack by concatenating their nodes and filling any missing metadata from the later pack.
*
* @param first - The base pack whose values take precedence.
* @param next - The colliding pack whose `nodes` are appended and whose non-null, non-`id` fields supply values only when `first` has them missing.
* @returns A new `Pack` whose `nodes` are `first.nodes` followed by `next.nodes`, with other fields taken from `first` unless absent, in which case the corresponding value from `next` is used.
*/
function mergeCollidedPacks(first: Pack, next: Pack): Pack {
const merged: Pack = { ...first, nodes: [...first.nodes, ...next.nodes] }
for (const [key, value] of Object.entries(next) as [keyof Pack, unknown][]) {
if (key === 'id' || key === 'nodes') continue
if (value === undefined || value === null) continue
if (merged[key] === undefined || merged[key] === null) {
;(merged as Record<keyof Pack, unknown>)[key] = value
}
}
return merged
}
/**
* Pause execution for the specified duration.
*
* @param ms - Duration to wait in milliseconds
* @returns A promise that resolves with no value when the delay has elapsed
*/
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -1,7 +1,7 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
} from '@e2e/fixtures/ComfyPage'
test.describe('Preview as Text node', () => {
test('does not include preview widget values in the API prompt', async ({

7
global.d.ts vendored
View File

@@ -11,6 +11,12 @@ interface ImpactQueueFunction {
a?: unknown[][]
}
interface RewardfulGlobal {
referral?: string
affiliate?: { id?: string; token?: string; name?: string }
campaign?: { id?: string; name?: string }
}
type GtagGetFieldName = 'client_id' | 'session_id' | 'session_number'
interface GtagGetFieldValueMap {
@@ -63,6 +69,7 @@ interface Window {
gtag?: GtagFunction
ire_o?: string
ire?: ImpactQueueFunction
Rewardful?: RewardfulGlobal
}
interface Navigator {

View File

@@ -26,6 +26,10 @@
width: 100%;
height: 100%;
margin: 0;
/* Disable trackpad two-finger horizontal swipe back/forward navigation
and other overscroll gestures. ComfyUI is a full-screen editor; the
browser's overscroll behaviors only ever leave or break the workflow. */
overscroll-behavior: none;
}
body {
display: grid;

View File

@@ -51,67 +51,4 @@ describe('groupNodesByPack', () => {
grouped.find((pack) => pack.id === 'comfyui-controlnet-aux')?.nodes
).toHaveLength(1)
})
it('slugifies pack ids to lowercase, hyphen-only URL slugs', () => {
const grouped = groupNodesByPack({
A: makeNodeDef('A', 'custom_nodes.ComfyUI-Crystools.nodes'),
B: makeNodeDef('B', 'custom_nodes.basic_data_handling.nodes'),
C: makeNodeDef('C', 'custom_nodes.ComfyUI_yanc.nodes')
})
expect(grouped.map((pack) => pack.id)).toEqual([
'basic-data-handling',
'comfyui-crystools',
'comfyui-yanc'
])
})
it('preserves the raw upstream id for registry lookups', () => {
const grouped = groupNodesByPack({
A: makeNodeDef('A', 'custom_nodes.ComfyUI-Crystools.nodes'),
B: makeNodeDef('B', 'custom_nodes.basic_data_handling.nodes')
})
expect(grouped.find((pack) => pack.id === 'comfyui-crystools')?.rawId).toBe(
'ComfyUI-Crystools'
)
expect(
grouped.find((pack) => pack.id === 'basic-data-handling')?.rawId
).toBe('basic_data_handling')
})
it('merges packs whose raw ids slugify to the same URL slug', () => {
const grouped = groupNodesByPack({
QwenA: makeNodeDef('QwenA', 'custom_nodes.ComfyUI-QwenVL.nodes'),
QwenB: makeNodeDef('QwenB', 'custom_nodes.ComfyUI_QwenVL.nodes')
})
expect(grouped).toHaveLength(1)
expect(grouped[0].id).toBe('comfyui-qwenvl')
expect(grouped[0].nodes.map((n) => n.className).sort()).toEqual([
'QwenA',
'QwenB'
])
expect(grouped[0].rawIds).toEqual(['ComfyUI-QwenVL', 'ComfyUI_QwenVL'])
})
it('does not record duplicate aliases when the same raw id appears twice', () => {
const grouped = groupNodesByPack({
QwenA: makeNodeDef('QwenA', 'custom_nodes.ComfyUI-QwenVL.nodes'),
QwenB: makeNodeDef('QwenB', 'custom_nodes.ComfyUI-QwenVL.nodes')
})
expect(grouped).toHaveLength(1)
expect(grouped[0].rawIds).toEqual(['ComfyUI-QwenVL'])
})
it('strips version suffix before slugifying', () => {
const grouped = groupNodesByPack({
A: makeNodeDef('A', 'custom_nodes.ComfyUI_yanc@1_0_3.nodes')
})
expect(grouped).toHaveLength(1)
expect(grouped[0].id).toBe('comfyui-yanc')
expect(grouped[0].rawId).toBe('ComfyUI_yanc')
})
})

View File

@@ -1,52 +0,0 @@
import { describe, expect, it } from 'vitest'
import { slugifyPackId } from '../helpers/slugifyPackId'
describe('slugifyPackId', () => {
it.for([
['comfyui-impact-pack', 'comfyui-impact-pack'],
['ComfyUI-Crystools', 'comfyui-crystools'],
['comfyui_impact_pack', 'comfyui-impact-pack'],
['ComfyUI_QwenVL', 'comfyui-qwenvl'],
['basic_data_handling', 'basic-data-handling'],
['ComfyUI_Step1X-Edit', 'comfyui-step1x-edit'],
['HunyuanVideo_Foley', 'hunyuanvideo-foley']
])('slugifies %s -> %s', ([input, expected]) => {
expect(slugifyPackId(input)).toBe(expected)
})
it('collapses runs of hyphens introduced by adjacent separators', () => {
expect(slugifyPackId('a__b')).toBe('a-b')
expect(slugifyPackId('a-_b')).toBe('a-b')
expect(slugifyPackId('a___-_b')).toBe('a-b')
})
it('strips leading and trailing separators', () => {
expect(slugifyPackId('_pack_')).toBe('pack')
expect(slugifyPackId('-pack-')).toBe('pack')
expect(slugifyPackId('__a__')).toBe('a')
})
it('produces URL-slug-safe output for every registry id observed today', () => {
const samples = [
'ComfyUI-AniPortrait',
'comfyui_aniportrait',
'ComfyUI-API-Manager',
'ComfyUI_API_Manager',
'comfy-oiio',
'comfy_oiio',
'ComfyUI-FlashVSR_Ultra_Fast',
'comfyui-frame-interpolation',
'Qwen3_TTS',
'qwen3-tts'
]
for (const sample of samples) {
expect(slugifyPackId(sample)).toMatch(/^[a-z0-9-]+$/)
}
})
it('returns the input unchanged when already a clean slug', () => {
expect(slugifyPackId('comfyui-impact-pack')).toBe('comfyui-impact-pack')
expect(slugifyPackId('rgthree-comfy')).toBe('rgthree-comfy')
})
})

View File

@@ -1,6 +1,5 @@
import { getNodeSource, NodeSourceType } from '../classifiers/nodeSource'
import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
import { slugifyPackId } from './slugifyPackId'
export interface PackedNode {
className: string
@@ -9,27 +8,10 @@ export interface PackedNode {
export interface NodePack {
id: string
rawId: string
rawIds: string[]
displayName: string
nodes: PackedNode[]
}
/**
* Group custom Comfy node definitions into packs keyed by a slugified pack identifier.
*
* Processes the provided node definitions, selects those identified as custom nodes, extracts
* a raw pack identifier from each definition's `python_module`, converts it to a slug, and
* aggregates nodes that share the same slug into a single `NodePack`.
*
* @param defs - Map of class name to `ComfyNodeDef` objects to be grouped
* @returns An array of `NodePack` objects sorted by `id` (ascending). Each `NodePack` includes:
* - `id`: the slugified pack identifier
* - `rawId`: the raw identifier extracted from a representative node's `python_module`
* - `rawIds`: all distinct raw identifiers that were mapped to the same slug
* - `displayName`: display text taken from the node source metadata
* - `nodes`: the list of packed node entries (`{ className, def }`)
*/
export function groupNodesByPack(
defs: Record<string, ComfyNodeDef>
): NodePack[] {
@@ -41,31 +23,21 @@ export function groupNodesByPack(
continue
}
const rawId = def.python_module.split('.')[1]?.split('@')[0]
if (!rawId) {
const packId = def.python_module.split('.')[1]?.split('@')[0]
if (!packId) {
continue
}
const slug = slugifyPackId(rawId)
if (!slug) {
continue
}
const existing = byPackId.get(slug)
const existing = byPackId.get(packId)
const node = { className, def }
if (existing) {
existing.nodes.push(node)
if (!existing.rawIds.includes(rawId)) {
existing.rawIds.push(rawId)
}
continue
}
byPackId.set(slug, {
id: slug,
rawId,
rawIds: [rawId],
byPackId.set(packId, {
id: packId,
displayName: source.displayText,
nodes: [node]
})

View File

@@ -1,34 +0,0 @@
/**
* Normalize a custom-node pack identifier into a URL-safe slug.
*
* Pack ids originate from Python module names exposed by ComfyUI and the
* Comfy custom-node registry. The upstream names mix three conventions
* freely: kebab-case (`comfyui-impact-pack`), snake_case
* (`comfyui_impact_pack`), and PascalCase (`ComfyUI-Crystools`). Using
* those raw strings as URL segments produces routes that are inconsistent
* across packs and fail the website's `[a-z0-9-]+` slug contract.
*
* `slugifyPackId` produces a deterministic, lowercase, hyphen-only slug
* suitable for use as a URL segment and as an `Astro.params` value. It
* does NOT replace the raw id used for registry lookups; callers that
* need to query the registry API must keep the raw `node_id` separately.
*
* The transformation is intentionally narrow:
* - lowercase
* - replace `_` with `-`
* - collapse runs of `-` to a single `-`
* - strip leading / trailing `-`
*
* Any other character (digits, letters, `-`) is preserved verbatim so
* legitimate registry ids like `comfyui-flashvsr-ultra-fast` survive
* untouched. The output is guaranteed to match `/^[a-z0-9-]+$/` as long
* as the input only contains ASCII letters, digits, `_`, and `-` — which
* is the case for every pack id observed in the registry today.
*/
export function slugifyPackId(rawId: string): string {
return rawId
.toLowerCase()
.replace(/_/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}

View File

@@ -2,4 +2,3 @@ export * from './schemas/nodeDefSchema'
export * from './classifiers/nodeSource'
export * from './helpers/groupNodesByPack'
export * from './helpers/sanitizeUserContent'
export * from './helpers/slugifyPackId'

20
src/base/wheelGestures.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Wheel events whose browser default would break the editing experience.
* On macOS trackpads:
* - `ctrl/meta + wheel` (pinch-zoom) triggers page-level zoom, which
* pushes fixed-position UI (e.g. ComfyActionbar) off-screen with no
* recovery short of a page reload.
* - Horizontal-dominant wheel (two-finger horizontal swipe) triggers
* back/forward navigation, which leaves the workflow.
*
* Equal `|deltaX| == |deltaY|` (including idle 0/0 frames between meaningful
* trackpad samples) intentionally falls on the false branch so native
* vertical scroll wins on a tie.
*
* Components that intercept wheel events should suppress the default for
* these gestures even when they otherwise let the browser scroll natively.
*/
export const isCanvasGestureWheel = (event: WheelEvent): boolean =>
event.ctrlKey ||
event.metaKey ||
Math.abs(event.deltaX) > Math.abs(event.deltaY)

View File

@@ -13,25 +13,18 @@ vi.mock('@/i18n', () => ({
const executionStore = reactive<{
isIdle: boolean
executionProgress: number
executingNode: unknown
executingNode: null | {
title?: string
type?: string
}
executingNodeProgress: number
nodeProgressStates: Record<string, unknown>
activeJob: {
workflow: {
changeTracker: {
activeState: {
nodes: { id: number; type: string }[]
}
}
}
} | null
}>({
isIdle: true,
executionProgress: 0,
executingNode: null,
executingNodeProgress: 0,
nodeProgressStates: {},
activeJob: null
nodeProgressStates: {}
})
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => executionStore
@@ -75,7 +68,6 @@ describe('useBrowserTabTitle', () => {
executionStore.executingNode = null
executionStore.executingNodeProgress = 0
executionStore.nodeProgressStates = {}
executionStore.activeJob = null
// reset setting and workflow stores
vi.mocked(settingStore.get).mockReturnValue('Enabled')
@@ -185,18 +177,12 @@ describe('useBrowserTabTitle', () => {
it('shows node execution title when executing a node using nodeProgressStates', async () => {
executionStore.isIdle = false
executionStore.executionProgress = 0.4
executionStore.executingNode = {
type: 'Foo'
}
executionStore.nodeProgressStates = {
'1': { state: 'running', value: 5, max: 10, node: '1', prompt_id: 'test' }
}
executionStore.activeJob = {
workflow: {
changeTracker: {
activeState: {
nodes: [{ id: 1, type: 'Foo' }]
}
}
}
}
const scope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()

View File

@@ -74,14 +74,14 @@ export const useBrowserTabTitle = () => {
}
// If only one node is running
const [nodeId, state] = runningNodes[0]
const [, state] = runningNodes[0]
const progress = Math.round((state.value / state.max) * 100)
const nodeType =
executionStore.activeJob?.workflow?.changeTracker?.activeState.nodes.find(
(n) => String(n.id) === nodeId
)?.type || 'Node'
const nodeLabel =
executionStore.executingNode?.type?.trim() ||
executionStore.executingNode?.title?.trim() ||
'Node'
return `${executionText.value}[${progress}%] ${nodeType}`
return `${executionText.value}[${progress}%] ${nodeLabel}`
})
const workflowTitle = computed(

View File

@@ -325,6 +325,7 @@ export interface CheckoutAttributionMetadata {
ga_session_id?: string
ga_session_number?: string
im_ref?: string
rewardful_referral?: string
utm_source?: string
utm_medium?: string
utm_campaign?: string

View File

@@ -15,6 +15,7 @@ describe('getCheckoutAttribution', () => {
}
window.gtag = undefined
window.ire = undefined
window.Rewardful = undefined
window.history.pushState({}, '', '/')
})
@@ -228,4 +229,47 @@ describe('getCheckoutAttribution', () => {
expect(attribution.im_ref).toBeUndefined()
})
it('captures Rewardful referral from window.Rewardful', async () => {
window.Rewardful = {
referral: 'rwd-abc-123'
}
const attribution = await getCheckoutAttribution()
expect(attribution.rewardful_referral).toBe('rwd-abc-123')
})
it('returns undefined Rewardful referral when window.Rewardful is absent', async () => {
const attribution = await getCheckoutAttribution()
expect(attribution.rewardful_referral).toBeUndefined()
})
it('returns undefined Rewardful referral when window.Rewardful.referral is empty', async () => {
window.Rewardful = { referral: '' }
const attribution = await getCheckoutAttribution()
expect(attribution.rewardful_referral).toBeUndefined()
})
it('captures Rewardful referral alongside Impact attribution', async () => {
window.history.pushState(
{},
'',
'/?im_ref=impact-url-id&utm_source=affiliate'
)
window.Rewardful = {
referral: 'rwd-xyz-789'
}
const attribution = await getCheckoutAttribution()
expect(attribution).toMatchObject({
im_ref: 'impact-url-id',
utm_source: 'affiliate',
rewardful_referral: 'rwd-xyz-789'
})
})
})

View File

@@ -180,6 +180,11 @@ async function getGeneratedClickId(): Promise<string | undefined> {
}
}
function getRewardfulReferral(): string | undefined {
if (typeof window === 'undefined') return undefined
return asNonEmptyString(window.Rewardful?.referral)
}
export function captureCheckoutAttributionFromSearch(search: string): void {
const fromUrl = readAttributionFromUrl(search)
const storedAttribution = readStoredAttribution()
@@ -213,11 +218,13 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
}
const gaIdentity = await getGaIdentity()
const rewardfulReferral = getRewardfulReferral()
return {
...attribution,
ga_client_id: gaIdentity?.client_id,
ga_session_id: gaIdentity?.session_id,
ga_session_number: gaIdentity?.session_number
ga_session_number: gaIdentity?.session_number,
rewardful_referral: rewardfulReferral
}
}

View File

@@ -46,10 +46,17 @@ function createMockPointerEvent(
return mockEvent as PointerEvent
}
function createMockWheelEvent(ctrlKey = false, metaKey = false): WheelEvent {
function createMockWheelEvent(
ctrlKey = false,
metaKey = false,
deltaX = 0,
deltaY = 0
): WheelEvent {
const mockEvent: Partial<WheelEvent> = {
ctrlKey,
metaKey,
deltaX,
deltaY,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
}
@@ -222,5 +229,107 @@ describe('useCanvasInteractions', () => {
document.body.removeChild(captureElement)
})
/** Regression: trackpad pinch-zoom inside a focused textarea must not
* fall through to browser page zoom in non-standard navigation modes. */
it.for(['legacy', 'custom'])(
'should forward ctrl+wheel to canvas when capture element IS focused in %s mode',
(mode) => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue(mode)
const captureElement = document.createElement('div')
captureElement.setAttribute('data-capture-wheel', 'true')
const textarea = document.createElement('textarea')
captureElement.appendChild(textarea)
document.body.appendChild(captureElement)
textarea.focus()
const { handleWheel } = useCanvasInteractions()
const mockEvent = createMockWheelEvent(true)
Object.defineProperty(mockEvent, 'target', { value: textarea })
handleWheel(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
document.body.removeChild(captureElement)
}
)
it('should forward meta+wheel to canvas when capture element IS focused', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('standard')
const captureElement = document.createElement('div')
captureElement.setAttribute('data-capture-wheel', 'true')
const textarea = document.createElement('textarea')
captureElement.appendChild(textarea)
document.body.appendChild(captureElement)
textarea.focus()
const { handleWheel } = useCanvasInteractions()
const mockEvent = createMockWheelEvent(false, true)
Object.defineProperty(mockEvent, 'target', { value: textarea })
handleWheel(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
document.body.removeChild(captureElement)
})
/** Regression: trackpad two-finger horizontal swipes inside a focused
* textarea must not fall through to browser back/forward navigation. */
it.for(['standard', 'legacy', 'custom'])(
'should forward horizontal-dominant wheel to canvas when capture element IS focused in %s mode',
(mode) => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue(mode)
const captureElement = document.createElement('div')
captureElement.setAttribute('data-capture-wheel', 'true')
const textarea = document.createElement('textarea')
captureElement.appendChild(textarea)
document.body.appendChild(captureElement)
textarea.focus()
const { handleWheel } = useCanvasInteractions()
const mockEvent = createMockWheelEvent(false, false, 30, 5)
Object.defineProperty(mockEvent, 'target', { value: textarea })
handleWheel(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
document.body.removeChild(captureElement)
}
)
it('should NOT forward vertical-dominant wheel when capture element IS focused', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('standard')
const captureElement = document.createElement('div')
captureElement.setAttribute('data-capture-wheel', 'true')
const textarea = document.createElement('textarea')
captureElement.appendChild(textarea)
document.body.appendChild(captureElement)
textarea.focus()
const { handleWheel } = useCanvasInteractions()
const mockEvent = createMockWheelEvent(false, false, 0, 30)
Object.defineProperty(mockEvent, 'target', { value: textarea })
handleWheel(mockEvent)
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
document.body.removeChild(captureElement)
})
})
})

View File

@@ -1,6 +1,7 @@
import { computed } from 'vue'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import { isCanvasGestureWheel } from '@/base/wheelGestures'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
@@ -41,30 +42,34 @@ export function useCanvasInteractions() {
return !!(captureElement && active && captureElement.contains(active))
}
/**
* Forward to canvas when the event is not consumed by a focused widget,
* or when it is a canvas gesture (which must override widget consumption
* to prevent destructive browser defaults).
*/
const shouldForwardWheelEvent = (event: WheelEvent): boolean =>
!wheelCapturedByFocusedElement(event) ||
(isStandardNavMode.value && (event.ctrlKey || event.metaKey))
!wheelCapturedByFocusedElement(event) || isCanvasGestureWheel(event)
/**
* Handles wheel events from UI components that should be forwarded to canvas
* when appropriate (e.g., Ctrl+wheel for zoom in standard mode)
* when appropriate (e.g., Ctrl+wheel for zoom, two-finger pan in standard
* mode; all wheel events in legacy mode).
*/
const handleWheel = (event: WheelEvent) => {
if (!shouldForwardWheelEvent(event)) return
// In standard mode, Ctrl+wheel should go to canvas for zoom
if (isStandardNavMode.value && (event.ctrlKey || event.metaKey)) {
forwardEventToCanvas(event)
// In standard mode, only canvas gestures (zoom/pan) are forwarded;
// vertical wheel falls through so the document/widget scrolls normally.
// The re-check is intentional and NOT redundant with shouldForwardWheelEvent:
// that function also returns true for unfocused vertical wheel (its
// `!wheelCapturedByFocusedElement` branch), which here must stay native.
if (isStandardNavMode.value) {
if (isCanvasGestureWheel(event)) forwardEventToCanvas(event)
return
}
// In legacy mode, all wheel events go to canvas for zoom
if (!isStandardNavMode.value) {
forwardEventToCanvas(event)
return
}
// Otherwise, let the component handle it normally
// In legacy mode, all forwardable wheel events go to canvas for zoom/pan.
forwardEventToCanvas(event)
}
/**

View File

@@ -94,14 +94,59 @@ describe('FormDropdownMenu', () => {
})
it('has data-capture-wheel="true" on the root element', () => {
const { container } = render(FormDropdownMenu, {
render(FormDropdownMenu, {
props: defaultProps,
global: globalConfig
})
expect(
// eslint-disable-next-line testing-library/no-node-access
container.firstElementChild!.getAttribute('data-capture-wheel')
screen
.getByTestId('form-dropdown-menu')
.getAttribute('data-capture-wheel')
).toBe('true')
})
/** Regression: PrimeVue Popover teleports the menu to document.body, so
* trackpad pinch-zoom and horizontal swipes must be guarded on the menu
* itself rather than relying on the LGraphNode wheel handler. */
it.for([
{ name: 'pinch-zoom', overrides: { ctrlKey: true, deltaY: -10 } },
{ name: 'horizontal swipe', overrides: { deltaX: 30, deltaY: 5 } }
])('suppresses browser default for $name', ({ overrides }) => {
render(FormDropdownMenu, {
props: defaultProps,
global: globalConfig
})
const root = screen.getByTestId('form-dropdown-menu')
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true
})
Object.entries(overrides).forEach(([key, value]) => {
Object.defineProperty(event, key, { value })
})
root.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
})
/** Vertical scrolling must remain native so the dropdown's own scroll
* container can scroll its content. */
it('does not suppress vertical scroll', () => {
render(FormDropdownMenu, {
props: defaultProps,
global: globalConfig
})
const root = screen.getByTestId('form-dropdown-menu')
const event = new WheelEvent('wheel', {
deltaY: 30,
bubbles: true,
cancelable: true
})
root.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
})
})

View File

@@ -3,6 +3,7 @@ import type { CSSProperties } from 'vue'
import { computed } from 'vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import { isCanvasGestureWheel } from '@/base/wheelGestures'
import type {
FilterOption,
@@ -93,12 +94,25 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
key: String(item.id)
}))
)
/**
* The dropdown content is teleported to `document.body` by PrimeVue Popover,
* detaching it from the LGraphNode subtree where the canvas wheel guard lives.
* Suppress only the destructive browser defaults (page zoom on pinch and
* back/forward on horizontal swipe); regular vertical scrolling still
* scrolls the dropdown's own content.
*/
const onWheel = (event: WheelEvent) => {
if (isCanvasGestureWheel(event)) event.preventDefault()
}
</script>
<template>
<div
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline -outline-offset-1 outline-node-component-border"
data-capture-wheel="true"
data-testid="form-dropdown-menu"
@wheel="onWheel"
>
<FormDropdownMenuFilter
v-if="filterOptions.length > 0"

View File

@@ -1632,6 +1632,7 @@ export class ComfyApp {
executionStore.storeJob({
id: res.prompt_id,
nodes: Object.keys(p.output),
promptOutput: p.output,
workflow: queuedWorkflow
})
}

View File

@@ -1,10 +1,14 @@
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { app } from '@/scripts/app'
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
import type { NodeProgressState } from '@/schemas/apiSchema'
// Create mock functions that will be shared
const {
@@ -18,10 +22,6 @@ const {
mockNodeLocatorIdToNodeExecutionId: vi.fn(),
mockShowTextPreview: vi.fn()
}))
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
import type { NodeProgressState } from '@/schemas/apiSchema'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { createTestingPinia } from '@pinia/testing'
@@ -70,7 +70,7 @@ vi.mock('@/scripts/api', () => ({
}
}))
vi.mock('@/stores/imagePreviewStore', () => ({
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({
revokePreviewsByExecutionId: vi.fn()
})
@@ -94,6 +94,26 @@ vi.mock('@/scripts/app', () => ({
}
}))
function createQueuedWorkflow(path: string = 'workflows/test.json') {
return {
activeState: { id: 'workflow-id' },
initialState: { id: 'workflow-id' },
path
} as Parameters<
ReturnType<typeof useExecutionStore>['storeJob']
>[0]['workflow']
}
function createPromptNode(title: string, classType: string) {
return {
inputs: {},
class_type: classType,
_meta: {
title
}
}
}
describe('useExecutionStore - NodeLocatorId conversions', () => {
let store: ReturnType<typeof useExecutionStore>
@@ -709,6 +729,103 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
})
})
describe('useExecutionStore - executingNode with subgraphs', () => {
let store: ReturnType<typeof useExecutionStore>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
})
it('should find executing node info in root graph from queued prompt data', () => {
store.storeJob({
id: 'test-prompt',
nodes: ['123'],
promptOutput: {
'123': createPromptNode('Test Node', 'TestNode')
},
workflow: createQueuedWorkflow()
})
store.activeJobId = 'test-prompt'
store.nodeProgressStates = {
'123': {
state: 'running',
value: 0,
max: 100,
display_node_id: '123',
prompt_id: 'test-prompt',
node_id: '123'
}
}
expect(store.executingNode).toEqual({
title: 'Test Node',
type: 'TestNode'
})
})
it('should find executing node info in subgraph using execution ID', () => {
store.storeJob({
id: 'test-prompt',
nodes: ['456:789'],
promptOutput: {
'456:789': createPromptNode('Nested Node', 'NestedNode')
},
workflow: createQueuedWorkflow()
})
store.activeJobId = 'test-prompt'
store.nodeProgressStates = {
'456:789': {
state: 'running',
value: 0,
max: 100,
display_node_id: '456:789',
prompt_id: 'test-prompt',
node_id: '456:789'
}
}
expect(store.executingNode).toEqual({
title: 'Nested Node',
type: 'NestedNode'
})
})
it('should return null when no node is executing', () => {
store.nodeProgressStates = {}
expect(store.executingNode).toBeNull()
})
it('should return null when executing node metadata cannot be found', () => {
store.storeJob({
id: 'test-prompt',
nodes: ['123'],
promptOutput: {
'123': createPromptNode('Test Node', 'TestNode')
},
workflow: createQueuedWorkflow()
})
store.activeJobId = 'test-prompt'
store.nodeProgressStates = {
'999': {
state: 'running',
value: 0,
max: 100,
display_node_id: '999',
prompt_id: 'test-prompt',
node_id: '999'
}
}
expect(store.executingNode).toBeNull()
})
})
describe('useMissingNodesErrorStore - setMissingNodeTypes', () => {
let store: ReturnType<typeof useMissingNodesErrorStore>
@@ -1076,9 +1193,21 @@ describe('useExecutionStore - storeJob and workflow path tracking', () => {
path: '/workflows/foo.json'
} as unknown as Parameters<typeof store.storeJob>[0]['workflow']
store.storeJob({ nodes: ['a', 'b'], id: 'job-1', workflow })
store.storeJob({
nodes: ['a', 'b'],
id: 'job-1',
promptOutput: {
a: createPromptNode('Node A', 'NodeA'),
b: createPromptNode('Node B', 'NodeB')
},
workflow
})
expect(store.queuedJobs['job-1']?.nodes).toEqual({ a: false, b: false })
expect(store.queuedJobs['job-1']?.nodeLookup).toEqual({
a: { title: 'Node A', type: 'NodeA' },
b: { title: 'Node B', type: 'NodeB' }
})
expect(store.queuedJobs['job-1']?.workflow).toStrictEqual(workflow)
expect(store.jobIdToWorkflowId.get('job-1')).toBe('wf-1')
expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe(

View File

@@ -7,8 +7,7 @@ import { useTelemetry } from '@/platform/telemetry'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type {
ComfyNode,
ComfyWorkflowJSON,
ComfyApiWorkflow,
NodeId,
WorkflowId
} from '@/platform/workflow/validation/schemas/workflowSchema'
@@ -36,6 +35,11 @@ import type { NodeLocatorId } from '@/types/nodeIdentification'
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
interface ExecutionNodeInfo {
title?: string | null
type?: string | null
}
interface QueuedJob {
/**
* The nodes that are queued to be executed. The key is the node id and the
@@ -46,6 +50,25 @@ interface QueuedJob {
* The workflow that is queued to be executed
*/
workflow?: ComfyWorkflow
/**
* Queue-time node metadata keyed by execution ID.
* This stays stable even if the user switches workflows or edits the canvas.
*/
nodeLookup?: Record<string, ExecutionNodeInfo>
}
function buildExecutionNodeLookup(
promptOutput: ComfyApiWorkflow
): Record<string, ExecutionNodeInfo> {
return Object.fromEntries(
Object.entries(promptOutput).map(([executionId, node]) => [
executionId,
{
title: node._meta.title,
type: node.class_type
}
])
)
}
/**
@@ -168,21 +191,11 @@ export const useExecutionStore = defineStore('execution', () => {
() => new Set(executingNodeIds.value.map(String))
)
// For backward compatibility - returns the primary executing node
const executingNode = computed<ComfyNode | null>(() => {
// For backward compatibility - returns the primary executing node info
const executingNode = computed<ExecutionNodeInfo | null>(() => {
if (!executingNodeId.value) return null
const workflow: ComfyWorkflow | undefined = activeJob.value?.workflow
if (!workflow) return null
const canvasState: ComfyWorkflowJSON | null =
workflow.changeTracker?.activeState ?? null
if (!canvasState) return null
return (
canvasState.nodes.find((n) => String(n.id) === executingNodeId.value) ??
null
)
return activeJob.value?.nodeLookup?.[String(executingNodeId.value)] ?? null
})
// This is the progress of the currently executing node (for backward compatibility)
@@ -548,10 +561,12 @@ export const useExecutionStore = defineStore('execution', () => {
function storeJob({
nodes,
id,
promptOutput,
workflow
}: {
nodes: string[]
id: JobId
promptOutput: ComfyApiWorkflow
workflow: ComfyWorkflow
}) {
queuedJobs.value[id] ??= { nodes: {} }
@@ -563,6 +578,7 @@ export const useExecutionStore = defineStore('execution', () => {
}, {}),
...queuedJob.nodes
}
queuedJob.nodeLookup = buildExecutionNodeLookup(promptOutput)
queuedJob.workflow = workflow
const wid = workflow?.activeState?.id ?? workflow?.initialState?.id
if (wid) {