Compare commits

..

44 Commits

Author SHA1 Message Date
Connor Byrne
24d893d401 docs(ext-api): lift @example onto shell-UI type declarations (close W17.N.2)
typedoc renders re-exported type pages from the original-declaration
site (src/types/extensionTypes.ts), not from the @comfyorg/extension-api
barrel re-export. The shell-UI type aliases (ToastMessageOptions,
HotkeyExtension, AboutBadgeExtension, ToolbarButtonExtension) therefore
shipped to the dashboard without any @example blocks even though their
matching defineX entry points carry full examples in registrations.ts /
imperatives.ts.

Lift the canonical @example from each defineX onto the source
declaration so the dashboard type page renders with a working snippet.

Pure JSDoc — zero exported-type diff. Phase A freeze respected.

Wave-18 W18.J. Closes W17.N.2.
2026-05-21 14:01:24 -07:00
Connor Byrne
3d09f89251 docs(ext-api): refresh @example blocks + deprecate notify (W17.C/D/E)
- W17.C: refresh 5 stale @example blocks (types.ts, lifecycle.ts, node.ts,
  imperatives.ts ×2) — replace deprecated `async setup()` with
  `setup() { onMounted(...) }`; remove A1-removed `node.getWidget` from
  defineNode + NodeHandle.on migration examples; align imperatives examples
  with W17.B notify-toast verdict.
- W17.D: author ~37 new @example blocks across 9 files for sparse exports
  surfaced by W17.A audit. Pure JSDoc — zero exported-type diff.
- W17.E: add @deprecated JSDoc to notify() + NotifyOptions per
  D-notify-toast-consolidation (W17.B verdict (a) soft-deprecate).

Pure JSDoc — no signature, type, or export changes.
Phase A surface remains FROZEN at ee0537fdb5 (foundation surface SHA
unchanged; only @example / @deprecated text differs).

See research/dashboard-snippet-audit-2026-05-21.md (W17.A) for full
per-edit rationale.
2026-05-20 20:07:13 -07:00
Connor Byrne
dd5335df7c fix(review): drop internal task-name refs from app.ts comment (W15.H)
Address PR #12142 review thread PRRT_kwDOMIrOxs6BQjVr —
remove '(I-SR.3 / MIG1.E5)' internal task-tracker reference from the
startExtensionSystem() comment. Wave-11 hygiene cleanup did the same
across src/extension-api/**; this completes the same hygiene for the
single call site in src/scripts/app.ts.

Review: https://github.com/Comfy-Org/ComfyUI_frontend/pull/12142#discussion_r3222947433
2026-05-20 18:29:01 -07:00
Connor Byrne
ee0537fdb5 chore(ext-api): hygiene cleanup — D-design-review-hygiene-cleanup (wave-11)
Closes the (a)+(b) propagation legs for five design-review-12142 rows:

- Row #1 (Comments hygiene): strip 25 HR-style section dividers
  (`// ── XYZ ──`) across widget.ts/node.ts/registrations.ts/events.ts/
  index.ts; strip ~30 sites of decision archaeology refs (D5, D20,
  D-immutability-enforcement (Hybrid C), D-bootstrap-hooks (W6.P6.C),
  D-shell-ui-entrypoints (W6.P5.C), etc.) from per-export JSDoc;
  preserve PHASE_A_EXCLUDED axiom-ref blocks (functional — tells
  axiom-checker + future readers WHY surfaces are absent); preserve
  AXIOMS.md §A1/A14/A15/A16/A12 cross-refs; delete shell.ts
  "What changed in W6.P5" wave-history banner.
- Row #2 (@stability stable): already DONE in code (0 stable hits);
  this ADR closes leg (b).
- Row #4 (apiVersion drop): already DONE in code (0 hits); this ADR
  closes leg (b).
- Row #5 (defineNode/defineWidget short-form): D11 ACCEPTED 2026-05-12;
  one stale JSDoc @example in lifecycle.ts:113,115 fixed
  (defineWidgetExtension → defineWidget).
- Row #6 (Widget-prefixed methods): already DONE via D-bootstrap-hooks
  (unprefixed onMounted/onUnmounted etc.); this ADR closes leg (b).

Also drops two now-unused imports in node.ts (WidgetHandle, WidgetOptions
were only referenced in commented-out A14-deferred surfaces).

ADR: decisions/D-design-review-hygiene-cleanup.md
Net diff: -62 lines, zero behavior change.
Quality gates: lint + format:check + axiom-compliance all green.
2026-05-20 16:09:45 -07:00
Connor Byrne
d5d5692928 feat(ext-api): A15 enforcement on v1 addWidget family — D-ban-runtime-addwidget (wave-10)
Wave-10 follow-up to D-widget-serialization-simplification (wave-9). Closes
the v1 enforcement loop on AXIOMS.md A15 (Widget Declarativity).

User direction 2026-05-20: "we dont need soft deprecate for beforeSerialize
OR addWidget — they just simply shouldnt be present in the new API. for the
old api surfaces, we can just add deprecations with the suggested
remediation."

## v2 surface — absent (no soft-deprecate)
- Delete dead `addWidget` + `addDOMWidget` shims in
  `src/services/extension-api-service.ts` (~30 lines), along with the
  `domWidgetElements` side-table and `getDOMWidgetElement` accessor.
  With the public NodeHandle no longer exposing these methods, all of
  this is unreachable.
- Drop `DOMWidgetOptions` import from the service.
- Scrub 5 v2 JSDoc `@example` blocks that still referenced
  `node.addWidget(...)` as if it were a valid call:
  node.ts (NodeBeforeSerialize migration example), widget.ts (3 spots:
  name doc, label doc, WidgetOptions doc), types.ts (defineWidget doc),
  lifecycle.ts (defineNode example).
- Replace each example with one of A15's three remediation paths
  (boxed widget / non-widget UI primitive / schema input).

## v1 surface — @deprecated + once-per-session console.warn
- Add @deprecated JSDoc + `warnDeprecated` (session-deduped via the
  existing `./utils/feedback` mechanism) on:
  - `LGraphNode.addWidget` (LGraphNode.ts)
  - `LGraphNode.addCustomWidget` (same file)
  - `LGraphNode.prototype.addDOMWidget` (src/scripts/domWidget.ts)
- Shared message constant `ADD_WIDGET_DEPRECATION_MESSAGE` so all three
  warn paths dedupe to one console line per session. Message text cites
  AXIOMS.md A15 + decisions/D-ban-runtime-addwidget.md and enumerates
  the three migration paths.

## First-party core call sites — silenced via sentinel
- New `__suppressDeprecationWarning?: boolean` field on
  `IWidgetOptions` (marked @internal, NOT part of public surface).
- Added to all first-party call sites that haven't yet migrated:
  src/scripts/widgets.ts (valueControl, comboFilter),
  src/extensions/core/uploadAudio.ts (4 sites),
  src/extensions/core/load3d.ts (3 sites),
  src/extensions/core/webcamCapture.ts (2 sites),
  src/extensions/core/customWidgets.ts (1 site).
- `src/scripts/domWidget.ts addWidget()` helper (internal renderer
  registration path) auto-injects the sentinel before delegating to
  `addCustomWidget` — third-party callers of LGraphNode.addCustomWidget
  still see the warning.
- Migration of these first-party sites tracked as wave-9 follow-up
  #2 (preview/progress/audio widgets migration audit).

## Axiom-Excluded Test Annotation Policy — new, codified
- AXIOMS.md: new "Axiom-Excluded Test Annotation Policy" section after
  A14, retiring the "compat-floor blast_radius ≥ N MUST pass" doctrine.
- AGENTS.md Rule 11: tests asserting on axiom-excluded surfaces MUST
  use `axiomExcluded({...})` (vitest test.fails + task.meta.annotations)
  rather than deletion. Deletion only when the *scenario* is no longer
  meaningful.
- decisions/D-ban-runtime-addwidget.md: "Testing policy" section
  documenting concrete application to bc-05 / bc-11 BC tests in tf
  branch (deferred to tf cascade step).

## Decision propagation
- (a) commit on PR branches — this commit (foundation) + cascade
- (b) ADR — `decisions/D-ban-runtime-addwidget.md` ACCEPTED 2026-05-20
- (c) dashboard MDX — refresh via scripts/refresh-api-docs.sh after
  cascade

## Validation
- scripts/check-axiom-compliance.sh: all 7 checks pass (no new strict
  patterns added; A15 already strict-fails on v2 re-introduction per
  wave-9, and the v1 deprecation is enforced at the call-site level
  via the warnDeprecated mechanism)
- oxlint: 0 warnings/errors on all 13 touched files
- oxfmt: applied to all 13 touched files
- tsc: no new errors introduced (pre-existing TS2305/TS2353/TS2307 errors
  from prior waves remain under AGENTS.md Rule 8 carve-out)
- Bundle size shrinks by ~30 lines (dead v2 shims removed)

## Compatibility (D6 parallel-paths)
- v1 path remains fully functional. Deprecation is a soft signal — no
  throw, no exception, no behavior change beyond one console.warn per
  session.
- Existing custom-node packs (R7 sweep: 5+ evidenced) continue to work;
  authors see migration text on first addWidget call.

Refs: AXIOMS.md A15, A16; D-widget-serialization-simplification (wave-9);
ADR-0010 (node-level beforeSerialize deprecation pattern); D6
parallel-paths.
2026-05-20 15:02:02 -07:00
Connor Byrne
e56187adf3 feat(ext-api): A16 closure — D-widget-serialization-simplification (wave-9)
Codify A15 (Widget Declarativity) + A16 (Unified Serialization Target) by
collapsing widget-level serialization to its irreducible core.

Per D-widget-serialization-simplification (wave-9, 2026-05-20) — comment out
in src/extension-api/widget.ts (A14 pattern):

- WidgetHandle.setSerializeEnabled(enabled) / .isSerializeEnabled()
- WidgetBeforeSerializeEvent.context: 'workflow'|'prompt'|'clone'|'subgraph-promote'
- WidgetBeforeSerializeEvent.skip()
- WidgetPropertyChangeEvent (vacuous union after 'serialize' removed)
- WidgetHandle.on('propertyChange', handler) overload
- WidgetOptions.serialize?: boolean key

Per A16, the sole extension-author interface to serialization is
`widget.on('beforeSerialize', handler)`. The framework writes one payload
to every transport (workflow JSON, API prompt, clone, subgraph-promote);
authors do not branch on transport and cannot disable serialization. Per
A15, runtime widget addition was the underlying reason for the disable
matrix — declarativity removes the cause, A16 removes the escape hatch.

Removed framework-side adapters in src/services/extension-api-service.ts:
isSerializeEnabled / setSerializeEnabled blocks (lines 302-315) and the
propertyChange branch of the event dispatcher (line 362). Dropped
WidgetComponentSerialize import (now unused).

Removed WidgetPropertyChangeEvent from src/extension-api/index.ts barrel.

Updated JSDoc: stripped 4-context narrative + skip() examples from
WidgetBeforeSerializeEvent, dropped widget.options.serialize references
from the options accessor, dropped e.context === 'prompt' from the
serializeValue accessor example.

node.on('beforeSerialize') (separate surface) remains `@deprecated` +
runtime-warned per ADR-0010 — NOT banned by this commit (per user
direction 2026-05-20).

Blast radius: 0 v2 ext call sites for any removed surface (verified via
rg sweep across restack-ext-v2/src and foundation extensions/core).
v1 path untouched per D6 parallel-paths migration.

Refs:
- decisions/D-widget-serialization-simplification.md (ACCEPTED 2026-05-20)
- AXIOMS.md §A15 (Widget Declarativity), §A16 (Unified Serialization Target)
- AXIOMS.md §A14 (7 new rows in COMMENTED OUT table)
- D14-serialization-convergence.md (predecessor — promised this end-state)
- D-no-node-widget-access.md (wave-8, structural sibling)
- design-review-12142.md row #11

Verified: scripts/check-axiom-compliance.sh  all checks pass
(A1, A2, A3, A4, A6, A15, A16 — strict-fail on re-introduction)
2026-05-20 13:41:40 -07:00
Connor Byrne
446d0a216e feat(ext-api): bilateral A1 closure — D-no-node-widget-access
Comments out NodeHandle.getWidget(name) + NodeHandle.getWidgets() per
D-no-node-widget-access (ACCEPTED 2026-05-19, ADR landed). Closes the
node→widget direction so that the v2 public surface enforces A1 as
written: 'nodes cannot enumerate widgets' is now bilateral with
'nodes cannot reference widgets' — the widget→node direction
(widget.parentNode) remains the sole node↔widget channel.

Origin: a contradiction was surfaced during handoff-14 (wave-7
post-cascade). AXIOMS.md §A1 is NON-NEGOTIABLE and says 'Node hooks
cannot reference/modify widgets' + 'Widgets can know their parent
node, but nodes cannot enumerate widgets'. However design-review-12142
decision #13 (2026-05-12, 'getWidget seems good') had renamed
widget() → getWidget() without re-checking A1, and a codified
read-only carve-out in scripts/check-axiom-compliance.sh:22 papered
over the gap. The carve-out is removed; the axiom checker is now
strict-fail on any node→widget surface.

Changes:
- node.ts: getWidget(name) + getWidgets() commented out with A1+A2
  rationale block (A2 pattern: 'comment out if not 100% sure')
- widget.ts: 2 JSDoc examples rewritten to use
  defineWidget({mount}, ctx.widget) — the only legal v2 path to a
  WidgetHandle

Migration cost (v2 surface):
- 0 call sites in the tf test suite
- 1 call site in ext-v2 (extensions/core/dynamicPrompts.v2.ts) —
  stubbed pending follow-up 'defineWidgetAugmenter' API; v1 path
  (Comfy.DynamicPrompts) continues to work
- TypeScript-only signature change; no runtime change

Restoration criteria: see AXIOMS.md §A14 entry — a documented use
case unexpressible via widget.parentNode + defineWidget({mount}) +
provide/inject, A1 re-audit pass, and bc-XX test triplet coverage
across promotion / app-mode / linked-instance scenarios.

Refs:
- ADR: decisions/D-no-node-widget-access.md (ACCEPTED 2026-05-19)
- AXIOMS.md §A1 + §A14
- scripts/check-axiom-compliance.sh (strict bilateral check)
2026-05-20 12:35:47 -07:00
Connor Byrne
bd4d195230 chore(ext-api): comment out NodeMode + Slot surfaces per AXIOMS.md A14
Pre-existing A14 enforcement work (was sitting uncommitted across
the wave-6/wave-7 reconciliations). Comments out the following
Phase A exclusions from the public NodeHandle / WidgetHandle surface:

node.ts:
- NodeMode (egregious use patterns per meeting; A2)
- SlotDirection / SlotInfo / SlotEntityId (slot/connection deferred; A2)
- NodeConnectedEvent / NodeDisconnectedEvent
- on('modeChanged'), on('connected'), on('disconnected') subscriptions
- getMode() / setMode()
- inputs() / outputs() slot enumeration
- getPosition()/setPosition()/getSize()/setSize() (deferred pending A13)
- getTitle()/setTitle(), isSelected()

widget.ts:
- isHidden()/setHidden(), isDisabled()/setDisabled()
  (deferred pending serialization convergence)

These align with the AXIOMS.md A14 'COMMENTED OUT' table. No
behavior change — TypeScript interface narrowing only. Restoration
criteria documented in AXIOMS.md A14.

Refs: AXIOMS.md §A14
2026-05-20 12:35:06 -07:00
Connor Byrne
ff314491da feat(extension-api): D-shell-ui-entrypoints (W6.P5.C) — per-surface defineX entries + toast/notify inline imperatives
Adopt per-surface defineX entries plus inline-imperative carve-out for
toast + notify, per D-shell-ui-entrypoints ADR (ACCEPTED 2026-05-18,
foundation impl handoff-13).

## What lands

New shell-UI registration entries (registrations.ts, NEW):
- defineSidebarTab / defineBottomPanelTab — wire to workspace stores
- defineCommand — wires to commandStore.registerCommand
- defineHotkey — parses key combo, wires to keybindingStore
- defineSetting — wires to settingStore.addSetting
- defineAboutBadge / defineToolbarButton — module-level registries
  (consumed by aboutPanelStore / action-bar component via P5.E follow-up)

All defineX return DisposableHandle { dispose(): void }. Lazy-mount
queue (pendingEntries[]) flushed by startExtensionSystem() via the new
_flushShellRegistrations() hook so defineX is safe to call at
module-eval time (before Pinia is ready). Async store imports inside
each mount-fn keep the registration module itself dependency-free.

Inline imperatives (imperatives.ts, NEW):
- toast.show / toast.remove / toast.removeAll
- notify({ kind, message, detail, life }) — thin convenience wrapper
- Fire-and-forget; no DisposableHandle, no defineX wrapper
- 100% imperative across 166 ecosystem hits / 16 repos (R1+R2+R3)

Net-new public arg types (extensionTypes.ts):
- CommandDefinition (alias of v1 ComfyCommand)
- HotkeyExtension (keys + commandId + optional targetElementId)
- AboutBadgeExtension (label/url/icon/severity)
- SettingDefinition<TValue> (alias of SettingParams<TValue>)
- ToolbarButtonExtension (id/icon/label/tooltip/class/onClick)

Barrel reshape (shell.ts + index.ts):
- Re-export the 5 new arg types from shell.ts
- DROP ExtensionManager + CommandManager from public re-export per
  W6.P5.B reconciliation — v2 uses per-surface disposables, not
  umbrella handles. Internal callers still import from
  @/types/extensionTypes directly.
- Export 7 new defineX + DisposableHandle + toast/notify/NotifyOptions
  from index.ts

v1 deprecation (comfy.ts):
- ComfyExtension.{commands, keybindings, settings, bottomPanelTabs,
  aboutPageBadges, actionBarButtons} gain @deprecated JSDoc pointing at
  the v2 defineX equivalent. Slots remain functional for back-compat
  during the deprecation window (v1 extensionService still loads them).
- menuCommands + topbarBadges marked @deprecated with scope-note that
  they're not part of W6.P5 (follow-on ADRs).

Runtime wiring (extension-api-service.ts):
- startExtensionSystem() dynamic-imports registrations and calls
  _flushShellRegistrations() so pending defineX calls land into stores
  exactly once at bootstrap.

Verification:
- tsc --noEmit: zero new errors in modified files (pre-existing node.ts
  / widget.ts errors are uncommitted working-tree state from prior
  agent, not from this work)
- oxfmt: clean
- oxlint: 0 warnings / 0 errors on touched files
- vitest/knip blocked by Node 20-vs-24 env requirement (env, not code)

ADR: decisions/D-shell-ui-entrypoints.md §Implementation plan Steps 1-5.
Steps 6 (codemod) lands in PKG; Step 7 (.d.ts regen) cascades via
restack-after-foundation.sh; Steps 8-13 land in EXT/TF/docs/dashboard.

Per AGENTS.md §Decision propagation: subject contains
'D-shell-ui-entrypoints' so check-decision-propagation.sh picks it up.
2026-05-18 17:45:40 -07:00
Connor Byrne
b3b3b10fea feat(extension-api): D-bootstrap-hooks (W6.P6.C) — context-scoped lifecycle hooks + 4 event-namespace facades
Adopt context-scoped Vue-style lifecycle hooks inside setup() bodies
plus four typed event-channel namespaces, per D-bootstrap-hooks ADR
(ACCEPTED 2026-05-14, foundation impl handoff-11).

## What lands

New lifecycle hooks (extension-api-service.ts + lifecycle.ts barrel):
- onMounted / onBeforeMount / onUnmounted / onActivated / onDeactivated
- All synchronous-only inside defineExtension/SidebarTab/BottomPanelTab.setup
- Throw in dev / no-op in prod outside context
- onActivated/onDeactivated kind-restricted to sidebarTab/bottomPanel

Event-namespace facades (events.ts):
- graph / execution / server / workbench — module-level singletons
- Subscriptions inside setup() auto-dispose via unmountHooks
- Handler<EventPayloadMap[K]> typed (defaults to unknown; D5 tightens later)

Sub-decisions resolved (handoff-11):
- SD-1 (a) tagged-union lifecycle context (kind: extension|sidebarTab|bottomPanel)
- SD-2 (pragmatic parallel slots — _currentScope unchanged, new _currentExtensionInstance; full consolidation is follow-up)
- SD-3 (b) lazy setup invocation via existing invokeV2AppExtensions
- SD-4 (a) module-level singletons + auto-dispose via currentContext
- SD-5 (b) typed EventPayloadMap with module augmentation

Deprecation (types.ts):
- ExtensionOptions.init and .setup gain @deprecated JSDoc per ADR migration table
- Runtime mapper implicit: invokeV2AppExtensions still invokes both for back-compat

Runtime (invokeV2AppExtensions):
- Phase 1: setup() invoked inside withExtensionInstance bracket
- Phase 2: beforeMountHooks → mountHooks flushed lex-order across all extensions
- teardownV2AppExtensions flushes unmountHooks at app teardown

Verification:
- tsc --noEmit: zero new errors in modified files
- oxfmt: clean
- oxlint: 3 warnings (pre-existing, unrelated), 0 errors
- vitest/knip blocked by Node 20 vs 24 env requirement (env, not code)

ADR: decisions/D-bootstrap-hooks.md §Implementation plan Steps 1+2+3.
Cascade tail W6.P6.D (dashboard + design-review row + PR comment) runs
separately in the handoff thread after this commit lands.

Per AGENTS.md §Decision propagation: subject contains 'D-bootstrap-hooks'
so check-decision-propagation.sh picks it up.
2026-05-18 16:59:01 -07:00
Connor Byrne
fa3229b402 docs(extension-api): D-coord-space (W6.P4.C) — single-coordinate-space policy on NodeHandle spatial accessors
Per D-coord-space ACCEPTED 2026-05-18 (option iii, Christian PICK) + new Axiom A13 (Single Coordinate Space — Canvas) in workspace AXIOMS.md.

Expands JSDoc on NodeHandle.{getPosition,setPosition,getSize,setSize} to: (1) explicitly state 'canvas units', (2) name client/CSS spaces in prose so the distinction is visible at the API boundary, (3) document the escape-hatch (window.app.canvas.ds.{scale,offset} + window.app.canvas.canvas.getBoundingClientRect() + window.devicePixelRatio) for legitimate screen-space cases, (4) point AI agents and human authors at A13 + ADR D-coord-space, (5) carry @stability stable. Also adds a SPATIAL STATE section header comment block stating the single-space policy once at the top.

No runtime surface change — getPosition/getSize/setPosition/setSize already returned/accepted canvas units post W6.P8.C (df921f3512). The PICK ships as documentation + axiom because the runtime contract was already in the right shape. R1+R2+R3 evidence (8,424 EXT hits / 103 repos / ★41,621; bug-class 2,617 hits / 71 repos / ★40,040) backs the choice.

Phase A gates: format clean / lint 0 errors 3 pre-existing warnings / knip 6 pre-existing tag hints 0 failures.
2026-05-18 16:10:00 -07:00
Connor Byrne
2f102353fa feat(extension-api): converge DOMWidget ↔ Widget via mount-lifecycle (D-widget-converge, A12)
Per D-widget-converge ACCEPTED 2026-05-18 (W6.P3.B PICK: option iii) and
new Axiom A12 (Mount-Lifecycle as the Sole DOM Seam):

- Add WidgetCleanup, WidgetMountContext, WidgetMountFn to widget.ts. The
  mount context provides widget/node handles plus onUnmount /
  onBeforeRemount / onAfterRemount lifecycle hooks. Cleanup fires on
  destruction only; host remount uses the remount hooks.
- Replace WidgetExtensionOptions.created({render, destroy}) with optional
  mount: WidgetMountFn. Authors capture host element via closure inside
  mount() — there is no widget.element accessor on the handle.
- Delete DOMWidgetOptions interface from node.ts.
- Delete NodeHandle.addDOMWidget() method (replaced by defineWidget + the
  same addWidget call as native widgets).
- Update WidgetHandle.setHeight() JSDoc — applies to widgets registered
  with a mount() body, not "DOM widgets" as a category.
- Drop DOMWidgetOptions from public barrel; add WidgetCleanup,
  WidgetMountContext, WidgetMountFn to the barrel.

Phase A gates (lint + format:check + knip) pass. typecheck/test on v2
stack continue to fail per AGENTS.md Rule #8 (parallel-paths stack).

Migration path: v1 patterns (addDOMWidget, widget.element, widget.inputEl,
addWidget('unregistered-type', …)) keep working through the v1 surface
during the D6 parallel-paths window and break deliberately at D6 Phase D
sunset. See decisions/D-widget-converge.md §Migration-guide rows.
2026-05-18 14:46:51 -07:00
Connor Byrne
df921f3512 feat(extension-api): apply Hybrid C Readonly typing per D-immutability-enforcement
Implements the W6.P8.C foundation slice of D-immutability-enforcement
(ACCEPTED 2026-05-14, Hybrid C):

* Point/Size are now readonly tuples — getPosition()[0] = X and
  getSize()[0] = X raise TS-ERR at compile time.
* getWidgets() now returns ReadonlyArray<Readonly<WidgetHandle>>
  — node.getWidgets().push(...) and []= raise TS-ERR.
* inputs()/outputs() are renamed to getInputs()/getOutputs() returning
  ReadonlyArray<Readonly<SlotInfo>>. Old names kept as @deprecated
  aliases for one minor release; SlotInfo fields were already readonly.
* New WidgetHandle.options: Readonly<WidgetOptions> accessor — bulk read
  of the options bag; widget.options.min = 0 and widget.options = {...}
  raise TS-ERR. Mutate via setOption(key, value).
* New WidgetHandle.serializeValue accessor-only — direct assignment
  raises TS-ERR; the v2 path is on('beforeSerialize') per D5.
* widget.value (setValue/getValue pair) is unchanged — already ZERO row
  in R3 per D14.

Zero runtime cost — no Object.freeze, no Proxy. The 'as any' /
'@ts-ignore' / JS-not-TS escape gap is the explicit trade per ADR; the
follow-up W6.P8.FREEZE closes that gap when prioritized.

Refs: decisions/D-immutability-enforcement.md (ACCEPTED 2026-05-14)
      research/architecture/D-immutability-enforcement-blast-radius.md
      W6.P8.B PICK (handoff-6, captured in tasks/W6-P8-B.lock)

Phase A — gates: lint OK / format:check OK / knip OK.
typecheck/test failures on the v2 stack are EXPECTED per AGENTS.md
Rule #8 until rebased onto Alex's ECS branch (PR #11939).
2026-05-18 13:34:58 -07:00
Connor Byrne
a058a410ac feat(ext-api/foundation): D20 identity convergence — rename .entityId → .id, add .equals(), demote *EntityId brands to @internal
Per decisions/D20-id-type-convergence.md (closes design-review rows #3 and #8):

- NodeHandle.entityId → NodeHandle.id (string), add equals(other)
- WidgetHandle.entityId → WidgetHandle.id (string), add equals(other)
- SlotInfo.entityId → SlotInfo.id (string), add equals(other)
- SlotInfo.nodeEntityId → SlotInfo.nodeId (string)
- Drop NodeEntityId/WidgetEntityId/SlotEntityId re-exports from src/extension-api/index.ts
- Mark the three brand re-exports in node.ts/widget.ts as @internal — still
  available to internal package modules but absent from the published barrel
  and TypeDoc output
- knip.config.ts: add -internal tag so the @internal demoted brands stop
  failing the unused-export check
- Update scope-registry.test.ts to use handle.id instead of handle.entityId
- Update createNodeHandle/createWidgetHandle/inputs/outputs in
  extension-api-service.ts to expose id + equals
- Drop unused PublicSlotEntityId import alias

Identity is now opaque on the public surface (90% case). Branded
NodeLocatorId / NodeExecutionId remain public for the protocol-boundary
10% case (workflow JSON, websocket frames) per D20 Tier 2.
2026-05-14 12:49:44 -07:00
Connor Byrne
300be13a4c fix(ext-api): correct NodeMode doc-comments to match LGraphEventMode
Per AUDIT-LG.4 finding: the previous doc-comments had numeric values
mapped to wrong names (claimed 1=Never, 2=Bypass, 3=once, 4=trigger,
but the actual LiteGraph runtime enum is 0=ALWAYS, 1=ON_EVENT,
2=NEVER, 3=ON_TRIGGER, 4=BYPASS). An extension following the old
docs and writing setMode(3) for 'execute once' would actually wire
the dead ON_TRIGGER plumbing.

Also notes that slots 1 and 3 are legacy ABI-reserved positions for
the dead trigger/action subsystem flagged for removal by AUDIT-LG.5.

Cross-ref: research/architecture/audit-litegraph-pruning.md
\xc2\xa7AUDIT-LG.4 \xc2\xa7AUDIT-LG.5
2026-05-14 12:11:22 -07:00
Connor Byrne
f069f540ce chore(ext-api): pull forward review refinements from PKG/EXT
Folds the foundation-file edits that were previously living in PRs #12143
and #12144 into the foundation PR (#12142) so each downstream PR's diff
contains only its own concerns.

From #12143 (pkg):
  - src/extension-api/index.ts: drop startExtensionSystem from public barrel
    (it is the internal boot fn; app.ts imports directly from the service)
  - src/extension-api/types.ts: fix bad escape in widgetCreated JSDoc example
  - src/services/extension-api-service.ts: add @internal tag to
    startExtensionSystem; widen widget.on() Phase-A stub warnings; harden
    addWidget name parse with explanatory TODO
  - src/services/__tests__/scope-registry.test.ts: drop unused removedCb in
    no-dispose-on-subgraph-promotion test

From #12144 (ext):
  - src/extension-api/lifecycle.ts: add JSDoc @example to defineWidgetExtension
  - src/extension-api/node.ts: hoist DOMWidgetOptions interface above NodeHandle
  - src/extension-api/shell.ts: clarify shell.ts is a re-export, not a move
  - src/services/extension-api-service.ts: more informative Phase-A stub
    warning text on node.on() (replaces pkg's terser version per ext review)

These are pure refinements (JSDoc, dev-only warnings, structural tidying);
no runtime behavior change. After this commit, rebasing pkg/ext onto the
new foundation will drop the corresponding source-touching commits as
empty no-ops and leave each PR scoped to its own concerns.
2026-05-14 12:11:22 -07:00
Connor Byrne
d4323d7ab1 feat(ext-api/foundation): D18 Phase 1 brand + registries; D19 drop VueExtension/CustomExtension from public barrel
D18 Phase 1 (decisions/D18-pure-functions-loader-registration.md):
- Add EXTENSION_BRAND symbol + isBrandedExtension type-guard in
  src/extension-api/brand.ts. Phase 2 loader will use these to identify
  define* outputs without grepping module exports.
- Stamp brand inside defineNode / defineWidget / defineExtension. Side-
  effect registration remains for Phase 1; Phase 2 removes it.
- Scaffold src/services/registries/{node,widget,app}ExtensionRegistry.ts
  with the {register,getAll,_clearForTesting} shape the loader will call.

D19 (decisions/D19-vueextension-disposition.md):
- Remove VueExtension and CustomExtension from the @comfyorg/extension-api
  barrel and from src/extension-api/shell.ts. They remain exported from
  src/types/extensionTypes.ts for ExtensionSlot.vue (the only internal
  consumer).

knip.config.ts: ignore the new Phase-1-scaffolding modules until Phase 2
wires them in. All three Phase-A quality gates (lint, format:check, knip)
pass.
2026-05-14 12:01:24 -07:00
Connor Byrne
c5d7fb113f fix(ext-api/foundation): add stub test:extension-api script
Add test:extension-api script that skips gracefully when the vitest
config doesn't exist. This allows CI to pass on stacked PRs that
precede the tf branch (which adds the actual tests).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 20:26:48 -07:00
Connor Byrne
6345359ca8 fix(ext-api/foundation): skip compat-floor gate when tests dir missing
The compat-floor gate should only enforce when extension-api-v2 tests
exist. On stacked PRs that precede the tf branch (which adds tests),
gracefully skip instead of failing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 20:23:41 -07:00
Connor Byrne
b2e9c8f749 feat(ext-api/foundation): add compat-floor CI gate script + touch-point data
Add scripts/check-compat-floor.py and research/touch-points data files
to foundation branch so CI compat-floor job passes on all stacked PRs.

Per PLAN.md §Compat-floor, all blast_radius ≥ 2.0 categories must have
complete test triples before v2 ships.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 19:51:51 -07:00
Connor Byrne
20daf22a68 fix(ext-api): address CodeRabbit review comments (CR-12142)
- Remove startExtensionSystem from public exports (internal-only)
- Remove unused World interface export (make module-local)
- Make addDOMWidget command serializable (store element in side table)
- Wrap event values in proper event objects ({pos}, {size}, {mode})
- defineExtension/defineNode/defineWidget now return options object

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 19:27:30 -07:00
Connor Byrne
f83510a223 refactor(ext-api): rename widgets() to getWidgets()
Aligns with getter naming convention (getPosition, getSize, etc.).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 16:46:58 -07:00
Connor Byrne
ba636765a7 docs(extension-api): improve setup-scope lifecycle hook documentation
Add comprehensive JSDoc explaining the implicit-context pattern for
onNodeMounted and onNodeRemoved hooks:

- Document how the runtime sets a global scope slot before nodeCreated()
- Explain why hooks must be called synchronously (Vue-style constraint)
- Add code examples showing correct vs incorrect async usage
- Document automatic cleanup and memory-safety benefits

Per design review decision #7.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 15:57:33 -07:00
Connor Byrne
d0614e595f fix(ext-api): remove decision refs from public API comments
Per design review decision #1: strip internal decision references
(D10c, D12) from JSDoc comments in the public API surface.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 15:49:48 -07:00
Connor Byrne
8e71fd0436 refactor(ext-api): remove HR comments, wire position/size to layoutStore
Design review cleanup:
- Remove all HR section header comments per design-review-12142.md Decision #1
- SlotEntityId changed from number to string brand (per D13 Slack resolution)

LayoutStore integration (D13 §4):
- NodeHandle.getPosition/setPosition now read/write via layoutStore
- NodeHandle.getSize/setSize now read/write via layoutStore
- Position/size are Yjs CRDT-backed with operation logging (undo/redo ready)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 15:46:08 -07:00
Connor Byrne
8bc2ff0800 docs(ext-api): update README to reflect implementation status
- Change status from "scaffolded" to "implemented (Phase A)"
- Update file structure to match actual files
- Replace stale decision doc refs with ADR links
- Add related research document links

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 13:55:39 -07:00
Connor Byrne
ceec47df88 feat(ext-api): add dev-mode warnings for deprecated/missing patterns
- Add console.warn when node.on('beforeSerialize') is used (ADR-0010)
- Add console.warn if extensions registered but startExtensionSystem()
  never called (ADR-0012 mitigation)
- Enhance JSDoc with migration examples for deprecated beforeSerialize

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 12:51:41 -07:00
Connor Byrne
aa0b00953b docs(research): widget state categories documentation
Documents the 5 categories of widget state (identity, value,
properties, options bag, DOM-specific) and their constraints
for agents working with the extension API.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 18:31:24 -07:00
Connor Byrne
7b9ea4a01f docs(research): serialization context simplification analysis
Analyzes whether the 4 serialization contexts can be reduced.
Recommends keeping all 4 - each has distinct semantics that
extensions may need to differentiate.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 18:30:12 -07:00
Connor Byrne
e292976f8d docs(research): DOM widget convergence analysis
Analyzes the relationship between DOM widgets and base widgets.
Recommends keeping the current partial convergence - unified at
entity/interface/command level, separate at creation API level.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 18:29:20 -07:00
Connor Byrne
a0478f66ea docs(research): coordinate systems analysis
Analyzes canvas vs screen coordinates in the extension API.
Current approach is appropriate - node positions in canvas space,
widget heights in pixels.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 18:28:28 -07:00
Connor Byrne
52146d918f docs(research): identity encapsulation analysis
Analyzes when extensions need raw entity IDs and recommends keeping
them exposed with clear documentation that they are opaque values.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 18:27:34 -07:00
Connor Byrne
fa0079dfb5 docs(adr): ADR-0012 pure function loader pattern
Documents the decision to use pure function registration (defineNode,
defineWidget) with a centralized loader (startExtensionSystem) rather
than side-effect registration at import time.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 18:26:22 -07:00
Connor Byrne
f10990df3a docs(adr): ADR-0011 immutability enforcement via fresh copies
Decision: All collection-returning methods (widgets(), inputs(), etc.)
and object-returning methods (getProperties()) return fresh copies.

Benefits:
- True immutability: mutations never affect internal state
- JavaScript-safe: works regardless of TypeScript types
- Simple mental model: "this is your copy"

Applied consistently across the extension API surface.

Addresses review discussion item #10 from design-review-12142.md (Topic 14)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 18:23:52 -07:00
Connor Byrne
ccfd53bdf5 docs(adr): ADR-0010 deprecate node-level serialization control
Node-level `node.on('beforeSerialize')` is wrong-layered:
- Extension state should flow through widgets, not arbitrary node fields
- Widget-level `widget.on('beforeSerialize')` handles all legitimate use cases
- Node-level hooks encourage ad-hoc state storage that breaks clean separation

Decision: Deprecate now, remove in v1.0. Add migration guidance to ADR.

Addresses review discussion item #9 from design-review-12142.md (Topic 11)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 18:22:28 -07:00
Connor Byrne
8da221b5db refactor(ext-api): rename defineNodeExtension → defineNode, defineWidgetExtension → defineWidget
Shorter function names improve ergonomics while maintaining clarity:
- defineNode() - register node-scoped extensions
- defineWidget() - register widget type extensions

Old names kept as deprecated aliases for backwards compatibility.
Will be removed in v1.0.

Updates all docs, examples, tests, and internal references.

Addresses review discussion item #4 from design-review-12142.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 18:20:11 -07:00
Connor Byrne
e74250fd8a docs(ext-api): document setup-scope hooks rationale
Add clear documentation explaining why onNodeMounted/onNodeRemoved must
be called synchronously in setup scope:
- Implicit context pattern (Vue-style _currentScope slot)
- Automatic cleanup via EffectScope disposal
- Memory leak prevention through scope-tied garbage collection

Also removes remaining decision refs (D6, D10a, etc.) and @stability stable tags.

Addresses review discussion item #6 from design-review-12142.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 17:28:49 -07:00
Connor Byrne
bf272a784d refactor(ext-api): rename widgetCreated → created in WidgetExtensionOptions
Remove redundant "widget" prefix since we're already in widget context.

Addresses review discussion item #5 from design-review-12142.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 17:27:24 -07:00
Connor Byrne
e25e210933 refactor(ext-api): clean up API surface per design review decisions
Changes:
- Strip internal decision refs (D3, D5, D6, D7, etc.) from JSDoc
- Remove @stability stable tags (pre-v1.0, nothing is stable yet)
- Remove apiVersion from ExtensionOptions (telemetry deferred)
- Change NodeMode from number union to string union
  ('always'|'never'|'bypass'|'once'|'onTrigger')
- Rename widget() to getWidget() for consistency

Addresses review discussion items #1, #2, #3, #7, #8 from design-review-12142.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 17:25:35 -07:00
Connor Byrne
5b05f2b793 fix(ext-api): add knip entry + @publicAPI tags for v2 surface
knip flagged the new src/extension-api/* files and exports as unused
because nothing internal consumes them — by design. They are the public
API surface published by PKG2 (#12143).

Changes:
- knip.config.ts: add src/extension-api/index.ts as entry point (per
  AGENTS.md, this barrel is the explicit exception to the
  no-barrel-files-in-src rule because it IS the package entry).
- knip.config.ts: ignore src/types/extensionV2.ts (deprecated stub,
  removed once PKG2 ships).
- knip.config.ts: register `-publicAPI` tag.
- Tag externally-consumed exports with @publicAPI:
  - lifecycle.ts: defineNodeExtension/Extension/WidgetExtension declares
    + back-compat type re-exports
  - extension-api-service.ts: _setDispatchImplForTesting, NodeInstanceScope
  - worldInstance.ts: World

Verifies the full lint-format CI matrix locally:
  pnpm lint        
  pnpm stylelint   (unaffected, CSS-only)
  pnpm format:check 
  pnpm knip        
2026-05-12 12:13:34 -07:00
Connor Byrne
3476d06fc9 docs(ext-api): clarify pauseTracking/resetTracking no-op stub comment (review #12142.A5)
The previous comment described what the Vue intrinsics do but called them
no-op shims without explaining the contract gap. That read as if the
service was relying on dep-tracking suppression that the shim was silently
violating.

Rewrite the comment to make the Phase A scope explicit: this is a no-op
stub by design (the API surface is the contract being stabilized, not the
dep-tracking guarantee), and the real @vue/reactivity import lands together
with the reactive-World contract in #11939. Adds the TODO(#11939) marker so
the swap-in is grep-able alongside the rest of the Phase B handoff points.

No code change - comment-only. Per Architect review #5, we deliberately do
NOT add @vue/reactivity as a direct dep in Phase A.
2026-05-11 17:43:43 -07:00
Connor Byrne
c1748c6fe3 fix(ext-api): break extension-api-service <-> lifecycle circular import (review #12142.A3)
Extract NodeExtensionOptions, ExtensionOptions, and WidgetExtensionOptions
into a new src/extension-api/types.ts so both the runtime service and the
public lifecycle barrel depend on the type contracts without forming a cycle.

Before:
  - extension-api-service.ts imported the option types from
    @/extension-api/lifecycle.
  - lifecycle.ts re-exported onNodeMounted/onNodeRemoved from
    @/services/extension-api-service.
  -> service <-> lifecycle circular import (Architect review #3).

After:
  - types.ts owns the three option-type interfaces (single source of truth).
  - lifecycle.ts re-exports them from ./types so the public path
    @/extension-api/lifecycle keeps working.
  - extension-api-service.ts imports the types from @/extension-api/types.
  - The barrel @/extension-api re-exports from ./types directly.

No public API change - all named exports remain available from
@comfyorg/extension-api and from @/extension-api/lifecycle.
2026-05-11 17:43:43 -07:00
GitHub Action
9a6fff645d [automated] Apply ESLint and Oxfmt fixes 2026-05-11 20:12:22 +00:00
Connor Byrne
2de2e07b36 feat(extension-api): foundation API surface + scope registry + Phase A world stubs
Restratified foundation. Includes:
- Public API declaration files (src/extension-api/{events,identifiers,index,
  lifecycle,node,shell,widget}.ts + README)
- Scope registry runtime (src/services/extension-api-service.ts) +
  scope-registry tests
- Boot wiring (src/scripts/app.ts, src/services/extensionService.ts)
- Phase A ECS world stubs (src/world/{componentKey,entityIds,
  widgets/widgetComponents,worldInstance}.ts)
- Public type re-exports (src/types/extensionV2.ts)

Test framework moved to ext-api/i-tf, npm package + docgen moved to
ext-api/i-pkg, core extension v2 conversions moved to ext-api/i-ext.

Original (pre-restratify) branch tip backed up at
refs/backup/restratify-20260511/ext-api-i-foundation
and tag backup/restratify/ext-api-i-foundation on fork.
2026-05-11 12:36:20 -07:00
257 changed files with 9823 additions and 13657 deletions

View File

@@ -1,173 +0,0 @@
---
name: add-model-page
description: 'add, update, or remove a model page entry on the comfy org website. creates a PR to Comfy-Org/ComfyUI_frontend apps/website folder with the change and posts a Vercel preview link back to Slack.'
---
# add-model-page
add, update, or remove model pages in the ComfyUI website.
## Trigger phrases
- `Add a model page for <model-name>`
- `Update the model page for <model-name>`
- `Remove <model-name> from model pages`
## Phase 1 — Parse the request
Extract:
- **action**: `add` | `update` | `remove`
- **model-name**: raw string (e.g. `flux1-schnell`, `flux1_dev.safetensors`)
Normalize to a slug: lowercase, replace `_` and `.` with `-`, strip file extensions.
Example: `flux1_dev.safetensors``flux1-dev`
## Architecture overview
Models come from two sources merged at build time:
| File | Purpose |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `apps/website/src/config/generated-models.json` | Auto-generated from workflow_templates (slug, name, directory, huggingFaceUrl, workflowCount, displayName, thumbnailUrl, docsUrl) |
| `apps/website/src/config/model-metadata.ts` | Hand-curated overrides (docsUrl, blogUrl, featured) — only add entries that need overrides |
| `apps/website/src/config/models.ts` | Merges the two above; exports typed `Model[]` |
To regenerate the JSON from workflow_templates:
```bash
pnpm tsx apps/website/scripts/generate-models.ts
```
This writes `apps/website/src/config/generated-models.json` directly.
Thumbnails are populated from local `.webp` files in `workflow_templates/templates/` — no network access needed.
---
## Phase 2 — Gather model data (ADD / UPDATE)
Run the generator to get fresh data, then find the model:
```bash
pnpm tsx apps/website/scripts/generate-models.ts
jq '.[] | select(.slug | contains("MODEL_SLUG"))' \
apps/website/src/config/generated-models.json
```
The JSON fields are:
- `slug` — URL slug
- `name` — exact filename or display name for partner nodes
- `huggingFaceUrl` — download URL (empty for partner nodes)
- `directory``diffusion_models` | `loras` | … | `partner_nodes`
- `workflowCount` — integer
- `displayName` — human-readable name
If no match and it is a known API/partner model, add it to `API_PROVIDER_MAP` in
`generate-models.ts` and re-run. Otherwise tell the user.
---
## Phase 3 — Check for existing entry
```bash
jq --arg slug "${SLUG}" '.[] | select(.slug == $slug)' \
apps/website/src/config/generated-models.json
```
- Match found + action is `add` → switch to UPDATE flow automatically
- No match + action is `update` → stop and tell the user
---
## Phase 4A — ADD: new partner/API model not in workflow_templates
For partner nodes (no local file), add an entry to `API_PROVIDER_MAP` in
`apps/website/scripts/generate-models.ts`:
```typescript
mymodel: { name: 'My Model', slug: 'my-model' },
```
Then re-run `pnpm tsx apps/website/scripts/generate-models.ts` — it will appear
in `generated-models.json` automatically.
If you also want a `docsUrl`, `blogUrl`, or a link to the hub model page, add an entry to `model-metadata.ts`:
```typescript
'my-model': {
docsUrl: 'https://docs.comfy.org/tutorials/...',
blogUrl: 'https://blog.comfy.org/...',
hubSlug: 'my-model', // slug at comfy.org/workflows/model/{hubSlug} — only set if the page exists (returns 200)
featured: true
}
```
No changes to `models.ts` or `translations.ts` are needed.
---
## Phase 4B — UPDATE: edit existing entry
Only `model-metadata.ts` needs editing for most updates (docsUrl, blogUrl,
featured). For `displayName` or `directory` changes, edit the entry directly in
`generated-models.json` (until the next generator run would overwrite it — then
fix the source in `generate-models.ts`).
---
## Phase 4C — REMOVE: delete entry
Remove the entry from `generated-models.json` (or mark it with `canonicalSlug`
pointing to the replacement). No translation file changes needed.
---
## Phase 5 — Verify TypeScript
```bash
pnpm typecheck 2>&1 | grep -E "error|warning" | head -20
```
Fix any type errors before proceeding. Common issues:
- `ModelDirectory` type not matching a new `directory` value — add it to the union
- JSON import shape mismatch — `generated-models.json` must match `OutputModel`
---
## Phase 6 — Create PR
```bash
BRANCH="add-model-page-MODEL-SLUG" # or update- / remove-
git checkout -b $BRANCH
git add apps/website/src/config/generated-models.json \
apps/website/scripts/generate-models.ts \
apps/website/src/config/model-metadata.ts
git commit -m "feat(models): add model page for MODEL-SLUG"
git push -u origin $BRANCH
gh pr create \
--title "Add model page: MODEL-SLUG" \
--body "$(cat <<'EOF'
Adds a new model page entry for MODEL-SLUG.
## Changes
- `generated-models.json`: regenerated with new entry (workflowCount N, directory DIRECTORY)
- `model-metadata.ts`: editorial overrides (docsUrl, featured) if needed
EOF
)"
```
For UPDATE use branch `update-model-page-MODEL-SLUG`.
For REMOVE use `remove-model-page-MODEL-SLUG`.
---
## Error states
| Situation | Response |
| ------------------------------- | ---------------------------------------------------------------- |
| Model not in workflow templates | Ask user to verify spelling or add it manually as a partner node |
| Slug already exists (add) | Switch to update flow automatically |
| Slug not found (update/remove) | Stop and ask user to confirm |
| Typecheck fails | Fix the error before pushing |

View File

@@ -19,26 +19,15 @@ reviews:
- name: End-to-end regression coverage for fixes
mode: error
instructions: |
Use only PR metadata already available in the review context:
- the PR title
- commit subjects in this PR
- The files changed in this PR relative to the PR base (equivalent to `base...head`)
- the PR description.
Do not rely on shell commands.
Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR.
If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
Use only PR metadata already available in the review context: the PR title, commit subjects in this PR, the files changed in this PR relative to the PR base (equivalent to `base...head`), and the PR description.
Do not rely on shell commands. Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
Fail if all of the following are true:
1. The PR title and/or any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes files under `src/` or `packages/` related to the main frontend application but the PR does not change at least one file under `browser_tests/`.
3. The PR description lacks a concrete explanation of why an end-to-end regression test was not added.
Do not fail if the changes are exclusively in `apps/website`, just documentation changes, or changes related to CI processes.
The goal is to make sure that fixes include End-to-End regression tests. Do not insist on tests when the PR is not fixing a bug.
Pass otherwise.
When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
Pass if at least one of the following is true:
1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
2. The PR changes at least one file under `browser_tests/`.
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
- name: ADR compliance for entity/litegraph changes
mode: warning
instructions: |

View File

@@ -1,123 +0,0 @@
name: Model Page Discovery
on:
schedule:
- cron: '0 9 * * 1'
workflow_dispatch:
jobs:
discover:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Fetch model labels from hub API
id: hub
shell: bash
run: |
set -euo pipefail
curl -fsSL 'https://comfy.org/api/hub/labels?type=model' -o hub-labels.json
echo "Fetched $(jq '.labels | length' hub-labels.json) model labels from hub"
- name: Checkout ComfyUI_frontend
uses: actions/checkout@v6
with:
sparse-checkout: apps/website/src/config/generated-models.json
- name: Compare against existing models
id: compare
shell: bash
run: |
set -euo pipefail
HUB_SLUGS=$(jq -r '[.labels[].name]' hub-labels.json)
EXISTING_SLUGS=$(node -e "
const fs = require('fs');
const models = JSON.parse(
fs.readFileSync(
'apps/website/src/config/generated-models.json',
'utf8'
)
);
console.log(JSON.stringify(models.map(m => m.slug)));
" 2>/dev/null || echo '[]')
ADDED_SLUGS=$(node -e "
const hub = $HUB_SLUGS;
const existing = new Set($EXISTING_SLUGS);
console.log(JSON.stringify(hub.filter(s => !existing.has(s))));
")
COUNT=$(node -e "console.log($ADDED_SLUGS.length)")
echo "new_count=$COUNT" >> \$GITHUB_OUTPUT
echo "new_slugs=$ADDED_SLUGS" >> \$GITHUB_OUTPUT
if [ "\$COUNT" -eq 0 ]; then
echo "No new models found."
else
echo "Found \$COUNT new model(s)"
fi
- name: Check for existing open discovery issue
id: existing_issue
if: steps.compare.outputs.new_count != '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
COUNT=$(gh issue list \
--repo "$GITHUB_REPOSITORY" \
--state open \
--search 'in:title "New models detected"' \
--json number \
--jq 'length')
echo "open_count=$COUNT" >> $GITHUB_OUTPUT
- name: Open GitHub issue for new models
if: |
steps.compare.outputs.new_count != '0' &&
steps.existing_issue.outputs.open_count == '0'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NEW_SLUGS: ${{ steps.compare.outputs.new_slugs }}
NEW_COUNT: ${{ steps.compare.outputs.new_count }}
shell: bash
run: |
SLUG_LIST=$(node -e "
const slugs = $NEW_SLUGS;
console.log(slugs.map(s => '- \`' + s + '\`').join('\n'));
")
gh issue create \
--repo "$GITHUB_REPOSITORY" \
--title "New models detected — add to model pages" \
--body "## $NEW_COUNT new model(s) found in hub
The weekly model discovery scan found model labels on the hub not yet in
\`apps/website/src/config/generated-models.json\`.
### New slugs ($NEW_COUNT)
$SLUG_LIST
### Next steps
1. Review which of these warrant an SEO model page
2. For local models: run \`SKIP_THUMBNAILS=1 pnpm generate:models\` and commit the result
3. For partner/API models: add to \`API_PROVIDER_MAP\` in \`generate-models.ts\`, regenerate, commit
---
*Generated by the [model-page-discovery workflow](https://github.com/$GITHUB_REPOSITORY/actions/workflows/model-page-discovery.yaml)*"
- name: Skip — open issue already exists
if: |
steps.compare.outputs.new_count != '0' &&
steps.existing_issue.outputs.open_count != '0'
run: echo "An open discovery issue already exists — skipping creation."
- name: No new models found
if: steps.compare.outputs.new_count == '0'
run: echo "No new models found — nothing to do."

View File

@@ -85,15 +85,6 @@
"typescript/no-unused-vars": "off",
"unicorn/no-empty-file": "off",
"vitest/require-mock-type-parameters": "off",
"vitest/consistent-each-for": [
"error",
{
"test": "for",
"it": "for",
"describe": "for",
"suite": "for"
}
],
"unicorn/no-new-array": "off",
"unicorn/no-single-promise-in-promise-methods": "off",
"unicorn/no-useless-fallback-in-spread": "off",

View File

@@ -9,7 +9,6 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' }
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
import { getDefaultLocale } from '@frontend-locales/localeConfig'
import { createI18n } from 'vue-i18n'
function buildLocale<
@@ -168,7 +167,7 @@ const messages: Record<string, LocaleMessages> = {
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,
locale: getDefaultLocale(),
locale: navigator.language.split('-')[0] || 'en',
fallbackLocale: 'en',
messages,
// Ignore warnings for locale options as each option is in its own language.

View File

@@ -113,12 +113,6 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
The script exits non-zero on any non-fresh outcome so stale/empty
snapshots can't be accidentally committed.
## Cloud nodes integration
`/cloud/supported-nodes` (and `/zh-CN/`) lists custom-node packs preinstalled on Comfy Cloud, joined with public metadata from the [ComfyUI Custom Node Registry](https://registry.comfy.org) ([`api.comfy.org`](https://api.comfy.org)). See [`src/pages/cloud/supported-nodes/AGENTS.md`](src/pages/cloud/supported-nodes/AGENTS.md) for the build pipeline, source-file map, and key invariants.
Build-time env var: `WEBSITE_CLOUD_API_KEY` (Cloud `/api/object_info` auth; the build falls back to the committed snapshot when unset). Must also be set in the Vercel project environment.
## HubSpot contact form
The contact page uses HubSpot's hosted form embed for the interest form:
@@ -152,4 +146,3 @@ renders the documented embed container.
- `pnpm test:unit` — Vitest unit tests
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot
- `pnpm cloud-nodes:refresh-snapshot` — refresh the committed cloud nodes snapshot

View File

@@ -23,6 +23,15 @@ test.describe('Careers page @smoke', () => {
expect(await roles.count()).toBeGreaterThan(0)
})
test('each role links to jobs.ashbyhq.com', async ({ page }) => {
const roles = page.getByTestId('careers-role-link')
const count = await roles.count()
for (let i = 0; i < count; i++) {
const href = await roles.nth(i).getAttribute('href')
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
}
})
test('clicking a department button scrolls to and activates that section', async ({
page
}) => {
@@ -54,21 +63,6 @@ test.describe('Careers page @smoke', () => {
})
})
test.describe('Careers page role links', () => {
test('each role links to the Ashby job description page, not the application form', async ({
page
}) => {
await page.goto('/careers')
const roles = page.getByTestId('careers-role-link')
const count = await roles.count()
for (let i = 0; i < count; i++) {
const href = await roles.nth(i).getAttribute('href')
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
expect(href).not.toMatch(/\/application\/?$/)
}
})
})
test.describe('Careers page (zh-CN) @smoke', () => {
test('renders localized heading and roles', async ({ page }) => {
await page.goto('/zh-CN/careers')

View File

@@ -1,169 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Cloud nodes page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/cloud/supported-nodes')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Custom-node packs on Comfy Cloud — supported by default'
)
})
test('renders at least one pack card', async ({ page }) => {
const cards = page.getByTestId('cloud-node-pack-card')
await expect(cards.first()).toBeVisible()
expect(await cards.count()).toBeGreaterThan(0)
})
test('search input filters cards down', async ({ page }) => {
const cards = page.getByTestId('cloud-node-pack-card')
const initialCount = await cards.count()
await page.getByTestId('cloud-nodes-search').fill('impact')
const filteredCards = page.getByTestId('cloud-node-pack-card')
await expect(filteredCards.first()).toBeVisible()
const filteredCount = await filteredCards.count()
expect(filteredCount).toBeGreaterThan(0)
expect(filteredCount).toBeLessThanOrEqual(initialCount)
})
test('renders banner image or fallback element', async ({ page }) => {
const banners = page.getByTestId('cloud-node-pack-banner')
await expect(banners.first()).toBeVisible()
})
test('opens pack detail page from first card', async ({ page }) => {
const firstCard = page.getByTestId('cloud-node-pack-card').first()
await expect(firstCard).toBeVisible()
await firstCard.locator('a').first().click()
await expect(page).toHaveURL(/\/cloud\/supported-nodes\/[a-z0-9-]+$/)
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
})
test('direct pack detail route renders node entries', async ({ page }) => {
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
await expect(
page.getByTestId('cloud-node-pack-detail-node').first()
).toBeVisible()
})
test('search with no matches shows empty state', async ({ page }) => {
await page
.getByTestId('cloud-nodes-search')
.fill('zzzz-this-pack-does-not-exist')
await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(0)
await expect(page.getByText('No matching packs')).toBeVisible()
})
test('clearing search restores the full list', async ({ page }) => {
const cards = page.getByTestId('cloud-node-pack-card')
const initialCount = await cards.count()
await page.getByTestId('cloud-nodes-search').fill('impact')
await expect(cards.first()).toBeVisible()
await page.getByTestId('cloud-nodes-search').fill('')
await expect(cards).toHaveCount(initialCount)
})
test('search matches against node display names, not just pack names', async ({
page
}) => {
await page.getByTestId('cloud-nodes-search').fill('FaceDetailer')
await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(1)
await expect(
page.getByTestId('cloud-node-pack-card-link').first()
).toContainText('Impact Pack')
})
test('switching sort to A → Z reorders cards alphabetically', async ({
page
}) => {
await page.locator('#cloud-nodes-sort').selectOption('az')
const firstName = await page
.getByTestId('cloud-node-pack-card-link')
.first()
.textContent()
expect(firstName?.trim().toLowerCase().charAt(0)).toMatch(/^[a-c]/)
})
test('list grid carries a localized aria-label', async ({ page }) => {
await expect(
page.getByRole('list', {
name: 'Custom-node packs supported on Comfy Cloud'
})
).toBeVisible()
})
test('clicking the back link returns to the index from a detail page', async ({
page
}) => {
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
await page.getByRole('link', { name: 'Back to all packs' }).click()
await expect(page).toHaveURL(/\/cloud\/supported-nodes\/?$/)
await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible()
})
test('detail page renders publisher and external repo link', async ({
page
}) => {
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
const repoLink = page.getByRole('link', {
name: /github\.com\/ltdrdata\/ComfyUI-Impact-Pack/
})
await expect(repoLink).toBeVisible()
await expect(repoLink).toHaveAttribute('rel', /noopener/)
})
test('unknown pack slug 404s', async ({ page }) => {
const response = await page.goto(
'/cloud/supported-nodes/this-pack-does-not-exist'
)
expect(response?.status()).toBe(404)
})
test('JSON-LD ItemList is emitted on the index page', async ({ page }) => {
const jsonLd = page.locator('script[type="application/ld+json"]')
const ldBlocks = await jsonLd.allTextContents()
expect(ldBlocks.some((b) => b.includes('"@type":"ItemList"'))).toBeTruthy()
})
test('JSON-LD payload escapes <-sequences', async ({ page }) => {
const ldBlocks = await page
.locator('script[type="application/ld+json"]')
.allTextContents()
for (const block of ldBlocks) {
expect(block).not.toContain('</script')
}
})
})
test.describe('Cloud nodes page (zh-CN) @smoke', () => {
test('renders localized title and packs', async ({ page }) => {
await page.goto('/zh-CN/cloud/supported-nodes')
await expect(page).toHaveTitle('Comfy Cloud 自定义节点包合集——开箱即用')
await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible()
await expect(
page.getByTestId('cloud-node-pack-banner').first()
).toBeVisible()
})
test('opens pack detail page from first card', async ({ page }) => {
await page.goto('/zh-CN/cloud/supported-nodes')
const firstCard = page.getByTestId('cloud-node-pack-card').first()
await expect(firstCard).toBeVisible()
await firstCard.locator('a').first().click()
await expect(page).toHaveURL(/\/zh-CN\/cloud\/supported-nodes\/[a-z0-9-]+$/)
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
})
})

View File

@@ -1,56 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Pricing page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/cloud/pricing')
})
test('shows the three paid tiers and Enterprise', async ({ page }) => {
const pricingGrid = page
.locator('section', {
has: page.getByRole('heading', { name: /Pricing/i })
})
.locator('.lg\\:grid')
for (const label of ['STANDARD', 'CREATOR', 'PRO']) {
await expect(
pricingGrid.locator('span', { hasText: new RegExp(`^${label}$`) })
).toBeVisible()
}
await expect(
page.getByRole('heading', { name: /Looking for Enterprise Solutions/i })
).toBeVisible()
})
test('does not show the Free tier when SHOW_FREE_TIER is disabled', async ({
page
}) => {
const pricingGrid = page
.locator('section', {
has: page.getByRole('heading', { name: /Pricing/i })
})
.locator('.lg\\:grid')
await expect(
pricingGrid.locator('span', { hasText: /^FREE$/ })
).toHaveCount(0)
await expect(page.getByRole('link', { name: /^START FREE$/ })).toHaveCount(
0
)
await expect(page.getByText(/Everything in Free, plus:/i)).toHaveCount(0)
})
})
test.describe('Cloud pricing teaser @smoke', () => {
test('does not show the "Start free" tagline when SHOW_FREE_TIER is disabled', async ({
page
}) => {
await page.goto('/cloud')
await expect(
page.getByText(/Start free\.\s*Upgrade when you're ready\./i)
).toHaveCount(0)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -15,15 +15,11 @@
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
"test:visual": "playwright test --project visual",
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
"generate:models": "tsx ./scripts/generate-models.ts"
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/object-info-parser": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@vercel/analytics": "catalog:",
"@vueuse/core": "catalog:",

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -1,396 +0,0 @@
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const WORKFLOW_TEMPLATES_BASE =
'https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates'
const TEMPLATES_DIR = fileURLToPath(
new URL('../../../../workflow_templates/templates', import.meta.url)
)
const QUANT_SUFFIXES = [
'_fp8_e4m3fn_scaled',
'_fp8_e4m3fn',
'_fp8_scaled',
'_fp4_mixed',
'_fp8mixed',
'_fp8',
'_fp16',
'_fp4',
'_bf16',
'_int8'
]
interface RawModel {
name: string
url: string
directory: string
}
interface ModelData {
url: string
directory: string
templates: Set<string>
firstTemplate?: string
}
interface OutputModel {
slug: string
name: string
huggingFaceUrl: string
directory: string
workflowCount: number
displayName: string
docsUrl?: string
thumbnailUrl?: string
canonicalSlug?: string
}
// Maps api_*.json filename prefix to a canonical display name and slug.
// Add entries here as new partner integrations land in workflow_templates.
const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
nano: { name: 'Nano Banana', slug: 'nano-banana' },
kling: { name: 'Kling AI', slug: 'kling-ai' },
kling2: { name: 'Kling AI', slug: 'kling-ai' },
meshy: { name: 'Meshy AI', slug: 'meshy-ai' },
luma: { name: 'Luma Dream Machine', slug: 'luma-dream-machine' },
runway: { name: 'Runway', slug: 'runway' },
vidu: { name: 'Vidu', slug: 'vidu' },
bfl: { name: 'Flux (API)', slug: 'flux-api' },
grok: { name: 'Grok Image', slug: 'grok-image' },
stability: { name: 'Stability AI', slug: 'stability-ai' },
bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
google: { name: 'Gemini Image', slug: 'gemini-image' },
hailuo: { name: 'Hailuo MiniMax', slug: 'hailuo-minimax' },
ideogram: { name: 'Ideogram', slug: 'ideogram' },
pixverse: { name: 'Pixverse', slug: 'pixverse' },
rodin: { name: 'Rodin 3D', slug: 'rodin-3d' },
magnific: { name: 'Magnific AI', slug: 'magnific-ai' },
bria: { name: 'Bria AI', slug: 'bria-ai' },
tripo: { name: 'Tripo 3D', slug: 'tripo-3d' },
tripo3: { name: 'Tripo 3D', slug: 'tripo-3d' },
hunyuan3d: { name: 'Hunyuan 3D', slug: 'hunyuan-3d' },
recraft: { name: 'Recraft', slug: 'recraft' },
topaz: { name: 'Topaz Labs', slug: 'topaz-labs' },
moonvalley: { name: 'Moonvalley', slug: 'moonvalley' },
ltxv: { name: 'LTX Video (API)', slug: 'ltxv-api' },
openai: { name: 'OpenAI DALL-E', slug: 'openai-dall-e' },
wan: { name: 'Wan (API)', slug: 'wan-api' },
wan2: { name: 'Wan (API)', slug: 'wan-api' },
veo2: { name: 'Veo 2', slug: 'veo-2' },
veo3: { name: 'Veo 3', slug: 'veo-3' },
flux2: { name: 'Flux 2 (API)', slug: 'flux-2-api' },
wavespeed: { name: 'Wavespeed', slug: 'wavespeed' },
wavespped: { name: 'Wavespeed', slug: 'wavespeed' }
}
function stripExt(name: string): string {
return name.replace(/\.(safetensors|ckpt|pt|bin)$/, '')
}
function stripQuant(base: string): string {
for (const suffix of QUANT_SUFFIXES) {
if (base.endsWith(suffix)) return base.slice(0, -suffix.length)
}
return base
}
function makeSlug(name: string): string {
const base = stripExt(name)
return base
.toLowerCase()
.replace(/[_.]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
function makeDisplayName(name: string): string {
const base = stripExt(name)
return base
.split(/[_-]/)
.map((part) => {
if (/^(fp\d+|bf\d+|int\d+)$/i.test(part)) return part.toUpperCase()
if (/^(e4m3fn|scaled|mixed|fp8mixed)$/i.test(part)) return part
if (/^\d+(\.\d+)?[bBkKmM]?$/.test(part)) return part
return part.charAt(0).toUpperCase() + part.slice(1)
})
.join(' ')
}
function extractModels(
obj: unknown,
templateName: string,
models: Map<string, ModelData>
): void {
if (obj === null || typeof obj !== 'object') return
if (Array.isArray(obj)) {
for (const item of obj) extractModels(item, templateName, models)
return
}
const record = obj as Record<string, unknown>
if (Array.isArray(record['models'])) {
for (const m of record['models'] as unknown[]) {
if (m === null || typeof m !== 'object' || Array.isArray(m)) continue
const model = m as Record<string, unknown>
if (typeof model['name'] !== 'string') continue
const name = model['name']
const url = typeof model['url'] === 'string' ? model['url'] : ''
const directory =
typeof model['directory'] === 'string' ? model['directory'] : ''
if (!models.has(name)) {
models.set(name, {
url,
directory,
templates: new Set(),
firstTemplate: templateName
})
}
models.get(name)!.templates.add(templateName)
}
}
for (const value of Object.values(record)) {
extractModels(value, templateName, models)
}
}
interface ApiModelData {
slug: string
name: string
directory: 'partner_nodes'
templateCount: number
}
function extractApiModels(files: string[]): ApiModelData[] {
const counts = new Map<string, number>()
for (const file of files) {
if (!file.startsWith('api_')) continue
const prefix = file.slice(4).split('_')[0]
const entry = API_PROVIDER_MAP[prefix]
if (!entry) continue
counts.set(entry.slug, (counts.get(entry.slug) ?? 0) + 1)
}
return [...counts.entries()].map(([slug, count]) => {
const found = Object.values(API_PROVIDER_MAP).find((e) => e.slug === slug)!
return {
slug,
name: found.name,
directory: 'partner_nodes' as const,
templateCount: count
}
})
}
// Reads all locale index.json files to build a map of
// raw model filename → tutorialUrl. Index entries name the template file;
// that file's embedded model objects give the actual filenames.
function buildTutorialUrlMap(templatesDir: string): Map<string, string> {
const map = new Map<string, string>()
const indexFiles = readdirSync(templatesDir).filter(
(f) =>
f.startsWith('index') &&
f.endsWith('.json') &&
!f.includes('schema') &&
!f.includes('logo')
)
// Collect template-name → tutorialUrl from all locale indexes (first wins)
const templateTutorialMap = new Map<string, string>()
const sorted = ['index.json', ...indexFiles.filter((f) => f !== 'index.json')]
for (const file of sorted) {
let data: unknown
try {
data = JSON.parse(readFileSync(join(templatesDir, file), 'utf8'))
} catch {
continue
}
if (!Array.isArray(data)) continue
for (const cat of data as unknown[]) {
if (typeof cat !== 'object' || cat === null) continue
const templates = (cat as Record<string, unknown>)['templates']
if (!Array.isArray(templates)) continue
for (const t of templates) {
if (typeof t !== 'object' || t === null) continue
const entry = t as Record<string, unknown>
const tutorialUrl =
typeof entry['tutorialUrl'] === 'string'
? entry['tutorialUrl']
: undefined
const templateName =
typeof entry['name'] === 'string' ? entry['name'] : undefined
if (
tutorialUrl &&
templateName &&
!templateTutorialMap.has(templateName)
) {
templateTutorialMap.set(templateName, tutorialUrl)
}
}
}
}
// For each template with a tutorialUrl, open the template file and map
// every embedded model filename to that tutorialUrl
for (const [templateName, tutorialUrl] of templateTutorialMap) {
const filePath = join(templatesDir, `${templateName}.json`)
let data: unknown
try {
data = JSON.parse(readFileSync(filePath, 'utf8'))
} catch {
continue
}
function extractModelNames(obj: unknown): void {
if (obj === null || typeof obj !== 'object') return
if (Array.isArray(obj)) {
for (const item of obj) extractModelNames(item)
return
}
const record = obj as Record<string, unknown>
if (Array.isArray(record['models'])) {
for (const m of record['models'] as unknown[]) {
if (m === null || typeof m !== 'object' || Array.isArray(m)) continue
const model = m as Record<string, unknown>
if (typeof model['name'] === 'string' && !map.has(model['name'])) {
map.set(model['name'], tutorialUrl)
}
}
}
for (const value of Object.values(record)) {
extractModelNames(value)
}
}
extractModelNames(data)
}
return map
}
function templateThumbnailUrl(
firstTemplate: string | undefined,
templatesDir: string
): string | undefined {
if (!firstTemplate) return undefined
const base = firstTemplate.replace(/\.json$/, '')
const localPath = join(templatesDir, `${base}-1.webp`)
if (!existsSync(localPath)) return undefined
return `${WORKFLOW_TEMPLATES_BASE}/${encodeURIComponent(base)}-1.webp`
}
function run(): void {
const models = new Map<string, ModelData>()
const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith('.json'))
for (const file of files) {
const filePath = join(TEMPLATES_DIR, file)
try {
const raw = readFileSync(filePath, 'utf8')
const data: unknown = JSON.parse(raw)
extractModels(data, file, models)
} catch (error) {
throw new Error(
`Failed to parse ${file}: ${
error instanceof Error ? error.message : String(error)
}`
)
}
}
const apiModels = extractApiModels(files)
const tutorialUrlMap = buildTutorialUrlMap(TEMPLATES_DIR)
const sorted = [...models.entries()].sort(
([, a], [, b]) => b.templates.size - a.templates.size
)
// Build quant convergence map
const groups = new Map<string, Array<[string, ModelData]>>()
for (const [name, data] of sorted) {
const base = stripExt(name)
const canonicalBase = stripQuant(base)
if (!groups.has(canonicalBase)) groups.set(canonicalBase, [])
groups.get(canonicalBase)!.push([name, data])
}
const canonicalMap = new Map<string, string | null>()
for (const members of groups.values()) {
if (members.length > 1) {
const membersSorted = [...members].sort(
([, a], [, b]) => b.templates.size - a.templates.size
)
const canonicalName = membersSorted[0][0]
canonicalMap.set(canonicalName, null)
for (const [name] of membersSorted.slice(1)) {
canonicalMap.set(name, canonicalName)
}
} else {
canonicalMap.set(members[0][0], null)
}
}
const output: OutputModel[] = sorted.map(([name, data]) => {
const canonicalRaw = canonicalMap.get(name) ?? null
const result: OutputModel = {
slug: makeSlug(name),
name,
huggingFaceUrl: data.url,
directory: data.directory,
workflowCount: data.templates.size,
displayName: makeDisplayName(name)
}
const docsUrl = tutorialUrlMap.get(name)
if (docsUrl) result.docsUrl = docsUrl
const thumb = templateThumbnailUrl(data.firstTemplate, TEMPLATES_DIR)
if (thumb) result.thumbnailUrl = thumb
if (canonicalRaw !== null) {
result.canonicalSlug = makeSlug(canonicalRaw)
}
return result
})
const apiOutput: OutputModel[] = apiModels
.sort((a, b) => b.templateCount - a.templateCount)
.map((m) => ({
slug: m.slug,
name: m.name,
huggingFaceUrl: '',
directory: m.directory,
workflowCount: m.templateCount,
displayName: m.name
}))
const combined = [...apiOutput, ...output]
const withThumbs = combined.filter((m) => m.thumbnailUrl).length
process.stdout.write(
` ${withThumbs}/${combined.length} models have thumbnails\n`
)
const defaultOut = join(
fileURLToPath(new URL('.', import.meta.url)),
'../src/config/generated-models.json'
)
const outputArg = process.argv[2] ?? defaultOut
const json = JSON.stringify(combined, null, 2) + '\n'
writeFileSync(outputArg, json, 'utf8')
process.stdout.write(
`Written ${combined.length} models ` +
`(${apiOutput.length} partner, ${output.length} local) to ${outputArg}\n`
)
}
try {
run()
} catch (err) {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
}

View File

@@ -1,32 +0,0 @@
import { renameSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { fetchCloudNodesForBuild } from '../src/utils/cloudNodes'
const snapshotPath = fileURLToPath(
new URL('../src/data/cloud-nodes.snapshot.json', import.meta.url)
)
const tempPath = `${snapshotPath}.tmp`
const outcome = await fetchCloudNodesForBuild()
if (outcome.status !== 'fresh') {
const reason = 'reason' in outcome ? outcome.reason : '(none)'
console.error(
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
)
process.exit(1)
}
const serialized = JSON.stringify(outcome.snapshot, null, 2) + '\n'
writeFileSync(tempPath, serialized, 'utf8')
renameSync(tempPath, snapshotPath)
const totalNodes = outcome.snapshot.packs.reduce(
(n, pack) => n + pack.nodes.length,
0
)
process.stdout.write(
`Wrote snapshot with ${outcome.snapshot.packs.length} pack(s) and ${totalNodes} node(s) to ${snapshotPath}\n`
)

View File

@@ -130,7 +130,7 @@ function scrollToDepartment(deptKey: string) {
<a
v-for="role in dept.roles"
:key="role.id"
:href="role.jobUrl"
:href="role.applyUrl"
target="_blank"
rel="noopener noreferrer"
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"

View File

@@ -1,30 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import SectionLabel from '../common/SectionLabel.vue'
const { locale = 'en' } = defineProps<{
locale?: Locale
}>()
</script>
<template>
<section class="px-6 pt-24 pb-14 md:px-20 md:pt-32 md:pb-20">
<div class="mx-auto flex w-full max-w-6xl flex-col gap-7">
<SectionLabel>
{{ t('cloudNodes.hero.label', locale) }}
</SectionLabel>
<h1
class="text-primary-comfy-canvas max-w-4xl text-4xl/tight md:text-6xl"
>
{{ t('cloudNodes.hero.heading', locale) }}
</h1>
<p class="text-primary-warm-gray max-w-3xl text-base/relaxed md:text-lg">
{{ t('cloudNodes.hero.body', locale) }}
</p>
</div>
</section>
</template>

View File

@@ -1,49 +0,0 @@
<script setup lang="ts">
import type { PackNode } from '../../data/cloudNodes'
import type { Locale } from '../../i18n/translations'
import { useNodesByCategory } from '../../composables/useNodesByCategory'
import { t } from '../../i18n/translations'
const { locale = 'en', nodes } = defineProps<{
locale?: Locale
nodes: readonly PackNode[]
}>()
const { groupedNodes } = useNodesByCategory(() => nodes)
</script>
<template>
<details
class="group border-primary-warm-gray/20 rounded-2xl border px-4 py-3"
>
<summary
class="text-primary-comfy-canvas cursor-pointer list-none text-sm font-semibold"
>
{{ t('cloudNodes.card.nodesHeading', locale) }}
</summary>
<div class="mt-4 flex flex-col gap-5">
<div
v-for="group in groupedNodes"
:key="group.category"
class="flex flex-col gap-2"
>
<h4
class="text-primary-warm-gray text-xs font-semibold tracking-widest uppercase"
>
{{ group.category }}
</h4>
<ul class="flex flex-col gap-1">
<li
v-for="node in group.nodes"
:key="node.name"
class="text-primary-comfy-canvas text-sm/relaxed"
>
{{ node.displayName }}
</li>
</ul>
</div>
</div>
</details>
</template>

View File

@@ -1,52 +0,0 @@
<script setup lang="ts">
import { useBannerImage } from '../../composables/useBannerImage'
const { bannerUrl, iconUrl, name } = defineProps<{
bannerUrl?: string
iconUrl?: string
name: string
}>()
const {
DEFAULT_BANNER,
isImageError,
showDefaultBanner,
imgSrc,
onImageError
} = useBannerImage({
bannerUrl: () => bannerUrl,
iconUrl: () => iconUrl
})
</script>
<template>
<div
class="z-0 aspect-7/3 w-full overflow-hidden"
data-testid="cloud-node-pack-banner"
>
<div v-if="showDefaultBanner" class="size-full">
<img
:src="DEFAULT_BANNER"
:alt="`${name} banner`"
class="size-full object-cover"
/>
</div>
<div v-else class="relative size-full">
<div
v-if="imgSrc && !isImageError"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
:style="{ backgroundImage: `url(${imgSrc})`, filter: 'blur(10px)' }"
/>
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="`${name} banner`"
:class="
isImageError
? 'relative z-10 size-full object-cover'
: 'relative z-10 size-full object-contain'
"
@error="onImageError"
/>
</div>
</div>
</template>

View File

@@ -1,80 +0,0 @@
<script setup lang="ts">
import type { Pack } from '../../data/cloudNodes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import NodeList from './NodeList.vue'
import PackBanner from './PackBanner.vue'
const { locale = 'en', pack } = defineProps<{
locale?: Locale
pack: Pack
}>()
const detailHref =
locale === 'zh-CN'
? `/zh-CN/cloud/supported-nodes/${pack.id}`
: `/cloud/supported-nodes/${pack.id}`
function nodeCountLabel(nodeCount: number): string {
const key =
new Intl.PluralRules(locale).select(nodeCount) === 'one'
? 'cloudNodes.card.nodeCountOne'
: 'cloudNodes.card.nodeCountOther'
return t(key, locale).replace('{count}', String(nodeCount))
}
</script>
<template>
<article
class="bg-transparency-white-t5 border-primary-warm-gray/20 flex h-full flex-col overflow-hidden rounded-3xl border"
data-testid="cloud-node-pack-card"
>
<PackBanner
:banner-url="pack.bannerUrl"
:icon-url="pack.iconUrl"
:name="pack.displayName"
/>
<div class="flex flex-1 flex-col gap-5 p-5 md:p-6">
<div class="flex flex-col gap-2">
<h3 class="text-primary-comfy-canvas text-2xl/tight font-semibold">
<a
:href="detailHref"
class="hover:text-primary-comfy-yellow"
data-testid="cloud-node-pack-card-link"
>
{{ pack.displayName }}
</a>
</h3>
<p class="text-primary-warm-gray text-sm/relaxed">
{{
pack.description ||
t('cloudNodes.card.unavailableDescription', locale)
}}
</p>
</div>
<div class="flex flex-wrap items-center gap-3 text-sm">
<a
v-if="pack.repoUrl"
:href="pack.repoUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 font-semibold underline"
>
{{ t('cloudNodes.card.viewRepo', locale) }}
</a>
<span v-else class="text-primary-warm-gray">
{{ t('cloudNodes.card.viewRepo', locale) }}
</span>
<span class="text-primary-warm-gray"></span>
<span class="text-primary-comfy-canvas">{{
nodeCountLabel(pack.nodes.length)
}}</span>
</div>
<NodeList :locale="locale" :nodes="pack.nodes" class="mt-auto" />
</div>
</article>
</template>

View File

@@ -1,203 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Pack } from '../../data/cloudNodes'
import type { Locale } from '../../i18n/translations'
import {
formatLocalizedMediumDate,
formatLocalizedNumber
} from '@comfyorg/shared-frontend-utils/formatUtil'
import { useNodesByCategory } from '../../composables/useNodesByCategory'
import { t } from '../../i18n/translations'
import PackBanner from './PackBanner.vue'
const { pack, locale = 'en' } = defineProps<{
pack: Pack
locale?: Locale
}>()
const backHref =
locale === 'zh-CN' ? '/zh-CN/cloud/supported-nodes' : '/cloud/supported-nodes'
const { groupedNodes } = useNodesByCategory(() => pack.nodes)
</script>
<template>
<article
class="px-6 pb-20 md:px-20 md:pb-28"
data-testid="cloud-node-pack-detail"
>
<div class="mx-auto flex w-full max-w-6xl flex-col gap-8">
<a
:href="backHref"
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 w-fit text-sm font-semibold underline"
>
{{ t('cloudNodes.detail.back', locale) }}
</a>
<div
class="bg-transparency-white-t5 border-primary-warm-gray/20 overflow-hidden rounded-3xl border"
>
<PackBanner
:banner-url="pack.bannerUrl"
:icon-url="pack.iconUrl"
:name="pack.displayName"
/>
<div class="flex flex-col gap-7 p-5 md:p-6">
<header class="flex flex-col gap-2">
<h1
class="text-primary-comfy-canvas text-3xl/tight font-semibold md:text-4xl"
>
{{ pack.displayName }}
</h1>
<p
class="text-primary-warm-gray text-sm/relaxed md:text-base/relaxed"
>
{{
pack.description ||
t('cloudNodes.card.unavailableDescription', locale)
}}
</p>
</header>
<dl class="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.card.viewRepo', locale) }}
</dt>
<dd>
<a
v-if="pack.repoUrl"
:href="pack.repoUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 font-semibold underline"
>
{{ pack.repoUrl }}
</a>
<span v-else class="text-primary-comfy-canvas"></span>
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.detail.publisher', locale) }}
</dt>
<dd class="text-primary-comfy-canvas">
{{ pack.publisher?.name || pack.publisher?.id || '—' }}
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.detail.downloads', locale) }}
</dt>
<dd class="text-primary-comfy-canvas">
{{ formatLocalizedNumber(pack.downloads, locale) }}
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.detail.stars', locale) }}
</dt>
<dd class="text-primary-comfy-canvas">
{{ formatLocalizedNumber(pack.githubStars, locale) }}
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.detail.latestVersion', locale) }}
</dt>
<dd class="text-primary-comfy-canvas">
{{ pack.latestVersion || '—' }}
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.detail.license', locale) }}
</dt>
<dd class="text-primary-comfy-canvas">
{{ pack.license || '—' }}
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-primary-warm-gray">
{{ t('cloudNodes.detail.lastUpdated', locale) }}
</dt>
<dd class="text-primary-comfy-canvas">
{{ formatLocalizedMediumDate(pack.lastUpdated, locale) }}
</dd>
</div>
</dl>
<section class="flex flex-col gap-4">
<h2
class="text-primary-comfy-canvas text-xl font-semibold md:text-2xl"
>
{{ t('cloudNodes.detail.nodesHeading', locale) }}
</h2>
<section
v-for="group in groupedNodes"
:key="group.category"
class="border-primary-warm-gray/20 rounded-2xl border p-4"
>
<h3 class="text-primary-comfy-canvas text-base font-semibold">
{{ group.category }}
</h3>
<ul class="mt-3 flex flex-col gap-3">
<li
v-for="node in group.nodes"
:key="node.name"
class="border-primary-warm-gray/20 rounded-xl border p-3"
data-testid="cloud-node-pack-detail-node"
>
<div class="flex flex-wrap items-center gap-2">
<span
class="text-primary-comfy-canvas text-sm font-semibold"
>
{{ node.displayName }}
</span>
<span
v-if="node.experimental"
:class="
cn(
'bg-primary-comfy-yellow/20 text-primary-comfy-canvas rounded-full px-2 py-0.5 text-xs font-semibold'
)
"
>
{{ t('cloudNodes.detail.experimental', locale) }}
</span>
<span
v-if="node.deprecated"
:class="
cn(
'text-primary-comfy-canvas rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-semibold'
)
"
>
{{ t('cloudNodes.detail.deprecated', locale) }}
</span>
</div>
<p
v-if="node.description"
class="text-primary-warm-gray mt-2 text-sm/relaxed"
>
{{ node.description }}
</p>
</li>
</ul>
</section>
</section>
</div>
</div>
</div>
</article>
</template>

View File

@@ -1,113 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import type { Pack } from '../../data/cloudNodes'
import type { Locale } from '../../i18n/translations'
import { useFilteredPacks } from '../../composables/useFilteredPacks'
import type { PackSortMode } from '../../composables/useFilteredPacks'
import { t } from '../../i18n/translations'
import SectionLabel from '../common/SectionLabel.vue'
import PackCard from './PackCard.vue'
const { locale = 'en', packs } = defineProps<{
locale?: Locale
packs: readonly Pack[]
}>()
const query = defineModel<string>('query', { default: '' })
const sortMode = ref<PackSortMode>('downloads')
const { filteredPacks } = useFilteredPacks({
packs: () => packs,
query,
sortMode
})
</script>
<template>
<section class="px-6 pb-20 md:px-20 md:pb-32">
<div class="mx-auto flex w-full max-w-6xl flex-col gap-8">
<div class="flex flex-col gap-3">
<SectionLabel>
{{ t('cloudNodes.hero.label', locale) }}
</SectionLabel>
<h2
class="text-primary-comfy-canvas text-3xl/tight font-medium md:text-4xl"
>
{{ t('cloudNodes.section.heading', locale) }}
</h2>
</div>
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
>
<label for="cloud-nodes-search" class="sr-only">
{{ t('cloudNodes.search.label', locale) }}
</label>
<input
id="cloud-nodes-search"
v-model="query"
:placeholder="t('cloudNodes.search.placeholder', locale)"
:class="
cn(
'bg-transparency-white-t5 border-primary-warm-gray/30 text-primary-comfy-canvas placeholder:text-primary-warm-gray/80 w-full rounded-2xl border px-4 py-3 text-sm md:max-w-md'
)
"
data-testid="cloud-nodes-search"
/>
<label for="cloud-nodes-sort" class="sr-only">
{{ t('cloudNodes.sort.label', locale) }}
</label>
<select
id="cloud-nodes-sort"
v-model="sortMode"
class="bg-transparency-white-t5 border-primary-warm-gray/30 text-primary-comfy-canvas w-full appearance-none rounded-2xl border bg-size-[0.65rem_0.65rem] bg-position-[right_1rem_center] bg-no-repeat py-3 pr-12 pl-4 text-sm md:w-64"
:style="{
backgroundImage:
'url(\'data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 12 12%22 fill=%22%23a39b8d%22><path d=%22M6 9.2L1.4 4.6 2.8 3.2 6 6.4l3.2-3.2 1.4 1.4z%22/></svg>\')'
}"
>
<option value="downloads">
{{ t('cloudNodes.sort.downloads', locale) }}
</option>
<option value="mostNodes">
{{ t('cloudNodes.sort.mostNodes', locale) }}
</option>
<option value="az">{{ t('cloudNodes.sort.az', locale) }}</option>
<option value="recentlyUpdated">
{{ t('cloudNodes.sort.recentlyUpdated', locale) }}
</option>
</select>
</div>
<p
v-if="filteredPacks.length === 0"
class="text-primary-warm-gray rounded-2xl border border-dashed border-current/30 px-5 py-6 text-sm"
>
<span class="text-primary-comfy-canvas block text-base font-semibold">
{{ t('cloudNodes.empty.heading', locale) }}
</span>
<span class="mt-2 block">{{ t('cloudNodes.empty.body', locale) }}</span>
</p>
<div
v-else
class="grid grid-cols-1 gap-5 md:grid-cols-2"
role="list"
:aria-label="t('cloudNodes.list.ariaLabel', locale)"
>
<PackCard
v-for="pack in filteredPacks"
:key="pack.id"
:pack="pack"
:locale="locale"
role="listitem"
/>
</div>
</div>
</section>
</template>

View File

@@ -26,7 +26,7 @@ const {
<img
src="/icons/node-left.svg"
alt=""
class="-mx-px h-full w-auto self-stretch"
class="-mx-px self-stretch"
aria-hidden="true"
/>
@@ -38,7 +38,7 @@ const {
v-if="i > 0"
src="/icons/node-union.svg"
alt=""
class="-mx-px h-full w-auto self-stretch"
class="-mx-px self-stretch"
aria-hidden="true"
/>
<span
@@ -72,7 +72,7 @@ const {
<img
src="/icons/node-right.svg"
alt=""
class="-mx-px h-full w-auto self-stretch"
class="-mx-px self-stretch"
aria-hidden="true"
/>
</div>

View File

@@ -1,137 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import BrandButton from '../common/BrandButton.vue'
import { t } from '../../i18n/translations'
const {
displayName,
huggingFaceUrl,
docsUrl,
blogUrl,
hubSlug,
workflowCount,
directory
} = defineProps<{
displayName: string
huggingFaceUrl: string
docsUrl?: string
blogUrl?: string
hubSlug?: string
workflowCount: number
directory: string
}>()
const workflowsUrl = hubSlug
? `https://www.comfy.org/workflows/model/${hubSlug}`
: null
const dirDisplayMap: Record<string, string> = {
diffusion_models: 'Diffusion Model',
checkpoints: 'Checkpoint',
loras: 'LoRA',
controlnet: 'ControlNet',
clip_vision: 'CLIP Vision',
model_patches: 'Model Patch',
vae: 'VAE',
text_encoders: 'Text Encoder',
audio_encoders: 'Audio Encoder',
latent_upscale_models: 'Latent Upscale Model',
upscale_models: 'Upscale Model',
style_models: 'Style Model',
partner_nodes: 'Partner Node'
}
const eyebrow = dirDisplayMap[directory] ?? directory
const isPartnerNode = directory === 'partner_nodes'
</script>
<template>
<section
:class="
cn(
'mx-auto flex max-w-7xl flex-col gap-8 px-6 py-16',
'lg:flex-row lg:items-center lg:gap-16 lg:px-8 lg:py-24'
)
"
>
<div class="flex max-w-2xl flex-1 flex-col gap-6">
<p
class="text-sm font-medium uppercase tracking-widest text-primary-comfy-yellow"
>
{{ eyebrow }}
</p>
<h1 class="text-4xl font-bold text-primary-comfy-canvas lg:text-6xl">
{{ displayName }} in ComfyUI
</h1>
<p class="text-sm text-primary-comfy-canvas/60">
{{
t('models.hero.workflowCount').replace(
'{count}',
String(workflowCount)
)
}}
</p>
<div class="flex flex-col gap-3 sm:flex-row">
<BrandButton
v-if="workflowsUrl"
:href="workflowsUrl"
variant="solid"
size="lg"
class="w-full uppercase sm:w-auto sm:min-w-48"
>
{{ t('models.hero.primaryCta') }}
</BrandButton>
<BrandButton
v-if="!isPartnerNode && huggingFaceUrl"
:href="huggingFaceUrl"
target="_blank"
rel="noopener noreferrer"
:variant="workflowsUrl ? 'outline' : 'solid'"
size="lg"
class="w-full uppercase sm:w-auto sm:min-w-48"
>
{{ t('models.hero.secondaryCta') }}
</BrandButton>
<BrandButton
v-if="!workflowsUrl"
href="https://www.comfy.org/cloud"
target="_blank"
rel="noopener noreferrer"
:variant="huggingFaceUrl && !isPartnerNode ? 'outline' : 'solid'"
size="lg"
class="w-full uppercase sm:w-auto sm:min-w-48"
>
{{ t('models.hero.cloudCta') }}
</BrandButton>
<BrandButton
v-if="docsUrl"
:href="docsUrl"
target="_blank"
rel="noopener noreferrer"
variant="outline"
size="lg"
class="w-full uppercase sm:w-auto sm:min-w-48"
>
{{ t('models.hero.tutorialCta') }}
</BrandButton>
</div>
<div v-if="blogUrl" class="text-sm text-primary-comfy-canvas/60">
<a
:href="blogUrl"
target="_blank"
rel="noopener noreferrer"
class="hover:text-primary-comfy-canvas underline"
>
{{ t('models.hero.blogLink') }}
</a>
</div>
</div>
</section>
</template>

View File

@@ -7,7 +7,6 @@ import { ref } from 'vue'
import BrandButton from '../common/BrandButton.vue'
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
import PricingTierCard from './PricingTierCard.vue'
import { SHOW_FREE_TIER } from '../../config/features'
import { externalLinks, getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
@@ -38,23 +37,21 @@ interface PricingPlan {
isEnterprise?: boolean
}
const freePlan: PricingPlan = {
id: 'free',
labelKey: 'pricing.plan.free.label',
summaryKey: 'pricing.plan.free.summary',
priceKey: 'pricing.plan.free.price',
creditsKey: 'pricing.plan.free.credits',
estimateKey: 'pricing.plan.free.estimate',
ctaKey: 'pricing.plan.free.cta',
ctaHref: externalLinks.cloud,
features: [
{ text: 'pricing.plan.free.feature1' },
{ text: 'pricing.plan.free.feature2' }
]
}
const plans: PricingPlan[] = [
...(SHOW_FREE_TIER ? [freePlan] : []),
{
id: 'free',
labelKey: 'pricing.plan.free.label',
summaryKey: 'pricing.plan.free.summary',
priceKey: 'pricing.plan.free.price',
creditsKey: 'pricing.plan.free.credits',
estimateKey: 'pricing.plan.free.estimate',
ctaKey: 'pricing.plan.free.cta',
ctaHref: externalLinks.cloud,
features: [
{ text: 'pricing.plan.free.feature1' },
{ text: 'pricing.plan.free.feature2' }
]
},
{
id: 'standard',
labelKey: 'pricing.plan.standard.label',
@@ -64,9 +61,7 @@ const plans: PricingPlan[] = [
estimateKey: 'pricing.plan.standard.estimate',
ctaKey: 'pricing.plan.standard.cta',
ctaHref: subscribeUrl('standard'),
featureIntroKey: SHOW_FREE_TIER
? 'pricing.plan.standard.featureIntro'
: undefined,
featureIntroKey: 'pricing.plan.standard.featureIntro',
features: [
{ text: 'pricing.plan.standard.feature1' },
{ text: 'pricing.plan.standard.feature2' }
@@ -155,14 +150,9 @@ const activePlanIndex = ref(0)
</button>
</div>
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: single card -->
<!-- Desktop: 4-column grid / Mobile: single card -->
<div
:class="
cn(
'rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:gap-2',
standardPlans.length === 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3'
)
"
class="rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:grid-cols-4 lg:gap-2"
>
<PricingTierCard v-for="plan in standardPlans" :key="plan.id">
<!-- Label + badge -->
@@ -233,18 +223,10 @@ const activePlanIndex = ref(0)
<!-- Features -->
<div v-if="plan.features.length" class="px-6 py-3">
<p
v-if="plan.featureIntroKey"
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
>
{{ t(plan.featureIntroKey, locale) }}
</p>
<p
v-else
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
aria-hidden="true"
>
&nbsp;
<p class="text-primary-comfy-canvas mb-2 text-sm font-semibold">
{{
plan.featureIntroKey ? t(plan.featureIntroKey, locale) : '&nbsp;'
}}
</p>
<ul class="space-y-2">
<li

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import { SHOW_FREE_TIER } from '../../../config/features'
import { getRoutes } from '../../../config/routes'
import { t } from '../../../i18n/translations'
@@ -26,10 +25,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
{{ t('cloud.pricing.description', locale) }}
</p>
<p
v-if="SHOW_FREE_TIER"
class="text-primary-comfy-ink mt-4 text-base font-bold"
>
<p class="text-primary-comfy-ink mt-4 text-base font-bold">
{{ t('cloud.pricing.tagline', locale) }}
</p>
</div>

View File

@@ -1,52 +0,0 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import { useBannerImage } from './useBannerImage'
describe('useBannerImage', () => {
it('shows the default banner when neither url is provided', () => {
const { showDefaultBanner, imgSrc } = useBannerImage({})
expect(showDefaultBanner.value).toBe(true)
expect(imgSrc.value).toBeUndefined()
})
it('prefers bannerUrl over iconUrl when both are provided', () => {
const { showDefaultBanner, imgSrc } = useBannerImage({
bannerUrl: 'https://example.com/banner.png',
iconUrl: 'https://example.com/icon.png'
})
expect(showDefaultBanner.value).toBe(false)
expect(imgSrc.value).toBe('https://example.com/banner.png')
})
it('falls back to iconUrl when bannerUrl is missing', () => {
const { imgSrc } = useBannerImage({
iconUrl: 'https://example.com/icon.png'
})
expect(imgSrc.value).toBe('https://example.com/icon.png')
})
it('reactively updates when sources change', () => {
const banner = ref<string | undefined>(undefined)
const { showDefaultBanner, imgSrc } = useBannerImage({ bannerUrl: banner })
expect(showDefaultBanner.value).toBe(true)
banner.value = 'https://example.com/new.png'
expect(showDefaultBanner.value).toBe(false)
expect(imgSrc.value).toBe('https://example.com/new.png')
})
it('flips isImageError when onImageError is called', () => {
const { isImageError, onImageError } = useBannerImage({
bannerUrl: 'x'
})
expect(isImageError.value).toBe(false)
onImageError()
expect(isImageError.value).toBe(true)
})
it('exposes the default banner constant for consumers', () => {
const { DEFAULT_BANNER } = useBannerImage({})
expect(DEFAULT_BANNER).toBe('/assets/images/fallback-gradient-avatar.svg')
})
})

View File

@@ -1,30 +0,0 @@
import { computed, ref, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
interface UseBannerImageInput {
bannerUrl?: MaybeRefOrGetter<string | undefined>
iconUrl?: MaybeRefOrGetter<string | undefined>
}
export function useBannerImage({ bannerUrl, iconUrl }: UseBannerImageInput) {
const isImageError = ref(false)
const showDefaultBanner = computed(
() => !toValue(bannerUrl) && !toValue(iconUrl)
)
const imgSrc = computed(() => toValue(bannerUrl) || toValue(iconUrl))
function onImageError() {
isImageError.value = true
}
return {
DEFAULT_BANNER,
isImageError,
showDefaultBanner,
imgSrc,
onImageError
}
}

View File

@@ -1,144 +0,0 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import type { Pack, PackNode } from '../data/cloudNodes'
import { useFilteredPacks } from './useFilteredPacks'
import type { PackSortMode } from './useFilteredPacks'
function pack(overrides: Partial<Pack> = {}): Pack {
return {
id: overrides.id ?? 'pack',
displayName: overrides.displayName ?? 'Pack',
nodes: overrides.nodes ?? [],
downloads: overrides.downloads,
lastUpdated: overrides.lastUpdated,
...overrides
}
}
function node(name: string, displayName: string): PackNode {
return { name, displayName, category: 'x' }
}
describe('useFilteredPacks', () => {
const packs: readonly Pack[] = [
pack({
id: 'a',
displayName: 'Alpha',
downloads: 100,
lastUpdated: '2025-01-01T00:00:00Z',
nodes: [node('aa', 'Aardvark')]
}),
pack({
id: 'b',
displayName: 'Beta',
downloads: 300,
lastUpdated: '2025-06-01T00:00:00Z',
nodes: [node('bb', 'Beaver'), node('bb2', 'Bumblebee')]
}),
pack({
id: 'c',
displayName: 'Gamma',
downloads: 200,
lastUpdated: '2025-03-01T00:00:00Z',
nodes: [
node('cc', 'Cat'),
node('cc2', 'Crocodile'),
node('cc3', 'Capybara')
]
})
]
it('sorts by downloads desc by default', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: '',
sortMode: 'downloads' as PackSortMode
})
expect(filteredPacks.value.map((p) => p.id)).toEqual(['b', 'c', 'a'])
})
it('sorts most-nodes places highest count first', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: '',
sortMode: 'mostNodes' as PackSortMode
})
expect(filteredPacks.value.map((p) => p.id)).toEqual(['c', 'b', 'a'])
})
it('sorts A → Z by display name', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: '',
sortMode: 'az' as PackSortMode
})
expect(filteredPacks.value.map((p) => p.displayName)).toEqual([
'Alpha',
'Beta',
'Gamma'
])
})
it('sorts recently updated newest first', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: '',
sortMode: 'recentlyUpdated' as PackSortMode
})
expect(filteredPacks.value.map((p) => p.id)).toEqual(['b', 'c', 'a'])
})
it('treats invalid lastUpdated as 0', () => {
const broken = [
pack({ id: 'x', lastUpdated: 'nonsense' }),
pack({ id: 'y', lastUpdated: '2025-01-01T00:00:00Z' })
]
const { filteredPacks } = useFilteredPacks({
packs: broken,
query: '',
sortMode: 'recentlyUpdated' as PackSortMode
})
expect(filteredPacks.value[0].id).toBe('y')
})
it('matches the search query against pack display names', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: 'beta',
sortMode: 'az' as PackSortMode
})
expect(filteredPacks.value.map((p) => p.id)).toEqual(['b'])
})
it('matches the search query against node display names', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: 'CAPYBARA',
sortMode: 'az' as PackSortMode
})
expect(filteredPacks.value.map((p) => p.id)).toEqual(['c'])
})
it('returns empty when nothing matches', () => {
const { filteredPacks } = useFilteredPacks({
packs,
query: 'zzz-no-such-thing',
sortMode: 'az' as PackSortMode
})
expect(filteredPacks.value).toHaveLength(0)
})
it('reacts when the query ref changes', () => {
const query = ref('beta')
const { filteredPacks } = useFilteredPacks({
packs,
query,
sortMode: 'az' as PackSortMode
})
expect(filteredPacks.value).toHaveLength(1)
query.value = ''
expect(filteredPacks.value).toHaveLength(3)
})
})

View File

@@ -1,53 +0,0 @@
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import type { Pack } from '../data/cloudNodes'
export type PackSortMode = 'downloads' | 'mostNodes' | 'az' | 'recentlyUpdated'
interface UseFilteredPacksInput {
packs: MaybeRefOrGetter<readonly Pack[]>
query: MaybeRefOrGetter<string>
sortMode: MaybeRefOrGetter<PackSortMode>
}
function matchesQuery(pack: Pack, normalizedQuery: string): boolean {
if (pack.displayName.toLowerCase().includes(normalizedQuery)) return true
return pack.nodes.some((node) =>
node.displayName.toLowerCase().includes(normalizedQuery)
)
}
function safeTimestamp(value: string | undefined): number {
if (!value) return 0
const ts = Date.parse(value)
return Number.isNaN(ts) ? 0 : ts
}
export function useFilteredPacks(input: UseFilteredPacksInput) {
const filteredPacks = computed<Pack[]>(() => {
const allPacks = toValue(input.packs)
const normalizedQuery = toValue(input.query).trim().toLowerCase()
const matching =
normalizedQuery.length === 0
? [...allPacks]
: allPacks.filter((pack) => matchesQuery(pack, normalizedQuery))
const mode = toValue(input.sortMode)
if (mode === 'az') {
return matching.sort((a, b) => a.displayName.localeCompare(b.displayName))
}
if (mode === 'recentlyUpdated') {
return matching.sort(
(a, b) => safeTimestamp(b.lastUpdated) - safeTimestamp(a.lastUpdated)
)
}
if (mode === 'mostNodes') {
return matching.sort((a, b) => b.nodes.length - a.nodes.length)
}
return matching.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0))
})
return { filteredPacks }
}

View File

@@ -1,68 +0,0 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import type { PackNode } from '../data/cloudNodes'
import { useNodesByCategory } from './useNodesByCategory'
function node(name: string, displayName: string, category: string): PackNode {
return { name, displayName, category }
}
describe('useNodesByCategory', () => {
it('groups nodes by category', () => {
const { groupedNodes } = useNodesByCategory(() => [
node('A', 'A', 'cat-1'),
node('B', 'B', 'cat-2'),
node('C', 'C', 'cat-1')
])
expect(groupedNodes.value).toHaveLength(2)
expect(groupedNodes.value[0]).toMatchObject({
category: 'cat-1',
nodes: [
expect.objectContaining({ name: 'A' }),
expect.objectContaining({ name: 'C' })
]
})
})
it('sorts nodes alphabetically by display name within a category', () => {
const { groupedNodes } = useNodesByCategory(() => [
node('z', 'Zulu', 'x'),
node('a', 'Alpha', 'x'),
node('m', 'Mike', 'x')
])
expect(groupedNodes.value[0].nodes.map((n) => n.displayName)).toEqual([
'Alpha',
'Mike',
'Zulu'
])
})
it('sorts categories alphabetically', () => {
const { groupedNodes } = useNodesByCategory(() => [
node('a', 'A', 'beta'),
node('b', 'B', 'alpha'),
node('c', 'C', 'gamma')
])
expect(groupedNodes.value.map((g) => g.category)).toEqual([
'alpha',
'beta',
'gamma'
])
})
it('falls back to a placeholder for missing categories', () => {
const { groupedNodes } = useNodesByCategory(() => [node('a', 'A', '')])
expect(groupedNodes.value[0].category).toBe('—')
})
it('reacts to ref changes', () => {
const nodes = ref<PackNode[]>([node('a', 'A', 'x')])
const { groupedNodes } = useNodesByCategory(nodes)
expect(groupedNodes.value).toHaveLength(1)
nodes.value = [node('a', 'A', 'x'), node('b', 'B', 'y')]
expect(groupedNodes.value).toHaveLength(2)
})
})

View File

@@ -1,40 +0,0 @@
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import type { PackNode } from '../data/cloudNodes'
const UNCATEGORIZED = '—'
interface NodeCategoryGroup {
category: string
nodes: PackNode[]
}
export function useNodesByCategory(
nodes: MaybeRefOrGetter<readonly PackNode[]>
) {
const groupedNodes = computed<NodeCategoryGroup[]>(() => {
const byCategory = new Map<string, PackNode[]>()
for (const node of toValue(nodes)) {
const category = node.category || UNCATEGORIZED
const existing = byCategory.get(category)
if (existing) {
existing.push(node)
continue
}
byCategory.set(category, [node])
}
return [...byCategory.entries()]
.map(([category, items]) => ({
category,
nodes: [...items].sort((a, b) =>
a.displayName.localeCompare(b.displayName)
)
}))
.sort((a, b) => a.category.localeCompare(b.category))
})
return { groupedNodes }
}

View File

@@ -1 +0,0 @@
export const SHOW_FREE_TIER = false

File diff suppressed because it is too large Load Diff

View File

@@ -1,208 +0,0 @@
interface ModelOverride {
docsUrl?: string
blogUrl?: string
featured?: boolean
// Slug used on comfy.org/workflows/model/{hubSlug}. Only set when the page exists.
hubSlug?: string
}
export const modelMetadata: Record<string, ModelOverride> = {
'nano-banana': {
docsUrl:
'https://docs.comfy.org/tutorials/partner-nodes/google/nano-banana-pro',
hubSlug: 'nano-banana',
featured: true
},
'kling-ai': {
docsUrl:
'https://docs.comfy.org/tutorials/partner-nodes/kling/kling-motion-control',
hubSlug: 'kling',
featured: true
},
'meshy-ai': {
docsUrl: 'https://docs.comfy.org/tutorials/partner-nodes/meshy/meshy-6',
hubSlug: 'meshy',
featured: true
},
'openai-dall-e': {
docsUrl: 'https://docs.comfy.org/tutorials/partner-nodes/openai/dall-e-3',
hubSlug: 'openai',
featured: true
},
'ltxv-api': {
docsUrl: 'https://docs.comfy.org/tutorials/video/ltxv',
hubSlug: 'ltx-2-3',
featured: true
},
'wan-api': {
docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan2_2',
hubSlug: 'wan',
featured: true
},
'wan-2-2': {
docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan2_2',
hubSlug: 'wan',
featured: true
},
'wan-2-1': {
docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan-video',
hubSlug: 'wan',
featured: true
},
'flux-1-kontext-dev': {
docsUrl:
'https://docs.comfy.org/tutorials/partner-nodes/black-forest-labs/flux-1-kontext',
hubSlug: 'flux-1-kontext',
featured: true
},
'flux1-dev': {
docsUrl: 'https://docs.comfy.org/tutorials/flux/flux-1-text-to-image',
hubSlug: 'flux-1',
featured: true
},
'flux1-schnell': {
hubSlug: 'flux-1',
featured: true
},
'hunyuan-video': {
docsUrl: 'https://docs.comfy.org/tutorials/video/hunyuan/hunyuan-video',
hubSlug: 'hunyuan-video',
featured: true
},
'hunyuan-3d': {
docsUrl: 'https://docs.comfy.org/tutorials/3d/hunyuan3D-2',
hubSlug: 'hunyuan-3d',
featured: true
},
vidu: {
hubSlug: 'vidu',
featured: true
},
runway: {
hubSlug: 'runway',
featured: true
},
'stability-ai': {
hubSlug: 'stability',
featured: true
},
'seedance-bytedance': {
hubSlug: 'seedance',
featured: true
},
'grok-image': {
hubSlug: 'grok',
featured: false
},
'luma-dream-machine': {
hubSlug: 'luma',
featured: false
},
moonvalley: {
hubSlug: 'moonvalley',
featured: false
},
'magnific-ai': {
hubSlug: 'magnific',
featured: false
},
pixverse: {
hubSlug: 'pixverse',
featured: false
},
'rodin-3d': {
hubSlug: 'rodin',
featured: false
},
recraft: {
hubSlug: 'recraft',
featured: false
},
'bria-ai': {
hubSlug: 'bria',
featured: false
},
'topaz-labs': {
hubSlug: 'topaz',
featured: false
},
wavespeed: {
hubSlug: 'wavespeed',
featured: false
},
ideogram: {
hubSlug: 'ideogram',
featured: false
},
'veo-2': {
hubSlug: 'veo',
featured: false
},
'veo-3': {
hubSlug: 'veo',
featured: false
},
'flux-2-api': {
hubSlug: 'flux-2',
featured: false
},
'ace-step-v1-3-5b': {
docsUrl: 'https://docs.comfy.org/tutorials/audio/ace-step/ace-step-v1',
hubSlug: 'ace-step',
featured: false
},
'hidream-i1-dev-fp8': {
docsUrl: 'https://docs.comfy.org/tutorials/image/hidream/hidream-i1',
hubSlug: 'hidream',
featured: false
},
'omnigen2-fp16': {
hubSlug: 'omnigen',
featured: false
},
'sd-xl-base-1-0': {
hubSlug: 'sdxl',
featured: false
},
'z-image-bf16': {
hubSlug: 'z-image',
featured: false
},
'z-image-turbo-bf16': {
hubSlug: 'z-image',
featured: false
},
'svd-xt': {
hubSlug: 'svd',
featured: false
},
'flux1-dev-kontext-fp8-scaled': {
docsUrl: 'https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev',
hubSlug: 'flux-1-kontext',
featured: false
},
'ltx-2-19b-dev-fp8': {
hubSlug: 'ltx-2',
featured: false
},
'ltx-2-19b-distilled': {
hubSlug: 'ltx-2',
featured: false
},
'flux1-fill-dev': {
hubSlug: 'flux-1',
featured: false
},
'flux-2-klein-base-9b-fp8': {
hubSlug: 'flux-2',
featured: false
},
'qwen-image-fp8-e4m3fn': {
hubSlug: 'qwen',
featured: false
},
'qwen-image-edit-2509-fp8-e4m3fn': {
hubSlug: 'qwen',
featured: false
}
}

View File

@@ -1,81 +0,0 @@
import generatedModels from './generated-models.json'
import { modelMetadata } from './model-metadata'
type ModelDirectory =
| 'diffusion_models'
| 'checkpoints'
| 'loras'
| 'controlnet'
| 'clip_vision'
| 'model_patches'
| 'vae'
| 'text_encoders'
| 'audio_encoders'
| 'latent_upscale_models'
| 'upscale_models'
| 'style_models'
| 'partner_nodes'
interface Model {
readonly slug: string
readonly canonicalSlug?: string
readonly name: string
readonly displayName: string
readonly directory: ModelDirectory
readonly huggingFaceUrl: string
readonly thumbnailUrl?: string
readonly docsUrl?: string
readonly blogUrl?: string
readonly hubSlug?: string
readonly featured: boolean
readonly workflowCount: number
}
export const models: readonly Model[] = (
generatedModels as Array<{
slug: string
canonicalSlug?: string
name: string
displayName: string
directory: string
huggingFaceUrl: string
docsUrl?: string
thumbnailUrl?: string
workflowCount: number
}>
).map((m) => ({
slug: m.slug,
...(m.canonicalSlug ? { canonicalSlug: m.canonicalSlug } : {}),
name: m.name,
displayName: m.displayName,
directory: m.directory as ModelDirectory,
huggingFaceUrl: m.huggingFaceUrl,
...(m.docsUrl ? { docsUrl: m.docsUrl } : {}),
...(m.thumbnailUrl ? { thumbnailUrl: m.thumbnailUrl } : {}),
featured: false,
workflowCount: m.workflowCount,
...modelMetadata[m.slug]
}))
const slugSet = new Set(models.map((m) => m.slug))
if (slugSet.size !== models.length) {
for (const model of models) {
if (models.filter((m) => m.slug === model.slug).length > 1) {
throw new Error(`Duplicate model slug: ${model.slug}`)
}
}
}
for (const model of models) {
if (
model.canonicalSlug !== undefined &&
(!slugSet.has(model.canonicalSlug) || model.canonicalSlug === model.slug)
) {
throw new Error(
`Invalid canonicalSlug "${model.canonicalSlug}" on "${model.slug}"`
)
}
}
export function getModelBySlug(slug: string): Model | undefined {
return models.find((m) => m.slug === slug)
}

View File

@@ -14,8 +14,7 @@ const baseRoutes = {
demos: '/demos',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
contact: '/contact',
models: '/p/supported-models'
contact: '/contact'
} as const
type Routes = typeof baseRoutes

View File

@@ -1,30 +1,37 @@
{
"fetchedAt": "2026-05-12T16:10:34.114Z",
"fetchedAt": "2026-05-02T20:15:18.321Z",
"departments": [
{
"name": "DESIGN",
"key": "design",
"roles": [
{
"id": "18743530eb448c99",
"id": "e915f2c78b17f93b",
"title": "Senior Product Designer",
"department": "Design",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3/application"
},
{
"id": "8718d17012f26fa2",
"id": "b9f9a23219be7cd4",
"title": "Design Engineer",
"department": "Design",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
},
{
"id": "547b6ba622c800a5",
"title": "Senior Product Designer - Craft",
"department": "Design",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f/application"
},
{
"id": "1e181b9ed8fb2e86",
"id": "7bb02634a24763bc",
"title": "Staff Product Designer - Systems",
"department": "Design",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34/application"
}
]
},
@@ -33,67 +40,67 @@
"key": "engineering",
"roles": [
{
"id": "6a6d865eeb3c10a8",
"id": "102d58e35a8a9817",
"title": "Senior Software Engineer, Frontend",
"department": "Engineering",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2/application"
},
{
"id": "1b4f7f1da9616e14",
"id": "d01d69fba7743905",
"title": "Senior Software Engineer, Backend Generalist",
"department": "Engineering",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e/application"
},
{
"id": "a6d8269c66e37c5c",
"id": "f36f60cfd5bb5910",
"title": "Senior/Staff Applied Machine Learning Engineer",
"department": "Engineering",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0/application"
},
{
"id": "841da783e6e41928",
"id": "9d8ec4c65e20b19e",
"title": "Software Engineer, Frontend",
"department": "Engineering",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40/application"
},
{
"id": "5d01d58b03870d7a",
"id": "be94b193d1f4d482",
"title": "Tech Lead Manager, Frontend",
"department": "Engineering",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261/application"
},
{
"id": "91604c4182a1bc3c",
"id": "ab48f5db6bd1783c",
"title": "Software Engineer, Core ComfyUI Contributor",
"department": "Engineering",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f/application"
},
{
"id": "a1dbc0576ab14034",
"id": "c5dff4ee628bdcd1",
"title": "Software Engineer, ComfyUI Desktop",
"department": "Engineering",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0/application"
},
{
"id": "0b8f4fecd89c3b11",
"id": "4302a7aaa87e16e3",
"title": "Product Manager, ComfyUI",
"department": "Engineering",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
},
{
"id": "2f6bac39d723dfef",
"id": "2eb53e8943cc9396",
"title": "Growth Engineer",
"department": "Engineering",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5/application"
}
]
},
@@ -102,39 +109,39 @@
"key": "marketing",
"roles": [
{
"id": "23dd98cab77ff459",
"id": "4c5d6afb78652df7",
"title": "Freelance Motion Designer",
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
},
{
"id": "a998b9fc973ff3c0",
"id": "0f5256cf302e552b",
"title": "Creative Artist",
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
},
{
"id": "3e730938026d6e70",
"id": "5746486d87874937",
"title": "Graphic Designer",
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
},
{
"id": "6f771af6858283aa",
"id": "b5803a0d4785d406",
"title": "Lifecycle Growth Marketer",
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64/application"
},
{
"id": "527a47e82970afc1",
"id": "130d7218d7895bdb",
"title": "Partnership & Events Marketing Manager",
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c/application"
}
]
},
@@ -143,18 +150,25 @@
"key": "operations",
"roles": [
{
"id": "0c6cc3685194ab7a",
"title": "Head of Talent",
"id": "ec68ae44dd5943c9",
"title": "Talent Lead",
"department": "Operations",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
},
{
"id": "82bd6ed26adab1c3",
"id": "16f556001ce1cef4",
"title": "BizOps Strategist",
"department": "Operations",
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/145b8558-0ab4-43e8-8fac-b59059cf2537/application"
},
{
"id": "8e773a72c1b8e099",
"title": "Founding Customer Success Manager",
"department": "Operations",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4/application"
}
]
}

View File

@@ -1,394 +0,0 @@
{
"fetchedAt": "2026-05-04T16:29:55.587Z",
"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.",
"repoUrl": "https://github.com/ltdrdata/ComfyUI-Impact-Pack",
"publisher": {
"id": "drltdata",
"name": "Dr.Lt.Data"
},
"downloads": 2618646,
"githubStars": 3092,
"latestVersion": "8.28.3",
"license": "See repository LICENSE",
"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."
},
{
"name": "SAMLoader",
"displayName": "SAMLoader",
"category": "ImpactPack/Detector",
"description": "Load Segment Anything models for high-fidelity masking."
},
{
"name": "MaskToSEGS",
"displayName": "MaskToSEGS",
"category": "ImpactPack/Operation",
"description": "Convert binary masks into SEGS regions for the detailer pipeline."
}
]
},
{
"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.",
"iconUrl": "https://raw.githubusercontent.com/crystian/ComfyUI-Crystools/main/docs/screwdriver.png",
"repoUrl": "https://github.com/crystian/ComfyUI-Crystools",
"publisher": {
"id": "crystian",
"name": "Crystian"
},
"downloads": 1671447,
"githubStars": 1855,
"latestVersion": "1.27.4",
"license": "See repository LICENSE",
"lastUpdated": "2025-10-26T19:11:09.943366Z",
"supportedOs": ["OS Independent"],
"supportedAccelerators": ["GPU :: NVIDIA CUDA"],
"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."
},
{
"name": "CCrystools_Json",
"displayName": "CCrystools_Json",
"category": "crystools/json",
"description": "Compose and parse JSON inline for advanced workflows."
},
{
"name": "CCrystools_Pipe_To_Any",
"displayName": "CCrystools_Pipe_To_Any",
"category": "crystools/pipe",
"description": "Convert a pipe bus into individual outputs."
},
{
"name": "CCrystools_Save_Metadata",
"displayName": "CCrystools_Save_Metadata",
"category": "crystools/save",
"description": "Save images with workflow metadata embedded."
}
]
},
{
"id": "rgthree-comfy",
"registryId": "rgthree-comfy",
"displayName": "rgthree-comfy",
"description": "Quality-of-life nodes that make complex workflows readable: Power Lora Loader, group bypassers, smarter reroutes, and inline debug widgets.",
"iconUrl": "https://comfy.rgthree.com/media/rgthree.svg",
"repoUrl": "https://github.com/rgthree/rgthree-comfy",
"publisher": {
"id": "rgthree"
},
"downloads": 3025389,
"githubStars": 3028,
"latestVersion": "1.0.2604070017",
"license": "See repository LICENSE",
"lastUpdated": "2026-04-07T04:19:24.689627Z",
"nodes": [
{
"name": "Power Lora Loader (rgthree)",
"displayName": "Power Lora Loader (rgthree)",
"category": "rgthree",
"description": "Stack multiple LoRAs in a single, foldable widget."
},
{
"name": "Fast Groups Bypasser (rgthree)",
"displayName": "Fast Groups Bypasser (rgthree)",
"category": "rgthree",
"description": "Toggle whole groups on or off without rewiring."
},
{
"name": "Seed (rgthree)",
"displayName": "Seed (rgthree)",
"category": "rgthree",
"description": "A predictable seed control with quick reset."
},
{
"name": "Reroute (rgthree)",
"displayName": "Reroute (rgthree)",
"category": "rgthree",
"description": "A clean reroute alternative with persistent labels."
},
{
"name": "Display Any (rgthree)",
"displayName": "Display Any (rgthree)",
"category": "rgthree",
"description": "Inspect any value with a compact debug widget."
}
]
},
{
"id": "comfyui-kjnodes",
"registryId": "comfyui-kjnodes",
"displayName": "ComfyUI-KJNodes",
"description": "Daily-driver utilities for image, latent and string handling: color matching, batch counters, resize helpers, and prompt presets.",
"iconUrl": "https://avatars.githubusercontent.com/u/40791699",
"repoUrl": "https://github.com/kijai/ComfyUI-KJNodes",
"publisher": {
"id": "kijai",
"name": "Kijai"
},
"downloads": 3319866,
"githubStars": 2544,
"latestVersion": "1.3.9",
"license": "See repository LICENSE",
"lastUpdated": "2026-04-24T09:32:28.326616Z",
"nodes": [
{
"name": "ColorMatch",
"displayName": "ColorMatch",
"category": "KJNodes/image",
"description": "Match the colors of one image to another using statistics."
},
{
"name": "ImageResizeKJ",
"displayName": "ImageResizeKJ",
"category": "KJNodes/image",
"description": "Resize images with intuitive size and divisibility controls."
},
{
"name": "StringConstantMultiline",
"displayName": "StringConstantMultiline",
"category": "KJNodes/string",
"description": "A multi-line string constant suitable for prompts."
},
{
"name": "EmptyLatentImagePresets",
"displayName": "EmptyLatentImagePresets",
"category": "KJNodes/latent",
"description": "Quickly create empty latents at common resolutions."
},
{
"name": "GetImageSizeAndCount",
"displayName": "GetImageSizeAndCount",
"category": "KJNodes/image",
"description": "Read width, height and batch size from an image input."
}
]
},
{
"id": "comfyui-easy-use",
"registryId": "comfyui-easy-use",
"displayName": "ComfyUI-Easy-Use",
"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": {
"id": "yolain",
"name": "yolain"
},
"downloads": 2767609,
"githubStars": 2500,
"latestVersion": "1.3.6",
"license": "See repository LICENSE",
"lastUpdated": "2026-01-23T06:19:17.505188Z",
"nodes": [
{
"name": "easy fullLoader",
"displayName": "easy fullLoader",
"category": "EasyUse/Loaders",
"description": "Combined checkpoint, VAE and CLIP loader with sensible defaults."
},
{
"name": "easy preSampling",
"displayName": "easy preSampling",
"category": "EasyUse/PreSampling",
"description": "A unified pre-sampling node bundling common settings."
},
{
"name": "easy kSampler",
"displayName": "easy kSampler",
"category": "EasyUse/KSampler",
"description": "A simplified KSampler with extra quality-of-life options."
},
{
"name": "easy showAnything",
"displayName": "easy showAnything",
"category": "EasyUse/Util",
"description": "Display any value inline for debugging."
},
{
"name": "easy XYPlot",
"displayName": "easy XYPlot",
"category": "EasyUse/XYPlot",
"description": "Compose XY plots over arbitrary parameters."
}
]
},
{
"id": "comfyui-advanced-controlnet",
"registryId": "comfyui-advanced-controlnet",
"displayName": "ComfyUI-Advanced-ControlNet",
"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",
"name": "Kosinkadink"
},
"downloads": 590539,
"githubStars": 967,
"latestVersion": "1.5.7",
"license": "See repository LICENSE",
"lastUpdated": "2026-03-30T01:40:06.836236Z",
"nodes": [
{
"name": "Apply Advanced ControlNet",
"displayName": "Apply Advanced ControlNet",
"category": "Adv-ControlNet/conditioning",
"description": "Apply ControlNet with timestep keyframes and per-frame masks."
},
{
"name": "ControlNetLoaderAdvanced",
"displayName": "ControlNetLoaderAdvanced",
"category": "Adv-ControlNet/loaders",
"description": "Load ControlNet models with the advanced wrapper."
},
{
"name": "Latent Keyframe Group",
"displayName": "Latent Keyframe Group",
"category": "Adv-ControlNet/keyframes",
"description": "Schedule ControlNet strength over a batch of latents."
},
{
"name": "Timestep Keyframe",
"displayName": "Timestep Keyframe",
"category": "Adv-ControlNet/keyframes",
"description": "Set ControlNet strength at a specific timestep."
},
{
"name": "Scaled Soft Mask",
"displayName": "Scaled Soft Mask",
"category": "Adv-ControlNet/masks",
"description": "Apply a soft attention mask to ControlNet conditioning."
}
]
},
{
"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 — the original \"kitchen sink\" pack still relied on by thousands of workflows.",
"repoUrl": "https://github.com/WASasquatch/was-node-suite-comfyui",
"publisher": {
"id": "was",
"name": "WAS"
},
"downloads": 981051,
"githubStars": 1777,
"latestVersion": "1.0.1",
"license": "See repository LICENSE",
"lastUpdated": "2024-08-01T05:28:23.655235Z",
"nodes": [
{
"name": "Image Filter Adjustments",
"displayName": "Image Filter Adjustments",
"category": "WAS Suite/Image/Adjustment",
"description": "Adjust brightness, contrast, saturation and more."
},
{
"name": "Image Blending Mode",
"displayName": "Image Blending Mode",
"category": "WAS Suite/Image/Compositing",
"description": "Composite two images with Photoshop-style blend modes."
},
{
"name": "Text String",
"displayName": "Text String",
"category": "WAS Suite/Text",
"description": "A reusable text constant suitable for prompts."
},
{
"name": "Number to Float",
"displayName": "Number to Float",
"category": "WAS Suite/Number",
"description": "Cast integer or string values to a float."
},
{
"name": "Image Save",
"displayName": "Image Save",
"category": "WAS Suite/IO",
"description": "Save an image to disk with rich filename templating."
}
]
},
{
"id": "comfyui_ipadapter_plus",
"registryId": "comfyui_ipadapter_plus",
"displayName": "ComfyUI_IPAdapter_plus",
"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",
"name": "Matteo"
},
"downloads": 1208394,
"githubStars": 5938,
"latestVersion": "2.0.0",
"license": "GPL-3.0 license",
"lastUpdated": "2024-06-05T06:57:13.485481Z",
"nodes": [
{
"name": "IPAdapterUnifiedLoader",
"displayName": "IPAdapterUnifiedLoader",
"category": "ipadapter",
"description": "Load IPAdapter, image encoder and CLIP vision in one node."
},
{
"name": "IPAdapterFaceID",
"displayName": "IPAdapterFaceID",
"category": "ipadapter/faceid",
"description": "Apply Face ID embeddings for high-fidelity portrait reference."
},
{
"name": "IPAdapterStyleComposition",
"displayName": "IPAdapterStyleComposition",
"category": "ipadapter",
"description": "Reference an image for style without copying its content."
},
{
"name": "IPAdapterAdvanced",
"displayName": "IPAdapterAdvanced",
"category": "ipadapter",
"description": "Full-control IPAdapter with masking, weights, and noise injection."
},
{
"name": "IPAdapterEncoder",
"displayName": "IPAdapterEncoder",
"category": "ipadapter/embeds",
"description": "Encode reference images into IPAdapter embeddings for reuse."
}
]
}
]
}

View File

@@ -1,52 +0,0 @@
export interface PackNode {
name: string
displayName: string
category: string
description?: string
deprecated?: boolean
experimental?: boolean
}
export interface Pack {
id: string
registryId?: string
displayName: string
description?: string
bannerUrl?: string
iconUrl?: string
repoUrl?: string
publisher?: {
id: string
name?: string
}
downloads?: number
githubStars?: number
latestVersion?: string
license?: string
lastUpdated?: string
supportedOs?: string[]
supportedAccelerators?: string[]
nodes: PackNode[]
}
export interface NodesSnapshot {
fetchedAt: string
packs: Pack[]
}
export function isNodesSnapshot(value: unknown): value is NodesSnapshot {
if (value === null || typeof value !== 'object') return false
const candidate = value as { fetchedAt?: unknown; packs?: unknown }
if (typeof candidate.fetchedAt !== 'string') return false
if (!Array.isArray(candidate.packs)) return false
return candidate.packs.every((pack) => {
if (pack === null || typeof pack !== 'object') return false
const p = pack as { id?: unknown; displayName?: unknown; nodes?: unknown }
return (
typeof p.id === 'string' &&
typeof p.displayName === 'string' &&
Array.isArray(p.nodes)
)
})
}

View File

@@ -3,7 +3,7 @@ export interface Role {
title: string
department: string
location: string
jobUrl: string
applyUrl: string
}
export interface Department {

View File

@@ -735,142 +735,6 @@ const translations = {
'zh-CN': '免费试用 COMFY CLOUD'
},
'cloudNodes.hero.label': {
en: 'CLOUD NODES',
'zh-CN': '云端节点目录'
},
'cloudNodes.hero.heading': {
en: 'Run your favorite ComfyUI custom nodes on the cloud',
'zh-CN': '在云端运行你喜爱的 ComfyUI 自定义节点'
},
'cloudNodes.hero.body': {
en: 'Spin up workflows with hundreds of community-built nodes — detailers, ControlNet preprocessors, animation tools, and quality-of-life utilities — preinstalled on Comfy Cloud and ready to run on managed GPUs.',
'zh-CN':
'在 Comfy Cloud 托管 GPU 上即开即用预装数百个社区节点——细节修复、ControlNet 预处理、动画工具与日常便利组件,应有尽有。'
},
'cloudNodes.section.heading': {
en: 'Find a custom-node pack',
'zh-CN': '查找自定义节点包'
},
'cloudNodes.search.placeholder': {
en: 'Search packs or nodes',
'zh-CN': '搜索节点包或节点名称'
},
'cloudNodes.sort.downloads': {
en: 'Most installed',
'zh-CN': '按安装量'
},
'cloudNodes.sort.mostNodes': {
en: 'Most nodes',
'zh-CN': '按节点数量'
},
'cloudNodes.sort.az': {
en: 'A → Z',
'zh-CN': '按名称 A → Z'
},
'cloudNodes.sort.recentlyUpdated': {
en: 'Recently updated',
'zh-CN': '最近更新'
},
'cloudNodes.search.label': {
en: 'Search custom-node packs',
'zh-CN': '搜索自定义节点包'
},
'cloudNodes.sort.label': {
en: 'Sort packs',
'zh-CN': '排序节点包'
},
'cloudNodes.list.ariaLabel': {
en: 'Custom-node packs supported on Comfy Cloud',
'zh-CN': 'Comfy Cloud 支持的自定义节点包'
},
'cloudNodes.meta.title': {
en: 'Custom-node packs on Comfy Cloud — supported by default',
'zh-CN': 'Comfy Cloud 自定义节点包合集——开箱即用'
},
'cloudNodes.meta.description': {
en: 'Browse hundreds of ComfyUI custom-node packs preinstalled on Comfy Cloud. Detailers, ControlNet preprocessors, animation tools, samplers, and more — search by pack or by node name.',
'zh-CN':
'浏览 Comfy Cloud 预装的数百个 ComfyUI 自定义节点包细节修复、ControlNet 预处理、动画工具、采样器等——按节点包或节点名搜索。'
},
'cloudNodes.detail.metaTitle': {
en: '{pack} on Comfy Cloud',
'zh-CN': '{pack}Comfy Cloud'
},
'cloudNodes.detail.metaDescription': {
en: '{pack} is preinstalled on Comfy Cloud — {nodeCount} nodes ready to run on managed GPUs. {description}',
'zh-CN':
'{pack} 已预装于 Comfy Cloud——{nodeCount} 个节点可在托管 GPU 上即时运行。{description}'
},
'cloudNodes.empty.heading': {
en: 'No matching packs',
'zh-CN': '未找到匹配的节点包'
},
'cloudNodes.empty.body': {
en: 'Try a different search term or clear your filters.',
'zh-CN': '试试其他关键词,或清空筛选条件。'
},
'cloudNodes.card.nodeCountOne': {
en: '{count} node',
'zh-CN': '{count} 个节点'
},
'cloudNodes.card.nodeCountOther': {
en: '{count} nodes',
'zh-CN': '{count} 个节点'
},
'cloudNodes.card.viewRepo': {
en: 'View repository',
'zh-CN': '查看仓库'
},
'cloudNodes.card.unavailableDescription': {
en: 'Description unavailable.',
'zh-CN': '暂无描述信息。'
},
'cloudNodes.card.nodesHeading': {
en: 'Included nodes',
'zh-CN': '包含节点'
},
'cloudNodes.detail.back': {
en: 'Back to all packs',
'zh-CN': '返回所有节点包'
},
'cloudNodes.detail.publisher': {
en: 'Publisher',
'zh-CN': '发布者'
},
'cloudNodes.detail.downloads': {
en: 'Downloads',
'zh-CN': '下载量'
},
'cloudNodes.detail.stars': {
en: 'GitHub stars',
'zh-CN': 'GitHub 星标'
},
'cloudNodes.detail.latestVersion': {
en: 'Latest version',
'zh-CN': '最新版本'
},
'cloudNodes.detail.license': {
en: 'License',
'zh-CN': '许可证'
},
'cloudNodes.detail.lastUpdated': {
en: 'Last updated',
'zh-CN': '最后更新'
},
'cloudNodes.detail.deprecated': {
en: 'Deprecated',
'zh-CN': '已弃用'
},
'cloudNodes.detail.experimental': {
en: 'Experimental',
'zh-CN': '实验性'
},
'cloudNodes.detail.nodesHeading': {
en: 'Nodes in this pack',
'zh-CN': '此节点包中的节点'
},
// Cloud ReasonSection
'cloud.reason.heading': {
en: 'Why\nprofessionals\nchoose ',
@@ -3814,60 +3678,6 @@ const translations = {
'zh-CN': 'Creative Studios AI 负责人'
},
// Models UI keys
'models.hero.eyebrow': {
en: 'AI Model',
'zh-CN': 'AI 模型'
},
'models.hero.primaryCta': {
en: 'TRY IN COMFY',
'zh-CN': '在 Comfy 中试用'
},
'models.hero.secondaryCta': {
en: 'DOWNLOAD MODEL',
'zh-CN': '下载模型'
},
'models.hero.cloudCta': {
en: 'RUN ON CLOUD',
'zh-CN': '云端运行'
},
'models.hero.tutorialCta': {
en: 'VIEW TUTORIAL',
'zh-CN': '查看教程'
},
'models.hero.blogLink': {
en: 'Read blog post',
'zh-CN': '阅读博客文章'
},
'models.hero.workflowCount': {
en: '{count} workflows use this model',
'zh-CN': '{count} 个工作流使用此模型'
},
'models.whatIs.heading': {
en: 'What is {name}?',
'zh-CN': '什么是 {name}'
},
'models.whatIs.tutorialLink': {
en: 'Read the full tutorial →',
'zh-CN': '阅读完整教程 →'
},
'models.index.title': {
en: 'Supported Models',
'zh-CN': '支持的模型'
},
'models.index.subtitle': {
en: "Run the world's leading AI models in ComfyUI",
'zh-CN': '在 ComfyUI 中运行世界领先的 AI 模型'
},
'models.breadcrumb.home': {
en: 'Home',
'zh-CN': '首页'
},
'models.breadcrumb.models': {
en: 'Supported Models',
'zh-CN': '支持的模型'
},
// Payment status pages
'payment.success.label': {
en: 'PAYMENT',

View File

@@ -5,7 +5,6 @@ import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import SiteNav from '../components/common/SiteNav.vue'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
interface Props {
@@ -95,11 +94,8 @@ const websiteJsonLd = {
<meta name="twitter:image" content={ogImageURL.href} />
<!-- Structured Data -->
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
<slot name="head" />
<slot name="head" />
<script is:inline type="application/ld+json" set:html={JSON.stringify(organizationJsonLd)} />
<script is:inline type="application/ld+json" set:html={JSON.stringify(websiteJsonLd)} />
<!-- Google Tag Manager -->
{gtmEnabled && (

View File

@@ -1,42 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/cloud-nodes/HeroSection.vue'
import PackGridSection from '../../components/cloud-nodes/PackGridSection.vue'
import { t } from '../../i18n/translations'
import { loadPacksForBuild } from '../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../utils/escapeJsonLd'
const packs = await loadPacksForBuild()
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL('/cloud/supported-nodes', siteBase).href
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Custom-node packs supported on Comfy Cloud',
url: pageUrl,
numberOfItems: packs.length,
itemListElement: packs.map((pack, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href,
name: pack.displayName,
image: pack.bannerUrl || pack.iconUrl
}))
}
---
<BaseLayout
title={t('cloudNodes.meta.title', 'en')}
description={t('cloudNodes.meta.description', 'en')}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(itemListJsonLd)}
/>
<HeroSection client:visible />
<PackGridSection packs={packs} client:visible />
</BaseLayout>

View File

@@ -1,48 +0,0 @@
# Cloud Nodes Pages
Build-time catalog of custom-node packs preinstalled on Comfy Cloud. Index at `/cloud/supported-nodes`, per-pack details at `/cloud/supported-nodes/[pack]`, both also under `/zh-CN/`.
## Sources
- **Cloud `/api/object_info`** — authoritative list of nodes available on Comfy Cloud (auth: `WEBSITE_CLOUD_API_KEY`)
- **ComfyUI Custom Node Registry** ([dashboard](https://registry.comfy.org), API at `https://api.comfy.org/nodes`) — public pack metadata (banner, icon, description, downloads, stars, license, version, repo, publisher)
The registry is the same one the in-app Manager dialog reads from. For reference and additional reading, see the existing client wrappers in `src/`:
- [`src/services/comfyRegistryService.ts`](../../../../../../src/services/comfyRegistryService.ts) — typed wrappers around `/nodes`, `/nodes/search`, `/nodes/{id}`, `/nodes/{id}/versions/{version}/comfy-nodes`, etc.
- [`src/stores/comfyRegistryStore.ts`](../../../../../../src/stores/comfyRegistryStore.ts) — cached store + `getPacksByIds` batch helper
- [`packages/registry-types/src/comfyRegistryTypes.ts`](../../../../../../packages/registry-types/src/comfyRegistryTypes.ts) — generated OpenAPI types
- Public docs: <https://docs.comfy.org/registry>
## Build pipeline
| File | Role |
| ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| [`pages/cloud/supported-nodes.astro`](./supported-nodes.astro) and [`[pack].astro`](./supported-nodes/%5Bpack%5D.astro) | Page shells (and `zh-CN` twins) |
| [`utils/cloudNodes.build.ts`](../../../utils/cloudNodes.build.ts) | `loadPacksForBuild()` shared by index + detail routes |
| [`utils/cloudNodes.ts`](../../../utils/cloudNodes.ts) | Cloud `object_info` fetcher with retry, sanitization, snapshot fallback |
| [`utils/cloudNodes.registry.ts`](../../../utils/cloudNodes.registry.ts) | Registry enrichment (batches of 50, soft-fail) |
| [`utils/cloudNodes.ci.ts`](../../../utils/cloudNodes.ci.ts) | GitHub Actions annotations + step summary |
| [`utils/escapeJsonLd.ts`](../../../utils/escapeJsonLd.ts) | XSS-safe `<script type="application/ld+json">` serializer |
| [`composables/useFilteredPacks.ts`](../../../composables/useFilteredPacks.ts) | Search + sort logic for the index page |
| [`composables/useNodesByCategory.ts`](../../../composables/useNodesByCategory.ts) | Group nodes by category, alphabetized |
| [`scripts/refresh-cloud-nodes-snapshot.ts`](../../../../scripts/refresh-cloud-nodes-snapshot.ts) | Manual `pnpm cloud-nodes:refresh-snapshot` |
| [`packages/object-info-parser`](../../../../../../packages/object-info-parser) | Shared Zod schemas, classifier, `sanitizeUserContent` |
## Key invariants
- **Sanitization is mandatory.** The cloud `/api/object_info` endpoint mixes the calling user's uploaded files into combo input lists; `sanitizeUserContent()` strips them before the snapshot is written. Tests in [`packages/object-info-parser/src/__tests__/sanitizeUserContent.test.ts`](../../../../../../packages/object-info-parser/src/__tests__/sanitizeUserContent.test.ts).
- **All remote data is Zod-validated.** `cloudNodes.schema.ts` validates the cloud envelope; `@comfyorg/object-info-parser` validates each node def; `cloudNodes.registry.ts` validates the registry response shape with a passthrough Zod schema (defense-in-depth on top of the generated OpenAPI types). The fetcher never trusts a network response.
- **JSON-LD must be escaped.** Pack metadata is registry-controlled; route every `<script type="application/ld+json">` payload through `escapeJsonLd()`.
- **Index and detail share one fetch.** Both call `loadPacksForBuild()` so the static routes can never diverge from the rendered list.
- **No `PUBLIC_` prefix on env vars.** Astro inlines `PUBLIC_*` into the client bundle; build-time secrets never start with that prefix.
## Why `Pack` is a domain projection, not the raw registry `Node`
`apps/website/src/data/cloudNodes.ts` defines its own `Pack` and `PackNode` types instead of re-exporting `components['schemas']['Node']` from `@comfyorg/registry-types`. That is intentional:
- A `Pack` joins **two upstream sources** — the cloud `object_info` shape (which has `python_module`, `category`, `display_name`, etc.) and the registry `Node` shape (which has `banner_url`, `downloads`, `github_stars`, etc.). Neither shape on its own captures both.
- A `Pack` is **post-sanitization**. Combo input lists, user-uploaded filenames, and any other build-time-stripped data are guaranteed gone by the time the type appears. The raw `Node` carries fields we never expose.
- A `Pack` adds **safe-URL invariants** (banner / icon / repo all flow through `safeExternalUrl()` before becoming part of the type), which the registry's optional `string` fields don't encode.
The `Pack`/`PackNode` types live next to the snapshot they describe; the validated build-time projections feed Astro's static routes directly. The OpenAPI-generated `Node` type is still consumed inside `cloudNodes.registry.ts` as `RegistryPack` for the upstream fetch.

View File

@@ -1,63 +0,0 @@
---
import type { GetStaticPaths } from 'astro'
import type { Pack } from '../../../data/cloudNodes'
import PackDetail from '../../../components/cloud-nodes/PackDetail.vue'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
return packs.map((pack) => ({
params: { pack: pack.id },
props: { pack }
}))
}
const { pack } = Astro.props as { pack: Pack }
const fallbackDescription = t('cloudNodes.card.unavailableDescription', 'en')
const description = pack.description || fallbackDescription
const title = t('cloudNodes.detail.metaTitle', 'en').replace(
'{pack}',
pack.displayName
)
const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
.replace('{pack}', pack.displayName)
.replace('{nodeCount}', String(pack.nodes.length))
.replace('{description}', description)
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href
const softwareJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: pack.displayName,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
url: pageUrl,
description,
image: pack.bannerUrl || pack.iconUrl,
softwareVersion: pack.latestVersion,
license: pack.license,
codeRepository: pack.repoUrl,
author: pack.publisher?.name
? { '@type': 'Person', name: pack.publisher.name }
: undefined,
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
}
---
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<PackDetail pack={pack} />
</BaseLayout>

View File

@@ -1,185 +0,0 @@
---
import type { GetStaticPaths } from 'astro'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
import { models, getModelBySlug } from '../../../config/models'
import { t } from '../../../i18n/translations'
export const getStaticPaths: GetStaticPaths = () => {
return models.map((model) => ({
params: { slug: model.slug }
}))
}
const { slug } = Astro.params
const model = getModelBySlug(slug as string)!
if (model.canonicalSlug) {
return Astro.redirect(`/p/supported-models/${model.canonicalSlug}`, 301)
}
const { displayName } = model
const canonicalURL = new URL(`/p/supported-models/${model.slug}`, Astro.site)
const dirDescriptions: Record<string, string> = {
diffusion_models: 'a diffusion model that generates images or video from text and image prompts',
checkpoints: 'an all-in-one checkpoint model that bundles a diffusion model, text encoder, and VAE',
loras: 'a LoRA (Low-Rank Adaptation) that fine-tunes an existing model for a specific style or subject',
controlnet: 'a ControlNet that steers image generation using structural guides like depth maps, edges, or poses',
clip_vision: 'a CLIP Vision encoder that converts images into embeddings for conditioning or style transfer',
vae: 'a VAE (Variational Autoencoder) that encodes and decodes latent representations',
text_encoders: 'a text encoder that converts prompts into embeddings used to guide generation',
audio_encoders: 'an audio encoder that converts audio into embeddings for audio-conditioned generation',
upscale_models: 'an upscale model that increases image resolution while preserving or enhancing detail',
latent_upscale_models: 'a latent upscale model that refines latents at higher resolution before decoding',
style_models: 'a style model that transfers artistic style onto generated images',
model_patches: 'a model patch that modifies or extends the behavior of an existing base model',
partner_nodes: 'a cloud API model accessible through ComfyUI partner nodes without local hardware requirements'
}
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
const softwareAppJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: displayName,
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Any',
url: canonicalURL.href,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: t('models.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('models.breadcrumb.models'),
item: 'https://comfy.org/p/supported-models'
},
{
'@type': 'ListItem',
position: 3,
name: displayName
}
]
}
const faqJsonLd = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: [
{
'@type': 'Question',
name: `What is ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: whatIsDescription
}
},
{
'@type': 'Question',
name: `How do I use ${displayName} in ComfyUI?`,
acceptedAnswer: {
'@type': 'Answer',
text: model.docsUrl
? `Follow the step-by-step tutorial at ${model.docsUrl}. You can also load any of the ${model.workflowCount} community workflow templates that use ${displayName} directly in ComfyUI.`
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`
}
},
{
'@type': 'Question',
name: `How many ComfyUI workflows use ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`
}
},
{
'@type': 'Question',
name: `Is ${displayName} free to use in ComfyUI?`,
acceptedAnswer: {
'@type': 'Answer',
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`
}
}
]
}
const pageTitle = `${displayName} in ComfyUI`
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
---
<BaseLayout
title={`${pageTitle} — Comfy`}
description={pageDescription}
ogImage={model.thumbnailUrl}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(softwareAppJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<ModelHeroSection
displayName={displayName}
huggingFaceUrl={model.huggingFaceUrl}
docsUrl={model.docsUrl}
blogUrl={model.blogUrl}
hubSlug={model.hubSlug}
workflowCount={model.workflowCount}
directory={model.directory}
/>
<section class="mx-auto max-w-7xl px-6 py-16 lg:px-8">
<h2 class="text-2xl font-bold text-primary-comfy-canvas lg:text-3xl">
{t('models.whatIs.heading').replace('{name}', displayName)}
</h2>
<p class="mt-4 max-w-3xl text-base/relaxed text-primary-comfy-canvas/70 lg:text-lg/relaxed">
{whatIsDescription}
</p>
{model.docsUrl && (
<a
href={model.docsUrl}
target="_blank"
rel="noopener noreferrer"
class="mt-6 inline-block text-primary-comfy-yellow hover:underline"
>
{t('models.whatIs.tutorialLink')}
</a>
)}
</section>
<section>
{/* TODO: workflow gallery — pulls from hub API */}
</section>
<section>
{/* TODO: shared HowComfyWorks component */}
</section>
</BaseLayout>

View File

@@ -1,68 +0,0 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { models } from '../../../config/models'
import { t } from '../../../i18n/translations'
const title = t('models.index.title')
const subtitle = t('models.index.subtitle')
const dirLabel: Record<string, string> = {
diffusion_models: 'Diffusion',
checkpoints: 'Checkpoint',
loras: 'LoRA',
controlnet: 'ControlNet',
clip_vision: 'CLIP Vision',
model_patches: 'Patch',
vae: 'VAE',
text_encoders: 'Text Encoder',
audio_encoders: 'Audio Encoder',
latent_upscale_models: 'Latent Upscale',
upscale_models: 'Upscale',
style_models: 'Style',
partner_nodes: 'Partner Node'
}
---
<BaseLayout
title={`${title} — Comfy`}
description={subtitle}
>
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-24">
<header class="mb-12">
<p
class="mb-2 text-sm font-medium uppercase tracking-widest text-primary-comfy-yellow"
>
{t('models.hero.eyebrow')}
</p>
<h1 class="text-4xl font-bold text-primary-comfy-canvas lg:text-6xl">
{title}
</h1>
<p class="mt-4 max-w-2xl text-lg text-primary-comfy-canvas/70">
{subtitle}
</p>
</header>
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{models.map((model) => (
<li>
<a
href={`/p/supported-models/${model.slug}`}
class="flex h-full flex-col gap-3 rounded-xl border border-white/10 p-5 transition-colors hover:border-white/25 hover:bg-white/5"
>
<div class="flex items-start justify-between gap-2">
<span class="text-base font-semibold leading-tight text-primary-comfy-canvas">
{model.displayName}
</span>
<span class="shrink-0 rounded-full bg-white/10 px-2 py-0.5 text-xs font-medium text-primary-comfy-canvas/70">
{dirLabel[model.directory] ?? model.directory}
</span>
</div>
<p class="mt-auto text-xs text-primary-comfy-canvas/50">
{model.workflowCount} workflows
</p>
</a>
</li>
))}
</ul>
</div>
</BaseLayout>

View File

@@ -1,42 +0,0 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro'
import HeroSection from '../../../components/cloud-nodes/HeroSection.vue'
import PackGridSection from '../../../components/cloud-nodes/PackGridSection.vue'
import { t } from '../../../i18n/translations'
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
const packs = await loadPacksForBuild()
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL('/zh-CN/cloud/supported-nodes', siteBase).href
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Comfy Cloud 支持的自定义节点包',
url: pageUrl,
numberOfItems: packs.length,
itemListElement: packs.map((pack, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href,
name: pack.displayName,
image: pack.bannerUrl || pack.iconUrl
}))
}
---
<BaseLayout
title={t('cloudNodes.meta.title', 'zh-CN')}
description={t('cloudNodes.meta.description', 'zh-CN')}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(itemListJsonLd)}
/>
<HeroSection locale="zh-CN" client:visible />
<PackGridSection locale="zh-CN" packs={packs} client:visible />
</BaseLayout>

View File

@@ -1,63 +0,0 @@
---
import type { GetStaticPaths } from 'astro'
import type { Pack } from '../../../../data/cloudNodes'
import PackDetail from '../../../../components/cloud-nodes/PackDetail.vue'
import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { t } from '../../../../i18n/translations'
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
return packs.map((pack) => ({
params: { pack: pack.id },
props: { pack }
}))
}
const { pack } = Astro.props as { pack: Pack }
const fallbackDescription = t('cloudNodes.card.unavailableDescription', 'zh-CN')
const description = pack.description || fallbackDescription
const title = t('cloudNodes.detail.metaTitle', 'zh-CN').replace(
'{pack}',
pack.displayName
)
const metaDescription = t('cloudNodes.detail.metaDescription', 'zh-CN')
.replace('{pack}', pack.displayName)
.replace('{nodeCount}', String(pack.nodes.length))
.replace('{description}', description)
const siteBase = Astro.site ?? new URL('https://comfy.org')
const pageUrl = new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href
const softwareJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: pack.displayName,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
url: pageUrl,
description,
image: pack.bannerUrl || pack.iconUrl,
softwareVersion: pack.latestVersion,
license: pack.license,
codeRepository: pack.repoUrl,
author: pack.publisher?.name
? { '@type': 'Person', name: pack.publisher.name }
: undefined,
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
}
---
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<PackDetail pack={pack} locale="zh-CN" />
</BaseLayout>

View File

@@ -22,7 +22,7 @@ function baseSnapshot(): RolesSnapshot {
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
jobUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
}
]
}
@@ -50,7 +50,7 @@ function freshOutcome(droppedCount = 0): FetchOutcome {
title: 'Design Engineer',
department: 'Engineering',
location: 'San Francisco',
jobUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
}
]
}

View File

@@ -40,7 +40,7 @@ function makeSnapshot(roleCount = 2): RolesSnapshot {
title: `Snapshot Role ${i}`,
department: 'Engineering',
location: 'San Francisco',
jobUrl: `https://jobs.ashbyhq.com/comfy-org/snapshot-${i}`
applyUrl: `https://jobs.ashbyhq.com/comfy-org/snapshot-${i}`
}))
return {
fetchedAt: '2026-04-01T00:00:00.000Z',
@@ -85,7 +85,26 @@ describe('fetchRolesForBuild', () => {
if (outcome.status !== 'fresh') return
expect(outcome.droppedCount).toBe(0)
expect(outcome.snapshot.departments).toHaveLength(1)
expect(outcome.snapshot.departments[0]!.roles[0]!.jobUrl).toBe(
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toMatch(
/design-engineer\/apply$/
)
})
it('falls back to jobUrl when applyUrl is missing and keeps the role', async () => {
const job = validJob()
delete (job as Record<string, unknown>).applyUrl
const fetchImpl = vi.fn(async () =>
response({ apiVersion: '1', jobs: [job] })
)
const outcome = await fetchRolesForBuild({
apiKey: KEY,
boardName: BOARD,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toBe(
'https://jobs.ashbyhq.com/comfy-org/design-engineer'
)
})

View File

@@ -243,13 +243,13 @@ function groupByDepartment(jobs: readonly AshbyJobPosting[]): Department[] {
}
function toDomainRole(job: AshbyJobPosting, department: string): Role {
const jobUrl = job.jobUrl
const applyUrl = job.applyUrl ?? job.jobUrl
return {
id: createHash('sha1').update(jobUrl).digest('hex').slice(0, 16),
id: createHash('sha1').update(applyUrl).digest('hex').slice(0, 16),
title: job.title,
department: capitalize(department),
location: (job.location ?? '').trim() || DEFAULT_LOCATION,
jobUrl
applyUrl
}
}

View File

@@ -1,27 +0,0 @@
import type { Pack } from '../data/cloudNodes'
import { fetchCloudNodesForBuild } from './cloudNodes'
import { reportCloudNodesOutcome } from './cloudNodes.ci'
/**
* Resolve the list of packs to render at build time.
*
* 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.
*/
export async function loadPacksForBuild(): Promise<Pack[]> {
const outcome = await fetchCloudNodesForBuild()
reportCloudNodesOutcome(outcome)
if (outcome.status === 'failed') {
throw new Error(
`Cloud nodes fetch failed and no snapshot is available. Reason: ${outcome.reason}. ` +
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot.'
)
}
return outcome.snapshot.packs
}

View File

@@ -1,121 +0,0 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FetchOutcome } from './cloudNodes'
import type { NodesSnapshot } from '../data/cloudNodes'
import {
reportCloudNodesOutcome,
resetCloudNodesReporterForTests
} from './cloudNodes.ci'
function baseSnapshot(): NodesSnapshot {
return {
fetchedAt: new Date().toISOString(),
packs: [
{
id: 'pack-id',
displayName: 'Pack',
nodes: [
{
name: 'NodeClass',
displayName: 'Node Class',
category: 'misc'
}
]
}
]
}
}
function freshOutcome(droppedCount = 0): FetchOutcome {
return {
status: 'fresh',
droppedCount,
droppedNodes:
droppedCount === 0
? []
: [{ name: 'BadNode', reason: 'invalid display_name' }],
snapshot: baseSnapshot()
}
}
describe('reportCloudNodesOutcome', () => {
let writeSpy: ReturnType<typeof vi.spyOn>
let summaryDir: string
let summaryPath: string
const originalSummary = process.env.GITHUB_STEP_SUMMARY
beforeEach(() => {
resetCloudNodesReporterForTests()
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
summaryDir = mkdtempSync(join(tmpdir(), 'cloud-nodes-summary-'))
summaryPath = join(summaryDir, 'summary.md')
writeFileSync(summaryPath, '')
process.env.GITHUB_STEP_SUMMARY = summaryPath
})
afterEach(() => {
writeSpy.mockRestore()
rmSync(summaryDir, { recursive: true, force: true })
if (originalSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY
else process.env.GITHUB_STEP_SUMMARY = originalSummary
})
it('emits nothing on a clean fresh outcome', () => {
reportCloudNodesOutcome(freshOutcome(0))
expect(writeSpy).not.toHaveBeenCalled()
expect(readFileSync(summaryPath, 'utf8')).toContain('Fresh')
})
it('emits exactly one set of annotations across repeated calls', () => {
reportCloudNodesOutcome(freshOutcome(1))
reportCloudNodesOutcome(freshOutcome(1))
expect(writeSpy).toHaveBeenCalledTimes(1)
const annotation = writeSpy.mock.calls[0]?.[0] as string
expect(annotation).toContain(
'::warning title=Cloud nodes: dropped 1 invalid'
)
expect(readFileSync(summaryPath, 'utf8')).toContain('Dropped')
})
it('emits ::error for auth failures in a stale outcome', () => {
reportCloudNodesOutcome({
status: 'stale',
reason: 'HTTP 401 Unauthorized',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]?.[0] as string
expect(annotation).toContain(
'::error title=Cloud nodes authentication failed'
)
})
it('emits ::warning for missing-env stale outcomes', () => {
reportCloudNodesOutcome({
status: 'stale',
reason: 'missing WEBSITE_CLOUD_API_KEY',
snapshot: baseSnapshot()
})
const annotation = writeSpy.mock.calls[0]?.[0] as string
expect(annotation).toContain('::warning title=Cloud nodes integration')
})
it('emits ::error for a failed outcome and writes no fresh-only sections', () => {
reportCloudNodesOutcome({
status: 'failed',
reason: 'HTTP 500 Server Error'
})
const annotation = writeSpy.mock.calls[0]?.[0] as string
expect(annotation).toContain('::error title=Cloud nodes fetch failed')
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
})
it('does not throw when GITHUB_STEP_SUMMARY is not set', () => {
delete process.env.GITHUB_STEP_SUMMARY
expect(() => reportCloudNodesOutcome(freshOutcome(0))).not.toThrow()
})
})

View File

@@ -1,115 +0,0 @@
import { appendFileSync } from 'node:fs'
import type { FetchOutcome } from './cloudNodes'
let hasReported = false
export function resetCloudNodesReporterForTests(): void {
hasReported = false
}
export function reportCloudNodesOutcome(outcome: FetchOutcome): void {
if (hasReported) return
hasReported = true
const lines = buildAnnotations(outcome)
for (const line of lines) {
process.stdout.write(`${line}\n`)
}
const summaryPath = process.env.GITHUB_STEP_SUMMARY
if (summaryPath) {
try {
appendFileSync(summaryPath, buildStepSummary(outcome))
} catch {
// Writing the summary is best-effort; do not fail the build if the
// runner's summary file is unavailable (e.g. local dev).
}
}
}
function buildAnnotations(outcome: FetchOutcome): string[] {
if (outcome.status === 'fresh') {
if (outcome.droppedCount === 0) return []
const nodeCount = outcome.droppedCount === 1 ? 'node' : 'nodes'
const drops = outcome.droppedNodes
.map((d) => {
const name = escapeAnnotation(d.name ? `"${d.name}"` : '(unnamed)')
const reason = escapeAnnotation(d.reason)
return ` - ${name}: ${reason}`
})
.join('%0A')
return [
`::warning title=Cloud nodes: dropped ${outcome.droppedCount} invalid ${nodeCount}::Dropped nodes:%0A${drops}%0A%0AAction items:%0A 1. Verify node definitions returned by cloud /api/object_info.%0A 2. If a valid node shape changed, update @comfyorg/object-info-parser/src/schemas/nodeDefSchema.ts and add tests.%0A 3. Dropped nodes are not shown on /cloud/supported-nodes until fixed.`
]
}
if (outcome.status === 'stale') {
return [staleAnnotation(outcome.reason)]
}
return [
`::error title=Cloud nodes fetch failed and no snapshot is available::Cannot build cloud nodes page without data.%0A%0AReason: ${escapeAnnotation(outcome.reason)}%0A%0AAction items:%0A 1. Run \`pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot\` locally with a valid WEBSITE_CLOUD_API_KEY.%0A 2. Commit apps/website/src/data/cloud-nodes.snapshot.json.%0A 3. Push and re-run CI.`
]
}
function staleAnnotation(reason: string): string {
const escaped = escapeAnnotation(reason)
if (reason.startsWith('missing ')) {
return `::warning title=Cloud nodes integration::${escaped}. Falling back to committed snapshot.%0A%0AAction items:%0A 1. If you're a contributor without key access, this is expected. The snapshot will be used.%0A 2. If this is CI, check that the \`WEBSITE_CLOUD_API_KEY\` secret exists in the repo and is referenced in .github/workflows/ci-website-build.yaml.`
}
if (reason.startsWith('HTTP 401') || reason.startsWith('HTTP 403')) {
return `::error title=Cloud nodes authentication failed::${escaped}. The WEBSITE_CLOUD_API_KEY is missing, invalid, or revoked. Build continues with the last-known-good snapshot.%0A%0AAction items:%0A 1. Verify the Cloud API key is active and scoped for /api/object_info.%0A 2. Update the \`WEBSITE_CLOUD_API_KEY\` secret in GitHub Actions and Vercel.%0A 3. Re-run this workflow.`
}
if (reason.startsWith('envelope')) {
return `::error title=Cloud nodes schema mismatch::${escaped}. The Cloud API contract likely changed. Build continues with the snapshot, but future updates will fail until schema is fixed.%0A%0AAction items:%0A 1. Check cloud/services/ingest/openapi.yaml for /api/object_info changes.%0A 2. Update apps/website/src/utils/cloudNodes.schema.ts and @comfyorg/object-info-parser schema as needed.`
}
return `::warning title=Cloud nodes API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check cloud service health.%0A 2. Re-run this workflow once cloud.comfy.org is healthy.`
}
function escapeAnnotation(value: string): string {
return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
}
function buildStepSummary(outcome: FetchOutcome): string {
const header = '## ☁️ Cloud nodes\n'
const rows: Array<[string, string]> = []
if (outcome.status === 'fresh') {
rows.push(['Status', '✅ Fresh (fetched from Cloud API)'])
rows.push(['Packs', String(outcome.snapshot.packs.length)])
rows.push([
'Nodes',
String(outcome.snapshot.packs.reduce((n, p) => n + p.nodes.length, 0))
])
rows.push(['Dropped', String(outcome.droppedCount)])
} else if (outcome.status === 'stale') {
rows.push(['Status', '⚠️ Stale (using snapshot — Cloud fetch failed)'])
rows.push(['Packs', String(outcome.snapshot.packs.length)])
rows.push([
'Nodes',
String(outcome.snapshot.packs.reduce((n, p) => n + p.nodes.length, 0))
])
rows.push(['Reason', outcome.reason])
rows.push(['Snapshot age', describeSnapshotAge(outcome.snapshot.fetchedAt)])
} else {
rows.push(['Status', '❌ Failed (no snapshot available)'])
rows.push(['Reason', outcome.reason])
}
const table =
'| | |\n|---|---|\n' +
rows.map(([k, v]) => `| **${k}** | ${v} |`).join('\n') +
'\n'
return `${header}${table}\n`
}
function describeSnapshotAge(fetchedAt: string): string {
const fetched = new Date(fetchedAt).getTime()
if (Number.isNaN(fetched)) return 'unknown'
const days = Math.floor((Date.now() - fetched) / 86_400_000)
if (days <= 0) return 'today'
if (days === 1) return '1 day'
return `${days} days`
}

View File

@@ -1,77 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import {
DEFAULT_REGISTRY_BASE_URL,
fetchRegistryPacks
} from './cloudNodes.registry'
function jsonResponse(
body: unknown,
init: Partial<ResponseInit> = {}
): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
...init
})
}
describe('fetchRegistryPacks', () => {
it('requests node ids in batches of 50', async () => {
const ids = Array.from({ length: 120 }, (_, i) => `pack-${i}`)
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
const batchIds = url.searchParams.getAll('node_id')
return jsonResponse({
nodes: batchIds.map((id) => ({ id, name: id })),
total: batchIds.length,
page: 1,
limit: 50
})
})
const result = await fetchRegistryPacks(ids, {
fetchImpl: fetchImpl as typeof fetch
})
expect(fetchImpl).toHaveBeenCalledTimes(3)
expect(result.size).toBe(120)
const firstCallUrl = new URL(String(fetchImpl.mock.calls[0]?.[0]))
expect(firstCallUrl.origin).toBe(DEFAULT_REGISTRY_BASE_URL)
expect(firstCallUrl.pathname).toBe('/nodes')
expect(firstCallUrl.searchParams.getAll('node_id')).toHaveLength(50)
})
it('retries a failed batch once and then succeeds', async () => {
const fetchImpl = vi
.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>()
.mockResolvedValueOnce(new Response('{}', { status: 503 }))
.mockResolvedValueOnce(
jsonResponse({
nodes: [{ id: 'pack-1', name: 'Pack One' }],
total: 1,
page: 1,
limit: 50
})
)
const result = await fetchRegistryPacks(['pack-1'], {
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(fetchImpl).toHaveBeenCalledTimes(2)
expect(result.get('pack-1')?.name).toBe('Pack One')
})
it('returns an empty map when all batches fail (soft failure)', async () => {
const ids = ['pack-a', 'pack-b']
const fetchImpl = vi.fn(async () => new Response('{}', { status: 500 }))
const result = await fetchRegistryPacks(ids, {
fetchImpl: fetchImpl as typeof fetch
})
expect(fetchImpl).toHaveBeenCalledTimes(2)
expect(result.size).toBe(0)
})
})

View File

@@ -1,197 +0,0 @@
import { z } from 'zod'
import type { components } from '@comfyorg/registry-types'
export const DEFAULT_REGISTRY_BASE_URL = 'https://api.comfy.org'
const DEFAULT_TIMEOUT_MS = 5_000
const BATCH_SIZE = 50
export type RegistryPack = components['schemas']['Node']
const RegistryPackSchema = z
.object({
id: z.string().optional(),
name: z.string().optional(),
description: z.string().optional(),
icon: z.string().optional(),
banner_url: z.string().optional(),
repository: z.string().optional(),
license: z.string().optional(),
downloads: z.number().optional(),
github_stars: z.number().optional(),
created_at: z.string().optional(),
supported_os: z.array(z.string()).optional(),
supported_accelerators: z.array(z.string()).optional(),
publisher: z
.object({
id: z.string().optional(),
name: z.string().optional()
})
.passthrough()
.optional(),
latest_version: z
.object({
version: z.string().optional(),
createdAt: z.string().optional()
})
.passthrough()
.optional()
})
.passthrough()
const RegistryListResponseSchema = z
.object({
nodes: z.array(RegistryPackSchema)
})
.passthrough()
interface FetchRegistryOptions {
baseUrl?: string
timeoutMs?: number
fetchImpl?: typeof fetch
}
export async function fetchRegistryPacks(
packIds: readonly string[],
options: FetchRegistryOptions = {}
): Promise<Map<string, RegistryPack | null>> {
const uniquePackIds = [...new Set(packIds.filter((id) => id.length > 0))]
if (uniquePackIds.length === 0) {
return new Map()
}
const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_BASE_URL
const timeoutMs = clampTimeoutMs(options.timeoutMs)
const fetchImpl = options.fetchImpl ?? fetch
const batches = chunk(uniquePackIds, BATCH_SIZE)
const resolved = new Map<string, RegistryPack | null>()
let successCount = 0
let failureCount = 0
for (const batch of batches) {
const nodes = await fetchBatchWithRetry(
fetchImpl,
baseUrl,
batch,
timeoutMs
)
if (!nodes) {
failureCount += 1
for (const packId of batch) {
resolved.set(packId, null)
}
continue
}
successCount += 1
const nodesById = new Map(
nodes
.map((node) => [node.id, node] as const)
.filter(([id]) => typeof id === 'string' && id.length > 0)
)
for (const packId of batch) {
resolved.set(packId, nodesById.get(packId) ?? null)
}
}
if (failureCount > 0) {
console.warn(
`[cloud-nodes] registry enrichment: ${successCount}/${batches.length} batches succeeded, ${failureCount} failed`
)
}
if (successCount === 0) {
return new Map()
}
return resolved
}
async function fetchBatchWithRetry(
fetchImpl: typeof fetch,
baseUrl: string,
packIds: readonly string[],
timeoutMs: number
): Promise<RegistryPack[] | null> {
const firstAttempt = await fetchBatch(fetchImpl, baseUrl, packIds, timeoutMs)
if (firstAttempt.kind === 'ok') {
return firstAttempt.nodes
}
if (!firstAttempt.retryable) {
return null
}
const secondAttempt = await fetchBatch(fetchImpl, baseUrl, packIds, timeoutMs)
if (secondAttempt.kind === 'ok') {
return secondAttempt.nodes
}
return null
}
type BatchResponse =
| { kind: 'ok'; nodes: RegistryPack[] }
| { kind: 'err'; retryable: boolean }
async function fetchBatch(
fetchImpl: typeof fetch,
baseUrl: string,
packIds: readonly string[],
timeoutMs: number
): Promise<BatchResponse> {
const params = new URLSearchParams()
for (const packId of packIds) {
params.append('node_id', packId)
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetchImpl(`${baseUrl}/nodes?${params.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json'
},
signal: controller.signal
})
if (!res.ok) {
return {
kind: 'err',
retryable: res.status === 429 || (res.status >= 500 && res.status < 600)
}
}
const rawBody: unknown = await res.json()
const parsed = RegistryListResponseSchema.safeParse(rawBody)
if (!parsed.success) {
return { kind: 'err', retryable: false }
}
return { kind: 'ok', nodes: parsed.data.nodes as RegistryPack[] }
} catch {
return { kind: 'err', retryable: true }
} finally {
clearTimeout(timer)
}
}
function chunk<T>(values: readonly T[], size: number): T[][] {
const chunks: T[][] = []
for (let i = 0; i < values.length; i += size) {
chunks.push(values.slice(i, i + size))
}
return chunks
}
function clampTimeoutMs(candidate: number | undefined): number {
if (
typeof candidate !== 'number' ||
!Number.isFinite(candidate) ||
candidate <= 0
) {
return DEFAULT_TIMEOUT_MS
}
return Math.floor(candidate)
}

View File

@@ -1,3 +0,0 @@
import { z } from 'zod'
export const CloudNodesEnvelopeSchema = z.record(z.unknown())

View File

@@ -1,309 +0,0 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodesSnapshot } from '../data/cloudNodes'
import type * as ObjectInfoParser from '@comfyorg/object-info-parser'
const fetchRegistryPacksMock = vi.hoisted(() => vi.fn(async () => new Map()))
const sanitizeCallSpy = vi.hoisted(() => vi.fn())
vi.mock('./cloudNodes.registry', () => ({
DEFAULT_REGISTRY_BASE_URL: 'https://api.comfy.org',
fetchRegistryPacks: fetchRegistryPacksMock
}))
vi.mock('@comfyorg/object-info-parser', async (importOriginal) => {
const actual = (await importOriginal()) as typeof ObjectInfoParser
return {
...actual,
sanitizeUserContent: (
defs: Parameters<typeof actual.sanitizeUserContent>[0]
) => {
sanitizeCallSpy(defs)
return actual.sanitizeUserContent(defs)
}
}
})
import {
fetchCloudNodesForBuild,
resetCloudNodesFetcherForTests
} from './cloudNodes'
const BASE_URL = 'https://cloud.test'
const KEY = 'cloud-secret'
function validNode(
overrides: Partial<Record<string, unknown>> = {}
): Record<string, unknown> {
return {
name: 'ImpactNode',
display_name: 'Impact Node',
description: 'Node description',
category: 'impact/testing',
output_node: false,
python_module: 'custom_nodes.comfyui-impact-pack.nodes',
...overrides
}
}
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
...init
})
}
function makeSnapshot(packCount = 1): NodesSnapshot {
const packs = Array.from({ length: packCount }, (_, i) => ({
id: `snapshot-pack-${i}`,
displayName: `Snapshot Pack ${i}`,
nodes: [
{
name: `SnapshotNode${i}`,
displayName: `Snapshot Node ${i}`,
category: 'snapshot'
}
]
}))
return {
fetchedAt: '2026-04-01T00:00:00.000Z',
packs
}
}
function withSnapshotDir(snapshot: NodesSnapshot | null): URL {
const dir = mkdtempSync(join(tmpdir(), 'cloud-nodes-test-'))
const file = join(dir, 'cloud-nodes.snapshot.json')
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
return pathToFileURL(file)
}
describe('fetchCloudNodesForBuild', () => {
const savedCloudApiKey = process.env.WEBSITE_CLOUD_API_KEY
beforeEach(() => {
resetCloudNodesFetcherForTests()
fetchRegistryPacksMock.mockReset()
fetchRegistryPacksMock.mockResolvedValue(new Map())
sanitizeCallSpy.mockReset()
delete process.env.WEBSITE_CLOUD_API_KEY
})
afterEach(() => {
vi.restoreAllMocks()
process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey
})
it('returns fresh when API succeeds', async () => {
fetchRegistryPacksMock.mockResolvedValue(
new Map([
[
'comfyui-impact-pack',
{
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
}
]
])
)
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
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.droppedCount).toBe(0)
expect(outcome.snapshot.packs).toHaveLength(1)
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
)
})
it('drops invalid nodes individually and keeps valid nodes', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () =>
response({
ValidNode: validNode({ name: 'ValidNode' }),
BrokenNode: {
name: 'BrokenNode',
python_module: 'custom_nodes.some-pack'
}
})
)
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.droppedCount).toBe(1)
expect(outcome.droppedNodes[0]?.name).toBe('BrokenNode')
expect(outcome.snapshot.packs[0]?.nodes).toHaveLength(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('applies sanitizer before grouping', async () => {
const fetchImpl = vi.fn(async () =>
response({
LoadImage: validNode({
name: 'LoadImage',
python_module: 'nodes',
input: {
required: {
image: [['private.png', 'public.webp'], {}]
}
}
}),
ImpactNode: validNode({
input: {
required: {
choice: [['safe', 'movie.mov'], {}]
}
}
})
})
)
await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
expect(sanitizeCallSpy).toHaveBeenCalledTimes(1)
})
it('returns stale with missing env when snapshot is present', async () => {
const snapshot = makeSnapshot()
const snapshotUrl = withSnapshotDir(snapshot)
const fetchImpl = vi.fn()
const outcome = await fetchCloudNodesForBuild({
snapshotUrl,
fetchImpl: fetchImpl as unknown as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^missing /)
expect(fetchImpl).not.toHaveBeenCalled()
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('returns failed when env and snapshot are missing', async () => {
const snapshotUrl = withSnapshotDir(null)
const outcome = await fetchCloudNodesForBuild({
snapshotUrl,
fetchImpl: vi.fn() as unknown as typeof fetch
})
expect(outcome.status).toBe('failed')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('does not retry on HTTP 401', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 401 }))
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^HTTP 401/)
expect(fetchImpl).toHaveBeenCalledTimes(1)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('retries 5xx then falls back to snapshot', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response({}, { status: 503 }))
const sleep = vi.fn(async () => undefined)
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
snapshotUrl,
retryDelaysMs: [1, 1, 1],
sleep,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('stale')
expect(fetchImpl).toHaveBeenCalledTimes(4)
expect(sleep).toHaveBeenCalledTimes(3)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('falls back to snapshot on envelope schema mismatch', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn(async () => response(['unexpected-array-envelope']))
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
snapshotUrl,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^envelope schema/)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('memoizes within a single process', async () => {
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
const opts = {
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
}
const [a, b] = await Promise.all([
fetchCloudNodesForBuild(opts),
fetchCloudNodesForBuild(opts)
])
expect(a).toBe(b)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('throws when called twice with materially different options', async () => {
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
expect(() =>
fetchCloudNodesForBuild({
apiKey: 'different-key',
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
).toThrow(/called twice with different options/)
})
it('returns fresh even when registry enrichment fails', async () => {
fetchRegistryPacksMock.mockResolvedValue(new Map())
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
baseUrl: BASE_URL,
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
})
})

View File

@@ -1,355 +0,0 @@
import { readFile } from 'node:fs/promises'
import {
groupNodesByPack,
sanitizeUserContent,
validateComfyNodeDef
} from '@comfyorg/object-info-parser'
import type { RegistryPack } from './cloudNodes.registry'
import type { NodesSnapshot, Pack, PackNode } from '../data/cloudNodes'
import bundledSnapshot from '../data/cloud-nodes.snapshot.json' with { type: 'json' }
import { isNodesSnapshot } from '../data/cloudNodes'
import { fetchRegistryPacks } from './cloudNodes.registry'
import { CloudNodesEnvelopeSchema } from './cloudNodes.schema'
const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
export interface DroppedNode {
name: string
reason: string
}
export type FetchOutcome =
| {
status: 'fresh'
snapshot: NodesSnapshot
droppedCount: number
droppedNodes: DroppedNode[]
}
| { status: 'stale'; snapshot: NodesSnapshot; reason: string }
| { status: 'failed'; reason: string }
interface FetchCloudNodesOptions {
apiKey?: string
baseUrl?: string
timeoutMs?: number
retryDelaysMs?: readonly number[]
fetchImpl?: typeof fetch
snapshotUrl?: URL
sleep?: (ms: number) => Promise<void>
}
let inflight: Promise<FetchOutcome> | undefined
let inflightOptions: FetchCloudNodesOptions | undefined
export function resetCloudNodesFetcherForTests(): void {
inflight = undefined
inflightOptions = undefined
}
function optionsDifferMaterially(
a: FetchCloudNodesOptions,
b: FetchCloudNodesOptions
): boolean {
return (
a.apiKey !== b.apiKey ||
a.baseUrl !== b.baseUrl ||
a.timeoutMs !== b.timeoutMs ||
a.snapshotUrl?.href !== b.snapshotUrl?.href
)
}
export function fetchCloudNodesForBuild(
options: FetchCloudNodesOptions = {}
): Promise<FetchOutcome> {
if (inflight && inflightOptions) {
if (optionsDifferMaterially(inflightOptions, options)) {
throw new Error(
'fetchCloudNodesForBuild called twice with different options; call resetCloudNodesFetcherForTests() between distinct configurations'
)
}
return inflight
}
inflightOptions = options
inflight = doFetchCloudNodesForBuild(options)
return inflight
}
async function doFetchCloudNodesForBuild(
options: FetchCloudNodesOptions
): Promise<FetchOutcome> {
const apiKey = options.apiKey ?? process.env.WEBSITE_CLOUD_API_KEY
if (!apiKey) {
return fallback('missing WEBSITE_CLOUD_API_KEY', options.snapshotUrl)
}
const result = await tryFetchAndParse(apiKey, options)
if (result.kind === 'ok') {
return {
status: 'fresh',
snapshot: {
fetchedAt: new Date().toISOString(),
packs: result.packs
},
droppedCount: result.droppedNodes.length,
droppedNodes: result.droppedNodes
}
}
return fallback(result.reason, options.snapshotUrl)
}
async function fallback(
reason: string,
snapshotUrl: URL | undefined
): Promise<FetchOutcome> {
const snapshot = await readSnapshot(snapshotUrl)
if (snapshot) return { status: 'stale', snapshot, reason }
return { status: 'failed', reason }
}
interface FetchOk {
kind: 'ok'
packs: Pack[]
droppedNodes: DroppedNode[]
}
interface FetchErr {
kind: 'err'
reason: string
}
async function tryFetchAndParse(
apiKey: string,
options: FetchCloudNodesOptions
): Promise<FetchOk | FetchErr> {
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS
const fetchImpl = options.fetchImpl ?? fetch
const sleep = options.sleep ?? defaultSleep
const url = `${baseUrl}/api/object_info`
let lastReason = 'unknown error'
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
if (attempt > 0) await sleep(retryDelaysMs[attempt - 1])
const response = await callOnce(fetchImpl, url, apiKey, timeoutMs)
if (response.kind === 'err') {
lastReason = response.reason
if (!response.retryable) return response
continue
}
const envelope = CloudNodesEnvelopeSchema.safeParse(response.body)
if (!envelope.success) {
return {
kind: 'err',
reason: `envelope schema validation failed: ${envelope.error.issues
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
.join('; ')}`
}
}
if (Object.keys(envelope.data).length === 0) {
return {
kind: 'err',
reason:
'envelope schema validation failed: <root>: expected non-empty object'
}
}
return parseCloudNodes(envelope.data, options)
}
return { kind: 'err', reason: lastReason }
}
type CallResponse =
| { kind: 'ok'; body: unknown }
| { kind: 'err'; reason: string; retryable: boolean }
async function callOnce(
fetchImpl: typeof fetch,
url: string,
apiKey: string,
timeoutMs: number
): Promise<CallResponse> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetchImpl(url, {
method: 'GET',
headers: {
Accept: 'application/json',
'X-API-Key': apiKey
},
signal: controller.signal
})
if (res.ok) {
return { kind: 'ok', body: await res.json() }
}
const retryable =
res.status === 429 || (res.status >= 500 && res.status < 600)
return {
kind: 'err',
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
retryable
}
} catch (error) {
const reason =
error instanceof Error
? `network error: ${error.message}`
: 'network error'
return { kind: 'err', reason, retryable: true }
} finally {
clearTimeout(timer)
}
}
async function parseCloudNodes(
envelope: Record<string, unknown>,
options: FetchCloudNodesOptions
): Promise<FetchOk> {
const validDefs: Record<string, ReturnType<typeof validateComfyNodeDef>> = {}
const droppedNodes: DroppedNode[] = []
for (const [name, rawDef] of Object.entries(envelope)) {
let validationError = 'unknown validation error'
const parsed = validateComfyNodeDef(rawDef, (error) => {
validationError = error
})
if (!parsed) {
droppedNodes.push({ name, reason: validationError })
continue
}
validDefs[name] = parsed
}
const sanitizedDefs = sanitizeUserContent(
validDefs as Record<string, NonNullable<(typeof validDefs)[string]>>
)
const grouped = groupNodesByPack(sanitizedDefs)
let registryMap = new Map<string, RegistryPack | null>()
try {
registryMap = await fetchRegistryPacks(
grouped.map((pack) => pack.id),
{ fetchImpl: options.fetchImpl }
)
} catch {
registryMap = new Map()
}
const packs = grouped.map((pack) =>
toDomainPack(
pack.id,
pack.displayName,
pack.nodes,
registryMap.get(pack.id)
)
)
return { kind: 'ok', packs, droppedNodes }
}
function safeExternalUrl(value: string | undefined): string | undefined {
if (!value) return undefined
try {
const url = new URL(value)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
if (!url.host) return undefined
return url.toString()
} catch {
return undefined
}
}
function toDomainPack(
packId: string,
fallbackDisplayName: string,
nodes: Array<{
className: string
def: {
display_name: string
category: string
description: string
deprecated?: boolean
experimental?: boolean
}
}>,
registryPack: RegistryPack | null | undefined
): Pack {
return {
id: packId,
registryId: registryPack?.id,
displayName: registryPack?.name?.trim() || fallbackDisplayName || packId,
description: registryPack?.description?.trim() || undefined,
bannerUrl: safeExternalUrl(registryPack?.banner_url),
iconUrl: safeExternalUrl(registryPack?.icon),
repoUrl: safeExternalUrl(registryPack?.repository),
publisher: registryPack?.publisher?.id
? {
id: registryPack.publisher.id,
name: registryPack.publisher.name
}
: undefined,
downloads: registryPack?.downloads,
githubStars: registryPack?.github_stars,
latestVersion: registryPack?.latest_version?.version,
license: registryPack?.license,
lastUpdated:
registryPack?.latest_version?.createdAt ?? registryPack?.created_at,
supportedOs: registryPack?.supported_os,
supportedAccelerators: registryPack?.supported_accelerators,
nodes: nodes
.map((node) => toDomainNode(node.className, node.def))
.sort((a, b) => a.displayName.localeCompare(b.displayName))
}
}
function toDomainNode(
className: string,
def: {
display_name: string
category: string
description: string
deprecated?: boolean
experimental?: boolean
}
): PackNode {
return {
name: className,
displayName: def.display_name,
category: def.category,
description: def.description || undefined,
deprecated: def.deprecated,
experimental: def.experimental
}
}
async function readSnapshot(
snapshotUrl: URL | undefined
): Promise<NodesSnapshot | null> {
if (!snapshotUrl) {
return isNodesSnapshot(bundledSnapshot) ? bundledSnapshot : null
}
try {
const text = await readFile(snapshotUrl, 'utf8')
const parsed: unknown = JSON.parse(text)
if (isNodesSnapshot(parsed)) return parsed
return null
} catch {
return null
}
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -1,24 +0,0 @@
/**
* Serialize an object to a JSON string safe to embed inside an inline
* `<script type="application/ld+json">` tag.
*
* Pack metadata flows in from the public ComfyUI registry, where any user
* can publish a pack with a `displayName`, `description`, or
* `publisher.name` that contains `</script>` or `<!--`. Without escaping,
* those sequences would close the surrounding `<script>` tag and allow
* stored XSS.
*
* Escapes:
* - Every `<` (covers `</script>`, `<script`, `<!--`)
* - U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR (which are
* line terminators in HTML script context but valid characters
* elsewhere in JSON, so `JSON.stringify` does not escape them)
*
* Reference: https://v8.dev/features/subsume-json
*/
export function escapeJsonLd(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029')
}

View File

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

View File

@@ -285,12 +285,10 @@ export class ComfyPage {
async setup({
clearStorage = true,
mockReleases = true,
url
mockReleases = true
}: {
clearStorage?: boolean
mockReleases?: boolean
url?: string
} = {}) {
// Mock release endpoint to prevent changelog popups (before navigation)
if (mockReleases) {
@@ -322,7 +320,7 @@ export class ComfyPage {
}, this.id)
}
await this.goto({ url })
await this.goto()
await this.page.waitForFunction(() => document.fonts.ready)
await this.waitForAppReady()
@@ -349,8 +347,8 @@ export class ComfyPage {
return assetPath(fileName)
}
async goto({ url }: { url?: string } = {}) {
await this.page.goto(url ? new URL(url, this.url).toString() : this.url)
async goto() {
await this.page.goto(this.url)
}
async nextFrame() {
@@ -472,7 +470,6 @@ const COLLECT_COVERAGE = process.env.COLLECT_COVERAGE === 'true'
export const comfyPageFixture = base.extend<{
initialFeatureFlags: Record<string, unknown>
initialSettings: Record<string, unknown>
comfyPage: ComfyPage
comfyMouse: ComfyMouse
comfyFiles: ComfyFiles
@@ -480,10 +477,6 @@ export const comfyPageFixture = base.extend<{
// Allows configuring feature flags for tests with before initial setup:
// `test.use({ initialFeatureFlags: { my_flag: true } })`.
initialFeatureFlags: [{}, { option: true }],
// Allows seeding user settings before initial page load:
// `test.use({ initialSettings: { 'Comfy.Locale': 'zh' } })`. Merged on top of
// the fixture's defaults so per-test values win.
initialSettings: [{}, { option: true }],
page: async ({ page, browserName }, use) => {
if (browserName !== 'chromium' || !COLLECT_COVERAGE) {
@@ -501,11 +494,7 @@ export const comfyPageFixture = base.extend<{
await mcr.add(coverage)
},
comfyPage: async (
{ page, request, initialFeatureFlags, initialSettings },
use,
testInfo
) => {
comfyPage: async ({ page, request, initialFeatureFlags }, use, testInfo) => {
const comfyPage = new ComfyPage(page, request)
const { parallelIndex } = testInfo
@@ -540,8 +529,7 @@ export const comfyPageFixture = base.extend<{
// Disable errors tab to prevent missing model detection from
// rendering error indicators on nodes during unrelated tests.
'Comfy.RightSidePanel.ShowErrorsTab': false,
...(isVueNodes && { 'Comfy.VueNodes.Enabled': true }),
...initialSettings
...(isVueNodes && { 'Comfy.VueNodes.Enabled': true })
})
} catch (e) {
console.error(e)

View File

@@ -384,14 +384,11 @@ export class AssetsSidebarTab extends SidebarTab {
return this.page.locator('.p-contextmenu').getByText(label)
}
override async open({ waitForAssets = true } = {}) {
override async open() {
// Remove any toast notifications that may overlay the sidebar button
await this.dismissToasts()
await super.open()
await this.generatedTab.waitFor({ state: 'visible' })
if (waitForAssets) {
await this.waitForAssets()
}
}
/** Dismiss all visible toast notifications by clicking their close buttons. */

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

@@ -4,16 +4,10 @@ import type { Locator, Page } from '@playwright/test'
export class ToastHelper {
public readonly visibleToasts: Locator
public readonly toastErrors: Locator
public readonly toastSuccesses: Locator
public readonly toastWarnings: Locator
constructor(private readonly page: Page) {
this.visibleToasts = page.locator('.p-toast-message:visible')
this.toastErrors = page.locator('.p-toast-message.p-toast-message-error')
this.toastSuccesses = page.locator(
'.p-toast-message.p-toast-message-success'
)
this.toastWarnings = page.locator('.p-toast-message.p-toast-message-warn')
}
async closeToasts(requireCount = 0): Promise<void> {

View File

@@ -92,6 +92,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: {

View File

@@ -1,199 +0,0 @@
/**
* FE-230: Deleting an asset must clear the Load Image node preview, widget
* value, and mark the workflow dirty.
*
* Local run (requires cloud build of the frontend):
* pnpm build:cloud
* pnpm exec playwright test --project=cloud \
* browser_tests/tests/assetDeleteClearsLoadImage.spec.ts --reporter=list
*
* The cloud project is required because input-asset deletion is gated on
* `isCloud === true` (see `useMediaAssetActions.deleteAssetApi`).
*/
import type { Page, Route } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
STABLE_CHECKPOINT,
STABLE_INPUT_IMAGE
} from '@e2e/fixtures/data/assetFixtures'
// The asset name must match the dropped file so that the deletion flow's
// widget-value matching (name + `name [input]`) actually targets the same
// value the drag-and-drop set on the Load Image widget.
const DROPPED_FILE = 'image64x64.webp'
const TARGET_ASSET: Asset = {
...STABLE_INPUT_IMAGE,
name: DROPPED_FILE,
mime_type: 'image/webp'
}
const SEEDED_ASSETS: Asset[] = [STABLE_CHECKPOINT, TARGET_ASSET]
// MediaAssetCard renders the basename without extension, so card-text
// matching uses the stripped form.
const TARGET_CARD_TEXT = TARGET_ASSET.name.replace(/\.[^.]+$/, '')
type AssetMockApi = {
readonly deleteCalls: ReadonlyArray<string>
}
const assetMockByPage = new WeakMap<Page, { deleteCalls: string[] }>()
function filterByTags(assets: Asset[], url: URL): Asset[] {
const includeTags = parseTagParam(url.searchParams.get('include_tags'))
const excludeTags = parseTagParam(url.searchParams.get('exclude_tags'))
return assets.filter(
(asset) =>
includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
excludeTags.every((tag) => !(asset.tags ?? []).includes(tag))
)
}
function parseTagParam(value: string | null): string[] {
return (
value
?.split(',')
.map((tag) => tag.trim())
.filter(Boolean) ?? []
)
}
// Narrow the route patterns to the cloud API endpoints we actually want to
// intercept. Using a broader pattern (e.g. `**/assets**`) collides with the
// cloud build's static bundle paths under `/assets/*.js` and starves the app
// of its own JavaScript, leaving it stuck on the loading splash.
async function registerAssetMocks(
page: Page,
assets: Asset[],
deleteCalls: string[]
): Promise<void> {
await page.route(/\/api\/assets(?:\?.*)?$/, (route: Route) => {
if (route.request().method() !== 'GET') return route.fallback()
const url = new URL(route.request().url())
const filtered = filterByTags(assets, url)
const body: ListAssetsResponse = {
assets: filtered,
total: filtered.length,
has_more: false
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
})
await page.route(/\/api\/assets\/([^/?#]+)$/, (route: Route) => {
const method = route.request().method()
const id = new URL(route.request().url()).pathname.split('/').pop() ?? ''
if (method === 'DELETE') {
deleteCalls.push(id)
return route.fulfill({ status: 204, body: '' })
}
if (method === 'GET') {
const found = assets.find((asset) => asset.id === id)
if (found) return route.fulfill({ status: 200, json: found })
return route.fulfill({ status: 404, json: { error: 'Not found' } })
}
return route.fallback()
})
}
const baseTest = comfyPageFixture.extend<{ assetMock: AssetMockApi }>({
page: async ({ page }, use) => {
const deleteCalls: string[] = []
await registerAssetMocks(page, SEEDED_ASSETS, deleteCalls)
assetMockByPage.set(page, { deleteCalls })
await use(page)
assetMockByPage.delete(page)
},
assetMock: async ({ page }, use) => {
const state = assetMockByPage.get(page)
if (!state) throw new Error('assetMock state missing for page')
await use({
get deleteCalls() {
return state.deleteCalls
}
})
}
})
baseTest.describe(
'FE-230 asset delete clears Load Image preview',
{ tag: '@cloud' },
() => {
baseTest(
'deleting an input asset clears widget value, preview cache, and marks workflow modified',
async ({ comfyPage, assetMock }) => {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
// Drive the production drag-and-drop flow to point the Load Image
// widget at the asset we are about to delete and populate the preview
// cache. FE-230 is asserting that the deletion tears these down.
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
const { x, y } = await loadImageNode.getPosition()
await comfyPage.dragDrop.dragAndDropFile(DROPPED_FILE, {
dropPosition: { x, y },
waitForUpload: true
})
const imageWidget = await loadImageNode.getWidget(0)
await expect.poll(() => imageWidget.getValue()).toBe(DROPPED_FILE)
// Re-baseline the change tracker so the deletion-side mutation is the
// only thing that can flip `isModified` later.
await comfyPage.page.evaluate(() => {
const tracker =
window.app?.extensionManager?.workflow?.activeWorkflow
?.changeTracker
tracker?.reset?.()
})
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(false)
// Drive the real production flow: assets sidebar → Imported tab →
// right-click asset card → Delete → confirm dialog.
const sidebar = comfyPage.menu.assetsTab
// The default `open()` waits for assets on the Generated tab; we seed
// only an input asset, so skip that wait and let `waitForAssets(1)`
// gate on the Imported tab instead.
await sidebar.open({ waitForAssets: false })
await sidebar.switchToImported()
await sidebar.waitForAssets(1)
await sidebar.rightClickAsset(TARGET_CARD_TEXT)
const deleteMenuItem = sidebar.contextMenuItem('Delete')
await expect(deleteMenuItem).toBeVisible()
await deleteMenuItem.click()
await comfyPage.confirmDialog.click('delete')
// Mocked DELETE was issued.
await expect
.poll(() => assetMock.deleteCalls.includes(TARGET_ASSET.id))
.toBe(true)
// Widget value was cleared.
await expect.poll(() => imageWidget.getValue()).toBe('')
// Preview cache was cleared.
await expect
.poll(() =>
comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
return node?.imgs?.length ?? 0
}, loadImageNode.id)
)
.toBe(0)
// Workflow was marked dirty by changeTracker.captureCanvasState().
await expect
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
.toBe(true)
}
)
}
)

View File

@@ -1,101 +0,0 @@
import type { Page } from '@playwright/test'
import type { CustomNodesI18n } from '@/schemas/apiSchema'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
const NODE_TYPE = 'DevToolsNodeWithStringInput'
const LOCALIZED_ZH = '本地化字符串输入 (ZH)'
const LOCALIZED_ZH_TW = '本地化字串輸入 (ZH-TW)'
const LOCALIZED_EN = 'Localized String Input (EN)'
async function routeCustomNodesI18n(page: Page, body: CustomNodesI18n) {
await page.route('**/api/i18n', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
})
}
test.describe(
'Custom node locales loading',
{ tag: ['@ui', '@vue-nodes'] },
() => {
test.describe('shipped base tag', () => {
test.use({ initialSettings: { 'Comfy.Locale': 'zh' } })
test.beforeEach(async ({ page }) => {
await routeCustomNodesI18n(page, {
zh: { nodeDefs: { [NODE_TYPE]: { display_name: LOCALIZED_ZH } } }
})
})
// Regression test for PR #7214 (issue #7025): custom-node i18n data was
// clobbered when a non-English locale was lazily loaded, so nodes from
// custom packs lost their translated display_name on locale switch.
test('preserves custom-node /api/i18n translation through lazy locale load', async ({
comfyPage
}) => {
await comfyPage.nodeOps.addNode(NODE_TYPE)
await expect(
comfyPage.vueNodes.getNodeByTitle(LOCALIZED_ZH)
).toHaveCount(1)
})
})
test.describe('unsupported tag clamps to en', () => {
// Regression test for PR #11712 (issue #10563): when Comfy.Locale holds
// an unsupported tag, the boundary helper clamps it to 'en'. Custom-node
// 'en' translations must still merge into the active locale messages.
test.use({ initialSettings: { 'Comfy.Locale': 'de' } })
test.beforeEach(async ({ page }) => {
await routeCustomNodesI18n(page, {
en: { nodeDefs: { [NODE_TYPE]: { display_name: LOCALIZED_EN } } }
})
})
test('renders en custom-node translation when locale clamps to en', async ({
comfyPage
}) => {
await comfyPage.nodeOps.addNode(NODE_TYPE)
await expect(
comfyPage.vueNodes.getNodeByTitle(LOCALIZED_EN)
).toHaveCount(1)
})
})
test.describe('regional tag preserved', () => {
// Regression test for PR #11712: full-tag match must beat base-tag
// fallback, so a shipped regional tag like 'zh-TW' is not collapsed to
// its base ('zh'). Both keys are present in the payload — the active
// locale must merge the regional variant.
test.use({ initialSettings: { 'Comfy.Locale': 'zh-TW' } })
test.beforeEach(async ({ page }) => {
await routeCustomNodesI18n(page, {
zh: { nodeDefs: { [NODE_TYPE]: { display_name: LOCALIZED_ZH } } },
'zh-TW': {
nodeDefs: { [NODE_TYPE]: { display_name: LOCALIZED_ZH_TW } }
}
})
})
test('uses zh-TW custom-node translation, not zh base-tag fallback', async ({
comfyPage
}) => {
await comfyPage.nodeOps.addNode(NODE_TYPE)
await expect(
comfyPage.vueNodes.getNodeByTitle(LOCALIZED_ZH_TW)
).toHaveCount(1)
})
})
}
)

View File

@@ -1,47 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
// Regression test for https://github.com/Comfy-Org/ComfyUI_frontend/issues/10563
//
// Pins the end-to-end cascade through createI18n + coreSettings defaultValue +
// GraphView watchEffect: when navigator.language base tag is unsupported (e.g.
// 'de-DE') and Comfy.Locale is unset (fresh-install state), sidebar labels
// must render translated strings, not literal i18n keys like
// 'sideToolbar.labels.assets'.
test.describe('i18n locale fallback', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.addInitScript(() => {
Object.defineProperty(navigator, 'language', {
value: 'de-DE',
configurable: true
})
Object.defineProperty(navigator, 'languages', {
value: ['de-DE', 'de'],
configurable: true
})
})
// Default sidebar size on small viewports hides labels; force normal so
// .side-bar-button-label is rendered for the assertion.
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
await comfyPage.page.reload()
await comfyPage.waitForAppReady()
})
test('sidebar labels render translated strings, not raw i18n keys', async ({
comfyPage
}) => {
const { page } = comfyPage
await page.setViewportSize({ width: 1920, height: 1080 })
const labelTexts = await page
.getByTestId('side-toolbar')
.locator('.side-bar-button-label')
.allTextContents()
expect(labelTexts.length).toBeGreaterThan(0)
for (const text of labelTexts) {
expect(text).not.toContain('sideToolbar.labels')
}
})
})

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

@@ -74,90 +74,6 @@ test.describe(
})
})
test.describe('Image without workflow', () => {
test('places LoadImage at the drop cursor when graph_mouse is stale', async ({
comfyPage
}) => {
await comfyPage.nodeOps.clearGraph()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await comfyPage.page.evaluate(() => {
window.app!.canvas.graph_mouse[0] = -9999
window.app!.canvas.graph_mouse[1] = -9999
})
const dropPosition = { x: 480, y: 320 }
await comfyPage.dragDrop.dragAndDropFile('image32x32.webp', {
dropPosition,
waitForUpload: true
})
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
const { nodePos, expectedPos } = await comfyPage.page.evaluate(
(drop) => {
const canvas = window.app!.canvas
const expected = canvas.convertEventToCanvasOffset(
new MouseEvent('drop', {
clientX: drop.x,
clientY: drop.y
})
) as [number, number]
const node = window.app!.graph.nodes[0]
return {
nodePos: [node.pos[0], node.pos[1]] as [number, number],
expectedPos: expected
}
},
dropPosition
)
expect(nodePos[0]).toBeCloseTo(expectedPos[0], 0)
expect(nodePos[1]).toBeCloseTo(expectedPos[1], 0)
expect(nodePos[0]).not.toBe(-9999)
expect(nodePos[1]).not.toBe(-9999)
})
test('places LoadImage above existing nodes (zIndex)', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
const initialNodeIds = await comfyPage.vueNodes.getNodeIds()
expect(initialNodeIds.length).toBeGreaterThan(0)
await comfyPage.dragDrop.dragAndDropFile('image32x32.webp', {
dropPosition: { x: 540, y: 380 },
waitForUpload: true
})
await expect
.poll(() => comfyPage.vueNodes.getNodeCount())
.toBe(initialNodeIds.length + 1)
const newNodeIds = await comfyPage.vueNodes.getNodeIds()
const addedNodeId = newNodeIds.find(
(id) => !initialNodeIds.includes(id)
)
expect(addedNodeId).toBeDefined()
const newNodeZ = await comfyPage.vueNodes
.getNodeLocator(addedNodeId!)
.evaluate((el) => Number((el as HTMLElement).style.zIndex))
const existingZIndexes = await comfyPage.vueNodes.nodes.evaluateAll(
(els, id) =>
els
.filter((el) => el.getAttribute('data-node-id') !== id)
.map((el) => Number((el as HTMLElement).style.zIndex)),
addedNodeId!
)
expect(newNodeZ).toBeGreaterThan(Math.max(0, ...existingZIndexes))
})
})
test('Load workflow from URL dropped onto Vue node', async ({
comfyPage
}) => {

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', () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

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

View File

@@ -82,19 +82,6 @@ const JOB_GAMMA_DETAIL: JobDetail = {
}
]
}
},
workflow: {
extra_data: {
extra_pnginfo: {
workflow: {
version: 0.4,
last_node_id: 0,
last_link_id: 0,
nodes: [],
links: []
}
}
}
}
}
@@ -125,7 +112,7 @@ test.describe('Assets sidebar - empty states', () => {
test('Shows empty-state copy for generated tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open({ waitForAssets: false })
await tab.open()
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
await expect(tab.emptyStateMessage).toBeVisible()
@@ -133,7 +120,7 @@ test.describe('Assets sidebar - empty states', () => {
test('Shows empty-state copy for imported tab', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open({ waitForAssets: false })
await tab.open()
await tab.switchToImported()
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
@@ -142,7 +129,7 @@ test.describe('Assets sidebar - empty states', () => {
test('No asset cards are rendered when empty', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open({ waitForAssets: false })
await tab.open()
await expect(tab.assetCards).toHaveCount(0)
})
@@ -222,6 +209,7 @@ test.describe('Assets sidebar - grid view display', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
})
@@ -282,6 +270,7 @@ test.describe('Assets sidebar - view mode toggle', () => {
test('Can switch to list view via settings menu', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Open settings menu and select list view
await tab.openSettingsMenu()
@@ -294,6 +283,7 @@ test.describe('Assets sidebar - view mode toggle', () => {
test('Can switch back to grid view', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Switch to list view
await tab.openSettingsMenu()
@@ -336,6 +326,7 @@ test.describe('Assets sidebar - search', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
@@ -349,6 +340,7 @@ test.describe('Assets sidebar - search', () => {
test('Clearing search restores all assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
@@ -363,6 +355,7 @@ test.describe('Assets sidebar - search', () => {
test('Search with no matches shows empty state', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.searchInput.fill('nonexistent_file_xyz')
await expect(tab.assetCards).toHaveCount(0)
@@ -387,6 +380,7 @@ test.describe('Assets sidebar - selection', () => {
test('Clicking an asset card selects it', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Click first asset card
await tab.assetCards.first().click()
@@ -398,6 +392,7 @@ test.describe('Assets sidebar - selection', () => {
test('Ctrl+click adds to selection', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const cards = tab.assetCards
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
@@ -416,6 +411,7 @@ test.describe('Assets sidebar - selection', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
@@ -427,6 +423,7 @@ test.describe('Assets sidebar - selection', () => {
test('Deselect all clears selection', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
@@ -444,6 +441,7 @@ test.describe('Assets sidebar - selection', () => {
test('Selection is cleared when switching tabs', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select an asset
await tab.assetCards.first().click()
@@ -477,6 +475,7 @@ test.describe('Assets sidebar - context menu', () => {
test('Right-clicking an asset shows context menu', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Right-click first asset
await tab.assetCards.first().click({ button: 'right' })
@@ -491,6 +490,7 @@ test.describe('Assets sidebar - context menu', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
@@ -505,6 +505,7 @@ test.describe('Assets sidebar - context menu', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
@@ -519,6 +520,7 @@ test.describe('Assets sidebar - context menu', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
@@ -533,6 +535,7 @@ test.describe('Assets sidebar - context menu', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await comfyPage.page
@@ -547,6 +550,7 @@ test.describe('Assets sidebar - context menu', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
@@ -559,79 +563,12 @@ test.describe('Assets sidebar - context menu', () => {
await expect(tab.contextMenuItem('Export workflow')).toBeVisible()
})
test('Cancelling export-workflow filename prompt does not show an error toast', async ({
comfyPage
}) => {
// job-gamma is the first card; its detail carries a valid workflow so
// extraction succeeds and the filename prompt opens.
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.assetCards.first().click({ button: 'right' })
await tab.contextMenuItem('Export workflow').click()
const promptDialog = comfyPage.page.getByRole('dialog', {
name: 'Export Workflow'
})
await expect(promptDialog).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(promptDialog).toBeHidden()
await expect(comfyPage.toast.toastErrors).toBeHidden({ timeout: 1500 })
})
test('Confirming export-workflow prompt downloads the file and shows a success toast', async ({
comfyPage
}) => {
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.assetCards.first().click({ button: 'right' })
await tab.contextMenuItem('Export workflow').click()
const promptDialog = comfyPage.page.getByRole('dialog', {
name: 'Export Workflow'
})
await expect(promptDialog).toBeVisible()
const downloadPromise = comfyPage.page.waitForEvent('download')
await promptDialog.getByRole('button', { name: 'Confirm' }).click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('abstract_art.json')
await expect(comfyPage.toast.toastSuccesses).toBeVisible()
})
test('Export-workflow shows a warning toast when the asset has no workflow', async ({
comfyPage
}) => {
// Strip the workflow field so extraction yields null and the export
// action returns { success: false, error: 'No workflow…' }.
const { workflow: _, ...detailWithoutWorkflow } = JOB_GAMMA_DETAIL
await comfyPage.assets.mockJobDetail('job-gamma', detailWithoutWorkflow)
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.assetCards.first().click({ button: 'right' })
await tab.contextMenuItem('Export workflow').click()
// Filename prompt should be skipped: extraction fails before the prompt.
await expect(comfyPage.toast.toastWarnings).toBeVisible()
await expect(comfyPage.toast.toastSuccesses).toBeHidden({ timeout: 1500 })
})
test('Bulk context menu shows when multiple assets selected', async ({
comfyPage
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const cards = tab.assetCards
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
@@ -686,6 +623,7 @@ test.describe('Assets sidebar - bulk actions', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click()
@@ -698,6 +636,7 @@ test.describe('Assets sidebar - bulk actions', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click()
@@ -708,6 +647,7 @@ test.describe('Assets sidebar - bulk actions', () => {
test('Selection count displays correct number', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
// Select the two single-output assets (job-alpha, job-beta).
// The count reflects total outputs, not cards — job-gamma has
@@ -736,6 +676,7 @@ cloudTest.describe('Assets sidebar - cloud exports', { tag: '@cloud' }, () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click()
await expect(tab.downloadSelectedButton).toBeVisible()
@@ -760,6 +701,7 @@ cloudTest.describe('Assets sidebar - cloud exports', { tag: '@cloud' }, () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards
.first()
@@ -795,6 +737,7 @@ cloudTest.describe('Assets sidebar - cloud exports', { tag: '@cloud' }, () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.nth(1).click()
await comfyPage.page.keyboard.down('Control')
@@ -841,6 +784,7 @@ test.describe('Assets sidebar - pagination', () => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const req = await firstRequest
const url = new URL(req.url())
@@ -896,6 +840,7 @@ test.describe('Assets sidebar - delete confirmation', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
await tab.assetCards.first().click({ button: 'right' })
await tab.contextMenuItem('Delete').click()
@@ -913,6 +858,7 @@ test.describe('Assets sidebar - delete confirmation', () => {
}) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
@@ -934,6 +880,7 @@ test.describe('Assets sidebar - delete confirmation', () => {
test('Cancelling delete preserves asset', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()
@@ -1030,6 +977,7 @@ test.describe('Assets sidebar - media type filter', () => {
test('Unchecking image filter hides image assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = tab.assetCards
await expect(
@@ -1050,6 +998,7 @@ test.describe('Assets sidebar - media type filter', () => {
test('Re-enabling filter restores hidden assets', async ({ comfyPage }) => {
const tab = comfyPage.menu.assetsTab
await tab.open()
await tab.waitForAssets()
const initialCount = await tab.assetCards.count()

View File

@@ -106,49 +106,6 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
await expect(comfyPage.templates.content).toBeVisible()
})
test('dialog should not be shown when first-time user opens a shared workflow link', async ({
comfyPage
}) => {
await comfyPage.page.route(
'**/workflows/published/test-share-id',
async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
share_id: 'test-share-id',
workflow_id: 'wf-1',
name: 'Shared Workflow',
listed: true,
publish_time: new Date().toISOString(),
workflow_json: {
version: 0.4,
nodes: [],
links: [],
groups: [],
config: {},
extra: {}
},
assets: []
})
})
}
)
await comfyPage.settings.setSetting('Comfy.TutorialCompleted', false)
await comfyPage.setup({
clearStorage: true,
url: '/?share=test-share-id'
})
await expect(
comfyPage.page.getByRole('heading', { name: 'Open shared workflow' })
).toBeVisible()
await expect(comfyPage.templates.content).toBeHidden()
})
test('Uses proper locale files for templates', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Locale', 'fr')
@@ -174,51 +131,48 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
test('Falls back to English templates when locale file not found', async ({
comfyPage
}) => {
// Pick a shipped LTR locale and simulate its template index returning 404.
// (Previously this test used 'de', but unsupported locales are now
// clamped to 'en' at boot so they never hit the template fallback path.
// 'fa' would also work but flips document.dir to rtl, which can leak
// into adjacent specs in the same worker.)
const locale = 'tr'
// Set locale to a language that doesn't have a template file
await comfyPage.settings.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
await comfyPage.page.route(
`**/templates/index.${locale}.json`,
async (route) => {
await route.fulfill({
status: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
})
}
// Wait for the German request (expected to 404)
const germanRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.de.json'
)
await comfyPage.page.route('**/templates/index.json', (route) =>
route.continue()
)
await comfyPage.settings.setSetting('Comfy.Locale', locale)
const localeRequestPromise = comfyPage.page.waitForRequest(
`**/templates/index.${locale}.json`
)
// Wait for the fallback English request
const englishRequestPromise = comfyPage.page.waitForRequest(
'**/templates/index.json'
)
// Intercept the German file to simulate a 404
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
await route.fulfill({
status: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
})
})
// Allow the English index to load normally
await comfyPage.page.route('**/templates/index.json', (route) =>
route.continue()
)
// Load the templates dialog
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
const localeRequest = await localeRequestPromise
// Verify German was requested first, then English as fallback
const germanRequest = await germanRequestPromise
const englishRequest = await englishRequestPromise
expect(localeRequest.url()).toContain(`templates/index.${locale}.json`)
expect(germanRequest.url()).toContain('templates/index.de.json')
expect(englishRequest.url()).toContain('templates/index.json')
// Assert on rendered content, not just the container — the container
// testid is present even when the dialog body is empty, which would let
// a regression where the fallback fetch succeeds but no cards render
// pass silently.
await expect(comfyPage.templates.allTemplateCards.first()).toBeVisible()
// Verify English titles are shown as fallback
await expect(
comfyPage.page.getByRole('main').getByText('All Templates')
).toBeVisible()
})
test('template cards are dynamically sized and responsive', async ({

View File

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

View File

@@ -0,0 +1,108 @@
# 10. Deprecate Node-Level Serialization Control
Date: 2026-05-12
## Status
Accepted
## Context
The v2 extension API initially included `node.on('beforeSerialize', handler)` as a migration path from v1 patterns like `node.onSerialize` and `nodeType.prototype.serialize` patching. This allowed extensions to:
1. **Append extra fields** to the serialized node object
2. **Transform the entire serialized object** via a replace function
However, during design review (PR #12142), we questioned whether node-level serialization control is the right abstraction:
### The Problem
Node-level serialization control is fundamentally **wrong-layered**:
- **Extension state should live in widgets**, not as arbitrary fields on the node
- Widget-level `beforeSerialize` already handles all legitimate use cases
- Node-level hooks encourage storing extension state in ad-hoc `node.properties` or custom fields, which:
- Breaks the clean separation between framework concerns and extension concerns
- Creates hidden dependencies between serialization format and extension behavior
- Makes migration and format evolution harder
### v1 Usage Analysis
Touch-point audit of `nodeType.prototype.serialize` and `node.onSerialize` patterns in the wild:
| Use Case | Proper v2 Alternative |
| --------------------------- | --------------------------------------------------- |
| Store extension state | Use widget values with `beforeSerialize` |
| Persist per-instance config | Use `widget.setOption()``widget_options` sidecar |
| Add metadata for export | Use a dedicated extension state widget |
| Transform output format | Framework concern, not extension concern |
No use case requires node-level control that can't be better served by widget-level APIs.
## Decision
**Deprecate `node.on('beforeSerialize')`** — mark as `@deprecated` with clear guidance pointing to widget-level alternatives. Remove in v1.0.
Widget-level serialization control (`widget.on('beforeSerialize')`) remains fully supported as the correct abstraction.
### Migration Path
Extensions currently using `node.on('beforeSerialize')` should:
1. **Store state in widgets** instead of arbitrary node fields
2. **Use `widget.on('beforeSerialize')`** to control serialization per-widget
3. **Use `widget.setOption()`** for per-instance configuration
Example migration:
```ts
// BEFORE (v1 / deprecated v2)
node.on('beforeSerialize', (e) => {
e.data['my_extension_state'] = computeState()
})
// AFTER (recommended v2)
const stateWidget = node.addWidget('STRING', '_my_state', '', {
hidden: true,
serialize: true
})
stateWidget.on('beforeSerialize', (e) => {
e.setSerializedValue(JSON.stringify(computeState()))
})
```
### Implementation Steps
1. Add `@deprecated` tag to `node.on('beforeSerialize')` with migration guidance
2. Add console.warn when the deprecated event is used (dev mode only)
3. Update documentation to recommend widget-level patterns
4. Remove `NodeBeforeSerializeEvent` type and handler in v1.0
## Consequences
### Positive
- **Cleaner architecture**: Extension state flows through widgets, the designed data channel
- **Better debuggability**: Widget values are visible in workflow JSON at predictable locations
- **Easier migration**: Future format changes only need to consider widget serialization
- **Reduced API surface**: One less event type to maintain and document
### Negative
- **Migration burden**: Extensions using node-level serialization must refactor
- **Potential edge cases**: Some exotic use cases may require workarounds
### Risk Mitigation
- Deprecation warning gives extension authors runway to migrate
- Widget-level APIs are already more capable than node-level alternatives
- The `@deprecated` tag and docs provide clear migration path
## Notes
This decision was made during design review of PR #12142 (ext-api foundation). See `design-review-12142.md` Topic 11 for the full discussion thread.
Related decisions:
- Widget-level `beforeSerialize` remains the primary extension serialization hook
- `setSerializeEnabled()` remains for simple static opt-out cases

View File

@@ -0,0 +1,111 @@
# 11. Immutability Enforcement via Fresh Copies
Date: 2026-05-12
## Status
Accepted
## Context
The extension API exposes collection-returning methods like `widgets()`, `inputs()`, `outputs()`, and object-returning methods like `getProperties()`. These methods need immutability guarantees to prevent extensions from accidentally or intentionally mutating internal state.
### The Problem
Without runtime immutability enforcement:
- Extensions could push items into `widgets()` array, corrupting internal state
- Mutations to returned objects would silently affect internal data
- Debugging would be difficult — state corruption could surface far from the mutation site
- Internal framework code might inadvertently rely on returned arrays being stable
TypeScript's `readonly` modifier and JSDoc annotations provide compile-time protection, but:
- JavaScript consumers have no protection
- Type assertions can bypass readonly
- Agent-generated code may not respect type hints
### Options Considered
| Option | Pros | Cons |
| ------------------------ | --------------------------------------------------------- | -------------------------------------------------------- |
| **1. `Object.freeze()`** | Runtime immutability, throws on mutation | Performance overhead, nested objects need deep freeze |
| **2. Return fresh copy** | Simple, functional style, no mutation affects source | Slight memory overhead, multiple calls = multiple arrays |
| **3. Proxy wrapper** | Helpful error messages, can intercept specific operations | Complexity, performance overhead, harder to debug |
| **4. TypeScript only** | Zero runtime cost | No protection for JS consumers, can be bypassed |
| **5. Private fields** | True encapsulation | Blocks read access too, not suitable for APIs |
## Decision
**Return fresh copies** (Option 2) for all collection-returning and object-returning methods in the extension API.
### Implementation Pattern
```ts
// CORRECT: Return fresh copy
widgets(): readonly WidgetHandle[] {
const container = world.getComponent(nodeId, WidgetComponentContainer)
return (container?.widgetIds ?? []).map(createWidgetHandle)
// Each call creates new array — mutations don't affect internal state
}
getProperties(): Record<string, unknown> {
return { ...world.getComponent(nodeId, NodeTypeKey)?.properties }
// Shallow copy — mutations don't affect source
}
```
### Scope
Apply this pattern to:
- `NodeHandle.widgets()` — returns fresh `WidgetHandle[]`
- `NodeHandle.inputs()` — returns fresh `SlotInfo[]`
- `NodeHandle.outputs()` — returns fresh `SlotInfo[]`
- `NodeHandle.getProperties()` — returns fresh `Record<string, unknown>`
- `WidgetHandle` methods that return objects (if any)
- Any future collection/object-returning methods
### Internal Callers
Framework-internal code must also use mutation APIs rather than mutating returned collections:
```ts
// WRONG: Mutating returned array
const widgets = node.widgets()
widgets.push(newWidget) // No effect on node!
// CORRECT: Use mutation API
node.addWidget(type, name, value, options)
```
## Consequences
### Positive
- **True immutability**: Mutations to returned data never affect internal state
- **Predictable behavior**: Each call returns fresh data reflecting current state
- **Simple mental model**: "This is your copy, do what you want with it"
- **JavaScript-safe**: Works regardless of TypeScript types
### Negative
- **Memory overhead**: Multiple calls create multiple arrays (usually negligible)
- **No mutation detection**: Extensions silently get isolated copies, won't know their mutations are ignored
- **Fresh reference each call**: Cannot use `===` to detect changes (use deep comparison or events)
### Mitigations
- Document that returned collections are snapshots
- Use events (`valueChange`, `propertyChange`) to observe changes
- The memory overhead is negligible for typical widget/slot counts
## Notes
This decision was made during design review of PR #12142 (ext-api foundation). See `design-review-12142.md` Topic 14 for the full discussion thread.
The alternative of `Object.freeze()` was rejected because:
- It requires deep freezing for nested objects
- Performance overhead for each call
- Fresh copies achieve the same goal more simply

View File

@@ -0,0 +1,138 @@
# 12. Pure Function Loader Pattern for Extension Registration
Date: 2026-05-12
## Status
Accepted
## Context
The v2 extension API needs a mechanism for extensions to register themselves with the runtime. Two broad approaches exist:
### Side-Effect Registration (Vue 2 Plugin Pattern)
```ts
// Extension self-registers at import time
import { app } from '@comfyorg/core'
app.use({
install(app) {
app.component('MyWidget', MyWidget)
app.directive('my-directive', myDirective)
}
})
```
Problems:
- **Import order matters**: If extension A depends on extension B being registered first, import order must be carefully managed
- **Hard to test**: Side effects at import time make mocking difficult; tests must manipulate module cache
- **Hard to tree-shake**: Bundlers can't eliminate unused extensions — the import executes
- **Timing coupling**: Registration and activation are conflated; can't collect extensions first, then activate later
### Pure Function + Loader Pattern
```ts
// Extension declares intent — no side effects
export default defineNode({
name: 'my-extension',
nodeTypes: ['MyNode'],
nodeCreated(handle) {
// ...
}
})
// App bootstrap activates all registered extensions
startExtensionSystem()
```
## Decision
**Adopt the pure function + loader pattern** for v2 extension registration.
### Implementation
```ts
// Extension Registry (data collection only)
const nodeExtensions: NodeExtensionOptions[] = []
export function defineNode(options: NodeExtensionOptions): void {
nodeExtensions.push(options)
}
// Loader (activation)
export function startExtensionSystem(): void {
const world = getWorld()
watch(
() => world.entitiesWith(NodeTypeKey),
(nodeEntityIds) => {
for (const id of nodeEntityIds) {
mountExtensionsForNode(id)
}
},
{ immediate: true }
)
}
```
### Key Properties
1. **Pure registration**: `defineNode()` has no side effects beyond pushing to an array. It doesn't touch the World, DOM, or any reactive state.
2. **Centralized activation**: `startExtensionSystem()` is called exactly once during app bootstrap. This single entry point controls when the extension system "goes live".
3. **Reactive mounting**: The loader watches the World for entity changes. Extensions are mounted/unmounted in response to ECS state, not imperative calls.
4. **Order independence**: Extensions can be defined in any order. The loader sorts by name (lexicographic, see D10b) for deterministic execution.
### Registration Flow
```
Extension files App bootstrap World
| | |
| defineNode({...}) | |
|--------------------->| |
| (push to array) | |
| | |
| | startExtensionSystem()
| |------------------>|
| | (watch for NodeType entities)
| | |
| | NodeType added |
| |<------------------|
| | |
| | mountExtensionsForNode(id)
| | (runs setup) |
```
## Consequences
### Positive
- **Testability**: Extensions are plain objects; tests can construct them without side effects. `_clearExtensionsForTesting()` resets state between tests.
- **Tree-shakeable**: Bundlers can eliminate unused extension files if their exports are never referenced.
- **Order independent**: No import order bugs — the loader handles activation order.
- **Lazy activation**: Registration is instant; activation only happens when `startExtensionSystem()` is called.
- **SSR friendly**: Pure functions don't execute browser-only code at import time.
### Negative
- **Manual bootstrap**: App must call `startExtensionSystem()` — forgetting it silently disables extensions.
- **Two-step mental model**: Developers must understand "register" vs "activate" phases.
### Mitigations
- App bootstrap is a well-defined location; the call is hard to miss.
- Clear documentation and starter templates include the bootstrap call.
- Dev-mode warnings if extensions are defined but the system never starts.
## Notes
This pattern aligns with modern framework conventions:
- **Vite plugins**: `vite.config.ts` collects plugins as an array; Vite activates them at build time.
- **Vue 3 Composition API**: `setup()` returns reactive state; the framework activates it.
- **React hooks**: Pure functions declare effects; React schedules them.
The key insight is separating **declaration** (what do I want?) from **execution** (make it happen). This separation enables testing, lazy loading, and predictable behavior.

View File

@@ -8,16 +8,19 @@ An Architecture Decision Record captures an important architectural decision mad
## ADR Index
| ADR | Title | Status | Date |
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| ADR | Title | Status | Date |
| ---------------------------------------------------------- | ------------------------------------------ | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| [0010](0010-deprecate-node-level-serialization-control.md) | Deprecate Node-Level Serialization Control | Accepted | 2026-05-12 |
| [0011](0011-immutability-via-fresh-copies.md) | Immutability Enforcement via Fresh Copies | Accepted | 2026-05-12 |
| [0012](0012-pure-function-loader-pattern.md) | Pure Function Loader Pattern | Accepted | 2026-05-12 |
## Creating a New ADR

View File

@@ -0,0 +1,93 @@
# Research: Canvas vs Client/Pixel Coordinate Usage
Date: 2026-05-12
## Question
How should the extension API handle coordinate systems? Should it expose canvas coordinates, screen/client coordinates, or both?
## Coordinate Systems in ComfyUI
### 1. Canvas Space (Logical Units)
Node positions and sizes are in canvas logical units:
- Independent of zoom/pan
- `[0, 0]` is the canvas origin
- Moving a node to `[100, 200]` places it at canvas position (100, 200) regardless of viewport state
### 2. Screen/Client Space (Pixels)
DOM elements use pixel coordinates relative to the viewport:
- Affected by zoom/pan/scroll
- `clientX`/`clientY` from mouse events
- `getBoundingClientRect()` returns pixel values
### 3. Widget Height (Pixels)
DOM widgets reserve height in pixels:
```ts
addDOMWidget({ name: 'preview', element: img, height: 200 }) // 200px
```
## Current Extension API
| Method | Coordinate System | Notes |
| -------------------------- | ----------------- | ----------------------------------------- |
| `getPosition()` | Canvas | Returns `[x, y]` in canvas units |
| `setPosition()` | Canvas | Accepts `[x, y]` in canvas units |
| `getSize()` | Canvas | Returns `[width, height]` in canvas units |
| `setSize()` | Canvas | Accepts `[width, height]` in canvas units |
| `addDOMWidget({ height })` | Pixels | Reserved height in pixels |
| `widget.setHeight(px)` | Pixels | Widget height in pixels |
## Analysis
### When Extensions Need Canvas Coordinates
1. **Node positioning**: Placing nodes relative to each other
2. **Layout algorithms**: Auto-arranging nodes in a pattern
3. **Collision detection**: Checking if nodes overlap
### When Extensions Need Screen Coordinates
1. **Custom overlays**: Drawing UI at a specific screen location
2. **Drag-and-drop from external sources**: Converting mouse position to canvas position
3. **Context menus**: Positioning menus near the cursor
### Current State
The extension API currently exposes:
- **Canvas coordinates** for node position/size — appropriate, as these are logical values
- **Pixel values** for DOM widget height — appropriate, as these are DOM measurements
**Missing**: No conversion helpers between canvas and screen coordinates.
## Recommendation
**The current approach is appropriate.** Extensions that manipulate node positions should work in canvas space. This is the natural abstraction — extensions shouldn't need to account for zoom/pan when laying out nodes.
### For Advanced Cases
Extensions needing coordinate conversion (e.g., custom overlays) should either:
1. **Use LiteGraph's existing transform utilities** (available on `app.canvas`)
2. **Access the transform state** via a future canvas API (not part of node/widget handles)
### Why Not Expose Conversion Helpers on NodeHandle?
- **Wrong abstraction level**: Coordinate conversion is a canvas concern, not a node concern
- **State dependency**: Conversion requires current zoom/pan state, which changes frequently
- **Rare use case**: Most extensions work entirely in canvas space
## Future Considerations
If multiple extensions need coordinate conversion, consider:
1. **Canvas API**: `canvas.screenToCanvas(point)` / `canvas.canvasToScreen(point)`
2. **Events with both coordinates**: `positionChanged` could include both canvas and screen positions
For now, no changes are needed — the current API serves the common cases well.

View File

@@ -0,0 +1,93 @@
# Research: DOM Widget Convergence with Base Widget
Date: 2026-05-12
## Question
Should DOM widgets be unified with base widgets, or kept as a separate concept?
## Current State
### Creation APIs
- `node.addWidget(type, name, value, options)` — creates a standard widget
- `node.addDOMWidget({ name, element, height })` — creates a DOM-backed widget
### Internal Implementation
Both use the same underlying `CreateWidget` command:
```ts
addWidget(type, name, defaultValue, options) {
return dispatch({ type: 'CreateWidget', widgetType: type, ... })
}
addDOMWidget(opts) {
return dispatch({ type: 'CreateWidget', widgetType: 'DOM', ... })
}
```
DOM widgets are just widgets with `widgetType: 'DOM'` and an element reference.
### Shared WidgetHandle Interface
Both widget types share the same `WidgetHandle` interface:
| Method | Standard Widget | DOM Widget |
| -------------------------------- | --------------- | ----------------------- |
| `entityId`, `name`, `widgetType` | ✓ | ✓ |
| `getValue()` / `setValue()` | ✓ (scalar) | ✓ (often unused) |
| `isHidden()` / `setHidden()` | ✓ | ✓ |
| `isDisabled()` / `setDisabled()` | ✓ | ✓ |
| `setHeight(px)` | no-op | ✓ (updates reservation) |
| `on('valueChange')` | ✓ | ✓ |
| `getOption()` / `setOption()` | ✓ | ✓ |
## Analysis
### Arguments FOR Full Convergence
1. **Single mental model**: Extensions learn one widget concept, not two.
2. **Consistent behavior**: All widgets appear in `node.widgets()`, serialize the same way.
3. **Simpler API surface**: Fewer methods to document and maintain.
### Arguments FOR Keeping Separate APIs
1. **Different ergonomics**: Standard widgets are data-driven (name, value, options); DOM widgets are element-driven (pass an HTMLElement).
2. **Type safety**: `addDOMWidget` can require `element: HTMLElement` at compile time; merging would make it optional with runtime checks.
3. **Clear intent**: Separate APIs signal different use cases.
## Recommendation
**Keep the current partial convergence.** The implementation is unified (`CreateWidget` command), but the creation APIs remain separate for ergonomic reasons.
### Rationale
1. **Creation differs, usage is unified.** Extensions create DOM widgets differently (need an element), but interact with them the same way (via `WidgetHandle`).
2. **Type safety is valuable.** `addDOMWidget({ element })` is clearer than `addWidget('DOM', name, null, { element })`.
3. **Already well-integrated.** DOM widgets appear in `node.widgets()`, get the same events, and use the same serialization infrastructure.
### What "Convergence" Means Here
The widgets are already converged at:
- **Entity level**: Same `WidgetEntityId` brand
- **Interface level**: Same `WidgetHandle` type
- **Command level**: Same `CreateWidget` command internally
The APIs are intentionally separate at:
- **Creation level**: `addWidget` vs `addDOMWidget`
This is the right split — unified where it matters (runtime behavior), separate where it improves DX (creation ergonomics).
## Future Considerations
If we add more widget creation patterns (e.g., `addCanvasWidget`, `addThreeJSWidget`), we might consider:
1. **Factory pattern**: `node.widgets.create('DOM', { element })` / `node.widgets.create('INT', { min, max })`
2. **Builder pattern**: `node.addWidget('DOM').withElement(el).withHeight(200).build()`
For now, two explicit methods (`addWidget`, `addDOMWidget`) serve the common cases well.

View File

@@ -0,0 +1,112 @@
# Research: Identity Encapsulation in the Extension API
Date: 2026-05-12
## Question
When do extensions need access to raw entity IDs (`NodeEntityId`, `WidgetEntityId`, `SlotEntityId`)? Should these be exposed or hidden?
## Current State
The v2 extension API exposes entity IDs as read-only properties:
```ts
interface NodeHandle {
readonly entityId: NodeEntityId
// ...
}
interface WidgetHandle {
readonly entityId: WidgetEntityId
// ...
}
interface SlotInfo {
readonly entityId: SlotEntityId
// ...
}
```
All IDs are **branded types** to prevent accidental mixing at compile time.
## Use Cases for Raw Entity IDs
### 1. Per-Instance State Mapping
Extensions maintaining external state per node:
```ts
const nodeCache = new Map<NodeEntityId, CachedData>()
defineNode({
name: 'my-cache-extension',
nodeCreated(handle) {
nodeCache.set(handle.entityId, computeExpensiveData())
onNodeRemoved(() => nodeCache.delete(handle.entityId))
}
})
```
### 2. Logging and Debugging
```ts
node.on('executed', (e) => {
console.log(`[${node.entityId}] Output:`, e.output)
})
```
### 3. Inter-Extension Communication
Extensions that need to coordinate across multiple nodes:
```ts
// Extension A stores data
globalState.set(nodeA.entityId, data)
// Extension B retrieves it
const data = globalState.get(nodeB.entityId)
```
### 4. External System Interop
Extensions integrating with analytics, debugging tools, or external services that need stable node identifiers.
## Analysis
### Arguments FOR Exposing Entity IDs
1. **Legitimate need exists** — The use cases above are real and common.
2. **Branded types prevent misuse** — Can't accidentally use `NodeEntityId` where `WidgetEntityId` is expected.
3. **Read-only access** — Extensions can't mutate the ID or corrupt internal state.
4. **Opaque value** — The format (`node:<graphUuid>:<localId>`) is an implementation detail; extensions should treat it as an opaque string.
### Arguments AGAINST Exposing Entity IDs
1. **Format coupling** — Extensions might parse the ID string and break if format changes.
2. **Internal detail leakage** — Knowing the ID scheme reveals ECS architecture.
3. **Future migration friction** — Changing ID representation requires careful deprecation.
### Mitigations
- **Document as opaque**: JSDoc clearly states IDs are opaque, not to be parsed.
- **Branded types**: TypeScript prevents misuse across entity categories.
- **Phase A format**: Current format includes graph UUID + local ID; this can evolve via semver.
## Recommendation
**Keep exposing entity IDs.** The use cases are legitimate, the branded types provide safety, and the read-only nature limits risk. Document that IDs are opaque strings — extensions should never parse or construct them.
### Guidelines for Extension Authors
1. **Use IDs only for keying** — Maps, Sets, logging, external system references.
2. **Never parse IDs** — The format is an implementation detail subject to change.
3. **Prefer handles over IDs** — When passing references between functions, use the handle object, not the raw ID.
4. **Clean up on removal** — Always use `onNodeRemoved()` to clean up Maps keyed by entityId.
## Future Considerations
If the ID format needs to change significantly, the branded types allow us to:
1. Introduce a new branded type (e.g., `NodeEntityIdV2`)
2. Deprecate the old ID with migration guidance
3. Keep both supported during a transition period

View File

@@ -0,0 +1,121 @@
# Research: Serialization Context Simplification
Date: 2026-05-12
## Question
Can the serialization context be simplified from 4 values to fewer?
Current contexts:
- `'workflow'` — saving workflow to disk
- `'prompt'` — queueing a run (API call)
- `'clone'` — copy/paste operation
- `'subgraph-promote'` — widget becoming subgraph IO
## Use Case Analysis
### Context: 'workflow'
**Purpose**: Full persistence of user's work.
**What extensions need**: Serialize everything the user configured.
**Example**: A widget storing user preferences needs to include all settings.
### Context: 'prompt'
**Purpose**: Sending data to the backend for execution.
**What extensions need**:
- Transform values (dynamic prompts → resolved text)
- Skip preview-only widgets
- Materialize async sources (webcam → frame data)
**Example**:
```ts
widget.on('beforeSerialize', async (e) => {
if (e.context === 'prompt') {
e.setSerializedValue(await captureFrame())
}
})
```
### Context: 'clone'
**Purpose**: Copy/paste should yield independent copy.
**What extensions need**: Reset instance-specific state while keeping user settings.
**Example**: A random seed widget might want a new seed on paste.
### Context: 'subgraph-promote'
**Purpose**: Widget becomes an input/output on a subgraph.
**What extensions need**: Convert internal representation to subgraph IO format.
**Example**: Internal state becomes an exposed parameter.
## Simplification Options
### Option A: Keep All 4 (Current State)
| Pro | Con |
| ---------------------------------------- | ----------------- |
| Each context has distinct semantics | 4 cases to handle |
| Type system enforces valid values | More complex API |
| Clear intent for each serialization path | |
### Option B: Collapse to 2 ('persist' | 'execute')
```ts
context: 'persist' | 'execute'
// 'persist' = workflow, clone, subgraph-promote
// 'execute' = prompt
```
| Pro | Con |
| ------------------------------------------ | ------------------------------- |
| Simpler mental model | Loses clone/promote distinction |
| Most extensions only care about this split | Can't reset seed on clone |
### Option C: Remove Context Entirely
Extensions always transform regardless of context. The framework handles differences.
| Pro | Con |
| ---------------------------- | ---------------------------------------------- |
| Simplest API | Loses control for edge cases |
| Framework handles all nuance | Some extensions need context-specific behavior |
## Recommendation
**Keep all 4 contexts.** The use cases are genuinely different:
1. **workflow vs prompt**: Very common distinction. Dynamic prompts only process on prompt; preview widgets skip prompt. This is the most important split.
2. **clone**: Less common, but needed for stateful widgets (random seeds, generated IDs, captured frames).
3. **subgraph-promote**: Specialized, but necessary for the subgraph feature to work correctly.
### Rationale
- Extensions that don't care can ignore the context.
- Extensions that do care have the information they need.
- The 4 values map to 4 distinct operations in the framework.
- Collapsing contexts would remove functionality with no real simplification gain.
### Mitigation for Complexity
- Document common patterns clearly
- Most extensions only need: `if (context === 'prompt')`
- Provide examples in JSDoc
## Note on Deprecation
The `NodeBeforeSerializeEvent` is deprecated (ADR-0010). The `WidgetBeforeSerializeEvent` remains supported and uses the same 4 contexts.
Since node-level serialization is being removed, this research applies to widget-level serialization only.

View File

@@ -0,0 +1,148 @@
# Widget State Categories
Date: 2026-05-12
## Overview
Widget state in the v2 extension API is organized into distinct categories, each with different characteristics for mutability, persistence, and event handling.
## Categories
### 1. Identity (Read-Only Invariants)
Set at construction, never change.
| Property | Type | Notes |
| ------------ | ---------------- | ------------------------------------ |
| `entityId` | `WidgetEntityId` | Branded, stable for widget lifetime |
| `name` | `string` | Widget name as registered |
| `widgetType` | `string` | e.g., `'INT'`, `'STRING'`, `'COMBO'` |
| `label` | `string` | Display label, defaults to `name` |
**Constraints:**
- No setters exist for these properties
- Extensions cannot modify identity after creation
- Attempting to change identity is a design error
### 2. Value (First-Class, Every Widget)
The primary user-edited data.
| Method | Notes |
| ------------------- | ----------------------------------- |
| `getValue()` | Returns current value |
| `setValue(v)` | Dispatches `SetWidgetValue` command |
| `on('valueChange')` | Fires on value mutation |
**Constraints:**
- Type varies by widget type (`number` for INT, `string` for STRING, etc.)
- Persisted to `widgets_values` in workflow JSON
- Included in API prompt by default (unless `setSerializeEnabled(false)`)
- Changes are undo-able via command dispatch
### 3. Properties (First-Class, Every Widget)
Common properties all widgets share.
| Property | Getter | Setter | Event |
| ----------- | ---------------------- | ------------------------ | ---------------- |
| `hidden` | `isHidden()` | `setHidden(b)` | `propertyChange` |
| `disabled` | `isDisabled()` | `setDisabled(b)` | `propertyChange` |
| `serialize` | `isSerializeEnabled()` | `setSerializeEnabled(b)` | `propertyChange` |
**Constraints:**
- Boolean values only
- `hidden` affects UI visibility, not serialization
- `disabled` makes widget read-only in UI
- `serialize` controls inclusion in workflow/prompt output
- Changes fire `propertyChange`, not `valueChange`
### 4. Options Bag (Type-Specific)
Per-instance overrides for type-specific configuration.
| Method | Notes |
| ----------------------- | ---------------------------------------------- |
| `getOption(key)` | Returns per-instance override or class default |
| `setOption(key, value)` | Persists to `widget_options` sidecar |
| `on('optionChange')` | Fires on option mutation |
**Common options by widget type:**
| Widget Type | Options |
| ----------- | ---------------------------------- |
| INT, FLOAT | `min`, `max`, `step`, `precision` |
| STRING | `multiline`, `placeholder`, `rows` |
| COMBO | `values` |
**Constraints:**
- Options are JSON-serializable values
- Persisted separately from `widgets_values` (additive, backward-compatible)
- Extensions can add custom options
- Option keys should be documented per widget type
### 5. DOM-Specific
Properties unique to DOM widgets.
| Method | Notes |
| --------------- | ------------------------------------------ |
| `setHeight(px)` | Updates reserved height, triggers relayout |
**Constraints:**
- Only meaningful for `addDOMWidget()` widgets
- No-op for non-DOM widgets
- Measured in pixels (screen space)
- No event fired; relayout is automatic
## Category Interaction Rules
### Event Separation
Each category has its own event:
| Category | Event |
| ---------- | ---------------- |
| Value | `valueChange` |
| Properties | `propertyChange` |
| Options | `optionChange` |
**Rule**: Events do not cross categories. Changing `hidden` does not fire `valueChange`.
### Serialization Behavior
| Category | Serialization |
| ---------- | ---------------------------------------------------------------- |
| Identity | Not serialized (derived from node type) |
| Value | `widgets_values` array |
| Properties | `hidden`/`disabled` not persisted; `serialize` affects inclusion |
| Options | `widget_options` sidecar object |
### Mutability Summary
| Category | Mutable | Undo-able | Fires Event |
| ---------- | ------- | --------- | ---------------- |
| Identity | ✗ | — | — |
| Value | ✓ | ✓ | `valueChange` |
| Properties | ✓ | ✓ | `propertyChange` |
| Options | ✓ | ✓ | `optionChange` |
| DOM Height | ✓ | ✗ | — |
## Agent Implementation Notes
Agents working with widget state should:
1. **Respect category boundaries**: Don't try to `setValue()` to change visibility; use `setHidden()`.
2. **Use appropriate events**: Listen to `propertyChange` for UI state, `valueChange` for data.
3. **Handle type-specific options carefully**: Check widget type before accessing type-specific options.
4. **Preserve identity invariants**: Never try to change `entityId`, `name`, `widgetType`, or `label`.
5. **Consider serialization context**: Options persist to a sidecar; values persist to the main array.

View File

@@ -9,6 +9,10 @@ const config: KnipConfig = {
'src/assets/css/style.css',
'src/scripts/ui/menu/index.ts',
'src/types/index.ts',
// Public extension API surface — published package entry point.
// Per AGENTS.md, this barrel is the explicit exception to the
// no-barrel-files-in-src rule because it IS the package entry.
'src/extension-api/index.ts',
'src/storybook/mocks/**/*.ts'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**']
@@ -60,7 +64,19 @@ const config: KnipConfig = {
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Devtools extensions, included dynamically
'tools/devtools/web/**'
'tools/devtools/web/**',
// Deprecated stub re-exporting from `@/extension-api`. Will be removed
// once PKG2 (`@comfyorg/extension-api`) ships and downstream imports
// migrate to the package path.
'src/types/extensionV2.ts',
// D18 Phase 1 scaffolding — empty registries the loader will populate
// in Phase 2 once side-effect registration moves out of
// extension-api-service. See decisions/D18-pure-functions-loader-registration.md.
'src/services/registries/**',
// D18 Phase 1 — brand symbol + isBrandedExtension guard. Currently
// consumed only by the define* call sites inside extension-api-service;
// the type-guard and getBrandKind are exported for the Phase 2 loader.
'src/extension-api/brand.ts'
],
vite: {
config: ['vite?(.*).config.mts']
@@ -79,7 +95,15 @@ const config: KnipConfig = {
tags: [
'-knipIgnoreUnusedButUsedByCustomNodes',
'-knipIgnoreUnusedButUsedByVueNodesBranch',
'-knipIgnoreUsedByStackedPR'
'-knipIgnoreUsedByStackedPR',
// Public API surface consumed externally by extension authors and the
// TypeDoc docgen pipeline (PKG2). Mark exports with @publicAPI when they
// are part of `@comfyorg/extension-api` but not internally referenced.
'-publicAPI',
// Per D20, the three *EntityId brand re-exports in src/extension-api/{node,widget}.ts
// are demoted to @internal — they stay available for internal package modules
// but are removed from the public barrel and from TypeDoc output.
'-internal'
]
}

View File

@@ -34,7 +34,7 @@ function formatAndEslint(fileNames: string[]) {
const joinedPaths = toJoinedRelativePaths(fileNames)
return [
`pnpm exec oxfmt --write ${joinedPaths}`,
`pnpm exec oxlint --type-aware --fix ${joinedPaths}`,
`pnpm exec oxlint --fix ${joinedPaths}`,
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.45.6",
"version": "1.45.4",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -48,6 +48,7 @@
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
"test:unit": "nx run test",
"test:extension-api": "[ -f vitest.extension-api.config.mts ] && vitest run --config vitest.extension-api.config.mts || echo 'SKIP: vitest.extension-api.config.mts not found'",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
"typecheck:desktop": "nx run @comfyorg/desktop-ui:typecheck",
@@ -60,7 +61,6 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/object-info-parser": "workspace:*",
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",

View File

@@ -16,7 +16,7 @@
@plugin "./lucideStrokePlugin.js";
/* Safelist dynamic comfy icons for node library folders */
@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"/></svg>

Before

Width:  |  Height:  |  Size: 306 B

View File

@@ -523,19 +523,6 @@ export type ImportPublishedAssetsRequest = {
* IDs of published assets (inputs and models) to import.
*/
published_asset_ids: Array<string>
/**
* Optional. Share ID of the published workflow these assets belong to.
* When provided (non-null, non-empty): all published_asset_ids must
* belong to this share's workflow version; returns
* 400/CodeInvalidAssets if the share is not found or any asset does
* not belong to it.
* When omitted, null, or empty string: no share-scoped validation is
* performed and the assets are validated only against global rules
* (legacy behaviour, preserved for clients that have not yet adopted
* share_id).
*
*/
share_id?: string | null
}
/**
@@ -2321,15 +2308,9 @@ export type Asset = {
*/
preview_id?: string | null
/**
* Deprecated: use job_id instead. ID of the prompt that created this asset, if available
*
* @deprecated
* ID of the job/prompt that created this asset, if available
*/
prompt_id?: string | null
/**
* ID of the job that created this asset, if available
*/
job_id?: string | null
/**
* Timestamp when the asset was created
*/
@@ -3169,15 +3150,9 @@ export type AssetWritable = {
*/
preview_id?: string | null
/**
* Deprecated: use job_id instead. ID of the prompt that created this asset, if available
*
* @deprecated
* ID of the job/prompt that created this asset, if available
*/
prompt_id?: string | null
/**
* ID of the job that created this asset, if available
*/
job_id?: string | null
/**
* Timestamp when the asset was created
*/
@@ -4940,6 +4915,10 @@ export type ImportPublishedAssetsErrors = {
* Unauthorized
*/
401: ErrorResponse
/**
* Not found
*/
404: ErrorResponse
/**
* Internal server error
*/

View File

@@ -310,8 +310,7 @@ export const zImportPublishedAssetsResponse = z.object({
* Request body for importing assets from a published workflow.
*/
export const zImportPublishedAssetsRequest = z.object({
published_asset_ids: z.array(z.string()),
share_id: z.string().nullish()
published_asset_ids: z.array(z.string())
})
/**
@@ -1372,7 +1371,6 @@ export const zAsset = z.object({
preview_url: z.string().url().optional(),
preview_id: z.string().uuid().nullish(),
prompt_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
last_access_time: z.string().datetime().optional(),
@@ -1814,7 +1812,6 @@ export const zAssetWritable = z.object({
preview_url: z.string().url().optional(),
preview_id: z.string().uuid().nullish(),
prompt_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
last_access_time: z.string().datetime().optional(),

View File

@@ -1,30 +0,0 @@
{
"name": "@comfyorg/object-info-parser",
"version": "1.0.0",
"description": "Shared object_info schemas and helpers",
"license": "GPL-3.0-only",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run --config ./vitest.config.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "catalog:",
"zod-validation-error": "catalog:"
},
"devDependencies": {
"typescript": "catalog:",
"vitest": "catalog:"
},
"nx": {
"tags": [
"scope:shared",
"type:util"
]
}
}

View File

@@ -1,54 +0,0 @@
import { describe, expect, it } from 'vitest'
import { groupNodesByPack } from '../helpers/groupNodesByPack'
import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
function makeNodeDef(
name: string,
pythonModule: string,
displayName = name
): ComfyNodeDef {
return {
name,
display_name: displayName,
description: '',
category: 'test',
output_node: false,
python_module: pythonModule
}
}
describe('groupNodesByPack', () => {
it('excludes core nodes and groups custom nodes by pack id', () => {
const grouped = groupNodesByPack({
CoreNode: makeNodeDef('CoreNode', 'nodes'),
ImpactA: makeNodeDef(
'ImpactA',
'custom_nodes.comfyui-impact-pack.nodes',
'Impact A'
),
ImpactB: makeNodeDef(
'ImpactB',
'custom_nodes.comfyui-impact-pack.nodes',
'Impact B'
),
AuxNode: makeNodeDef(
'AuxNode',
'custom_nodes.comfyui-controlnet-aux.nodes',
'Aux Node'
)
})
expect(grouped).toHaveLength(2)
expect(grouped.map((pack) => pack.id)).toEqual([
'comfyui-controlnet-aux',
'comfyui-impact-pack'
])
expect(
grouped.find((pack) => pack.id === 'comfyui-impact-pack')?.nodes
).toHaveLength(2)
expect(
grouped.find((pack) => pack.id === 'comfyui-controlnet-aux')?.nodes
).toHaveLength(1)
})
})

View File

@@ -1,83 +0,0 @@
import { describe, expect, it } from 'vitest'
import { sanitizeUserContent } from '../helpers/sanitizeUserContent'
import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
function makeNodeDef(
name: string,
pythonModule: string,
input: ComfyNodeDef['input']
): ComfyNodeDef {
return {
name,
display_name: name,
description: '',
category: 'test',
output_node: false,
python_module: pythonModule,
input
}
}
describe('sanitizeUserContent', () => {
it('strips known user filenames from combo inputs across all nodes', () => {
const defs = {
CustomCombo: makeNodeDef('CustomCombo', 'custom_nodes.some-pack', {
required: {
choice: [['my-secret.png', 'safe-option', 'video.mp4', 42], {}],
choiceV2: ['COMBO', { options: ['a.jpg', 'keep-me', 'b'] }]
}
})
}
const sanitized = sanitizeUserContent(defs)
const required = sanitized.CustomCombo.input?.required
expect(required?.choice).toEqual([['safe-option', 42], {}])
expect(required?.choiceV2).toEqual(['COMBO', { options: ['keep-me', 'b'] }])
})
it('zeros combo lists for known upload nodes in required/optional/hidden sections', () => {
const defs = {
LoadImage: makeNodeDef('LoadImage', 'nodes', {
required: {
image: [['personal.png', 'public.png'], { image_upload: true }]
},
optional: {
mask: ['COMBO', { options: ['another.jpg', 'value'] }]
},
hidden: {
cache: [['movie.mov', 'keep'], {}],
hiddenV2: ['COMBO', { options: ['private.wav', 'other'] }]
}
}),
LoadVideo: makeNodeDef('LoadVideo', 'nodes', {
required: {
video: [['clip.mp4', 'clip2.webm'], {}]
}
}),
LoadAudio: makeNodeDef('LoadAudio', 'nodes', {
required: {
audio: [['song.mp3', 'song.flac'], {}]
}
})
}
const sanitized = sanitizeUserContent(defs)
expect(sanitized.LoadImage.input?.required?.image).toEqual([
[],
{ image_upload: true }
])
expect(sanitized.LoadImage.input?.optional?.mask).toEqual([
'COMBO',
{ options: [] }
])
expect(sanitized.LoadImage.input?.hidden?.cache).toEqual([[], {}])
expect(sanitized.LoadImage.input?.hidden?.hiddenV2).toEqual([
'COMBO',
{ options: [] }
])
expect(sanitized.LoadVideo.input?.required?.video).toEqual([[], {}])
expect(sanitized.LoadAudio.input?.required?.audio).toEqual([[], {}])
})
})

Some files were not shown because too many files have changed in this diff Show More