Compare commits

..

4 Commits

Author SHA1 Message Date
Terry Jia
38f57b4e9a debug: add temporary logs to painter serializeValue path 2026-05-12 09:08:23 -04:00
Christian Byrne
3bd6331690 Merge branch 'main' into fix/painter-serialize-preserves-modelvalue-on-remount 2026-05-11 12:19:44 -07:00
bymyself
b32cd539d9 fix: detect canvas emptiness from pixel data, not stroke flags
Read actual canvas pixel data in serializeValue instead of trusting the
closure-local hasStrokes/isDirty flags. Per Slack thread evidence (Matt
Miller, 2026-05-05), the canvas can hold visible strokes (~9% non-zero
alpha pixels) while hasStrokes stays false. Two known triggers:

1. WidgetPainter remount swaps usePainter() for a fresh closure with
   hasStrokes=false; the registered serializeValue on the mask widget
   then disagrees with what the user sees on the canvas.
2. handlePointerDown early-returns on e.button !== 0, which can fire
   for some touchpad / pen pointer-event variants. startStroke never
   runs and hasStrokes never flips, even though pixels reach the
   canvas via other paths.

The new isCanvasPixelEmpty(el) reads ImageData and scans the alpha
channel. When canvas is empty, serializeValue defers to modelValue so
workflow-restored references survive a pre-image-load remount;
handleClear now also resets modelValue so a user-initiated clear still
resolves to ''.

Removes the now-unused hasStrokes flag and isCanvasEmpty helper.

Adds unit tests covering: pixel-driven upload despite isDirty=false,
empty canvas + empty modelValue → '', and post-handleClear → ''.
2026-05-06 00:19:38 -07:00
bymyself
1521e6956f fix: painter widget preserves mask reference across WidgetPainter remount
The painter's serializeValue closure captured hasStrokes and isDirty as
local variables that reset on every WidgetPainter.vue mount. After a
remount (e.g. NodeWidgets re-render driven by useProcessedWidgets, added
in #10966), serializeValue saw isCanvasEmpty()=true and returned '' even
when modelValue still held a valid mask reference from a workflow load
or a prior upload — backend then received an empty mask, and on cloud
this surfaced as ImageDownloadError.

Reorder the short-circuits so a non-dirty painter returns its existing
modelValue.value before the canvas-empty guard, and fall back to the
prior modelValue.value if the canvas element is no longer mounted.
2026-05-05 20:37:50 -07:00
626 changed files with 8130 additions and 48536 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

@@ -106,12 +106,17 @@ jobs:
- 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'
@@ -125,8 +130,7 @@ jobs:
needs: merge
if: >
github.event.workflow_run.head_branch == 'main' &&
needs.merge.outputs.has-coverage == 'true' &&
github.event.workflow_run.event == 'push'
needs.merge.outputs.has-coverage == 'true'
runs-on: ubuntu-latest
permissions:
pages: write

View File

@@ -45,8 +45,12 @@ jobs:
path: dist/
retention-days: 1
# Build cloud distribution for @cloud tagged tests
# NX_SKIP_NX_CACHE=true is required because `nx build` was already run
# for the OSS distribution above. Without skipping cache, Nx returns
# the cached OSS build since env vars aren't part of the cache key.
- name: Build cloud frontend
run: pnpm build:cloud
run: NX_SKIP_NX_CACHE=true pnpm build:cloud
- name: Upload cloud frontend
uses: actions/upload-artifact@v6

View File

@@ -58,7 +58,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 +151,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

@@ -59,7 +59,7 @@ jobs:
pnpm zipdist ./dist ./dist-desktop.zip
# Default release artifact for core/PyPI.
pnpm build
NX_SKIP_NX_CACHE=true pnpm build
pnpm zipdist
- name: Upload dist artifact
uses: actions/upload-artifact@v6

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

5
.gitignore vendored
View File

@@ -19,7 +19,6 @@ yarn.lock
node_modules
.pnpm-store
.nx
dist
dist-ssr
*.local
@@ -90,6 +89,10 @@ storybook-static
# MCP Servers
.playwright-mcp/*
.nx/cache
.nx/workspace-data
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
vite.config.*.timestamp*
vitest.config.*.timestamp*

3
.npmrc Normal file
View File

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

1
.nxignore Normal file
View File

@@ -0,0 +1 @@
.claude/worktrees

View File

@@ -2,6 +2,7 @@
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": [
".i18nrc.cjs",
".nx/*",
"**/vite.config.*.timestamp*",
"**/vitest.config.*.timestamp*",
"components.d.ts",
@@ -84,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

@@ -35,7 +35,7 @@ See @docs/guidance/\*.md for file-type-specific conventions (auto-loaded by glob
## Monorepo Architecture
The project uses **pnpm workspaces** for monorepo organization and native tool CLIs for task execution
The project uses **Nx** for build orchestration and task management
## Package Manager
@@ -237,6 +237,7 @@ See @docs/testing/\*.md for detailed patterns.
- ComfyUI: <https://docs.comfy.org>
- Electron: <https://www.electronjs.org/docs/latest/>
- Wiki: <https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview>
- Nx: <https://nx.dev/docs/reference/nx-commands>
- [Practical Test Pyramid](https://martinfowler.com/articles/practical-test-pyramid.html)
## Architecture Decision Records

View File

@@ -7,7 +7,7 @@ This guide helps you resolve common issues when developing ComfyUI Frontend.
```mermaid
flowchart TD
A[Having Issues?] --> B{What's the problem?}
B -->|Dev server stuck| C[pnpm dev hangs]
B -->|Dev server stuck| C[nx serve hangs]
B -->|Build errors| D[Check build issues]
B -->|Lint errors| Q[Check linting issues]
B -->|Dependency issues| E[Package problems]
@@ -23,7 +23,7 @@ flowchart TD
G -->|No| H[Run: pnpm i]
G -->|Still stuck| I[Run: pnpm clean]
I --> J{Still stuck?}
J -->|Yes| K[Nuclear option:<br/>pnpm clean:all<br/>&& pnpm i]
J -->|Yes| K[Nuclear option:<br/>pnpm dlx rimraf node_modules<br/>&& pnpm i]
J -->|No| L[Fixed!]
H --> L
@@ -41,11 +41,11 @@ flowchart TD
### Development Server Issues
#### Q: `pnpm dev` gets stuck and won't start
#### Q: `pnpm dev` or `nx serve` gets stuck and won't start
**Symptoms:**
- Command hangs during Vite startup
- Command hangs on "nx serve"
- Dev server doesn't respond
- Terminal appears frozen
@@ -65,7 +65,7 @@ flowchart TD
3. **Last resort - Full node_modules reset:**
```bash
pnpm clean:all && pnpm i
pnpm dlx rimraf node_modules && pnpm i
```
**Why this happens:**
@@ -73,7 +73,7 @@ flowchart TD
- Corrupted dependency cache
- Outdated lock files after branch switching
- Incomplete previous installations
- stale local build cache
- NX cache corruption
---

View File

@@ -3,11 +3,8 @@
"version": "0.0.6",
"type": "module",
"scripts": {
"dev": "pnpm -w exec vite --config apps/desktop-ui/vite.config.mts",
"build": "pnpm -w exec vite build --config apps/desktop-ui/vite.config.mts",
"preview": "pnpm -w exec vite preview --config apps/desktop-ui/vite.config.mts",
"lint": "eslint src --cache",
"typecheck": "vue-tsc --noEmit -p tsconfig.json",
"lint": "nx run @comfyorg/desktop-ui:lint",
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
"test:unit": "vitest run --config vitest.config.mts",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build -o dist/storybook"
@@ -36,5 +33,88 @@
"vite-plugin-html": "catalog:",
"vite-plugin-vue-devtools": "catalog:",
"vue-tsc": "catalog:"
},
"nx": {
"tags": [
"scope:desktop",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "vite --config vite.config.mts"
}
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "vite --config vite.config.mts"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"^build"
],
"options": {
"command": "vite build --config apps/desktop-ui/vite.config.mts"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/desktop-ui",
"command": "vite preview --config vite.config.mts"
}
},
"storybook": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "storybook dev -p 6007"
}
},
"build-storybook": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "storybook build -o dist/storybook"
},
"outputs": [
"{projectRoot}/dist/storybook"
]
},
"lint": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "eslint src --cache"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/desktop-ui",
"command": "vue-tsc --noEmit -p tsconfig.json"
}
}
}
}
}

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

@@ -28,8 +28,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

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

View File

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

View File

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -15,15 +15,11 @@
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
"test:visual": "playwright test --project visual",
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
"generate:models": "tsx ./scripts/generate-models.ts"
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",
"@comfyorg/design-system": "workspace:*",
"@comfyorg/object-info-parser": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@vercel/analytics": "catalog:",
"@vueuse/core": "catalog:",
@@ -45,5 +41,88 @@
"tsx": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"nx": {
"tags": [
"scope:website",
"type:app"
],
"targets": {
"dev": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"serve": {
"executor": "nx:run-commands",
"continuous": true,
"options": {
"cwd": "apps/website",
"command": "astro dev"
}
},
"build": {
"executor": "nx:run-commands",
"cache": true,
"dependsOn": [
"^build"
],
"options": {
"cwd": "apps/website",
"command": "astro build"
},
"outputs": [
"{projectRoot}/dist"
]
},
"preview": {
"executor": "nx:run-commands",
"continuous": true,
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "astro preview"
}
},
"typecheck": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "astro check"
}
},
"test:unit": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run"
}
},
"test:coverage": {
"executor": "nx:run-commands",
"cache": true,
"options": {
"cwd": "apps/website",
"command": "vitest run --coverage"
}
},
"test:e2e": {
"executor": "nx:run-commands",
"dependsOn": [
"build"
],
"options": {
"cwd": "apps/website",
"command": "playwright test"
}
}
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 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

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

@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<section class="px-6 py-24 lg:px-20 lg:py-32">
<GlassCard
class="mx-auto mt-20 flex flex-col gap-12 lg:flex-row lg:items-stretch lg:gap-8"
>

View File

@@ -74,7 +74,7 @@ useHeroAnimation({
</div>
<!-- Video -->
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
<div ref="videoRef" class="px-4 pb-20 lg:px-20 lg:pb-40">
<VideoPlayer
src="https://media.comfy.org/website/about/co-founders.webm"
poster="https://media.comfy.org/website/about/co-founders-poster.webp"

View File

@@ -33,7 +33,7 @@ const values: {
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<section class="px-6 py-24 lg:px-20 lg:py-32">
<div class="mx-auto max-w-5xl text-center">
<SectionLabel>
{{ t('about.values.label', locale) }}

View File

@@ -16,7 +16,7 @@ const investors = [
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<section class="px-6 py-24 lg:px-20 lg:py-32">
<div class="mx-auto text-center">
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"

View File

@@ -14,7 +14,7 @@ const reasons: TranslationKey[] = [
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-24 lg:px-20 lg:py-32">
<section class="px-6 py-24 lg:px-20 lg:py-32">
<WireNodeLayout :reasons right-card-padding="p-6" :locale="locale">
<template #right-card>
<img

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,7 +41,7 @@ function toggle(index: number) {
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
<section class="px-4 py-24 md:px-20 md:py-40">
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div

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

@@ -46,9 +46,7 @@ const cards = excludeProduct
</script>
<template>
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-0 py-20 lg:px-20 lg:py-24"
>
<section class="bg-primary-comfy-ink px-0 py-20 lg:px-20 lg:py-24">
<!-- Header -->
<div class="flex flex-col items-center px-4 text-center">
<SectionLabel v-if="labelKey">

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

@@ -45,11 +45,11 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:px-16 lg:py-24">
<section class="px-6 py-16 lg:px-16 lg:py-24">
<!-- Scrollable track -->
<div
ref="trackRef"
class="flex snap-x snap-mandatory scrollbar-none gap-12 overflow-x-auto lg:gap-20"
class="scrollbar-none flex snap-x snap-mandatory gap-12 overflow-x-auto lg:gap-20"
>
<div
v-for="(fb, i) in feedbacks"

View File

@@ -72,7 +72,7 @@ function handleLogoLoad() {
</div>
<!-- Video -->
<div ref="videoRef" class="max-w-9xl mx-auto px-4 pb-20 lg:px-20 lg:pb-40">
<div ref="videoRef" class="px-4 pb-20 lg:px-20 lg:pb-40">
<VideoPlayer
src="https://media.comfy.org/website/customers/blackmath/video.webm"
poster="https://media.comfy.org/website/customers/blackmath/poster.webp"

View File

@@ -10,7 +10,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
<template>
<section
class="max-w-9xl mx-auto grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
class="grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
>
<a
v-for="story in customerStories"

View File

@@ -7,7 +7,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:px-20 lg:py-40">
<section class="px-6 py-16 lg:px-20 lg:py-40">
<VideoPlayer
src="https://media.comfy.org/website/customers/silverside/video.webm"
poster="https://media.comfy.org/website/customers/silverside/poster.webp"

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

@@ -7,7 +7,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<template>
<section
class="max-w-9xl mx-auto flex flex-col items-center px-4 pt-16 pb-24 text-center lg:px-20 lg:pt-20 lg:pb-40"
class="flex flex-col items-center px-4 pt-16 pb-24 text-center lg:px-20 lg:pt-20 lg:pb-40"
>
<span
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"

View File

@@ -223,10 +223,7 @@ while (idx < items.length) {
</script>
<template>
<section
data-testid="gallery-grid"
class="max-w-9xl mx-auto px-4 pb-20 lg:px-20"
>
<section data-testid="gallery-grid" class="px-4 pb-20 lg:px-20">
<!-- Desktop grid -->
<div
class="rounded-5xl bg-transparency-white-t4 hidden flex-col gap-2 p-2 lg:flex"

View File

@@ -8,9 +8,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-36 pb-16 text-center"
>
<section class="flex flex-col items-center px-6 pt-36 pb-16 text-center">
<SectionLabel>
{{ t('gallery.label', locale) }}
</SectionLabel>

View File

@@ -15,7 +15,7 @@ const row2 = [
<template>
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto flex flex-col items-center px-4 py-24 lg:px-6 lg:py-32"
class="bg-primary-comfy-ink flex flex-col items-center px-4 py-24 lg:px-6 lg:py-32"
>
<!-- Node rows -->
<div

View File

@@ -12,9 +12,7 @@ const routes = getRoutes(locale)
</script>
<template>
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-20 lg:px-20 lg:py-24"
>
<section class="bg-primary-comfy-ink px-4 py-20 lg:px-20 lg:py-24">
<GlassCard
class="flex flex-col gap-12 lg:flex-row lg:items-stretch lg:gap-8"
>

View File

@@ -36,9 +36,7 @@ const steps = [
</script>
<template>
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-20 lg:px-20 lg:py-24"
>
<section class="bg-primary-comfy-ink px-4 py-20 lg:px-20 lg:py-24">
<div class="flex flex-col gap-12 lg:flex-row lg:gap-8">
<!-- Left heading -->
<div

View File

@@ -15,7 +15,7 @@ const { loaded: logoLoaded } = useHeroLogo(logoContainer)
<template>
<section
class="max-w-9xl relative mx-auto flex min-h-auto flex-col lg:flex-row lg:items-center"
class="relative flex min-h-auto flex-col lg:flex-row lg:items-center"
>
<div
ref="logoContainer"

View File

@@ -55,10 +55,7 @@ watch(activeIndex, (current, previous) => {
</script>
<template>
<section
ref="sectionRef"
class="max-w-9xl mx-auto px-4 py-20 lg:px-20 lg:py-24"
>
<section ref="sectionRef" class="px-4 py-20 lg:px-20 lg:py-24">
<!-- Section header -->
<div class="flex flex-col items-center text-center">
<NodeBadge :segments="badgeSegments" segment-class="" />

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' }
@@ -121,7 +116,7 @@ const activePlanIndex = ref(0)
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-14">
<section class="px-4 py-16 lg:px-20 lg:py-14">
<!-- Header -->
<div class="mx-auto mb-8 max-w-3xl text-center lg:mb-10">
<h1
@@ -135,7 +130,7 @@ const activePlanIndex = ref(0)
</div>
<!-- Mobile plan tabs -->
<div class="mb-6 flex scrollbar-none gap-2 overflow-x-auto lg:hidden">
<div class="scrollbar-none mb-6 flex gap-2 overflow-x-auto lg:hidden">
<button
v-for="(plan, index) in plans"
:key="plan.id"
@@ -155,14 +150,9 @@ const activePlanIndex = ref(0)
</button>
</div>
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: single card -->
<!-- Desktop: 4-column grid / Mobile: single card -->
<div
:class="
cn(
'rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:gap-2',
standardPlans.length === 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3'
)
"
class="rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:grid-cols-4 lg:gap-2"
>
<PricingTierCard v-for="plan in standardPlans" :key="plan.id">
<!-- Label + badge -->
@@ -233,18 +223,10 @@ const activePlanIndex = ref(0)
<!-- Features -->
<div v-if="plan.features.length" class="px-6 py-3">
<p
v-if="plan.featureIntroKey"
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
>
{{ t(plan.featureIntroKey, locale) }}
</p>
<p
v-else
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
aria-hidden="true"
>
&nbsp;
<p class="text-primary-comfy-canvas mb-2 text-sm font-semibold">
{{
plan.featureIntroKey ? t(plan.featureIntroKey, locale) : '&nbsp;'
}}
</p>
<ul class="space-y-2">
<li

View File

@@ -60,7 +60,7 @@ const features: IncludedFeature[] = [
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-16 lg:px-20 lg:py-24">
<section class="px-4 py-16 lg:px-20 lg:py-24">
<div class="mx-auto w-full lg:grid lg:grid-cols-[280px_1fr] lg:gap-x-16">
<!-- Heading -->
<div

View File

@@ -25,7 +25,7 @@ const cards = [
</script>
<template>
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
<section class="px-4 pt-24 lg:px-20 lg:pt-40">
<h2
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
>

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'
@@ -10,7 +9,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<template>
<section
class="bg-transparency-white-t4 rounded-5xl max-w-9xl mx-auto mt-4 mb-24 p-2 px-4 lg:mt-8 lg:mb-40 lg:px-20"
class="bg-transparency-white-t4 rounded-5xl mx-4 mt-4 mb-24 p-2 lg:mx-20 lg:mt-8 lg:mb-40"
>
<div
class="bg-primary-comfy-yellow flex flex-col gap-24 rounded-4xl p-8 lg:flex-row lg:items-end lg:justify-between"
@@ -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

@@ -442,7 +442,7 @@ onBeforeUnmount(() => {
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
<section class="px-4 py-24 lg:px-20">
<GlassCard
class="flex flex-col gap-8 lg:flex-row lg:items-stretch lg:gap-16"
>

View File

@@ -10,7 +10,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40">
<section class="px-4 py-24 lg:px-20 lg:py-40">
<div
class="bg-transparency-white-t4 rounded-5xl flex flex-col-reverse items-stretch gap-10 p-2 lg:flex-row lg:gap-8"
>

View File

@@ -77,9 +77,7 @@ function getCardClass(layoutClass: string): string {
</script>
<template>
<section
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40"
>
<section class="bg-primary-comfy-ink px-4 py-24 lg:px-20 lg:py-40">
<div class="mx-auto flex w-full max-w-7xl flex-col items-center">
<p
class="text-primary-comfy-yellow text-center text-sm font-bold tracking-widest uppercase"

View File

@@ -11,7 +11,7 @@ defineProps<{
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
<section class="px-4 py-24 lg:px-20">
<SectionHeader>
{{ heading }}
<template v-if="subtitle" #subtitle>

View File

@@ -22,7 +22,7 @@ defineProps<{
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-24 lg:px-20">
<section class="px-4 py-24 lg:px-20">
<SectionHeader>
{{ heading }}
<template #subtitle>

View File

@@ -29,7 +29,7 @@ const {
<template>
<section
class="max-w-9xl mx-auto flex flex-col gap-4 px-4 py-24 lg:flex-row lg:gap-16 lg:px-20 lg:py-40"
class="flex flex-col gap-4 px-4 py-24 lg:flex-row lg:gap-16 lg:px-20 lg:py-40"
>
<!-- Left heading -->
<div

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,22 +14,16 @@ const baseRoutes = {
demos: '/demos',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
contact: '/contact',
models: '/p/supported-models'
contact: '/contact'
} as const
type Routes = typeof baseRoutes
const localeInvariantRouteKeys = new Set<keyof Routes>(['termsOfService'])
export function getRoutes(locale: Locale = 'en'): Routes {
if (locale === 'en') return baseRoutes
const prefix = `/${locale}`
return Object.fromEntries(
Object.entries(baseRoutes).map(([k, v]) => [
k,
localeInvariantRouteKeys.has(k as keyof Routes) ? v : `${prefix}${v}`
])
Object.entries(baseRoutes).map(([k, v]) => [k, `${prefix}${v}`])
) as unknown as Routes
}
@@ -37,7 +31,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-22T00:07:48.353Z",
"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": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2"
"location": "San Francisco",
"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": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e"
"location": "San Francisco",
"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": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f"
"location": "San Francisco",
"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": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0"
"location": "San Francisco",
"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,53 +109,39 @@
"key": "marketing",
"roles": [
{
"id": "23dd98cab77ff459",
"id": "4c5d6afb78652df7",
"title": "Freelance Motion Designer",
"department": "Marketing",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b"
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
},
{
"id": "a998b9fc973ff3c0",
"id": "0f5256cf302e552b",
"title": "Creative Artist",
"department": "Marketing",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d"
"location": "San Francisco",
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
},
{
"id": "3e730938026d6e70",
"id": "5746486d87874937",
"title": "Graphic Designer",
"department": "Marketing",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f"
"location": "San Francisco",
"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"
},
{
"id": "e11f8b9e58dbea81",
"title": "Creative Producer",
"department": "Marketing",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7be2d690-7a2b-4ebf-b1c4-6907b273d3d9"
},
{
"id": "6eac654593208ec3",
"title": "Forward Deployed Creative Technologist",
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/af49c05f-dcd8-4c3d-a464-43eb3b1c6efc"
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c/application"
}
]
},
@@ -157,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"
}
]
}

File diff suppressed because one or more lines are too long

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)
)
})
}

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