Compare commits

..

10 Commits

Author SHA1 Message Date
glary-bot
c2433d7b35 fix(affiliates-terms): bump effective date to May 16, 2026
Roll the effective date forward to today so the ship date and the
published Effective Date match. The previous May 15 value was set on
2026-05-14 to be 'tomorrow' relative to that day; shipping has slipped
into May 16, so the date is now stale by one day.

Verified: typecheck (90 files, 0 errors), 36/36 unit tests, format
check clean, oxlint clean, build clean (52 pages, /affiliates/terms
emits 'Effective Date: May 16, 2026', no /zh-CN/affiliates route).
2026-05-16 08:53:33 +00:00
glary-bot
d98cc41146 fix(routes): make affiliateTerms locale-invariant in getRoutes
Re-prefixing /affiliates/terms with /zh-CN/ would produce a dead link
now that the localized affiliate-terms route has been removed (no
zh-CN page is served from the legal-reviewed English-only document).

Today nothing in apps/website/src or apps/website/e2e calls
getRoutes(<non-en>).affiliateTerms, so this is not user-visible. The
change pre-empts a future regression: most likely from the sibling
affiliates landing PR (#12002), whose footer 'Read the affiliate
program terms' link could route through getRoutes(locale).affiliateTerms
and 404 in zh-CN.

Implementation:
- Add a LOCALE_INVARIANT_ROUTE_KEYS set in src/config/routes.ts and
  skip the /<locale>/ prefix for keys in that set. affiliateTerms is
  the only member today.
- Add a guard test in legalSections.test.ts asserting that both
  getRoutes('en').affiliateTerms and getRoutes('zh-CN').affiliateTerms
  resolve to '/affiliates/terms'. Test count: 35 → 36 passing.

Verification:
- 'pnpm --filter @comfyorg/website typecheck' — 0 errors (90 files)
- 'pnpm --filter @comfyorg/website test:unit' — 36/36 passing
- 'pnpm format:check apps/website' — clean
- 'pnpm exec oxlint apps/website/src apps/website/e2e --quiet' — 0 errors
- 'pnpm --filter @comfyorg/website build' — clean, 52 pages
- 'pnpm exec playwright test affiliates-terms' — 9/9 passing

Addresses the single low-severity finding from an Oracle code review of
the prior two commits on this branch.
2026-05-14 07:04:27 +00:00
glary-bot
0fe808ea10 fix(affiliates-terms): remove zh-CN route, English-only legal copy
The Affiliate Program Terms is a legal-reviewed document. Shipping
an unreviewed Chinese translation as the active terms (the previous
zh-CN page rendered translation keys whose 'zh-CN' values intentionally
mirrored 'en' verbatim) exposes us to liability from the translation
diverging from the legal-approved English source, particularly if a
future contributor 'fixes' the placeholder strings into real Chinese
without going through legal.

This change:

- Removes 'apps/website/src/pages/zh-CN/affiliates/terms.astro'.
- Removes the 9 'Disallow: /zh-CN/affiliates/terms' lines from robots.txt
  (one per UA stack); the route no longer exists.
- Simplifies '/affiliates/terms.astro' to be unconditionally English-only:
  drops the runtime locale switch and the 'Locale' import, hardcodes
  'locale=en' on the LegalContentSection.
- Updates the comment on the affiliate-terms i18n block in
  'translations.ts' from 'zh-CN mirrors en until legal approves a
  translation' (which implied a translation could later be approved
  edit-in-place) to an explicit 'ENGLISH ONLY — do NOT translate;
  if a translated terms page is ever needed, add a separate route
  only after legal signs off on that specific translation'. The
  'zh-CN' values themselves still mirror 'en' in source because the
  translations dictionary's type signature requires every entry to
  satisfy Record<Locale, string>; they are now unreachable through
  any route, sitemap, or robots rule.

Sitemap exclusion in astro.config.ts is unchanged — it generically
flatMaps NOINDEX_PATHNAMES over LOCALE_PREFIXES, so removing the
'/zh-CN/affiliates/terms' route automatically drops the (now-stale)
zh-CN entry from the excluded set.

Verification:
- 'pnpm --filter @comfyorg/website typecheck' — 0 errors (90 files, was 91)
- 'pnpm --filter @comfyorg/website test:unit' — 35/35 passing
  (including legalSections.test.ts, 5/5)
- 'pnpm format:check apps/website' — clean
- 'pnpm exec oxlint apps/website/src apps/website/e2e --quiet' — 0 errors
- 'pnpm knip' — clean (pre-existing unrelated warning)
- 'pnpm --filter @comfyorg/website build' — clean, 52 pages built,
  '/zh-CN/affiliates/' absent from dist output
- 'pnpm exec playwright test affiliates-terms' — 9/9 passing
- Probed running 'pnpm preview' server:
    /affiliates/terms        → 200
    /zh-CN/affiliates/terms  → 404 (standard site 404 page)
- Built sitemap-0.xml: zero affiliate-terms entries (en or zh-CN)
- Built robots.txt: 9 'Disallow: /affiliates/terms' entries, zero
  'Disallow: /zh-CN/...' entries

Manual verification:
Screenshots attached at .glary/screenshots/affiliate-terms-hero-date.png
(hero shows 'Effective Date: May 15, 2026' centered under H1) and
.glary/screenshots/affiliate-terms-zh-CN-404.png (zh-CN route resolves
to site 404).
2026-05-14 07:04:27 +00:00
glary-bot
178bb42271 fix(affiliates-terms): set effective date to May 15, 2026
Replaces the [TBD] placeholder in the affiliate-terms hero with a
real effective date. Updates both en and zh-CN entries (zh-CN
intentionally mirrors en per the existing convention noted at the
top of the affiliate-terms block in translations.ts).

The date renders in the centered caption directly under the page H1
via the existing 'affiliate-terms.effective-date' i18n key — no
component or layout changes needed.
2026-05-14 07:04:27 +00:00
Glary-Bot
893fb7429f fix(affiliates-terms): merge mobile TOC link colors via cn()
The mobile TOC anchor was setting text-primary-comfy-canvas in its
static class while the conditional :class added text-primary-comfy-
yellow or text-primary-warm-gray. Three text-* utilities targeting the
same property let CSS source order decide the winner instead of the
active-section state. Switch to cn() from @comfyorg/tailwind-utils so
tailwind-merge resolves the color conflict deterministically, matching
the codebase convention from AGENTS.md and ContentSection.vue.

The desktop TOC was already fine because its only static color rule
was hover:text-primary-comfy-canvas, which doesn't conflict with the
base text colors.
2026-05-05 21:00:38 +00:00
Glary-Bot
0f0bcc9de8 fix(affiliates-terms): preserve URL hash on TOC click for shareable permalinks
Because the TOC links use `@click.prevent` to drive smooth-scroll
manually, the browser's native hash update was suppressed and users
couldn't copy the URL after clicking a TOC entry to share a deep link.
`scrollToSection` now calls `history.replaceState` to sync the hash
without polluting back/forward history.

Adds an e2e regression test that asserts `window.location.hash` is
updated to the matching section anchor after a TOC click.
2026-05-05 20:50:23 +00:00
Glary-Bot
dcc81d9180 feat(affiliates-terms): apply Nav's four legal-copy edits
1. Section 7: change '12-month commission window' to '3-month
   commission window' so the termination clause matches Section 3's
   declared 3-month commission duration. Section 9's 'preceding 12
   months' liability cap is intentionally left unchanged.
2. Section 3: append a new 'Commission cessation' bullet covering
   subscription cancellation and refunded/charged-back transactions
   (cross-references Section 4).
3. Move the Effective Date from a footer at the end of the article to
   a centered caption directly under the H1, matching the legal-doc
   convention. Drops the now-unused effectiveDateKey/Label props from
   LegalContentSection so the component stays focused on body content.
4. Section 3: change the minimum payout threshold from $30 to $100.
2026-05-05 20:36:50 +00:00
Glary-Bot
5c215aa999 fix(affiliates-terms): address CodeRabbit review
- e2e: 'in order' test now actually verifies DOM ordering via
  compareDocumentPosition, not just element presence. The previous
  loop would have passed even with shuffled sections.
- robots.txt: also disallow /zh-CN/affiliates/terms in every
  user-agent block. The sitemap filter already excluded both locales
  but the robots rules only blocked /affiliates/terms, leaving the
  zh-CN draft crawlable.
- e2e: move the desktop TOC click/scroll test out of the @smoke
  describe block into a separate 'interactions' describe so smoke
  coverage stays render/visibility only, matching the convention used
  in apps/website/e2e/responsive.spec.ts.
2026-05-05 09:40:42 +00:00
Glary-Bot
8a1ce890d0 fix(affiliates-terms): address review feedback
- Drop the redundant 'Last updated' footer row that mirrored the
  Effective Date; the spec's last-updated stamp is satisfied by the
  single Effective Date line.
- Remove the now-unused 'lastUpdatedLabel' i18n key, prop, and
  associated test/e2e assertions.
- Add src/pages/zh-CN/affiliates/terms.astro so /zh-CN/affiliates/terms
  is generated alongside its en counterpart, matching the existing
  privacy-policy / terms-of-service localization pattern.
2026-05-05 03:55:43 +00:00
Glary-Bot
4453d858ed feat: draft Affiliate Program Terms page at /affiliates/terms
Adds a noindex'd legal page rendering the Comfy.org Affiliate Program
Terms and Conditions verbatim from i18n translations, so legal/non-
engineers can edit copy without a code change.

LegalContentSection.vue derives sections from i18n keys, renders
paragraph + bullet-list blocks, sticky desktop TOC, collapsed details
accordion TOC on mobile, smooth-scroll anchors with header offset,
IntersectionObserver-driven active state.

Anchor IDs match the spec (#1-program-overview through
#11-miscellaneous) for stable deep-linking.

BaseLayout noindex prop emits robots meta; robots.txt and the sitemap
filter both exclude /affiliates/terms (and the zh-CN locale variant).

Effective Date driven by a single i18n key
(affiliate-terms.effective-date), consumed by the page footer.

Unit tests assert the eleven canonical sections, numbered titles, and
that internal-only sections are not leaked. Playwright covers desktop
and mobile flows including accordion toggle, anchor scroll, and
viewport overflow.
2026-05-05 03:45:00 +00:00
727 changed files with 7587 additions and 54089 deletions

View File

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

View File

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

View File

@@ -41,10 +41,6 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
# Enable PostHog debug logging in the browser console.
# VITE_POSTHOG_DEBUG=true
# Override staging comfy-api / comfy-platform base URLs.
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org
# Sentry ENV vars replace with real ones for debugging
# SENTRY_AUTH_TOKEN=private-token # get from sentry
# SENTRY_ORG=comfy-org

View File

@@ -1,19 +0,0 @@
name: Cloud Nodes Pull
description: 'Refresh the apps/website cloud nodes snapshot from the Comfy Cloud /api/object_info endpoint'
inputs:
api_key:
description: 'Comfy Cloud API key (WEBSITE_CLOUD_API_KEY).'
required: true
runs:
using: 'composite'
steps:
# Note: this action assumes the frontend repo is checked out at the workspace root.
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Refresh cloud nodes snapshot
shell: bash
env:
WEBSITE_CLOUD_API_KEY: ${{ inputs.api_key }}
run: pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot

View File

@@ -54,14 +54,10 @@ jobs:
- name: Start ComfyUI server
uses: ./.github/actions/start-comfyui-server
# PRs run each test once to keep wall time bounded; main runs 3× so the
# baseline saved to perf-data has enough samples to median over noise.
- name: Run performance tests
id: perf
continue-on-error: true
env:
PERF_REPEAT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && '3' || '2' }}
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=$PERF_REPEAT
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
- name: Upload perf metrics
if: always()

View File

@@ -20,8 +20,6 @@ jobs:
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
has-coverage: ${{ steps.coverage-shards.outputs.has-coverage }}
steps:
- name: Checkout repository
@@ -39,33 +37,31 @@ jobs:
path: temp/coverage-shards
if_no_artifact_found: warn
- name: Detect shard coverage data
id: coverage-shards
run: |
if [ -d temp/coverage-shards ] && find temp/coverage-shards -name 'coverage.lcov' -type f | grep -q .; then
echo "has-coverage=true" >> "$GITHUB_OUTPUT"
else
echo "has-coverage=false" >> "$GITHUB_OUTPUT"
echo "No E2E coverage shard artifacts found; treating this run as skipped." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Install lcov
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: sudo apt-get install -y -qq lcov
- name: Merge shard coverage into single LCOV
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
mkdir -p coverage/playwright
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
if [ -z "$LCOV_FILES" ]; then
echo "No coverage.lcov files found"
touch coverage/playwright/coverage.lcov
exit 0
fi
ADD_ARGS=""
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
wc -l coverage/playwright/coverage.lcov
- name: Validate merged coverage
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
if [ "$SHARD_COUNT" -eq 0 ]; then
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
exit 0
fi
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
@@ -86,7 +82,7 @@ jobs:
done
- name: Upload merged coverage data
if: steps.coverage-shards.outputs.has-coverage == 'true'
if: always()
uses: actions/upload-artifact@v6
with:
name: e2e-coverage
@@ -95,7 +91,7 @@ jobs:
if-no-files-found: warn
- name: Upload E2E coverage to Codecov
if: steps.coverage-shards.outputs.has-coverage == 'true'
if: always()
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/playwright/coverage.lcov
@@ -104,17 +100,20 @@ jobs:
fail_ci_if_error: false
- name: Generate HTML coverage report
if: steps.coverage-shards.outputs.has-coverage == 'true'
run: |
if [ ! -s coverage/playwright/coverage.lcov ]; then
echo "No coverage data; generating placeholder report."
mkdir -p coverage/html
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
exit 0
fi
genhtml coverage/playwright/coverage.lcov \
-o coverage/html \
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source,unmapped
--precision 1
- name: Upload HTML report artifact
if: steps.coverage-shards.outputs.has-coverage == 'true'
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-html
@@ -123,10 +122,7 @@ jobs:
deploy:
needs: merge
if: >
github.event.workflow_run.head_branch == 'main' &&
needs.merge.outputs.has-coverage == 'true' &&
github.event.workflow_run.event == 'push'
if: github.event.workflow_run.head_branch == 'main'
runs-on: ubuntu-latest
permissions:
pages: write

View File

@@ -4,9 +4,6 @@ name: 'CI: Vercel Website Preview'
on:
pull_request:
types: [opened, synchronize, reopened]
branches-ignore:
- 'core/**'
- 'cloud/**'
paths:
- 'apps/website/**'
- 'packages/design-system/**'
@@ -58,7 +55,6 @@ jobs:
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: vercel build
- name: Fetch head commit metadata
@@ -152,20 +148,10 @@ jobs:
- name: Pull Vercel environment information
run: vercel pull --yes --environment=production
- name: Verify WEBSITE_CLOUD_API_KEY is present for production build
env:
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: |
if [ -z "${WEBSITE_CLOUD_API_KEY:-}" ]; then
echo "::error title=Missing WEBSITE_CLOUD_API_KEY::Production builds require WEBSITE_CLOUD_API_KEY so /cloud/supported-nodes is generated from fresh Cloud API data. Add it as a GitHub Actions repo secret and to the Vercel project environment. See apps/website/README.md."
exit 1
fi
- name: Build project artifacts
env:
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
run: vercel build --prod
- name: Deploy project artifacts to Vercel

View File

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

View File

@@ -39,7 +39,7 @@ jobs:
- name: Install dependencies for analysis tools
run: |
pnpm add -g typescript @vue/compiler-sfc
pnpm install -g typescript @vue/compiler-sfc
- name: Run Claude PR Review
uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38

View File

@@ -1,6 +1,6 @@
# Description: Manual workflow to refresh the apps/website Ashby roles and
# cloud nodes snapshots and open a PR. Merging the PR triggers the existing
# Vercel website production deploy via ci-vercel-website-preview.yaml.
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
# and open a PR. Merging the PR triggers the existing Vercel website production
# deploy via ci-vercel-website-preview.yaml.
name: 'Release: Website'
on:
@@ -11,7 +11,7 @@ concurrency:
cancel-in-progress: true
jobs:
refresh-snapshots:
refresh-snapshot:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
@@ -31,39 +31,28 @@ jobs:
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
- name: Refresh cloud nodes snapshot
uses: ./.github/actions/cloud-nodes-pull
with:
api_key: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: 'chore(website): refresh Ashby and cloud nodes snapshots'
title: 'chore(website): refresh Ashby and cloud nodes snapshots'
commit-message: 'chore(website): refresh Ashby roles snapshot'
title: 'chore(website): refresh Ashby roles snapshot'
body: |
Automated refresh of remote-data snapshots used by the website
build:
- `apps/website/src/data/ashby-roles.snapshot.json` — Ashby job
board API
- `apps/website/src/data/cloud-nodes.snapshot.json` — Comfy Cloud
`/api/object_info`
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
from the Ashby job board API.
**Flow:**
1. `Release: Website` workflow ran (manual trigger).
2. This PR opens with the regenerated snapshots.
2. This PR opens with the regenerated snapshot.
3. `CI: Vercel Website Preview` deploys a preview for review.
4. Merging to `main` triggers the production Vercel deploy.
The snapshot fallback in `apps/website/src/utils/ashby.ts` and
`apps/website/src/utils/cloudNodes.ts` remains intact: builds
without the respective API keys continue to use the committed
snapshot (with a warning annotation in CI).
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
committed snapshot.
Triggered by workflow run `${{ github.run_id }}`.
branch: chore/refresh-website-snapshots-${{ github.run_id }}
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
base: main
labels: |
Release:Website

View File

@@ -40,11 +40,11 @@ jobs:
- name: Install dependencies for analysis tools
run: |
# Check if packages are already available locally
if ! pnpm list -g typescript @vue/compiler-sfc >/dev/null 2>&1; then
if ! pnpm list typescript @vue/compiler-sfc >/dev/null 2>&1; then
echo "Installing TypeScript and Vue compiler globally..."
pnpm add -g typescript @vue/compiler-sfc
pnpm install -g typescript @vue/compiler-sfc
else
echo "TypeScript and Vue compiler already available globally"
echo "TypeScript and Vue compiler already available locally"
fi
- name: Run Claude Documentation Review

3
.npmrc Normal file
View File

@@ -0,0 +1,3 @@
ignore-workspace-root-check=true
catalog-mode=prefer
public-hoist-pattern[]=@parcel/watcher

View File

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

View File

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

View File

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

View File

@@ -113,50 +113,6 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
The script exits non-zero on any non-fresh outcome so stale/empty
snapshots can't be accidentally committed.
## Cloud nodes integration
`/cloud/supported-nodes` (and `/zh-CN/`) lists custom-node packs preinstalled on Comfy Cloud, joined with public metadata from the [ComfyUI Custom Node Registry](https://registry.comfy.org) ([`api.comfy.org`](https://api.comfy.org)). See [`src/pages/cloud/supported-nodes/AGENTS.md`](src/pages/cloud/supported-nodes/AGENTS.md) for the build pipeline, source-file map, and key invariants.
Build-time env var: `WEBSITE_CLOUD_API_KEY` (Cloud `/api/object_info` auth; the build falls back to the committed snapshot when unset). Must also be set in the Vercel project environment.
### Production strictness
`src/utils/cloudNodes.build.ts` throws when `fetchCloudNodesForBuild()` returns
`{ status: 'stale' }` **and** `process.env.VERCEL_ENV === 'production'`. This
prevents the production deploy from silently shipping an out-of-date snapshot
when the Cloud API is unreachable or `WEBSITE_CLOUD_API_KEY` is missing. Preview
and local builds continue to use the committed snapshot with a warning
annotation.
### Required GitHub Actions / Vercel secrets
| Name | Where | Purpose |
| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- |
| `WEBSITE_CLOUD_API_KEY` | GitHub Actions repo secret + Vercel project env | Auth for Cloud `/api/object_info`. Required for fresh production data. |
The `Release: Website` workflow uses the GitHub Actions secret to regenerate
`apps/website/src/data/cloud-nodes.snapshot.json` via
`.github/actions/cloud-nodes-pull/action.yaml`. The Vercel environment value is
read at build time by `vercel build` in `ci-vercel-website-preview.yaml`; the
`deploy-production` job hard-fails before `vercel build --prod` if the secret
is missing.
### Refreshing the snapshot
To update the committed snapshot manually (e.g. after onboarding new packs
to Comfy Cloud):
```bash
WEBSITE_CLOUD_API_KEY=\
pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
git commit apps/website/src/data/cloud-nodes.snapshot.json
```
The script exits non-zero on any non-fresh outcome so stale/empty snapshots
can't be accidentally committed. Otherwise the `Release: Website` GitHub
Actions workflow runs the same step on every manual dispatch and opens a PR
with the refreshed snapshot.
## HubSpot contact form
The contact page uses HubSpot's hosted form embed for the interest form:
@@ -190,4 +146,3 @@ renders the documented embed container.
- `pnpm test:unit` — Vitest unit tests
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot
- `pnpm cloud-nodes:refresh-snapshot` — refresh the committed cloud nodes snapshot

View File

@@ -9,10 +9,12 @@ const PAYMENT_STATUSES = ['success', 'failed'] as const
const LOCALE_PREFIXES = LOCALES.map((locale) =>
locale === DEFAULT_LOCALE ? '' : `/${locale}`
)
const NOINDEX_PATHNAMES = ['/affiliates/terms']
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
LOCALE_PREFIXES.flatMap((prefix) =>
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
)
LOCALE_PREFIXES.flatMap((prefix) => [
...PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`),
...NOINDEX_PATHNAMES.map((path) => `${prefix}${path}`)
])
)
function isExcludedFromSitemap(page: string): boolean {
@@ -28,8 +30,7 @@ export default defineConfig({
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
'/customers/moment-factory/',
'/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui':
'/customers/series-entertainment/',
'/zh-CN/terms-of-service': '/terms-of-service'
'/customers/series-entertainment/'
},
build: {
assets: '_website'

View File

@@ -0,0 +1,143 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const PATH = '/affiliates/terms'
const SECTION_IDS = [
'1-program-overview',
'2-eligible-products',
'3-commission-structure',
'4-attribution-rules',
'5-prohibited-activities',
'6-content-guidelines',
'7-termination',
'8-program-modifications',
'9-indemnification',
'10-governing-law',
'11-miscellaneous'
] as const
test.describe('Affiliate Terms — desktop @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('renders heading and is marked noindex', async ({ page }) => {
await expect(
page.getByRole('heading', { name: 'Affiliate Terms', level: 1 })
).toBeVisible()
const robotsContent = await page
.locator('meta[name="robots"]')
.getAttribute('content')
expect(robotsContent).toContain('noindex')
})
test('exposes one anchor per legal section in order', async ({ page }) => {
for (const id of SECTION_IDS) {
await expect(page.locator(`[id="${id}"]`)).toBeAttached()
}
const orderedIds = await page.evaluate(
(ids) => {
const elements = ids
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null)
return elements
.slice()
.sort((a, b) => {
const relation = a.compareDocumentPosition(b)
if (relation & Node.DOCUMENT_POSITION_FOLLOWING) return -1
if (relation & Node.DOCUMENT_POSITION_PRECEDING) return 1
return 0
})
.map((el) => el.id)
},
[...SECTION_IDS]
)
expect(orderedIds).toEqual([...SECTION_IDS])
})
test('renders an effective date footer', async ({ page }) => {
await expect(page.getByText(/Effective Date:/)).toBeVisible()
})
test('skips internal-only sections (competitive analysis, open questions)', async ({
page
}) => {
await expect(page.getByText(/Competitive analysis/i)).toHaveCount(0)
await expect(
page.getByText(/Open questions for legal review/i)
).toHaveCount(0)
})
})
test.describe('Affiliate Terms — desktop interactions', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('clicking a desktop TOC link scrolls to the matching section', async ({
page
}) => {
const desktopToc = page.getByRole('navigation', { name: 'On this page' })
await expect(desktopToc).toBeVisible()
const link = desktopToc.getByRole('link', { name: /5\. Prohibited/ })
await link.click()
const target = page.locator('[id="5-prohibited-activities"]')
await expect(target).toBeInViewport()
})
test('clicking a TOC link updates the URL hash so the section is shareable', async ({
page
}) => {
const desktopToc = page.getByRole('navigation', { name: 'On this page' })
await desktopToc.getByRole('link', { name: /7\. Termination/ }).click()
await expect
.poll(() => page.evaluate(() => window.location.hash))
.toBe('#7-termination')
})
})
test.describe('Affiliate Terms — mobile @mobile', () => {
test.beforeEach(async ({ page }) => {
await page.goto(PATH)
})
test('shows a collapsed accordion TOC by default', async ({ page }) => {
const accordion = page.locator('details', {
has: page.getByText('On this page')
})
await expect(accordion).toBeVisible()
await expect(accordion).not.toHaveAttribute('open', '')
})
test('expanding the accordion reveals every section link', async ({
page
}) => {
const accordion = page.locator('details', {
has: page.getByText('On this page')
})
await accordion.locator('summary').click()
await expect(accordion).toHaveAttribute('open', '')
for (const id of SECTION_IDS) {
await expect(accordion.locator(`a[href="#${id}"]`).first()).toBeVisible()
}
})
test('headings remain readable at narrow viewports without horizontal overflow', async ({
page
}) => {
const heading = page.getByRole('heading', { name: '1. Program Overview' })
await expect(heading).toBeVisible()
const box = await heading.boundingBox()
expect(box, 'heading box').not.toBeNull()
expect(box!.x).toBeGreaterThanOrEqual(0)
expect(box!.x + box!.width).toBeLessThanOrEqual(page.viewportSize()!.width)
})
})

View File

@@ -23,50 +23,26 @@ test.describe('Careers page @smoke', () => {
expect(await roles.count()).toBeGreaterThan(0)
})
test('clicking a department button scrolls to and activates that section', async ({
page
}) => {
const rolesSection = page.getByTestId('careers-roles')
await rolesSection.scrollIntoViewIfNeeded()
await expect(rolesSection).toBeVisible()
const allCount = await page.getByTestId('careers-role-link').count()
const engineeringButton = page.getByRole('button', {
name: 'ENGINEERING',
exact: true
})
// RolesSection is hydrated via `client:visible`. Once the button responds
// to a click by flipping aria-pressed, Vue is hydrated and the rest of
// the locator logic is in effect.
await expect(async () => {
await engineeringButton.click()
await expect(engineeringButton).toHaveAttribute('aria-pressed', 'true', {
timeout: 1_000
})
}).toPass({ timeout: 10_000 })
const engineeringSection = page.locator('#careers-dept-engineering')
await expect(engineeringSection).toBeInViewport()
expect(await page.getByTestId('careers-role-link').count()).toBe(allCount)
})
})
test.describe('Careers page role links', () => {
test('each role links to the Ashby job description page, not the application form', async ({
page
}) => {
await page.goto('/careers')
test('each role links to jobs.ashbyhq.com', async ({ page }) => {
const roles = page.getByTestId('careers-role-link')
const count = await roles.count()
for (let i = 0; i < count; i++) {
const href = await roles.nth(i).getAttribute('href')
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
expect(href).not.toMatch(/\/application\/?$/)
}
})
test('ENGINEERING category filter narrows the role list', async ({
page
}) => {
const allCount = await page.getByTestId('careers-role-link').count()
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
const engineeringLocator = page.getByTestId('careers-role-link')
await expect(engineeringLocator.first()).toBeVisible()
const engineeringCount = await engineeringLocator.count()
expect(engineeringCount).toBeLessThanOrEqual(allCount)
expect(engineeringCount).toBeGreaterThan(0)
})
})
test.describe('Careers page (zh-CN) @smoke', () => {

View File

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

View File

@@ -1,61 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
const M4_PRO_14_INCH_VIEWPORT = { width: 2016, height: 1310 }
const LAST_SECTION_HASH = '#contact'
test.describe(
'ContentSection scroll-spy @smoke',
{
annotation: [
{
type: 'issue',
description:
'https://linear.app/comfyorg/issue/FE-604/bug-bottom-badge-not-activating-on-scroll-at-high-resolution-3024x1964'
},
{
type: 'environment',
description:
'14" MacBook M4 Pro logical viewport reported in FE-604; /privacy-policy reproduces because of its short trailing sections'
}
]
},
() => {
test.use({ viewport: M4_PRO_14_INCH_VIEWPORT })
test('activates the last badge when user scrolls to the bottom', async ({
page
}) => {
await page.goto('/privacy-policy')
const sidebarNav = page.getByRole('navigation', {
name: 'Category filter'
})
const badges = sidebarNav.getByRole('button')
const lastBadge = badges.last()
await expect(badges.first()).toHaveAttribute('aria-pressed', 'true')
await expect(lastBadge).toHaveAttribute('aria-pressed', 'false')
await page.evaluate(() =>
window.scrollTo(0, document.documentElement.scrollHeight)
)
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
})
test('activates the last badge when page mounts already at the bottom via trailing hash', async ({
page
}) => {
await page.goto(`/privacy-policy${LAST_SECTION_HASH}`)
const sidebarNav = page.getByRole('navigation', {
name: 'Category filter'
})
const lastBadge = sidebarNav.getByRole('button').last()
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
})
}
)

View File

@@ -1,33 +0,0 @@
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
test.describe('Customers @smoke', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/customers')
})
test('hero image declares intrinsic dimensions so layout reserves space before load', async ({
page
}) => {
const heroImage = page.locator('img[alt="Comfy 3D logo"]')
await expect(heroImage).toBeVisible()
await expect(heroImage).toHaveAttribute('width', /^\d+$/)
await expect(heroImage).toHaveAttribute('height', /^\d+$/)
// Regression guard: an unloaded <img> without intrinsic dimensions
// collapses to ~0px, then jumps to its natural size on load and pushes
// the video below it. Reserved space must persist before bytes arrive.
const heightWhileUnloaded = await page.evaluate(() => {
const img = document.querySelector<HTMLImageElement>(
'img[alt="Comfy 3D logo"]'
)
if (!img) return null
img.removeAttribute('src')
return img.getBoundingClientRect().height
})
expect(heightWhileUnloaded).not.toBeNull()
expect(heightWhileUnloaded!).toBeGreaterThan(100)
})
})

View File

@@ -1,71 +1,27 @@
import { expect, test } from '@playwright/test'
import { demos, getNextDemo } from '../src/config/demos'
import { t } from '../src/i18n/translations'
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
test.describe('Demo pages @smoke', () => {
for (const demo of demos) {
const nextDemo = getNextDemo(demo.slug)
test('demo detail page renders hero and embed', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
await expect(page.getByRole('heading', { level: 1 })).toContainText(
'Create a Video from an Image'
)
const iframe = page.locator('iframe[title*="Interactive demo"]')
await expect(iframe).toBeAttached()
})
test(`/demos/${demo.slug} renders hero, embed, transcript, and next-demo nav`, async ({
page
}) => {
await page.goto(`/demos/${demo.slug}`)
test('demo detail page has transcript section', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(
page.getByRole('button', { name: /demo transcript/i })
).toBeVisible()
})
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
await expect(heading).toContainText(t(demo.title, 'en'))
const ogImage = page.locator('head meta[property="og:image"]')
await expect(ogImage).toHaveAttribute(
'content',
new RegExp(`${escapeRegExp(demo.slug)}-og\\.png`)
)
const iframe = page.locator(
`iframe[title*="${t('demos.embed.label', 'en')}"]`
)
await expect(iframe).toBeAttached()
await expect(iframe).toHaveAttribute(
'src',
new RegExp(escapeRegExp(demo.arcadeId))
)
await expect(
page.getByRole('button', { name: /demo transcript/i })
).toBeVisible()
await expect(
page.getByText(t(nextDemo.title, 'en')).first()
).toBeVisible()
const nextThumb = page.locator(`img[src="${nextDemo.thumbnail}"]`).first()
await expect(nextThumb).toBeAttached()
await expect(nextThumb).toBeVisible()
const naturalWidth = await nextThumb.evaluate(
(img) => (img as HTMLImageElement).naturalWidth
)
expect(naturalWidth).toBeGreaterThan(1)
})
test(`/zh-CN/demos/${demo.slug} renders localized content`, async ({
page
}) => {
await page.goto(`/zh-CN/demos/${demo.slug}`)
await expect(page).toHaveURL(/\/zh-CN\/demos\//)
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toContainText(t(demo.title, 'zh-CN'))
await expect(heading).toContainText(/[\u4E00-\u9FFF]/)
await expect(
page.getByText(t(nextDemo.title, 'zh-CN')).first()
).toBeVisible()
})
}
test('demo detail page has next demo navigation', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(page.getByText(/what's next/i)).toBeVisible()
})
test('demo library page renders', async ({ page }) => {
await page.goto('/demos')
@@ -76,4 +32,13 @@ test.describe('Demo pages @smoke', () => {
const response = await page.goto('/demos/nonexistent')
expect(response?.status()).toBe(404)
})
test('zh-CN demo page renders localized content', async ({ page }) => {
await page.goto('/zh-CN/demos/image-to-video')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
'从图片创建视频'
)
const nextDemoLink = page.locator('a[href*="/zh-CN/demos/"]').first()
await expect(nextDemoLink).toBeAttached()
})
})

View File

@@ -61,7 +61,7 @@ test.describe('Payment failed page @smoke', () => {
test('shows failure heading and subtitle', async ({ page }) => {
await expect(
page.getByRole('heading', {
name: /Unable to complete payment/i,
name: /Payment was not completed/i,
level: 1
})
).toBeVisible()
@@ -102,7 +102,7 @@ test.describe('Payment pages zh-CN @smoke', () => {
await expect(page).toHaveTitle('支付失败 — Comfy')
await expectNoIndex(page)
await expect(
page.getByRole('heading', { name: '无法完成支付', level: 1 })
page.getByRole('heading', { name: '支付未完成', level: 1 })
).toBeVisible()
await expect(page.getByRole('link', { name: '联系支持' })).toHaveAttribute(
'href',

View File

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

View File

@@ -1,4 +1,3 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { test } from './fixtures/blockExternalMedia'
@@ -48,105 +47,4 @@ test.describe('Mobile layout @mobile', () => {
const mobileContainer = page.getByTestId('social-proof-mobile')
await expect(mobileContainer).toBeVisible()
})
test.describe('SocialProofBar seamless marquee', () => {
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
test('mobile forward marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-mobile"] .animate-marquee'
)
expectSeamlessForwardLoop(geometry)
})
test('mobile reverse marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-mobile"] .animate-marquee-reverse'
)
expectSeamlessReverseLoop(geometry)
})
})
})
test.describe('Desktop SocialProofBar @smoke', () => {
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('desktop marquee loops seamlessly', async ({ page }) => {
const geometry = await measureMarqueeLoopGeometry(
page,
'[data-testid="social-proof-desktop"] .animate-marquee'
)
expectSeamlessForwardLoop(geometry)
})
})
type MarqueeGeometry = {
copyWidths: number[]
startPositions: number[]
endPositions: number[]
}
async function measureMarqueeLoopGeometry(
page: Page,
selector: string
): Promise<MarqueeGeometry> {
await page.locator(selector).first().waitFor()
return page.evaluate((sel) => {
const tracks = Array.from(
document.querySelectorAll<HTMLElement>(sel)
).slice(0, 2)
const firstAnimation = tracks[0]?.getAnimations()[0]
if (!firstAnimation) {
throw new Error(`No CSS animation found on ${sel}`)
}
const duration = firstAnimation.effect?.getTiming().duration
if (typeof duration !== 'number' || duration <= 1) {
throw new Error(
`Animation on ${sel} has unusable duration: ${String(duration)}`
)
}
const setAllTimes = (time: number) => {
for (const track of tracks) {
for (const anim of track.getAnimations()) {
anim.currentTime = time
}
}
void document.body.offsetWidth
}
const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
setAllTimes(0)
const startPositions = readX()
const copyWidths = tracks.map(
(track) => track.getBoundingClientRect().width
)
setAllTimes(duration - 0.1)
const endPositions = readX()
return { copyWidths, startPositions, endPositions }
}, selector)
}
function expectTwoMatchingCopies(geometry: MarqueeGeometry) {
const { copyWidths } = geometry
expect(copyWidths.length, 'expected two duplicate marquee tracks').toBe(2)
expect(copyWidths[0]).toBeGreaterThan(0)
expect(copyWidths[1]).toBeCloseTo(copyWidths[0], 0)
}
function expectSeamlessForwardLoop(geometry: MarqueeGeometry) {
expectTwoMatchingCopies(geometry)
// Copy 2 ends the cycle exactly where copy 1 started, so the restart
// (when copy 1 jumps back to its start position) is visually indistinguishable.
expect(geometry.endPositions[1]).toBeCloseTo(geometry.startPositions[0], 0)
}
function expectSeamlessReverseLoop(geometry: MarqueeGeometry) {
expectTwoMatchingCopies(geometry)
// Reverse marquee: copy 1 ends the cycle where copy 2 started.
expect(geometry.endPositions[0]).toBeCloseTo(geometry.startPositions[1], 0)
}

View File

@@ -26,8 +26,8 @@ async function assertNoOverflow(page: Page) {
}
async function navigateAndSettle(page: Page, url: string) {
await page.goto(url, { waitUntil: 'domcontentloaded' })
await page.waitForLoadState('load')
await page.goto(url)
await page.waitForLoadState('networkidle')
}
test.describe('Home', { tag: '@visual' }, () => {
@@ -126,7 +126,6 @@ test.describe('Overflow guards', { tag: '@visual' }, () => {
const pages = [
'/',
'/cloud',
'/cloud/enterprise',
'/cloud/pricing',
'/contact',
'/download',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

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

View File

@@ -28,7 +28,7 @@ export default defineConfig({
? [['html'], ['json', { outputFile: 'results.json' }]]
: 'html',
expect: {
toHaveScreenshot: { maxDiffPixels: 100 }
toHaveScreenshot: { maxDiffPixels: 50 }
},
...maybeLocalOptions,
webServer: {

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -1,14 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: #000000; }
.fg { fill: #F2FF59; }
@media (prefers-color-scheme: dark) {
.bg { fill: #F2FF59; }
.fg { fill: #000000; }
}
</style>
<circle class="bg" cx="24" cy="24" r="24"/>
<g transform="translate(7.8 6.72) scale(0.72)">
<path class="fg" d="M35.6487 36.021C35.733 35.7387 35.7791 35.4411 35.7791 35.1283C35.7791 33.3963 34.3675 31.9924 32.6262 31.9924H18.4956C17.7361 32 17.1147 31.3896 17.1147 30.6342C17.1147 30.4969 17.1377 30.3672 17.1684 30.2451L20.9734 17.0606C21.1345 16.4807 21.6715 16.0534 22.3005 16.0534L36.4848 16.0382C39.4766 16.0382 42.0005 14.0315 42.76 11.2923L44.8926 3.94468C44.9616 3.68526 45 3.40296 45 3.12065C45 1.39628 43.5961 0 41.8624 0L24.7017 0C21.7252 0 19.209 1.99142 18.4342 4.70005L16.992 9.71292C16.8232 10.2852 16.2939 10.7048 15.6648 10.7048H11.5453C8.59189 10.7048 6.0987 12.6581 5.30089 15.3362L0.11507 33.3505C0.0383566 33.6175 0 33.9075 0 34.1974C0 35.9294 1.41152 37.3333 3.15292 37.3333H7.20338C7.96284 37.3333 8.58421 37.9437 8.58421 38.7067C8.58421 38.8364 8.56887 38.9661 8.53051 39.0882L7.09598 44.0553C7.02694 44.3224 6.98091 44.597 6.98091 44.8794C6.98091 46.6037 8.38476 48 10.1185 48L27.2869 47.9847C30.2711 47.9847 32.7873 45.9857 33.5544 43.2618L35.641 36.0286L35.6487 36.021Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 69 B

View File

@@ -30,29 +30,38 @@ Disallow: /_astro/
Disallow: /_website/
Disallow: /_vercel/
Disallow: /payment/
Disallow: /affiliates/terms
User-agent: GPTBot
Allow: /
Disallow: /affiliates/terms
User-agent: OAI-SearchBot
Allow: /
Disallow: /affiliates/terms
User-agent: ChatGPT-User
Allow: /
Disallow: /affiliates/terms
User-agent: ClaudeBot
Allow: /
Disallow: /affiliates/terms
User-agent: Claude-User
Allow: /
Disallow: /affiliates/terms
User-agent: Claude-SearchBot
Allow: /
Disallow: /affiliates/terms
User-agent: PerplexityBot
Allow: /
Disallow: /affiliates/terms
User-agent: Google-Extended
Allow: /
Disallow: /affiliates/terms
Sitemap: https://comfy.org/sitemap-index.xml

View File

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

View File

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

View File

@@ -1,13 +1,10 @@
<script setup lang="ts">
import { useEventListener, useTemplateRefsList } from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import { computed, ref } from 'vue'
import type { Department } from '../../data/roles'
import type { Locale } from '../../i18n/translations'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import { t } from '../../i18n/translations'
import { scrollTo } from '../../scripts/smoothScroll'
import CategoryNav from '../common/CategoryNav.vue'
import SectionLabel from '../common/SectionLabel.vue'
@@ -16,72 +13,24 @@ const { locale = 'en', departments = [] } = defineProps<{
departments?: readonly Department[]
}>()
const activeCategory = ref('all')
const visibleDepartments = computed(() =>
departments.filter((d) => d.roles.length > 0)
)
const categories = computed(() =>
visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
const categories = computed(() => [
{ label: 'ALL', value: 'all' },
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
])
const filteredDepartments = computed(() =>
activeCategory.value === 'all'
? visibleDepartments.value
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
)
const hasRoles = computed(() => visibleDepartments.value.length > 0)
const activeCategory = ref('')
const sectionRefs = useTemplateRefsList<HTMLElement>()
let isScrolling = false
let pendingFrame = 0
const HEADER_OFFSET = -144
const ACTIVATION_OFFSET = 300
const deptElementId = (key: string) => `careers-dept-${key}`
function pickActiveSection() {
pendingFrame = 0
if (isScrolling) return
const sections = sectionRefs.value as HTMLElement[]
if (sections.length === 0) return
let active = sections[0]
for (const el of sections) {
if (el.getBoundingClientRect().top - ACTIVATION_OFFSET <= 0) {
active = el
} else {
break
}
}
activeCategory.value = active.id.replace(/^careers-dept-/, '')
}
function scheduleUpdate() {
if (pendingFrame !== 0) return
pendingFrame = requestAnimationFrame(pickActiveSection)
}
onMounted(pickActiveSection)
useEventListener('scroll', scheduleUpdate, { passive: true })
useEventListener('resize', scheduleUpdate, { passive: true })
function scrollToDepartment(deptKey: string) {
activeCategory.value = deptKey
isScrolling = true
const el = document.getElementById(deptElementId(deptKey))
if (!el) {
isScrolling = false
return
}
scrollTo(el, {
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: () => {
isScrolling = false
pickActiveSection()
}
})
}
</script>
<template>
@@ -99,10 +48,9 @@ function scrollToDepartment(deptKey: string) {
</h2>
<CategoryNav
v-if="hasRoles"
v-model="activeCategory"
:categories="categories"
:model-value="activeCategory"
class="mt-4"
@update:model-value="scrollToDepartment"
/>
</div>
</div>
@@ -117,11 +65,9 @@ function scrollToDepartment(deptKey: string) {
</p>
<div
v-for="dept in visibleDepartments"
:id="deptElementId(dept.key)"
:ref="sectionRefs.set"
v-for="dept in filteredDepartments"
:key="dept.key"
class="mb-12 scroll-mt-24 last:mb-0 md:scroll-mt-36"
class="mb-12 last:mb-0"
>
<SectionLabel>
{{ dept.name }}
@@ -130,7 +76,7 @@ function scrollToDepartment(deptKey: string) {
<a
v-for="role in dept.roles"
:key="role.id"
:href="role.jobUrl"
:href="role.applyUrl"
target="_blank"
rel="noopener noreferrer"
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const photos = [
{
src: 'https://media.comfy.org/website/careers/team0.webp',
@@ -15,34 +17,45 @@ const photos = [
{
src: 'https://media.comfy.org/website/careers/team3.webp',
alt: 'Team on a boat'
},
{
src: 'https://media.comfy.org/website/careers/team4.webp',
alt: 'Teammates posing at a restaurant'
},
{
src: 'https://media.comfy.org/website/careers/team5.webp',
alt: 'Teammates at a social gathering'
},
{
src: 'https://media.comfy.org/website/careers/team6.webp',
alt: 'Team sailing at golden hour'
},
{
src: 'https://media.comfy.org/website/careers/team7.webp',
alt: 'Team on a sailboat at sunset'
}
]
const loopedPhotos = [...photos, ...photos, ...photos]
const scrollRef = ref<HTMLElement>()
function onScroll() {
const el = scrollRef.value
if (!el) return
const third = el.scrollWidth / 3
const maxScroll = el.scrollWidth - el.clientWidth
if (el.scrollLeft >= maxScroll - 1) {
el.scrollLeft -= third
} else if (el.scrollLeft <= 1) {
el.scrollLeft += third
}
}
onMounted(() => {
const el = scrollRef.value
if (el) {
el.scrollLeft = el.scrollWidth / 3
}
})
</script>
<template>
<section class="py-12 md:py-24">
<div
ref="scrollRef"
class="flex gap-4 overflow-x-auto px-6 md:gap-6 md:px-20"
style="scrollbar-width: none"
@scroll="onScroll"
>
<div
v-for="(photo, i) in photos"
v-for="(photo, i) in loopedPhotos"
:key="i"
class="aspect-3/4 h-64 shrink-0 md:h-96"
>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,7 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import {
useEventListener,
useIntersectionObserver,
useTemplateRefsList
} from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
import { computed, ref } from 'vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
@@ -44,25 +40,13 @@ const activeSection = ref(sections[0]?.id ?? '')
const sectionRefs = useTemplateRefsList<HTMLElement>()
let isScrolling = false
let scrollSafetyTimer: ReturnType<typeof setTimeout> | undefined
const HEADER_OFFSET = -144
const BOTTOM_THRESHOLD_PX = 4
const SCROLL_SAFETY_MS = 1500
function clearScrollLock() {
isScrolling = false
if (scrollSafetyTimer !== undefined) {
clearTimeout(scrollSafetyTimer)
scrollSafetyTimer = undefined
}
}
useIntersectionObserver(
sectionRefs,
(entries) => {
if (isScrolling) return
if (isAtBottom()) return
let best: IntersectionObserverEntry | null = null
for (const entry of entries) {
if (!entry.isIntersecting) continue
@@ -74,39 +58,22 @@ useIntersectionObserver(
{ rootMargin: '-20% 0px -60% 0px' }
)
function isAtBottom(): boolean {
const scrollBottom = window.scrollY + window.innerHeight
return (
scrollBottom >= document.documentElement.scrollHeight - BOTTOM_THRESHOLD_PX
)
}
function activateLastIfAtBottom() {
if (isScrolling) return
if (!isAtBottom()) return
const lastId = sections[sections.length - 1]?.id
if (lastId) activeSection.value = lastId
}
onMounted(activateLastIfAtBottom)
useEventListener('scroll', activateLastIfAtBottom, { passive: true })
function scrollToSection(id: string) {
activeSection.value = id
clearScrollLock()
isScrolling = true
scrollSafetyTimer = setTimeout(clearScrollLock, SCROLL_SAFETY_MS)
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: clearScrollLock
onComplete: () => {
isScrolling = false
}
})
return
}
clearScrollLock()
isScrolling = false
}
</script>

View File

@@ -13,7 +13,7 @@ const { stars } = defineProps<{
target="_blank"
rel="noopener noreferrer"
:aria-label="`ComfyUI on GitHub ${stars} stars`"
class="hidden shrink-0 items-center gap-1 lg:flex"
class="hidden shrink-0 items-center gap-2 lg:flex"
>
<NodeBadge
:segments="[{ text: stars }]"
@@ -22,7 +22,7 @@ const { stars } = defineProps<{
size-class="h-5 sm:h-5"
/>
<span
class="bg-primary-comfy-yellow block size-6 shrink-0"
class="bg-primary-comfy-yellow block size-7"
aria-hidden="true"
style="mask: url('/icons/social/github.svg') center / contain no-repeat"
/>

View File

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

View File

@@ -82,7 +82,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
]
}
const contactColumn: { title: string; links: FooterLink[] } = {
const contactColumn = {
title: t('footer.contact', locale),
links: [
{ label: t('footer.sales', locale), href: routes.contact },
@@ -91,11 +91,6 @@ const contactColumn: { title: string; links: FooterLink[] } = {
href: externalLinks.support,
external: true
},
{
label: t('footer.cloudStatus', locale),
href: externalLinks.cloudStatus,
external: true
},
{ label: t('footer.press', locale), href: 'mailto:press@comfy.org' }
]
}

View File

@@ -14,28 +14,23 @@ const logos = [
'Ubisoft'
]
const mobileRow1Logos = logos.slice(0, 6)
const mobileRow2Logos = logos.slice(6)
const desktopLogos = Array.from({ length: 4 }, () => logos).flat()
const row1 = logos.slice(0, 6)
const mobileRow1 = [...row1, ...row1]
const row2 = logos.slice(6)
const mobileRow2 = [...row2, ...row2]
</script>
<template>
<section class="overflow-hidden py-12">
<!-- Single row on desktop -->
<div data-testid="social-proof-desktop" class="hidden w-max gap-2 md:flex">
<div class="animate-marquee hidden items-center gap-2 md:flex">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-2"
style="--marquee-gap: 0.5rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
v-for="(logo, i) in desktopLogos"
:key="`${logo}-${i}`"
class="flex h-20 w-50 shrink-0 items-center justify-center"
>
<div
v-for="logo in logos"
:key="logo"
class="flex h-20 w-50 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
@@ -44,38 +39,22 @@ const mobileRow2Logos = logos.slice(6)
data-testid="social-proof-mobile"
class="flex flex-col gap-8 md:hidden"
>
<div class="flex w-max gap-8">
<div class="animate-marquee flex items-center gap-8">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
v-for="(logo, i) in mobileRow1"
:key="`${logo}-${i}`"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<div
v-for="logo in mobileRow1Logos"
:key="logo"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
<div class="flex w-max gap-8">
<div class="animate-marquee-reverse flex items-center gap-8">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
v-for="(logo, i) in mobileRow2"
:key="`${logo}-${i}`"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<div
v-for="logo in mobileRow2Logos"
:key="logo"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
</div>

View File

@@ -75,7 +75,7 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
<!-- Progress bar -->
<div class="h-1 flex-1 rounded-full bg-white/20">
<div
class="bg-primary-comfy-yellow h-full rounded-full"
class="bg-primary-comfy-yellow h-full rounded-full transition-all duration-200"
:style="{ width: progressPercent }"
/>
</div>

View File

@@ -5,7 +5,6 @@ import { useHeroAnimation } from '../../composables/useHeroAnimation'
import SectionLabel from '../common/SectionLabel.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { ScrollTrigger } from '../../scripts/gsapSetup'
import VideoPlayer from '../common/VideoPlayer.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -23,10 +22,6 @@ useHeroAnimation({
logo: logoRef,
video: videoRef
})
function handleLogoLoad() {
ScrollTrigger.refresh(true)
}
</script>
<template>
@@ -42,10 +37,7 @@ function handleLogoLoad() {
<img
src="https://media.comfy.org/website/customers/c-projection.webp"
alt="Comfy 3D logo"
width="1568"
height="1763"
class="mx-auto h-auto w-full max-w-md lg:max-w-none"
@load="handleLogoLoad"
class="mx-auto w-full max-w-md lg:max-w-none"
/>
</div>

View File

@@ -8,12 +8,10 @@ import { t } from '../../i18n/translations'
const {
arcadeId,
title,
aspectRatio = 16 / 9,
locale = 'en'
} = defineProps<{
arcadeId: string
title: string
aspectRatio?: number
locale?: Locale
}>()
@@ -26,8 +24,7 @@ const loaded = ref(false)
:aria-label="t('demos.embed.label', locale)"
>
<div
class="relative mx-auto max-w-6xl overflow-hidden rounded-4xl border border-white/10"
:style="{ aspectRatio }"
class="relative mx-auto aspect-video max-w-6xl overflow-hidden rounded-4xl border border-white/10"
>
<div
v-if="!loaded"

View File

@@ -1,31 +1,24 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { externalLinks } from '../../config/routes'
import { useHeroLogo } from '../../composables/useHeroLogo'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const logoContainer = ref<HTMLElement>()
const { loaded: logoLoaded } = useHeroLogo(logoContainer)
</script>
<template>
<section
class="relative flex min-h-auto flex-col lg:flex-row lg:items-center"
>
<div
ref="logoContainer"
class="relative flex aspect-square w-full flex-1 items-center justify-center"
>
<img
v-show="!logoLoaded"
src="https://media.comfy.org/website/homepage/hero-logo-seq/Logo00.webp"
alt="Comfy logo"
class="w-3/5"
<div class="relative flex-1">
<video
src="https://media.comfy.org/website/homepage/hero-logo-seq.webm"
autoplay
loop
muted
playsinline
class="w-full"
/>
</div>

View File

@@ -25,7 +25,7 @@ const categories: Category[] = [
{
label: t('useCase.vfx', locale),
leftSrc: 'https://media.comfy.org/website/homepage/use-case/left1.webm',
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webm'
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webp'
},
{
label: t('useCase.advertising', locale),

View File

@@ -0,0 +1,244 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
import { computed, ref } from 'vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { hasKey, t, translationKeys } from '../../i18n/translations'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import { scrollTo } from '../../scripts/smoothScroll'
const {
prefix,
locale = 'en',
tocLabelKey
} = defineProps<{
prefix: string
locale?: Locale
tocLabelKey: TranslationKey
}>()
interface Block {
type: 'paragraph' | 'list'
key: TranslationKey
}
interface LegalSection {
id: string
title: string
blocks: Block[]
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function buildSections(): LegalSection[] {
const labelRegex = new RegExp(`^${escapeRegex(prefix)}\\.([^.]+)\\.label$`)
const sectionIds: string[] = []
for (const key of translationKeys) {
const match = key.match(labelRegex)
if (match && !sectionIds.includes(match[1])) sectionIds.push(match[1])
}
return sectionIds.map((id) => {
const blockRegex = new RegExp(
`^${escapeRegex(prefix)}\\.${escapeRegex(id)}\\.block\\.(\\d+)$`
)
const indices: number[] = []
for (const key of translationKeys) {
const match = key.match(blockRegex)
if (match) indices.push(parseInt(match[1]))
}
indices.sort((a, b) => a - b)
const blocks: Block[] = indices.map((i) => {
const key = `${prefix}.${id}.block.${i}` as TranslationKey
const value = t(key, locale)
return { type: value.includes('\n') ? 'list' : 'paragraph', key }
})
const titleKey = `${prefix}.${id}.title` as TranslationKey
return {
id,
title: hasKey(titleKey)
? t(titleKey, locale)
: t(`${prefix}.${id}.label` as TranslationKey, locale),
blocks
}
})
}
const sections = buildSections()
const tocItems = computed(() =>
sections.map((s) => ({ id: s.id, title: s.title }))
)
const activeSection = ref(sections[0]?.id ?? '')
const sectionRefs = useTemplateRefsList<HTMLElement>()
const mobileTocOpen = ref(false)
let isScrolling = false
const HEADER_OFFSET = -144
useIntersectionObserver(
sectionRefs,
(entries) => {
if (isScrolling) return
let best: IntersectionObserverEntry | null = null
for (const entry of entries) {
if (!entry.isIntersecting) continue
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top)
best = entry
}
if (best) activeSection.value = best.target.id
},
{ rootMargin: '-20% 0px -60% 0px' }
)
function scrollToSection(id: string) {
activeSection.value = id
isScrolling = true
mobileTocOpen.value = false
const nextHash = `#${id}`
if (window.location.hash !== nextHash) {
history.replaceState(null, '', nextHash)
}
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: () => {
isScrolling = false
}
})
return
}
isScrolling = false
}
function listItems(key: TranslationKey): string[] {
return t(key, locale).split('\n')
}
</script>
<template>
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-12 lg:pb-32">
<div class="mx-auto max-w-7xl lg:flex lg:gap-16">
<aside class="lg:w-64 lg:shrink-0">
<details
:open="mobileTocOpen"
class="border-transparency-white-t4 mb-8 rounded-2xl border bg-(--site-bg-soft) lg:hidden"
@toggle="
(e) => (mobileTocOpen = (e.target as HTMLDetailsElement).open)
"
>
<summary
class="text-primary-comfy-canvas flex cursor-pointer items-center justify-between px-4 py-3 text-sm font-semibold tracking-wide select-none"
>
<span>{{ t(tocLabelKey, locale) }}</span>
<span
:class="
mobileTocOpen
? 'rotate-180 transition-transform'
: 'transition-transform'
"
aria-hidden="true"
>
</span>
</summary>
<ul class="border-transparency-white-t4 border-t p-2">
<li v-for="item in tocItems" :key="item.id">
<a
:href="`#${item.id}`"
:aria-current="activeSection === item.id ? 'true' : undefined"
:class="
cn(
'hover:bg-transparency-white-t4 block rounded-lg px-3 py-2 text-sm transition-colors',
activeSection === item.id
? 'text-primary-comfy-yellow font-semibold'
: 'text-primary-warm-gray'
)
"
@click.prevent="scrollToSection(item.id)"
>
{{ item.title }}
</a>
</li>
</ul>
</details>
<nav
class="hidden lg:sticky lg:top-32 lg:block"
:aria-label="t(tocLabelKey, locale)"
>
<p
class="text-primary-warm-gray mb-4 text-xs font-semibold tracking-widest uppercase"
>
{{ t(tocLabelKey, locale) }}
</p>
<ul class="space-y-2">
<li v-for="item in tocItems" :key="item.id">
<a
:href="`#${item.id}`"
:aria-current="activeSection === item.id ? 'true' : undefined"
class="hover:text-primary-comfy-canvas block text-sm/snug transition-colors"
:class="
activeSection === item.id
? 'text-primary-comfy-yellow font-semibold'
: 'text-primary-warm-gray'
"
@click.prevent="scrollToSection(item.id)"
>
{{ item.title }}
</a>
</li>
</ul>
</nav>
</aside>
<article class="flex-1 lg:max-w-3xl">
<section
v-for="section in sections"
:id="section.id"
:ref="sectionRefs.set"
:key="section.id"
class="mb-16 scroll-mt-24 lg:scroll-mt-36"
>
<h2
class="text-primary-comfy-canvas mb-6 text-2xl font-light lg:text-3xl"
>
{{ section.title }}
</h2>
<template v-for="block in section.blocks" :key="block.key">
<p
v-if="block.type === 'paragraph'"
class="text-primary-comfy-canvas mt-4 text-sm/relaxed lg:text-base/relaxed"
v-html="t(block.key, locale)"
/>
<ul
v-else
class="mt-4 space-y-2 pl-5 text-sm/relaxed lg:text-base/relaxed"
>
<li
v-for="(item, j) in listItems(block.key)"
:key="j"
class="text-primary-comfy-canvas flex items-start gap-2"
>
<span
class="bg-primary-comfy-yellow mt-2 size-1.5 shrink-0 rounded-full"
aria-hidden="true"
/>
<span v-html="item" />
</li>
</ul>
</template>
</section>
</article>
</div>
</section>
</template>

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { getRoutes } from '../../config/routes'
import { hasKey, t, translationKeys } from '../../i18n/translations'
const PREFIX = 'affiliate-terms'
const EXPECTED_SECTION_IDS = [
'1-program-overview',
'2-eligible-products',
'3-commission-structure',
'4-attribution-rules',
'5-prohibited-activities',
'6-content-guidelines',
'7-termination',
'8-program-modifications',
'9-indemnification',
'10-governing-law',
'11-miscellaneous'
] as const
function deriveAffiliateSectionIds(): string[] {
const labelRegex = new RegExp(`^${PREFIX}\\.([0-9]+-[a-z-]+)\\.label$`)
const ids: string[] = []
for (const key of translationKeys) {
const match = key.match(labelRegex)
if (match && !ids.includes(match[1])) ids.push(match[1])
}
return ids
}
describe('affiliate terms i18n', () => {
it('exposes the eleven canonical sections in numeric order', () => {
const ids = deriveAffiliateSectionIds()
expect(ids).toEqual([...EXPECTED_SECTION_IDS])
})
it('every section has a label, title, and at least one block', () => {
for (const id of EXPECTED_SECTION_IDS) {
expect(hasKey(`${PREFIX}.${id}.label`)).toBe(true)
expect(hasKey(`${PREFIX}.${id}.title`)).toBe(true)
expect(hasKey(`${PREFIX}.${id}.block.0`)).toBe(true)
}
})
it('section titles follow the "N. Section Name" pattern', () => {
for (const id of EXPECTED_SECTION_IDS) {
const title = t(`${PREFIX}.${id}.title` as never)
const numberPrefix = id.split('-')[0]
expect(title).toMatch(new RegExp(`^${numberPrefix}\\. `))
}
})
it('exposes the effective date and page-chrome keys editors will need', () => {
expect(hasKey('affiliate-terms.effective-date')).toBe(true)
expect(hasKey('affiliate-terms.page.title')).toBe(true)
expect(hasKey('affiliate-terms.page.heading')).toBe(true)
expect(hasKey('affiliate-terms.page.tocLabel')).toBe(true)
expect(hasKey('affiliate-terms.page.effectiveDateLabel')).toBe(true)
})
it('does not include any internal-only "Competitive analysis" or "Open questions" keys', () => {
const internalRegex = /(competitive-analysis|open-questions|legal-review)/
const leaks = translationKeys.filter(
(key) => key.startsWith(PREFIX) && internalRegex.test(key)
)
expect(leaks).toEqual([])
})
it('exposes affiliate terms at the canonical /affiliates/terms path regardless of locale', () => {
// Guards against re-introducing /zh-CN/affiliates/terms, which would
// serve an unreviewed translation of legal-reviewed copy. See the
// comment on LOCALE_INVARIANT_ROUTE_KEYS in src/config/routes.ts.
expect(getRoutes('en').affiliateTerms).toBe('/affiliates/terms')
expect(getRoutes('zh-CN').affiliateTerms).toBe('/affiliates/terms')
})
})

View File

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

View File

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

View File

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

View File

@@ -35,20 +35,20 @@ onMounted(() => {
<template>
<section
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden pt-16 lg:flex-row-reverse lg:items-center lg:overflow-x-visible lg:overflow-y-clip lg:pt-[min(8vw,10rem)] lg:pb-[min(8vw,10rem)]"
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden lg:flex-row-reverse lg:items-center lg:overflow-x-visible lg:overflow-y-clip lg:pb-[min(8vw,10rem)]"
>
<!-- Illustration (overlaps text slightly; stacks above on mobile, right on lg) -->
<div
class="aspect-square w-4/5 max-w-md scale-150 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-ml-12 lg:-translate-x-[10%] lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
class="aspect-square w-4/5 max-w-md scale-150 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-ml-12 lg:-translate-x-[10%] lg:translate-y-[40px] lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
>
<svg
ref="svgRef"
class="block size-full overflow-visible"
class="block size-full"
viewBox="0 0 1600 1046"
fill="none"
aria-hidden="true"
>
<g>
<g clip-path="url(#enterpriseHeroClip)">
<rect width="1600" height="1046" fill="#211927" />
<rect
width="800"
@@ -84,7 +84,7 @@ onMounted(() => {
/>
<!-- Exploding block cluster -->
<g class="block-cluster" clip-path="url(#enterpriseHeroBlockClip)">
<g class="block-cluster">
<path
class="block-piece"
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
@@ -353,7 +353,7 @@ onMounted(() => {
<stop stop-color="#211927" stop-opacity="0" />
<stop offset="1" stop-color="#211927" />
</linearGradient>
<clipPath id="enterpriseHeroBlockClip">
<clipPath id="enterpriseHeroClip">
<rect width="1600" height="1046" fill="white" />
</clipPath>
</defs>

View File

@@ -276,6 +276,29 @@ onUnmounted(() => {
fill="#211927"
/>
</g>
<!-- Left-edge fade -->
<rect
x="300"
y="150"
width="250"
height="900"
fill="url(#localHeroFadeLeft)"
/>
<defs>
<linearGradient
id="localHeroFadeLeft"
x1="550"
y1="600"
x2="300"
y2="600"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#211927" stop-opacity="0" />
<stop offset="1" stop-color="#211927" />
</linearGradient>
</defs>
</svg>
</div>

View File

@@ -8,9 +8,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="bg-transparency-white-t4 relative z-20 p-4 text-center lg:px-20 lg:py-8"
>
<section class="bg-transparency-white-t4 p-4 text-center lg:px-20 lg:py-8">
<p
class="text-primary-comfy-canvas relative z-10 text-lg font-semibold lg:text-sm lg:font-normal"
>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,328 +0,0 @@
import type { Ref } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import * as THREE from 'three'
import { SVGLoader } from 'three/addons/loaders/SVGLoader.js'
import { prefersReducedMotion } from './useReducedMotion'
const IMAGE_COUNT = 16
const BASE_URL = 'https://media.comfy.org/website/homepage/hero-logo-seq'
const SVG_MARKUP = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 375 404"><path fill="#000000" d="M296.597 302.576C297.299 300.205 297.682 297.705 297.682 295.078C297.682 280.529 285.938 268.736 271.45 268.736H153.883C147.564 268.8 142.395 263.673 142.395 257.328C142.395 256.174 142.586 255.084 142.841 254.059L174.499 143.309C175.839 138.438 180.307 134.849 185.541 134.849L303.554 134.72C328.446 134.72 349.444 117.864 355.763 94.8555L373.506 33.1353C374.081 30.9562 374.4 28.5848 374.4 26.2134C374.4 11.7288 362.72 0 348.295 0H205.518C180.754 0 159.819 16.7279 153.373 39.4804L141.373 81.5886C139.969 86.3954 135.565 89.9205 130.332 89.9205H96.0573C71.4845 89.9205 50.7412 106.328 44.1034 128.824L0.957382 280.144C0.319127 282.387 0 284.823 0 287.258C0 301.807 11.7439 313.6 26.2323 313.6H59.9321C66.2508 313.6 71.4207 318.727 71.4207 325.137C71.4207 326.226 71.293 327.316 70.9739 328.341L59.0385 370.065C58.4641 372.308 58.0811 374.615 58.0811 376.987C58.0811 391.471 69.7612 403.2 84.1857 403.2L227.027 403.072C251.855 403.072 272.79 386.28 279.172 363.399L296.533 302.64L296.597 302.576Z"/></svg>`
interface HeroLogoConfig {
speed: number
tiltX: number
tiltZ: number
zoom: number
fov: number
logoColor: string
extrudeDepth: number
cursorTiltStrength: number
bgScale: number
slideDuration: number
}
const DEFAULTS: HeroLogoConfig = {
speed: 1,
tiltX: -0.1,
tiltZ: -0.1,
zoom: 7,
fov: 50,
logoColor: '#F2FF59',
extrudeDepth: 200,
cursorTiltStrength: 0.5,
bgScale: 0.8,
slideDuration: 0.4
}
function buildImageUrls(): string[] {
return Array.from({ length: IMAGE_COUNT }, (_, i) => {
const index = String(i).padStart(5, '0')
return `${BASE_URL}/image_sequence_${index}.webp`
})
}
function parseShapes(): THREE.Shape[] {
const loader = new SVGLoader()
const svgData = loader.parse(SVG_MARKUP)
const shapes: THREE.Shape[] = []
svgData.paths.forEach((path) => {
shapes.push(...SVGLoader.createShapes(path))
})
return shapes
}
function loadTextures(urls: string[]): Promise<THREE.Texture[]> {
return Promise.all(
urls.map(
(url) =>
new Promise<THREE.Texture | null>((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
const tex = new THREE.Texture(img)
tex.needsUpdate = true
tex.colorSpace = THREE.SRGBColorSpace
resolve(tex)
}
img.onerror = () => resolve(null)
img.src = url
})
)
).then((results) => results.filter((t): t is THREE.Texture => t !== null))
}
export function useHeroLogo(
containerRef: Ref<HTMLElement | undefined>,
config: Partial<HeroLogoConfig> = {}
) {
const cfg = { ...DEFAULTS, ...config }
const loaded = ref(false)
let cleanup: (() => void) | undefined
onMounted(async () => {
try {
const container = containerRef.value
if (!container || prefersReducedMotion()) return
const { width, height } = container.getBoundingClientRect()
const renderer = new THREE.WebGLRenderer({
antialias: true,
stencil: true,
alpha: true
})
renderer.setSize(width, height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.domElement.style.position = 'absolute'
renderer.domElement.style.inset = '0'
renderer.domElement.style.width = '100%'
renderer.domElement.style.height = '100%'
renderer.domElement.style.opacity = '0'
renderer.domElement.setAttribute('aria-hidden', 'true')
container.appendChild(renderer.domElement)
let disposed = false
const teardowns: Array<() => void> = []
cleanup = () => {
disposed = true
teardowns.forEach((fn) => fn())
}
teardowns.push(() => {
renderer.dispose()
renderer.domElement.remove()
})
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(
cfg.fov,
width / height,
0.1,
1000
)
camera.position.z = cfg.zoom
// SVG shape
const shapes = parseShapes()
const tempGeo = new THREE.ShapeGeometry(shapes)
tempGeo.computeBoundingBox()
const bb = tempGeo.boundingBox!
const cx = (bb.max.x + bb.min.x) / 2
const cy = (bb.max.y + bb.min.y) / 2
const scaleFactor = 3 / (bb.max.y - bb.min.y)
tempGeo.dispose()
// Image sequence textures — load first frame eagerly, rest lazily
const urls = buildImageUrls()
const textures = await loadTextures(urls.slice(0, 1))
if (disposed) return
renderer.domElement.style.opacity = '1'
loaded.value = true
loadTextures(urls.slice(1)).then((rest) => {
if (!disposed) textures.push(...rest)
})
// Background plane (stencil read)
const bgPlaneGeo = new THREE.PlaneGeometry(14, 14)
const bgPlaneMat = new THREE.MeshBasicMaterial({
transparent: true,
opacity: 1,
map: textures[0] ?? null,
depthTest: false,
depthWrite: false,
stencilWrite: true,
stencilFunc: THREE.EqualStencilFunc,
stencilRef: 1,
stencilFail: THREE.KeepStencilOp,
stencilZFail: THREE.KeepStencilOp,
stencilZPass: THREE.KeepStencilOp
})
const bgPlane = new THREE.Mesh(bgPlaneGeo, bgPlaneMat)
bgPlane.renderOrder = 1
bgPlane.scale.set(cfg.bgScale, cfg.bgScale, 1)
scene.add(bgPlane)
// Logo group
const group = new THREE.Group()
scene.add(group)
const s = scaleFactor
const depth = cfg.extrudeDepth
// Front face
const shapeGeo = new THREE.ShapeGeometry(shapes)
shapeGeo.translate(-cx, -cy, 0)
shapeGeo.scale(s, -s, s)
const shapeMat = new THREE.MeshBasicMaterial({
color: cfg.logoColor,
side: THREE.DoubleSide,
depthTest: false,
depthWrite: false,
transparent: true
})
const logoMesh = new THREE.Mesh(shapeGeo, shapeMat)
logoMesh.renderOrder = 2
group.add(logoMesh)
// Extrusion stencil mask
const extrudeGeo = new THREE.ExtrudeGeometry(shapes, {
depth,
bevelEnabled: false
})
extrudeGeo.translate(-cx, -cy, -depth)
extrudeGeo.scale(s, -s, s)
const extrudeMat = new THREE.MeshBasicMaterial({
colorWrite: false,
depthWrite: true,
depthTest: true,
stencilWrite: true,
stencilRef: 1,
stencilFunc: THREE.AlwaysStencilFunc,
stencilZPass: THREE.ReplaceStencilOp,
stencilFail: THREE.KeepStencilOp,
stencilZFail: THREE.KeepStencilOp,
side: THREE.DoubleSide
})
const extrudeMesh = new THREE.Mesh(extrudeGeo, extrudeMat)
extrudeMesh.renderOrder = 0
group.add(extrudeMesh)
// Interaction
let isDragging = false
let previousX = 0
let dragVelocity = 0
let currentTiltX = 0
let currentTiltY = 0
let pointerX = 0
let pointerY = 0
let rotationT = 0
let currentSlide = 0
let slideTimer = 0
let animationId = 0
function onMouseMove(e: MouseEvent) {
pointerX = (e.clientX / window.innerWidth) * 2 - 1
pointerY = (e.clientY / window.innerHeight) * 2 - 1
}
function onPointerDown(e: PointerEvent) {
isDragging = true
dragVelocity = 0
previousX = e.clientX
}
function onPointerMove(e: PointerEvent) {
if (!isDragging) return
dragVelocity = (e.clientX - previousX) * 0.005
rotationT += dragVelocity
previousX = e.clientX
}
function onPointerUp() {
isDragging = false
}
function onResize() {
const rect = container!.getBoundingClientRect()
camera.aspect = rect.width / rect.height
camera.updateProjectionMatrix()
renderer.setSize(rect.width, rect.height)
}
window.addEventListener('mousemove', onMouseMove)
renderer.domElement.addEventListener('pointerdown', onPointerDown)
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('resize', onResize)
const clock = new THREE.Clock()
function animate() {
if (disposed) return
animationId = requestAnimationFrame(animate)
const dt = clock.getDelta()
if (!isDragging && Math.abs(dragVelocity) > 0.0001) {
dragVelocity *= 0.95
rotationT += dragVelocity
} else if (!isDragging) {
dragVelocity = 0
}
rotationT += cfg.speed * dt
currentTiltX += (pointerY - currentTiltX) * 0.08
currentTiltY += (pointerX - currentTiltY) * 0.08
group.rotation.y = rotationT % (Math.PI * 2)
group.rotation.x = cfg.tiltX - currentTiltX * cfg.cursorTiltStrength
group.rotation.z = cfg.tiltZ
if (textures.length > 1) {
slideTimer += dt
if (slideTimer >= cfg.slideDuration) {
slideTimer = 0
currentSlide = (currentSlide + 1) % textures.length
bgPlaneMat.map = textures[currentSlide]
bgPlaneMat.needsUpdate = true
}
}
renderer.render(scene, camera)
}
animate()
teardowns.push(
() => cancelAnimationFrame(animationId),
() => window.removeEventListener('mousemove', onMouseMove),
() =>
renderer.domElement.removeEventListener('pointerdown', onPointerDown),
() => window.removeEventListener('pointermove', onPointerMove),
() => window.removeEventListener('pointerup', onPointerUp),
() => window.removeEventListener('resize', onResize),
() => bgPlaneGeo.dispose(),
() => bgPlaneMat.dispose(),
() => shapeGeo.dispose(),
() => shapeMat.dispose(),
() => extrudeGeo.dispose(),
() => extrudeMat.dispose(),
() => textures.forEach((tex) => tex.dispose())
)
} catch (err) {
console.error('[useHeroLogo] initialization failed:', err)
cleanup?.()
}
})
onUnmounted(() => {
cleanup?.()
})
return { loaded }
}

View File

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

View File

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

View File

@@ -15,14 +15,6 @@ interface Demo {
readonly transcript?: TranslationKey
readonly publishedDate: string
readonly modifiedDate: string
/**
* Width / height of the Arcade demo's source recording (e.g. 1.93 for a
* landscape screencast). Sizes the embed container to match so rounded
* corners hug the content instead of empty letterbox space. Source from
* Arcade's `_serializablePublicFlow.aspectRatio` (which is height/width —
* invert it). Defaults to 16/9 if omitted.
*/
readonly aspectRatio?: number
}
export const demos: readonly Demo[] = [
@@ -40,8 +32,7 @@ export const demos: readonly Demo[] = [
difficulty: 'beginner',
tags: ['templates', 'image', 'video'],
publishedDate: '2026-04-19',
modifiedDate: '2026-04-19',
aspectRatio: 1.931
modifiedDate: '2026-04-19'
},
{
slug: 'workflow-templates',
@@ -57,25 +48,7 @@ export const demos: readonly Demo[] = [
difficulty: 'beginner',
tags: ['getting-started', 'templates', 'workflow'],
publishedDate: '2026-04-19',
modifiedDate: '2026-04-19',
aspectRatio: 1.931
},
{
slug: 'community-workflows',
arcadeId: 'mqZh17oWDuWIyhK0xwEV',
category: 'demos.category.gettingStarted',
title: 'demos.community-workflows.title',
description: 'demos.community-workflows.description',
transcript: 'demos.community-workflows.transcript',
ogImage: '/images/demos/community-workflows-og.png',
thumbnail: '/images/demos/community-workflows-thumb.webp',
estimatedTime: 'demos.duration.2min',
durationIso: 'PT2M',
difficulty: 'beginner',
tags: ['getting-started', 'community', 'workflow', 'hub'],
publishedDate: '2026-05-04',
modifiedDate: '2026-05-04',
aspectRatio: 1.931
modifiedDate: '2026-04-19'
}
]

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -14,13 +14,21 @@ const baseRoutes = {
demos: '/demos',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
contact: '/contact',
models: '/p/supported-models'
affiliateTerms: '/affiliates/terms',
contact: '/contact'
} as const
type Routes = typeof baseRoutes
const localeInvariantRouteKeys = new Set<keyof Routes>(['termsOfService'])
// Routes that are served only at their canonical path regardless of the
// active locale. Localized variants of these routes intentionally do not
// exist, so getRoutes(<non-en>) must not prefix them — emitting
// /zh-CN/<route> would produce a dead link.
//
// affiliateTerms: legal-reviewed English-only document. See the comment
// header in src/pages/affiliates/terms.astro and the affiliate-terms i18n
// block in src/i18n/translations.ts for the reasoning.
const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>(['affiliateTerms'])
export function getRoutes(locale: Locale = 'en'): Routes {
if (locale === 'en') return baseRoutes
@@ -28,7 +36,7 @@ export function getRoutes(locale: Locale = 'en'): Routes {
return Object.fromEntries(
Object.entries(baseRoutes).map(([k, v]) => [
k,
localeInvariantRouteKeys.has(k as keyof Routes) ? v : `${prefix}${v}`
LOCALE_INVARIANT_ROUTE_KEYS.has(k as keyof Routes) ? v : `${prefix}${v}`
])
) as unknown as Routes
}
@@ -37,7 +45,6 @@ export const externalLinks = {
apiKeys: 'https://platform.comfy.org/profile/api-keys',
blog: 'https://blog.comfy.org/',
cloud: 'https://cloud.comfy.org',
cloudStatus: 'https://status.comfy.org',
discord: 'https://discord.com/invite/comfyorg',
docs: 'https://docs.comfy.org/',
docsApi: 'https://docs.comfy.org/api-reference/cloud',

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -5,13 +5,11 @@ import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import SiteNav from '../components/common/SiteNav.vue'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
interface Props {
title: string
description?: string
keywords?: string[]
ogImage?: string
noindex?: boolean
}
@@ -19,13 +17,10 @@ interface Props {
const {
title,
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
keywords,
ogImage = 'https://media.comfy.org/website/comfy.webp',
noindex = false,
} = 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)
@@ -67,11 +62,10 @@ const websiteJsonLd = {
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
{keywordsContent && <meta name="keywords" content={keywordsContent} />}
{noindex && <meta name="robots" content="noindex, nofollow" />}
<title>{title}</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/icons/logomark.svg" type="image/svg+xml" />
<link rel="canonical" href={canonicalURL.href} />
<link rel="preconnect" href="https://www.googletagmanager.com" />
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
@@ -95,11 +89,8 @@ const websiteJsonLd = {
<meta name="twitter:image" content={ogImageURL.href} />
<!-- Structured Data -->
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
<slot name="head" />
<slot name="head" />
<script is:inline type="application/ld+json" set:html={JSON.stringify(organizationJsonLd)} />
<script is:inline type="application/ld+json" set:html={JSON.stringify(websiteJsonLd)} />
<!-- Google Tag Manager -->
{gtmEnabled && (

View File

@@ -0,0 +1,31 @@
---
// Affiliate Program Terms — English only, by design.
// Legal-reviewed copy must not be served under a localized route until
// legal explicitly approves a translation; rendering an unreviewed
// translation as the active terms exposes us to liability from the
// translation diverging from the approved English source. See the
// matching comment in src/i18n/translations.ts for the i18n block.
import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/legal/HeroSection.vue'
import LegalContentSection from '../../components/legal/LegalContentSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout
title={t('affiliate-terms.page.title')}
description={t('affiliate-terms.page.description')}
noindex
>
<HeroSection title={t('affiliate-terms.page.heading')} />
<p class="text-primary-warm-gray mt-2 text-center text-sm">
{t('affiliate-terms.page.effectiveDateLabel')}: {
t('affiliate-terms.effective-date')
}
</p>
<LegalContentSection
prefix="affiliate-terms"
locale="en"
tocLabelKey="affiliate-terms.page.tocLabel"
client:load
/>
</BaseLayout>

View File

@@ -7,14 +7,9 @@ import AudienceSection from '../../components/product/cloud/AudienceSection.vue'
import PricingSection from '../../components/product/cloud/PricingSection.vue'
import ProductCardsSection from '../../components/product/cloud/ProductCardsSection.vue'
import FAQSection from '../../components/product/cloud/FAQSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout
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']}
>
<BaseLayout title="Comfy Cloud — AI in the Cloud">
<HeroSection />
<ReasonSection />
<AIModelsSection />

View File

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

View File

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

View File

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

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