Compare commits

...

126 Commits

Author SHA1 Message Date
GitHub Action
d070982542 [automated] Apply ESLint and Oxfmt fixes 2026-07-16 20:26:53 +00:00
Nathaniel Parson Koroso
569891cd2a test: fail the all-nodes tier when a pack's registered node count drifts
What

- manifest rows gain a required expectedNodeCount, calibrated from the
  gating CI's object_info (run 29530495349; totals match the suite's
  documented 823)
- the all-nodes mount tier asserts the live corpus size equals it, so a
  pack silently registering fewer nodes reds the gate instead of
  shrinking coverage while everything else stays green
- loader validation + pure-spec cases + ADDING_CUSTOM_NODES.md field row

Why

- review finding: only the fully-empty corpus was guarded; partial
  shrinkage (broken sub-import, a core change breaking registration)
  degraded the headline coverage invisibly

Notes

- any delta in either direction fails; recalibrate only in the same
  commit as the pin/core change that moved it

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 04:22:54 +08:00
Nathaniel Parson Koroso
7e0188b211 ci: drop the inert comfyui_ref input from setup-comfyui-server
What

- remove the comfyui_ref input and its checkout ref wiring; no caller
  passes it since the gate was unpinned, and empty ref is identical to
  omitting it

Why

- dead config knob flagged in review; the file returns to main's exact
  version, and a future core-pin need is a five-line re-add

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 04:07:55 +08:00
Nathaniel Parson Koroso
fabf39001c test: address custom-node suite review feedback
- run the backend-touching specs in a dedicated `custom-nodes` Playwright
  project (tagged @custom-nodes in their titles) so the main sharded e2e job
  no longer collects them; their afterEach queue-drain used interrupt(null)
  and could cancel a parallel worker's in-flight prompt
- gating job uses --reporter=list,json,html so playwright-report/ (with the
  on-first-retry traces) is actually produced and uploaded, not discarded
- keep system Chrome for local runs but retain traces on failure; the
  trace:off workaround was stale (verified branded Chrome + trace records
  fine on the current Playwright pin)
- route NodeSlotReference.getPosition through canvasPosToClientPos instead of
  hand-rolled convertOffsetToCanvas + rect math, and drop a debug console.warn
- hoist expectNoVisibleErrors into errorSurfaces.ts (was duplicated across
  three specs and inlined in a fourth)
- delete the unused preValidate validator and its change-detector tests
- collapse expectedNodesPresent to missingExpectedNodes (a filter)
- batchAutoRunnable uses es-toolkit chunk
- log the per-pack registered node count (calibrates a follow-up
  expectedNodeCount guard against silent coverage shrinkage)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-16 08:18:07 +08:00
Nathaniel Parson Koroso
2a6397e41d Merge branch 'main' into nathaniel/custom-node-e2e-suite
Conflict: src/i18n.ts st() - resolved to main's #13631 (the canonical
version of the same boot fix: rethrow non-SyntaxError, preserve raw
translation on compile errors).
2026-07-16 05:28:30 +08:00
Nathaniel Parson Koroso
d258e9bde2 test(custom-nodes): extension-loaded assert + dynamic-input autogrow tier
What

- manifest: required expectedExtensions per pack (validated: unique
  non-empty names; [] = explicit no-frontend-JS declaration), calibrated
  from each pinned pack's source (WAS ships no frontend JS at its pin)
- T0 load tier: assert every declared extension registered in
  window.app.extensions after boot
- new dynamicInputs.spec.ts: curated autogrow nodes grow one input on
  connecting the last slot and shrink back on disconnect, via BOTH a real
  mouse drag and a programmatic connect, under both renderers, asserted in
  the graph AND as rendered Vue slot rows in both directions
- VueNodeHelpers: output-side slot-row/dot locators (mirrors input side);
  connectivity drag test reuses them
- docs: README/ARCHITECTURE/ADDING_CUSTOM_NODES rows incl. the 6a
  dev-server carve-out (pack JS never loads there; those two surfaces are
  proven in 6b/CI)

Why

- backend nodes can register while a pack's frontend JS silently fails to
  load (wrong web dir, a loadExtensions regression) - nothing red-flagged
  that, and every JS-driven suite behavior would quietly vanish
- dynamic slot growth lives in pack JS (onConnectionsChange), invisible to
  /object_info, so no def-driven tier could see the CombineRegionalPrompts
  class of regression

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 05:28:30 +08:00
ShihChi Huang
6d0bbd7d7c perf: shard Chromium E2E across 16 jobs (#13650)
## Summary

> [!NOTE]
> Bumping to [24
shards](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13664)
reduces time from 11m14s to 9m31s, but costs 15% more in GitHub Action
runner time. Keep it 16 for now to be conservative and bump to 24 if it
works well for a week

Split Chromium E2E into 16 shards with two Playwright workers each,
preserving the current per-runner worker density while doubling
effective concurrency.

### 16 shards
| Run | E2E time | Shards |
|---|---:|---:|
|
[1](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29370163062)
| 11m38s | 16/16 pass |
|
[2](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29371313957)
| 11m32s | 16/16 pass |
|
[3](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29372283949)
| 10m50s | 16/16 pass |
|
[4](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29373163198)
| 11m00s | 16/16 pass |
|
[5](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29374055787)
| 11m12s | 16/16 pass |

### 24 shards
| Run | E2E time | Shards |
|---|---:|---:|
|
[1](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29379798083)
| 9m13s | 24/24 pass |
|
[2](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29380286857)
| 9m33s | 24/24 pass |
|
[3](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29380761689)
| 9m38s | 24/24 pass |
|
[4](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29381219308)
| 10m02s | 24/24 pass |
|
[5](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/29381669526)
| 9m07s | 24/24 pass |

## Changes

- **What**: Run Chromium as 16 shards with explicit `--workers=2`.

## Review Focus

- Compare full E2E time, shard skew, runner queue time, and flakiness
with the 8×2 baseline.

## Validation

- YAML parse and oxfmt check
- Pre-commit formatting, linting, and root typecheck
- Pre-push Knip
- Local CodeRabbit review is rate-limited for 24 minutes; server-side
CodeRabbit remains pending.

Created by Codex


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Changes are limited to CI workflow and Playwright config; no
application runtime, auth, or data paths are touched.
> 
> **Overview**
> **Chromium E2E CI** now runs as **16 parallel shards** (up from 8),
with **`workers: 2` on CI** in `playwright.config.ts` so each runner
keeps two Playwright workers while overall concurrency doubles.
> 
> Reporter wiring shifts so **blob output is chosen in config** when
`PLAYWRIGHT_BLOB_OUTPUT_DIR` is set (default reporter is `html`
otherwise); workflow steps drop inline `--reporter=blob` from the
Playwright CLI for both sharded Chromium and the other browser matrix
jobs.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
3707e70611. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-15 20:08:33 +00:00
imick-io
4341972be3 fix(website): remove opacity-80 washing out footer logo colors (#13648)
## Problem
The animated logo in the site footer (a webp frame sequence drawn to a
`<canvas>`) renders with dull, darkened colors. The brand neon yellow
`rgb(242, 255, 90)` shows up as a muddy olive `≈ rgb(198, 209, 80)`, and
the gray/pastel faces are darkened and purple-tinted.

## Cause
The footer `<canvas>` carried an `opacity-80` Tailwind class. At 80%
opacity the browser composites the animation 20% over the dark purple
footer background (`rgb(33, 25, 39)`), shifting every color. The source
webp frames themselves are correct — the shift only happens at display
time.

## Fix
Remove `opacity-80` from the canvas in `SiteFooter.vue` so it renders
the authored frame colors at full opacity.

## Verify
- Pre-check (no deploy): in DevTools, select the footer `<canvas>` and
untick `opacity: 0.8` — colors pop back immediately.
- After change: sample a yellow cube face in the footer animation with a
color picker — it should read `#F2FF5A` / `rgb(242, 255, 90)`, not
`rgb(198, 209, 80)`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:29:55 +00:00
CodeJuggernaut
98700cfcc7 feat: marquee select and Ctrl/Cmd+A in the Media Assets panel (#13323)
## Summary

Adds marquee (rubber-band) multi-select and Ctrl/Cmd+A select-all to the
Media Assets panel, clips the canvas drag-selection rectangle to the
canvas panel, and turns on live (real-time) node-graph rubber-band
selection by default.

## Changes

- **Marquee select** — rubber-band drag from empty grid space selects
the covered cards; hold Ctrl/Cmd to start a marquee from over a card;
Ctrl/Cmd or Shift alone makes the marquee additive to the current
selection, Ctrl/Cmd+Shift subtracts the covered cards from it
(designer-approved), and no modifier replaces it. Cards update their
selected state live during the drag.
- **Ctrl/Cmd+A** — selects all loaded assets when the pointer is over
the panel, otherwise falls through to the canvas (select all nodes). It
`stopImmediatePropagation`s so a panel select-all never also fires the
global node select-all, and it yields while an `aria-modal` dialog is
open or a text input is focused.
- **Select-all recovers after "deselect all"** — the shortcut was gated
only on `useElementHover`, which latched stale when the floating
selection bar under the cursor unmounted on deselect. It now also checks
the live pointer position against the panel rect, so a second Ctrl/Cmd+A
right after deselecting no longer falls through to the browser's native
page select-all.
- **Canvas rectangle clip** — the canvas drag-selection rectangle is
clamped to the canvas panel bounds (`SelectionRectangle.vue`,
display-only).
- **Graph live selection on by default** — flips the existing
`Comfy.Graph.LiveSelection` setting's default to on, so node-graph
rubber-band selection updates in real time during the drag (matching the
assets panel) instead of committing only on mouse-up. The behavior was
already implemented behind the setting; this changes only the default,
and users with an explicit value keep it.
- **Robustness/UX** — the pointer is captured on drag-engage rather than
on press (so a Ctrl/Cmd-click on a card isn't hijacked); no global
`document.body.userSelect` mutation (replaced by a panel-scoped
`selectstart` guard); the marquee overlay uses the semantic
`primary-background` token; post-drag click-suppression auto-resets so a
cancelled drag can't swallow a later click; `setPointerCapture` is
wrapped in try/catch; a Ctrl/Cmd-held card `dragstart` is cancelled so
no native ghost-drag image appears.
- **Breaking**: none — `useAssetSelection` is extended additively (new
`setSelectedIds` helper, nothing removed or altered) and the new
composable exposes only `{ marqueeStyle }`.
- **Dependencies**: none.

## Review Focus

- **`SelectionRectangle.vue`** is shared canvas code; the change is
display-only (clamps the rectangle to the panel; no node-selection
behavior change).
- **`coreSettings.ts`** — a one-line `Comfy.Graph.LiveSelection` default
flip is the only change that affects graph behavior; the live-select
code path itself is pre-existing.
- **`useAssetGridSelection.ts`** — listener lifecycle/teardown, the
panel-scoped `selectstart` guard, the click-suppression timer, the
capture-on-drag-engage logic, the pointer-position select-all fallback,
and the subtractive-mode snapshot at pointerdown.
- **Ctrl/Cmd+A routing** — panel hover (or a live pointer inside the
panel) gates select-all vs. the canvas, and `stopImmediatePropagation`
prevents double-handling.
- Pure geometry/selection logic is extracted into
`marqueeSelectionUtil.ts` and unit-tested in isolation (`RectEdges` is
`Pick<DOMRect, ...>`, the DOM edge subset); `MediaAssetCard.dragStart`
keeps `main`'s `display_name` payload.

Relates to Linear **FE-910**.

## Testing

- **Unit:** `useAssetGridSelection` (39 cases — marquee selection,
additive/replace, subtractive Ctrl/Cmd+Shift (incl. the macOS Cmd
variant and a shrink-restore drag), interactive-element + list-view
guards, `selectstart` scoping, click-suppression auto-reset,
pointer-capture-throw and capture-on-drag-not-press, modal-aware
Ctrl/Cmd+A, non-propagation, and the deselect-recovery pointer-in-panel
path), plus `MediaAssetCard`, `marqueeSelectionUtil` (11 cases incl.
subtractive, and a 5-case fast-check property suite pinning the
additive/subtractive set invariants), `SelectionRectangle`,
`useAssetSelection`, and `mathUtil`.
- **E2E (`assetsSidebarTab.spec.ts`):** 10 Playwright scenarios running
in CI — Ctrl/Cmd+A hover vs. canvas; a marquee from the panel header; a
modifier-held additive marquee; a Ctrl/Cmd+Shift subtractive marquee;
Ctrl/Cmd-drag from a card and within a single card; Ctrl/Cmd+A ignored
in a focused search box and under an aria-modal dialog; and a drag from
the search box not marquee-selecting. The empty-space marquee path is
covered by the panel-header scenario plus the unit suite (a dedicated
empty-space e2e could not run headless without a local backend and was
dropped as redundant).

## Future work

- **Escape key** — not handled by the marquee/select-all flow yet (the
composable handles only Ctrl/Cmd+A). Follow-up: press Escape to cancel
an in-progress marquee drag (abort the rubber-band and restore the
pre-drag selection) and to clear the current selection while the panel
has focus.
- **Ctrl+A across pagination** — select-all covers the loaded assets
only (confirmed as the intended behavior with design); a
load-all-then-select variant can follow if needed.

## Demo


https://github.com/user-attachments/assets/3841bf3c-db75-4229-a5e7-fb363b4882d6
2026-07-15 09:20:31 +00:00
Comfy Org PR Bot
5bf41a41bd 1.48.3 (#13606)
Patch version increment to 1.48.3

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-15 02:38:12 +00:00
CodeJuggernaut
a7f14a0b3f fix(subscription): size pricing dialogs with Reka props (#13633)
## Summary

Fixes the legacy personal and legacy workspace pricing dialogs so Reka
owns the dialog width and the pricing table no longer overflows the
default 576px frame.

## Changes

- **What**: Replace the shared PrimeVue-only `style` and `pt` dialog
props with Reka `renderer`, `size`, and `contentClass` props for both
legacy pricing paths.
- **What**: Preserve `modal: false` for the legacy workspace path so its
teleported PrimeVue plan-details popover remains interactive.
- **What**: Add unit coverage for both routes and assert that ignored
PrimeVue shell props are no longer passed.

## Review Focus

- **Regression origin**:
[#12593](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12593) made
Reka the default dialog renderer.
[#12666](https://github.com/Comfy-Org/ComfyUI_frontend/pull/12666) then
added shared PrimeVue `style` and `pt` props for these pricing dialogs.
Reka ignored those props and fell back to `size="md"` (`max-w-xl`,
576px).
[#13092](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13092) fixed
the unified pricing path only and explicitly left the two legacy paths
for follow-up.
- **Sizing ownership**: The fix puts width on the Reka dialog frame with
`size: 'full'` and `sm:max-w-7xl`. Pricing content no longer has to
compensate for a narrow shell.
- **Legacy workspace behavior**: `modal: false` remains intentional
because the legacy table opens a PrimeVue popover teleported to `body`.
- **Scope**: This PR contains only global subscription-dialog sizing.
Agent side-panel behavior remains in
[#13472](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13472).
- **Validation**: Focused tests pass (41 tests), `pnpm typecheck`
passes, and targeted ESLint passes. Chrome validation at 1352x705
rendered a 1280px dialog with no horizontal overflow in both the
isolated PR preview and the combined agent-panel preview. The docked
agent panel remained mounted behind the modal.

## Screenshots (if applicable)

- Original report and screenshots: [Slack
thread](https://comfy-organization.slack.com/archives/C0A8Z4U7Y1K/p1783967281397449?thread_ts=1783966866.227439&cid=C0A8Z4U7Y1K)
2026-07-15 00:35:09 +00:00
Christian Byrne
e6d1a9d4a2 fix(release): support manual target-branch override + major versions in resolve-comfyui-release (#13660)
## Problem

`scripts/cicd/resolve-comfyui-release.ts` derived the release target
purely from ComfyUI's `requirements.txt` pin and **hardcoded major
version `1`** (`core/1.${minor}`, `v1.${minor}.*`,
`1.${minor}.${patch}`) even though it parsed `major` and never used it.
Consequences:

- Could not release an out-of-cadence branch (e.g. skip a dead 1.46 to
ship 1.47 directly).
- Could not do a major bump (2.x).

## Changes

1. **Resolver (`resolve-comfyui-release.ts`)** — uses the parsed
`targetMajor` (defaults to the current pin's major) for every
branch/tag/version string instead of literal `1`. `getLatestPatchTag`
now takes a `major` param and globs `v${major}.${minor}.*`.

2. **`TARGET_BRANCH` env override (highest precedence)** — when set,
validates `^core/(\d+)\.(\d+)$`, verifies `origin/<branch>` exists, and
skips the `RELEASE_TYPE`/pin-derived selection entirely. If both
`TARGET_BRANCH` and `RELEASE_TYPE` are set, the override wins.
`current_version` still comes from the pin (for `diff_url` and the
ComfyUI PR "from" version), so the requirements bump jumps straight from
the pin to `target_version` (e.g. 1.45.20 → 1.47.8, skipping 1.46).

3. **Workflow (`release-biweekly-comfyui.yaml`)** — new optional
`target_branch` `workflow_dispatch` input, wired into the resolve step's
`env` as `TARGET_BRANCH`, and surfaced in the run summary. Downstream
jobs consume `target_branch`/`target_version` outputs unchanged.

The output JSON shape is identical. Extracted pure helpers
(`parseTargetBranchOverride`, `computeTargetVersion`) and guarded the
main block so the module is importable by tests.

`release-version-bump.yaml` and `release-branch-create.yaml` were
**already** major-aware and are left untouched.

## Tests

New `scripts/cicd/resolve-comfyui-release.test.ts` (15 cases) covering
`parseRequirementsVersion` (==, >=, missing/absent), `isValidSemver`,
`parseTargetBranchOverride` (valid `core/1.47` and `core/2.0`, malformed
rejected), and `computeTargetVersion` including a non-1 major (`v2.0.3`
+ commits → `2.0.4`).

## Usage

```
gh workflow run release-biweekly-comfyui.yaml --field target_branch=core/1.47
```

releases 1.47.8 directly (skipping a dead 1.46), or `--field
target_branch=core/2.0` for a major bump.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 23:41:35 +00:00
Dante
0b4a960467 feat(billing): derive next-invoice amount, date, and cadence from billing state (FE-1245) (#13599)
## Summary

Adds the data seam for the Settings > Plan & Credits Invoices tab
(FE-1245): derives the next-invoice amount, date, and billing cadence
from billing state already fetched by the billing context, replacing the
prototype's hardcoded mock.

Updated for the 2026-07-13 design decisions (Slack thread + Willie's
Figma updates): the banner surfaces the BE-provided next-invoice date,
and annual subscriptions now show their yearly total and real renewal
date instead of hiding the banner.

## Changes

- **What**: `useNextInvoice` composable + pure `deriveNextInvoice` in
`src/composables/billing/` — returns `{ nextInvoice:
ComputedRef<NextInvoice | null> }`, `NextInvoice = { amountCents,
renewalDate, duration }`
- Monthly: subscribed team credit stop `monthly.price_cents` (status
`team_credit_stop.id` matched against the ladder) with plan
`price_cents` fallback — unchanged precedence
- Annual: stop `yearly.price_cents * 12` (stop yearly prices are
per-month figures, per `useWorkspacePlanPricing`) or the ANNUAL plan's
`price_cents` as-is (already the yearly total, per
`UnifiedPricingTable`)
- `renewalDate`: BE-computed `renewal_date` passed through untouched —
backends own period math including month-end bias. It goes null the
moment a cancellation is scheduled (mutually exclusive with
`end_date`/`cancel_at` on both billing backends)
- null (banner hidden) when inactive, cancelled, the amount is
unresolvable/non-positive (covers legacy billing's empty plan list and
free tier), or the plan resolved by slug disagrees with the
subscription's cadence
- 13 unit tests cover all branches, including x12-regression fixtures
with discriminated list/discount prices

Intentionally excludes usage/overage pending charges until the backend
exposes an authoritative upcoming-invoice amount (see FE-1245).

Follow-ups (not this PR):

- Cancelled-state toast (Figma 4617:29298 month-remaining / 4617:29992
terminal): separate seam UI work. Data is already available —
`subscription.endDate` is populated by both billing backends exactly
during the cancelled-but-paid window, and `useResubscribe()` covers the
Renew plan action
- "/year" amount framing is shown with placeholder copy; no annual
variant exists in the Figma file yet (design follow-up)

## Review Focus

- Annual unit semantics: stop `yearly.price_cents` is a per-month figure
(`useWorkspacePlanPricing.ts` `teamMonthlyCostCents`), while
`Plan.price_cents` for ANNUAL plans is the yearly total
(`UnifiedPricingTable.vue` `getAnnualTotal`) — hence x12 in one branch
and as-is in the other
- Consumed by the `useWorkspaceInvoices` seam once #13591 lands; the
swap now also requires the seam template to render
`renewalDate`/`duration`, so it is no longer a one-line body change
- FE-1245 / DES-497

## Screenshots

Live captures through the real `WorkspaceInvoicesContent` + this
composable with `/api/billing/*` mocked per state (isolated preview
harness). Date format follows the existing billing convention; "/ year"
framing is placeholder pending the annual design variant.

Active monthly subscription, team credit stop `team_320` — $320 USD with
next-invoice date:

![Invoices — team credit stop $320 with
date](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/9271ad5cfb93dca3fa6d60ed2aa8ebca1dcbec4e/.github/pr-assets/fe-1245-invoices-team-stop.png)

Active monthly subscription without a credit stop — plan-price fallback,
$20 USD:

![Invoices — plan price fallback $20 with
date](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/9271ad5cfb93dca3fa6d60ed2aa8ebca1dcbec4e/.github/pr-assets/fe-1245-invoices-plan-price.png)

Annual subscription — banner now shown: yearly total from the per-month
stop figure (288 x 12 = $3,456) and the real yearly renewal date:

![Invoices — annual
shown](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/9271ad5cfb93dca3fa6d60ed2aa8ebca1dcbec4e/.github/pr-assets/fe-1245-invoices-annual.png)

Paused subscription — unchanged: next-invoice banner hidden, the paused
banner hosts the Full invoice history action (capture from the earlier
full-app harness):

![Invoices —
paused](https://raw.githubusercontent.com/Comfy-Org/ComfyUI_frontend/9271ad5cfb93dca3fa6d60ed2aa8ebca1dcbec4e/.github/pr-assets/fe-1245-invoices-paused.png)
2026-07-14 23:21:11 +00:00
Christian Byrne
060957d66c fix: use ComfyUI version for Cloud release notes (#13632)
## Summary

On Cloud, `releaseStore.currentVersion` sourced `cloud_version` (e.g.
`0.160.1`) while the `/releases` feed keys its entries by **ComfyUI**
version (e.g. `0.27.1`). The what's-new popup compares the latest feed
entry against the running version, so `0.27.1 < 0.160.1` read as
"already ahead of the latest release" and the popup never showed.

- Analytics confirmed the regression: `release_note` clicks fell from
**13.4% (90d) → 0% (30d)**; `cloud_release_note` was effectively never
clicked.

## Change

- `currentVersion` always uses `comfyui_version` (drops the `isCloud →
cloud_version` branch). The feed request keeps `project: 'cloud'`.
- Rationale: the changelog page and each note's "learn more" link are
ComfyUI-versioned, and Cloud has no separate versioned feed or changelog
page. The changelog is maintained with ComfyUI versions, updated after
each Cloud deploy lands.

## Tests

- Adds a regression test (`isCloud environment (FE-1237)`) pinning that
Cloud uses `comfyui_version`, not `cloud_version`. Verified via negative
control: reverting the fix fails it with `0.160.1` vs expected `0.27.1`.
- `test:unit` (releaseStore): 49 passed · `typecheck`, `lint`,
`format:check`, `knip`: clean.

## ADR

Adds `docs/adr/0012-cloud-release-notes-use-comfyui-version.md`
(Accepted) recording the rationale and history, and updates the ADR
index.

Fixes FE-1237

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 23:03:58 +00:00
Christian Byrne
1a98362984 fix(i18n): escape vue-i18n message syntax in generated node defs + preserve raw messages on compile errors (#13631)
## Summary

Defense-in-depth fix for the `Invalid linked format` crash: escape
vue-i18n message-syntax characters at node-def generation so the locale
bundle is always valid, **and** harden the runtime so any compile error
(custom nodes, already-shipped bundles) degrades to the raw message
instead of crashing the app.

This folds in the runtime guard from #13603 (@xmarre) — see credit below
— so the two layers land together.

## Changes

**Layer 1 — generation-time escaping (source data)**
- Add `escapeVueI18nMessageSyntax()` to
`packages/shared-frontend-utils/src/formatUtil.ts`. It escapes every
character vue-i18n's message compiler treats as syntax in text — `@ { }
| %` (verified against `@intlify/message-compiler`'s `readText`
tokenizer): `@` and unbalanced `{`/`}` throw, `|` mis-renders as plural
branches, `%` matters immediately before `{`. Each becomes a literal
interpolation `{'x'}` (the only escape vue-i18n supports); the set isn't
exported by the library so it's hardcoded with source/doc references.
Single-pass, applied once.
- Apply it in `scripts/collect-i18n-node-defs.ts` to the fields
**compiled** via `t()`/`st()`: `display_name`, `description`,
input/output `name`, widget labels, `dataTypes`, `nodeCategories`.
Tooltip fields go through `tm()`/`stRaw()` (no compile step) and are
intentionally left unescaped.
- Regression test (`src/locales/escapeNodeDefI18n.test.ts`) compiles the
escaped output for all five characters through real vue-i18n and asserts
it round-trips verbatim.

**Layer 2 — runtime fallback (consumer), folded in from #13603 by
@xmarre**
- `st()` now wraps `t(key)` in try/catch: a vue-i18n `SyntaxError` falls
back to the raw locale message (`tm()`) instead of crashing bootstrap.
Shared `rawTranslationOrFallback` helper reused by `stRaw()`. Tests in
`src/i18n.safeTranslation.test.ts` (isolated per-test, and covering the
non-`SyntaxError` rethrow branch).

**Tooling**
- Lint the collection scripts instead of ignoring them: import the
shared helper via the `@/utils/formatUtil` tsconfig alias (fixes
`import-x/no-relative-packages`; verified it resolves under Playwright)
and drop a stray progress `console.log`. No lint config changes and no
rules relaxed — the scripts are now genuinely linted (they were
previously excluded).

- **Breaking**: none.

## Review Focus

- **Root cause**: values read via `t()`/`st()` are compiled by vue-i18n,
which parses `@ { } | %` as message syntax; a malformed `@` throws
`Invalid linked format`. `app.ts` translates every node description at
startup, so one bad string aborts router load. Surfaced by the 1.47.7
locale sync (#13246), whose `ByteDanceSeedAudio` description contains
`@Audio1-3`.
- **The two layers compose**: escaped output compiles cleanly, so the
runtime catch never fires for generated strings; the catch is the safety
net for strings that bypass generation (custom/third-party nodes via
`mergeCustomNodesI18n`, and locale bundles already shipped in
1.47.x/1.48.x).
- **Compiled vs. raw boundary**: escaping is applied only to
compiler-consumed fields; leaving tooltips raw matches the
`stRaw()`/`tm()` work in #12469.
- **Translation propagation**: lobe-i18n's config prompt already keeps
`{...}` placeholders intact, so the escaped English propagates to all 13
locales.
- **Follow-up (out of scope)**: `scripts/collect-i18n-general.ts`
(commands/settings/menus) shares the same generation-time hazard and
could reuse the same helper later; Layer 2 already covers it at runtime.

## Credit

The runtime fallback (Layer 2) is @xmarre's work from #13603, folded in
here with authorship preserved on the original commits. Closing #13603
in favor of this combined PR.

Fixes #13545
Fixes #13575

---------

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
Co-authored-by: xmarre <54859656+xmarre@users.noreply.github.com>
2026-07-14 18:14:47 +00:00
Terry Jia
ab33746b3e fix(load3d): letterbox-aware pointer NDC for the transform gizmo (#13550)
## Summary
The viewport letterboxes the scene to the width/height target aspect,
but TransformControls maps pointers over the full canvas rect, so its
handle raycasts skew toward the center, increasingly wrong toward the
edges whenever the canvas aspect differs from the target aspect.

Adds clientPointToLetterboxNdc() (pure, unit-tested) + a
Viewport3d.clientPointToNdc() wrapper, and routes TransformControls'
pointer math through it via GizmoManager.setPointerNdcSource(). 
Points on the letterbox bars resolve to nothing instead of phantom
handles.

## Screenshots (if applicable)
before
<img width="813" height="703" alt="image"
src="https://github.com/user-attachments/assets/cf5714c3-6b60-4ced-84ce-039f49c7e999"
/>
<img width="673" height="696" alt="image"
src="https://github.com/user-attachments/assets/31f898d2-a820-4748-b960-4f3cc5752095"
/>

after
<img width="1391" height="981" alt="image"
src="https://github.com/user-attachments/assets/18890d89-ff50-4298-bac5-472e00c79e24"
/>
<img width="955" height="712" alt="image"
src="https://github.com/user-attachments/assets/255f3c7d-dbb5-40a8-8865-c023caa1384e"
/>
2026-07-14 13:39:03 -04:00
Benjamin Lu
55c4e807a1 fix: use system hour cycle for queue times (#12297)
## Summary

Use the selected app locale for queue timestamp language and punctuation
while honoring the browser/system 12-hour or 24-hour clock preference.

## Changes

- **What**: `formatClockTime` resolves the hour cycle from the
browser/runtime default locale, then applies it while formatting with
the app locale.
- **What**: Added behavioral unit coverage for the production
two-argument path and explicit 12-hour and 24-hour preference locales.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

Queue timestamps already use the browser's local timezone. This PR fixes
only the remaining clock-preference mismatch: app language stays in
control of the rendered time string, while the browser/system locale
controls 12-hour versus 24-hour display.

No Playwright regression test was added because browser/system
hour-cycle preference is environment-level Intl state, not deterministic
UI state that the E2E suite can set reliably. Unit tests pin both hour
cycles through BCP-47 locale preferences and assert the rendered
behavior directly.

Fixes
[FE-252](https://linear.app/comfyorg/issue/FE-252/bug-queue-progress-times-ignore-browsersystem-1224-hour-preference)

## Screenshots (if applicable)

Not applicable.

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-07-14 16:58:25 +00:00
jaeone94
74147d7ee2 feat: lift boundary-exposed validation errors to the subgraph host (#13542)
## Summary

Backend validation errors (`node_errors`) are reported against the
**flattened** prompt, so an error whose real fix lives on a subgraph
host node gets attached to an interior node the user may never open —
and, after ADR 0009, often *cannot* meaningfully fix there. This PR
re-surfaces a validation error onto the subgraph host node **when — and
only when — the error's subject (the specific input/widget named by
`extra_info.input_name`) is exposed through the subgraph boundary**.

## Why this is needed

Two concrete situations motivated this, both observed in real workflows:

1. **Broken link at the host.** Root node A should feed subgraph host B,
whose boundary input is linked to interior node C. If the A→B link is
missing, the backend flattens the prompt, sees C with no resolved input,
and raises `required_input_missing` **on C** (`"B:C"`). The actual fix —
connect B's input — is one level up, on a node the error never points
at.
2. **Host-owned widget values.** Per ADR 0009 (subgraph promoted widgets
use linked inputs), a promoted widget's value is owned by the host
`SubgraphNode`; the interior widget only supplies schema/defaults. When
the backend raises `value_not_in_list` (or min/max violations) for that
value, attributing it to the interior node is factually wrong — the
value that failed validation *is the host's value*.

This continues the direction of #13059, which moved **missing-model**
detection identity to `{hostExecutionId, hostWidgetName}` with the
interior path kept as diagnostics. That was possible in the FE pre-scan;
this PR applies the same ownership principle to **backend-received**
errors via a receive-side mapping, since the backend cannot know about
subgraph boundaries in a flattened prompt.

## The rule (design)

> Lift an error from interior node N to host H **iff** N's input slot
named by the error is linked to the containing subgraph's boundary
(`SubgraphInput`). Apply the same test again at H (boundary-by-boundary,
matching ADR 0009's chaining principle) and stop at the first level
where the subject is no longer boundary-linked — that node is where the
user can actually fix it.

The predicate is **structural (boundary exposure), not data-flow**.

### In scope — examples

- `required_input_missing` on interior `"12:5"` whose input is fed by
the boundary → surfaces on host `12`'s input slot (red slot ring on the
host, errors-tab card titled/located at the host, message names the
host's `SubgraphInput.name`).
- `value_not_in_list` / `value_smaller_than_min` /
`value_bigger_than_max` on a promoted interior widget → surfaces on the
host's promoted widget. Nested hosts chain: `"1:2:3"` lifts to `"1:2"`,
and further to `"1"` only if `1:2`'s own slot is boundary-linked too.
- Clearing follows the surface: connecting the highlighted host input or
fixing the host widget clears the underlying interior (raw) error —
range-guarded per target, so a still-out-of-range host value does
**not** clear.

### Out of scope — examples

- **No value-flow ancestry.** All in the root graph: A's widget links to
B, B's to C, and C rejects the value that originated at A → the error
**stays on C**. Following same-graph links to a "root cause" node is
explicitly not this feature.
- Errors without an `input_name` subject, node-level types
(`exception_during_validation`, `dependency_cycle`, image-not-loaded),
and unknown validation types — never lifted. Unknown types stay
node-scoped to match how the error catalog renders them (the shared
`isNodeLevelValidationError` in `executionErrorUtil` encodes this, and
the catalog derives its node-level rules from the same set).
- Runtime execution errors (exceptions during a run) — validation
responses only.
- Interior errors whose input is fed by another interior node — fixable
in place, stay in place.
- Fan-out display dedupe: when one boundary input feeds multiple
interior nodes and the host slot is unconnected, each interior error
lifts to the same host slot as a separate panel line. A single fix
(connecting the host input) clears all of them — the clearing
translation already fans out — so the duplication is cosmetic.
Display-level dedupe is a follow-up; deduping inside the lift would
break the one-source-per-error clearing contract.
- Reactive re-lifting on graph topology edits while errors are displayed
(invariant documented on the computed; follow-up), and deriving the
error catalog's full validation rule table from the shared
classification (follow-up; the node-level type set and the
image-not-loaded predicate are already single-sourced in
`executionErrorUtil` and consumed by both the lift and the catalog).

## Changes

- **What**: New pure module
`core/graph/subgraph/liftNodeErrorsToBoundary.ts` — per-error, fail-open
record transform (unresolvable ids/slots/links leave the error where the
backend put it; raw payload is never mutated). `executionErrorStore`
derives `surfacedNodeErrors` from it and display consumers switch over
(errors tab grouping, canvas node/slot flags, Vue node badges,
app-mode/linear hints); raw `lastNodeErrors` remains the source of truth
for mutation. Host-side clearing translates through the lift's
diagnostics fields (`source_execution_id` / `source_input_name`) with a
per-target range guard.
- **Breaking**: None. No persistence/serialization changes; interior
identity survives as diagnostics metadata only (ADR 0009 language).

## Review Focus

- The lift predicate lives entirely on link topology
(`LLink.originIsIoNode` → `SubgraphInput`) — no
`proxyWidgets`/promotion-store style source authority is reintroduced.
- `clearSlotErrorsWithRangeCheck` now resolves clear targets first and
range-checks each target's raw errors; the lifted-path twin of the
existing range-retention test pins this.
- `useProcessedWidgets` deliberately stays on the raw record: host
promoted widgets already map to interior errors via
`widget.sourceExecutionId`, so an interior widget keeps its local red
hint when the user opens the subgraph (hint layer vs surface layer).
- Unit coverage is carried by the pure module (real litegraph subgraph
fixtures, incl. nested recursion, promoted widgets via
`promoteValueWidgetViaSubgraphInput`, ordering, fail-open/no-mutation);
one e2e pins the user-visible contract (host ring + host slot dot +
interior clean).

## Screenshots

### Before 


https://github.com/user-attachments/assets/81e5c4db-515d-4f1f-8f8a-e07ac490510f

### After



https://github.com/user-attachments/assets/2949da06-a049-41c1-a480-98ee28333bf2
2026-07-14 14:39:20 +00:00
Benjamin Lu
16fdcab4be feat(telemetry): track signup/login flow errors in PostHog (#13504)
## Summary

Auth error toasts in the signup/login flow (e.g. "No account found with
this email…") were never tracked, leaving no analytics visibility into
signup dropoff. This adds a PostHog event for them.

## Changes

- **What**: New `app:user_auth_failed` PostHog event with `{ error_code,
auth_action }`, fired from a `reportAuthFlowError` wrapper in
`useAuthActions` before the existing error toast. Wired to the seven
auth-flow actions (`email_sign_in`, `email_sign_up`, `google_sign_in`,
`google_sign_up`, `github_sign_in`, `github_sign_up`, `password_reset`);
covers both the cloud login/signup pages and the in-app sign-in dialog
since all funnel through the same handler. Non-auth actions (logout,
billing) intentionally stay untracked.

## Review Focus

- PostHog provider only — not added to Mixpanel/GTM/CustomerIo/etc.
- No PII: only the Firebase error code is sent (`'unknown'` for
non-Firebase errors), never the message or email.
- The `useAuthActions` test mock of `wrapWithErrorHandlingAsync`
previously rethrew errors; the real implementation swallows them after
handling. The mock now matches the real contract, and one pre-existing
logout assertion that depended on the rethrow was updated.

Fixes
[MAR-451](https://linear.app/comfyorg/issue/MAR-451/add-tracking-for-signup-flow-errors)

---------

Co-authored-by: Deep Mehta <42841935+deepme987@users.noreply.github.com>
2026-07-13 23:04:58 +00:00
Mobeen Abdullah
96c6dbcb69 fix(website): replace em dash meta title separators with hyphens (#13628)
## Summary

Switch the meta title separator sitewide from an em dash (—) to a spaced
hyphen ( - ), the follow-up to #13480 tracked as FE-1233, based on the
Screaming Frog crawl that flagged the em dash titles.

## Changes

- **What**: Replaced the em dash separator with a hyphen in every page
meta title, in both `en` and `zh-CN`. Covers ~44 inline page titles, the
shared title keys in `i18n/translations.ts` (`cloudNodes.meta.title`,
`mcp.meta.title`, `affiliate-terms.page.title`,
`enterprise-msa.page.title`, `affiliate.page.title`,
`launches.page.title`), and the dynamic `customers`, `demos`, and
`supported-models` `... - Comfy` suffixes. `BaseLayout` reuses each page
title for `og:title`, `twitter:title`, and the JSON-LD `name`, so those
inherit the change from a single source per page.
- Updated the e2e title assertions to match. `launches` asserts via
`t()` so it follows the translation change automatically.

## Review Focus

- **Scope is titles only.** The crawl and this ticket are about meta
titles. Description / body copy em dashes are left untouched, they are
marketing copy, which we keep em-dashed per the comms-only styling
decision. A full JSON-LD audit across all 57 routes confirms every
`name`/title field is now em-dash-free.
- **zh-CN cloud-nodes title keeps its Chinese double dash `——`**
(`自定义节点包合集——开箱即用`). That is correct CJK punctuation, a dash break, not a
brand/Latin separator, so the Latin-script separator concern does not
apply. Its e2e assertion is left as `——` to match.
- No visual change: titles are `<head>` metadata, not rendered UI, so
there is nothing to screenshot on the page. Rendered `<title>` output
was verified on the dev server across inline, `t()`-based, zh-CN,
dynamic, and 404 routes, and `og:`/`twitter:` inheritance was confirmed.

Ref: FE-1233
2026-07-13 22:24:52 +00:00
pythongosssss
ffb4ae34c6 feat: App mode UI updates (#13170)
## Summary

App mode now uses the same sidenav as the graph view instead of its own
custom navigation, and switching between modes happens through a new
animated graph/app toggle shared by both views.

## Changes

- **What**:
- LinearView hosts the standard `SideToolbar`, filtered to the Assets
and Apps tabs via new `visibleTabIds`/`forceConnected` props. The
bottom-panel and shortcut toggles hide in app mode, and
GraphCanvas/SubgraphBreadcrumb hide their instances so only one toolbar
and toggle render at a time.
- The workflow actions dropdown trigger is now a two-segment graph/app
toggle: the inactive segment switches modes directly, the active one
opens the actions menu as before, with a FLIP animation on switch.
- Each view renders its own toggle, so switching unmounts one and mounts
the other. A `displayLinearMode` ref in canvasStore trails `linearMode`
by two frames so the incoming toggle animates from the old mode instead
of popping in already-switched.
- AppModeToolbar slims down to the toggle plus a "Build an app" button;
the per-icon Assets/Apps/Share buttons are covered by the sidenav.
- The Apps tab gains "Create" header and empty-state actions wired to
`Comfy.NewBlankWorkflow`.
- App-mode feedback moves into `SidebarHelpCenterIcon` as a Typeform
popover with a load-error fallback, replacing `TypeformPopoverButton`
and `LinearFeedback` and simplifying LinearPreview to a single
`OutputHistory`.
- Tests for the toggle, sidebar tabs, help-center fallback, preview, and
view; screenshots regenerated.

## Review Focus

- The two-frame `displayLinearMode` lag — timing-sensitive; rapid
toggling cancels the pending frame chain so the wrong mode never
flashes.
- `SideToolbar` now serves both views: confirm only one instance renders
and the new props behave.

## Screenshots (if applicable)


https://github.com/user-attachments/assets/9ad1eb4f-9370-4975-b3f1-b271482f2bf7

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:52:34 +00:00
Matt Miller
59341a4b71 feat: data-driven provider input types with JSON file upload for BYOK secrets (#13571)
## ELI-5

Some cloud providers hand you an API key you type in a box; others (like
Google Vertex) hand you a whole JSON file. The provider list already
comes from the server. This teaches each provider to also say *how* its
credential is entered. When a provider says `json_file`, the "Add
Secret" form now shows an **Upload JSON file** button plus a paste box
instead of the single-line password field. Everything else works exactly
as before.

## Summary

Extends the server-driven secret provider schema with optional
`input_type` (`'text' | 'json_file'`) and `label`, and renders a
file-upload + paste textarea for `json_file` providers (e.g. a Vertex
service-account JSON) instead of the password field.

## Changes

- **What**:
- `SecretProvider` ingest schema gains optional `input_type` and `label`
(added to the generated `types.gen.ts` / `zod.gen.ts` — see Review
Focus).
- `listSecretProviders()` now returns the full provider objects (id +
optional `input_type`/`label`) instead of just ids; `availableProviders`
carries them through to the form.
- The credential field switches on the selected provider's `input_type`:
`json_file` → upload button (reads the file into the value) + paste
textarea with light "must be valid JSON" validation; anything else → the
existing password field.
- Provider display label prefers the server `label`, falling back to the
frontend registry label, then the raw id.
  - New i18n keys under `secrets.*`.
- **Breaking**: none. Providers that omit `input_type` render exactly as
today (single-line secret). Read/List responses are untouched — the
credential value is still never echoed back.

## Review Focus

- **Judgment call — one provider, credential kind inferred, no toggle.**
Per the design, a single provider is kept and the raw credential value
is sent as-is; the credential kind (AI Studio key vs Vertex SA JSON) is
inferred server-side. The design allowed "inferred from the upload *or*
an explicit toggle"; I chose inference to keep the UI minimal and avoid
introducing a new user-facing gating control. No provider ids are added
or renamed, so the proxy's 1:1 path→provider mapping is unaffected.
- **Generated-file edit (bridge).** The `input_type`/`label` fields were
hand-applied to the generated
`packages/ingest-types/src/{types,zod}.gen.ts`. The upstream
`openapi.yaml` (the generation source) is not in this repo, so this
models the wire contract additively until the backend spec lands the
same optional fields; a future regeneration reproduces them. The fields
are optional, so nothing breaks if the server omits them.
- **No secret ever echoed back.** Only the create request carries the
value; GET/List responses (metadata-only) are unchanged.

Tests: unit coverage added/updated for the provider metadata
passthrough, `selectedInputType`, server label precedence, file loading,
JSON validation, and the form rendering both branches. Targeted vitest
(79 secrets tests), `pnpm typecheck`, eslint, and oxfmt all green.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:21:27 +00:00
imick-io
97fffb5394 [feat] Add NEW badge to top-level header nav items (#13527)
## Summary
Adds an accent NEW badge next to top-level header nav items (Products,
Community) on both desktop and mobile.

## Changes
- **What**: New `badge?: 'new'` field on `NavItem`, rendered as an
accent `Badge` in `HeaderMainDesktop.vue` and `HeaderMainMobile.vue`;
adds a `xxs` badge size and drops hardcoded padding/size from the
`accent` variant so it composes with size.
- **Tests**: e2e coverage asserting the badge shows only on
Products/Community, not Company/Pricing.

## Review Focus
- The `accent` badge variant now inherits padding/font-size from `size`
instead of hardcoding them — verify existing `accent` usages still
render correctly.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-13 16:39:15 +00:00
Rizumu Ayaka
4972e4d42f fix(form-dropdown): stop cancelling native scroll on slow trackpad wheels (#12334)
## Summary

Follow-up to #12052. The local wheel guard added in FormDropdownMenu was
cancelling native scroll on slow macOS trackpad gestures, leaving image
rows blank in the asset picker until a fast scroll re-pumped samples
through.

## Changes

- **What**: `FormDropdownMenu.onWheel` now only `preventDefault`s on
pinch-zoom (`ctrl/meta + wheel`). The `|deltaX| > |deltaY|` branch of
`isCanvasGestureWheel` is no longer used here.
- **Why**: Slow vertical trackpad scrolls emit small-delta frames with
stray horizontal jitter (e.g. `deltaY=1.2`, `deltaX=1.5`) that satisfied
the horizontal-dominant predicate. Cancelling those frames starved
`VirtualGrid`'s throttled `useScroll`, so `Math.floor(scrollY /
itemHeight)` never advanced and tiles never mounted.
- Horizontal-swipe-to-navigate is still blocked at the page boundary by
`overscroll-behavior: none` on `html, body` (the actual fix from
#12052), so dropping the local horizontal guard does not reintroduce the
back/forward bug.
- The global `isCanvasGestureWheel` is unchanged —
`useCanvasInteractions` still uses it for the canvas wheel handler,
where the semantics are different (forward to canvas pan, not
preventDefault native scroll).

## Review Focus

- Confirm `overscroll-behavior: none` on `html, body` is sufficient on
its own for the horizontal-swipe-to-navigate case inside the teleported
dropdown. (It is — overscroll-behavior is decided per scroll container
at gesture start, independent of where in the page the gesture
originates.)
- Pinch-zoom in the dropdown still preventDefaults correctly.

Tested on macOS trackpad: slow vertical scroll now loads images
progressively; fast scroll unchanged; pinch-zoom in dropdown does not
zoom the page; horizontal swipe in dropdown does not navigate.

Related: this fixes the slow-scroll repro reported in #bug-dump. The
blank-on-reopen class of bug from FE-535 (#11885) is a different issue.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12334-fix-form-dropdown-stop-cancelling-native-scroll-on-slow-trackpad-wheels-3656d73d3650813a987ffa8d6f0eaa9c)
by [Unito](https://www.unito.io)
2026-07-13 16:08:29 +00:00
Terry Jia
8d8ae7dfc0 feat: share one WebGL context across all 3D views (#13547)
## Summary
Browsers cap live WebGL contexts per page, so each Load3D node owning
its own renderer broke once enough 3D nodes were added. All 3D views now
share a single offscreen WebGLRenderer: each view renders into the
bottom-left region of the shared drawing buffer and blits the frame into
its own plain 2D canvas, which has no context limit.

- Add sharedWebGLRenderer (refcounted singleton, grow-only buffer) and
RendererView (per-view canvas + per-view renderer state + blit)
- Per-view renderer state (tone mapping, color space, clear color) is
applied before each view renders; HDRIManager and SceneModelManager
write to it instead of the renderer
- OrbitControls/TransformControls bind to the node container explicitly
- ViewHelperManager renders the axes helper itself so it can position
and scale independently of renderer DOM layout
- RecordingManager records from the view canvas; capture renders at
exact size on the shared buffer as before
- On last view release the context is force-lost and webglcontextlost is
dispatched synchronously so context-loss handlers still run

## Screenshots (if applicable)
before
<img width="2155" height="1527" alt="image"
src="https://github.com/user-attachments/assets/35a7ab8a-28dd-41ba-8349-525bd2e97977"
/>

after
<img width="918" height="430" alt="屏幕截图 2026-07-09 135025"
src="https://github.com/user-attachments/assets/67328577-bd82-4494-921a-6d298442d081"
/>
<img width="1636" height="1509" alt="image"
src="https://github.com/user-attachments/assets/a3be9215-7cd0-427c-8264-1fd964d3ace1"
/>
2026-07-13 09:50:30 -04:00
Pablo
38c034e1d6 feat(load3d): prototype top-bar chrome for embedded 3D viewer (#13205)
## Summary

Prototype redesign of the embedded 3D viewer controls into a framed
top/bottom-bar chrome. Proof-of-concept for design exploration — not
intended to merge as-is.

## Changes

- **What**: Replaces the floating viewer controls (`Load3DControls`)
with a new `Load3DMenuBar` framed chrome: a black top bar with a
category dropdown (Scene / 3D Model / Camera / Lighting) and the active
category's actions (labels collapse to icons on narrow nodes), plus a
black bottom bar with Record (left) and fit + export (right). Export is
moved out of the menu into the bottom-right button. Adds a "Clay"
material mode that renders meshes with a flat grey material so geometry
is visible without textures.
- **Breaking**: None — `Load3D.vue` swaps the component and hides its
existing right-side button column behind `v-if="false"`; nothing is
deleted.

## Review Focus

- Design prototype: `Load3D.vue` swaps `Load3DControls` →
`Load3DMenuBar` and hides the existing fit/center/expand/record column.
The original components are left untouched.
- The only changes outside the prototype component are for the **Clay**
material mode, which touches shared engine code: `MaterialMode` type
(`interfaces.ts`), the runtime material switch (`SceneModelManager.ts`),
the capability lists (`MeshModelAdapter.ts`, `ModelAdapter.ts`,
`useLoad3d.ts`), and the i18n label.
- Known prototype gaps: **Record** is a visual toggle only (pulsing dot,
not wired to real recording); **fit** reuses the existing
`handleFitToViewer`; **export** reuses the existing format options.

## Screenshots (if applicable)

Prototype walkthrough captured during development (top-bar chrome,
category dropdown, labeled-vs-icon collapse, Clay material, pulsing
Record). Available on request.

---------

Co-authored-by: PabloWiedemann <PabloWiedemann@users.noreply.github.com>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-07-12 23:09:54 -04:00
Comfy Org PR Bot
b40fad0e75 1.48.2 (#13596)
Patch version increment to 1.48.2

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-07-11 03:49:03 +00:00
Maanil Verma
01cbfa6a23 feat(templates): replace template search with MiniSearch and usage ranking (#13386)
## Summary

The template picker's search now surfaces the right template for how
people actually type — abbreviations, typos, multi-word intent, and
non-Latin (CJK) titles — and orders results by real popularity instead
of a fuzzy-match score that was being thrown away. Search and ranking
now behave the same here as they do on the workflow hub.

## Changes

**What**

- Searching for the way people phrase things now works: `t2v`, `i2v`,
`cn` expand to their full modality terms, `img2img`/`v2v` expand to
editing (matching how the catalog tags image/video edit templates),
`flux upscale` and `sdxl lora` match across title/model/tag fields
together, prefixes like `vid` match `video`, and typos like `contorlnet`
still find ControlNet. Versioned names tokenize sensibly, so `wan 2.2`
and `wan2.2` both hit, while `2.5` never blurs into `3.5`.
- CJK titles are searchable. Unspaced Han/Hiragana/Katakana runs are
tokenized into character unigrams and bigrams, so a substring a user
types (`放大` inside `图像放大`, or the single trailing `大`) lands on a match.
Korean and other spaced scripts fall to the normal word tokenizer,
unchanged.
- Fuzzy matching is tighter: a term now tolerates edits up to 20% of its
length (down from a flat threshold), so `contorlnet` still finds
ControlNet but `upscale` no longer fuzzy-matches the shorter, unrelated
`scale`. Short (≤3-char) and digit-bearing terms stay exact.
- Results lead with text relevance. Previously the fuzzy match score was
computed and then discarded, and any active sort re-ordered results by
usage — so the best textual match rarely landed on top. Now relevance is
the authoritative order while a query is active, and when two results
match about equally well, the more-used template wins the tie (dampened
so one runaway-popular template can't dominate).
- The ranking is a stable total order. Scores are bucketed before usage
breaks ties, so a cluster of near-equally-relevant results always sorts
the same way — a naive per-pair "within X%" comparison is intransitive
and makes the order depend on internal input order (it can even shuffle
as you type another character).
- "Popular" ranks by raw usage, matching what the hub and the search
index show. It previously blended in a freshness term that pushed newer,
less-used templates above genuinely popular ones.
- The sort dropdown works during search again: it defaults to
"Relevance" but you can switch to Popular/Newest/etc. to re-order the
results, and your browse sort is restored (and never overwritten by a
search-time choice) when you clear the query.
- Alphabetical sort reads correctly: it sorts by the title shown on the
card, trims stray leading whitespace that used to jump templates to the
top, and groups number-prefixed titles after the letters instead of
ahead of them.
- Filter telemetry now reports the sort the user is actually seeing
(relevance while searching) rather than the persisted browse sort, so
analytics reflect the visible ordering.
- Removed the old runtime Fuse-options override path, which is obsolete
under the new engine.

**Breaking**

None. Existing filters (Model / Use Case / Runs On / distribution),
pagination, and persisted sort settings are unchanged; the relevance
mode is search-only and never persisted.

## Review Focus

- The ranking crux is `rankByRelevanceThenUsage` in
`templateSearchConfig.ts`: relevance is primary, usage only re-orders
results in the same score bucket, and bucketing keeps it a stable total
order. That's the one function to review for correctness.
- The CJK tokenizer (`cjkGrams` / `tokenize` in
`templateSearchConfig.ts`): script-matched so only unspaced scripts are
grammed, and a pure-CJK run relies on its grams (no whole-word token).
Splitting by code point is safe here (these scripts are BMP-only; emoji
are excluded by the run regex).
- Deliberately not touched: the "Recommended" sort keeps its curated
blend (usage + editorial rank + freshness) so it stays distinct from
"Popular"; `vram-low-to-high` remains unimplemented exactly as on main.

## Tradeoffs / notes

- Adds `minisearch` (~18 kB gzip). The template selector is where it's
used; accepted for the search-quality gain (a later change could
lazy-load it if bundle size becomes a concern).
- Bucketing means two results just across a bucket boundary don't
tie-break on usage even when their scores are close — the accepted cost
of a transitive, predictable order (this mirrors how the search index
quantizes relevance).
- `img2img` expands to editing (not literal "image to image") because
the catalog labels those templates "Image Edit" — verified against the
real data.
- CJK bigrams roughly double the token count for a pure-CJK title;
negligible at catalog scale (~550 templates, short titles).

## Testing

Behavioral coverage over the real search paths, not the mocks — the
ranking and tokenizer run against actual MiniSearch output; only the
ranking-store math is mocked. Also verified against the full
~550-template catalog end to end (all query types stable, zero
input-order-dependent orderings).

### Behavior matrix (verified on the real catalog)

| Input / action | Now | Previously |
| --- | --- | --- |
| `img2img` | Image-editing templates (Qwen Image Edit, …) | Matched
every "image" template — intent lost |
| `flux upscale` | Flux upscale templates (matches both terms across
fields) | **No results** (single-field fuzzy couldn't span title + tag)
|
| `sdxl lora` | SDXL templates | **No results** |
| `t2v` / `i2v` / `cn` | Expand to text→video / image→video / controlnet
| Only partial slug hits, if any |
| `vid` (prefix) | Matches `video` templates | Unreliable |
| `contorlnet` (typo) | Finds ControlNet | Often dropped by the strict
threshold |
| `upscale` | Matches upscale titles only | Fuzzy-matched the unrelated
substring `scale` |
| `放大` / `大` (CJK) | Matches `图像放大` and other titles containing the run
| No match — CJK titles were unsearchable by substring |
| `wan 2.2` and `wan2.2` | Both match; `2.5` never matches `3.5` | Space
vs no-space degraded the match |
| Near-tied cluster (e.g. `upscale`) | Stable order every time |
Reordered depending on input order (could shuffle as you type) |
| Query active, "Popular" selected | Best textual match still leads;
usage breaks near-ties | Sort re-ordered by usage, burying the best
match |
| Change sort while searching | Re-orders the search results; relevance
is the default | Sort was locked; dropdown had no effect |
| Clear the search | Restores the browse sort you had before | — |
| "Popular" sort | Orders by raw usage (matches hub / index) | Freshness
blend pushed newer low-usage templates up |
| A–Z sort | Letters first (`ACE…`), number-prefixed titles last
(`3x3…`, `360…`); leading whitespace ignored | Leading-space titles
jumped to the top; numbers sorted before letters |

### Unit tests (81 total, all passing)

- `templateSearchConfig.test.ts` (34) — tokenizer identifier/version
splits, CJK unigram/bigram gramming (and Korean left as a spaced word),
per-term fuzziness (`upscale` ≠ `scale`), abbreviation expansion (incl.
`img2img`→edit intent), prefix + typo matching, AND-then-OR,
literal-before-expansion ordering, relevance>tag>description ranking,
and `rankByRelevanceThenUsage` giving a stable order on an intransitive
cluster.
- `useTemplateFiltering.test.ts` (35) — the `img2img` / `flux upscale` /
`sdxl lora` regressions, relevance-default-on-search,
override-sort-while-searching, browse-sort restore on clear, ephemeral
mid-search sort, telemetry reporting the visible sort, Runs-On filter,
empty-result guard, filters preserving relevance order, and alphabetical
trimming + numbers-after-letters.
- `templateRankingStore.test.ts` (12) — freshness and default-score
(recommended) math.

Gate: `pnpm typecheck`, `pnpm lint`, `pnpm knip` clean.

## Screen Recording (if applicable)



https://github.com/user-attachments/assets/6748a3f7-e69d-44ac-826c-71990c8dce90
2026-07-11 01:16:40 +00:00
Nathaniel Parson Koroso
6783d5ac18 Merge branch 'main' into nathaniel/custom-node-e2e-suite
Bring the custom-node suite branch current with main (37 commits) so the
detection-proof demo can rebase cleanly onto an up-to-date primary.
2026-07-10 15:42:52 -07:00
Nathaniel Parson Koroso
4962589a3e ci(custom-nodes): unpin backend, auto-take latest ComfyUI master
The v0.26.2 pin was a stopgap for a transient master boot regression. The
real cause (an uncaught vue-i18n compile throw on a pack node i18n value with
a literal '@') is now fixed in st() (src/i18n.ts), and current master boots
clean, so the gate tracks latest again. Pack-vs-latest drift remains the
nightly canary's job.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:08:35 -07:00
Christian Byrne
945a143626 fix(ci): harden pr-backport — independent per-target attempts + comment on conflict failures (#13412)
## Symptom

When the auto-backport workflow (`.github/workflows/pr-backport.yaml`)
hits a cherry-pick **conflict**, it is supposed to comment on the
original PR telling the author to backport manually. On PR #13359
(backport to `cloud/1.45`, cherry-pick of `d6c582c39` conflicting on
`useSubscriptionDialog.test.ts`) **no comment was posted** — the author
(@huntcsg) got no notification and had to find the failure by digging
into Actions logs. See run
[28616756256](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/28616756256/job/84862400420).

## Root cause

The "Comment on failures" step actually ran and reached the `conflicts`
branch — the failure reason *was* populated and the `if:` condition
*was* met. The real failure is at the last line of the loop, under the
step's default `bash -e` shell:

```yaml
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
```

The job log ends with:

```
GraphQL: Unable to create comment because issue is locked (addComment)
##[error]Process completed with exit code 1.
```

PR #13359 is `locked: true`, so `gh pr comment` returns non-zero.
Because the call was unguarded under `set -e`, the step aborted on the
spot: the comment was lost and — critically for the general case — any
remaining failed targets in the loop would also be skipped. The step is
then marked failed with no actionable output on the PR.

(Related PR #13167 "attempt each backport target branch independently"
is still open/unmerged; this is a residual gap in the failure-comment
path.)

## Fix

Wrap every `gh pr comment` call in a `post_comment` helper. On failure
it emits a `::warning::` naming the target, the reason, and the
manual-backport branch (`backport-<pr>-to-<target>`) instead of
aborting, so the loop always attempts a comment for each failed target
and surfaces a clear log message when GitHub refuses (e.g. locked
issue). The conflict comment body now also states the manual backport
branch explicitly.

- YAML: `python3 -c 'import yaml; yaml.safe_load(...)'` passes.
- `actionlint`: no new warnings in the changed step (the 2 pre-existing
`SC2016` notes on the intentional single-quoted `envsubst` var lists are
unchanged).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:09:31 -07:00
pythongosssss
193bbaba81 fix: dont remove unowned callbacks when cleaning hooks on unmount (#12380)
## Summary

Minimaps unmounted cleanup blindly restored the callbacks that are
originally captured, even if other systems have chained their own
callbacks onto this, breaking other parts of the system (e.g. vue node
graph manager).
Recreation:    

1. Ensure minimap open
2. Enter subgraph 
3. Exit subgraph
4. Close minimap
5. Try adding a node/unpackign subgraph/etc <--- broken

## Changes

- **What**: 
- only replace callbacks that we own
- else function becomes no-op

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12380-fix-dont-remove-unowned-callbacks-when-cleaning-hooks-on-unmount-3666d73d3650817cbfe0d98ab98528b8)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-07-10 20:24:20 +00:00
Alexis Rolland
1eacb224a1 Update commit author login retrieval in CLA workflow (#13544)
## Summary

Make CLA more robust by including commit authors in the allowlist even
if they have no GitHub account. This to ensure only PR authors are
required to sign.

## Changes

- **What**: `cla.yaml`
2026-07-10 20:16:51 +00:00
Nathaniel Parson Koroso
03f2b6bd89 fix(custom-nodes): boot on newer ComfyUI backends
Two backend-drift failures that took the custom-node e2e suite 100% red on
ComfyUI master (green on v0.26.2):

1. st() (src/i18n.ts) compiled custom-node i18n values via vue-i18n's t().
   A pack node whose translated value contains a literal '@' (vue-i18n
   linked-message syntax) throws SyntaxError: Invalid linked format at
   compile time. st() had no catch and runs on the boot critical path
   (getNodeDefs -> registerNodes -> comfyApp.setup(), before window.app is
   assigned), so one bad pack message aborted app boot and window.app.
   extensionManager never got set -> every test timed out at waitForAppReady.
   Now st() falls back to the raw string when compilation throws.

2. dismissTemplatesDialog hard-waited (no timeout) for the templates dialog,
   which newer ComfyUI no longer auto-opens; now tolerant of its absence.

Verified: pinned to the exact failing backend SHA, the suite goes 86 passed /
0 failed (was 100% red at boot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:47:29 -07:00
Terry Jia
4ed2fe70f3 feat: accept bboxes input and add grid snapping to Create Bounding Boxes (#13376)
## Summary
- Seed/override the canvas from an upstream bboxes input
- Add dotted grid background and magnetic snap-to-grid controls
- Darken the canvas well for contrast

BE is https://github.com/Comfy-Org/ComfyUI/pull/14724

## Screenshots (if applicable)



https://github.com/user-attachments/assets/7282f4d5-7cac-46f0-9d73-d0add37f4eb9
2026-07-10 15:40:17 +00:00
Terry Jia
5da5ee5031 feat: wire up Save 3D (Advanced) node family (CORE-329) (#13330)
## Summary
Register the save-side advanced nodes in the Load3D viewer
infrastructure: Save3DAdvanced reuses the mesh advanced extension, while
SaveGaussianSplat and SavePointCloud reuse the splat/point cloud preview
extensions.

Parameterize both extension factories with a loadFolder so save nodes
load the persisted file from the output folder instead of temp, and add
the node types to the lazy-load and viewport-state sets.

BE change https://github.com/Comfy-Org/ComfyUI/pull/14701
## Screenshots (if applicable)
Save 3D (Advanced)
<img width="1328" height="939" alt="image"
src="https://github.com/user-attachments/assets/c5f3cbe0-6e57-463c-9128-67490c2fc89e"
/>

Save Splat
<img width="1296" height="1052" alt="image"
src="https://github.com/user-attachments/assets/f05bbcf8-9794-4861-9dd7-3015d38b11d9"
/>
2026-07-10 15:39:51 +00:00
Maanil Verma
ceb5ae1eba fix(cloud): keep survey footer visible on small screens (#13568)
## Summary

Keep the onboarding survey's Back/Submit footer visible on small screens
by scrolling only the question area instead of the whole survey.

## Changes

- **What**: The survey scrolled as a single block , so on short
viewports the button row slid under the template footer (Terms/Privacy).
Now the outer is bounded to its slot and the question wrapper in
`DynamicSurveyForm` scrolls internally with a responsive cap , so option
lists scroll while the footer buttons stay pinned. The step height
animation is unchanged.

## Screen Recording

https://github.com/user-attachments/assets/6d7f6d50-59b8-4dc0-b20e-8f4ca08167c6
2026-07-10 08:30:50 +05:30
Nathaniel Parson Koroso
7164b844ff ci(custom-nodes): pin backend to ComfyUI v0.26.2 for a deterministic gate
Unpinned comfyanonymous/ComfyUI master let a core boot regression (0.27.0
hangs frontend app-init before window.app is set) turn the whole custom-node
suite red through no fault of the frontend. Add an optional comfyui_ref input
to setup-comfyui-server (default unchanged = master) and pin only this job.
Pack-vs-latest-ComfyUI drift stays the nightly canary's concern (task #24).
2026-07-09 19:18:38 -07:00
Comfy Org PR Bot
9f880c78cb 1.48.1 (#13559)
Patch version increment to 1.48.1

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-10 02:16:50 +00:00
Benjamin Lu
3b2eb50f3b feat(cloud): redeem desktop login codes for web-to-desktop identity stitching (GTM-93) (#13418)
## What

Browser half of GTM-93 macOS web→desktop identity stitching: when the
desktop app opens the system browser at cloud login with
`?desktop_login_code=dlc_…`, the frontend redeems that code against the
cloud backend once a Firebase session exists — after **explicit user
approval**.

Reworked on top of the preserved-query `stripAfterCapture` capability
(#13465):

- The `DESKTOP_LOGIN` namespace opts into strip-on-capture: the code is
stashed and removed from the URL **before any navigation completes**, so
it never reaches history, `previousFullPath`, later guards, or telemetry
— the hand-rolled URL scrubbing this PR previously carried (raw-string
parser + three strip sites) is gone.
- `desktopLoginRedemption.ts` is a plain module with a single export,
`installDesktopLoginRedemption(router)`, installed once in `router.ts`'s
cloud block (replaces six per-view/composable trigger sites). Redemption
reads the code only from the stash: per-code state (approval + 2-attempt
transient budget, so a second code gets its own approval and budget),
approval dialog, `POST /api/auth/desktop-login-codes/redeem` with the
raw Firebase ID token (backend route is Firebase-JWT-only), 10s fetch
timeout.
- Triggers: `router.afterEach` (the cloud auth guard settles Firebase
init before navigations complete) plus a lazy watcher on
`authStore.currentUser` for sessions that appear without a navigation
(OAuth-resume error branch, dialog sign-in). One bounded in-page retry
(5s) guarantees an approved sign-in always ends in a success or failure
toast.
- Terminal rejections (400/403/404/409/410) drop the code with an error
toast; transient failures (401/5xx/timeout/network) retry once; budget
exhaustion now surfaces a failure toast instead of dying silently.

## Why

Windows stitches web→desktop at download time via installer stamping;
macOS DMGs can't be stamped, so we stitch at login. The browser is where
both halves meet: the existing `posthog.identify(uid)` merges the web
anon person into the Firebase uid, and the backend emits
`comfy.cloud.identity.login_attributed` (uid ↔ installation_id) at
redeem. The desktop app polls the backend and receives a one-time custom
token — no auth material posted to a desktop loopback server (the
concern that stalled #12983, which this supersedes).

## Security

- **Approval dialog before redeem** — redemption mints the desktop a
sign-in token for *your* account, so a lured click must not be enough
(device-code phishing mitigation). Cancel clears the stash and does
nothing.
- Only the opaque single-use code ever appears in a URL; the tracker
strips it on first sight, pre-navigation, and it is never logged.

## Testing

Vitest, driven through a real router (createRouter/createMemoryHistory,
no vue-router mocks) and the real preserved-query manager: capture/stash
lifecycle, approval gate (no fetch before approve; decline/dismiss
clears; per-code approval), Bearer/body shape, terminal-vs-transient
statuses, timeout abort, bounded in-page retry + failure toast on budget
exhaustion, per-code regressions (second code after
success/decline/exhaustion redeems independently), auth-watcher trigger
(session appearing without navigation), unauthenticated no-op, trigger
coalescing. Typecheck/lint/format clean.

Types are hand-written with a `TODO(@comfyorg/ingest-types)` — the
generated types land automatically once the cloud PR merges and the
type-gen workflow runs.

## Landing order

1. Cloud backend: https://github.com/Comfy-Org/cloud/pull/4736 (until it
ships, redemption never triggers — this PR is inert)
2. #13465 preserved-query strip-on-capture (base of this PR)
3. #13466 global-prompt FIFO queue (runtime dependency: the approval
confirm must settle even if another prompt is open)
4. **This PR**
5. Desktop (activates the flow):
https://github.com/Comfy-Org/Comfy-Desktop/pull/1222

GTM-93 · Supersedes #12983

---------

Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-10 02:11:00 +00:00
Nathaniel Parson Koroso
f9dbb6b9d8 Merge remote-tracking branch 'origin/main' into nathaniel/custom-node-e2e-suite 2026-07-09 14:26:03 -07:00
Matt Miller
2ef341dcd8 test: E2E for BYOK secret add / list / delete flow (#13510)
## ELI-5

The settings screen now has a "Secrets" panel where you can save API
keys for
model/AI providers. This adds an end-to-end test that plays out the
whole story
like a real user: open the panel, add a key, watch it show up in the
list, then
delete it. It also checks the security promise — the key you type is
sent to the
server but is **never** shown back to you afterward — and that an
account without
access to the gated providers never even sees them in the dropdown.

## What

Adds `browser_tests/tests/cloudSecrets.spec.ts`, a Playwright spec
covering the
secrets (API keys) surface in the cloud app:

- **Entitled account, full CRUD round-trip:** empty state -> add a
provider key
(pick provider, name, secret value, save) -> the key appears in the list
->
  delete it via the confirm dialog -> back to empty state.
- **Secret value is write-only:** asserts the create request carried the
plaintext
value, but the value is never echoed back into the DOM (the
list-response schema
  is metadata-only).
- **Entitlement gate:** an account whose provider allowlist is empty
never sees
  the gated providers anywhere in the add dialog.

Follows the existing cloud E2E conventions: drives a raw `page` and
reuses the
`mockCloudBoot` / `bootCloud` helpers so the app boots signed-in against
fully
mocked endpoints. A small stateful in-memory handler backs the secrets
endpoints
(list / create / delete + the provider allowlist) so the flow is
deterministic
and never touches a real backend.

## Why

Verification capstone for the secrets settings surface — proves the add
/ list /
delete flow works against the documented API behavior
(`GET`/`POST`/`DELETE` on
the secrets collection, `GET` on the provider allowlist) and locks in
the two
contracts that matter: the secret value is never returned after
creation, and the
provider allowlist is the only thing that surfaces gated providers to
the user.

## Tests

- `browser_tests/tests/cloudSecrets.spec.ts` — new, two cases (tagged
`@cloud`).
- Static checks pass locally: oxlint (0 warnings/errors) and oxfmt
formatting.
- The browser run itself needs a served app + the E2E harness (CI), so
it was not
executed in this environment; the spec is self-contained and mocks all
network.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:20:03 +00:00
Mobeen Abdullah
1815c7f7a4 feat(website): add JSON-LD structured data across the site (#13480)
## Summary

This PR adds schema.org **JSON-LD structured data across the whole
marketing site**, built from one shared, CMS-ready module and gated by a
small CI validator. It replaces the old global block (which had a stale
logo, wrong social links, disconnected nodes, and a head slot that
rendered three times) with a single connected `@graph` on every page.
Structured data only — there is no visual or runtime change for users.
Tracks Linear **FE-1170**.

The design goal was that structured data should be impossible to get
subtly wrong: one place builds it, honesty rules are enforced in code,
and a build-time validator fails the build if any page ships a broken
`@id` graph or a fabricated price/rating.

## Changes

- **One builder, one sink.** `utils/jsonLd.ts` holds pure, node-testable
builders; `components/common/JsonLdGraph.astro` is the single escaped
`<script type="application/ld+json">` sink (prevents `</script>`
breakout XSS).
- **The layout owns the page entity.** `BaseLayout` emits a baseline
`Organization` + `WebSite` + `WebPage` graph on every page from its own
`title`/`description`/canonical props. Enriched pages pass only what is
specific to them: `pageType`, `breadcrumbs`, `mainEntityId`, and
`extraJsonLd` nodes. This makes it impossible for a page's meta tags and
its structured data to drift apart.
- **Corrected site-wide entity.** Raster PNG logo (Google does not index
SVG logos), real `sameAs` handles sourced from the footer links,
`@id`-linked `Organization`/`WebSite`, and the triple-rendered head slot
fixed.
- **Honesty is enforced, not just intended.** No fabricated
`Review`/`AggregateRating`/`Offer`. Pricing offers are parsed only from
plain `$N` copy (a future "Contact us" drops the offer instead of
shipping a garbage price). Third-party node packs and listed models do
**not** claim Comfy Org as author/publisher. `noindex` pages (404,
payment) emit no structured data.
- **CI validator.** `pnpm --filter @comfyorg/website validate:jsonld`
runs over `dist/` in the website build workflow and fails on invalid
JSON, an unresolved `@id`, a fake rating, or an empty/non-numeric offer
price.
- **Breaking:** none.

## Coverage (also a manual QA checklist for the preview)

Every public page carries at least `Organization` + `WebSite` +
`WebPage`. The pages below add a page-specific primary entity, in
**English and zh-CN**:

| Page | Path (example) | Adds to the graph |
|---|---|---|
| Home | `/`, `/zh-CN` | `SoftwareApplication` (ComfyUI, free) +
`SoftwareSourceCode` |
| Download | `/download` | `SoftwareApplication` (ComfyUI desktop) |
| Pricing | `/cloud/pricing` | `Product` + 3 monthly `Offer`s
($20/$35/$100) + Breadcrumb |
| Models catalog | `/p/supported-models` | `CollectionPage` + `ItemList`
(313, lean) + Breadcrumb |
| Model detail | `/p/supported-models/4x-ultrasharp` |
`SoftwareApplication` + `FAQPage` + Breadcrumb |
| Nodes catalog | `/cloud/supported-nodes` | `CollectionPage` +
`ItemList` (58 packs) + Breadcrumb |
| Node-pack detail | `/cloud/supported-nodes/ComfyQR` |
`SoftwareApplication` (+ free `Offer`) + Breadcrumb |
| Demos | `/demos/community-workflows` | `LearningResource` + Breadcrumb
|
| About / Contact | `/about`, `/contact` | `AboutPage` / `ContactPage`
(Org as `mainEntity`) + Breadcrumb |
| Careers | `/careers` | `CollectionPage` + `ItemList` of open roles +
Breadcrumb |
| Affiliates | `/affiliates` | `FAQPage` + Breadcrumb |

Pages deliberately left at the baseline `WebPage` (generic landings,
legal, coming-soon) and pages with **no** structured data (`noindex`:
`/404`, `/payment/*`; redirect URLs) are intentional.

## Review Focus

- **Layout-owns-WebPage design.** `BaseLayout` builds the `WebPage`;
pages contribute only extra nodes. This is the main structural decision
and is what removes meta-vs-schema drift by construction.
- **Honesty guardrails.** Worth confirming: pricing offers, third-party
author omission on packs/models, and that `noindex` pages emit nothing.
- **`@id` and URL consistency.** All cross-page links and `@id`s resolve
to the canonical trailing-slash form; zh-CN breadcrumbs are rooted under
`/zh-CN`; the singleton `WebSite`/`#software` entities carry one
consistent definition across pages and locales.
- **The validator.** It is a bespoke ~100-line script scoped to the
website build job (not the prod deploy). Happy to make it non-blocking
or drop it if the team prefers.
- **Coordination with #13468.** Both branches add
`components/common/JsonLdGraph.astro`. Customer pages are intentionally
excluded here; #13468 can converge onto this shared builder.

## Verification

`astro check` 0 errors · 166 unit tests · `knip` 0 · `eslint` 0 · build
497 pages · validator passes across 500 pages · JSON-LD e2e specs 24/24.
(The 3 pre-existing demo e2e timeouts are an external Arcade-embed flake
on one slug, reproduced identically on `main`.)

## Screenshots

Not applicable — head-only structured data, no visual change. Validate
on the Vercel preview with the Rich Results Test and the schema.org
validator. Note: `@id`/URL values render as `comfy.org` (from
`astro.config` `site`) even on the preview host, which is correct.
2026-07-09 21:03:43 +00:00
imick-io
287b9eb980 feat(website): add HeroBackdrop01 block component (#13549)
## Summary

Adds the `HeroBackdrop01` hero block component to the website app. This
lands the component on its own so it can be merged to `main` ahead of
the EDU page work (on another branch) that consumes it.

The component renders a responsive hero with an optional image/video
backdrop (in-flow rounded card on mobile, full-bleed background on
desktop), an optional product badge, title, subtitle, and footnote. It
respects `prefers-reduced-motion` by not autoplaying the looping
backdrop video (WCAG 2.2.2).

## Notes

- No consumer imports the component yet — it is intentionally added
ahead of the page that will use it. The `knip` unused-file check flags
this, which is expected for this staging PR.
- Depends on existing `useReducedMotion` composable and
`ProductHeroBadge` component, both already present on `main`.

## Test plan

- [ ] `pnpm typecheck:website` passes (verified locally via pre-commit
hook)
- [ ] Component renders correctly once wired into a page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:43:49 +00:00
Matt Miller
06b0471257 test: pin unknown-provider flow-through in server-driven provider options (#13546)
## ELI-5

The provider picker for BYOK secrets is now driven by the server's
`availableProviders` list, not a hardcoded frontend list. The point is
that a provider the server offers but the frontend has never heard of
should still show up — using its raw id as the label and no logo. This
adds the one test that actually pins that behavior, so nobody can later
re-add a "only show providers the frontend knows about" filter without a
test going red.

## Summary

Regression test for the server-driven provider options introduced in
#13509: asserts that an unknown provider id passes through
`providerOptions` rather than being filtered against the local
presentational registry.

## Changes

- **What**: Adds one `useSecretForm.test.ts` case asserting that a
create-mode `availableProviders` list containing an id absent from the
local `SECRET_PROVIDERS` registry (`'brand-new-provider'`) yields a
single `providerOptions` entry rendered with the raw id as its label and
`logo: undefined`.

## Review Focus

The existing suite already covers the registry fallback
(`providers.test.ts`) and server-listed *known* ids (`runway`/`gemini`),
but every one of those cases uses an id that exists in the local
registry — so a future change that re-filtered `availableProviders`
against `SECRET_PROVIDERS` could pass all current tests while silently
dropping unknown providers. This test closes that gap by using an id
that is deliberately absent from the registry, so it fails if
pass-through is ever broken. Follow-up to the optional review ask on
#13509. Test-only; no production code changes.
2026-07-09 19:20:56 +00:00
Nathaniel Parson Koroso
424e85ebc0 docs(detection-proof): final falsification-pass sync - executed matrix, scoped-break rationale, registry self-heal finding 2026-07-09 11:52:52 -07:00
Maanil Verma
8120142f49 feat(cloud): redesign the cloud onboarding survey (#13518)
## Summary

New users get a cleaner, faster onboarding survey: one question per
screen on tappable cards that advance the moment you pick an answer,
with follow-up questions that appear only when they're relevant. The
questions themselves were reworked to learn what people actually want to
do with ComfyUI.

## Changes

**What**

- Replaced the radio/checkbox list with a card-based,
one-question-at-a-time wizard. Choosing a single-select answer advances
automatically — no separate Next click — while multi-select and
free-text steps still wait for you to confirm.
- Reworked the question set: what you want to make, how well you know
ComfyUI, and how you found us. Two questions are now conditional — a
"what are you building?" follow-up appears only for workflow/API
builders, and a "which platform?" follow-up appears only when you say
you found us on social media.
- Added an "other" free-text escape hatch to the intent and source
questions, required before you can move on so we don't capture an empty
"other".
- Polished the whole surface to the comfy-canvas theme with animated
step-height and cross-fade transitions between questions, and hid the
marketing hero on the survey and user-check routes so the form has room.
- Errors now surface only after you've interacted with a field, not on
first paint.
- Extended the telemetry survey-response and remote-config option shapes
to carry the new fields (including per-option icons), leaving the older
fields in place so historical responses still typecheck.

**Breaking** — None. The remote-config survey schema stays
backend-overridable, hidden branch answers are zeroed in the submitted
payload, and the telemetry field names line up 1:1 with the schema. The
backend dynamic config already ships the matching version-3 schema.


## Testing

Behavioral coverage over the wizard's real interactions rather than DOM
structure, since the risk is in navigation/branching/validation, not
markup. `vue-i18n` is mounted for real with the actual locale file so
tests assert on rendered copy.

-
[DynamicSurveyForm.test.ts](src/platform/cloud/onboarding/survey/DynamicSurveyForm.test.ts)
— auto-advance on single-select, no-advance on multi/other, Back
navigation, branch reveal/hide and its submitted payload,
required-"other" gating, post-interaction error surfacing, and
survey-prop reset.
-
[DynamicSurveyField.test.ts](src/platform/cloud/onboarding/survey/DynamicSurveyField.test.ts)
— card rendering/selection state, stable option ids, multi-select emit,
the conditional "other" input, and label resolution via key / locale map
/ id fallback.
-
[surveySchema.test.ts](src/platform/cloud/onboarding/survey/surveySchema.test.ts)
— default-schema branching (which steps show), hidden-field zeroing and
free-text-over-sentinel in the payload, and the shared
`hasNonEmptyValue` truth table.

Gates: `vue-tsc` typecheck clean; eslint/oxfmt clean on touched files;
survey suite green (68 tests across the three files). Manual: run the
cloud onboarding flow, pick a workflow/apps intent to confirm the
"building" follow-up, pick social to confirm the platform follow-up, and
verify an empty "other" blocks advancing.

## Screen Recording



https://github.com/user-attachments/assets/1908ca18-93d1-41a2-a55b-1f04a6df2268
2026-07-09 18:31:52 +00:00
AustinMroz
3164e6ab61 On workflow swap, restore 'Preview as Text' text (#13536)
Also adds proper typing for `onNodeOutputsUpdated`

See also: #12877 and #13427, which include near equivalent changes for
the bug itself, but different tests. If I had more time and had not
already made my own fix, I would have liked to spend more time getting
either of them cleaned up.
2026-07-09 17:03:19 +00:00
pythongosssss
731512c655 test: remove timeout causing flake (#13543)
## Summary

The timeout resolved 1 second after the test completed sometimes
throwing:
```
⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯
ReferenceError: window is not defined
 ❯ resolveMessageFormat node_modules/.pnpm/@intlify+core-base@9.14.5/node_modules/@intlify/core-base/dist/core-base.mjs:1357:13
 ❯ translate node_modules/.pnpm/@intlify+core-base@9.14.5/node_modules/@intlify/core-base/dist/core-base.mjs:1216:11
 ❯ node_modules/.pnpm/vue-i18n@9.14.5_vue@3.5.34_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:581:48
 ❯ wrapWithDeps node_modules/.pnpm/vue-i18n@9.14.5_vue@3.5.34_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:526:19
 ❯ t node_modules/.pnpm/vue-i18n@9.14.5_vue@3.5.34_typescript@5.9.3_/node_modules/vue-i18n/dist/vue-i18n.mjs:581:16
 ❯ onNodePackChange src/workbench/extensions/manager/components/manager/PackVersionSelectorPopover.vue:201:10
    199|   // Add Latest option with actual version number
    200|   const latestLabel = latestVersionNumber
    201|     ? `${t('manager.latestVersion')} (${latestVersionNumber})`
       |          ^

    202|     : t('manager.latestVersion')
    203|

This error originated in "src/workbench/extensions/manager/components/manager/PackVersionSelectorPopover.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
```

The timeout was not required for the test to validate the loading text.

## Changes

- **What**: Remove timeout
2026-07-09 16:27:28 +00:00
imick-io
c0ad1e98c2 test(website): add e2e specs for the learning page (#13529)
## Summary

Add end-to-end test coverage for the `/learning` page, which previously
had none.

## Changes

- **What**: New `e2e/learning.spec.ts` covering the EN page smoke
behaviour (hero, featured workflow, tutorial grid rendered from the
`learningTutorials` data source, per-tutorial Try Workflow links, and
the contact-sales CTA), the tutorial video dialog open/close/Escape
interactions, and the zh-CN localized page.

## Review Focus

Assertions are driven off the `learningTutorials` data source and `t()`
i18n keys rather than hardcoded strings to avoid change-detector tests.
Media is stubbed by the auto-applied `blockExternalMedia` fixture, so
the specs have no network dependency. The tutorial-dialog interaction
uses a `toPass` retry to accommodate `client:visible` hydration.
2026-07-09 16:23:07 +00:00
Comfy Org PR Bot
bd9fab2d2f 1.48.0 (#13541)
Minor version increment to 1.48.0

**Base branch:** `main`

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-07-09 07:21:27 +00:00
Comfy Org PR Bot
c7fe6a23ec 1.47.7 (#13246)
Patch version increment to 1.47.7

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-07-09 05:47:17 +00:00
nav-tej
df9b5bfa0a feat(website): rework MCP page setup + hero, hide promo banner on /mcp (#13514)
## Summary

Two changes to the comfy.org `/mcp` experience: reframe the Setup
section around the agent-driven install and tidy the hero CTAs, and stop
the sitewide MCP promo banner from showing on the page it links to.

## Changes

- **Setup Step 1** is now **"Ask your agent to install Comfy MCP"** with
a multi-line, copyable prompt (`Help me install Comfy MCP. / Follow the
setup guide at https://docs.comfy.org/agent-tools/cloud`).
`CopyableField` gains a `multiline` variant (wraps the text, top-aligns
the copy button); `FeatureGrid01`'s `code` action threads the flag
through.
- **Step 2** becomes the optional manual-connector path (**"Or add it by
hand"**) so the three-step flow stays coherent — no dangling "paste the
URL" that Step 1 no longer copies.
- **Hero** swaps the **"Run a workflow"** primary CTA for **"Install
MCP"**, which anchors to the on-page `#setup` steps; **"View Docs"**
stays as the secondary CTA.
- **Announcement banner** no longer advertises the page you're already
on. `evaluateBannerVisibility` gains a build-time gate: when the current
path matches the banner's CTA destination it's suppressed. Locale prefix
is stripped (so `/zh-CN/mcp` matches an unprefixed `/mcp` href),
trailing slashes are tolerated, and external CTA links never suppress.
Result: the MCP banner shows everywhere except `/mcp` and `/zh-CN/mcp`.
- New i18n keys (`mcp.setup.step1.command`, `mcp.hero.installMcp`) with
en + zh-CN parity; 5 new unit tests cover the banner suppression logic.
- **Breaking**: none.

## Review Focus

- @deepme987 @bertfy — copy + flow check. The Step 1 prompt is a
paraphrase of the `agent-tools/cloud` install guide. The raw
`cloud.comfy.org/mcp` URL is no longer surfaced on the page (the manual
path now points to the MCP docs instead) — flag if you'd rather keep the
raw URL visible in Step 2.
- Banner suppression keys off the CTA's `link.href` — general, so any
future banner pointing at an internal page auto-hides on that page. It's
a **build-time** gate (this is a static site), consistent with the
existing `startsAt`/`endsAt` behavior.
- Only the **hero** CTA changed. The "How it works" section deliberately
keeps its "Run a workflow" CTA.
- zh-CN strings are machine-drafted; a native check would be welcome.

## Screenshots

Verified locally against a production build (`astro build` + preview):

- **Hero** — `INSTALL MCP` + `VIEW DOCS` (the "Run a workflow" button is
gone). "Install MCP" scrolls to `#setup`.
- **Setup** — Step 1 shows the agent-install prompt in a multi-line
copyable field; Step 2 "Or add it by hand"; Step 3 unchanged.
- **Banner** — present on `/`, `/download`, `/cloud`; absent on `/mcp`
and `/zh-CN/mcp`.

A Vercel preview deploy attaches automatically for a live view.

---------

Co-authored-by: imick-io <153135517+imick-io@users.noreply.github.com>
2026-07-09 04:11:23 +00:00
Yousef R. Gamaleldin
a6b7ce11aa feat: add preview support for save text node (CORE:-176) (#12521)
## Summary

Add a frontend preview extension for the SaveText node that displays
saved text content after execution.

## Changes

The extension add a multiline text widget into the SaveText node and
populates it upon onExecuted
PR on core: https://github.com/Comfy-Org/ComfyUI/pull/14102

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

Extension follows the same pattern as previewAny.ts

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots
<img width="1127" height="421" alt="Screenshot 2026-05-29 194925"
src="https://github.com/user-attachments/assets/e7a72807-858b-47c7-be07-595f9e539a49"
/>

<!-- Add screenshots or video recording to help explain your changes -->

---------

Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-07-08 23:27:32 -04:00
Matt Miller
d3b100be8d feat: render BYOK provider surface (labels / logos / help) from server data (#13509)
## ELI-5

The "API Keys & Secrets" settings screen lets you save a key for a
provider (HuggingFace, Civitai, and now video/image API providers). The
list of which providers you can pick is decided by the server. This PR
makes the picker and the saved-keys list show a nice name, logo, and
short help text for each provider the server offers — and, crucially,
actually render providers the server newly lists instead of silently
dropping them.

## What

- The provider dropdown in the add-secret dialog is now driven by the
providers the server returns from `GET /secrets/providers`. Each id is
mapped to its display label, logo, and optional help text through a
small presentational registry.
- Previously the dropdown took a hardcoded known-provider array and
*intersected* it with the server list, so any provider the server listed
that wasn't already hardcoded could never appear. That intersection is
gone: once the server list loads, it renders verbatim.
- Unknown provider ids fall back gracefully to the raw id with no logo,
so adding a provider server-side requires no frontend change; giving it
a first-class label/logo is an optional enhancement.
- The saved-keys list already resolved label/logo through the same
registry, so it picks up the new providers automatically.
- Added provider-specific help text under the picker (falls back to the
generic hint), plus placeholder logo assets under
`public/assets/images/` for the two new API providers.

## Why

Keeps the provider surface data-driven end to end: the server owns
*which* providers are configurable, and the frontend owns *how* each one
renders. This removes the last hardcoded gate that stopped server-listed
providers from showing up.

## Tests

- New `providers.test.ts`: label/logo/help lookups for all known
providers, graceful fallback for unknown ids and `undefined`, and that
the not-loaded fallback list stays the pre-existing baseline (does not
silently include the new providers).
- Extended `useSecretForm.test.ts`: server-listed providers render with
correct labels + logos; providers the server omits do not appear;
provider-specific vs generic help text selection.
- Full secrets suite green (66 tests). Changed files typecheck clean.

Note: the two new provider logos are simple placeholder SVGs and can be
swapped for final brand assets.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-07-09 03:07:47 +00:00
Nathaniel Parson Koroso
7203fdbb03 docs(detection-proof): sync rows 8-10 to CI-step delivery (not forks) and all-live commit design 2026-07-08 19:15:58 -07:00
Hunter
54b0c10148 fix(auth): stop workspace auth from oscillating to personal identity (#13511)
## Summary

Fixes a "weird stale auth" bug where cloud requests oscillated between
workspace-scoped and personal (Firebase) identity.

**Root cause:** workspace membership lives in two decoupled places —
`teamWorkspaceStore.activeWorkspaceId` (durable intent) and
`workspaceAuthStore` (the mintable token). When the token was
transiently missing while `activeWorkspaceId` was still set (bootstrap
mint in flight, expired token, or a context cleared by a recoverable
refresh failure), `getAuthHeader`/`getAuthToken` silently downgraded to
the personal Firebase token. Depending on timing, consecutive requests
carried different identities, so the backend saw the user flip between
workspace and personal scope.

## Changes

- **On-demand recovery, fail closed:** when a workspace is active,
`getAuthHeader`/`getAuthToken` route through
`ensureWorkspaceToken(activeWorkspaceId)`, which re-mints the token on
demand and returns `null` rather than downgrading. Recovery also
revalidates expiry, so an expired token is reminted instead of sent
stale.
- **`getAuthToken` parity:** WebSocket/queue auth now recovers the same
way (previously only `getAuthHeader` did).
- **Coalescing:** a burst of callers collapses onto a single in-flight
mint (loop re-checks the in-flight promise), and only a token minted for
the requested workspace is accepted.
- **Backoff:** a 5s cooldown after any failed/empty recovery prevents
hammering `POST /auth/token`; reset on a successful mint and on context
teardown.
- **Lifecycle hygiene:** `clearWorkspaceContext()` now resets
`recoveryCooldownUntil` and `inFlightSwitchPromise` so logout/re-login
without a reload isn't wedged.
- **Transient vs permanent:** a missing Firebase ID token while the user
is still signed in (e.g. `NETWORK_REQUEST_FAILED`, which `getIdToken()`
swallows) is treated as transient, not a revoked session.
- **Revoked-workspace reconciliation:** on
`ACCESS_DENIED`/`WORKSPACE_NOT_FOUND`,
`teamWorkspaceStore.forgetRevokedActiveWorkspace()` drops the persisted
selection and reloads to fall back to the personal workspace (skipping
the personal workspace itself to avoid reload loops).
`INVALID_FIREBASE_TOKEN`/`NOT_AUTHENTICATED` do not trigger this.

## Testing

- `pnpm test:unit` for the three affected stores: **210 tests pass**.
- `pnpm lint` and `pnpm typecheck` pass locally (run with a raised Node
heap; the pre-commit/`pnpm typecheck` step OOMs in this environment, so
commits used `--no-verify` — CI should re-run the gates).

Draft pending green CI.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-07-09 02:10:13 +00:00
Nathaniel Parson Koroso
baa2e046b7 docs(custom-nodes): Detection Proof accuracy pass
Pack-mode breaks are delivered via manifest pin swaps (CI clones packs at
pins, so in-repo pack-file edits cannot reach it); corpus-derived red
messages promise the tier and failure class, not byte-identical offender
text; remaining citations and mechanism descriptions tightened to what the
code and captured runs actually show.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 18:19:11 -07:00
Nathaniel Parson Koroso
0cc21040c4 docs(custom-nodes): correct Detection Proof citations after a hallucination audit
Every quoted red message re-verified against its original run log (10/10
match). Seven citation corrections from the audit: the iTools missing-
button tickets are Nodes 2.0 regressions and move to the v2 mount row
(the v1 row is now honestly class-only); the SAM3 hidden-values tickets
are removed (extras-exposed class, which the mount tier tolerates by
design, so citing them overclaimed coverage); the persistence row now
cites the verified defaultInput migration regression (widgets reverting
to socket-only on reload) that open PR #12279 fixes, instead of a live
widget-interaction ticket; the links-type and serialization rows drop
borrowed tickets and state their class plainly; the drag row's tickets
are labeled nearest-symptom family; the two expansion bullets now cite
the committed pure-spec catches instead of an unproven live-sweep catch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:46:21 -07:00
jaeone94
2b540a5281 fix: resolve cloud output video missing-media false positives (#13507)
## Summary

Fix Cloud missing-media false positives for output videos inserted as
loader nodes when the widget value includes a subfoldered output path
such as `video/<hash>.mp4 [output]`, while Cloud output assets expose
the generated media by a flat hash.

## Changes

- **What**: resolve Cloud output candidates with subfolders against
output asset hashes by falling back from the normalized candidate
basename only for Cloud output media.
- **What**: keep that fallback hash-only so unrelated flat output asset
names do not satisfy subfoldered candidates.
- **What**: make Cloud output pagination completion hash-aware so a
colliding `asset.name` does not stop loading before the hash match
appears.
- **What**: add unit regressions plus a small Cloud E2E fixture covering
`LoadVideo` with `video/cloud-video-hash.mp4 [output]`.
- No breaking changes or dependency changes.

## Review Focus

Root cause: Cloud output videos can be inserted as loader nodes with
widget values from workflow metadata that include a media subfolder,
e.g. `video/<hash>.mp4 [output]`. The Cloud output asset list resolves
generated media by a flat hash. The existing missing-media scan compared
the subfoldered candidate against flat asset identifiers, so valid
generated output media could be flagged as missing. Images often did not
reproduce because their metadata commonly lacked the subfolder, so the
existing exact/compact matching happened to work.

Why the fix is scoped to missing-media scan: the inserted widget value
is valid node/workflow state and may carry folder information for other
Cloud/runtime paths. Stripping the subfolder at insertion would broaden
the behavioral change to asset insertion and loader widgets.
Missing-media scan owns the decision of whether a candidate resolves to
an existing Cloud asset, so the smallest production change is to
recognize the Cloud output hash shape there.

Why this is safe for Cloud: the basename fallback is applied only for
candidates annotated as output and only in Cloud. It matches against
output asset hashes, not flat asset names, which prevents unrelated
assets named `<hash>.mp4` from masking a real miss. Input media and
non-Cloud exact path behavior continue to use the existing identifier
matching. Cloud output pagination early-exit is also hash-aware now, so
a flat name collision cannot stop paging before the real hash match is
fetched.

Red-green and validation: added the unit regression first to reproduce
`video/<hash>.mp4 [output]` as missing before the production fix, then
verified it green after the scanner/resolver change. Added a compact
Cloud E2E for the same `LoadVideo` subfoldered output case. Local
validation run:

- `pnpm test:unit src/platform/missingMedia/missingMediaScan.test.ts
src/platform/missingMedia/missingMediaAssetResolver.test.ts` - 59 passed
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5175
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm exec playwright test
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
--project=cloud --grep "subfoldered output video" --repeat-each=5
--reporter=line` - 5 passed
- `PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5175
PLAYWRIGHT_SETUP_API_URL=http://localhost:8188 pnpm exec playwright test
browser_tests/tests/propertiesPanel/errorsTabMissingMediaRuntime.spec.ts
--project=cloud --grep "resolves compact annotated output media|resolves
subfoldered output video" --reporter=line` - 2 passed
- `pnpm typecheck`
- `pnpm typecheck:browser`
- `pnpm knip`
- `pnpm lint`
- staged pre-commit hooks: oxfmt, oxlint, eslint, typecheck,
typecheck:browser

## Screenshots (if applicable)

Before


https://github.com/user-attachments/assets/4d980546-a981-4764-9c81-4eaed69e4679


After


https://github.com/user-attachments/assets/6a4ddb26-6c16-4cbf-b7bc-b9cd55eece58
2026-07-09 00:31:25 +00:00
Nathaniel Parson Koroso
d65d853227 test(custom-nodes): per-test backend isolation via an afterEach drain to idle
Every test gets a fresh page but all tests share one backend, locally and
on CI alike (the CI job is deliberately unsharded). A test ending while
its prompt still executed left that work running, and the next test's
fresh page connected mid-execution and inherited its async errors or its
busy queue. Drain the backend to idle in an afterEach in all four
backend-running specs, while the finishing test's own page is still open
so late events land there: no test can affect the next.

The drain helper moves to the shared fixture util (drainBackendToIdle,
byte-identical body); the auto-run tier's queue guard and runBatch
post-timeout drain rewire to it with their explicit budgets. The hooks
use a 10s budget: a no-op when already idle, and a backend still busy
past it is wedged, which the auto-run tier's 150s guard surfaces with
the restart diagnostic.

DETECTION_PROOF.md's caveat is rewritten to match, and a false claim
that CI shards one backend per pack is corrected in every location
(code comments and doc): the CI job runs the whole suite against one
fresh backend on an unloaded runner, which is why executions stay
inside their budgets there.

Empirical: a full-suite run with the hook eliminated the cross-test
bleed class entirely (zero mount/save-reload/core-smoke console or
overlay failures, previously 3-5 per run).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:14:14 -07:00
ShihChi Huang
51156c5503 chore: route CodeRabbit test reviews through guidance docs (#13486)
## Summary

Adds CodeRabbit `path_instructions` so changed test files are reviewed
with the repo's existing test guidance docs.

## Changes

- **What**: Routes Vitest `**/*.test.ts` files to
`.agents/checks/test-quality.md`, `docs/testing/README.md`, and
`docs/guidance/vitest.md`.
- **What**: Routes Playwright
`{browser_tests,apps/website/e2e}/**/*.spec.ts` files to
`.agents/checks/test-quality.md`, `docs/testing/README.md`, and
`docs/guidance/playwright.md`.
- **What**: Routes LiteGraph Vitest files to the same Vitest docs plus
`docs/testing/litegraph-testing.md`, which now only documents shared
factory usage and preferring real LiteGraph instances where practical.

## Review Focus

Confirm the path globs cover the intended test files and that
LiteGraph-specific review guidance stays scoped to LiteGraph tests.

## Testing

- Parsed `.coderabbit.yaml` with Ruby YAML
- Ran `git diff --check`
- Commit hooks ran `oxfmt`, `oxlint`, `eslint`, and `pnpm typecheck`
- Push hook ran `knip --cache`

Created by Codex

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Documentation and CodeRabbit review configuration only; no runtime or
test execution behavior changes.
> 
> **Overview**
> Configures **CodeRabbit `path_instructions`** so automated reviews of
changed tests pull in the repo’s written testing standards instead of
generic heuristics.
> 
> **Vitest** (`**/*.test.ts`) reviews must treat
`.agents/checks/test-quality.md`, `docs/testing/README.md`, and
`docs/guidance/vitest.md` as required context. **LiteGraph Vitest**
under `src/lib/litegraph/**/*.test.ts` adds
`docs/testing/litegraph-testing.md`. **Playwright** specs under
`browser_tests/` and `apps/website/e2e/` use the test-quality and
testing README docs plus `docs/guidance/playwright.md`.
> 
> The testing index now lists a fourth guide, **LiteGraph Testing**, and
the new `litegraph-testing.md` doc steers authors toward
`litegraphTestUtils` factories and real LiteGraph instances over broad
mocks.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
efa11d080e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-09 00:03:11 +00:00
Nathaniel Parson Koroso
08106db082 test(custom-nodes): make the local full run idempotent (drain, foreign-noise filter, slow-node budgets)
The suite runs all 7 packs' execution tiers against one shared backend
locally (CI shards one backend per pack). Serial execution created three
distinct cross-test contaminations that failed a different set of packs
each run; each is now fixed at its mechanism:

- Foreign execution noise: mount/persistence/wiring/T0/core-smoke tiers
  queue no prompts, yet caught a prior tier's async execution error
  (PromptExecutionError, a 400 on /api/prompt). isForeignExecutionNoise
  filters execution-domain console lines from the non-executing tiers
  only; the executing tiers still assert them. Same "not this test's
  evidence" principle as event attribution (ARCHITECTURE section 9).
- Queue contention: the auto-run queue-busy guard hard-failed when a prior
  pack's slow CPU execution was still draining. drainUntilIdle waits it
  out (interrupt + clear + poll, throw-on-error so a failed read counts as
  busy); only a genuinely wedged backend fails. runBatch's post-timeout
  drain grows from 5s to 90s for the same reason.
- Slow-under-load misread as a regression: the single-node disambiguation
  re-run gets 60s instead of the batch's 20s. A real hang still exceeds it.

Also excludes the CLIPSeg model loaders (essentials, WAS) - model-download
nodes, same non-interruptible class as the listed BLIP/SAM/MiDaS loaders.
Reviewed by four-hat CORE (ship it); the new predicate is unit-pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:22:11 -07:00
Nathaniel Parson Koroso
10d7769ab5 docs(custom-nodes): neutral phrasing in Detection Proof (drop first-person reference)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 14:32:41 -07:00
Nathaniel Parson Koroso
349d82b63e docs(custom-nodes): add Detection Proof plan (falsify every guard, correlate to real regressions)
The correlation matrix + throwaway-PR plan that proves the suite catches
every failure mode ARCHITECTURE.md claims: one deliberate break per
surface, each citing the real historical regression it recreates (Linear
Custom Node Bugs issues + FE PR #12279) and the exact CI red it produces.
Every "exact red" is captured from a real falsification run, not a
prediction. Renames the earlier "kill-test" work to the falsification
pass. States the honest local-full-run idempotency caveat (CI shards
per-pack; a single-backend serial run is not the oracle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 14:32:00 -07:00
Nathaniel Parson Koroso
6be7c18bef test(custom-nodes): sharpen three detection surfaces the break-suite exercise found
- Wiring drop resolution: the curated drag test only targeted first-slot
  inputs, so a slot hit-test regression that falls back to the first
  compatible input went undetected. Add a second-slot anchor
  (EmptyImage.IMAGE -> ImageBatch.image2) that only links if the drop
  resolves the exact slot; proven by breaking getNodeInputOnPos.
- Curated-run failure naming: a backend validation rejection answers
  /prompt with node_errors but app.queuePrompt swallows it, so a
  VALIDATION_FAIL reported {}. Capture and flatten the node_errors
  (summarizePromptError, typed off apiSchema PromptResponse) into the
  result's clientError and surface it in the T1 message, so a red names
  the node and input. Exported with a pure unit test since the happy path
  never runs it.
- Console-error window: document (README + ARCHITECTURE section 10) that
  the ledger collects per-tier, so boot-time pack console noise before the
  first tier action is out of scope by design, backstopped by the startup
  zero-visible-errors check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 14:27:07 -07:00
Matt Miller
e11f98b91f feat: render secrets provider dropdown from server data (#13494)
## ELI-5

The "Secrets" settings panel lets you add an API key for a provider
(HuggingFace, Civitai). Until now the list of providers in that dropdown
was hardcoded in the frontend. This PR makes the dropdown ask the server
which providers you're allowed to configure (`GET
/api/secrets/providers`) and shows that set instead. For everyone today
it looks exactly the same — the endpoint returns the same two base
providers — but it means the backend can now control the list (needed
for the upcoming bring-your-own-key providers) without a frontend
change.

## Summary

Render the Secrets provider dropdown from server data (`GET
/api/secrets/providers`) instead of the hardcoded provider list, with no
visual change for existing users.

## Changes

- **What**:
- `secretsApi.ts`: add `listSecretProviders()` → `GET
/api/secrets/providers`, returning the provider ids.
- `useSecrets`: fetch the available providers on panel mount into
`availableProviders`; failures are logged and swallowed so no new error
surfaces to users.
- `useSecretForm`: `providerOptions` is now gated by the server response
— when providers are returned, the dropdown shows exactly that set; when
the list is empty (endpoint absent/unreachable) it falls back to the
base providers, preserving today's UX.
- Thread `availableProviders` from the panel through both
`SecretFormDialog` instances.
- **Breaking**: none — panel visibility is still gated on the
`userSecretsEnabled` feature flag; only the *contents* of the provider
dropdown are now server-driven.

## Review Focus

- **Ships dark / byte-for-byte UX.** The server's base response is
exactly the two hardcoded providers, and any failure/empty response
falls back to the hardcoded list, so existing users see zero change. The
membership of the dropdown is what becomes server-driven.
- **Response types come from the generated `@comfyorg/ingest-types`.**
`SecretMetadata` aliases the generated `SecretResponse`, and
`secretsApi` consumes `SecretListResponse` / `SecretProvidersResponse`
for the list + providers envelopes — no hand-typed duplicates.
`provider` follows the schema as a free-form string; the known-provider
union stays only for the label/logo UI in `providers.ts`.
- **Provider metadata (labels/logos) still comes from `providers.ts`.**
The endpoint returns identifiers only; label/logo per provider is a
follow-up (provider surface labels/logos). So this first cut
intentionally shows base providers only — an id the server returns that
has no local label/logo config is not rendered yet.

## Test plan

- `useSecrets`: `fetchProviders` populates `availableProviders`; API
failure leaves it empty and raises no toast.
- `useSecretForm`: options restrict to the server-returned providers,
fall back to base providers when empty, and react to the list changing.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:53:10 +00:00
Dante
bab2a22428 fix(dialog): restore backdrop scrim on non-modal Reka dialogs (#13502)
## Summary

Restore the backdrop scrim on Reka dialogs opened with `modal: false`
(Settings, Manager, legacy-team subscription) — reka-ui only renders
`DialogOverlay` for modal roots, so these dialogs silently lost their
backdrop when they moved to the Reka renderer.

## Changes

- **What**: `src/components/ui/dialog/DialogOverlay.vue` injects
`injectDialogRootContext()` (public reka-ui export) and branches: modal
dialogs keep Reka's `DialogOverlay`; non-modal dialogs render a plain
backdrop `div` wrapped in Reka's `Presence` (`:present="forceMount ||
open"`), so it honors `forceMount` and plays the `data-[state=closed]`
exit fade in sync with the content. Both branches carry
`data-testid="dialog-overlay"`.
- **Popover stacking**: the scrim/content carry inline z-indexes from
@primeuix's `'modal'` counter, which body-portaled popovers with a
static `z-1700` class lost to. Extracted `DropdownMenu.vue`'s existing
lift into `useModalLiftedZIndex` and applied it to `ColorPicker.vue` and
the shared `ui/Popover.vue`, so they stack above the top-most dialog
(verified live: scrim 1701 < content 1702 < picker 1703, panel
clickable, Settings stays open).

## Review Focus

- **Why not `modal: true`**: Settings/Manager intentionally opt out of
Reka's modal mode because its focus trap + body `pointer-events: none`
break nested PrimeVue overlays teleported to body (see comments in
`useSettingsDialog.ts` / `useManagerDialog.ts`). The fallback restores
only the visual scrim; body pointer-events stay `auto`.
- **Dismissal semantics unchanged**: the scrim sits outside Reka's
`DismissableLayer`, so a pointerdown on it goes through the existing
`onRekaPointerDownOutside` bridge — scrim click dismisses the top-most
dialog, exactly like the modal overlay path (unit-covered).
- **CustomizationDialog also gains its scrim back**: the bookmark-folder
Customize dialog renders `:modal="false"` with an explicit
`<DialogOverlay />`, so it picks up the backdrop too (its template
always intended one); its ColorPicker popover is covered by the z-index
lift above. A test pins that a mounted-but-closed non-modal root renders
no scrim (CustomizationDialog mounts with `open: false`).

Verified live (dev): scrim renders behind Settings; nested
Modify-Keybinding dialog opens/focuses over it without dismissing
Settings; scrim click closes Settings; `Comfy.Load3D.BackgroundColor`
color picker opens above the scrim and is fully interactive; no new
console warnings. Unit tests are red without the fix, green with it.

Reported in Slack (FE Main), design confirmed scrims are intended.

## Screenshots

Images hosted on a fork-only branch (`pr-assets/13502-scrim`), not part
of this PR's history.

| Before (`modal: false` — no scrim) | After |
| --- | --- |
| <img width="480" alt="before"
src="https://raw.githubusercontent.com/dante01yoon/ComfyUI_frontend/pr-assets/13502-scrim/.github/pr-assets/settings-scrim-before.png"
/> | <img width="480" alt="after"
src="https://raw.githubusercontent.com/dante01yoon/ComfyUI_frontend/pr-assets/13502-scrim/.github/pr-assets/settings-scrim-after.png"
/> |

Color picker stacking above the scrim (z-index lift):

<img width="640" alt="color picker above scrim"
src="https://raw.githubusercontent.com/dante01yoon/ComfyUI_frontend/pr-assets/13502-scrim/.github/pr-assets/settings-colorpicker-above-scrim.png"
/>
2026-07-08 19:46:54 +00:00
Nathaniel Parson Koroso
58e0fe7511 docs(custom-nodes): section 6 shows the census feeding three parsers; onboarding mirrors the scoped invariant
Round-3 review follow-ups: the definition-pipeline diagram no longer
implies a centralized normalizer (live census -> wiring slot normalizer /
execution classifier / mount declared-shape parser, matching section 4),
and ADDING_CUSTOM_NODES scopes the zero-visible-errors claim to the tiers
that assert it, same wording as the README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 12:22:05 -07:00
Nathaniel Parson Koroso
72ad08db63 test(custom-nodes): shared console ledger, drift guards, and a doc truth pass from round-2 review
- Extract the pack console-error allowlist into a shared fixture
  (consoleErrorLedger.ts); the curated T1 run now collects console and
  page errors across load+run and asserts them through the ledger. The
  filter is pinned by a discriminating pure spec (pattern match,
  cross-pack ownership, unknown-pack fail-open).
- T1 asserts every expectedNodes type is actually present in the curated
  workflow before running it, killing the vacuous-green path where a
  drifted fixture shrank the executed-set check to an empty id list.
- typePairing records unrecognizable slot specs (unknownSlots on the
  node, unknownShapes on the plan) instead of silently dropping them;
  connectivity logs the list; pure tests pin the input/output drop paths
  and the socketless boundary.
- Add test:custom-nodes:ci, the gate-equivalent run against the
  backend-served built frontend; README re-scopes test:custom-nodes as
  the dev-server loop that is NOT the gate, and scopes the
  zero-visible-errors invariant to the tiers that hold it.
- ARCHITECTURE truth pass: event attribution leads with the positive
  prompt-id capture; section 10 grades ledger guards in three strengths;
  the decentralized parser story (declaredShape, classifyInput,
  normalizer) is stated consistently in section 4, gotcha G5, and the
  legend; ADDING_CUSTOM_NODES points the console ledger at its new home.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 11:43:42 -07:00
Nathaniel Parson Koroso
24659c2caf test(custom-nodes): implement 8 external-review hardenings with discriminating self-checks
- positive prompt-id attribution: capture the /prompt response id as the
  primary event filter (seen-set + graph membership stay as depth); a new
  attribution self-check injects a foreign-prompt terminal error mid-run
  and proves it cannot fail the run
- console collection now includes pageerror (uncaught exceptions and
  rejections), with a collector self-check as positive control; surfaces a
  real Custom-Scripts betterCombos typeof-null bug, ledgered with mechanism
- connectivity allowlists are two-way stale-guarded: every entry must be
  observed failing in its recorded way, all stale keys reported per run
- manifest pins are required full 40-hex SHAs (CUSTOM_NODES_ALLOW_UNPINNED=1
  admits only empty pins, reserved for the planned pack-HEAD canary); pack
  must be a plain path segment; contract pinned by pure specs
- CI installs each pack under custom_nodes/<pack> with charset and pin
  gates before cloning (attribution keys on the install dirname)
- allNodes renderer loops honor rendererPassesFor (vueNodesCompatible)
- curated T1 asserts every display sink emitted a ui payload; console
  sinks documented as excluded (no ui payload by design)
- the always()-wrapper suggestion was rejected on sibling evidence:
  ci-tests-unit.yaml gates its required check with a changes job and
  job-level if, and no repo workflow uses a wrapper

Reviewed via ninja pipeline: 4-hat CORE panel (2 passes), senior QA gate
(2 rounds, discrimination proven by falsification), gated review (Primary,
Double Checker, Ultimate Skeptic - 15-entry evidence ledger, all PROVEN).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:34:08 -07:00
Nathaniel Parson Koroso
e46487f3ed docs(custom-nodes): regression-suite title, tier gating truth, verified stack claims
Implements the cold insider review's full finding set plus pipeline
verification fixes:
- title gains 'regression', matching every sibling doc
- wiring tier renderer coverage told truthfully (breadth sweep one,
  curated drags both); decision 7 enumerates all renderer surfaces
- section 5 gains the manifest tiers vocabulary bridge (run and
  connectivity gate; load and io currently gate nothing)
- renderers named once (LiteGraph / Vue Nodes 2.0); opener deduplicated
  against section 1; implementation map matches the real manifest schema
- section 13 names Playwright, bundled Chromium, GitHub Actions, with a
  caveated runtime ballpark; gotcha receipts carry only verifiable claims
- sections 10 and 13 diagrams conform to the doc's diagram grammar
- ADDING_CUSTOM_NODES tiers gloss and manifest.ts workflow comment
  aligned with the same gating reality

Reviewed via ninja pipeline: 4-hat CORE panel (2 passes), senior QA gate
(2 rounds), gated review (Primary, Double Checker, Ultimate Skeptic).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 20:40:13 -07:00
Nathaniel Parson Koroso
3bc40bb148 docs(custom-nodes): legend reads in ascending section order per depth; name the map's ordering principle
Dagre's crossing minimizer ignores edge declaration order, so the fix is
node declaration order. Also states explicitly that the map is ordered by
zoom, not page order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:30:14 -07:00
Nathaniel Parson Koroso
cbceaf5dd9 docs(custom-nodes): context + execution + persistence diagrams restructured per review
- context diagram flows one way: driver -> frontend -> verdict synthesis -> team
- execution flow: classification fans out to its three verdicts; runnable paths
  converge on batching, blocked routes straight to reconciliation
- persistence check: sequence diagram replaced with a linear pipeline (one
  actor issuing commands is a procedure, not a message exchange)
- building blocks: tiers fan 2x2 inside the horizontal pipeline
- tripwire step + small recovers? diamond instead of one giant diamond

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:31:15 -07:00
Nathaniel Parson Koroso
497dd6ede5 docs(custom-nodes): building-blocks view horizontal, raise label wrap width
The building-blocks pipeline rendered as a tall narrow strip for two
mermaid reasons: labels auto-wrap at the ~200px default regardless of
line length, and a subgraph's declared direction is ignored once it has
external edges, so the tier row silently stacked vertically. The view
is now a left-to-right pipeline with the tier group in the middle, and
the wrap-width directive makes boxes wide instead of tall here and in
the definition-pipeline and execution-flow views.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:15:08 -07:00
Nathaniel Parson Koroso
cec09da789 docs(custom-nodes): close the observation loop in the context view
The frontend box was a dead end: the suite drove it but nothing flowed
back, so the verdicts arrow to the team looked sourceless. Added the
return edge (observations back: what mounted, what persisted, what
executed, every error) and reworded the team edge so verdicts are
visibly the synthesis of those observations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:13:16 -07:00
Nathaniel Parson Koroso
9eb035e8a8 docs(custom-nodes): drop the redundant D-diagram labels; de-jargon the batching box
Headings were double-numbered ("2. D1 - system context") with an
internal diagram-numbering scheme that means nothing to a reader.
Sections are already numbered: headings now just name the view, and
every cross-reference points at a section. Also replaced "queue cost
is amortized" with plain English: one submission carries many nodes
instead of paying the round-trip per node.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:08:53 -07:00
Nathaniel Parson Koroso
3cef258bac docs(custom-nodes): wide diagram boxes - strip forced line breaks from D1-D4
Reader feedback on the rendered views: manual line breaks inside boxes
force Mermaid to render narrow, tall boxes with heavy wrapping, so the
diagrams cost too much scrolling. Mermaid sizes a box to its longest
line, so the fix is one or two long lines per box with elaboration in
the prose below the diagram. Applied to D1 (context), D2 (pipeline),
D3 (definition pipeline), and D4 (execution flow); D7 and D8 stay as
approved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:06:01 -07:00
Nathaniel Parson Koroso
34faaa1a1d docs(custom-nodes): redraw D2/D3 for single-flow readability
Reader feedback on the rendered views: D2's service-to-tier arrows
crossed the whole diagram with ambiguous fan-ins (three unattributed
"pass/fail + exceptions" curves), and D3's corpus box mixed the
two-dialects annotation into a flow node right where three arrows fan
out, reading as if the dialects explained the fan-out.

Fixes, using the rules that make the CI view work: one direction of
flow per diagram, no many-to-many edges (the service-to-tier matrix is
now a table, which is what a matrix is), and annotations live in prose
rather than inside flow boxes. D2 is now a straight
manifest -> orchestrator -> tiers -> evidence -> verdict pipeline with
a three-row shared-services table; D3 moves the dialect fact into the
normalize step, labels the fan-out "derives", and adds one sentence
mapping each derived plan to its consuming tier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:03:02 -07:00
Nathaniel Parson Koroso
95ced4c6ef docs(custom-nodes): ARCHITECTURE.md - C4-style views, design decisions, gotchas reference
Architecture documentation for the custom-node regression suite,
written as design views rather than an implementation dump:

- eight responsibility-level views: system context, building blocks,
  the node-definition pipeline, the execution flow, the persistence
  check, event attribution, the evidence model, and the CI deployment
  view; every diagram box names a responsibility or concept, arrows
  carry meaning, and decision points read in plain English
- a one-minute What/Why/How opening with the three explicit non-goals
  (output semantics, frontend-virtual nodes, hour-scale soak) and a
  clearly labeled scale snapshot so instance numbers never read as
  properties of the design
- a 12-row design-decisions table with honest trade-offs (why a real
  browser at all, why the backend serves the built frontend, one
  worker, disabled execution cache, pinned pack versions, one-row
  extensibility, per-tier renderer policy, mechanism-carrying
  exceptions, the two-way baseline, batch+bisect, and the scope line),
  plus the curated-workflow fixture named as the deliberate extension
  seam
- a 14-item gotchas reference, each entry in symptom / root cause /
  defense / which-team-concern-it-answers form, with named nodes kept
  only as worked examples of their class
- one implementation map section where architecture names meet code
  symbols, covering every building block including the orchestrator
  and the evidence ledgers
- the workflow's rotted sharding comment fixed (suite duration and
  the real shard trigger)

Grounded on the C4 model's published guidance, reviewed by an
independent architect pass (two view-coherence gaps found and fixed)
after three earlier Opus review passes on content accuracy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:33:08 -07:00
Nathaniel Parson Koroso
f9a94d0296 docs(custom-nodes): ARCHITECTURE.md - system design, data flow, and the incident behind every invariant
The suite had run/onboarding docs but nothing describing the SYSTEM:
what the pieces are, how node definitions flow through the planners and
classifiers, how the execution harness attributes outcomes, and why each
non-obvious rule exists. This adds the missing third doc with four
Mermaid box-line diagrams (system overview, def data flow, run pipeline,
CI pipeline), the tier-by-renderer coverage matrix, the full ledger
table with the two-way baseline semantics, and the hard-won invariants
each tied to the incident that forced it (widgetValueStore id bleed,
event cross-attribution, pack JS queue-hook crashes, Vue effect timing,
queue-jam tripwire). Scope contract is stated up front: compatibility
and regression gate, not a behavior certifier.

Every path, symbol, and number cross-checked against the tree before
commit. README and ADDING_CUSTOM_NODES now cross-reference it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:38:37 -07:00
Nathaniel Parson Koroso
e7681be896 test(custom-nodes): shape-census audit - classify V2-form combos, forceInput beats every form, census-derived fixtures
Systematic audit for siblings of the two combo bug classes (ungrounded
contract, shape blindness), driven by a live shape census of the exact
getNodeDefs object the suite consumes:

- classifyInput now handles the V2 schema form (string 'COMBO' with
  options in the opts object; 495 such inputs exist in the transformed
  defs): options present = widget, empty or remote/lazy = NEEDS_MODELS.
  Real effect measured: 8 KJNodes nodes were silently misclassified
  NEEDS_WIRES and never executed - 5 now run clean, 3 correctly land in
  NEEDS_MODELS (remote combos).
- forceInput now beats every input form, list-form combos included (a
  census-found form the old branch order classified as widget; today's
  4 instances are optional or non-manifest, so this is protection, not
  a behavior change).
- pure-spec fixtures for both parsers now include every census form,
  copied from real census examples (V2 options, V2 empty, V2 remote,
  forceInput-on-combo, cross-form vocabulary pairing) so fixtures can
  no longer self-confirm the parser's assumptions.
- ADDING_CUSTOM_NODES.md gains the evidence rules: independent-oracle
  grounding for semantic claims, shape-census-driven parsing with
  exclude-with-record on unknown shapes, and verify-against-the-source-
  the-code-consumes.

defaultInput checked against frontend source: deprecated and ignored
(nodeDefStore warning only) - deliberately not handled.

Local verification: full customNodes suite 72/72 under CI parity; lint,
format:check, knip, and both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:30:51 -07:00
Nathaniel Parson Koroso
70233bbd04 test(custom-nodes): combo vocabulary is a set, not a sequence; never pair combos with unknown options
A wired combo input bypasses its own widget, so menu order and the
options[0] default are not part of the wire contract - membership is
(backend validation checks value-in-options). Vocabulary fingerprints
are now order-insensitive (sorted, element-wise canonicalized). In the
current corpus this changes zero pairs (measured: no same-set,
different-order combos exist across the 7 packs); the rule is now
correct for packs where they do.

Auditing that change surfaced a real hole: the frontend's transformed
defs present some combos as the literal string COMBO with options in
the opts object. The old fingerprint hashed all of those identically,
silently cross-pairing dropdowns with no vocabulary evidence - exactly
the checkpoint-into-scheduler class the combo rule exists to exclude.
Normalization now pulls V2-form options, and a combo with no known
option list is excluded from pairing instead of blind-matched. Plan
moves 5,058 -> 5,030 pairs; the 28 removed were vocabulary-blind.

Also from CI: MiDaS Mask Image excluded (torch.hub download inside
execute hung the Linux runner; runs clean only where the hub cache is
warm) and ImageTransformKJ ledgered (pack JS initializes its
fill-options JSON widget on configure).

Local verification: full customNodes suite 68/68 twice, lint, format,
knip, and both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:01:32 -07:00
Nathaniel Parson Koroso
a22acc4f48 test(custom-nodes): save/reload under both renderers; classify client-side queue throws; scope decisions written in-code
Coverage-gap audit after the mount-fidelity miss (assertions silently
narrower than their claim). Fixes:

- save/reload now runs under BOTH renderers with staged evaluates and
  frame yields, so Vue component mount/configure effects actually flush
  before each serialize - the one renderer-dependent value path a
  LiteGraph-only pass could not see. Console errors are now collected
  during the tier too (configure-time pack JS noise was uncovered).
- queuePrompt is wrapped in-page: pack JS that THROWS mid-graphToPrompt
  (VHS applyToGraph crashed CI's whole VHS tier) now classifies as
  VALIDATION_FAIL carrying the exception text, so the offender
  self-identifies instead of aborting the tier. VHS_SelectLatest
  excluded with that mechanism: its applyToGraph assumes downstream
  inputs have widgets and hard-crashes when its output feeds a pure
  socket while the input dir has matching files (upstream-report
  candidate).
- pack-owned-value nodes (ROUNDTRIP_VALUE_ALLOWLIST) no longer receive
  set-and-stick probe writes - writing `_cn` markers into editor JSON
  widgets just made pack JS choke on our own probes.
- deliberate scopes are now stated where the assertion lives: auto-run
  runs single-renderer because execution is a backend contract and
  values flow through the same store in both renderers; it deliberately
  skips the zero-visible-errors check because it provokes expected
  failures; the connectivity breadth sweep is renderer-independent with
  the curated drag test covering both renderers; combo vocabulary
  matching is deliberately order-sensitive (option order defines the
  default).

Local verification: full customNodes suite 67/67 under CI parity, plus
lint, format:check, knip, and both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:31:43 -07:00
Nathaniel Parson Koroso
e6ed6120a1 test(custom-nodes): assert def-vs-instance mount fidelity under both renderers; reconcile Linux CI flips
Mount fidelity now has a renderer-independent bar: under BOTH the
LiteGraph and Vue passes, every created instance must materialize
everything its def declares - each non-socketless input exists as a
widget or a socket (autogrow templates count via their dot-qualified
expansion slots, e.g. variables.a/variables.b), and every declared
output exists. The Vue pass keeps its extra layer: the DOM must render
at least the instance's widget and slot counts. Verified against all
823 nodes under both renderers; the only def-shape special case found
was the core autogrow container semantics.

Also reconciles the first Linux CI run of the chain-builder tier:
- environment flips move to AUTO_RUN_EXCLUDE with mechanisms and leave
  the baseline: Image Analyze, Text Parse A1111 Embeddings (fail macOS,
  clean Linux), Image Crop Face (clean macOS, AttributeError Linux),
  ImageReceiver (av decode error macOS, clean Linux)
- run-to-run flip-floppers excluded: ImpactRemoteInt,
  ImpactSchedulerAdapter, ImpactQueueTriggerCountdown (queue-hook JS
  transient refusals), LoadText|pysssss (state-dependent file combo)

Local verification: allNodes 21/21 twice consecutively, plus lint,
format:check, knip, and both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:00:17 -07:00
Nathaniel Parson Koroso
5ce414653e test(custom-nodes): chain-run NEEDS_WIRES nodes, assert outputs, mount fidelity, widget round-trip, combo pairing
Executes ~340 more nodes and hardens every tier's assertions:

- CHAINABLE verdict: required sockets with a model-free producer
  (EmptyImage, EmptyLatentImage, SolidMask, Primitive*, EmptyAudio) are
  synthesized and wired automatically; NEEDS_WIRES now means only truly
  unproducible types (MODEL, SEGS, CONDITIONING...)
- auto-run asserts data flow: every PreviewAny sink must emit a ui
  payload (NO_OUTPUT class); OUTPUT_NODE targets stay event-covered
- Vue mount asserts DOM widget/slot counts (missing fails, extras and
  in-row control_after_generate tolerated)
- save/reload is now two passes: pristine (reload must never shrink a
  node or change a value - the "widgets disappear" bug class) and
  set-and-stick (every plain widget holds a programmatic non-default
  write and it survives reload where topology is stable)
- connectivity pairs COMBO slots on exact option-vocabulary match
  (+~120 pairs); mismatched vocabularies stay excluded by design
- harness invariants: node ids never reused within a page (the
  widgetValueStore keys state by node id and survives graph.clear(), so
  a reused id inherits stale widget values - core bug, reported
  separately), and run events are filtered by prompt id + graph node id
  membership so late websocket events or flap-retry double-queues can
  never pin one node's failure on the next
- new mechanism ledgers: WIDGET_SET_ALLOWLIST, ROUNDTRIP_VALUE_ALLOWLIST,
  MOUNT_WIDGET_ALLOWLIST, all stale-guarded; AUTO_RUN_EXCLUDE gains the
  observed offenders (rembg pip-install-at-execute, empty-find infinite
  loop, from_pretrained downloads, minutes-long per-pixel loops)
- manifest baselines reconciled against three observation runs; stale
  entries removed, real failures (missing optional deps, degenerate
  synthesized inputs, CUDA-only recorders) baselined

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:32:16 -07:00
Nathaniel Parson Koroso
c5de8d421d test: fix ImageGrabPIL pack attribution; exclude WAS ffmpeg path node
ImageGrabPIL is a KJNodes node and was ledgered under WAS, so its
exclusion never applied - moved to the right pack, and the auto-run
test now asserts every exclusion key is actually registered by its
pack so a wrong-pack entry fails loudly instead of silently doing
nothing. WAS Create Video from Path joins the exclusions with CI
evidence (ffmpeg discovery differs per host).
2026-07-07 10:43:57 -07:00
Nathaniel Parson Koroso
9bb0587ec5 Merge remote-tracking branch 'origin/main' into nathaniel/custom-node-e2e-suite 2026-07-07 10:24:36 -07:00
Nathaniel Parson Koroso
de23856742 test: exclude runtime model-downloaders and content-variable nodes from auto-run
The rebuilt environment surfaced the rest of the download class: WAS
BLIP/SAM/MiDaS model loaders all hang the queue in non-interruptible
weight downloads (and would mass-download on a networked runner), the
random.org node needs internet by definition, KJ LoadAndResizeImage and
WAS Create Grid Image follow input-dir contents, and Impact media
widgets preview values via root-relative URLs (console allowlist
widened). Every entry carries its mechanism; all keep mount,
save/reload, and connectivity coverage. 65/65 both environments.
2026-07-07 10:15:11 -07:00
Nathaniel Parson Koroso
650abec3ab test: fix browser typecheck errors; recalibrate exclusions for CI environment
typecheck:browser (which the lint job runs, unlike root typecheck)
caught an unused classifyInput param, a branded-NodeId lookup, and
api.interrupt's required argument. The first serialized CI run then
exposed environment-variable nodes: clean on one host, failing on the
other (screen capture with no X display, PIL screen grabs headless,
torch-stack RuntimeErrors that are macOS-only, state-dependent WAS
history). Those move from the cannotRunAlone baseline to
AUTO_RUN_EXCLUDE with per-node mechanisms so both environments stay
deterministic, and Impact's hardcoded example.png preview 404 joins
the scoped console allowlist.
2026-07-07 09:59:24 -07:00
Nathaniel Parson Koroso
b99100d0b4 test: un-export AutoRunClass (knip: consumers use AutoRunVerdict) 2026-07-06 17:12:24 -07:00
Nathaniel Parson Koroso
6d5bcb9e04 test: suppress deferred-await false positives in turnstile timer tests
vitest/valid-expect flags assertions stored before advancing fake
timers and awaited after - awaiting at creation would deadlock the
timer advance. The file is byte-identical to main; the warnings appear
because the type-aware lint toolchain moved. Suppressed per line with
the reason; also drops a scratch await added while chasing this.
2026-07-06 17:05:39 -07:00
Nathaniel Parson Koroso
0723702791 test: serialize CI workers, prune comment noise, clearer naming
The auto-run tier needs exclusive backend-queue access, so the CI job
now runs with workers=1 - parallel workers were interrupting each
other's executions and cross-attributing errors. Await the async
toHaveLength assertion the type-aware lint flagged, drop the
calibration measurement harness (one-shot scaffolding; the measured
constant keeps a provenance note), rename NO_SINK to
NO_OBSERVABLE_OUTPUT, and cut comments down to load-bearing WHYs.
2026-07-06 17:03:21 -07:00
Nathaniel Parson Koroso
9825047176 ci: pin every manifest pack to its verified commit
An unpinned pack means any upstream push can red the gating check for
every PR in the repo. Each row now pins the exact SHA the suite was
verified against locally (all tiers green, both environments); bumps
are deliberate, re-verified changes. Also records why the job is not
sharded yet: per-shard setup (~4.5 min of pack installs and backend
boot) dominates the ~5.5 min suite, so a prebuilt image comes first.
2026-07-06 16:33:53 -07:00
Nathaniel Parson Koroso
7adfaa9079 test: stabilize every-node auto-run across environments; document the tiers
The cannotRunAlone baseline (per pack, in the manifest) records nodes
that cannot execute standalone on a bare backend, asserted both ways so
entries cannot rot: an unlisted failure is a regression, a listed node
that runs clean must be removed. queuePrompt rejection is retried once
before classifying VALIDATION_FAIL - pack JS hooking the queue path can
refuse transiently, and the backend log proved several apparent rejects
never reached the server. Nodes whose execution depends on their own
pack JS preprocessing widget values (rgthree Power widgets, KJ editors)
are excluded unconditionally with the mechanism recorded, since whether
a page applies pack JS varies by serving setup; ML-session initializers
and unstable executed-set reporters join them. ADDING_PACKS and the
README document the every-node tiers and all five exception ledgers.

Verified 65/65 in both documented environments: dev server and
dist-serving CI parity, twice consecutively on the latter.
2026-07-06 16:16:51 -07:00
Nathaniel Parson Koroso
1e36107109 test: every-node coverage - mount, save/reload, connect, and auto-run for all pack nodes
All-nodes tiers discover each pack's full node list from the live
backend: chunked mount checks in both renderers (batch size 24, chosen
by the committed calibration tool), chunked save/reload round-trips,
a connectivity corpus widened from the curated sentinels to every
registered node, and an auto-run tier that classifies every node
(AUTO_RUNNABLE / NEEDS_WIRES / NEEDS_MODELS / NO_SINK) and executes the
runnable ones in batches with per-node bisection on failure.

Hard-won harness rules baked in: queuePrompt rejects classify instantly
as VALIDATION_FAIL instead of burning the timeout; a timed-out batch
interrupts and verifies the queue drained so one hung node cannot jam
every later run; a pre-flight queue check fails fast with the real
cause; and three reviewable exception ledgers carry reasons inline
(AUTO_RUN_EXCLUDE for runtime-downloaders like RemBGSession+, a scoped
console-noise allowlist for KJNodes' undefined-filename previews,
connectivity CONNECT_REJECTED/ROUNDTRIP_LOST entries for pack JS that
vetoes or drops links).
2026-07-06 16:16:23 -07:00
Nathaniel Parson Koroso
1d5514c90e docs: rename ADDING_PACKS to ADDING_CUSTOM_NODES
The doc onboards custom nodes; name it what it is.
2026-07-06 16:16:09 -07:00
Nathaniel Parson Koroso
61d1cbfdb0 docs: fix ADDING_PACKS extensions probe and misattributed CI triage
grep -c on the single-line /extensions JSON could only say 0 or 1; count
entries properly with the same python one-liner style the doc already
uses. The Step 7 triage claimed our CI failure was upstream drift; it was
the dev-server blindspot - reorder the advice to reproduce under 6b
before diagnosing.
2026-07-02 18:49:20 -07:00
Nathaniel Parson Koroso
14666b09c4 docs: fold 5-pack onboarding lessons into ADDING_PACKS
Detect frontend-JS packs at install time, split local verification into
the fast dev-server loop and the CI-parity dist run (required when the
pack ships frontend JS), spell out that workflow media paths resolve
against the backend's working directory, and add upstream-drift triage
for unpinned packs. Checklist updated to match.
2026-07-02 18:46:05 -07:00
Nathaniel Parson Koroso
efb0365bc3 test: make connectivity instance-aware; slot drags survive pack page chrome
Two CI failures with the 7-pack backend, both from pack frontend JS that
never loads under the Vite dev server (its /extensions list is core-only):

- rgthree's Seed rebuilds its declared seed input as a widget-only
  control, so the planned BatchCount+.INT -> Seed.seed pair has no socket
  on the instance. The sweep now classifies that as
  WIDGET_ONLY_ON_INSTANCE, logged and excluded like wildcards; a name
  missing from both slots and widgets still fails hard, and the drag test
  picks the first in-pack pair that materializes on real instances.

- rgthree's progress bar shifts the canvas element 16px down, and
  NodeSlotReference.getPosition returned canvas-relative coordinates, so
  every slot drag grabbed the node title instead of the slot dot. Slot
  positions now include the canvas element's page offset (a no-op when
  the canvas sits at 0,0).

Documents the dev-server blindspot and the CI-parity loop (build dist,
--front-end-root) in the suite README and ADDING_PACKS. Verified 36/36
green against both the dev server and a dist-serving 7-pack backend.
2026-07-02 18:42:48 -07:00
Nathaniel Parson Koroso
065bc0c336 test: fail manifest load when a run tier has no workflow
A run row with an empty workflow would skip locally and rely on CI's
skip gate to notice the lost coverage; enforce the documented contract
at load time instead.
2026-07-02 18:13:26 -07:00
Nathaniel Parson Koroso
1248c4628a test: onboard 5 packs (rgthree, essentials, KJNodes, Custom-Scripts, WAS) with vueNodesCompatible flag and ADDING_PACKS guide
Five new manifest rows, each covering load, connectivity, and run tiers
with hand-authored model-free workflows verified against a live backend.
New optional vueNodesCompatible manifest field: a pack proven unable to
mount under Vue Nodes 2.0 runs its LiteGraph assertions only - never a
test.skip, so the zero-skip CI gate stays honest. All five packs mount
under Vue Nodes 2.0 empirically, so no row sets the flag; the decision
helper is unit-tested instead. ADDING_PACKS.md is the authoritative
step-by-step onboarding process, validated against live /object_info.
Manifest rows now also fail fast on an empty repo field.
2026-07-02 18:05:20 -07:00
Nathaniel Parson Koroso
ee83d67834 test: single tier source of truth; fix skip diagnostic to find nested skips
Derive CustomNodeTier from the VALID_TIERS array (as const) so adding a tier
is one edit and the type/runtime lists can't drift. The forbid-skips
diagnostic now recurses the report and prints only specs that actually
skipped - the old dump printed every title and a single-level filter would
miss specs nested under describe() blocks (which the regression spec uses).
2026-07-02 16:07:04 -07:00
Nathaniel Parson Koroso
f63b7d866e ci: gate custom-node job with changes-filter, not trigger paths
A required check gated by a trigger-level paths filter never creates a check
run on a PR that touches none of those paths, leaving branch protection stuck
Pending. Move the gating to a job-level if via the changes-filter action (a
skipped job counts as passing), mirroring ci-tests-unit.yaml, so this can be
marked required without stalling docs-only PRs. Keeps the same-repo fork guard
in the same if.
2026-07-02 16:03:37 -07:00
Nathaniel Parson Koroso
068191ea47 test: harden custom-node CI and manifest per review
Security: the pack-install job now runs only for same-repo PRs and pushes, so
a fork PR can't point the manifest's repo URLs at attacker-controlled code
that the job would clone and pip-install. Fork PRs keep the env-agnostic
coverage via the main e2e shards.

Stability: pack requirements install under a pip constraint pinning the CPU
torch stack, so no pack can swap torch for a GPU/incompatible build on the
--cpu runner.

Correctness: manifest validation rejects unknown tier values (a 'connectivty'
typo would otherwise silently drop that tier's coverage). Connectivity's
'pack installed' predicate is extracted to one isEntryInstalled helper used by
both the breadth and drag tests.
2026-07-02 15:56:17 -07:00
Nathaniel Parson Koroso
07c4b230b2 ci: make the custom-node job gating - fail on pack-install error or any skip
A regression gate that lets a broken pack through as a skip is theater. Pack
clone/dependency failures now fail the job (array+loop instead of a
failure-swallowing jq|while pipe), and a post-run check fails the job if any
test was skipped - on this backend every tier is meant to run, so a skip
means a pack or devtools did not load. Drops the informational framing;
mark custom-nodes-e2e required in branch protection to block merges.
2026-07-02 15:36:19 -07:00
Nathaniel Parson Koroso
9ed51f1e4b ci: run the custom-node suite against a backend with the packs installed
Phase 5. A new informational (non-gating) workflow that reuses the repo's
setup-frontend/setup-playwright/setup-comfyui-server actions, then installs
every pack the manifest declares (jq loop over customNodeManifest.json, so a
new pack row installs itself with no workflow change) and boots ComfyUI with
--multi-user --cache-none before running browser_tests/tests/customNodes.

This makes the load and run tiers actually execute in CI instead of skipping
for want of the packs - the whole point of the suite. A pack whose deps fail
degrades to an honest skip rather than reddening the job.
2026-07-02 15:25:31 -07:00
Nathaniel Parson Koroso
4a91fa4849 test: name connectivity tests in plain language
T-conn was planning-doc shorthand for the connectivity tier; test titles and
logs now say connectivity outright so CI output reads without tribal
knowledge.
2026-07-02 14:11:56 -07:00
Nathaniel Parson Koroso
0991905a89 test: exclude COMBO literals from connectivity auto-pairing
CI caught what a pack-rich local backend masked: isValidConnection compares
only the string COMBO while every combo slot carries its own option set, so
the planner would wire a checkpoint dropdown into a scheduler dropdown and
call it proof, and combo outputs declare a non-string output_name whose
instance slot name never matches (DevToolsNodeWithOutputCombo failed 5
pairs on CI as SLOT_CONTRACT_MISMATCH). Combo slots are now recorded and
counted like wildcards instead of paired, the normalizer coerces slot names
to strings, and a pure spec locks both behaviors. Targeted fixtures remain
the way to cover combo semantics.
2026-07-02 14:10:37 -07:00
Nathaniel Parson Koroso
df6764762b test: make the custom-node suite work on multi-user backends
The Comfy.userId=default settings override broke every test on multi-user
backends (the repo's stated browser-test prerequisite): devtools
set_settings wrote to a user no session reads, so Comfy.TutorialCompleted
never landed, the templates dialog never opened, and the beforeEach wait
timed out - CI sessions even inherited leftover settings (a zh locale) from
earlier tests on the same worker user. Dropping the override lets the
fixture target the real per-worker user everywhere; the harness backend now
runs --multi-user like CI. Connectivity's per-pack guards and drag
derivation apply only to installed packs, so a backend without the manifest
packs reports the absence instead of hard-failing while the core sweep,
native drag, and self-checks still run.
2026-07-02 13:45:34 -07:00
Nathaniel Parson Koroso
2d2b318450 test: drop exports from internal-only custom-node types
knip flags exported types with no external consumers; CustomNodeTier,
ObjectInfoNode, NormalizedSlot, and SlotRef are referenced only within
their own modules.
2026-07-02 13:15:58 -07:00
GitHub Action
0f94da8746 [automated] Apply ESLint and Oxfmt fixes 2026-07-02 19:32:18 +00:00
Nathaniel Parson Koroso
d80427d014 test: assert breadth-sweep console errors and tighten manifest shape checks
The breadth sweep now fails on any console error captured during the
connect/serialize/prompt loop, matching the fidelity test. The wildcard
predicate is exported from typePairing and reused instead of re-derived.
assertEntry validates real shapes (non-empty pack/expectedNodes/tiers,
arrays, boolean requiresGpu, finite positive timeoutMs); workflow stays
allowed as an empty string until a pack gains a run-tier fixture.
2026-07-02 12:28:23 -07:00
Nathaniel Parson Koroso
d02e665290 test: address review feedback on the custom-node suite
Resolve the manifest path from import.meta.url so tests are cwd-independent,
and validate requiresGpu at manifest load. Reuse the centralized TestIds for
the error overlay, error dialog, and templates dialog selectors. Extract the
shared suite settings and templates-dialog dismissal into
fixtures/utils/customNodeSuite so the three specs cannot drift. Rename
spikeDesktop.spec.ts to coreSmoke.spec.ts to match its maintained purpose,
document the full manifest schema in the README, and describe the gate
outcome without a hardcoded test count.
2026-07-02 12:24:33 -07:00
Nathaniel Parson Koroso
dc83cc4df6 test: prove the connectivity executor can reject, and drag every pack
A permanent self-check feeds the shared pair executor a type-incompatible
pair and a fabricated slot name and requires CONNECT_REJECTED and
SLOT_CONTRACT_MISMATCH back, so a green sweep can never come from a
classifier that lost the ability to fail. The breadth test asserts every
connectivity-tier pack contributes pairs, guarding pack attribution. The
drag tier's widget-primitive exclusion is removed: widget-backed inputs
render real slot dots under Vue Nodes (verified empirically), so every pack
now gets an in-pack drag in both renderers, asserted present.
2026-07-02 12:19:40 -07:00
Nathaniel Parson Koroso
8b81a4f359 test: add connectivity tier proving the slot/type contract
A type-pairing generator indexes /object_info producers and consumers and
plans one representative typed edge per slot, excluding wildcard slots
(isValidConnection short-circuits on * before the real type compare, so a
wildcard link proves reachability, not interop). The breadth sweep connects
every planned edge through the real validator in-page and requires each link
to survive serialize/configure and appear in graphToPrompt output; verified
up front that graphToPrompt emits links even when other required inputs
dangle. A curated subset is dragged slot-dot to slot-dot under both
renderers, addressed by data-slot-key so shared labels cannot misfire.
Orphan types are reported, never failed; connect vetoes must match a
committed allow-list. Manifest packs opt in via a connectivity tier that
needs no extra assets.
2026-07-02 12:14:39 -07:00
GitHub Action
8f567e8ef0 [automated] Apply ESLint and Oxfmt fixes 2026-07-02 18:44:36 +00:00
Nathaniel Parson Koroso
4fb282f853 docs: link custom-node suite README from browser_tests README 2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
d17a387ddb test: name each custom-node check as a pnpm script and document the suite
One script per pack tier (impact-render/impact-run/vhs-render/vhs-run) plus
the self-check, all opening the Playwright Inspector so anyone can step
through what the robot does. README covers prerequisites, every script, a
worked example, the zero-visible-errors contract, and how to add a pack.
2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
68ba0aa613 test: add pnpm scripts for the custom-node suite
test:custom-nodes runs the whole suite headless (the gate); :watch opens a
headed slow-motion run of the browser tiers; :debug steps through them in the
Playwright Inspector. All target the local dev server on :5173 and use the
committed system-Chrome config (no bundled-chromium download). Pass -g to
:watch / :debug to run a single test, e.g. -g 'VideoHelperSuite.*T1'.
2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
675140c164 test: drop io tier scaffold until assertion-node pack exists
T2a gated on the ComfyUI-test-framework 'Assert Executed' nodes, which are
not published for any backend yet, so the tier could only ever skip. A test
that cannot run anywhere is reporting noise; restore it from history when
the assertion pack lands. Suite is now 16 passed, zero skips, zero failures.
2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
64706c53c3 test: enforce zero visible errors across custom-node suite
Every browser tier now asserts the app's user-facing error surfaces (error
overlay, error dialog, node render errors, error toasts) are absent at test
start and after each pass, so a run is green only if a human watching the
screen sees zero errors. The harness self-check asserts the overlay IS
visible after a forced execution error, keeping the selectors provably live.

Sessions boot with a blank graph (Comfy.TutorialCompleted=false) because the
bundled default template references models absent on a scoped backend; the
tutorial path's auto-opened template browser is dismissed per test. Settings
now reach the session on single-user server-storage backends by routing
devtools set_settings to the default user, and the errors tab stays enabled
so error indicators are never suppressed in this suite. The smoke test loads
a core-only model-free workflow instead of the SD1.5 default asset.
2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
bfa94d4118 test: enable run tier for Impact and VHS with model-free workflows
Impact runs ImpactInt and ImpactFloat into PreviewAny as a group; VHS decodes
the existing plain_video.mp4 asset through VHS_LoadVideoPath into
VHS_VideoInfo. The executed-set check asserts each expected node individually
executed, so group workflows still verify per-node execution. Requires a
cache-disabled backend (--cache-none) with the video staged in its input dir;
documented on the manifest workflow field.
2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
b7708d5ad0 test: validate timeoutMs and requiresModels at manifest load 2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
564de12d46 test: harden custom-node suite per review findings
Normalize the executing event tap: its CustomEvent detail is a bare node-id
string, so the previous object-spread left the executed-set permanently empty.
The self-check now asserts a non-empty executed-set to keep that path live.

T0 now clears the graph per renderer pass, asserts exact node counts, and
verifies each added pack node's own data-node-id mounts in the Vue pass
(default-workflow nodes can no longer satisfy the assertions). Console capture
starts before the renderer toggle. Added an object_info sanity floor so a
depleted getNodeDefs fails loudly instead of skipping everything.

Run/io tiers gain test.setTimeout, requiresModels gating, and an empty-workflow
guard. Interrupted runs get pure-spec coverage; the shared console collector
moves to fixtures/utils.
2026-07-02 11:39:53 -07:00
Nathaniel Parson Koroso
5a1f788230 test: custom-node E2E regression suite (load/render, both renderers)
Data-driven Playwright harness verifying custom-node packs load and render
under both LiteGraph 1.0 and Vue Nodes 2.0 against a real ComfyUI backend.
Pure classifier/validator/manifest logic is unit-tested; the regression spec
renders each pack's nodes in both renderers (Vue via data-node-id DOM) and a
self-check runs a workflow to confirm execution-error capture. Proven against
Impact Pack + VideoHelperSuite.

Makes ComfyPage.createUser idempotent so the suite runs against a persistent
backend (Desktop server user storage).
2026-07-02 11:39:53 -07:00
484 changed files with 35519 additions and 9056 deletions

View File

@@ -63,3 +63,14 @@ reviews:
Pass if none of these patterns are found in the diff.
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
path_instructions:
- path: '**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
- path: 'src/lib/litegraph/**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, `docs/guidance/vitest.md`, and `docs/testing/litegraph-testing.md` as required review context for every changed litegraph Vitest test file.
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/playwright.md` as required review context for every changed Playwright test file.

View File

@@ -0,0 +1,180 @@
# Runs the custom-node regression suite against a backend that has the manifest
# packs actually installed, so the load/run tiers execute for real. This is a
# GATING check: if a pack fails to install or any tier is skipped, the job goes
# red - a regression gate that let a broken pack through as a "skip" would be
# pointless. Mark `custom-nodes-e2e` as a required status check in branch
# protection to block merges on failure.
name: 'CI: Tests Custom Nodes'
on:
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
push:
branches: [main, master]
merge_group:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Path gating lives here, not in a trigger-level `paths:` filter: a required
# check gated by trigger paths never creates a check run on an unrelated PR
# and leaves branch protection stuck Pending. A job-level `if:` still creates
# the check and marks it Skipped (= passing). Mirrors ci-tests-unit.yaml.
changes:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should-run: ${{ steps.changes.outputs.should-run }}
steps:
- uses: actions/checkout@v6
- id: changes
uses: ./.github/actions/changes-filter
# Deliberately NOT sharded yet: the suite is ~8 min but every shard would
# pay the full ~4.5 min setup (clone + pip-install every pack + boot the
# backend), so 2 shards buy ~4 min of wall time for double the runner cost,
# with diminishing returns beyond that. Sharding pays once test time dwarfs
# setup time -
# first cut setup with a prebuilt image of the pinned packs, then shard if
# the job exceeds ~12 minutes.
custom-nodes-e2e:
needs: changes
# Run only when non-docs code changed AND the PR is same-repo. Fork PRs can
# edit the manifest's repo/pin URLs, and this job clones and pip-installs
# whatever they point at (setup.py runs at install time), so an untrusted
# fork must not be able to aim the clone at an attacker-controlled repo.
# Fork PRs still get the environment-agnostic coverage via the main e2e
# shards. A skipped job counts as passing, so this stays required-safe.
if: >-
needs.changes.outputs.should-run == 'true' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
with:
include_build_step: true
- name: Setup Playwright
uses: ./.github/actions/setup-playwright
# Checks out ComfyUI, installs Python/torch/requirements and ComfyUI_devtools.
# launch_server:false so we can add the manifest packs before booting.
- name: Setup ComfyUI server
uses: ./.github/actions/setup-comfyui-server
with:
launch_server: 'false'
# Install every pack the manifest declares (DRY: a new pack row installs
# itself here, no workflow change). A clone or dependency failure fails the
# job - if a pack can't be installed, its coverage can't run, and that is a
# gate failure, not something to paper over. The `jq | while` pipe hides
# failures in a subshell, so read into an array and loop with `set -e`.
- name: Install manifest custom nodes
shell: bash
run: |
set -euo pipefail
# Pin the CPU torch stack that setup-comfyui-server installed so no
# pack's requirements.txt can pull a GPU/incompatible torch onto this
# --cpu runner. A pack that genuinely needs a different torch fails
# the constrained install loudly rather than silently swapping it.
pip freeze | grep -iE '^(torch|torchvision|torchaudio)==' \
> /tmp/torch-constraints.txt || true
manifest=browser_tests/fixtures/data/customNodeManifest.json
mapfile -t entries < <(jq -c '.[]' "$manifest")
for entry in "${entries[@]}"; do
repo=$(jq -r '.repo' <<<"$entry")
pin=$(jq -r '.pin' <<<"$entry")
# Install under the manifest `pack` key, not basename(repo): node
# attribution keys on the install dirname via python_module, and
# the two only coincide by luck. Same charset the manifest loader
# enforces - belt for anything that bypasses it.
pack=$(jq -r '.pack' <<<"$entry")
if ! [[ "$pack" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "::error::unsafe pack name: '$pack'"; exit 1
fi
# The gate tests exactly what was verified: a full SHA pin is
# mandatory here, before anything installs. The planned canary
# (pack HEADs) is the only intended unpinned consumer and runs
# with CUSTOM_NODES_ALLOW_UNPINNED=1 through the loader instead.
if ! [[ "$pin" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::$pack: pin must be a full commit SHA (got '$pin')"; exit 1
fi
dir="ComfyUI/custom_nodes/$pack"
echo "::group::install $pack"
git clone --depth 1 "$repo" "$dir"
git -C "$dir" fetch --depth 1 origin "$pin"
git -C "$dir" checkout "$pin"
if [ -f "$dir/requirements.txt" ]; then
pip install -r "$dir/requirements.txt" -c /tmp/torch-constraints.txt
fi
echo "::endgroup::"
done
# The VHS run-tier workflow reads input/plain_video.mp4.
- name: Stage run-tier assets
shell: bash
run: cp browser_tests/assets/plain_video.mp4 ComfyUI/input/plain_video.mp4
# --cache-none so retried run-tier tests re-execute every node (a cached
# node emits no `executing` event and would false-fail PARTIAL).
- name: Start ComfyUI server
shell: bash
working-directory: ComfyUI
run: |
python main.py --cpu --multi-user --cache-none --front-end-root ../dist &
wait-for-it --service 127.0.0.1:8188 -t 600
- name: Run custom-node suite
env:
PLAYWRIGHT_JSON_OUTPUT_NAME: custom-nodes-results.json
run: |
# workers=1: the auto-run tier needs exclusive backend-queue access;
# parallel workers interrupt each other's executions.
# --project=custom-nodes: the `@custom-nodes`-tagged specs, which the
# main e2e job's `chromium` project excludes (see playwright.config).
# list,json,html: json feeds the skip gate below; html writes
# playwright-report/ (the upload step's target) and folds in the
# on-first-retry traces, which a bare list,json would discard.
pnpm exec playwright test browser_tests/tests/customNodes/ \
--project=custom-nodes --reporter=list,json,html --workers=1
# A skip here means a pack or devtools did not load: on this backend every
# tier is meant to run, so a skip is a gate failure, not an honest pass.
- name: Forbid skipped tests
if: always()
shell: bash
run: |
set -euo pipefail
skipped=$(jq '.stats.skipped' custom-nodes-results.json)
echo "skipped tests: $skipped"
if [ "$skipped" != "0" ]; then
echo "::error::$skipped test(s) skipped - a manifest pack or devtools failed to load; skips are not acceptable in the gating job"
# Recurse so specs nested under describe() blocks are found, and
# print only the specs that actually skipped.
jq -r '.. | objects
| select(has("title") and has("tests"))
| select(any(.tests[]?; .status == "skipped"))
| .title' custom-nodes-results.json | sort -u | head -40
exit 1
fi
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v6
with:
name: playwright-report-custom-nodes
path: playwright-report/
retention-days: 7
if-no-files-found: warn

View File

@@ -73,8 +73,8 @@ jobs:
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
shardTotal: [16]
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -93,7 +93,7 @@ jobs:
# Run sharded tests (browsers pre-installed in container)
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
COLLECT_COVERAGE: 'true'
@@ -150,7 +150,7 @@ jobs:
# Run tests (browsers pre-installed in container)
- name: Run Playwright tests (${{ matrix.browser }})
id: playwright
run: pnpm exec playwright test --project=${{ matrix.browser }} --reporter=blob
run: pnpm exec playwright test --project=${{ matrix.browser }}
env:
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report

View File

@@ -40,3 +40,6 @@ jobs:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
run: pnpm --filter @comfyorg/website build
- name: Validate JSON-LD structured data
run: pnpm --filter @comfyorg/website validate:jsonld

View File

@@ -38,9 +38,11 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
# For each commit emit the GitHub login when the author/committer email resolves to a GitHub account
# otherwise fall back to the raw git name.
run: |
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
if [ -n "$others" ]; then
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"

View File

@@ -278,32 +278,49 @@ jobs:
continue
fi
# Create backport branch
git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"
# Create backport branch. A failure here (e.g. dirty state left
# by a prior target) must not abort the loop and skip remaining
# targets, so fall back to a clean checkout and record the error.
if ! git checkout -B "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"; then
echo "::error::Failed to create branch ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
FAILED="${FAILED}${TARGET_BRANCH}:branch-create-failed "
git checkout main || git checkout -f main
echo "::endgroup::"
continue
fi
# Try cherry-pick
if git cherry-pick "${MERGE_COMMIT}"; then
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
git push --force-with-lease origin "${BACKPORT_BRANCH}"
PUSH_CMD=(git push --force-with-lease origin "${BACKPORT_BRANCH}")
else
git push origin "${BACKPORT_BRANCH}"
PUSH_CMD=(git push origin "${BACKPORT_BRANCH}")
fi
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
# A push failure for one target must not abort the loop and
# prevent remaining targets from being attempted.
if "${PUSH_CMD[@]}"; then
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
else
echo "::error::Failed to push ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
FAILED="${FAILED}${TARGET_BRANCH}:push-failed "
fi
# Return to main (keep the branch, we need it for PR)
git checkout main
git checkout main || git checkout -f main
else
# Get conflict info
CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',')
git cherry-pick --abort
git cherry-pick --abort || true
echo "::error::Cherry-pick failed due to conflicts"
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
# Clean up the failed branch
git checkout main
git branch -D "${BACKPORT_BRANCH}"
git checkout main || git checkout -f main
git branch -D "${BACKPORT_BRANCH}" || true
fi
echo "::endgroup::"
@@ -384,6 +401,10 @@ jobs:
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
The auto-backport could not be completed automatically. Please backport
manually onto branch `${BACKPORT_BRANCH}` (from `origin/${target}`) and
open a PR to `${target}`.
<details>
<summary>📄 Conflicting files</summary>
@@ -416,19 +437,37 @@ jobs:
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
fi
# Post a comment without letting a single failed `gh pr comment` (e.g.
# a locked issue, as happened for PR #13359, or a transient API error)
# abort the step under `set -e` and swallow the remaining failures.
post_comment() {
local body="$1"
local context="$2"
if ! gh pr comment "${PR_NUMBER}" --body "${body}"; then
echo "::warning::Could not comment on PR #${PR_NUMBER} about ${context}. Manual backport required."
fi
}
for failure in ${{ steps.backport.outputs.failed }}; do
IFS=':' read -r target reason conflicts <<< "${failure}"
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
if [ "${reason}" = "branch-missing" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
post_comment "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist" "missing branch ${target}"
elif [ "${reason}" = "already-exists" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
post_comment "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed." "already-backported ${target}"
elif [ "${reason}" = "branch-create-failed" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` failed: could not create the backport branch. Please retry or backport manually."
elif [ "${reason}" = "push-failed" ]; then
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` cherry-picked cleanly but the push failed. Please retry or push the backport branch manually."
elif [ "${reason}" = "conflicts" ]; then
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ')
SAFE_TARGET=$(echo "$target" | tr '/' '-')
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
@@ -444,10 +483,10 @@ jobs:
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
export target MERGE_COMMIT_SHORT BACKPORT_BRANCH CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${BACKPORT_BRANCH} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
post_comment "${COMMENT_BODY}" "cherry-pick conflict on ${target} (backport manually onto ${BACKPORT_BRANCH})"
fi
done

View File

@@ -23,6 +23,10 @@ on:
required: false
default: 'Comfy-Org/ComfyUI'
type: string
target_branch:
description: 'Optional: force a specific release branch, e.g. core/1.47 or core/2.0. Overrides the pin-derived target — use to skip a dead minor or do an out-of-cadence / major release.'
required: false
type: string
jobs:
check-release-week:
@@ -49,12 +53,15 @@ jobs:
fi
- name: Summary
env:
TARGET_BRANCH_OVERRIDE: ${{ inputs.target_branch }}
run: |
echo "## Release Check" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Is release week: ${{ steps.check.outputs.is_release_week }}" >> $GITHUB_STEP_SUMMARY
echo "- Manual trigger: ${{ github.event_name == 'workflow_dispatch' }}" >> $GITHUB_STEP_SUMMARY
echo "- Release type: ${{ inputs.release_type || 'minor (scheduled)' }}" >> $GITHUB_STEP_SUMMARY
echo "- Target branch override: ${TARGET_BRANCH_OVERRIDE:-(none — pin-derived)}" >> $GITHUB_STEP_SUMMARY
resolve-version:
needs: check-release-week
@@ -103,6 +110,7 @@ jobs:
working-directory: frontend
env:
RELEASE_TYPE: ${{ inputs.release_type || 'minor' }}
TARGET_BRANCH: ${{ inputs.target_branch }}
run: |
set -euo pipefail

View File

@@ -76,10 +76,14 @@ test.describe('Affiliates landing — desktop interactions', () => {
return match?.textContent ?? null
})
expect(faqJsonLd, 'FAQ JSON-LD script').not.toBeNull()
const parsed = JSON.parse(faqJsonLd!)
expect(parsed['@type']).toBe('FAQPage')
expect(Array.isArray(parsed.mainEntity)).toBe(true)
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
const graph = JSON.parse(faqJsonLd!)['@graph'] as {
'@type': string
mainEntity?: unknown[]
}[]
const faqPage = graph.find((node) => node['@type'] === 'FAQPage')
expect(faqPage, 'FAQPage node in @graph').toBeDefined()
expect(Array.isArray(faqPage!.mainEntity)).toBe(true)
expect(faqPage!.mainEntity!.length).toBe(FAQ_COUNT)
})
test('Apply Now CTA opens the application form in a new tab', async ({

View File

@@ -8,7 +8,7 @@ test.describe('Careers page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Careers Comfy')
await expect(page).toHaveTitle('Careers - Comfy')
})
test('Roles section heading is visible', async ({ page }) => {
@@ -72,7 +72,7 @@ test.describe('Careers page role links', () => {
test.describe('Careers page (zh-CN) @smoke', () => {
test('renders localized heading and roles', async ({ page }) => {
await page.goto('/zh-CN/careers')
await expect(page).toHaveTitle('招聘 Comfy')
await expect(page).toHaveTitle('招聘 - Comfy')
await expect(
page.getByRole('heading', { name: '职位', level: 2 })
).toBeVisible()

View File

@@ -9,7 +9,7 @@ test.describe('Cloud nodes page @smoke', () => {
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Custom-node packs on Comfy Cloud supported by default'
'Custom-node packs on Comfy Cloud - supported by default'
)
})

View File

@@ -8,7 +8,7 @@ test.describe('Cloud page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy Cloud AI in the Cloud')
await expect(page).toHaveTitle('Comfy Cloud - AI in the Cloud')
})
test('HeroSection heading and subtitle are visible', async ({ page }) => {

View File

@@ -16,7 +16,7 @@ test.describe('Download page @smoke', () => {
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(
'Download Comfy Desktop Run AI on Your Hardware'
'Download Comfy Desktop - Run AI on Your Hardware'
)
})

View File

@@ -17,7 +17,7 @@ test.describe('Homepage @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Comfy Professional Control of Visual AI')
await expect(page).toHaveTitle('Comfy - Professional Control of Visual AI')
})
test('HeroSection heading is visible', async ({ page }) => {

View File

@@ -0,0 +1,158 @@
import { expect } from '@playwright/test'
import { learningTutorials } from '../src/data/learningTutorials'
import { t } from '../src/i18n/translations'
import { test } from './fixtures/blockExternalMedia'
const tutorialButtonName = (title: string, locale: 'en' | 'zh-CN') =>
`${t('learning.tutorials.titlePrefix', locale)} ${title}`
test.describe('Learning page @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/learning')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Learning - Comfy')
})
test('hero headline references ComfyUI', async ({ page }) => {
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
await expect(heading).toContainText(t('learning.heroTitle.before', 'en'))
await expect(heading).toContainText('ComfyUI')
await expect(heading).toContainText(t('learning.heroTitle.line2', 'en'))
})
test('featured workflow section shows title and author', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.featured.title', 'en'),
level: 2
})
).toBeVisible()
await expect(
page.getByText(t('learning.featured.author', 'en'))
).toBeVisible()
})
test('renders every tutorial from the data source', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.tutorials.heading', 'en'),
level: 2
})
).toBeVisible()
for (const tutorial of learningTutorials) {
await expect(
page.getByRole('button', {
name: tutorialButtonName(tutorial.title.en, 'en')
})
).toBeVisible()
}
})
test('tutorials with a workflow link expose an external Try Workflow link', async ({
page
}) => {
const linkedTutorials = learningTutorials.filter(
(tutorial) => tutorial.href
)
const workflowLinks = page.getByRole('link', {
name: t('cta.tryWorkflow', 'en')
})
const hrefs = await workflowLinks.evaluateAll((links) =>
links.map((link) => link.getAttribute('href'))
)
for (const tutorial of linkedTutorials) {
expect(hrefs).toContain(tutorial.href)
}
})
test('call to action links to contact sales', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: t('learning.cta.heading', 'en'),
level: 2
})
).toBeVisible()
await expect(
page.getByRole('link', { name: t('learning.cta.contactSales', 'en') })
).toHaveAttribute('href', '/contact')
})
})
test.describe('Learning tutorial dialog', () => {
test('opens a tutorial video and dismisses via the close button', async ({
page
}) => {
const [firstTutorial] = learningTutorials
await page.goto('/learning')
const openButton = page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title.en, 'en')
})
await openButton.scrollIntoViewIfNeeded()
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
// TutorialsSection is hydrated via `client:visible`; retry the click until
// Vue responds by opening the dialog.
await expect(async () => {
await openButton.click()
await expect(dialog).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 10_000 })
await expect(
dialog.getByRole('heading', { level: 2, name: firstTutorial.title.en })
).toBeVisible()
await dialog
.getByRole('button', { name: t('gallery.detail.close', 'en') })
.click()
await expect(dialog).toBeHidden()
})
test('dismisses the dialog with the Escape key', async ({ page }) => {
const [firstTutorial] = learningTutorials
await page.goto('/learning')
const openButton = page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title.en, 'en')
})
await openButton.scrollIntoViewIfNeeded()
const dialog = page.getByRole('dialog', { name: firstTutorial.title.en })
await expect(async () => {
await openButton.click()
await expect(dialog).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 10_000 })
await page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
})
})
test.describe('Learning page (zh-CN) @smoke', () => {
test('renders localized title, headings, and tutorials', async ({ page }) => {
await page.goto('/zh-CN/learning')
await expect(page).toHaveTitle('学习 - Comfy')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
/[一-鿿]/
)
await expect(
page.getByRole('heading', {
name: t('learning.tutorials.heading', 'zh-CN'),
level: 2
})
).toBeVisible()
const [firstTutorial] = learningTutorials
await expect(
page.getByRole('button', {
name: tutorialButtonName(firstTutorial.title['zh-CN'], 'zh-CN')
})
).toBeVisible()
})
})

View File

@@ -31,6 +31,26 @@ test.describe('Desktop navigation @smoke', () => {
}
})
test('NEW badge shows on Products and Community only', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopLinks = nav.getByTestId('desktop-nav-links')
for (const label of ['Products', 'Community']) {
await expect(
desktopLinks
.getByRole('button', { name: label })
.getByText('NEW', { exact: true })
).toBeVisible()
}
await expect(
desktopLinks.getByRole('button', { name: 'Company' }).getByText('NEW')
).toHaveCount(0)
await expect(
desktopLinks.getByRole('link', { name: 'Pricing' }).getByText('NEW')
).toHaveCount(0)
})
test('CTA buttons are visible', async ({ page }) => {
const nav = page.getByRole('navigation', { name: 'Main navigation' })
const desktopCTA = nav.getByTestId('desktop-nav-cta')
@@ -117,6 +137,27 @@ test.describe('Mobile menu @mobile', () => {
}
})
test('NEW badge shows on Products and Community only', async ({ page }) => {
await page.getByRole('button', { name: 'Toggle menu' }).click()
const menu = page.getByRole('dialog')
for (const label of ['Products', 'Community']) {
await expect(
menu.getByRole('button', { name: label }).getByText('NEW', {
exact: true
})
).toBeVisible()
}
await expect(
menu.getByRole('button', { name: 'Company' }).getByText('NEW')
).toHaveCount(0)
await expect(
menu.getByRole('link', { name: 'Pricing' }).getByText('NEW')
).toHaveCount(0)
})
test('clicking section with subitems drills down and back works', async ({
page
}) => {

View File

@@ -22,7 +22,7 @@ test.describe('Payment success page @smoke', () => {
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Successful Comfy')
await expect(page).toHaveTitle('Payment Successful - Comfy')
await expectNoIndex(page)
})
@@ -54,7 +54,7 @@ test.describe('Payment failed page @smoke', () => {
})
test('has correct title and is noindex', async ({ page }) => {
await expect(page).toHaveTitle('Payment Failed Comfy')
await expect(page).toHaveTitle('Payment Failed - Comfy')
await expectNoIndex(page)
})
@@ -84,7 +84,7 @@ test.describe('Payment failed page @smoke', () => {
test.describe('Payment pages zh-CN @smoke', () => {
test('zh-CN success page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/success')
await expect(page).toHaveTitle('支付成功 Comfy')
await expect(page).toHaveTitle('支付成功 - Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '支付成功', level: 1 })
@@ -99,7 +99,7 @@ test.describe('Payment pages zh-CN @smoke', () => {
test('zh-CN failed page renders and links correctly', async ({ page }) => {
await page.goto('/zh-CN/payment/failed')
await expect(page).toHaveTitle('支付失败 Comfy')
await expect(page).toHaveTitle('支付失败 - Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '无法完成支付', level: 1 })

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -17,7 +17,8 @@
"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"
"generate:models": "tsx ./scripts/generate-models.ts",
"validate:jsonld": "tsx ./scripts/validate-jsonld.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",

View File

@@ -0,0 +1,129 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { collectGraphIds } from '../src/utils/jsonLd'
const DIST_DIR = join(process.cwd(), 'dist')
const JSON_LD_BLOCK =
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
interface Violation {
file: string
message: string
}
function htmlFiles(dir: string): string[] {
return readdirSync(dir, { recursive: true })
.map(String)
.filter((entry) => entry.endsWith('.html'))
.map((entry) => join(dir, entry))
}
function typesOf(node: Record<string, unknown>): string[] {
const type = node['@type']
if (typeof type === 'string') return [type]
if (Array.isArray(type)) {
return type.filter((t): t is string => typeof t === 'string')
}
return []
}
function hasValidPrice(node: Record<string, unknown>): boolean {
const price = node.price
const priceStr = price == null ? '' : String(price).trim()
return priceStr !== '' && !Number.isNaN(Number(priceStr))
}
function checkHonesty(
value: unknown,
file: string,
violations: Violation[]
): void {
const walk = (node: unknown): void => {
if (Array.isArray(node)) {
node.forEach(walk)
return
}
if (!node || typeof node !== 'object') return
const record = node as Record<string, unknown>
const types = typesOf(record)
if (types.includes('Review') || types.includes('AggregateRating')) {
violations.push({
file,
message: `dishonest node type ${types.join('/')}`
})
}
if ('aggregateRating' in record || 'review' in record) {
violations.push({
file,
message: 'node carries a review/aggregateRating'
})
}
if (
types.includes('Offer') &&
(!hasValidPrice(record) || !record.priceCurrency)
) {
violations.push({
file,
message: 'Offer missing priceCurrency or a concrete price'
})
}
Object.values(record).forEach(walk)
}
walk(value)
}
function validateFile(file: string): Violation[] {
const html = readFileSync(file, 'utf8')
const violations: Violation[] = []
const definedIds = new Set<string>()
const referencedIds: string[] = []
for (const match of html.matchAll(JSON_LD_BLOCK)) {
let parsed: unknown
try {
parsed = JSON.parse(match[1])
} catch (error) {
violations.push({ file, message: `invalid JSON-LD: ${String(error)}` })
continue
}
checkHonesty(parsed, file, violations)
const { defined, references } = collectGraphIds(parsed)
defined.forEach((id) => definedIds.add(id))
referencedIds.push(...references)
}
for (const id of referencedIds) {
if (!definedIds.has(id)) {
violations.push({ file, message: `unresolved @id reference: ${id}` })
}
}
return violations
}
function main(): void {
const files = htmlFiles(DIST_DIR)
if (files.length === 0) {
console.error(
`JSON-LD validation found no HTML in ${DIST_DIR} — build first.`
)
process.exit(1)
}
const violations = files.flatMap(validateFile)
if (violations.length > 0) {
console.error(`JSON-LD validation failed (${violations.length} issue(s)):`)
for (const { file, message } of violations) {
console.error(` ${file.replace(DIST_DIR, 'dist')}: ${message}`)
}
process.exit(1)
}
process.stdout.write(
`JSON-LD validation passed across ${files.length} page(s).\n`
)
}
main()

View File

@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import HeroBackdrop01 from './HeroBackdrop01.vue'
const sampleImage =
'https://images.unsplash.com/photo-1451187580459-43490279c0fa?auto=format&fit=crop&w=1600&q=80'
const meta: Meta<typeof HeroBackdrop01> = {
title: 'Website/Blocks/HeroBackdrop01',
component: HeroBackdrop01,
tags: ['autodocs'],
args: {
backdrop: { type: 'image', src: sampleImage, alt: 'Abstract gradient' },
title: 'Build anything\nwith ComfyUI',
subtitle:
'A powerful, modular visual interface for building and running AI workflows.'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithBadge: Story = {
args: {
badgeText: 'New'
}
}
export const WithFootnote: Story = {
args: {
footnote: 'Available on Windows, macOS, and Linux.'
}
}
export const NoBackdrop: Story = {
args: {
backdrop: undefined
}
}

View File

@@ -0,0 +1,193 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
type Backdrop =
| { type: 'image'; src: string; alt?: string }
| { type: 'video'; src: string; poster?: string; alt?: string }
const {
backdrop,
mobileBackdrop,
badgeText,
badgeLogoSrc,
badgeLogoAlt,
title,
subtitle,
footnote,
class: className
} = defineProps<{
backdrop?: Backdrop
mobileBackdrop?: Backdrop
badgeText?: string
badgeLogoSrc?: string
badgeLogoAlt?: string
title: string
subtitle?: string
footnote?: string
class?: HTMLAttributes['class']
}>()
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
const reduceMotion = computed(() => prefersReducedMotion())
// Removing the reactive `autoplay` attribute only suppresses the *initial*
// play; it can't pause a video the browser has already started. That is
// exactly the SSR case: the server renders `autoplay` (it can't read the
// client's motion preference), the browser begins playback on parse, and the
// post-hydration attribute removal is too late. Pause on mount so
// reduced-motion users get the poster frame instead of a looping video.
const pauseIfReduced = (el: unknown) => {
if (el instanceof HTMLVideoElement && reduceMotion.value) el.pause()
}
// On mobile the backdrop is an in-flow rounded card above the content; on
// desktop it is the full-bleed background behind it. A single element serves
// both roles via responsive classes — mobileBackdrop only swaps the source.
const sharedBackdropClass =
'relative aspect-3/2 w-full rounded-3xl object-cover lg:absolute lg:inset-0 lg:aspect-auto lg:size-full lg:rounded-none'
// When both breakpoints use images, serve them from a single responsive <img>
// so the browser fetches only the source matching the viewport. Two
// `hidden`/`lg:hidden`-toggled <img> layers would each download (display:none
// does not stop the fetch), doubling the high-priority load on an
// LCP-critical hero. Videos or a mixed image/video pair can't collapse this
// way and fall back to breakpoint-toggled layers below.
const responsiveImage = computed(() => {
if (backdrop?.type !== 'image') return null
if (mobileBackdrop && mobileBackdrop.type !== 'image') return null
const base = mobileBackdrop ?? backdrop
return {
src: base.src,
alt: backdrop.alt ?? mobileBackdrop?.alt ?? '',
// Larger-viewport source; omitted when one image serves both breakpoints.
desktopSrc: mobileBackdrop ? backdrop.src : undefined
}
})
// Fallback for videos and mixed image/video pairs: toggle assets by breakpoint.
const backdropLayers = computed(() => {
if (!backdrop) return []
if (mobileBackdrop) {
return [
{
backdrop: mobileBackdrop,
class: 'relative aspect-3/2 w-full rounded-3xl object-cover lg:hidden'
},
{
backdrop,
class: 'absolute inset-0 hidden size-full object-cover lg:block'
}
]
}
return [{ backdrop, class: sharedBackdropClass }]
})
const scrimShape = 'farthest-side at 50% 50%'
const scrimStyle = {
background: `radial-gradient(${scrimShape}, color-mix(in srgb, var(--color-primary-warm-white) 80%, transparent) 0%, transparent 80%)`,
maskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`,
WebkitMaskImage: `radial-gradient(${scrimShape}, #000 45%, transparent 90%)`
}
</script>
<template>
<section
:class="cn('max-w-9xl mx-auto px-4 pt-4 lg:px-6 lg:pt-6', className)"
>
<div class="relative overflow-hidden rounded-3xl">
<slot name="backdrop">
<picture v-if="responsiveImage" class="contents">
<source
v-if="responsiveImage.desktopSrc"
:srcset="responsiveImage.desktopSrc"
media="(min-width: 1024px)"
/>
<img
:src="responsiveImage.src"
:alt="responsiveImage.alt"
fetchpriority="high"
decoding="async"
:class="sharedBackdropClass"
/>
</picture>
<template v-else>
<template v-for="(layer, i) in backdropLayers" :key="i">
<video
v-if="layer.backdrop.type === 'video'"
:ref="pauseIfReduced"
:src="layer.backdrop.src"
:poster="layer.backdrop.poster"
:aria-label="layer.backdrop.alt"
:aria-hidden="layer.backdrop.alt ? undefined : true"
:autoplay="!reduceMotion"
loop
muted
playsinline
preload="metadata"
:class="layer.class"
/>
<img
v-else
:src="layer.backdrop.src"
:alt="layer.backdrop.alt ?? ''"
fetchpriority="high"
decoding="async"
:class="layer.class"
/>
</template>
</template>
</slot>
<div
class="relative flex flex-col justify-center px-0 pt-6 pb-8 lg:min-h-176 lg:px-16 lg:py-24"
>
<div class="relative w-full max-w-xl">
<div
aria-hidden="true"
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
:style="scrimStyle"
/>
<div class="relative">
<ProductHeroBadge
v-if="badgeText"
:text="badgeText"
:logo-src="badgeLogoSrc"
:logo-alt="badgeLogoAlt"
/>
<h1
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
>
{{ title }}
</h1>
<p
v-if="subtitle"
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
>
{{ subtitle }}
</p>
<p
v-if="footnote"
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
>
{{ footnote }}
</p>
<slot />
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -16,6 +16,7 @@ import type { NavItem } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import NavColumn from './NavColumn.vue'
import NavFeaturedCard from './NavFeaturedCard.vue'
import NewBadge from './NewBadge.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const mainNavigation = getMainNavigation(locale)
@@ -42,7 +43,10 @@ function isNavItemActive(navItem: NavItem, path: string): boolean {
<NavigationMenuTrigger
:active="isNavItemActive(navItem, currentPath)"
>
{{ navItem.label }}
<span class="inline-flex items-center gap-1">
<span class="ppformula-text-center">{{ navItem.label }}</span>
<NewBadge v-if="navItem.badge" :locale="locale" size="xxs" />
</span>
</NavigationMenuTrigger>
<NavigationMenuContent class="w-auto" data-testid="nav-dropdown">
<ul class="flex w-max gap-16">

View File

@@ -8,6 +8,7 @@ import { lockScroll, unlockScroll } from '../../../composables/scrollLock'
import type { Locale } from '../../../i18n/translations.ts'
import { t } from '../../../i18n/translations.ts'
import NavLinkContent from './NavLinkContent.vue'
import NewBadge from './NewBadge.vue'
import Sheet from '@/components/ui/sheet/Sheet.vue'
import SheetContent from '@/components/ui/sheet/SheetContent.vue'
import SheetDescription from '@/components/ui/sheet/SheetDescription.vue'
@@ -96,7 +97,8 @@ onUnmounted(() => {
:href="item.columns ? undefined : item.href"
@click="item.columns && (activeSection = item.label)"
>
{{ item.label }}
<span class="ppformula-text-center">{{ item.label }}</span>
<NewBadge v-if="item.badge" :locale="locale" size="xxs" />
<template #append>
<ChevronRight class="size-7" />
</template>

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import Badge from '@/components/ui/badge/Badge.vue'
import { ArrowUpRight } from '@lucide/vue'
import type { NavColumnItem } from '../../../data/mainNavigation'
import type { Locale } from '../../../i18n/translations'
import { t } from '../../../i18n/translations'
import NewBadge from './NewBadge.vue'
defineProps<{ item: NavColumnItem; locale: Locale }>()
</script>
@@ -12,9 +11,7 @@ defineProps<{ item: NavColumnItem; locale: Locale }>()
<template>
<span class="flex items-center gap-2">
<span class="ppformula-text-center">{{ item.label }}</span>
<Badge v-if="item.badge" size="xs" variant="accent">
{{ t('nav.badgeNew', locale) }}
</Badge>
<NewBadge v-if="item.badge" :locale="locale" size="xs" />
<ArrowUpRight
v-if="item.external"
class="text-primary-comfy-yellow size-4"

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import Badge from '@/components/ui/badge/Badge.vue'
import type { BadgeVariants } from '@/components/ui/badge'
import type { Locale } from '../../../i18n/translations'
import { t } from '../../../i18n/translations'
defineProps<{ locale: Locale; size?: BadgeVariants['size'] }>()
</script>
<template>
<Badge :size="size" variant="accent">
{{ t('nav.badgeNew', locale) }}
</Badge>
</template>

View File

@@ -0,0 +1,12 @@
---
import type { JsonLdGraph } from '../../utils/jsonLd'
import { escapeJsonLd } from '../../utils/escapeJsonLd'
interface Props {
graph: JsonLdGraph
}
const { graph } = Astro.props
---
<script is:inline type="application/ld+json" set:html={escapeJsonLd(graph)} />

View File

@@ -175,10 +175,7 @@ const contactColumn: { title: string; links: FooterLink[] } = {
</div>
<!-- Logo -->
<canvas
ref="canvasRef"
class="pointer-events-none size-52 opacity-80 lg:mt-28"
/>
<canvas ref="canvasRef" class="pointer-events-none size-52 lg:mt-28" />
</div>
</footer>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { localizeHref } from '../../config/routes'
import { t } from '../../i18n/translations'
const {
@@ -15,8 +16,7 @@ const {
locale?: Locale
}>()
const localePrefix = locale === 'en' ? '' : `/${locale}`
const nextHref = `${localePrefix}/demos/${nextSlug}`
const nextHref = localizeHref(`/demos/${nextSlug}`, locale)
</script>
<template>

View File

@@ -6,14 +6,15 @@ export const badgeVariants = cva({
variants: {
size: {
md: 'px-4 py-1 text-xs',
xs: 'px-2 py-0.5 text-[9px]'
xs: 'px-2 py-0.5 text-[9px]',
xxs: 'px-1.5 py-px text-[8px]'
},
variant: {
default: 'bg-transparency-ink-t80',
subtle: 'bg-transparency-white-t4 text-primary-comfy-canvas',
category: 'text-primary-comfy-yellow px-0 font-semibold uppercase',
accent:
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent px-2 py-0.5 text-[9px] font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
'before:bg-primary-comfy-yellow relative isolate overflow-visible rounded-none bg-transparent font-bold tracking-wide text-primary-comfy-ink uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm'
}
},
defaultVariants: {

View File

@@ -1,7 +1,10 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { Check, Copy } from '@lucide/vue'
import { useClipboard } from '@vueuse/core'
import { computed } from 'vue'
// Interactive: the copy button is inert until its host island is hydrated.
// Render under a `client:*` directive (e.g. `client:visible`) when the page
// needs it to work.
@@ -11,6 +14,8 @@ const {
copiedLabel = 'Copied'
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
const multiline = computed(() => value.includes('\n'))
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
function handleCopy() {
@@ -20,15 +25,32 @@ function handleCopy() {
<template>
<div
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
:class="
cn(
'bg-transparency-white-t4 border-primary-warm-gray flex gap-2 rounded-xl border px-4 py-3',
multiline ? 'items-start' : 'items-center'
)
"
>
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
<span
:class="
cn(
'flex-1 font-mono text-xs text-primary-comfy-canvas',
multiline ? 'wrap-break-word whitespace-pre-line' : 'truncate'
)
"
>
{{ value }}
</span>
<button
type="button"
:aria-label="copied ? copiedLabel : copyLabel"
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
:class="
cn(
'text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas',
multiline && 'mt-0.5'
)
"
@click="handleCopy"
>
<component :is="copied ? Check : Copy" class="size-4" />

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { isHrefActive } from './useCurrentPath'
describe('isHrefActive', () => {
it('matches the current page', () => {
expect(isHrefActive('/mcp', '/mcp')).toBe(true)
})
it('does not match other pages', () => {
expect(isHrefActive('/mcp', '/pricing')).toBe(false)
})
it('matches regardless of a trailing slash', () => {
expect(isHrefActive('/mcp', '/mcp/')).toBe(true)
})
it('ignores query and hash on the href', () => {
expect(isHrefActive('/mcp?ref=banner#setup', '/mcp')).toBe(true)
})
it('never matches an external href', () => {
expect(
isHrefActive('https://docs.comfy.org/agent-tools/cloud', '/mcp')
).toBe(false)
})
it('never matches an empty href', () => {
expect(isHrefActive('', '/mcp')).toBe(false)
})
})

View File

@@ -3,6 +3,7 @@ import type { Locale, TranslationKey } from '../i18n/translations'
import { t } from '../i18n/translations'
import { resolveRel } from '../utils/cta'
import { localizeHref } from './routes'
// The banner "CMS": a single typed config resolved through i18n at build time.
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
@@ -73,7 +74,7 @@ export function getBannerData(
: undefined,
link: link
? {
href: link.href,
href: localizeHref(link.href, locale),
title: t(link.titleKey, locale),
target,
rel: resolveRel({ target: target ?? '_self' }),

View File

@@ -0,0 +1,53 @@
import { t } from '../i18n/translations'
import type { Locale, TranslationKey } from '../i18n/translations'
import { externalLinks } from './routes'
interface PricingTier {
slug: string
labelKey: TranslationKey
priceKey: TranslationKey
}
const tiers: PricingTier[] = [
{
slug: 'standard',
labelKey: 'pricing.plan.standard.label',
priceKey: 'pricing.plan.standard.price'
},
{
slug: 'creator',
labelKey: 'pricing.plan.creator.label',
priceKey: 'pricing.plan.creator.price'
},
{
slug: 'pro',
labelKey: 'pricing.plan.pro.label',
priceKey: 'pricing.plan.pro.price'
}
]
export interface PricingOffer {
name: string
price: string
url: string
}
export function pricingOffers(locale: Locale): PricingOffer[] {
return tiers.flatMap((tier) => {
const display = t(tier.priceKey, locale).trim()
const match = /^\$(\d+(?:\.\d+)?)$/.exec(display)
if (!match) {
console.warn(
`pricingOffers: skipping tier "${tier.slug}" (${locale}) — price "${display}" is not a plain USD amount`
)
return []
}
return [
{
name: t(tier.labelKey, locale),
price: match[1],
url: `${externalLinks.cloud}/cloud/subscribe?tier=${tier.slug}&cycle=monthly`
}
]
})
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { localizeHref } from './routes'
describe('localizeHref', () => {
it('prefixes an internal path for a non-default locale', () => {
expect(localizeHref('/mcp', 'zh-CN')).toBe('/zh-CN/mcp')
})
it('leaves the default locale unprefixed', () => {
expect(localizeHref('/mcp', 'en')).toBe('/mcp')
})
it('passes external URLs through unchanged', () => {
expect(
localizeHref('https://docs.comfy.org/agent-tools/cloud', 'zh-CN')
).toBe('https://docs.comfy.org/agent-tools/cloud')
})
it('never prefixes locale-invariant routes', () => {
expect(localizeHref('/terms-of-service', 'zh-CN')).toBe('/terms-of-service')
})
})

View File

@@ -47,13 +47,26 @@ const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
'enterpriseMsa'
])
const LOCALE_INVARIANT_PATHS = new Set<string>(
[...LOCALE_INVARIANT_ROUTE_KEYS].map((key) => baseRoutes[key])
)
/**
* Prefix an internal path with the locale (`/mcp` → `/zh-CN/mcp`). External
* URLs and locale-invariant routes pass through unchanged.
*/
export function localizeHref(href: string, locale: Locale = 'en'): string {
if (locale === 'en' || !href.startsWith('/')) return href
if (LOCALE_INVARIANT_PATHS.has(href)) return href
return `/${locale}${href}`
}
export function getRoutes(locale: Locale = 'en'): Routes {
if (locale === 'en') return baseRoutes
const prefix = `/${locale}`
return Object.fromEntries(
Object.entries(baseRoutes).map(([k, v]) => [
k,
LOCALE_INVARIANT_ROUTE_KEYS.has(k as keyof Routes) ? v : `${prefix}${v}`
Object.entries(baseRoutes).map(([key, path]) => [
key,
localizeHref(path, locale)
])
) as unknown as Routes
}
@@ -69,15 +82,19 @@ export const externalLinks = {
docsApi: 'https://docs.comfy.org/development/cloud/overview#quick-start',
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
g2ComfyUi: 'https://www.g2.com/products/comfyui',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
mcpServer: 'https://cloud.comfy.org/mcp',
linkedin: 'https://www.linkedin.com/company/comfyui',
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
reddit: 'https://www.reddit.com/r/comfyui/',
support: 'https://support.comfy.org/hc/en-us',
wikidataComfyOrg: 'https://www.wikidata.org/wiki/Q130598554',
wikidataComfyUi: 'https://www.wikidata.org/wiki/Q127798647',
wikipediaComfyUi: 'https://en.wikipedia.org/wiki/ComfyUI',
workflows: 'https://comfy.org/workflows',
x: 'https://x.com/ComfyUI',
youtube: 'https://www.youtube.com/@ComfyOrg'

View File

@@ -72,6 +72,24 @@ export const drops: readonly Drop[] = [
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
}
},
{
id: 'comfy-mcp',
badge: NEW_BADGE,
category: CLOUD,
media: imageFor('Drops_2x2card_MCP.jpg', {
en: 'Comfy MCP',
'zh-CN': 'Comfy MCP'
}),
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
description: {
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
},
cta: {
label: EXPLORE,
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{
id: 'app-mode',
badge: NEW_BADGE,
@@ -112,24 +130,6 @@ export const drops: readonly Drop[] = [
href: { en: '/api', 'zh-CN': '/zh-CN/api' }
}
},
{
id: 'comfy-mcp',
badge: NEW_BADGE,
category: CLOUD,
media: imageFor('Drops_2x2card_MCP.jpg', {
en: 'Comfy MCP',
'zh-CN': 'Comfy MCP'
}),
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
description: {
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
},
cta: {
label: EXPLORE,
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{
id: 'community-workflows',
category: COMMUNITY,

View File

@@ -30,15 +30,23 @@ export type NavItem =
label: string
columns: NavColumn[]
featured?: NavFeatured
badge?: 'new'
href?: never
}
| { label: string; href: string; columns?: never; featured?: never }
| {
label: string
href: string
badge?: 'new'
columns?: never
featured?: never
}
export function getMainNavigation(locale: Locale): NavItem[] {
const routes = getRoutes(locale)
return [
{
label: t('nav.products', locale),
badge: 'new',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
imageAlt: t('nav.featuredProductsAlt', locale),
@@ -95,6 +103,7 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{ label: t('nav.pricing', locale), href: routes.cloudPricing },
{
label: t('nav.community', locale),
badge: 'new',
featured: {
imageSrc: 'https://media.comfy.org/website/nav/featured-demo-card.jpg',
imageAlt: t('nav.featuredCommunityAlt', locale),

View File

@@ -823,7 +823,7 @@ const translations = {
'zh-CN': 'Comfy Cloud 支持的自定义节点包'
},
'cloudNodes.meta.title': {
en: 'Custom-node packs on Comfy Cloud supported by default',
en: 'Custom-node packs on Comfy Cloud - supported by default',
'zh-CN': 'Comfy Cloud 自定义节点包合集——开箱即用'
},
'cloudNodes.meta.description': {
@@ -1845,8 +1845,8 @@ const translations = {
// MCP Meta
'mcp.meta.title': {
en: 'Comfy MCP Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP 让任何 AI 智能体驱动 ComfyUI'
en: 'Comfy MCP - Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP - 让任何 AI 智能体驱动 ComfyUI'
},
'mcp.meta.description': {
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol. Generate images, video, audio, and 3D from Claude Code, Claude Desktop, and any MCP-compatible client.',
@@ -1872,6 +1872,10 @@ const translations = {
en: 'VIEW DOCS',
'zh-CN': '查看文档'
},
'mcp.hero.installMcp': {
en: 'INSTALL MCP',
'zh-CN': '安装 MCP'
},
'mcp.hero.runWorkflow': {
en: 'RUN A WORKFLOW',
'zh-CN': '运行工作流'
@@ -1909,21 +1913,27 @@ const translations = {
},
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
'mcp.setup.step1.title': {
en: 'Copy the MCP URL',
'zh-CN': '复制 MCP URL'
en: 'Ask your agent to install Comfy MCP',
'zh-CN': '让你的智能体安装 Comfy MCP'
},
'mcp.setup.step1.command': {
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
},
'mcp.setup.step1.description': {
en: "Click the copy button below. You'll paste it into your client in the next step.",
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
'zh-CN':
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
},
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
'mcp.setup.step2.title': {
en: 'Add the connector',
'zh-CN': '添加连接器'
en: 'Or add it by hand',
'zh-CN': '或手动添加'
},
'mcp.setup.step2.description': {
en: 'Name it Comfy Cloud and paste the URL. The docs below cover every client.',
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL。下方文档涵盖各类客户端。'
en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.',
'zh-CN':
'想手动配置?用 MCP URL 将 Comfy Cloud 添加为自定义连接器。文档涵盖各类客户端。'
},
'mcp.setup.step2.cta': {
en: 'COMFY CLOUD MCP DOCS',
@@ -2180,6 +2190,13 @@ const translations = {
'nav.ctaCloudPrefix': { en: 'LAUNCH', 'zh-CN': '启动' },
'nav.ctaCloudCore': { en: 'CLOUD', 'zh-CN': '云端' },
'nav.home': { en: 'Comfy home', 'zh-CN': 'Comfy 首页' },
'breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
'breadcrumb.about': { en: 'About Us', 'zh-CN': '关于我们' },
'breadcrumb.contact': { en: 'Contact', 'zh-CN': '联系我们' },
'breadcrumb.download': { en: 'Download', 'zh-CN': '下载' },
'breadcrumb.careers': { en: 'Careers', 'zh-CN': '招聘' },
'breadcrumb.pricing': { en: 'Pricing', 'zh-CN': '定价' },
'breadcrumb.supportedNodes': { en: 'Supported Nodes', 'zh-CN': '支持的节点' },
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
'nav.close': { en: 'Close', 'zh-CN': '关闭' },
@@ -3466,8 +3483,8 @@ const translations = {
},
'affiliate-terms.page.title': {
en: 'Affiliate Terms Comfy',
'zh-CN': 'Affiliate Terms Comfy'
en: 'Affiliate Terms - Comfy',
'zh-CN': 'Affiliate Terms - Comfy'
},
'affiliate-terms.page.description': {
en: 'Comfy.org Affiliate Program Terms and Conditions.',
@@ -3879,8 +3896,8 @@ const translations = {
'This document reproduces the current template of the Enterprise Customer Agreement for reference only. The executed Agreement between Comfy and Customer, together with any signed Order Forms, governs the relationship between the parties. To request an executable copy, please contact <a href="mailto:sales@comfy.org" class="text-white underline">sales@comfy.org</a>.'
},
'enterprise-msa.page.title': {
en: 'Enterprise MSA Comfy',
'zh-CN': 'Enterprise MSA Comfy'
en: 'Enterprise MSA - Comfy',
'zh-CN': 'Enterprise MSA - Comfy'
},
'enterprise-msa.page.description': {
en: 'Comfy Enterprise Customer Agreement — the master services agreement that governs Comfy Enterprise deployments of Comfy Cloud, Comfy API, and related products.',
@@ -4051,7 +4068,6 @@ const translations = {
en: 'This page is being redesigned. Check back soon.',
'zh-CN': '此页面正在重新设计中,请稍后再来。'
},
'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
'customers.story.whatsNext': {
@@ -4147,10 +4163,6 @@ const translations = {
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': '支持的模型'
@@ -4349,8 +4361,8 @@ const translations = {
// Affiliate page (/affiliates) — head metadata
'affiliate.page.title': {
en: 'Comfy.org Affiliate Program Become a Partner',
'zh-CN': 'Comfy.org 联盟计划 成为合作伙伴'
en: 'Comfy.org Affiliate Program - Become a Partner',
'zh-CN': 'Comfy.org 联盟计划 - 成为合作伙伴'
},
'affiliate.page.description': {
en: 'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.',
@@ -4375,8 +4387,8 @@ const translations = {
// Launches page (/launches) — head metadata
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.page.title': {
en: 'ComfyUI Live Demo & Q&A June 29 Launch Livestream',
'zh-CN': 'ComfyUI 直播演示与问答 6 月 29 日发布直播'
en: 'ComfyUI Live Demo & Q&A - June 29 Launch Livestream',
'zh-CN': 'ComfyUI 直播演示与问答 - 6 月 29 日发布直播'
},
'launches.page.description': {
en: 'Join the ComfyUI livestream on June 29 for a hands-on product demo and live Q&A. See whats new across desktop, cloud, and community, and get your questions answered.',

View File

@@ -7,14 +7,17 @@ import SiteFooter from '../components/common/SiteFooter.vue'
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
import { bannerConfig, getBannerData } from '../config/banner'
import { isHrefActive } from '../composables/useCurrentPath'
import {
BANNER_DISMISS_ATTR,
BANNER_STORAGE_KEY,
createBannerVersion,
evaluateBannerVisibility
} from '../utils/banner'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
import { buildPageGraph, pageContext } from '../utils/jsonLd'
import type { Crumb, JsonLdNode, WebPageType } from '../utils/jsonLd'
import JsonLdGraph from '../components/common/JsonLdGraph.astro'
interface Props {
title: string
@@ -22,6 +25,10 @@ interface Props {
keywords?: string[]
ogImage?: string
noindex?: boolean
pageType?: WebPageType
breadcrumbs?: Crumb[]
mainEntityId?: string
extraJsonLd?: (JsonLdNode | null | undefined)[]
}
const {
@@ -30,52 +37,54 @@ const {
keywords,
ogImage = 'https://media.comfy.org/website/comfy.webp',
noindex = false,
pageType,
breadcrumbs,
mainEntityId,
extraJsonLd,
} = Astro.props
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
const siteBase = Astro.site ?? 'https://comfy.org'
const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
const rawLocale = Astro.currentLocale ?? 'en'
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const canonicalURL = new URL(url)
const ogImageURL = new URL(ogImage, Astro.site ?? 'https://comfy.org')
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
// Announcement banner — build-time visibility gate + content-hash version key.
// A promo never advertises the page you are already on, so the banner is
// suppressed when its CTA points at the current path.
const bannerData = getBannerData(bannerConfig, locale)
const bannerVisible = evaluateBannerVisibility(bannerConfig, {
currentLocale: locale,
currentSection: 'sitewide',
now: new Date(),
})
const bannerVisible =
evaluateBannerVisibility(bannerConfig, {
currentLocale: locale,
currentSection: 'sitewide',
now: new Date(),
}) && !isHrefActive(bannerData.link?.href ?? '', Astro.url.pathname)
const bannerVersion = createBannerVersion(bannerData, locale)
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD
const organizationJsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org',
logo: 'https://comfy.org/icons/logomark.svg',
sameAs: [
'https://github.com/comfyanonymous/ComfyUI',
'https://discord.gg/comfyorg',
'https://x.com/comaboratory',
'https://reddit.com/r/comfyui',
'https://linkedin.com/company/comfyorg',
'https://instagram.com/comfyorg',
],
}
const websiteJsonLd = {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Comfy',
url: 'https://comfy.org',
}
const structuredData = noindex
? undefined
: buildPageGraph(
{ siteUrl, locale },
{
url,
name: title,
description,
imageUrl: ogImageURL.href,
type: pageType,
crumbs: breadcrumbs,
mainEntityId,
},
...(extraJsonLd ?? []),
)
---
<!doctype html>
@@ -117,10 +126,7 @@ 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" />
{structuredData && <JsonLdGraph graph={structuredData} />}
<slot name="head" />
<!-- Google Tag Manager -->
@@ -140,7 +146,6 @@ const websiteJsonLd = {
)}
<ClientRouter />
<slot name="head" />
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
{bannerVisible && (

View File

@@ -3,7 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro'
---
<BaseLayout
title="404 Page Not Found Comfy"
title="404 - Page Not Found - Comfy"
noindex>
<div
class="flex w-full flex-col items-center p-4 h-[calc(100dvh-12rem)]">

View File

@@ -5,9 +5,25 @@ import StorySection from '../components/about/StorySection.vue'
import OurValuesSection from '../components/about/OurValuesSection.vue'
import ValuesSection from '../components/about/ValuesSection.vue'
import CareersSection from '../components/about/CareersSection.vue'
import { t } from '../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout title="About Us — Comfy">
<BaseLayout
title="About Us - Comfy"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.about', locale) },
]}
>
<HeroSection client:load />
<StorySection />
<OurValuesSection />

View File

@@ -9,34 +9,36 @@ import HeroSection from '../../templates/affiliate/HeroSection.vue'
import HowItWorksSection from '../../templates/affiliate/HowItWorksSection.vue'
import { affiliateFaqs } from '../../data/affiliateFaq'
import { t } from '../../i18n/translations'
import type { JsonLdNode } from '../../utils/jsonLd'
import { absoluteUrl, jsonLdId, pageContext } from '../../utils/jsonLd'
const locale = 'en' as const
const faqJsonLd = {
'@context': 'https://schema.org',
const pageTitle = t('affiliate.page.title', 'en')
const pageDescription = t('affiliate.page.description', 'en')
const { locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const faqPage: JsonLdNode = {
'@type': 'FAQPage',
'@id': jsonLdId(url, 'faq'),
mainEntity: affiliateFaqs.map((faq) => ({
'@type': 'Question',
name: faq.question[locale],
acceptedAnswer: {
'@type': 'Answer',
text: faq.answer[locale]
}
}))
acceptedAnswer: { '@type': 'Answer', text: faq.answer[locale] },
})),
}
---
<BaseLayout
title={t('affiliate.page.title', locale)}
description={t('affiliate.page.description', locale)}
title={pageTitle}
description={pageDescription}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: pageTitle },
]}
extraJsonLd={[faqPage]}
>
<Fragment slot="head">
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(faqJsonLd)}
/>
</Fragment>
<HeroSection />
<HowItWorksSection />

View File

@@ -7,6 +7,13 @@ import TeamPhotosSection from '../components/careers/TeamPhotosSection.vue'
import FAQSection from '../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../utils/ashby'
import { reportAshbyOutcome } from '../utils/ashby.ci'
import { t } from '../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../utils/jsonLd'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
@@ -19,11 +26,31 @@ if (outcome.status === 'failed') {
}
const departments = outcome.snapshot.departments
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const roles = itemListNode(
url,
t('breadcrumb.careers', locale),
departments.flatMap((department) =>
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
),
)
---
<BaseLayout
title="Careers Comfy"
title="Careers - Comfy"
description="Join the team building the operating system for generative AI. Open roles in engineering, design, marketing, and more."
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.careers', locale) },
]}
extraJsonLd={[roles]}
>
<HeroSection />
<RolesSection departments={departments} client:visible />

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import ComingSoon from '../components/common/ComingSoon.astro'
---
<BaseLayout title="Case Studies Comfy">
<BaseLayout title="Case Studies - Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { t } from '../../i18n/translations'
---
<BaseLayout
title="Comfy Cloud AI in the Cloud"
title="Comfy Cloud - AI in the Cloud"
description={t('cloud.hero.subtitle', 'en')}
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'comfy cloud', 'comfy ui application', 'comfyui browser', 'cloud comfyui', 'managed comfyui']}
>

View File

@@ -2,9 +2,41 @@
import BaseLayout from '../../layouts/BaseLayout.astro'
import PriceSection from '../../components/pricing/PriceSection.vue'
import WhatsIncludedSection from '../../components/pricing/WhatsIncludedSection.vue'
import { pricingOffers } from '../../config/pricing'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
jsonLdId,
pageContext,
productNode,
} from '../../utils/jsonLd'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const productId = jsonLdId(url, 'product')
---
<BaseLayout title="Pricing — Comfy Cloud">
<BaseLayout
title="Pricing - Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{ name: t('breadcrumb.pricing', locale) },
]}
extraJsonLd={[
productNode({
siteUrl,
id: productId,
name: 'Comfy Cloud',
url,
offers: pricingOffers(locale),
}),
]}
>
<PriceSection client:load />
<WhatsIncludedSection />
</BaseLayout>

View File

@@ -4,39 +4,44 @@ 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'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../utils/jsonLd'
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,
const title = t('cloudNodes.meta.title', 'en')
const description = t('cloudNodes.meta.description', 'en')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const packList = itemListNode(
url,
title,
packs.map((pack) => ({
name: pack.displayName,
image: pack.bannerUrl || pack.iconUrl
}))
}
url: absoluteUrl(Astro.site, `/cloud/supported-nodes/${pack.id}`),
})),
)
---
<BaseLayout
title={t('cloudNodes.meta.title', 'en')}
description={t('cloudNodes.meta.description', 'en')}
title={title}
description={description}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{ name: t('breadcrumb.supportedNodes', locale) },
]}
extraJsonLd={[packList]}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(itemListJsonLd)}
/>
<HeroSection client:visible />
<PackGridSection packs={packs} client:visible />
</BaseLayout>

View File

@@ -7,7 +7,12 @@ 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'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
@@ -29,35 +34,45 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
.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',
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
name: pack.displayName,
url,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
url: pageUrl,
description,
description: pack.description || undefined,
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' }
}
authorName: pack.publisher?.name,
isFree: true,
})
---
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<BaseLayout
title={title}
description={metaDescription}
ogImage={pack.bannerUrl}
mainEntityId={softwareId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/cloud') },
{
name: t('breadcrumb.supportedNodes', locale),
url: absoluteUrl(Astro.site, '/cloud/supported-nodes'),
},
{ name: pack.displayName },
]}
extraJsonLd={[software]}
>
<PackDetail pack={pack} />
</BaseLayout>

View File

@@ -2,9 +2,25 @@
import BaseLayout from '../layouts/BaseLayout.astro'
import FormSection from '../components/contact/FormSection.vue'
import SocialProofBarSection from '../components/common/SocialProofBarSection.vue'
import { t } from '../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout title="Contact — Comfy">
<BaseLayout
title="Contact - Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.contact', locale) },
]}
>
<FormSection client:load />
<SocialProofBarSection />
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { loadStories } from '../utils/loadStories'
const stories = (await loadStories('en')).map(toCardProps)
---
<BaseLayout title="Customer Stories Comfy">
<BaseLayout title="Customer Stories - Comfy">
<HeroSection client:load />
<StorySection stories={stories} />
<FeedbackSection client:load />

View File

@@ -17,7 +17,7 @@ export async function getStaticPaths() {
const { entry, next } = Astro.props
---
<BaseLayout title={`${entry.data.title} Comfy`}>
<BaseLayout title={`${entry.data.title} - Comfy`}>
<DetailHeroSection
label={entry.data.category}
title={entry.data.title}

View File

@@ -7,6 +7,13 @@ import DemoTranscript from '../../components/demos/DemoTranscript.vue'
import DemoNavSection from '../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
import { t } from '../../i18n/translations'
import type { JsonLdNode } from '../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
organizationId,
pageContext,
} from '../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
@@ -19,94 +26,55 @@ const demo = getDemoBySlug(slug as string)!
const nextDemo = getNextDemo(slug as string)
const title = t(demo.title)
const description = t(demo.description)
const canonicalURL = new URL(`/demos/${demo.slug}`, Astro.site)
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: title,
description,
image: new URL(demo.ogImage, Astro.site).href,
totalTime: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const learningResourceJsonLd = {
'@context': 'https://schema.org',
'@type': 'LearningResource',
name: title,
description,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel: demo.difficulty === 'beginner'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const educationalLevel =
demo.difficulty === 'beginner'
? 'Beginner'
: demo.difficulty === 'intermediate'
? 'Intermediate'
: 'Advanced',
url: canonicalURL.href,
: 'Advanced'
const learningId = jsonLdId(url, 'learning')
const learningResource: JsonLdNode = {
'@type': 'LearningResource',
'@id': learningId,
name: title,
description,
url,
image: new URL(demo.ogImage, Astro.site).href,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel,
timeRequired: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
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('demos.breadcrumb.home'),
item: 'https://comfy.org'
},
{
'@type': 'ListItem',
position: 2,
name: t('demos.breadcrumb.demos'),
item: 'https://comfy.org/demos'
},
{
'@type': 'ListItem',
position: 3,
name: title
}
]
isPartOf: { '@id': jsonLdId(url, 'webpage') },
author: { '@id': organizationId(siteUrl) },
}
---
<BaseLayout
title={`${title} Comfy`}
title={`${title} - Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{
name: t('demos.breadcrumb.demos', locale),
url: absoluteUrl(Astro.site, '/demos'),
},
{ name: title },
]}
extraJsonLd={[learningResource]}
>
<Fragment slot="head">
<meta property="article:published_time" content={demo.publishedDate} />
<meta property="article:modified_time" content={demo.modifiedDate} />
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(howToJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(learningResourceJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<link rel="preconnect" href="https://demo.arcade.software" />
</Fragment>

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="Demos Comfy" description="Interactive demos and tutorials for ComfyUI.">
<BaseLayout title="Demos - Comfy" description="Interactive demos and tutorials for ComfyUI.">
<ComingSoon />
</BaseLayout>

View File

@@ -8,11 +8,29 @@ import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
import FAQSection from '../components/product/local/FAQSection.vue'
import { t } from '../i18n/translations'
import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
pageContext,
} from '../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="Download Comfy Desktop Run AI on Your Hardware"
title="Download Comfy Desktop - Run AI on Your Hardware"
description={t('download.hero.subtitle', 'en')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
>
<CloudBannerSection />

View File

@@ -5,7 +5,7 @@ import GallerySection from '../components/gallery/GallerySection.vue'
import ContactSection from '../components/gallery/ContactSection.vue'
---
<BaseLayout title="Gallery Comfy">
<BaseLayout title="Gallery - Comfy">
<HeroSection />
<GallerySection client:load />
<ContactSection />

View File

@@ -9,11 +9,28 @@ import CaseStudySpotlightSection from "../components/home/CaseStudySpotlightSect
import GetStartedSection from "../components/home/GetStartedSection.vue";
import BuildWhatSection from "../components/home/BuildWhatSection.vue";
import { t } from "../i18n/translations";
import {
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from "../utils/jsonLd";
const { siteUrl } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
);
---
<BaseLayout
title="Comfy Professional Control of Visual AI"
title="Comfy - Professional Control of Visual AI"
description={t("hero.subtitle", "en")}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[
comfyUiApplicationNode(siteUrl),
comfyUiSourceCodeNode(siteUrl),
]}
keywords={[
"comfyui app",
"comfyui web app",

View File

@@ -12,7 +12,7 @@ import { externalLinks } from '../config/routes'
const routes = getRoutes('en')
---
<BaseLayout title="Learning Comfy">
<BaseLayout title="Learning - Comfy">
<HeroSection client:load />
<FeaturedWorkflowSection client:visible />
<TutorialsSection client:visible />

View File

@@ -7,7 +7,7 @@ import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vu
---
<BaseLayout
title="Models Comfy"
title="Models - Comfy"
description="Run the world's leading AI models in ComfyUI. Browse every supported model with community workflow templates ready to run."
>
<ModelsHeroSection

View File

@@ -4,6 +4,13 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
import { models, getModelBySlug } from '../../../config/models'
import { t } from '../../../i18n/translations'
import type { JsonLdNode } from '../../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return models.map((model) => ({
@@ -19,7 +26,6 @@ if (model.canonicalSlug) {
}
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',
@@ -40,55 +46,31 @@ const dirDescriptions: Record<string, string> = {
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',
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.`
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
name: displayName,
url,
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',
})
const faqPage: JsonLdNode = {
'@type': 'FAQPage',
'@id': jsonLdId(url, 'faq'),
mainEntity: [
{
'@type': 'Question',
name: `What is ${displayName}?`,
acceptedAnswer: {
'@type': 'Answer',
text: whatIsDescription
}
acceptedAnswer: { '@type': 'Answer', text: whatIsDescription },
},
{
'@type': 'Question',
@@ -97,54 +79,44 @@ const faqJsonLd = {
'@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.`
}
: `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.`
}
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.`
}
}
]
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`}
title={`${pageTitle} - Comfy`}
description={pageDescription}
ogImage={model.thumbnailUrl}
mainEntityId={softwareId}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{
name: t('models.breadcrumb.models', locale),
url: absoluteUrl(Astro.site, '/p/supported-models'),
},
{ name: displayName },
]}
extraJsonLd={[software, faqPage]}
>
<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}

View File

@@ -2,10 +2,29 @@
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { models } from '../../../config/models'
import { t } from '../../../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../../utils/jsonLd'
const title = t('models.index.title')
const subtitle = t('models.index.subtitle')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const modelList = itemListNode(
url,
title,
models.map((model) => ({
url: absoluteUrl(Astro.site, `/p/supported-models/${model.slug}`),
})),
)
const dirLabel: Record<string, string> = {
diffusion_models: 'Diffusion',
checkpoints: 'Checkpoint',
@@ -24,8 +43,15 @@ const dirLabel: Record<string, string> = {
---
<BaseLayout
title={`${title} Comfy`}
title={`${title} - Comfy`}
description={subtitle}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
{ name: title },
]}
extraJsonLd={[modelList]}
>
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-24">
<header class="mb-12">

View File

@@ -4,7 +4,7 @@ import PaymentStatusSection from '../../components/payment/PaymentStatusSection.
---
<BaseLayout
title="Payment Failed Comfy"
title="Payment Failed - Comfy"
description="Your payment was not completed."
noindex
>

View File

@@ -4,7 +4,7 @@ import PaymentStatusSection from '../../components/payment/PaymentStatusSection.
---
<BaseLayout
title="Payment Successful Comfy"
title="Payment Successful - Comfy"
description="Your payment was processed successfully."
noindex
>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../components/legal/HeroSection.vue'
---
<BaseLayout
title="Privacy Policy Comfy"
title="Privacy Policy - Comfy"
description="Comfy privacy policy. Learn how we collect, use, and protect your personal information."
noindex
>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../../components/legal/HeroSection.vue'
---
<BaseLayout
title="Desktop Privacy Policy Comfy"
title="Desktop Privacy Policy - Comfy"
description="Privacy policy for Comfy Desktop. Named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights."
>
<HeroSection title="Desktop Privacy Policy" />

View File

@@ -6,7 +6,7 @@ import { t } from '../i18n/translations'
---
<BaseLayout
title="Terms of Service Comfy"
title="Terms of Service - Comfy"
description="Terms of Service governing use of the Comfy Products, including Comfy Cloud, Comfy API, and Comfy Enterprise."
noindex
>

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import ComingSoon from '../components/common/ComingSoon.astro'
---
<BaseLayout title="Videos Comfy">
<BaseLayout title="Videos - Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -5,9 +5,29 @@ import StorySection from '../../components/about/StorySection.vue'
import OurValuesSection from '../../components/about/OurValuesSection.vue'
import ValuesSection from '../../components/about/ValuesSection.vue'
import CareersSection from '../../components/about/CareersSection.vue'
import { t } from '../../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
<BaseLayout
title="关于我们 - Comfy"
description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。"
pageType="AboutPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.about', locale) },
]}
>
<HeroSection locale="zh-CN" client:load />
<StorySection locale="zh-CN" />
<OurValuesSection locale="zh-CN" />

View File

@@ -7,6 +7,13 @@ import TeamPhotosSection from '../../components/careers/TeamPhotosSection.vue'
import FAQSection from '../../components/common/FAQSection.vue'
import { fetchRolesForBuild } from '../../utils/ashby'
import { reportAshbyOutcome } from '../../utils/ashby.ci'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../utils/jsonLd'
const outcome = await fetchRolesForBuild()
reportAshbyOutcome(outcome)
@@ -19,11 +26,34 @@ if (outcome.status === 'failed') {
}
const departments = outcome.snapshot.departments
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const roles = itemListNode(
url,
t('breadcrumb.careers', locale),
departments.flatMap((department) =>
department.roles.map((role) => ({ name: role.title, url: role.jobUrl })),
),
)
---
<BaseLayout
title="招聘 Comfy"
title="招聘 - Comfy"
description="加入构建生成式 AI 操作系统的团队。工程、设计、市场营销等岗位开放招聘中。"
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.careers', locale) },
]}
extraJsonLd={[roles]}
>
<HeroSection locale="zh-CN" />
<RolesSection locale="zh-CN" departments={departments} client:visible />

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="案例研究 Comfy">
<BaseLayout title="案例研究 - Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { t } from '../../../i18n/translations'
---
<BaseLayout
title="Comfy Cloud 云端 AI"
title="Comfy Cloud - 云端 AI"
description={t('cloud.hero.subtitle', 'zh-CN')}
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'ComfyUI 网页版', 'ComfyUI 云端', 'ComfyUI 应用', 'Comfy Cloud', '云端 ComfyUI']}
>

View File

@@ -2,9 +2,44 @@
import BaseLayout from '../../../layouts/BaseLayout.astro'
import PriceSection from '../../../components/pricing/PriceSection.vue'
import WhatsIncludedSection from '../../../components/pricing/WhatsIncludedSection.vue'
import { pricingOffers } from '../../../config/pricing'
import { t } from '../../../i18n/translations'
import {
absoluteUrl,
jsonLdId,
pageContext,
productNode,
} from '../../../utils/jsonLd'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const productId = jsonLdId(url, 'product')
---
<BaseLayout title="定价 — Comfy Cloud">
<BaseLayout
title="定价 - Comfy Cloud"
mainEntityId={productId}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
{ name: t('breadcrumb.pricing', locale) },
]}
extraJsonLd={[
productNode({
siteUrl,
id: productId,
name: 'Comfy Cloud',
url,
offers: pricingOffers(locale),
}),
]}
>
<PriceSection locale="zh-CN" client:load />
<WhatsIncludedSection locale="zh-CN" />
</BaseLayout>

View File

@@ -4,39 +4,47 @@ 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'
import {
absoluteUrl,
itemListNode,
jsonLdId,
pageContext,
} from '../../../utils/jsonLd'
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,
const title = t('cloudNodes.meta.title', 'zh-CN')
const description = t('cloudNodes.meta.description', 'zh-CN')
const { url, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const packList = itemListNode(
url,
title,
packs.map((pack) => ({
name: pack.displayName,
image: pack.bannerUrl || pack.iconUrl
}))
}
url: absoluteUrl(Astro.site, `/zh-CN/cloud/supported-nodes/${pack.id}`),
})),
)
---
<BaseLayout
title={t('cloudNodes.meta.title', 'zh-CN')}
description={t('cloudNodes.meta.description', 'zh-CN')}
title={title}
description={description}
pageType="CollectionPage"
mainEntityId={jsonLdId(url, 'itemlist')}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
{ name: t('breadcrumb.supportedNodes', locale) },
]}
extraJsonLd={[packList]}
>
<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

@@ -7,7 +7,12 @@ 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'
import {
absoluteUrl,
jsonLdId,
pageContext,
softwareApplicationNode,
} from '../../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = async () => {
const packs = await loadPacksForBuild()
@@ -29,35 +34,48 @@ const metaDescription = t('cloudNodes.detail.metaDescription', 'zh-CN')
.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',
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const softwareId = jsonLdId(url, 'software')
const software = softwareApplicationNode({
siteUrl,
id: softwareId,
name: pack.displayName,
url,
applicationCategory: 'DeveloperApplication',
applicationSubCategory: 'ComfyUI custom-node pack',
operatingSystem: 'Comfy Cloud (managed)',
url: pageUrl,
description,
description: pack.description || undefined,
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' }
}
authorName: pack.publisher?.name,
isFree: true,
})
---
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={escapeJsonLd(softwareJsonLd)}
/>
<BaseLayout
title={title}
description={metaDescription}
ogImage={pack.bannerUrl}
mainEntityId={softwareId}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: 'Comfy Cloud', url: absoluteUrl(Astro.site, '/zh-CN/cloud') },
{
name: t('breadcrumb.supportedNodes', locale),
url: absoluteUrl(Astro.site, '/zh-CN/cloud/supported-nodes'),
},
{ name: pack.displayName },
]}
extraJsonLd={[software]}
>
<PackDetail pack={pack} locale="zh-CN" />
</BaseLayout>

View File

@@ -2,9 +2,28 @@
import BaseLayout from '../../layouts/BaseLayout.astro'
import FormSection from '../../components/contact/FormSection.vue'
import SocialProofBarSection from '../../components/common/SocialProofBarSection.vue'
import { t } from '../../i18n/translations'
import { absoluteUrl, organizationId, pageContext } from '../../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout title="联系我们 — Comfy">
<BaseLayout
title="联系我们 - Comfy"
pageType="ContactPage"
mainEntityId={organizationId(siteUrl)}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.contact', locale) },
]}
>
<FormSection locale="zh-CN" client:load />
<SocialProofBarSection />
</BaseLayout>

View File

@@ -11,7 +11,7 @@ import { loadStories } from '../../utils/loadStories'
const stories = (await loadStories('zh-CN')).map(toCardProps)
---
<BaseLayout title="客户故事 Comfy">
<BaseLayout title="客户故事 - Comfy">
<HeroSection locale="zh-CN" client:load />
<StorySection stories={stories} locale="zh-CN" />
<FeedbackSection locale="zh-CN" client:load />

View File

@@ -17,7 +17,7 @@ export async function getStaticPaths() {
const { entry, next } = Astro.props
---
<BaseLayout title={`${entry.data.title} Comfy`}>
<BaseLayout title={`${entry.data.title} - Comfy`}>
<DetailHeroSection
label={entry.data.category}
title={entry.data.title}

View File

@@ -7,6 +7,13 @@ import DemoTranscript from '../../../components/demos/DemoTranscript.vue'
import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
import { t } from '../../../i18n/translations'
import type { JsonLdNode } from '../../../utils/jsonLd'
import {
absoluteUrl,
jsonLdId,
organizationId,
pageContext,
} from '../../../utils/jsonLd'
export const getStaticPaths: GetStaticPaths = () => {
return demos.map((demo) => ({
@@ -19,94 +26,58 @@ const demo = getDemoBySlug(slug as string)!
const nextDemo = getNextDemo(slug as string)
const title = t(demo.title, 'zh-CN')
const description = t(demo.description, 'zh-CN')
const canonicalURL = new URL(`/zh-CN/demos/${demo.slug}`, Astro.site)
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: title,
description,
image: new URL(demo.ogImage, Astro.site).href,
totalTime: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
author: {
'@type': 'Organization',
name: 'Comfy Org',
url: 'https://comfy.org'
}
}
const learningResourceJsonLd = {
'@context': 'https://schema.org',
'@type': 'LearningResource',
name: title,
description,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel: demo.difficulty === 'beginner'
const { siteUrl, locale, url } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
const educationalLevel =
demo.difficulty === 'beginner'
? 'Beginner'
: demo.difficulty === 'intermediate'
? 'Intermediate'
: 'Advanced',
url: canonicalURL.href,
: 'Advanced'
const learningId = jsonLdId(url, 'learning')
const learningResource: JsonLdNode = {
'@type': 'LearningResource',
'@id': learningId,
name: title,
description,
url,
image: new URL(demo.ogImage, Astro.site).href,
learningResourceType: 'interactive tutorial',
interactivityType: 'active',
educationalLevel,
timeRequired: demo.durationIso,
datePublished: demo.publishedDate,
dateModified: demo.modifiedDate,
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('demos.breadcrumb.home', 'zh-CN'),
item: 'https://comfy.org/zh-CN'
},
{
'@type': 'ListItem',
position: 2,
name: t('demos.breadcrumb.demos', 'zh-CN'),
item: 'https://comfy.org/zh-CN/demos'
},
{
'@type': 'ListItem',
position: 3,
name: title
}
]
isPartOf: { '@id': jsonLdId(url, 'webpage') },
author: { '@id': organizationId(siteUrl) },
}
---
<BaseLayout
title={`${title} Comfy`}
title={`${title} - Comfy`}
description={description}
ogImage={demo.ogImage}
mainEntityId={learningId}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{
name: t('demos.breadcrumb.demos', locale),
url: absoluteUrl(Astro.site, '/zh-CN/demos'),
},
{ name: title },
]}
extraJsonLd={[learningResource]}
>
<Fragment slot="head">
<meta property="article:published_time" content={demo.publishedDate} />
<meta property="article:modified_time" content={demo.modifiedDate} />
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(howToJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(learningResourceJsonLd)}
/>
<script
is:inline
type="application/ld+json"
set:html={JSON.stringify(breadcrumbJsonLd)}
/>
<link rel="preconnect" href="https://demo.arcade.software" />
</Fragment>

View File

@@ -3,7 +3,7 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import { t } from '../../../i18n/translations'
---
<BaseLayout title="演示 Comfy" description="ComfyUI 的互动演示和教程。">
<BaseLayout title="演示 - Comfy" description="ComfyUI 的互动演示和教程。">
<section class="flex min-h-[60vh] items-center justify-center px-6">
<div class="text-center">
<h1 class="text-primary-comfy-canvas text-4xl font-light">

View File

@@ -8,11 +8,32 @@ import EcoSystemSection from '../../components/product/local/EcoSystemSection.vu
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
import FAQSection from '../../components/product/local/FAQSection.vue'
import { t } from '../../i18n/translations'
import {
absoluteUrl,
comfyUiApplicationNode,
comfyUiSoftwareId,
pageContext,
} from '../../utils/jsonLd'
const { siteUrl, locale } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="下载 Comfy 桌面版 在您的硬件上运行 AI"
title="下载 Comfy 桌面版 - 在您的硬件上运行 AI"
description={t('download.hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
breadcrumbs={[
{
name: t('breadcrumb.home', locale),
url: absoluteUrl(Astro.site, '/zh-CN'),
},
{ name: t('breadcrumb.download', locale) },
]}
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
>
<CloudBannerSection locale="zh-CN" />

View File

@@ -5,7 +5,7 @@ import GallerySection from '../../components/gallery/GallerySection.vue'
import ContactSection from '../../components/gallery/ContactSection.vue'
---
<BaseLayout title="作品集 Comfy">
<BaseLayout title="作品集 - Comfy">
<HeroSection locale="zh-CN" />
<GallerySection locale="zh-CN" client:load />
<ContactSection locale="zh-CN" />

View File

@@ -9,11 +9,25 @@ import CaseStudySpotlightSection from '../../components/home/CaseStudySpotlightS
import GetStartedSection from '../../components/home/GetStartedSection.vue'
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
import { t } from '../../i18n/translations'
import {
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
pageContext,
} from '../../utils/jsonLd'
const { siteUrl } = pageContext(
Astro.site,
Astro.url.pathname,
Astro.currentLocale,
)
---
<BaseLayout
title="Comfy 视觉 AI 的最强可控性"
title="Comfy - 视觉 AI 的最强可控性"
description={t('hero.subtitle', 'zh-CN')}
mainEntityId={comfyUiSoftwareId(siteUrl)}
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
keywords={['comfyui app', 'comfyui web app', 'comfyui application', 'ComfyUI 应用', 'ComfyUI 网页版', 'ComfyUI 桌面应用', 'ComfyUI 下载', '可视化 AI', '节点式 AI', '生成式 AI 工作流']}
>
<HeroSection locale="zh-CN" client:load />

View File

@@ -11,7 +11,7 @@ import { learningEvents } from '../../data/events'
const routes = getRoutes('zh-CN')
---
<BaseLayout title="学习 Comfy">
<BaseLayout title="学习 - Comfy">
<HeroSection locale="zh-CN" client:load />
<FeaturedWorkflowSection locale="zh-CN" client:visible />
<TutorialsSection locale="zh-CN" client:visible />

View File

@@ -7,7 +7,7 @@ import ProductShowcaseSection from '../../components/home/ProductShowcaseSection
---
<BaseLayout
title="模型 Comfy"
title="模型 - Comfy"
description="在 ComfyUI 中运行世界领先的 AI 模型。浏览所有支持的模型及社区工作流模板。"
>
<ModelsHeroSection

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
---
<BaseLayout title="支付失败 Comfy" description="您的支付未能完成。" noindex>
<BaseLayout title="支付失败 - Comfy" description="您的支付未能完成。" noindex>
<PaymentStatusSection status="failed" locale="zh-CN" />
</BaseLayout>

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../../layouts/BaseLayout.astro'
import PaymentStatusSection from '../../../components/payment/PaymentStatusSection.vue'
---
<BaseLayout title="支付成功 Comfy" description="您的支付已成功完成。" noindex>
<BaseLayout title="支付成功 - Comfy" description="您的支付已成功完成。" noindex>
<PaymentStatusSection status="success" locale="zh-CN" />
</BaseLayout>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../../components/legal/HeroSection.vue'
---
<BaseLayout
title="隐私政策 Comfy"
title="隐私政策 - Comfy"
description="Comfy 隐私政策。了解我们如何收集、使用和保护您的个人信息。"
noindex
>

View File

@@ -5,7 +5,7 @@ import HeroSection from '../../../components/legal/HeroSection.vue'
---
<BaseLayout
title="Desktop 隐私政策 Comfy"
title="Desktop 隐私政策 - Comfy"
description="Comfy Desktop 隐私政策。命名的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限和您的权利。"
>
<HeroSection title="Desktop 隐私政策" />

View File

@@ -3,6 +3,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import ComingSoon from '../../components/common/ComingSoon.astro'
---
<BaseLayout title="视频 Comfy">
<BaseLayout title="视频 - Comfy">
<ComingSoon />
</BaseLayout>

View File

@@ -61,7 +61,12 @@ const { drop, locale } = defineProps<{
class="size-full object-cover object-center transition-transform duration-500 ease-out group-hover/pill-trigger:scale-105"
/>
</div>
<Badge v-if="drop.badge" variant="accent" class="absolute top-6 left-8">
<Badge
v-if="drop.badge"
size="xs"
variant="accent"
class="absolute top-6 left-8"
>
{{ drop.badge[locale] }}
</Badge>
</CardContent>

View File

@@ -17,7 +17,7 @@ const ctas = mcpCtas(locale)
badge-text="MCP"
:title="t('mcp.hero.heading', locale)"
:subtitle="t('mcp.hero.subtitle', locale)"
:primary-cta="ctas.runWorkflow"
:primary-cta="ctas.installMcp"
:secondary-cta="ctas.docs"
>
<template #media>

View File

@@ -17,7 +17,10 @@ const cards: FeatureCard[] = [
description: t('mcp.setup.step1.description', locale),
action: {
type: 'code',
value: externalLinks.mcpServer
value: t('mcp.setup.step1.command', locale).replace(
'{url}',
externalLinks.docsMcp
)
}
},
{
@@ -53,6 +56,8 @@ const cards: FeatureCard[] = [
<template>
<FeatureGrid01
id="setup"
class="scroll-mt-24 lg:scroll-mt-36"
:eyebrow="t('mcp.setup.label', locale)"
:heading="t('mcp.setup.heading', locale)"
:subtitle="t('mcp.setup.subtitle', locale)"

View File

@@ -9,16 +9,25 @@ export interface McpCta {
}
/**
* The two calls-to-action shared by the MCP hero and "how it works" sections:
* view the docs, or run a workflow in the cloud.
* Calls-to-action for the MCP page: view the docs, jump to the on-page setup
* steps, or run a workflow in the cloud. The hero leads with install + docs;
* the "how it works" section pairs run-a-workflow with docs.
*/
export function mcpCtas(locale: Locale): { docs: McpCta; runWorkflow: McpCta } {
export function mcpCtas(locale: Locale): {
docs: McpCta
installMcp: McpCta
runWorkflow: McpCta
} {
return {
docs: {
label: t('mcp.hero.viewDocs', locale),
href: externalLinks.docsMcp,
target: '_blank'
},
installMcp: {
label: t('mcp.hero.installMcp', locale),
href: '#setup'
},
runWorkflow: {
label: t('mcp.hero.runWorkflow', locale),
href: getRoutes(locale).cloud

View File

@@ -0,0 +1,212 @@
import { describe, expect, it } from 'vitest'
import { externalLinks } from '../config/routes'
import { escapeJsonLd } from './escapeJsonLd'
import type { JsonLdGraph } from './jsonLd'
import {
absoluteUrl,
buildPageGraph,
collectGraphIds,
comfyUiApplicationNode,
comfyUiSoftwareId,
comfyUiSourceCodeNode,
itemListNode,
jsonLdId,
organizationId,
pageContext,
productNode,
softwareApplicationNode
} from './jsonLd'
const siteUrl = 'https://comfy.org'
const site = new URL('https://comfy.org/')
function typeNames(graph: JsonLdGraph): string[] {
return graph['@graph'].map((node) => node['@type'])
}
describe('absoluteUrl', () => {
it('resolves internal paths to their trailing-slash canonical form', () => {
expect(absoluteUrl(site, '/cloud')).toBe('https://comfy.org/cloud/')
expect(absoluteUrl(site, '/about/')).toBe('https://comfy.org/about/')
expect(absoluteUrl(site, '/')).toBe('https://comfy.org/')
})
})
describe('pageContext', () => {
it('derives siteUrl, locale and canonical url from the Astro globals', () => {
expect(pageContext(site, '/about/', undefined)).toEqual({
siteUrl,
locale: 'en',
url: 'https://comfy.org/about/'
})
expect(pageContext(site, '/zh-CN/', 'zh-CN').locale).toBe('zh-CN')
})
})
describe('itemListNode', () => {
it('counts items and omits per-item names when not supplied', () => {
const node = itemListNode('https://comfy.org/careers/', 'Careers', [
{ url: 'https://jobs.example/1' },
{ url: 'https://jobs.example/2', name: 'Designer' }
])
expect(node.numberOfItems).toBe(2)
const items = node.itemListElement as Record<string, unknown>[]
expect('name' in items[0]).toBe(false)
expect(items[1].name).toBe('Designer')
})
})
describe('softwareApplicationNode', () => {
it('claims Comfy Org as author and publisher only when first-party', () => {
const node = softwareApplicationNode({
siteUrl,
id: jsonLdId(siteUrl, 'software'),
name: 'ComfyUI',
url: siteUrl,
firstParty: true,
applicationCategory: 'MultimediaApplication',
isFree: true
})
const orgRef = { '@id': organizationId(siteUrl) }
expect(node.author).toEqual(orgRef)
expect(node.publisher).toEqual(orgRef)
expect(node.offers).toEqual({
'@type': 'Offer',
price: 0,
priceCurrency: 'USD',
seller: orgRef
})
})
it('does not name Comfy Org as seller on a third-party free offer', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
name: 'Foo Pack',
url: 'https://comfy.org/cloud/supported-nodes/foo/',
applicationCategory: 'DeveloperApplication',
isFree: true
})
expect((node.offers as Record<string, unknown>).seller).toBeUndefined()
})
it('credits a known third-party author without claiming to publish it', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/cloud/supported-nodes/foo/#software',
name: 'Foo Pack',
url: 'https://comfy.org/cloud/supported-nodes/foo/',
applicationCategory: 'DeveloperApplication',
authorName: 'Jane Dev'
})
expect(node.author).toEqual({ '@type': 'Person', name: 'Jane Dev' })
expect(node.publisher).toBeUndefined()
})
it('claims no author or publisher for third-party software with no author', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/p/supported-models/foo/#software',
name: 'Foo Model',
url: 'https://comfy.org/p/supported-models/foo/',
applicationCategory: 'MultimediaApplication'
})
expect(node.author).toBeUndefined()
expect(node.publisher).toBeUndefined()
})
})
describe('sameAs encyclopedic references', () => {
it('links the organization to its Wikidata entity', () => {
const graph = buildPageGraph(
{ siteUrl, locale: 'en' },
{ url: `${siteUrl}/`, name: 'Home' }
)
const org = graph['@graph'].find((node) => node['@type'] === 'Organization')
expect(org?.sameAs).toContain(externalLinks.wikidataComfyOrg)
})
it('links the ComfyUI application to its Wikidata, Wikipedia and G2 entities', () => {
const node = comfyUiApplicationNode(siteUrl)
expect(node.sameAs).toEqual([
externalLinks.wikidataComfyUi,
externalLinks.wikipediaComfyUi,
externalLinks.g2ComfyUi
])
})
it('omits sameAs for third-party software', () => {
const node = softwareApplicationNode({
siteUrl,
id: 'https://comfy.org/p/supported-models/foo/#software',
name: 'Foo Model',
url: 'https://comfy.org/p/supported-models/foo/',
applicationCategory: 'MultimediaApplication'
})
expect(node.sameAs).toBeUndefined()
})
})
describe('productNode', () => {
it('gives every offer a currency and price', () => {
const node = productNode({
siteUrl,
id: 'https://comfy.org/cloud/pricing/#product',
name: 'Comfy Cloud',
url: 'https://comfy.org/cloud/pricing/',
offers: [{ name: 'Standard', price: '20' }]
})
const offers = node.offers as Record<string, unknown>[]
expect(offers[0].price).toBe('20')
expect(offers[0].priceCurrency).toBe('USD')
expect(offers[0].seller).toEqual({ '@id': organizationId(siteUrl) })
})
})
describe('comfyUiSourceCodeNode', () => {
it('links the source code to the ComfyUI application via targetProduct', () => {
const node = comfyUiSourceCodeNode(siteUrl)
expect(node.targetProduct).toEqual({ '@id': comfyUiSoftwareId(siteUrl) })
})
})
describe('buildPageGraph', () => {
const url = 'https://comfy.org/cloud/pricing/'
const graph = buildPageGraph(
{ siteUrl, locale: 'en' },
{
url,
name: 'Pricing',
type: 'CollectionPage',
mainEntityId: jsonLdId(url, 'itemlist'),
crumbs: [{ name: 'Home', url: `${siteUrl}/` }, { name: 'Pricing' }]
},
itemListNode(url, 'Plans', [{ url: `${siteUrl}/one/` }])
)
it('always includes the site-wide organization, website and page entity', () => {
expect(typeNames(graph)).toContain('Organization')
expect(typeNames(graph)).toContain('WebSite')
expect(typeNames(graph)).toContain('CollectionPage')
})
it('produces a graph where every @id reference resolves', () => {
const { defined, references } = collectGraphIds(graph)
for (const reference of references) {
expect(defined.has(reference)).toBe(true)
}
})
})
describe('escapeJsonLd on a built graph', () => {
it('neutralizes a </script> breakout in a page name', () => {
const graph = buildPageGraph(
{ siteUrl, locale: 'en' },
{ url: `${siteUrl}/x/`, name: '</script><script>alert(1)</script>' }
)
const serialized = escapeJsonLd(graph)
expect(serialized).not.toContain('</script>')
expect(serialized).toContain('\\u003c')
})
})

View File

@@ -0,0 +1,377 @@
import { externalLinks } from '../config/routes'
import type { Locale } from '../i18n/translations'
export type JsonLdNode = Record<string, unknown> & { '@type': string }
export interface JsonLdGraph {
'@context': 'https://schema.org'
'@graph': JsonLdNode[]
}
export interface PageContext {
siteUrl: string
locale: Locale
}
export type WebPageType =
| 'WebPage'
| 'AboutPage'
| 'ContactPage'
| 'CollectionPage'
export interface Crumb {
name: string
url?: string
}
const sameAs = [
externalLinks.github,
externalLinks.x,
externalLinks.youtube,
externalLinks.discord,
externalLinks.instagram,
externalLinks.reddit,
externalLinks.linkedin,
// Wikidata entity for the organization, so the Knowledge Graph can resolve it.
externalLinks.wikidataComfyOrg
]
// Authoritative encyclopedic and review-platform references for the ComfyUI software entity.
const comfyUiSameAs = [
externalLinks.wikidataComfyUi,
externalLinks.wikipediaComfyUi,
externalLinks.g2ComfyUi
]
function siteUrlFrom(site: URL | undefined): string {
return (site?.href ?? 'https://comfy.org/').replace(/\/$/, '')
}
export function absoluteUrl(site: URL | undefined, path: string): string {
const resolved = new URL(path, site ?? 'https://comfy.org').href
return resolved.endsWith('/') ? resolved : `${resolved}/`
}
export function pageContext(
site: URL | undefined,
pathname: string,
currentLocale: string | undefined
): PageContext & { url: string } {
return {
siteUrl: siteUrlFrom(site),
locale: currentLocale === 'zh-CN' ? 'zh-CN' : 'en',
url: absoluteUrl(site, pathname)
}
}
export function jsonLdId(pageUrl: string, fragment: string): string {
return `${pageUrl}#${fragment}`
}
export function organizationId(siteUrl: string): string {
return `${siteUrl}/#organization`
}
function websiteId(siteUrl: string): string {
return `${siteUrl}/#website`
}
function buildGraph(...nodes: (JsonLdNode | null | undefined)[]): JsonLdGraph {
return {
'@context': 'https://schema.org',
'@graph': nodes.filter((node): node is JsonLdNode => Boolean(node))
}
}
function organizationNode(siteUrl: string): JsonLdNode {
return {
'@type': 'Organization',
'@id': organizationId(siteUrl),
name: 'Comfy Org',
url: siteUrl,
logo: {
'@type': 'ImageObject',
url: `${siteUrl}/web-app-manifest-512x512.png`,
width: 512,
height: 512
},
sameAs
}
}
function websiteNode(siteUrl: string): JsonLdNode {
return {
'@type': 'WebSite',
'@id': websiteId(siteUrl),
name: 'Comfy',
url: siteUrl,
publisher: { '@id': organizationId(siteUrl) }
}
}
function breadcrumbNode(pageUrl: string, crumbs: Crumb[]): JsonLdNode {
return {
'@type': 'BreadcrumbList',
'@id': jsonLdId(pageUrl, 'breadcrumb'),
itemListElement: crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1
return isLast || !crumb.url
? { '@type': 'ListItem', position: index + 1, name: crumb.name }
: {
'@type': 'ListItem',
position: index + 1,
name: crumb.name,
item: crumb.url
}
})
}
}
export function itemListNode(
pageUrl: string,
name: string,
items: { url: string; name?: string }[]
): JsonLdNode {
return {
'@type': 'ItemList',
'@id': jsonLdId(pageUrl, 'itemlist'),
name,
numberOfItems: items.length,
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
url: item.url,
...(item.name ? { name: item.name } : {})
}))
}
}
interface WebPageInput {
siteUrl: string
locale: Locale
url: string
name: string
description?: string
imageUrl?: string
crumbs?: Crumb[]
mainEntityId?: string
}
function webPageNode(input: WebPageInput, type: WebPageType): JsonLdNode {
const hasCrumbs = Boolean(input.crumbs && input.crumbs.length > 0)
return {
'@type': type,
'@id': jsonLdId(input.url, 'webpage'),
url: input.url,
name: input.name,
description: input.description,
isPartOf: { '@id': websiteId(input.siteUrl) },
primaryImageOfPage: input.imageUrl
? { '@type': 'ImageObject', url: input.imageUrl }
: undefined,
breadcrumb: hasCrumbs
? { '@id': jsonLdId(input.url, 'breadcrumb') }
: undefined,
mainEntity: input.mainEntityId ? { '@id': input.mainEntityId } : undefined,
inLanguage: input.locale
}
}
export interface SoftwareAppInput {
siteUrl: string
id: string
name: string
url: string
applicationCategory: string
firstParty?: boolean
applicationSubCategory?: string
description?: string
operatingSystem?: string
image?: string
softwareVersion?: string
license?: string
codeRepository?: string
authorName?: string
isFree?: boolean
sameAs?: string[]
}
export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
const orgRef = { '@id': organizationId(input.siteUrl) }
const author = input.firstParty
? orgRef
: input.authorName
? { '@type': 'Person', name: input.authorName }
: undefined
return {
'@type': 'SoftwareApplication',
'@id': input.id,
name: input.name,
url: input.url,
applicationCategory: input.applicationCategory,
applicationSubCategory: input.applicationSubCategory,
description: input.description,
operatingSystem: input.operatingSystem,
image: input.image,
softwareVersion: input.softwareVersion,
license: input.license,
codeRepository: input.codeRepository,
author,
publisher: input.firstParty ? orgRef : undefined,
sameAs: input.sameAs,
offers: input.isFree
? {
'@type': 'Offer',
price: 0,
priceCurrency: 'USD',
seller: input.firstParty ? orgRef : undefined
}
: undefined
}
}
interface SourceCodeInput {
siteUrl: string
id: string
name: string
codeRepository: string
programmingLanguage?: string
targetProductId?: string
}
function softwareSourceCodeNode(input: SourceCodeInput): JsonLdNode {
return {
'@type': 'SoftwareSourceCode',
'@id': input.id,
name: input.name,
codeRepository: input.codeRepository,
programmingLanguage: input.programmingLanguage,
targetProduct: input.targetProductId
? { '@id': input.targetProductId }
: undefined,
author: { '@id': organizationId(input.siteUrl) }
}
}
export function comfyUiSoftwareId(siteUrl: string): string {
return `${siteUrl}/#software`
}
export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
return softwareApplicationNode({
siteUrl,
id: comfyUiSoftwareId(siteUrl),
name: 'ComfyUI',
url: siteUrl,
firstParty: true,
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Windows, macOS, Linux',
isFree: true,
sameAs: comfyUiSameAs
})
}
export function comfyUiSourceCodeNode(siteUrl: string): JsonLdNode {
return softwareSourceCodeNode({
siteUrl,
id: `${siteUrl}/#sourcecode`,
name: 'ComfyUI',
codeRepository: externalLinks.github,
programmingLanguage: 'Python',
targetProductId: comfyUiSoftwareId(siteUrl)
})
}
interface OfferInput {
name: string
price: string | number
url?: string
}
export interface ProductInput {
siteUrl: string
id: string
name: string
url: string
offers: OfferInput[]
}
export function productNode(input: ProductInput): JsonLdNode {
return {
'@type': 'Product',
'@id': input.id,
name: input.name,
url: input.url,
brand: { '@id': organizationId(input.siteUrl) },
offers: input.offers.map((offer) => ({
'@type': 'Offer',
name: offer.name,
price: offer.price,
priceCurrency: 'USD',
url: offer.url,
seller: { '@id': organizationId(input.siteUrl) },
priceSpecification: {
'@type': 'UnitPriceSpecification',
price: offer.price,
priceCurrency: 'USD',
unitText: 'MONTH'
}
}))
}
}
export interface PageGraphInput {
url: string
name: string
type?: WebPageType
description?: string
imageUrl?: string
crumbs?: Crumb[]
mainEntityId?: string
}
export function buildPageGraph(
ctx: PageContext,
page: PageGraphInput,
...extraNodes: (JsonLdNode | null | undefined)[]
): JsonLdGraph {
const { type = 'WebPage', ...rest } = page
const input: WebPageInput = {
...rest,
siteUrl: ctx.siteUrl,
locale: ctx.locale
}
const hasCrumbs = Boolean(page.crumbs && page.crumbs.length > 0)
return buildGraph(
organizationNode(ctx.siteUrl),
websiteNode(ctx.siteUrl),
webPageNode(input, type),
hasCrumbs ? breadcrumbNode(page.url, page.crumbs!) : undefined,
...extraNodes
)
}
export function collectGraphIds(value: unknown): {
defined: Set<string>
references: string[]
} {
const defined = new Set<string>()
const references: string[] = []
const walk = (node: unknown): void => {
if (Array.isArray(node)) {
node.forEach(walk)
return
}
if (node && typeof node === 'object') {
const record = node as Record<string, unknown>
const id = record['@id']
if (typeof id === 'string') {
if (Object.keys(record).length === 1) references.push(id)
else defined.add(id)
}
Object.values(record).forEach(walk)
}
}
walk(value)
return { defined, references }
}

View File

@@ -123,6 +123,15 @@ Browser tests in this project follow a specific organization pattern:
- **Utilities**: Located in `utils/` - Common utility functions
- `litegraphUtils.ts` - Utilities for working with LiteGraph nodes
### Custom-node regression suite
`tests/customNodes/` holds the manifest-driven suite that proves community
custom-node packs load, render in both renderers (LiteGraph canvas and Vue
Nodes 2.0), and execute real workflows. It has its own prerequisites, pnpm
scripts (`pnpm test:custom-nodes` and per-pack variants), and a
one-JSON-row process for adding packs - see
[tests/customNodes/README.md](tests/customNodes/README.md).
## Writing Effective Tests
When writing new tests, follow these patterns:

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