Compare commits
72 Commits
v1.45.4
...
glary/redr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9529182117 | ||
|
|
bb3731bcd9 | ||
|
|
71092b2011 | ||
|
|
d05ec230bf | ||
|
|
d6f632477f | ||
|
|
1ab63807e9 | ||
|
|
e35bea51d6 | ||
|
|
f429e1e0c4 | ||
|
|
d6b4137eec | ||
|
|
b578147714 | ||
|
|
06e09df673 | ||
|
|
e972d658d3 | ||
|
|
f090ea3d28 | ||
|
|
daab936d15 | ||
|
|
f176d18fe0 | ||
|
|
01742672bb | ||
|
|
b7fe0365af | ||
|
|
bdb92c845e | ||
|
|
d96be3d668 | ||
|
|
1869416185 | ||
|
|
a3106c4d53 | ||
|
|
b59b342a43 | ||
|
|
95e616b894 | ||
|
|
5738c7a539 | ||
|
|
8abfa678a3 | ||
|
|
b36b601a1c | ||
|
|
ad63f7cb9b | ||
|
|
f7ef563b46 | ||
|
|
9cc09cd46c | ||
|
|
de1c1ee1f2 | ||
|
|
86b1e1a965 | ||
|
|
4321013798 | ||
|
|
7ce0973386 | ||
|
|
6e9be7b164 | ||
|
|
4b5b184cad | ||
|
|
129bfd9f1b | ||
|
|
e60ae14bc0 | ||
|
|
b172534f55 | ||
|
|
a0150ffe17 | ||
|
|
c92030b158 | ||
|
|
988d532467 | ||
|
|
fe08ad2fcd | ||
|
|
93edf166d0 | ||
|
|
9fe19a2afb | ||
|
|
6845d57a80 | ||
|
|
469a5edf99 | ||
|
|
35443e94f5 | ||
|
|
e05e6cd2fb | ||
|
|
681915275e | ||
|
|
3d9c9ce327 | ||
|
|
e765eb1bb2 | ||
|
|
56434ae9ac | ||
|
|
25c2d828c0 | ||
|
|
ceb9936058 | ||
|
|
bb420fe2c7 | ||
|
|
4504256f11 | ||
|
|
1290bbd359 | ||
|
|
7ddf71d91b | ||
|
|
74caeb0b0b | ||
|
|
ced7c93e63 | ||
|
|
759ed3d4e2 | ||
|
|
5d53e75d23 | ||
|
|
d23e86d9a4 | ||
|
|
d901c63a0b | ||
|
|
5ca9f3e7e6 | ||
|
|
6d5fa743b3 | ||
|
|
603dd3eb4e | ||
|
|
d767a325a2 | ||
|
|
39b2bb5eab | ||
|
|
c643438601 | ||
|
|
02e1ba2968 | ||
|
|
15b8771cc2 |
173
.claude/skills/add-model-page/SKILL.md
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
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 |
|
||||
@@ -19,15 +19,26 @@ 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`), 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.
|
||||
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.
|
||||
|
||||
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 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.
|
||||
|
||||
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: |
|
||||
|
||||
19
.github/actions/cloud-nodes-pull/action.yaml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
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
|
||||
12
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -106,17 +106,12 @@ 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
|
||||
--precision 1 \
|
||||
--ignore-errors source,unmapped
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
@@ -130,7 +125,8 @@ jobs:
|
||||
needs: merge
|
||||
if: >
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
needs.merge.outputs.has-coverage == 'true'
|
||||
needs.merge.outputs.has-coverage == 'true' &&
|
||||
github.event.workflow_run.event == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
|
||||
11
.github/workflows/ci-vercel-website-preview.yaml
vendored
@@ -58,6 +58,7 @@ 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
|
||||
@@ -151,10 +152,20 @@ 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
|
||||
|
||||
123
.github/workflows/model-page-discovery.yaml
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
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."
|
||||
37
.github/workflows/release-website.yaml
vendored
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
# 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.
|
||||
name: 'Release: Website'
|
||||
|
||||
on:
|
||||
@@ -11,7 +11,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
refresh-snapshot:
|
||||
refresh-snapshots:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -31,28 +31,39 @@ 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 roles snapshot'
|
||||
title: 'chore(website): refresh Ashby roles snapshot'
|
||||
commit-message: 'chore(website): refresh Ashby and cloud nodes snapshots'
|
||||
title: 'chore(website): refresh Ashby and cloud nodes snapshots'
|
||||
body: |
|
||||
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
|
||||
from the Ashby job board API.
|
||||
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`
|
||||
|
||||
**Flow:**
|
||||
1. `Release: Website` workflow ran (manual trigger).
|
||||
2. This PR opens with the regenerated snapshot.
|
||||
2. This PR opens with the regenerated snapshots.
|
||||
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` remains
|
||||
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
|
||||
committed snapshot.
|
||||
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).
|
||||
|
||||
Triggered by workflow run `${{ github.run_id }}`.
|
||||
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
|
||||
branch: chore/refresh-website-snapshots-${{ github.run_id }}
|
||||
base: main
|
||||
labels: |
|
||||
Release:Website
|
||||
|
||||
@@ -85,6 +85,15 @@
|
||||
"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",
|
||||
|
||||
@@ -9,6 +9,7 @@ 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<
|
||||
@@ -167,7 +168,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: navigator.language.split('-')[0] || 'en',
|
||||
locale: getDefaultLocale(),
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
// Ignore warnings for locale options as each option is in its own language.
|
||||
|
||||
@@ -113,6 +113,50 @@ 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:
|
||||
@@ -146,3 +190,4 @@ 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
|
||||
|
||||
@@ -23,26 +23,50 @@ test.describe('Careers page @smoke', () => {
|
||||
expect(await roles.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('each role links to jobs.ashbyhq.com', async ({ page }) => {
|
||||
test('clicking a department button scrolls to and activates that section', async ({
|
||||
page
|
||||
}) => {
|
||||
const rolesSection = page.getByTestId('careers-roles')
|
||||
await rolesSection.scrollIntoViewIfNeeded()
|
||||
await expect(rolesSection).toBeVisible()
|
||||
|
||||
const allCount = await page.getByTestId('careers-role-link').count()
|
||||
|
||||
const engineeringButton = page.getByRole('button', {
|
||||
name: 'ENGINEERING',
|
||||
exact: true
|
||||
})
|
||||
|
||||
// RolesSection is hydrated via `client:visible`. Once the button responds
|
||||
// to a click by flipping aria-pressed, Vue is hydrated and the rest of
|
||||
// the locator logic is in effect.
|
||||
await expect(async () => {
|
||||
await engineeringButton.click()
|
||||
await expect(engineeringButton).toHaveAttribute('aria-pressed', 'true', {
|
||||
timeout: 1_000
|
||||
})
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
const engineeringSection = page.locator('#careers-dept-engineering')
|
||||
await expect(engineeringSection).toBeInViewport()
|
||||
|
||||
expect(await page.getByTestId('careers-role-link').count()).toBe(allCount)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Careers page role links', () => {
|
||||
test('each role links to the Ashby job description page, not the application form', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/careers')
|
||||
const roles = page.getByTestId('careers-role-link')
|
||||
const count = await roles.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const href = await roles.nth(i).getAttribute('href')
|
||||
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
|
||||
expect(href).not.toMatch(/\/application\/?$/)
|
||||
}
|
||||
})
|
||||
|
||||
test('ENGINEERING category filter narrows the role list', async ({
|
||||
page
|
||||
}) => {
|
||||
const allCount = await page.getByTestId('careers-role-link').count()
|
||||
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
|
||||
const engineeringLocator = page.getByTestId('careers-role-link')
|
||||
await expect(engineeringLocator.first()).toBeVisible()
|
||||
const engineeringCount = await engineeringLocator.count()
|
||||
expect(engineeringCount).toBeLessThanOrEqual(allCount)
|
||||
expect(engineeringCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Careers page (zh-CN) @smoke', () => {
|
||||
|
||||
169
apps/website/e2e/cloud-nodes.spec.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
61
apps/website/e2e/content-section.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const M4_PRO_14_INCH_VIEWPORT = { width: 2016, height: 1310 }
|
||||
const LAST_SECTION_HASH = '#contact'
|
||||
|
||||
test.describe(
|
||||
'ContentSection scroll-spy @smoke',
|
||||
{
|
||||
annotation: [
|
||||
{
|
||||
type: 'issue',
|
||||
description:
|
||||
'https://linear.app/comfyorg/issue/FE-604/bug-bottom-badge-not-activating-on-scroll-at-high-resolution-3024x1964'
|
||||
},
|
||||
{
|
||||
type: 'environment',
|
||||
description:
|
||||
'14" MacBook M4 Pro logical viewport reported in FE-604; /privacy-policy reproduces because of its short trailing sections'
|
||||
}
|
||||
]
|
||||
},
|
||||
() => {
|
||||
test.use({ viewport: M4_PRO_14_INCH_VIEWPORT })
|
||||
|
||||
test('activates the last badge when user scrolls to the bottom', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/privacy-policy')
|
||||
|
||||
const sidebarNav = page.getByRole('navigation', {
|
||||
name: 'Category filter'
|
||||
})
|
||||
const badges = sidebarNav.getByRole('button')
|
||||
const lastBadge = badges.last()
|
||||
|
||||
await expect(badges.first()).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(lastBadge).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
await page.evaluate(() =>
|
||||
window.scrollTo(0, document.documentElement.scrollHeight)
|
||||
)
|
||||
|
||||
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('activates the last badge when page mounts already at the bottom via trailing hash', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(`/privacy-policy${LAST_SECTION_HASH}`)
|
||||
|
||||
const sidebarNav = page.getByRole('navigation', {
|
||||
name: 'Category filter'
|
||||
})
|
||||
const lastBadge = sidebarNav.getByRole('button').last()
|
||||
|
||||
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,27 +1,71 @@
|
||||
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', () => {
|
||||
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()
|
||||
})
|
||||
for (const demo of demos) {
|
||||
const nextDemo = getNextDemo(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()
|
||||
})
|
||||
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 next demo navigation', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(page.getByText(/what's next/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 library page renders', async ({ page }) => {
|
||||
await page.goto('/demos')
|
||||
@@ -32,13 +76,4 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
56
apps/website/e2e/pricing.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
@@ -47,4 +48,105 @@ test.describe('Mobile layout @mobile', () => {
|
||||
const mobileContainer = page.getByTestId('social-proof-mobile')
|
||||
await expect(mobileContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('SocialProofBar seamless marquee', () => {
|
||||
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
|
||||
|
||||
test('mobile forward marquee loops seamlessly', async ({ page }) => {
|
||||
const geometry = await measureMarqueeLoopGeometry(
|
||||
page,
|
||||
'[data-testid="social-proof-mobile"] .animate-marquee'
|
||||
)
|
||||
expectSeamlessForwardLoop(geometry)
|
||||
})
|
||||
|
||||
test('mobile reverse marquee loops seamlessly', async ({ page }) => {
|
||||
const geometry = await measureMarqueeLoopGeometry(
|
||||
page,
|
||||
'[data-testid="social-proof-mobile"] .animate-marquee-reverse'
|
||||
)
|
||||
expectSeamlessReverseLoop(geometry)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Desktop SocialProofBar @smoke', () => {
|
||||
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('desktop marquee loops seamlessly', async ({ page }) => {
|
||||
const geometry = await measureMarqueeLoopGeometry(
|
||||
page,
|
||||
'[data-testid="social-proof-desktop"] .animate-marquee'
|
||||
)
|
||||
expectSeamlessForwardLoop(geometry)
|
||||
})
|
||||
})
|
||||
|
||||
type MarqueeGeometry = {
|
||||
copyWidths: number[]
|
||||
startPositions: number[]
|
||||
endPositions: number[]
|
||||
}
|
||||
|
||||
async function measureMarqueeLoopGeometry(
|
||||
page: Page,
|
||||
selector: string
|
||||
): Promise<MarqueeGeometry> {
|
||||
await page.locator(selector).first().waitFor()
|
||||
return page.evaluate((sel) => {
|
||||
const tracks = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(sel)
|
||||
).slice(0, 2)
|
||||
const firstAnimation = tracks[0]?.getAnimations()[0]
|
||||
if (!firstAnimation) {
|
||||
throw new Error(`No CSS animation found on ${sel}`)
|
||||
}
|
||||
const duration = firstAnimation.effect?.getTiming().duration
|
||||
if (typeof duration !== 'number' || duration <= 1) {
|
||||
throw new Error(
|
||||
`Animation on ${sel} has unusable duration: ${String(duration)}`
|
||||
)
|
||||
}
|
||||
const setAllTimes = (time: number) => {
|
||||
for (const track of tracks) {
|
||||
for (const anim of track.getAnimations()) {
|
||||
anim.currentTime = time
|
||||
}
|
||||
}
|
||||
void document.body.offsetWidth
|
||||
}
|
||||
const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
|
||||
setAllTimes(0)
|
||||
const startPositions = readX()
|
||||
const copyWidths = tracks.map(
|
||||
(track) => track.getBoundingClientRect().width
|
||||
)
|
||||
setAllTimes(duration - 0.1)
|
||||
const endPositions = readX()
|
||||
return { copyWidths, startPositions, endPositions }
|
||||
}, selector)
|
||||
}
|
||||
|
||||
function expectTwoMatchingCopies(geometry: MarqueeGeometry) {
|
||||
const { copyWidths } = geometry
|
||||
expect(copyWidths.length, 'expected two duplicate marquee tracks').toBe(2)
|
||||
expect(copyWidths[0]).toBeGreaterThan(0)
|
||||
expect(copyWidths[1]).toBeCloseTo(copyWidths[0], 0)
|
||||
}
|
||||
|
||||
function expectSeamlessForwardLoop(geometry: MarqueeGeometry) {
|
||||
expectTwoMatchingCopies(geometry)
|
||||
// Copy 2 ends the cycle exactly where copy 1 started, so the restart
|
||||
// (when copy 1 jumps back to its start position) is visually indistinguishable.
|
||||
expect(geometry.endPositions[1]).toBeCloseTo(geometry.startPositions[0], 0)
|
||||
}
|
||||
|
||||
function expectSeamlessReverseLoop(geometry: MarqueeGeometry) {
|
||||
expectTwoMatchingCopies(geometry)
|
||||
// Reverse marquee: copy 1 ends the cycle where copy 2 started.
|
||||
expect(geometry.endPositions[0]).toBeCloseTo(geometry.startPositions[1], 0)
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 91 KiB |
@@ -15,11 +15,15 @@
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"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:",
|
||||
|
||||
|
After Width: | Height: | Size: 81 KiB |
BIN
apps/website/public/images/demos/community-workflows-og.png
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
apps/website/public/images/demos/community-workflows-thumb.webp
Normal file
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 20 KiB |
396
apps/website/scripts/generate-models.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
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)
|
||||
}
|
||||
32
apps/website/scripts/refresh-cloud-nodes-snapshot.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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`
|
||||
)
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useEventListener, useTemplateRefsList } from '@vueuse/core'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import type { Department } from '../../data/roles'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { scrollTo } from '../../scripts/smoothScroll'
|
||||
import CategoryNav from '../common/CategoryNav.vue'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
|
||||
@@ -13,24 +16,72 @@ const { locale = 'en', departments = [] } = defineProps<{
|
||||
departments?: readonly Department[]
|
||||
}>()
|
||||
|
||||
const activeCategory = ref('all')
|
||||
|
||||
const visibleDepartments = computed(() =>
|
||||
departments.filter((d) => d.roles.length > 0)
|
||||
)
|
||||
|
||||
const categories = computed(() => [
|
||||
{ label: 'ALL', value: 'all' },
|
||||
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
|
||||
])
|
||||
|
||||
const filteredDepartments = computed(() =>
|
||||
activeCategory.value === 'all'
|
||||
? visibleDepartments.value
|
||||
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
|
||||
const categories = computed(() =>
|
||||
visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
|
||||
)
|
||||
|
||||
const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
|
||||
const activeCategory = ref('')
|
||||
|
||||
const sectionRefs = useTemplateRefsList<HTMLElement>()
|
||||
|
||||
let isScrolling = false
|
||||
let pendingFrame = 0
|
||||
|
||||
const HEADER_OFFSET = -144
|
||||
const ACTIVATION_OFFSET = 300
|
||||
|
||||
const deptElementId = (key: string) => `careers-dept-${key}`
|
||||
|
||||
function pickActiveSection() {
|
||||
pendingFrame = 0
|
||||
if (isScrolling) return
|
||||
const sections = sectionRefs.value as HTMLElement[]
|
||||
if (sections.length === 0) return
|
||||
|
||||
let active = sections[0]
|
||||
for (const el of sections) {
|
||||
if (el.getBoundingClientRect().top - ACTIVATION_OFFSET <= 0) {
|
||||
active = el
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
activeCategory.value = active.id.replace(/^careers-dept-/, '')
|
||||
}
|
||||
|
||||
function scheduleUpdate() {
|
||||
if (pendingFrame !== 0) return
|
||||
pendingFrame = requestAnimationFrame(pickActiveSection)
|
||||
}
|
||||
|
||||
onMounted(pickActiveSection)
|
||||
useEventListener('scroll', scheduleUpdate, { passive: true })
|
||||
useEventListener('resize', scheduleUpdate, { passive: true })
|
||||
|
||||
function scrollToDepartment(deptKey: string) {
|
||||
activeCategory.value = deptKey
|
||||
isScrolling = true
|
||||
const el = document.getElementById(deptElementId(deptKey))
|
||||
if (!el) {
|
||||
isScrolling = false
|
||||
return
|
||||
}
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: () => {
|
||||
isScrolling = false
|
||||
pickActiveSection()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -48,9 +99,10 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
</h2>
|
||||
<CategoryNav
|
||||
v-if="hasRoles"
|
||||
v-model="activeCategory"
|
||||
:categories="categories"
|
||||
:model-value="activeCategory"
|
||||
class="mt-4"
|
||||
@update:model-value="scrollToDepartment"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,9 +117,11 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="dept in filteredDepartments"
|
||||
v-for="dept in visibleDepartments"
|
||||
:id="deptElementId(dept.key)"
|
||||
:ref="sectionRefs.set"
|
||||
:key="dept.key"
|
||||
class="mb-12 last:mb-0"
|
||||
class="mb-12 scroll-mt-24 last:mb-0 md:scroll-mt-36"
|
||||
>
|
||||
<SectionLabel>
|
||||
{{ dept.name }}
|
||||
@@ -76,7 +130,7 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
<a
|
||||
v-for="role in dept.roles"
|
||||
:key="role.id"
|
||||
:href="role.applyUrl"
|
||||
:href="role.jobUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
|
||||
|
||||
30
apps/website/src/components/cloud-nodes/HeroSection.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<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>
|
||||
49
apps/website/src/components/cloud-nodes/NodeList.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<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>
|
||||
52
apps/website/src/components/cloud-nodes/PackBanner.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<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>
|
||||
80
apps/website/src/components/cloud-nodes/PackCard.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<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>
|
||||
203
apps/website/src/components/cloud-nodes/PackDetail.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<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>
|
||||
113
apps/website/src/components/cloud-nodes/PackGridSection.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<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>
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
useEventListener,
|
||||
useIntersectionObserver,
|
||||
useTemplateRefsList
|
||||
} from '@vueuse/core'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
@@ -40,13 +44,25 @@ const activeSection = ref(sections[0]?.id ?? '')
|
||||
|
||||
const sectionRefs = useTemplateRefsList<HTMLElement>()
|
||||
let isScrolling = false
|
||||
let scrollSafetyTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const HEADER_OFFSET = -144
|
||||
const BOTTOM_THRESHOLD_PX = 4
|
||||
const SCROLL_SAFETY_MS = 1500
|
||||
|
||||
function clearScrollLock() {
|
||||
isScrolling = false
|
||||
if (scrollSafetyTimer !== undefined) {
|
||||
clearTimeout(scrollSafetyTimer)
|
||||
scrollSafetyTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
useIntersectionObserver(
|
||||
sectionRefs,
|
||||
(entries) => {
|
||||
if (isScrolling) return
|
||||
if (isAtBottom()) return
|
||||
let best: IntersectionObserverEntry | null = null
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
@@ -58,22 +74,39 @@ useIntersectionObserver(
|
||||
{ rootMargin: '-20% 0px -60% 0px' }
|
||||
)
|
||||
|
||||
function isAtBottom(): boolean {
|
||||
const scrollBottom = window.scrollY + window.innerHeight
|
||||
return (
|
||||
scrollBottom >= document.documentElement.scrollHeight - BOTTOM_THRESHOLD_PX
|
||||
)
|
||||
}
|
||||
|
||||
function activateLastIfAtBottom() {
|
||||
if (isScrolling) return
|
||||
if (!isAtBottom()) return
|
||||
const lastId = sections[sections.length - 1]?.id
|
||||
if (lastId) activeSection.value = lastId
|
||||
}
|
||||
|
||||
onMounted(activateLastIfAtBottom)
|
||||
useEventListener('scroll', activateLastIfAtBottom, { passive: true })
|
||||
|
||||
function scrollToSection(id: string) {
|
||||
activeSection.value = id
|
||||
clearScrollLock()
|
||||
isScrolling = true
|
||||
scrollSafetyTimer = setTimeout(clearScrollLock, SCROLL_SAFETY_MS)
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: () => {
|
||||
isScrolling = false
|
||||
}
|
||||
onComplete: clearScrollLock
|
||||
})
|
||||
return
|
||||
}
|
||||
isScrolling = false
|
||||
clearScrollLock()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const {
|
||||
<img
|
||||
src="/icons/node-left.svg"
|
||||
alt=""
|
||||
class="-mx-px self-stretch"
|
||||
class="-mx-px h-full w-auto self-stretch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
@@ -38,7 +38,7 @@ const {
|
||||
v-if="i > 0"
|
||||
src="/icons/node-union.svg"
|
||||
alt=""
|
||||
class="-mx-px self-stretch"
|
||||
class="-mx-px h-full w-auto self-stretch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
@@ -72,7 +72,7 @@ const {
|
||||
<img
|
||||
src="/icons/node-right.svg"
|
||||
alt=""
|
||||
class="-mx-px self-stretch"
|
||||
class="-mx-px h-full w-auto self-stretch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -82,7 +82,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
|
||||
]
|
||||
}
|
||||
|
||||
const contactColumn = {
|
||||
const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
title: t('footer.contact', locale),
|
||||
links: [
|
||||
{ label: t('footer.sales', locale), href: routes.contact },
|
||||
@@ -91,6 +91,11 @@ const contactColumn = {
|
||||
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' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,23 +14,28 @@ const logos = [
|
||||
'Ubisoft'
|
||||
]
|
||||
|
||||
const desktopLogos = Array.from({ length: 4 }, () => logos).flat()
|
||||
const row1 = logos.slice(0, 6)
|
||||
const mobileRow1 = [...row1, ...row1]
|
||||
const row2 = logos.slice(6)
|
||||
const mobileRow2 = [...row2, ...row2]
|
||||
const mobileRow1Logos = logos.slice(0, 6)
|
||||
const mobileRow2Logos = logos.slice(6)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overflow-hidden py-12">
|
||||
<!-- Single row on desktop -->
|
||||
<div class="animate-marquee hidden items-center gap-2 md:flex">
|
||||
<div data-testid="social-proof-desktop" class="hidden w-max gap-2 md:flex">
|
||||
<div
|
||||
v-for="(logo, i) in desktopLogos"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-20 w-50 shrink-0 items-center justify-center"
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-2"
|
||||
style="--marquee-gap: 0.5rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
<div
|
||||
v-for="logo in logos"
|
||||
:key="logo"
|
||||
class="flex h-20 w-50 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,22 +44,38 @@ const mobileRow2 = [...row2, ...row2]
|
||||
data-testid="social-proof-mobile"
|
||||
class="flex flex-col gap-8 md:hidden"
|
||||
>
|
||||
<div class="animate-marquee flex items-center gap-8">
|
||||
<div class="flex w-max gap-8">
|
||||
<div
|
||||
v-for="(logo, i) in mobileRow1"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
<div
|
||||
v-for="logo in mobileRow1Logos"
|
||||
:key="logo"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="animate-marquee-reverse flex items-center gap-8">
|
||||
<div class="flex w-max gap-8">
|
||||
<div
|
||||
v-for="(logo, i) in mobileRow2"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
<div
|
||||
v-for="logo in mobileRow2Logos"
|
||||
:key="logo"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,12 @@ import { t } from '../../i18n/translations'
|
||||
const {
|
||||
arcadeId,
|
||||
title,
|
||||
aspectRatio = 16 / 9,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
arcadeId: string
|
||||
title: string
|
||||
aspectRatio?: number
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
@@ -24,7 +26,8 @@ const loaded = ref(false)
|
||||
:aria-label="t('demos.embed.label', locale)"
|
||||
>
|
||||
<div
|
||||
class="relative mx-auto aspect-video max-w-6xl overflow-hidden rounded-4xl border border-white/10"
|
||||
class="relative mx-auto max-w-6xl overflow-hidden rounded-4xl border border-white/10"
|
||||
:style="{ aspectRatio }"
|
||||
>
|
||||
<div
|
||||
v-if="!loaded"
|
||||
|
||||
137
apps/website/src/components/models/ModelHeroSection.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<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>
|
||||
@@ -7,6 +7,7 @@ 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'
|
||||
|
||||
@@ -37,21 +38,23 @@ 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[] = [
|
||||
{
|
||||
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' }
|
||||
]
|
||||
},
|
||||
...(SHOW_FREE_TIER ? [freePlan] : []),
|
||||
{
|
||||
id: 'standard',
|
||||
labelKey: 'pricing.plan.standard.label',
|
||||
@@ -61,7 +64,9 @@ const plans: PricingPlan[] = [
|
||||
estimateKey: 'pricing.plan.standard.estimate',
|
||||
ctaKey: 'pricing.plan.standard.cta',
|
||||
ctaHref: subscribeUrl('standard'),
|
||||
featureIntroKey: 'pricing.plan.standard.featureIntro',
|
||||
featureIntroKey: SHOW_FREE_TIER
|
||||
? 'pricing.plan.standard.featureIntro'
|
||||
: undefined,
|
||||
features: [
|
||||
{ text: 'pricing.plan.standard.feature1' },
|
||||
{ text: 'pricing.plan.standard.feature2' }
|
||||
@@ -150,9 +155,14 @@ const activePlanIndex = ref(0)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: 4-column grid / Mobile: single card -->
|
||||
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: single card -->
|
||||
<div
|
||||
class="rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:grid-cols-4 lg:gap-2"
|
||||
: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'
|
||||
)
|
||||
"
|
||||
>
|
||||
<PricingTierCard v-for="plan in standardPlans" :key="plan.id">
|
||||
<!-- Label + badge -->
|
||||
@@ -223,10 +233,18 @@ const activePlanIndex = ref(0)
|
||||
|
||||
<!-- Features -->
|
||||
<div v-if="plan.features.length" class="px-6 py-3">
|
||||
<p class="text-primary-comfy-canvas mb-2 text-sm font-semibold">
|
||||
{{
|
||||
plan.featureIntroKey ? t(plan.featureIntroKey, locale) : ' '
|
||||
}}
|
||||
<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"
|
||||
>
|
||||
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<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'
|
||||
|
||||
@@ -25,7 +26,10 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
{{ t('cloud.pricing.description', locale) }}
|
||||
</p>
|
||||
|
||||
<p class="text-primary-comfy-ink mt-4 text-base font-bold">
|
||||
<p
|
||||
v-if="SHOW_FREE_TIER"
|
||||
class="text-primary-comfy-ink mt-4 text-base font-bold"
|
||||
>
|
||||
{{ t('cloud.pricing.tagline', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -276,29 +276,6 @@ onUnmounted(() => {
|
||||
fill="#211927"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Left-edge fade -->
|
||||
<rect
|
||||
x="300"
|
||||
y="150"
|
||||
width="250"
|
||||
height="900"
|
||||
fill="url(#localHeroFadeLeft)"
|
||||
/>
|
||||
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="localHeroFadeLeft"
|
||||
x1="550"
|
||||
y1="600"
|
||||
x2="300"
|
||||
y2="600"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#211927" stop-opacity="0" />
|
||||
<stop offset="1" stop-color="#211927" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
|
||||
52
apps/website/src/composables/useBannerImage.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
30
apps/website/src/composables/useBannerImage.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
144
apps/website/src/composables/useFilteredPacks.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
53
apps/website/src/composables/useFilteredPacks.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
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 }
|
||||
}
|
||||
68
apps/website/src/composables/useNodesByCategory.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
40
apps/website/src/composables/useNodesByCategory.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
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 }
|
||||
}
|
||||
@@ -15,6 +15,14 @@ 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[] = [
|
||||
@@ -32,7 +40,8 @@ export const demos: readonly Demo[] = [
|
||||
difficulty: 'beginner',
|
||||
tags: ['templates', 'image', 'video'],
|
||||
publishedDate: '2026-04-19',
|
||||
modifiedDate: '2026-04-19'
|
||||
modifiedDate: '2026-04-19',
|
||||
aspectRatio: 1.931
|
||||
},
|
||||
{
|
||||
slug: 'workflow-templates',
|
||||
@@ -48,7 +57,25 @@ export const demos: readonly Demo[] = [
|
||||
difficulty: 'beginner',
|
||||
tags: ['getting-started', 'templates', 'workflow'],
|
||||
publishedDate: '2026-04-19',
|
||||
modifiedDate: '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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
1
apps/website/src/config/features.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const SHOW_FREE_TIER = false
|
||||
1945
apps/website/src/config/generated-models.json
Normal file
208
apps/website/src/config/model-metadata.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
81
apps/website/src/config/models.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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)
|
||||
}
|
||||
@@ -14,7 +14,8 @@ const baseRoutes = {
|
||||
demos: '/demos',
|
||||
termsOfService: '/terms-of-service',
|
||||
privacyPolicy: '/privacy-policy',
|
||||
contact: '/contact'
|
||||
contact: '/contact',
|
||||
models: '/p/supported-models'
|
||||
} as const
|
||||
|
||||
type Routes = typeof baseRoutes
|
||||
@@ -31,6 +32,7 @@ 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',
|
||||
|
||||
@@ -1,37 +1,30 @@
|
||||
{
|
||||
"fetchedAt": "2026-05-02T20:15:18.321Z",
|
||||
"fetchedAt": "2026-05-12T16:10:34.114Z",
|
||||
"departments": [
|
||||
{
|
||||
"name": "DESIGN",
|
||||
"key": "design",
|
||||
"roles": [
|
||||
{
|
||||
"id": "e915f2c78b17f93b",
|
||||
"id": "18743530eb448c99",
|
||||
"title": "Senior Product Designer",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"id": "8718d17012f26fa2",
|
||||
"title": "Senior Product Designer - Craft",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f"
|
||||
},
|
||||
{
|
||||
"id": "7bb02634a24763bc",
|
||||
"id": "1e181b9ed8fb2e86",
|
||||
"title": "Staff Product Designer - Systems",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -40,67 +33,67 @@
|
||||
"key": "engineering",
|
||||
"roles": [
|
||||
{
|
||||
"id": "102d58e35a8a9817",
|
||||
"id": "6a6d865eeb3c10a8",
|
||||
"title": "Senior Software Engineer, Frontend",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2"
|
||||
},
|
||||
{
|
||||
"id": "d01d69fba7743905",
|
||||
"id": "1b4f7f1da9616e14",
|
||||
"title": "Senior Software Engineer, Backend Generalist",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e"
|
||||
},
|
||||
{
|
||||
"id": "f36f60cfd5bb5910",
|
||||
"id": "a6d8269c66e37c5c",
|
||||
"title": "Senior/Staff Applied Machine Learning Engineer",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0"
|
||||
},
|
||||
{
|
||||
"id": "9d8ec4c65e20b19e",
|
||||
"id": "841da783e6e41928",
|
||||
"title": "Software Engineer, Frontend",
|
||||
"department": "Engineering",
|
||||
"location": "Remote",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40"
|
||||
},
|
||||
{
|
||||
"id": "be94b193d1f4d482",
|
||||
"id": "5d01d58b03870d7a",
|
||||
"title": "Tech Lead Manager, Frontend",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261"
|
||||
},
|
||||
{
|
||||
"id": "ab48f5db6bd1783c",
|
||||
"id": "91604c4182a1bc3c",
|
||||
"title": "Software Engineer, Core ComfyUI Contributor",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f"
|
||||
},
|
||||
{
|
||||
"id": "c5dff4ee628bdcd1",
|
||||
"id": "a1dbc0576ab14034",
|
||||
"title": "Software Engineer, ComfyUI Desktop",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0"
|
||||
},
|
||||
{
|
||||
"id": "4302a7aaa87e16e3",
|
||||
"id": "0b8f4fecd89c3b11",
|
||||
"title": "Product Manager, ComfyUI",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e"
|
||||
},
|
||||
{
|
||||
"id": "2eb53e8943cc9396",
|
||||
"id": "2f6bac39d723dfef",
|
||||
"title": "Growth Engineer",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -109,39 +102,39 @@
|
||||
"key": "marketing",
|
||||
"roles": [
|
||||
{
|
||||
"id": "4c5d6afb78652df7",
|
||||
"id": "23dd98cab77ff459",
|
||||
"title": "Freelance Motion Designer",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b"
|
||||
},
|
||||
{
|
||||
"id": "0f5256cf302e552b",
|
||||
"id": "a998b9fc973ff3c0",
|
||||
"title": "Creative Artist",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d"
|
||||
},
|
||||
{
|
||||
"id": "5746486d87874937",
|
||||
"id": "3e730938026d6e70",
|
||||
"title": "Graphic Designer",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f"
|
||||
},
|
||||
{
|
||||
"id": "b5803a0d4785d406",
|
||||
"id": "6f771af6858283aa",
|
||||
"title": "Lifecycle Growth Marketer",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64"
|
||||
},
|
||||
{
|
||||
"id": "130d7218d7895bdb",
|
||||
"id": "527a47e82970afc1",
|
||||
"title": "Partnership & Events Marketing Manager",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -150,25 +143,18 @@
|
||||
"key": "operations",
|
||||
"roles": [
|
||||
{
|
||||
"id": "ec68ae44dd5943c9",
|
||||
"title": "Talent Lead",
|
||||
"id": "0c6cc3685194ab7a",
|
||||
"title": "Head of Talent",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"id": "82bd6ed26adab1c3",
|
||||
"title": "Founding Customer Success Manager",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4/application"
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
394
apps/website/src/data/cloud-nodes.snapshot.json
Normal file
@@ -0,0 +1,394 @@
|
||||
{
|
||||
"fetchedAt": "2026-05-04T16:29:55.587Z",
|
||||
"packs": [
|
||||
{
|
||||
"id": "comfyui-impact-pack",
|
||||
"registryId": "comfyui-impact-pack",
|
||||
"displayName": "ComfyUI Impact Pack",
|
||||
"description": "Production-grade detailer, detector, and SEG (segmentation) tooling. The most-used pack for face restoration, region-based refinement, and iterative upscaling on Comfy Cloud.",
|
||||
"repoUrl": "https://github.com/ltdrdata/ComfyUI-Impact-Pack",
|
||||
"publisher": {
|
||||
"id": "drltdata",
|
||||
"name": "Dr.Lt.Data"
|
||||
},
|
||||
"downloads": 2618646,
|
||||
"githubStars": 3092,
|
||||
"latestVersion": "8.28.3",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-04-19T17:08:04.993918Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "FaceDetailer",
|
||||
"displayName": "FaceDetailer",
|
||||
"category": "ImpactPack/Detailer",
|
||||
"description": "Detect and refine faces with iterative passes."
|
||||
},
|
||||
{
|
||||
"name": "DetailerForEach",
|
||||
"displayName": "DetailerForEach",
|
||||
"category": "ImpactPack/Detailer",
|
||||
"description": "Run iterative detail refinement over detected SEG regions."
|
||||
},
|
||||
{
|
||||
"name": "UltralyticsDetectorProvider",
|
||||
"displayName": "UltralyticsDetectorProvider",
|
||||
"category": "ImpactPack/Detector",
|
||||
"description": "Provide detector models powered by Ultralytics YOLO."
|
||||
},
|
||||
{
|
||||
"name": "SAMLoader",
|
||||
"displayName": "SAMLoader",
|
||||
"category": "ImpactPack/Detector",
|
||||
"description": "Load Segment Anything models for high-fidelity masking."
|
||||
},
|
||||
{
|
||||
"name": "MaskToSEGS",
|
||||
"displayName": "MaskToSEGS",
|
||||
"category": "ImpactPack/Operation",
|
||||
"description": "Convert binary masks into SEGS regions for the detailer pipeline."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ComfyUI-Crystools",
|
||||
"registryId": "ComfyUI-Crystools",
|
||||
"displayName": "ComfyUI-Crystools",
|
||||
"description": "Live system monitoring (GPU, RAM, disk) and rich image inspection inside your workflow. The most-installed quality-of-life pack on the registry.",
|
||||
"iconUrl": "https://raw.githubusercontent.com/crystian/ComfyUI-Crystools/main/docs/screwdriver.png",
|
||||
"repoUrl": "https://github.com/crystian/ComfyUI-Crystools",
|
||||
"publisher": {
|
||||
"id": "crystian",
|
||||
"name": "Crystian"
|
||||
},
|
||||
"downloads": 1671447,
|
||||
"githubStars": 1855,
|
||||
"latestVersion": "1.27.4",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2025-10-26T19:11:09.943366Z",
|
||||
"supportedOs": ["OS Independent"],
|
||||
"supportedAccelerators": ["GPU :: NVIDIA CUDA"],
|
||||
"nodes": [
|
||||
{
|
||||
"name": "CCrystools_Show_Resources",
|
||||
"displayName": "CCrystools_Show_Resources",
|
||||
"category": "crystools/show",
|
||||
"description": "Display GPU, RAM and disk usage live in the workflow."
|
||||
},
|
||||
{
|
||||
"name": "CCrystools_Show_Image",
|
||||
"displayName": "CCrystools_Show_Image",
|
||||
"category": "crystools/show",
|
||||
"description": "Inspect images at full resolution with metadata overlays."
|
||||
},
|
||||
{
|
||||
"name": "CCrystools_Json",
|
||||
"displayName": "CCrystools_Json",
|
||||
"category": "crystools/json",
|
||||
"description": "Compose and parse JSON inline for advanced workflows."
|
||||
},
|
||||
{
|
||||
"name": "CCrystools_Pipe_To_Any",
|
||||
"displayName": "CCrystools_Pipe_To_Any",
|
||||
"category": "crystools/pipe",
|
||||
"description": "Convert a pipe bus into individual outputs."
|
||||
},
|
||||
{
|
||||
"name": "CCrystools_Save_Metadata",
|
||||
"displayName": "CCrystools_Save_Metadata",
|
||||
"category": "crystools/save",
|
||||
"description": "Save images with workflow metadata embedded."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rgthree-comfy",
|
||||
"registryId": "rgthree-comfy",
|
||||
"displayName": "rgthree-comfy",
|
||||
"description": "Quality-of-life nodes that make complex workflows readable: Power Lora Loader, group bypassers, smarter reroutes, and inline debug widgets.",
|
||||
"iconUrl": "https://comfy.rgthree.com/media/rgthree.svg",
|
||||
"repoUrl": "https://github.com/rgthree/rgthree-comfy",
|
||||
"publisher": {
|
||||
"id": "rgthree"
|
||||
},
|
||||
"downloads": 3025389,
|
||||
"githubStars": 3028,
|
||||
"latestVersion": "1.0.2604070017",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-04-07T04:19:24.689627Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Power Lora Loader (rgthree)",
|
||||
"displayName": "Power Lora Loader (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "Stack multiple LoRAs in a single, foldable widget."
|
||||
},
|
||||
{
|
||||
"name": "Fast Groups Bypasser (rgthree)",
|
||||
"displayName": "Fast Groups Bypasser (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "Toggle whole groups on or off without rewiring."
|
||||
},
|
||||
{
|
||||
"name": "Seed (rgthree)",
|
||||
"displayName": "Seed (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "A predictable seed control with quick reset."
|
||||
},
|
||||
{
|
||||
"name": "Reroute (rgthree)",
|
||||
"displayName": "Reroute (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "A clean reroute alternative with persistent labels."
|
||||
},
|
||||
{
|
||||
"name": "Display Any (rgthree)",
|
||||
"displayName": "Display Any (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "Inspect any value with a compact debug widget."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "comfyui-kjnodes",
|
||||
"registryId": "comfyui-kjnodes",
|
||||
"displayName": "ComfyUI-KJNodes",
|
||||
"description": "Daily-driver utilities for image, latent and string handling: color matching, batch counters, resize helpers, and prompt presets.",
|
||||
"iconUrl": "https://avatars.githubusercontent.com/u/40791699",
|
||||
"repoUrl": "https://github.com/kijai/ComfyUI-KJNodes",
|
||||
"publisher": {
|
||||
"id": "kijai",
|
||||
"name": "Kijai"
|
||||
},
|
||||
"downloads": 3319866,
|
||||
"githubStars": 2544,
|
||||
"latestVersion": "1.3.9",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-04-24T09:32:28.326616Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "ColorMatch",
|
||||
"displayName": "ColorMatch",
|
||||
"category": "KJNodes/image",
|
||||
"description": "Match the colors of one image to another using statistics."
|
||||
},
|
||||
{
|
||||
"name": "ImageResizeKJ",
|
||||
"displayName": "ImageResizeKJ",
|
||||
"category": "KJNodes/image",
|
||||
"description": "Resize images with intuitive size and divisibility controls."
|
||||
},
|
||||
{
|
||||
"name": "StringConstantMultiline",
|
||||
"displayName": "StringConstantMultiline",
|
||||
"category": "KJNodes/string",
|
||||
"description": "A multi-line string constant suitable for prompts."
|
||||
},
|
||||
{
|
||||
"name": "EmptyLatentImagePresets",
|
||||
"displayName": "EmptyLatentImagePresets",
|
||||
"category": "KJNodes/latent",
|
||||
"description": "Quickly create empty latents at common resolutions."
|
||||
},
|
||||
{
|
||||
"name": "GetImageSizeAndCount",
|
||||
"displayName": "GetImageSizeAndCount",
|
||||
"category": "KJNodes/image",
|
||||
"description": "Read width, height and batch size from an image input."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "comfyui-easy-use",
|
||||
"registryId": "comfyui-easy-use",
|
||||
"displayName": "ComfyUI-Easy-Use",
|
||||
"description": "Simplified, opinionated nodes that bundle common patterns into single drop-ins — full loader, pre-sampling, easy KSampler, and XY plotting.",
|
||||
"iconUrl": "https://mintlify.s3.us-west-1.amazonaws.com/yolain/images/logo.svg",
|
||||
"repoUrl": "https://github.com/yolain/ComfyUI-Easy-Use",
|
||||
"publisher": {
|
||||
"id": "yolain",
|
||||
"name": "yolain"
|
||||
},
|
||||
"downloads": 2767609,
|
||||
"githubStars": 2500,
|
||||
"latestVersion": "1.3.6",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-01-23T06:19:17.505188Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "easy fullLoader",
|
||||
"displayName": "easy fullLoader",
|
||||
"category": "EasyUse/Loaders",
|
||||
"description": "Combined checkpoint, VAE and CLIP loader with sensible defaults."
|
||||
},
|
||||
{
|
||||
"name": "easy preSampling",
|
||||
"displayName": "easy preSampling",
|
||||
"category": "EasyUse/PreSampling",
|
||||
"description": "A unified pre-sampling node bundling common settings."
|
||||
},
|
||||
{
|
||||
"name": "easy kSampler",
|
||||
"displayName": "easy kSampler",
|
||||
"category": "EasyUse/KSampler",
|
||||
"description": "A simplified KSampler with extra quality-of-life options."
|
||||
},
|
||||
{
|
||||
"name": "easy showAnything",
|
||||
"displayName": "easy showAnything",
|
||||
"category": "EasyUse/Util",
|
||||
"description": "Display any value inline for debugging."
|
||||
},
|
||||
{
|
||||
"name": "easy XYPlot",
|
||||
"displayName": "easy XYPlot",
|
||||
"category": "EasyUse/XYPlot",
|
||||
"description": "Compose XY plots over arbitrary parameters."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "comfyui-advanced-controlnet",
|
||||
"registryId": "comfyui-advanced-controlnet",
|
||||
"displayName": "ComfyUI-Advanced-ControlNet",
|
||||
"description": "ControlNet with timestep keyframes, per-frame masks, and advanced strength scheduling — essential for animation and batched-latent workflows.",
|
||||
"repoUrl": "https://github.com/Kosinkadink/ComfyUI-Advanced-ControlNet",
|
||||
"publisher": {
|
||||
"id": "kosinkadink",
|
||||
"name": "Kosinkadink"
|
||||
},
|
||||
"downloads": 590539,
|
||||
"githubStars": 967,
|
||||
"latestVersion": "1.5.7",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-03-30T01:40:06.836236Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Apply Advanced ControlNet",
|
||||
"displayName": "Apply Advanced ControlNet",
|
||||
"category": "Adv-ControlNet/conditioning",
|
||||
"description": "Apply ControlNet with timestep keyframes and per-frame masks."
|
||||
},
|
||||
{
|
||||
"name": "ControlNetLoaderAdvanced",
|
||||
"displayName": "ControlNetLoaderAdvanced",
|
||||
"category": "Adv-ControlNet/loaders",
|
||||
"description": "Load ControlNet models with the advanced wrapper."
|
||||
},
|
||||
{
|
||||
"name": "Latent Keyframe Group",
|
||||
"displayName": "Latent Keyframe Group",
|
||||
"category": "Adv-ControlNet/keyframes",
|
||||
"description": "Schedule ControlNet strength over a batch of latents."
|
||||
},
|
||||
{
|
||||
"name": "Timestep Keyframe",
|
||||
"displayName": "Timestep Keyframe",
|
||||
"category": "Adv-ControlNet/keyframes",
|
||||
"description": "Set ControlNet strength at a specific timestep."
|
||||
},
|
||||
{
|
||||
"name": "Scaled Soft Mask",
|
||||
"displayName": "Scaled Soft Mask",
|
||||
"category": "Adv-ControlNet/masks",
|
||||
"description": "Apply a soft attention mask to ControlNet conditioning."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "was-node-suite-comfyui",
|
||||
"registryId": "was-node-suite-comfyui",
|
||||
"displayName": "WAS Node Suite",
|
||||
"description": "A broad utility suite covering image adjustments, compositing, text, math, and I/O — the original \"kitchen sink\" pack still relied on by thousands of workflows.",
|
||||
"repoUrl": "https://github.com/WASasquatch/was-node-suite-comfyui",
|
||||
"publisher": {
|
||||
"id": "was",
|
||||
"name": "WAS"
|
||||
},
|
||||
"downloads": 981051,
|
||||
"githubStars": 1777,
|
||||
"latestVersion": "1.0.1",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2024-08-01T05:28:23.655235Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Image Filter Adjustments",
|
||||
"displayName": "Image Filter Adjustments",
|
||||
"category": "WAS Suite/Image/Adjustment",
|
||||
"description": "Adjust brightness, contrast, saturation and more."
|
||||
},
|
||||
{
|
||||
"name": "Image Blending Mode",
|
||||
"displayName": "Image Blending Mode",
|
||||
"category": "WAS Suite/Image/Compositing",
|
||||
"description": "Composite two images with Photoshop-style blend modes."
|
||||
},
|
||||
{
|
||||
"name": "Text String",
|
||||
"displayName": "Text String",
|
||||
"category": "WAS Suite/Text",
|
||||
"description": "A reusable text constant suitable for prompts."
|
||||
},
|
||||
{
|
||||
"name": "Number to Float",
|
||||
"displayName": "Number to Float",
|
||||
"category": "WAS Suite/Number",
|
||||
"description": "Cast integer or string values to a float."
|
||||
},
|
||||
{
|
||||
"name": "Image Save",
|
||||
"displayName": "Image Save",
|
||||
"category": "WAS Suite/IO",
|
||||
"description": "Save an image to disk with rich filename templating."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "comfyui_ipadapter_plus",
|
||||
"registryId": "comfyui_ipadapter_plus",
|
||||
"displayName": "ComfyUI_IPAdapter_plus",
|
||||
"description": "Reference-image conditioning with IPAdapter — style transfer, Face ID, and multi-image embeddings. The most-installed conditioning pack on the registry, used in countless portrait, product, and animation workflows.",
|
||||
"repoUrl": "https://github.com/cubiq/ComfyUI_IPAdapter_plus",
|
||||
"publisher": {
|
||||
"id": "matteo",
|
||||
"name": "Matteo"
|
||||
},
|
||||
"downloads": 1208394,
|
||||
"githubStars": 5938,
|
||||
"latestVersion": "2.0.0",
|
||||
"license": "GPL-3.0 license",
|
||||
"lastUpdated": "2024-06-05T06:57:13.485481Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "IPAdapterUnifiedLoader",
|
||||
"displayName": "IPAdapterUnifiedLoader",
|
||||
"category": "ipadapter",
|
||||
"description": "Load IPAdapter, image encoder and CLIP vision in one node."
|
||||
},
|
||||
{
|
||||
"name": "IPAdapterFaceID",
|
||||
"displayName": "IPAdapterFaceID",
|
||||
"category": "ipadapter/faceid",
|
||||
"description": "Apply Face ID embeddings for high-fidelity portrait reference."
|
||||
},
|
||||
{
|
||||
"name": "IPAdapterStyleComposition",
|
||||
"displayName": "IPAdapterStyleComposition",
|
||||
"category": "ipadapter",
|
||||
"description": "Reference an image for style without copying its content."
|
||||
},
|
||||
{
|
||||
"name": "IPAdapterAdvanced",
|
||||
"displayName": "IPAdapterAdvanced",
|
||||
"category": "ipadapter",
|
||||
"description": "Full-control IPAdapter with masking, weights, and noise injection."
|
||||
},
|
||||
{
|
||||
"name": "IPAdapterEncoder",
|
||||
"displayName": "IPAdapterEncoder",
|
||||
"category": "ipadapter/embeds",
|
||||
"description": "Encode reference images into IPAdapter embeddings for reuse."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
52
apps/website/src/data/cloudNodes.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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)
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -3,7 +3,7 @@ export interface Role {
|
||||
title: string
|
||||
department: string
|
||||
location: string
|
||||
applyUrl: string
|
||||
jobUrl: string
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
|
||||
@@ -735,6 +735,142 @@ const translations = {
|
||||
'zh-CN': '免费试用 COMFY CLOUD'
|
||||
},
|
||||
|
||||
'cloudNodes.hero.label': {
|
||||
en: 'CLOUD NODES',
|
||||
'zh-CN': '云端节点目录'
|
||||
},
|
||||
'cloudNodes.hero.heading': {
|
||||
en: 'Run your favorite ComfyUI custom nodes on the cloud',
|
||||
'zh-CN': '在云端运行你喜爱的 ComfyUI 自定义节点'
|
||||
},
|
||||
'cloudNodes.hero.body': {
|
||||
en: 'Spin up workflows with hundreds of community-built nodes — detailers, ControlNet preprocessors, animation tools, and quality-of-life utilities — preinstalled on Comfy Cloud and ready to run on managed GPUs.',
|
||||
'zh-CN':
|
||||
'在 Comfy Cloud 托管 GPU 上即开即用,预装数百个社区节点——细节修复、ControlNet 预处理、动画工具与日常便利组件,应有尽有。'
|
||||
},
|
||||
'cloudNodes.section.heading': {
|
||||
en: 'Find a custom-node pack',
|
||||
'zh-CN': '查找自定义节点包'
|
||||
},
|
||||
'cloudNodes.search.placeholder': {
|
||||
en: 'Search packs or nodes',
|
||||
'zh-CN': '搜索节点包或节点名称'
|
||||
},
|
||||
'cloudNodes.sort.downloads': {
|
||||
en: 'Most installed',
|
||||
'zh-CN': '按安装量'
|
||||
},
|
||||
'cloudNodes.sort.mostNodes': {
|
||||
en: 'Most nodes',
|
||||
'zh-CN': '按节点数量'
|
||||
},
|
||||
'cloudNodes.sort.az': {
|
||||
en: 'A → Z',
|
||||
'zh-CN': '按名称 A → Z'
|
||||
},
|
||||
'cloudNodes.sort.recentlyUpdated': {
|
||||
en: 'Recently updated',
|
||||
'zh-CN': '最近更新'
|
||||
},
|
||||
'cloudNodes.search.label': {
|
||||
en: 'Search custom-node packs',
|
||||
'zh-CN': '搜索自定义节点包'
|
||||
},
|
||||
'cloudNodes.sort.label': {
|
||||
en: 'Sort packs',
|
||||
'zh-CN': '排序节点包'
|
||||
},
|
||||
'cloudNodes.list.ariaLabel': {
|
||||
en: 'Custom-node packs supported on Comfy Cloud',
|
||||
'zh-CN': 'Comfy Cloud 支持的自定义节点包'
|
||||
},
|
||||
'cloudNodes.meta.title': {
|
||||
en: 'Custom-node packs on Comfy Cloud — supported by default',
|
||||
'zh-CN': 'Comfy Cloud 自定义节点包合集——开箱即用'
|
||||
},
|
||||
'cloudNodes.meta.description': {
|
||||
en: 'Browse hundreds of ComfyUI custom-node packs preinstalled on Comfy Cloud. Detailers, ControlNet preprocessors, animation tools, samplers, and more — search by pack or by node name.',
|
||||
'zh-CN':
|
||||
'浏览 Comfy Cloud 预装的数百个 ComfyUI 自定义节点包:细节修复、ControlNet 预处理、动画工具、采样器等——按节点包或节点名搜索。'
|
||||
},
|
||||
'cloudNodes.detail.metaTitle': {
|
||||
en: '{pack} on Comfy Cloud',
|
||||
'zh-CN': '{pack}(Comfy Cloud)'
|
||||
},
|
||||
'cloudNodes.detail.metaDescription': {
|
||||
en: '{pack} is preinstalled on Comfy Cloud — {nodeCount} nodes ready to run on managed GPUs. {description}',
|
||||
'zh-CN':
|
||||
'{pack} 已预装于 Comfy Cloud——{nodeCount} 个节点可在托管 GPU 上即时运行。{description}'
|
||||
},
|
||||
'cloudNodes.empty.heading': {
|
||||
en: 'No matching packs',
|
||||
'zh-CN': '未找到匹配的节点包'
|
||||
},
|
||||
'cloudNodes.empty.body': {
|
||||
en: 'Try a different search term or clear your filters.',
|
||||
'zh-CN': '试试其他关键词,或清空筛选条件。'
|
||||
},
|
||||
'cloudNodes.card.nodeCountOne': {
|
||||
en: '{count} node',
|
||||
'zh-CN': '{count} 个节点'
|
||||
},
|
||||
'cloudNodes.card.nodeCountOther': {
|
||||
en: '{count} nodes',
|
||||
'zh-CN': '{count} 个节点'
|
||||
},
|
||||
'cloudNodes.card.viewRepo': {
|
||||
en: 'View repository',
|
||||
'zh-CN': '查看仓库'
|
||||
},
|
||||
'cloudNodes.card.unavailableDescription': {
|
||||
en: 'Description unavailable.',
|
||||
'zh-CN': '暂无描述信息。'
|
||||
},
|
||||
'cloudNodes.card.nodesHeading': {
|
||||
en: 'Included nodes',
|
||||
'zh-CN': '包含节点'
|
||||
},
|
||||
'cloudNodes.detail.back': {
|
||||
en: 'Back to all packs',
|
||||
'zh-CN': '返回所有节点包'
|
||||
},
|
||||
'cloudNodes.detail.publisher': {
|
||||
en: 'Publisher',
|
||||
'zh-CN': '发布者'
|
||||
},
|
||||
'cloudNodes.detail.downloads': {
|
||||
en: 'Downloads',
|
||||
'zh-CN': '下载量'
|
||||
},
|
||||
'cloudNodes.detail.stars': {
|
||||
en: 'GitHub stars',
|
||||
'zh-CN': 'GitHub 星标'
|
||||
},
|
||||
'cloudNodes.detail.latestVersion': {
|
||||
en: 'Latest version',
|
||||
'zh-CN': '最新版本'
|
||||
},
|
||||
'cloudNodes.detail.license': {
|
||||
en: 'License',
|
||||
'zh-CN': '许可证'
|
||||
},
|
||||
'cloudNodes.detail.lastUpdated': {
|
||||
en: 'Last updated',
|
||||
'zh-CN': '最后更新'
|
||||
},
|
||||
'cloudNodes.detail.deprecated': {
|
||||
en: 'Deprecated',
|
||||
'zh-CN': '已弃用'
|
||||
},
|
||||
'cloudNodes.detail.experimental': {
|
||||
en: 'Experimental',
|
||||
'zh-CN': '实验性'
|
||||
},
|
||||
'cloudNodes.detail.nodesHeading': {
|
||||
en: 'Nodes in this pack',
|
||||
'zh-CN': '此节点包中的节点'
|
||||
},
|
||||
|
||||
// Cloud – ReasonSection
|
||||
'cloud.reason.heading': {
|
||||
en: 'Why\nprofessionals\nchoose ',
|
||||
@@ -1637,6 +1773,7 @@ const translations = {
|
||||
'footer.support': { en: 'Support', 'zh-CN': '支持' },
|
||||
'footer.sales': { en: 'Sales', 'zh-CN': '销售' },
|
||||
'footer.press': { en: 'Press', 'zh-CN': '媒体' },
|
||||
'footer.cloudStatus': { en: 'Cloud Status', 'zh-CN': '云端状态' },
|
||||
'footer.blog': { en: 'Blog', 'zh-CN': '博客' },
|
||||
'footer.location': {
|
||||
en: 'San Francisco, USA',
|
||||
@@ -3570,6 +3707,20 @@ const translations = {
|
||||
'<ol><li><strong>打开模板浏览器</strong> — 点击 ComfyUI 侧栏中的模板图标。</li><li><strong>浏览分类</strong> — 模板按任务分类:图像生成、视频、放大等。</li><li><strong>预览模板</strong> — 将鼠标悬停在模板上查看预览。</li><li><strong>加载并自定义</strong> — 点击加载模板,然后修改参数。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.community-workflows.title': {
|
||||
en: 'Explore and Use a Community Workflow from the Hub',
|
||||
'zh-CN': '探索并使用社区工作流'
|
||||
},
|
||||
'demos.community-workflows.description': {
|
||||
en: 'Discover how to find and get started with popular community workflows for generative AI projects.',
|
||||
'zh-CN': '了解如何查找并使用流行的社区工作流来构建生成式 AI 项目。'
|
||||
},
|
||||
'demos.community-workflows.transcript': {
|
||||
en: '<ol><li><strong>Open the Workflow Hub</strong> — From the ComfyUI sidebar, navigate to the community Workflow Hub to browse curated and trending workflows shared by the community.</li><li><strong>Browse popular workflows</strong> — Explore featured projects sorted by popularity, recency, and category to find one that matches your goal.</li><li><strong>Preview a workflow</strong> — Click a workflow card to see example outputs, required models, and a description of what it produces.</li><li><strong>Open in ComfyUI</strong> — Use the "Get Started" action to load the selected community workflow directly onto your canvas.</li><li><strong>Run and customize</strong> — Queue the workflow to generate your first result, then tweak prompts, models, and parameters to make it your own.</li></ol>',
|
||||
'zh-CN':
|
||||
'<ol><li><strong>打开工作流中心</strong> — 在 ComfyUI 侧栏中,进入社区工作流中心,浏览社区分享的精选和热门工作流。</li><li><strong>浏览热门工作流</strong> — 按热度、时间和分类浏览精选项目,找到符合需求的工作流。</li><li><strong>预览工作流</strong> — 点击工作流卡片,查看示例输出、所需模型和功能描述。</li><li><strong>在 ComfyUI 中打开</strong> — 使用"开始使用"按钮,将选中的社区工作流直接加载到画布。</li><li><strong>运行并自定义</strong> — 排队执行工作流以生成首个结果,然后调整提示词、模型和参数。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.nav.nextDemo': { en: "What's Next", 'zh-CN': '下一个演示' },
|
||||
'demos.nav.viewDemo': { en: 'View Demo', 'zh-CN': '查看演示' },
|
||||
'demos.nav.allDemos': { en: 'All Demos', 'zh-CN': '所有演示' },
|
||||
@@ -3664,6 +3815,60 @@ const translations = {
|
||||
'zh-CN': 'Creative Studios AI 负责人'
|
||||
},
|
||||
|
||||
// Models – UI keys
|
||||
'models.hero.eyebrow': {
|
||||
en: 'AI Model',
|
||||
'zh-CN': 'AI 模型'
|
||||
},
|
||||
'models.hero.primaryCta': {
|
||||
en: 'TRY IN COMFY',
|
||||
'zh-CN': '在 Comfy 中试用'
|
||||
},
|
||||
'models.hero.secondaryCta': {
|
||||
en: 'DOWNLOAD MODEL',
|
||||
'zh-CN': '下载模型'
|
||||
},
|
||||
'models.hero.cloudCta': {
|
||||
en: 'RUN ON CLOUD',
|
||||
'zh-CN': '云端运行'
|
||||
},
|
||||
'models.hero.tutorialCta': {
|
||||
en: 'VIEW TUTORIAL',
|
||||
'zh-CN': '查看教程'
|
||||
},
|
||||
'models.hero.blogLink': {
|
||||
en: 'Read blog post',
|
||||
'zh-CN': '阅读博客文章'
|
||||
},
|
||||
'models.hero.workflowCount': {
|
||||
en: '{count} workflows use this model',
|
||||
'zh-CN': '{count} 个工作流使用此模型'
|
||||
},
|
||||
'models.whatIs.heading': {
|
||||
en: 'What is {name}?',
|
||||
'zh-CN': '什么是 {name}?'
|
||||
},
|
||||
'models.whatIs.tutorialLink': {
|
||||
en: 'Read the full tutorial →',
|
||||
'zh-CN': '阅读完整教程 →'
|
||||
},
|
||||
'models.index.title': {
|
||||
en: 'Supported Models',
|
||||
'zh-CN': '支持的模型'
|
||||
},
|
||||
'models.index.subtitle': {
|
||||
en: "Run the world's leading AI models in ComfyUI",
|
||||
'zh-CN': '在 ComfyUI 中运行世界领先的 AI 模型'
|
||||
},
|
||||
'models.breadcrumb.home': {
|
||||
en: 'Home',
|
||||
'zh-CN': '首页'
|
||||
},
|
||||
'models.breadcrumb.models': {
|
||||
en: 'Supported Models',
|
||||
'zh-CN': '支持的模型'
|
||||
},
|
||||
|
||||
// Payment status pages
|
||||
'payment.success.label': {
|
||||
en: 'PAYMENT',
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../styles/global.css'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import SiteFooter from '../components/common/SiteFooter.vue'
|
||||
import SiteNav from '../components/common/SiteNav.vue'
|
||||
import { escapeJsonLd } from '../utils/escapeJsonLd'
|
||||
import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
|
||||
interface Props {
|
||||
@@ -94,8 +95,11 @@ const websiteJsonLd = {
|
||||
<meta name="twitter:image" content={ogImageURL.href} />
|
||||
|
||||
<!-- Structured Data -->
|
||||
<script is:inline type="application/ld+json" set:html={JSON.stringify(organizationJsonLd)} />
|
||||
<script is:inline type="application/ld+json" set:html={JSON.stringify(websiteJsonLd)} />
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
|
||||
<slot name="head" />
|
||||
|
||||
<slot name="head" />
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
{gtmEnabled && (
|
||||
|
||||
42
apps/website/src/pages/cloud/supported-nodes.astro
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../components/cloud-nodes/HeroSection.vue'
|
||||
import PackGridSection from '../../components/cloud-nodes/PackGridSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../utils/escapeJsonLd'
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL('/cloud/supported-nodes', siteBase).href
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Custom-node packs supported on Comfy Cloud',
|
||||
url: pageUrl,
|
||||
numberOfItems: packs.length,
|
||||
itemListElement: packs.map((pack, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href,
|
||||
name: pack.displayName,
|
||||
image: pack.bannerUrl || pack.iconUrl
|
||||
}))
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('cloudNodes.meta.title', 'en')}
|
||||
description={t('cloudNodes.meta.description', 'en')}
|
||||
>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(itemListJsonLd)}
|
||||
/>
|
||||
<HeroSection client:visible />
|
||||
<PackGridSection packs={packs} client:visible />
|
||||
</BaseLayout>
|
||||
48
apps/website/src/pages/cloud/supported-nodes/AGENTS.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Cloud Nodes Pages
|
||||
|
||||
Build-time catalog of custom-node packs preinstalled on Comfy Cloud. Index at `/cloud/supported-nodes`, per-pack details at `/cloud/supported-nodes/[pack]`, both also under `/zh-CN/`.
|
||||
|
||||
## Sources
|
||||
|
||||
- **Cloud `/api/object_info`** — authoritative list of nodes available on Comfy Cloud (auth: `WEBSITE_CLOUD_API_KEY`)
|
||||
- **ComfyUI Custom Node Registry** ([dashboard](https://registry.comfy.org), API at `https://api.comfy.org/nodes`) — public pack metadata (banner, icon, description, downloads, stars, license, version, repo, publisher)
|
||||
|
||||
The registry is the same one the in-app Manager dialog reads from. For reference and additional reading, see the existing client wrappers in `src/`:
|
||||
|
||||
- [`src/services/comfyRegistryService.ts`](../../../../../../src/services/comfyRegistryService.ts) — typed wrappers around `/nodes`, `/nodes/search`, `/nodes/{id}`, `/nodes/{id}/versions/{version}/comfy-nodes`, etc.
|
||||
- [`src/stores/comfyRegistryStore.ts`](../../../../../../src/stores/comfyRegistryStore.ts) — cached store + `getPacksByIds` batch helper
|
||||
- [`packages/registry-types/src/comfyRegistryTypes.ts`](../../../../../../packages/registry-types/src/comfyRegistryTypes.ts) — generated OpenAPI types
|
||||
- Public docs: <https://docs.comfy.org/registry>
|
||||
|
||||
## Build pipeline
|
||||
|
||||
| File | Role |
|
||||
| ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| [`pages/cloud/supported-nodes.astro`](./supported-nodes.astro) and [`[pack].astro`](./supported-nodes/%5Bpack%5D.astro) | Page shells (and `zh-CN` twins) |
|
||||
| [`utils/cloudNodes.build.ts`](../../../utils/cloudNodes.build.ts) | `loadPacksForBuild()` shared by index + detail routes |
|
||||
| [`utils/cloudNodes.ts`](../../../utils/cloudNodes.ts) | Cloud `object_info` fetcher with retry, sanitization, snapshot fallback |
|
||||
| [`utils/cloudNodes.registry.ts`](../../../utils/cloudNodes.registry.ts) | Registry enrichment (batches of 50, soft-fail) |
|
||||
| [`utils/cloudNodes.ci.ts`](../../../utils/cloudNodes.ci.ts) | GitHub Actions annotations + step summary |
|
||||
| [`utils/escapeJsonLd.ts`](../../../utils/escapeJsonLd.ts) | XSS-safe `<script type="application/ld+json">` serializer |
|
||||
| [`composables/useFilteredPacks.ts`](../../../composables/useFilteredPacks.ts) | Search + sort logic for the index page |
|
||||
| [`composables/useNodesByCategory.ts`](../../../composables/useNodesByCategory.ts) | Group nodes by category, alphabetized |
|
||||
| [`scripts/refresh-cloud-nodes-snapshot.ts`](../../../../scripts/refresh-cloud-nodes-snapshot.ts) | Manual `pnpm cloud-nodes:refresh-snapshot` |
|
||||
| [`packages/object-info-parser`](../../../../../../packages/object-info-parser) | Shared Zod schemas, classifier, `sanitizeUserContent` |
|
||||
|
||||
## Key invariants
|
||||
|
||||
- **Sanitization is mandatory.** The cloud `/api/object_info` endpoint mixes the calling user's uploaded files into combo input lists; `sanitizeUserContent()` strips them before the snapshot is written. Tests in [`packages/object-info-parser/src/__tests__/sanitizeUserContent.test.ts`](../../../../../../packages/object-info-parser/src/__tests__/sanitizeUserContent.test.ts).
|
||||
- **All remote data is Zod-validated.** `cloudNodes.schema.ts` validates the cloud envelope; `@comfyorg/object-info-parser` validates each node def; `cloudNodes.registry.ts` validates the registry response shape with a passthrough Zod schema (defense-in-depth on top of the generated OpenAPI types). The fetcher never trusts a network response.
|
||||
- **JSON-LD must be escaped.** Pack metadata is registry-controlled; route every `<script type="application/ld+json">` payload through `escapeJsonLd()`.
|
||||
- **Index and detail share one fetch.** Both call `loadPacksForBuild()` so the static routes can never diverge from the rendered list.
|
||||
- **No `PUBLIC_` prefix on env vars.** Astro inlines `PUBLIC_*` into the client bundle; build-time secrets never start with that prefix.
|
||||
|
||||
## Why `Pack` is a domain projection, not the raw registry `Node`
|
||||
|
||||
`apps/website/src/data/cloudNodes.ts` defines its own `Pack` and `PackNode` types instead of re-exporting `components['schemas']['Node']` from `@comfyorg/registry-types`. That is intentional:
|
||||
|
||||
- A `Pack` joins **two upstream sources** — the cloud `object_info` shape (which has `python_module`, `category`, `display_name`, etc.) and the registry `Node` shape (which has `banner_url`, `downloads`, `github_stars`, etc.). Neither shape on its own captures both.
|
||||
- A `Pack` is **post-sanitization**. Combo input lists, user-uploaded filenames, and any other build-time-stripped data are guaranteed gone by the time the type appears. The raw `Node` carries fields we never expose.
|
||||
- A `Pack` adds **safe-URL invariants** (banner / icon / repo all flow through `safeExternalUrl()` before becoming part of the type), which the registry's optional `string` fields don't encode.
|
||||
|
||||
The `Pack`/`PackNode` types live next to the snapshot they describe; the validated build-time projections feed Astro's static routes directly. The OpenAPI-generated `Node` type is still consumed inside `cloudNodes.registry.ts` as `RegistryPack` for the upstream fetch.
|
||||
63
apps/website/src/pages/cloud/supported-nodes/[pack].astro
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
|
||||
import type { Pack } from '../../../data/cloudNodes'
|
||||
|
||||
import PackDetail from '../../../components/cloud-nodes/PackDetail.vue'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const packs = await loadPacksForBuild()
|
||||
return packs.map((pack) => ({
|
||||
params: { pack: pack.id },
|
||||
props: { pack }
|
||||
}))
|
||||
}
|
||||
|
||||
const { pack } = Astro.props as { pack: Pack }
|
||||
const fallbackDescription = t('cloudNodes.card.unavailableDescription', 'en')
|
||||
const description = pack.description || fallbackDescription
|
||||
const title = t('cloudNodes.detail.metaTitle', 'en').replace(
|
||||
'{pack}',
|
||||
pack.displayName
|
||||
)
|
||||
const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
|
||||
.replace('{pack}', pack.displayName)
|
||||
.replace('{nodeCount}', String(pack.nodes.length))
|
||||
.replace('{description}', description)
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href
|
||||
|
||||
const softwareJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: pack.displayName,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'ComfyUI custom-node pack',
|
||||
operatingSystem: 'Comfy Cloud (managed)',
|
||||
url: pageUrl,
|
||||
description,
|
||||
image: pack.bannerUrl || pack.iconUrl,
|
||||
softwareVersion: pack.latestVersion,
|
||||
license: pack.license,
|
||||
codeRepository: pack.repoUrl,
|
||||
author: pack.publisher?.name
|
||||
? { '@type': 'Person', name: pack.publisher.name }
|
||||
: undefined,
|
||||
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(softwareJsonLd)}
|
||||
/>
|
||||
<PackDetail pack={pack} />
|
||||
</BaseLayout>
|
||||
@@ -121,6 +121,7 @@ const breadcrumbJsonLd = {
|
||||
<ArcadeEmbed
|
||||
arcadeId={demo.arcadeId}
|
||||
title={title}
|
||||
aspectRatio={demo.aspectRatio}
|
||||
client:load
|
||||
/>
|
||||
|
||||
|
||||
185
apps/website/src/pages/p/supported-models/[slug].astro
Normal file
@@ -0,0 +1,185 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
|
||||
import { models, getModelBySlug } from '../../../config/models'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return models.map((model) => ({
|
||||
params: { slug: model.slug }
|
||||
}))
|
||||
}
|
||||
|
||||
const { slug } = Astro.params
|
||||
const model = getModelBySlug(slug as string)!
|
||||
|
||||
if (model.canonicalSlug) {
|
||||
return Astro.redirect(`/p/supported-models/${model.canonicalSlug}`, 301)
|
||||
}
|
||||
|
||||
const { displayName } = model
|
||||
const canonicalURL = new URL(`/p/supported-models/${model.slug}`, Astro.site)
|
||||
|
||||
const dirDescriptions: Record<string, string> = {
|
||||
diffusion_models: 'a diffusion model that generates images or video from text and image prompts',
|
||||
checkpoints: 'an all-in-one checkpoint model that bundles a diffusion model, text encoder, and VAE',
|
||||
loras: 'a LoRA (Low-Rank Adaptation) that fine-tunes an existing model for a specific style or subject',
|
||||
controlnet: 'a ControlNet that steers image generation using structural guides like depth maps, edges, or poses',
|
||||
clip_vision: 'a CLIP Vision encoder that converts images into embeddings for conditioning or style transfer',
|
||||
vae: 'a VAE (Variational Autoencoder) that encodes and decodes latent representations',
|
||||
text_encoders: 'a text encoder that converts prompts into embeddings used to guide generation',
|
||||
audio_encoders: 'an audio encoder that converts audio into embeddings for audio-conditioned generation',
|
||||
upscale_models: 'an upscale model that increases image resolution while preserving or enhancing detail',
|
||||
latent_upscale_models: 'a latent upscale model that refines latents at higher resolution before decoding',
|
||||
style_models: 'a style model that transfers artistic style onto generated images',
|
||||
model_patches: 'a model patch that modifies or extends the behavior of an existing base model',
|
||||
partner_nodes: 'a cloud API model accessible through ComfyUI partner nodes without local hardware requirements'
|
||||
}
|
||||
|
||||
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
|
||||
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
|
||||
|
||||
const softwareAppJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: displayName,
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
operatingSystem: 'Any',
|
||||
url: canonicalURL.href,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('models.breadcrumb.home'),
|
||||
item: 'https://comfy.org'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('models.breadcrumb.models'),
|
||||
item: 'https://comfy.org/p/supported-models'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: displayName
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `What is ${displayName}?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: whatIsDescription
|
||||
}
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `How do I use ${displayName} in ComfyUI?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: model.docsUrl
|
||||
? `Follow the step-by-step tutorial at ${model.docsUrl}. You can also load any of the ${model.workflowCount} community workflow templates that use ${displayName} directly in ComfyUI.`
|
||||
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`
|
||||
}
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `How many ComfyUI workflows use ${displayName}?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`
|
||||
}
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `Is ${displayName} free to use in ComfyUI?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const pageTitle = `${displayName} in ComfyUI`
|
||||
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${pageTitle} — Comfy`}
|
||||
description={pageDescription}
|
||||
ogImage={model.thumbnailUrl}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(softwareAppJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(faqJsonLd)}
|
||||
/>
|
||||
</Fragment>
|
||||
|
||||
<ModelHeroSection
|
||||
displayName={displayName}
|
||||
huggingFaceUrl={model.huggingFaceUrl}
|
||||
docsUrl={model.docsUrl}
|
||||
blogUrl={model.blogUrl}
|
||||
hubSlug={model.hubSlug}
|
||||
workflowCount={model.workflowCount}
|
||||
directory={model.directory}
|
||||
/>
|
||||
|
||||
<section class="mx-auto max-w-7xl px-6 py-16 lg:px-8">
|
||||
<h2 class="text-2xl font-bold text-primary-comfy-canvas lg:text-3xl">
|
||||
{t('models.whatIs.heading').replace('{name}', displayName)}
|
||||
</h2>
|
||||
<p class="mt-4 max-w-3xl text-base/relaxed text-primary-comfy-canvas/70 lg:text-lg/relaxed">
|
||||
{whatIsDescription}
|
||||
</p>
|
||||
{model.docsUrl && (
|
||||
<a
|
||||
href={model.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-6 inline-block text-primary-comfy-yellow hover:underline"
|
||||
>
|
||||
{t('models.whatIs.tutorialLink')}
|
||||
</a>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{/* TODO: workflow gallery — pulls from hub API */}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{/* TODO: shared HowComfyWorks component */}
|
||||
</section>
|
||||
</BaseLayout>
|
||||
68
apps/website/src/pages/p/supported-models/index.astro
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { models } from '../../../config/models'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
const title = t('models.index.title')
|
||||
const subtitle = t('models.index.subtitle')
|
||||
|
||||
const dirLabel: Record<string, string> = {
|
||||
diffusion_models: 'Diffusion',
|
||||
checkpoints: 'Checkpoint',
|
||||
loras: 'LoRA',
|
||||
controlnet: 'ControlNet',
|
||||
clip_vision: 'CLIP Vision',
|
||||
model_patches: 'Patch',
|
||||
vae: 'VAE',
|
||||
text_encoders: 'Text Encoder',
|
||||
audio_encoders: 'Audio Encoder',
|
||||
latent_upscale_models: 'Latent Upscale',
|
||||
upscale_models: 'Upscale',
|
||||
style_models: 'Style',
|
||||
partner_nodes: 'Partner Node'
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${title} — Comfy`}
|
||||
description={subtitle}
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-24">
|
||||
<header class="mb-12">
|
||||
<p
|
||||
class="mb-2 text-sm font-medium uppercase tracking-widest text-primary-comfy-yellow"
|
||||
>
|
||||
{t('models.hero.eyebrow')}
|
||||
</p>
|
||||
<h1 class="text-4xl font-bold text-primary-comfy-canvas lg:text-6xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p class="mt-4 max-w-2xl text-lg text-primary-comfy-canvas/70">
|
||||
{subtitle}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{models.map((model) => (
|
||||
<li>
|
||||
<a
|
||||
href={`/p/supported-models/${model.slug}`}
|
||||
class="flex h-full flex-col gap-3 rounded-xl border border-white/10 p-5 transition-colors hover:border-white/25 hover:bg-white/5"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="text-base font-semibold leading-tight text-primary-comfy-canvas">
|
||||
{model.displayName}
|
||||
</span>
|
||||
<span class="shrink-0 rounded-full bg-white/10 px-2 py-0.5 text-xs font-medium text-primary-comfy-canvas/70">
|
||||
{dirLabel[model.directory] ?? model.directory}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-auto text-xs text-primary-comfy-canvas/50">
|
||||
{model.workflowCount} workflows
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
42
apps/website/src/pages/zh-CN/cloud/supported-nodes.astro
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../../components/cloud-nodes/HeroSection.vue'
|
||||
import PackGridSection from '../../../components/cloud-nodes/PackGridSection.vue'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL('/zh-CN/cloud/supported-nodes', siteBase).href
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Comfy Cloud 支持的自定义节点包',
|
||||
url: pageUrl,
|
||||
numberOfItems: packs.length,
|
||||
itemListElement: packs.map((pack, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href,
|
||||
name: pack.displayName,
|
||||
image: pack.bannerUrl || pack.iconUrl
|
||||
}))
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('cloudNodes.meta.title', 'zh-CN')}
|
||||
description={t('cloudNodes.meta.description', 'zh-CN')}
|
||||
>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(itemListJsonLd)}
|
||||
/>
|
||||
<HeroSection locale="zh-CN" client:visible />
|
||||
<PackGridSection locale="zh-CN" packs={packs} client:visible />
|
||||
</BaseLayout>
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
|
||||
import type { Pack } from '../../../../data/cloudNodes'
|
||||
|
||||
import PackDetail from '../../../../components/cloud-nodes/PackDetail.vue'
|
||||
import BaseLayout from '../../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const packs = await loadPacksForBuild()
|
||||
return packs.map((pack) => ({
|
||||
params: { pack: pack.id },
|
||||
props: { pack }
|
||||
}))
|
||||
}
|
||||
|
||||
const { pack } = Astro.props as { pack: Pack }
|
||||
const fallbackDescription = t('cloudNodes.card.unavailableDescription', 'zh-CN')
|
||||
const description = pack.description || fallbackDescription
|
||||
const title = t('cloudNodes.detail.metaTitle', 'zh-CN').replace(
|
||||
'{pack}',
|
||||
pack.displayName
|
||||
)
|
||||
const metaDescription = t('cloudNodes.detail.metaDescription', 'zh-CN')
|
||||
.replace('{pack}', pack.displayName)
|
||||
.replace('{nodeCount}', String(pack.nodes.length))
|
||||
.replace('{description}', description)
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href
|
||||
|
||||
const softwareJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: pack.displayName,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'ComfyUI custom-node pack',
|
||||
operatingSystem: 'Comfy Cloud (managed)',
|
||||
url: pageUrl,
|
||||
description,
|
||||
image: pack.bannerUrl || pack.iconUrl,
|
||||
softwareVersion: pack.latestVersion,
|
||||
license: pack.license,
|
||||
codeRepository: pack.repoUrl,
|
||||
author: pack.publisher?.name
|
||||
? { '@type': 'Person', name: pack.publisher.name }
|
||||
: undefined,
|
||||
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(softwareJsonLd)}
|
||||
/>
|
||||
<PackDetail pack={pack} locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
@@ -122,6 +122,7 @@ const breadcrumbJsonLd = {
|
||||
<ArcadeEmbed
|
||||
arcadeId={demo.arcadeId}
|
||||
title={title}
|
||||
aspectRatio={demo.aspectRatio}
|
||||
locale="zh-CN"
|
||||
client:load
|
||||
/>
|
||||
|
||||
@@ -101,13 +101,13 @@
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee-reverse {
|
||||
0% {
|
||||
transform: translateX(-50%);
|
||||
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
@@ -115,11 +115,15 @@
|
||||
}
|
||||
|
||||
@utility animate-marquee {
|
||||
animation: marquee 30s linear infinite;
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: marquee 30s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@utility animate-marquee-reverse {
|
||||
animation: marquee-reverse 30s linear infinite;
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: marquee-reverse 30s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ripple-effect {
|
||||
|
||||
@@ -22,7 +22,7 @@ function baseSnapshot(): RolesSnapshot {
|
||||
title: 'Design Engineer',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
|
||||
jobUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -50,7 +50,7 @@ function freshOutcome(droppedCount = 0): FetchOutcome {
|
||||
title: 'Design Engineer',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
|
||||
jobUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ function makeSnapshot(roleCount = 2): RolesSnapshot {
|
||||
title: `Snapshot Role ${i}`,
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
applyUrl: `https://jobs.ashbyhq.com/comfy-org/snapshot-${i}`
|
||||
jobUrl: `https://jobs.ashbyhq.com/comfy-org/snapshot-${i}`
|
||||
}))
|
||||
return {
|
||||
fetchedAt: '2026-04-01T00:00:00.000Z',
|
||||
@@ -85,26 +85,7 @@ describe('fetchRolesForBuild', () => {
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.droppedCount).toBe(0)
|
||||
expect(outcome.snapshot.departments).toHaveLength(1)
|
||||
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toMatch(
|
||||
/design-engineer\/apply$/
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to jobUrl when applyUrl is missing and keeps the role', async () => {
|
||||
const job = validJob()
|
||||
delete (job as Record<string, unknown>).applyUrl
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({ apiVersion: '1', jobs: [job] })
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toBe(
|
||||
expect(outcome.snapshot.departments[0]!.roles[0]!.jobUrl).toBe(
|
||||
'https://jobs.ashbyhq.com/comfy-org/design-engineer'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -243,13 +243,13 @@ function groupByDepartment(jobs: readonly AshbyJobPosting[]): Department[] {
|
||||
}
|
||||
|
||||
function toDomainRole(job: AshbyJobPosting, department: string): Role {
|
||||
const applyUrl = job.applyUrl ?? job.jobUrl
|
||||
const jobUrl = job.jobUrl
|
||||
return {
|
||||
id: createHash('sha1').update(applyUrl).digest('hex').slice(0, 16),
|
||||
id: createHash('sha1').update(jobUrl).digest('hex').slice(0, 16),
|
||||
title: job.title,
|
||||
department: capitalize(department),
|
||||
location: (job.location ?? '').trim() || DEFAULT_LOCATION,
|
||||
applyUrl
|
||||
jobUrl
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
128
apps/website/src/utils/cloudNodes.build.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { FetchOutcome } from './cloudNodes'
|
||||
import type { NodesSnapshot } from '../data/cloudNodes'
|
||||
|
||||
const fetchCloudNodesMock = vi.hoisted(() =>
|
||||
vi.fn<() => Promise<FetchOutcome>>()
|
||||
)
|
||||
const reportCloudNodesOutcomeMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./cloudNodes', () => ({
|
||||
fetchCloudNodesForBuild: fetchCloudNodesMock
|
||||
}))
|
||||
|
||||
vi.mock('./cloudNodes.ci', () => ({
|
||||
reportCloudNodesOutcome: reportCloudNodesOutcomeMock
|
||||
}))
|
||||
|
||||
import { loadPacksForBuild } from './cloudNodes.build'
|
||||
|
||||
const SNAPSHOT: NodesSnapshot = {
|
||||
fetchedAt: '2026-04-01T00:00:00.000Z',
|
||||
packs: [
|
||||
{
|
||||
id: 'snapshot-pack',
|
||||
displayName: 'Snapshot Pack',
|
||||
nodes: [
|
||||
{ name: 'SnapshotNode', displayName: 'Snapshot Node', category: 'x' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('loadPacksForBuild', () => {
|
||||
const savedVercelEnv = process.env.VERCEL_ENV
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCloudNodesMock.mockReset()
|
||||
reportCloudNodesOutcomeMock.mockReset()
|
||||
delete process.env.VERCEL_ENV
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (savedVercelEnv === undefined) {
|
||||
delete process.env.VERCEL_ENV
|
||||
return
|
||||
}
|
||||
process.env.VERCEL_ENV = savedVercelEnv
|
||||
})
|
||||
|
||||
it('returns packs when fetch is fresh', async () => {
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'fresh',
|
||||
snapshot: SNAPSHOT,
|
||||
droppedCount: 0,
|
||||
droppedNodes: []
|
||||
})
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
expect(packs).toBe(SNAPSHOT.packs)
|
||||
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns snapshot packs when outcome is stale outside production', async () => {
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'missing WEBSITE_CLOUD_API_KEY'
|
||||
})
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
expect(packs).toBe(SNAPSHOT.packs)
|
||||
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns snapshot packs when outcome is stale on Vercel preview', async () => {
|
||||
process.env.VERCEL_ENV = 'preview'
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'HTTP 503'
|
||||
})
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
expect(packs).toBe(SNAPSHOT.packs)
|
||||
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws when outcome is stale on Vercel production', async () => {
|
||||
process.env.VERCEL_ENV = 'production'
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'missing WEBSITE_CLOUD_API_KEY'
|
||||
})
|
||||
|
||||
await expect(loadPacksForBuild()).rejects.toThrow(
|
||||
/stale data in a production build/
|
||||
)
|
||||
await expect(loadPacksForBuild()).rejects.toThrow(
|
||||
/missing WEBSITE_CLOUD_API_KEY/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when outcome is failed regardless of environment', async () => {
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'failed',
|
||||
reason: 'network error: ECONNREFUSED'
|
||||
})
|
||||
|
||||
await expect(loadPacksForBuild()).rejects.toThrow(
|
||||
/Cloud nodes fetch failed and no snapshot is available/
|
||||
)
|
||||
await expect(loadPacksForBuild()).rejects.toThrow(/ECONNREFUSED/)
|
||||
})
|
||||
|
||||
it('still reports outcome before throwing on stale-in-production', async () => {
|
||||
process.env.VERCEL_ENV = 'production'
|
||||
fetchCloudNodesMock.mockResolvedValue({
|
||||
status: 'stale',
|
||||
snapshot: SNAPSHOT,
|
||||
reason: 'HTTP 503'
|
||||
})
|
||||
|
||||
await expect(loadPacksForBuild()).rejects.toThrow()
|
||||
expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
45
apps/website/src/utils/cloudNodes.build.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Pack } from '../data/cloudNodes'
|
||||
|
||||
import { fetchCloudNodesForBuild } from './cloudNodes'
|
||||
import { reportCloudNodesOutcome } from './cloudNodes.ci'
|
||||
|
||||
const REFRESH_HINT =
|
||||
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot, ' +
|
||||
'or re-run the `Release: Website` workflow with a valid WEBSITE_CLOUD_API_KEY.'
|
||||
|
||||
function isProductionBuild(): boolean {
|
||||
return process.env.VERCEL_ENV === 'production'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the list of packs to render at build time.
|
||||
*
|
||||
* Used by both the index page and the per-pack detail pages so that the
|
||||
* static index and the static detail routes are always derived from the
|
||||
* same source. `fetchCloudNodesForBuild` is memoized on a module-level
|
||||
* `inflight` promise, so repeated calls in the same build process share a
|
||||
* single network round-trip and the same outcome.
|
||||
*
|
||||
* Production builds (VERCEL_ENV=production) fail hard on a stale outcome
|
||||
* to prevent silently shipping out-of-date snapshot data. Preview and
|
||||
* local builds continue to use the committed snapshot.
|
||||
*/
|
||||
export async function loadPacksForBuild(): Promise<Pack[]> {
|
||||
const outcome = await fetchCloudNodesForBuild()
|
||||
reportCloudNodesOutcome(outcome)
|
||||
|
||||
if (outcome.status === 'failed') {
|
||||
throw new Error(
|
||||
`Cloud nodes fetch failed and no snapshot is available. Reason: ${outcome.reason}. ${REFRESH_HINT}`
|
||||
)
|
||||
}
|
||||
|
||||
if (outcome.status === 'stale' && isProductionBuild()) {
|
||||
throw new Error(
|
||||
`Cloud nodes fetch returned stale data in a production build (VERCEL_ENV=production). ` +
|
||||
`Reason: ${outcome.reason}. ${REFRESH_HINT}`
|
||||
)
|
||||
}
|
||||
|
||||
return outcome.snapshot.packs
|
||||
}
|
||||
121
apps/website/src/utils/cloudNodes.ci.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { FetchOutcome } from './cloudNodes'
|
||||
import type { NodesSnapshot } from '../data/cloudNodes'
|
||||
|
||||
import {
|
||||
reportCloudNodesOutcome,
|
||||
resetCloudNodesReporterForTests
|
||||
} from './cloudNodes.ci'
|
||||
|
||||
function baseSnapshot(): NodesSnapshot {
|
||||
return {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
packs: [
|
||||
{
|
||||
id: 'pack-id',
|
||||
displayName: 'Pack',
|
||||
nodes: [
|
||||
{
|
||||
name: 'NodeClass',
|
||||
displayName: 'Node Class',
|
||||
category: 'misc'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function freshOutcome(droppedCount = 0): FetchOutcome {
|
||||
return {
|
||||
status: 'fresh',
|
||||
droppedCount,
|
||||
droppedNodes:
|
||||
droppedCount === 0
|
||||
? []
|
||||
: [{ name: 'BadNode', reason: 'invalid display_name' }],
|
||||
snapshot: baseSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
describe('reportCloudNodesOutcome', () => {
|
||||
let writeSpy: ReturnType<typeof vi.spyOn>
|
||||
let summaryDir: string
|
||||
let summaryPath: string
|
||||
const originalSummary = process.env.GITHUB_STEP_SUMMARY
|
||||
|
||||
beforeEach(() => {
|
||||
resetCloudNodesReporterForTests()
|
||||
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
summaryDir = mkdtempSync(join(tmpdir(), 'cloud-nodes-summary-'))
|
||||
summaryPath = join(summaryDir, 'summary.md')
|
||||
writeFileSync(summaryPath, '')
|
||||
process.env.GITHUB_STEP_SUMMARY = summaryPath
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
writeSpy.mockRestore()
|
||||
rmSync(summaryDir, { recursive: true, force: true })
|
||||
if (originalSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY
|
||||
else process.env.GITHUB_STEP_SUMMARY = originalSummary
|
||||
})
|
||||
|
||||
it('emits nothing on a clean fresh outcome', () => {
|
||||
reportCloudNodesOutcome(freshOutcome(0))
|
||||
expect(writeSpy).not.toHaveBeenCalled()
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Fresh')
|
||||
})
|
||||
|
||||
it('emits exactly one set of annotations across repeated calls', () => {
|
||||
reportCloudNodesOutcome(freshOutcome(1))
|
||||
reportCloudNodesOutcome(freshOutcome(1))
|
||||
expect(writeSpy).toHaveBeenCalledTimes(1)
|
||||
const annotation = writeSpy.mock.calls[0]?.[0] as string
|
||||
expect(annotation).toContain(
|
||||
'::warning title=Cloud nodes: dropped 1 invalid'
|
||||
)
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Dropped')
|
||||
})
|
||||
|
||||
it('emits ::error for auth failures in a stale outcome', () => {
|
||||
reportCloudNodesOutcome({
|
||||
status: 'stale',
|
||||
reason: 'HTTP 401 Unauthorized',
|
||||
snapshot: baseSnapshot()
|
||||
})
|
||||
const annotation = writeSpy.mock.calls[0]?.[0] as string
|
||||
expect(annotation).toContain(
|
||||
'::error title=Cloud nodes authentication failed'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits ::warning for missing-env stale outcomes', () => {
|
||||
reportCloudNodesOutcome({
|
||||
status: 'stale',
|
||||
reason: 'missing WEBSITE_CLOUD_API_KEY',
|
||||
snapshot: baseSnapshot()
|
||||
})
|
||||
const annotation = writeSpy.mock.calls[0]?.[0] as string
|
||||
expect(annotation).toContain('::warning title=Cloud nodes integration')
|
||||
})
|
||||
|
||||
it('emits ::error for a failed outcome and writes no fresh-only sections', () => {
|
||||
reportCloudNodesOutcome({
|
||||
status: 'failed',
|
||||
reason: 'HTTP 500 Server Error'
|
||||
})
|
||||
const annotation = writeSpy.mock.calls[0]?.[0] as string
|
||||
expect(annotation).toContain('::error title=Cloud nodes fetch failed')
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
|
||||
})
|
||||
|
||||
it('does not throw when GITHUB_STEP_SUMMARY is not set', () => {
|
||||
delete process.env.GITHUB_STEP_SUMMARY
|
||||
expect(() => reportCloudNodesOutcome(freshOutcome(0))).not.toThrow()
|
||||
})
|
||||
})
|
||||
115
apps/website/src/utils/cloudNodes.ci.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
import type { FetchOutcome } from './cloudNodes'
|
||||
|
||||
let hasReported = false
|
||||
|
||||
export function resetCloudNodesReporterForTests(): void {
|
||||
hasReported = false
|
||||
}
|
||||
|
||||
export function reportCloudNodesOutcome(outcome: FetchOutcome): void {
|
||||
if (hasReported) return
|
||||
hasReported = true
|
||||
|
||||
const lines = buildAnnotations(outcome)
|
||||
for (const line of lines) {
|
||||
process.stdout.write(`${line}\n`)
|
||||
}
|
||||
|
||||
const summaryPath = process.env.GITHUB_STEP_SUMMARY
|
||||
if (summaryPath) {
|
||||
try {
|
||||
appendFileSync(summaryPath, buildStepSummary(outcome))
|
||||
} catch {
|
||||
// Writing the summary is best-effort; do not fail the build if the
|
||||
// runner's summary file is unavailable (e.g. local dev).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildAnnotations(outcome: FetchOutcome): string[] {
|
||||
if (outcome.status === 'fresh') {
|
||||
if (outcome.droppedCount === 0) return []
|
||||
const nodeCount = outcome.droppedCount === 1 ? 'node' : 'nodes'
|
||||
const drops = outcome.droppedNodes
|
||||
.map((d) => {
|
||||
const name = escapeAnnotation(d.name ? `"${d.name}"` : '(unnamed)')
|
||||
const reason = escapeAnnotation(d.reason)
|
||||
return ` - ${name}: ${reason}`
|
||||
})
|
||||
.join('%0A')
|
||||
return [
|
||||
`::warning title=Cloud nodes: dropped ${outcome.droppedCount} invalid ${nodeCount}::Dropped nodes:%0A${drops}%0A%0AAction items:%0A 1. Verify node definitions returned by cloud /api/object_info.%0A 2. If a valid node shape changed, update @comfyorg/object-info-parser/src/schemas/nodeDefSchema.ts and add tests.%0A 3. Dropped nodes are not shown on /cloud/supported-nodes until fixed.`
|
||||
]
|
||||
}
|
||||
|
||||
if (outcome.status === 'stale') {
|
||||
return [staleAnnotation(outcome.reason)]
|
||||
}
|
||||
|
||||
return [
|
||||
`::error title=Cloud nodes fetch failed and no snapshot is available::Cannot build cloud nodes page without data.%0A%0AReason: ${escapeAnnotation(outcome.reason)}%0A%0AAction items:%0A 1. Run \`pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot\` locally with a valid WEBSITE_CLOUD_API_KEY.%0A 2. Commit apps/website/src/data/cloud-nodes.snapshot.json.%0A 3. Push and re-run CI.`
|
||||
]
|
||||
}
|
||||
|
||||
function staleAnnotation(reason: string): string {
|
||||
const escaped = escapeAnnotation(reason)
|
||||
if (reason.startsWith('missing ')) {
|
||||
return `::warning title=Cloud nodes integration::${escaped}. Falling back to committed snapshot.%0A%0AAction items:%0A 1. If you're a contributor without key access, this is expected. The snapshot will be used.%0A 2. If this is CI, check that the \`WEBSITE_CLOUD_API_KEY\` secret exists in the repo and is referenced in .github/workflows/ci-website-build.yaml.`
|
||||
}
|
||||
if (reason.startsWith('HTTP 401') || reason.startsWith('HTTP 403')) {
|
||||
return `::error title=Cloud nodes authentication failed::${escaped}. The WEBSITE_CLOUD_API_KEY is missing, invalid, or revoked. Build continues with the last-known-good snapshot.%0A%0AAction items:%0A 1. Verify the Cloud API key is active and scoped for /api/object_info.%0A 2. Update the \`WEBSITE_CLOUD_API_KEY\` secret in GitHub Actions and Vercel.%0A 3. Re-run this workflow.`
|
||||
}
|
||||
if (reason.startsWith('envelope')) {
|
||||
return `::error title=Cloud nodes schema mismatch::${escaped}. The Cloud API contract likely changed. Build continues with the snapshot, but future updates will fail until schema is fixed.%0A%0AAction items:%0A 1. Check cloud/services/ingest/openapi.yaml for /api/object_info changes.%0A 2. Update apps/website/src/utils/cloudNodes.schema.ts and @comfyorg/object-info-parser schema as needed.`
|
||||
}
|
||||
return `::warning title=Cloud nodes API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check cloud service health.%0A 2. Re-run this workflow once cloud.comfy.org is healthy.`
|
||||
}
|
||||
|
||||
function escapeAnnotation(value: string): string {
|
||||
return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
|
||||
}
|
||||
|
||||
function buildStepSummary(outcome: FetchOutcome): string {
|
||||
const header = '## ☁️ Cloud nodes\n'
|
||||
const rows: Array<[string, string]> = []
|
||||
|
||||
if (outcome.status === 'fresh') {
|
||||
rows.push(['Status', '✅ Fresh (fetched from Cloud API)'])
|
||||
rows.push(['Packs', String(outcome.snapshot.packs.length)])
|
||||
rows.push([
|
||||
'Nodes',
|
||||
String(outcome.snapshot.packs.reduce((n, p) => n + p.nodes.length, 0))
|
||||
])
|
||||
rows.push(['Dropped', String(outcome.droppedCount)])
|
||||
} else if (outcome.status === 'stale') {
|
||||
rows.push(['Status', '⚠️ Stale (using snapshot — Cloud fetch failed)'])
|
||||
rows.push(['Packs', String(outcome.snapshot.packs.length)])
|
||||
rows.push([
|
||||
'Nodes',
|
||||
String(outcome.snapshot.packs.reduce((n, p) => n + p.nodes.length, 0))
|
||||
])
|
||||
rows.push(['Reason', outcome.reason])
|
||||
rows.push(['Snapshot age', describeSnapshotAge(outcome.snapshot.fetchedAt)])
|
||||
} else {
|
||||
rows.push(['Status', '❌ Failed (no snapshot available)'])
|
||||
rows.push(['Reason', outcome.reason])
|
||||
}
|
||||
|
||||
const table =
|
||||
'| | |\n|---|---|\n' +
|
||||
rows.map(([k, v]) => `| **${k}** | ${v} |`).join('\n') +
|
||||
'\n'
|
||||
|
||||
return `${header}${table}\n`
|
||||
}
|
||||
|
||||
function describeSnapshotAge(fetchedAt: string): string {
|
||||
const fetched = new Date(fetchedAt).getTime()
|
||||
if (Number.isNaN(fetched)) return 'unknown'
|
||||
const days = Math.floor((Date.now() - fetched) / 86_400_000)
|
||||
if (days <= 0) return 'today'
|
||||
if (days === 1) return '1 day'
|
||||
return `${days} days`
|
||||
}
|
||||
77
apps/website/src/utils/cloudNodes.registry.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_REGISTRY_BASE_URL,
|
||||
fetchRegistryPacks
|
||||
} from './cloudNodes.registry'
|
||||
|
||||
function jsonResponse(
|
||||
body: unknown,
|
||||
init: Partial<ResponseInit> = {}
|
||||
): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...init
|
||||
})
|
||||
}
|
||||
|
||||
describe('fetchRegistryPacks', () => {
|
||||
it('requests node ids in batches of 50', async () => {
|
||||
const ids = Array.from({ length: 120 }, (_, i) => `pack-${i}`)
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
const batchIds = url.searchParams.getAll('node_id')
|
||||
return jsonResponse({
|
||||
nodes: batchIds.map((id) => ({ id, name: id })),
|
||||
total: batchIds.length,
|
||||
page: 1,
|
||||
limit: 50
|
||||
})
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacks(ids, {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3)
|
||||
expect(result.size).toBe(120)
|
||||
const firstCallUrl = new URL(String(fetchImpl.mock.calls[0]?.[0]))
|
||||
expect(firstCallUrl.origin).toBe(DEFAULT_REGISTRY_BASE_URL)
|
||||
expect(firstCallUrl.pathname).toBe('/nodes')
|
||||
expect(firstCallUrl.searchParams.getAll('node_id')).toHaveLength(50)
|
||||
})
|
||||
|
||||
it('retries a failed batch once and then succeeds', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>()
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 503 }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
nodes: [{ id: 'pack-1', name: 'Pack One' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 50
|
||||
})
|
||||
)
|
||||
|
||||
const result = await fetchRegistryPacks(['pack-1'], {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||
expect(result.get('pack-1')?.name).toBe('Pack One')
|
||||
})
|
||||
|
||||
it('returns an empty map when all batches fail (soft failure)', async () => {
|
||||
const ids = ['pack-a', 'pack-b']
|
||||
const fetchImpl = vi.fn(async () => new Response('{}', { status: 500 }))
|
||||
|
||||
const result = await fetchRegistryPacks(ids, {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
})
|
||||
197
apps/website/src/utils/cloudNodes.registry.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { components } from '@comfyorg/registry-types'
|
||||
|
||||
export const DEFAULT_REGISTRY_BASE_URL = 'https://api.comfy.org'
|
||||
const DEFAULT_TIMEOUT_MS = 5_000
|
||||
const BATCH_SIZE = 50
|
||||
|
||||
export type RegistryPack = components['schemas']['Node']
|
||||
|
||||
const RegistryPackSchema = z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
banner_url: z.string().optional(),
|
||||
repository: z.string().optional(),
|
||||
license: z.string().optional(),
|
||||
downloads: z.number().optional(),
|
||||
github_stars: z.number().optional(),
|
||||
created_at: z.string().optional(),
|
||||
supported_os: z.array(z.string()).optional(),
|
||||
supported_accelerators: z.array(z.string()).optional(),
|
||||
publisher: z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
latest_version: z
|
||||
.object({
|
||||
version: z.string().optional(),
|
||||
createdAt: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const RegistryListResponseSchema = z
|
||||
.object({
|
||||
nodes: z.array(RegistryPackSchema)
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
interface FetchRegistryOptions {
|
||||
baseUrl?: string
|
||||
timeoutMs?: number
|
||||
fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
export async function fetchRegistryPacks(
|
||||
packIds: readonly string[],
|
||||
options: FetchRegistryOptions = {}
|
||||
): Promise<Map<string, RegistryPack | null>> {
|
||||
const uniquePackIds = [...new Set(packIds.filter((id) => id.length > 0))]
|
||||
if (uniquePackIds.length === 0) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_BASE_URL
|
||||
const timeoutMs = clampTimeoutMs(options.timeoutMs)
|
||||
const fetchImpl = options.fetchImpl ?? fetch
|
||||
|
||||
const batches = chunk(uniquePackIds, BATCH_SIZE)
|
||||
const resolved = new Map<string, RegistryPack | null>()
|
||||
let successCount = 0
|
||||
let failureCount = 0
|
||||
|
||||
for (const batch of batches) {
|
||||
const nodes = await fetchBatchWithRetry(
|
||||
fetchImpl,
|
||||
baseUrl,
|
||||
batch,
|
||||
timeoutMs
|
||||
)
|
||||
if (!nodes) {
|
||||
failureCount += 1
|
||||
for (const packId of batch) {
|
||||
resolved.set(packId, null)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
successCount += 1
|
||||
const nodesById = new Map(
|
||||
nodes
|
||||
.map((node) => [node.id, node] as const)
|
||||
.filter(([id]) => typeof id === 'string' && id.length > 0)
|
||||
)
|
||||
|
||||
for (const packId of batch) {
|
||||
resolved.set(packId, nodesById.get(packId) ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
if (failureCount > 0) {
|
||||
console.warn(
|
||||
`[cloud-nodes] registry enrichment: ${successCount}/${batches.length} batches succeeded, ${failureCount} failed`
|
||||
)
|
||||
}
|
||||
|
||||
if (successCount === 0) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
async function fetchBatchWithRetry(
|
||||
fetchImpl: typeof fetch,
|
||||
baseUrl: string,
|
||||
packIds: readonly string[],
|
||||
timeoutMs: number
|
||||
): Promise<RegistryPack[] | null> {
|
||||
const firstAttempt = await fetchBatch(fetchImpl, baseUrl, packIds, timeoutMs)
|
||||
if (firstAttempt.kind === 'ok') {
|
||||
return firstAttempt.nodes
|
||||
}
|
||||
if (!firstAttempt.retryable) {
|
||||
return null
|
||||
}
|
||||
|
||||
const secondAttempt = await fetchBatch(fetchImpl, baseUrl, packIds, timeoutMs)
|
||||
if (secondAttempt.kind === 'ok') {
|
||||
return secondAttempt.nodes
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type BatchResponse =
|
||||
| { kind: 'ok'; nodes: RegistryPack[] }
|
||||
| { kind: 'err'; retryable: boolean }
|
||||
|
||||
async function fetchBatch(
|
||||
fetchImpl: typeof fetch,
|
||||
baseUrl: string,
|
||||
packIds: readonly string[],
|
||||
timeoutMs: number
|
||||
): Promise<BatchResponse> {
|
||||
const params = new URLSearchParams()
|
||||
for (const packId of packIds) {
|
||||
params.append('node_id', packId)
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
const res = await fetchImpl(`${baseUrl}/nodes?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
kind: 'err',
|
||||
retryable: res.status === 429 || (res.status >= 500 && res.status < 600)
|
||||
}
|
||||
}
|
||||
|
||||
const rawBody: unknown = await res.json()
|
||||
const parsed = RegistryListResponseSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return { kind: 'err', retryable: false }
|
||||
}
|
||||
return { kind: 'ok', nodes: parsed.data.nodes as RegistryPack[] }
|
||||
} catch {
|
||||
return { kind: 'err', retryable: true }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function chunk<T>(values: readonly T[], size: number): T[][] {
|
||||
const chunks: T[][] = []
|
||||
for (let i = 0; i < values.length; i += size) {
|
||||
chunks.push(values.slice(i, i + size))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
function clampTimeoutMs(candidate: number | undefined): number {
|
||||
if (
|
||||
typeof candidate !== 'number' ||
|
||||
!Number.isFinite(candidate) ||
|
||||
candidate <= 0
|
||||
) {
|
||||
return DEFAULT_TIMEOUT_MS
|
||||
}
|
||||
return Math.floor(candidate)
|
||||
}
|
||||
3
apps/website/src/utils/cloudNodes.schema.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const CloudNodesEnvelopeSchema = z.record(z.unknown())
|
||||
309
apps/website/src/utils/cloudNodes.test.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodesSnapshot } from '../data/cloudNodes'
|
||||
import type * as ObjectInfoParser from '@comfyorg/object-info-parser'
|
||||
|
||||
const fetchRegistryPacksMock = vi.hoisted(() => vi.fn(async () => new Map()))
|
||||
const sanitizeCallSpy = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./cloudNodes.registry', () => ({
|
||||
DEFAULT_REGISTRY_BASE_URL: 'https://api.comfy.org',
|
||||
fetchRegistryPacks: fetchRegistryPacksMock
|
||||
}))
|
||||
|
||||
vi.mock('@comfyorg/object-info-parser', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as typeof ObjectInfoParser
|
||||
return {
|
||||
...actual,
|
||||
sanitizeUserContent: (
|
||||
defs: Parameters<typeof actual.sanitizeUserContent>[0]
|
||||
) => {
|
||||
sanitizeCallSpy(defs)
|
||||
return actual.sanitizeUserContent(defs)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
fetchCloudNodesForBuild,
|
||||
resetCloudNodesFetcherForTests
|
||||
} from './cloudNodes'
|
||||
|
||||
const BASE_URL = 'https://cloud.test'
|
||||
const KEY = 'cloud-secret'
|
||||
|
||||
function validNode(
|
||||
overrides: Partial<Record<string, unknown>> = {}
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
name: 'ImpactNode',
|
||||
display_name: 'Impact Node',
|
||||
description: 'Node description',
|
||||
category: 'impact/testing',
|
||||
output_node: false,
|
||||
python_module: 'custom_nodes.comfyui-impact-pack.nodes',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...init
|
||||
})
|
||||
}
|
||||
|
||||
function makeSnapshot(packCount = 1): NodesSnapshot {
|
||||
const packs = Array.from({ length: packCount }, (_, i) => ({
|
||||
id: `snapshot-pack-${i}`,
|
||||
displayName: `Snapshot Pack ${i}`,
|
||||
nodes: [
|
||||
{
|
||||
name: `SnapshotNode${i}`,
|
||||
displayName: `Snapshot Node ${i}`,
|
||||
category: 'snapshot'
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
return {
|
||||
fetchedAt: '2026-04-01T00:00:00.000Z',
|
||||
packs
|
||||
}
|
||||
}
|
||||
|
||||
function withSnapshotDir(snapshot: NodesSnapshot | null): URL {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cloud-nodes-test-'))
|
||||
const file = join(dir, 'cloud-nodes.snapshot.json')
|
||||
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
|
||||
return pathToFileURL(file)
|
||||
}
|
||||
|
||||
describe('fetchCloudNodesForBuild', () => {
|
||||
const savedCloudApiKey = process.env.WEBSITE_CLOUD_API_KEY
|
||||
|
||||
beforeEach(() => {
|
||||
resetCloudNodesFetcherForTests()
|
||||
fetchRegistryPacksMock.mockReset()
|
||||
fetchRegistryPacksMock.mockResolvedValue(new Map())
|
||||
sanitizeCallSpy.mockReset()
|
||||
delete process.env.WEBSITE_CLOUD_API_KEY
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey
|
||||
})
|
||||
|
||||
it('returns fresh when API succeeds', async () => {
|
||||
fetchRegistryPacksMock.mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'comfyui-impact-pack',
|
||||
{
|
||||
id: 'comfyui-impact-pack',
|
||||
name: 'ComfyUI Impact Pack',
|
||||
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
|
||||
}
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.droppedCount).toBe(0)
|
||||
expect(outcome.snapshot.packs).toHaveLength(1)
|
||||
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
|
||||
'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
|
||||
)
|
||||
})
|
||||
|
||||
it('drops invalid nodes individually and keeps valid nodes', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({
|
||||
ValidNode: validNode({ name: 'ValidNode' }),
|
||||
BrokenNode: {
|
||||
name: 'BrokenNode',
|
||||
python_module: 'custom_nodes.some-pack'
|
||||
}
|
||||
})
|
||||
)
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.droppedCount).toBe(1)
|
||||
expect(outcome.droppedNodes[0]?.name).toBe('BrokenNode')
|
||||
expect(outcome.snapshot.packs[0]?.nodes).toHaveLength(1)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('applies sanitizer before grouping', async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({
|
||||
LoadImage: validNode({
|
||||
name: 'LoadImage',
|
||||
python_module: 'nodes',
|
||||
input: {
|
||||
required: {
|
||||
image: [['private.png', 'public.webp'], {}]
|
||||
}
|
||||
}
|
||||
}),
|
||||
ImpactNode: validNode({
|
||||
input: {
|
||||
required: {
|
||||
choice: [['safe', 'movie.mov'], {}]
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(sanitizeCallSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns stale with missing env when snapshot is present', async () => {
|
||||
const snapshot = makeSnapshot()
|
||||
const snapshotUrl = withSnapshotDir(snapshot)
|
||||
const fetchImpl = vi.fn()
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^missing /)
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns failed when env and snapshot are missing', async () => {
|
||||
const snapshotUrl = withSnapshotDir(null)
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
snapshotUrl,
|
||||
fetchImpl: vi.fn() as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('failed')
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('does not retry on HTTP 401', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({}, { status: 401 }))
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^HTTP 401/)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('retries 5xx then falls back to snapshot', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({}, { status: 503 }))
|
||||
const sleep = vi.fn(async () => undefined)
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
retryDelaysMs: [1, 1, 1],
|
||||
sleep,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(4)
|
||||
expect(sleep).toHaveBeenCalledTimes(3)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('falls back to snapshot on envelope schema mismatch', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response(['unexpected-array-envelope']))
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^envelope schema/)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('memoizes within a single process', async () => {
|
||||
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
||||
const opts = {
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
}
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
fetchCloudNodesForBuild(opts),
|
||||
fetchCloudNodesForBuild(opts)
|
||||
])
|
||||
|
||||
expect(a).toBe(b)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws when called twice with materially different options', async () => {
|
||||
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
||||
await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
fetchCloudNodesForBuild({
|
||||
apiKey: 'different-key',
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
).toThrow(/called twice with different options/)
|
||||
})
|
||||
|
||||
it('returns fresh even when registry enrichment fails', async () => {
|
||||
fetchRegistryPacksMock.mockResolvedValue(new Map())
|
||||
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
})
|
||||
})
|
||||
355
apps/website/src/utils/cloudNodes.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
import {
|
||||
groupNodesByPack,
|
||||
sanitizeUserContent,
|
||||
validateComfyNodeDef
|
||||
} from '@comfyorg/object-info-parser'
|
||||
|
||||
import type { RegistryPack } from './cloudNodes.registry'
|
||||
import type { NodesSnapshot, Pack, PackNode } from '../data/cloudNodes'
|
||||
|
||||
import bundledSnapshot from '../data/cloud-nodes.snapshot.json' with { type: 'json' }
|
||||
import { isNodesSnapshot } from '../data/cloudNodes'
|
||||
import { fetchRegistryPacks } from './cloudNodes.registry'
|
||||
import { CloudNodesEnvelopeSchema } from './cloudNodes.schema'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
|
||||
|
||||
export interface DroppedNode {
|
||||
name: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type FetchOutcome =
|
||||
| {
|
||||
status: 'fresh'
|
||||
snapshot: NodesSnapshot
|
||||
droppedCount: number
|
||||
droppedNodes: DroppedNode[]
|
||||
}
|
||||
| { status: 'stale'; snapshot: NodesSnapshot; reason: string }
|
||||
| { status: 'failed'; reason: string }
|
||||
|
||||
interface FetchCloudNodesOptions {
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
timeoutMs?: number
|
||||
retryDelaysMs?: readonly number[]
|
||||
fetchImpl?: typeof fetch
|
||||
snapshotUrl?: URL
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
let inflight: Promise<FetchOutcome> | undefined
|
||||
let inflightOptions: FetchCloudNodesOptions | undefined
|
||||
|
||||
export function resetCloudNodesFetcherForTests(): void {
|
||||
inflight = undefined
|
||||
inflightOptions = undefined
|
||||
}
|
||||
|
||||
function optionsDifferMaterially(
|
||||
a: FetchCloudNodesOptions,
|
||||
b: FetchCloudNodesOptions
|
||||
): boolean {
|
||||
return (
|
||||
a.apiKey !== b.apiKey ||
|
||||
a.baseUrl !== b.baseUrl ||
|
||||
a.timeoutMs !== b.timeoutMs ||
|
||||
a.snapshotUrl?.href !== b.snapshotUrl?.href
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchCloudNodesForBuild(
|
||||
options: FetchCloudNodesOptions = {}
|
||||
): Promise<FetchOutcome> {
|
||||
if (inflight && inflightOptions) {
|
||||
if (optionsDifferMaterially(inflightOptions, options)) {
|
||||
throw new Error(
|
||||
'fetchCloudNodesForBuild called twice with different options; call resetCloudNodesFetcherForTests() between distinct configurations'
|
||||
)
|
||||
}
|
||||
return inflight
|
||||
}
|
||||
inflightOptions = options
|
||||
inflight = doFetchCloudNodesForBuild(options)
|
||||
return inflight
|
||||
}
|
||||
|
||||
async function doFetchCloudNodesForBuild(
|
||||
options: FetchCloudNodesOptions
|
||||
): Promise<FetchOutcome> {
|
||||
const apiKey = options.apiKey ?? process.env.WEBSITE_CLOUD_API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
return fallback('missing WEBSITE_CLOUD_API_KEY', options.snapshotUrl)
|
||||
}
|
||||
|
||||
const result = await tryFetchAndParse(apiKey, options)
|
||||
if (result.kind === 'ok') {
|
||||
return {
|
||||
status: 'fresh',
|
||||
snapshot: {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
packs: result.packs
|
||||
},
|
||||
droppedCount: result.droppedNodes.length,
|
||||
droppedNodes: result.droppedNodes
|
||||
}
|
||||
}
|
||||
|
||||
return fallback(result.reason, options.snapshotUrl)
|
||||
}
|
||||
|
||||
async function fallback(
|
||||
reason: string,
|
||||
snapshotUrl: URL | undefined
|
||||
): Promise<FetchOutcome> {
|
||||
const snapshot = await readSnapshot(snapshotUrl)
|
||||
if (snapshot) return { status: 'stale', snapshot, reason }
|
||||
return { status: 'failed', reason }
|
||||
}
|
||||
|
||||
interface FetchOk {
|
||||
kind: 'ok'
|
||||
packs: Pack[]
|
||||
droppedNodes: DroppedNode[]
|
||||
}
|
||||
|
||||
interface FetchErr {
|
||||
kind: 'err'
|
||||
reason: string
|
||||
}
|
||||
|
||||
async function tryFetchAndParse(
|
||||
apiKey: string,
|
||||
options: FetchCloudNodesOptions
|
||||
): Promise<FetchOk | FetchErr> {
|
||||
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
const retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS
|
||||
const fetchImpl = options.fetchImpl ?? fetch
|
||||
const sleep = options.sleep ?? defaultSleep
|
||||
|
||||
const url = `${baseUrl}/api/object_info`
|
||||
|
||||
let lastReason = 'unknown error'
|
||||
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
|
||||
if (attempt > 0) await sleep(retryDelaysMs[attempt - 1])
|
||||
|
||||
const response = await callOnce(fetchImpl, url, apiKey, timeoutMs)
|
||||
if (response.kind === 'err') {
|
||||
lastReason = response.reason
|
||||
if (!response.retryable) return response
|
||||
continue
|
||||
}
|
||||
|
||||
const envelope = CloudNodesEnvelopeSchema.safeParse(response.body)
|
||||
if (!envelope.success) {
|
||||
return {
|
||||
kind: 'err',
|
||||
reason: `envelope schema validation failed: ${envelope.error.issues
|
||||
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
|
||||
.join('; ')}`
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(envelope.data).length === 0) {
|
||||
return {
|
||||
kind: 'err',
|
||||
reason:
|
||||
'envelope schema validation failed: <root>: expected non-empty object'
|
||||
}
|
||||
}
|
||||
|
||||
return parseCloudNodes(envelope.data, options)
|
||||
}
|
||||
|
||||
return { kind: 'err', reason: lastReason }
|
||||
}
|
||||
|
||||
type CallResponse =
|
||||
| { kind: 'ok'; body: unknown }
|
||||
| { kind: 'err'; reason: string; retryable: boolean }
|
||||
|
||||
async function callOnce(
|
||||
fetchImpl: typeof fetch,
|
||||
url: string,
|
||||
apiKey: string,
|
||||
timeoutMs: number
|
||||
): Promise<CallResponse> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const res = await fetchImpl(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-API-Key': apiKey
|
||||
},
|
||||
signal: controller.signal
|
||||
})
|
||||
if (res.ok) {
|
||||
return { kind: 'ok', body: await res.json() }
|
||||
}
|
||||
const retryable =
|
||||
res.status === 429 || (res.status >= 500 && res.status < 600)
|
||||
return {
|
||||
kind: 'err',
|
||||
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
|
||||
retryable
|
||||
}
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof Error
|
||||
? `network error: ${error.message}`
|
||||
: 'network error'
|
||||
return { kind: 'err', reason, retryable: true }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function parseCloudNodes(
|
||||
envelope: Record<string, unknown>,
|
||||
options: FetchCloudNodesOptions
|
||||
): Promise<FetchOk> {
|
||||
const validDefs: Record<string, ReturnType<typeof validateComfyNodeDef>> = {}
|
||||
const droppedNodes: DroppedNode[] = []
|
||||
|
||||
for (const [name, rawDef] of Object.entries(envelope)) {
|
||||
let validationError = 'unknown validation error'
|
||||
const parsed = validateComfyNodeDef(rawDef, (error) => {
|
||||
validationError = error
|
||||
})
|
||||
if (!parsed) {
|
||||
droppedNodes.push({ name, reason: validationError })
|
||||
continue
|
||||
}
|
||||
validDefs[name] = parsed
|
||||
}
|
||||
|
||||
const sanitizedDefs = sanitizeUserContent(
|
||||
validDefs as Record<string, NonNullable<(typeof validDefs)[string]>>
|
||||
)
|
||||
const grouped = groupNodesByPack(sanitizedDefs)
|
||||
|
||||
let registryMap = new Map<string, RegistryPack | null>()
|
||||
try {
|
||||
registryMap = await fetchRegistryPacks(
|
||||
grouped.map((pack) => pack.id),
|
||||
{ fetchImpl: options.fetchImpl }
|
||||
)
|
||||
} catch {
|
||||
registryMap = new Map()
|
||||
}
|
||||
|
||||
const packs = grouped.map((pack) =>
|
||||
toDomainPack(
|
||||
pack.id,
|
||||
pack.displayName,
|
||||
pack.nodes,
|
||||
registryMap.get(pack.id)
|
||||
)
|
||||
)
|
||||
|
||||
return { kind: 'ok', packs, droppedNodes }
|
||||
}
|
||||
|
||||
function safeExternalUrl(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
|
||||
if (!url.host) return undefined
|
||||
return url.toString()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function toDomainPack(
|
||||
packId: string,
|
||||
fallbackDisplayName: string,
|
||||
nodes: Array<{
|
||||
className: string
|
||||
def: {
|
||||
display_name: string
|
||||
category: string
|
||||
description: string
|
||||
deprecated?: boolean
|
||||
experimental?: boolean
|
||||
}
|
||||
}>,
|
||||
registryPack: RegistryPack | null | undefined
|
||||
): Pack {
|
||||
return {
|
||||
id: packId,
|
||||
registryId: registryPack?.id,
|
||||
displayName: registryPack?.name?.trim() || fallbackDisplayName || packId,
|
||||
description: registryPack?.description?.trim() || undefined,
|
||||
bannerUrl: safeExternalUrl(registryPack?.banner_url),
|
||||
iconUrl: safeExternalUrl(registryPack?.icon),
|
||||
repoUrl: safeExternalUrl(registryPack?.repository),
|
||||
publisher: registryPack?.publisher?.id
|
||||
? {
|
||||
id: registryPack.publisher.id,
|
||||
name: registryPack.publisher.name
|
||||
}
|
||||
: undefined,
|
||||
downloads: registryPack?.downloads,
|
||||
githubStars: registryPack?.github_stars,
|
||||
latestVersion: registryPack?.latest_version?.version,
|
||||
license: registryPack?.license,
|
||||
lastUpdated:
|
||||
registryPack?.latest_version?.createdAt ?? registryPack?.created_at,
|
||||
supportedOs: registryPack?.supported_os,
|
||||
supportedAccelerators: registryPack?.supported_accelerators,
|
||||
nodes: nodes
|
||||
.map((node) => toDomainNode(node.className, node.def))
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName))
|
||||
}
|
||||
}
|
||||
|
||||
function toDomainNode(
|
||||
className: string,
|
||||
def: {
|
||||
display_name: string
|
||||
category: string
|
||||
description: string
|
||||
deprecated?: boolean
|
||||
experimental?: boolean
|
||||
}
|
||||
): PackNode {
|
||||
return {
|
||||
name: className,
|
||||
displayName: def.display_name,
|
||||
category: def.category,
|
||||
description: def.description || undefined,
|
||||
deprecated: def.deprecated,
|
||||
experimental: def.experimental
|
||||
}
|
||||
}
|
||||
|
||||
async function readSnapshot(
|
||||
snapshotUrl: URL | undefined
|
||||
): Promise<NodesSnapshot | null> {
|
||||
if (!snapshotUrl) {
|
||||
return isNodesSnapshot(bundledSnapshot) ? bundledSnapshot : null
|
||||
}
|
||||
try {
|
||||
const text = await readFile(snapshotUrl, 'utf8')
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (isNodesSnapshot(parsed)) return parsed
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
24
apps/website/src/utils/escapeJsonLd.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Serialize an object to a JSON string safe to embed inside an inline
|
||||
* `<script type="application/ld+json">` tag.
|
||||
*
|
||||
* Pack metadata flows in from the public ComfyUI registry, where any user
|
||||
* can publish a pack with a `displayName`, `description`, or
|
||||
* `publisher.name` that contains `</script>` or `<!--`. Without escaping,
|
||||
* those sequences would close the surrounding `<script>` tag and allow
|
||||
* stored XSS.
|
||||
*
|
||||
* Escapes:
|
||||
* - Every `<` (covers `</script>`, `<script`, `<!--`)
|
||||
* - U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR (which are
|
||||
* line terminators in HTML script context but valid characters
|
||||
* elsewhere in JSON, so `JSON.stringify` does not escape them)
|
||||
*
|
||||
* Reference: https://v8.dev/features/subsume-json
|
||||
*/
|
||||
export function escapeJsonLd(value: unknown): string {
|
||||
return JSON.stringify(value)
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/\u2028/g, '\\u2028')
|
||||
.replace(/\u2029/g, '\\u2029')
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 120],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png[output]",
|
||||
"image"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 120],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["ComfyUI_00001_.png [output]", "image"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadVideo",
|
||||
"pos": [430, 120],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "VIDEO",
|
||||
"type": "VIDEO",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadVideo"
|
||||
},
|
||||
"widgets_values": ["clip.mp4 [output]", "image"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "LoadAudio",
|
||||
"pos": [810, 120],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "AUDIO",
|
||||
"type": "AUDIO",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadAudio"
|
||||
},
|
||||
"widgets_values": ["sound.wav [output]", null, ""]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
BIN
browser_tests/assets/plain_video.mp4
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"id": "06e5b524-5a40-40b9-b561-199dfab18cf0",
|
||||
"revision": 0,
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 10,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "KSampler",
|
||||
"pos": [230, 110],
|
||||
"size": [270, 317.5666809082031],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "PrimitiveFloat",
|
||||
"pos": [-80.55032348632812, 375.2260443115233],
|
||||
"size": [270, 80.23332977294922],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": [10]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveFloat"
|
||||
},
|
||||
"widgets_values": [0]
|
||||
}
|
||||
],
|
||||
"links": [[10, 11, 0, 10, 4, "FLOAT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8264462809917354,
|
||||
"offset": [1335.8909766107738, 692.7345403667316]
|
||||
},
|
||||
"frontendVersion": "1.45.4"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 10,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["this-image-does-not-exist-deadbeef.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -285,10 +285,12 @@ export class ComfyPage {
|
||||
|
||||
async setup({
|
||||
clearStorage = true,
|
||||
mockReleases = true
|
||||
mockReleases = true,
|
||||
url
|
||||
}: {
|
||||
clearStorage?: boolean
|
||||
mockReleases?: boolean
|
||||
url?: string
|
||||
} = {}) {
|
||||
// Mock release endpoint to prevent changelog popups (before navigation)
|
||||
if (mockReleases) {
|
||||
@@ -320,7 +322,7 @@ export class ComfyPage {
|
||||
}, this.id)
|
||||
}
|
||||
|
||||
await this.goto()
|
||||
await this.goto({ url })
|
||||
|
||||
await this.page.waitForFunction(() => document.fonts.ready)
|
||||
await this.waitForAppReady()
|
||||
@@ -347,8 +349,8 @@ export class ComfyPage {
|
||||
return assetPath(fileName)
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto(this.url)
|
||||
async goto({ url }: { url?: string } = {}) {
|
||||
await this.page.goto(url ? new URL(url, this.url).toString() : this.url)
|
||||
}
|
||||
|
||||
async nextFrame() {
|
||||
@@ -470,6 +472,7 @@ const COLLECT_COVERAGE = process.env.COLLECT_COVERAGE === 'true'
|
||||
|
||||
export const comfyPageFixture = base.extend<{
|
||||
initialFeatureFlags: Record<string, unknown>
|
||||
initialSettings: Record<string, unknown>
|
||||
comfyPage: ComfyPage
|
||||
comfyMouse: ComfyMouse
|
||||
comfyFiles: ComfyFiles
|
||||
@@ -477,6 +480,10 @@ export const comfyPageFixture = base.extend<{
|
||||
// Allows configuring feature flags for tests with before initial setup:
|
||||
// `test.use({ initialFeatureFlags: { my_flag: true } })`.
|
||||
initialFeatureFlags: [{}, { option: true }],
|
||||
// Allows seeding user settings before initial page load:
|
||||
// `test.use({ initialSettings: { 'Comfy.Locale': 'zh' } })`. Merged on top of
|
||||
// the fixture's defaults so per-test values win.
|
||||
initialSettings: [{}, { option: true }],
|
||||
|
||||
page: async ({ page, browserName }, use) => {
|
||||
if (browserName !== 'chromium' || !COLLECT_COVERAGE) {
|
||||
@@ -494,7 +501,11 @@ export const comfyPageFixture = base.extend<{
|
||||
await mcr.add(coverage)
|
||||
},
|
||||
|
||||
comfyPage: async ({ page, request, initialFeatureFlags }, use, testInfo) => {
|
||||
comfyPage: async (
|
||||
{ page, request, initialFeatureFlags, initialSettings },
|
||||
use,
|
||||
testInfo
|
||||
) => {
|
||||
const comfyPage = new ComfyPage(page, request)
|
||||
|
||||
const { parallelIndex } = testInfo
|
||||
@@ -529,7 +540,8 @@ export const comfyPageFixture = base.extend<{
|
||||
// Disable errors tab to prevent missing model detection from
|
||||
// rendering error indicators on nodes during unrelated tests.
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': false,
|
||||
...(isVueNodes && { 'Comfy.VueNodes.Enabled': true })
|
||||
...(isVueNodes && { 'Comfy.VueNodes.Enabled': true }),
|
||||
...initialSettings
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
@@ -246,4 +246,18 @@ export class VueNodeHelpers {
|
||||
position: { x: box.width / 2, y: box.height * 0.75 }
|
||||
})
|
||||
}
|
||||
async isSlotConnected(slot: Locator) {
|
||||
const key = await slot.getByTestId('slot-dot').getAttribute('data-slot-key')
|
||||
if (!key) return false
|
||||
|
||||
return await this.page.evaluate((key) => {
|
||||
const [nodeId, type, slotId] = key.split('-')
|
||||
const node = app?.canvas?.graph?.getNodeById(nodeId)
|
||||
if (!node) return false
|
||||
|
||||
return type === 'in'
|
||||
? node.inputs[Number(slotId)]?.link !== null
|
||||
: !!node.outputs[Number(slotId)].links?.length
|
||||
}, key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,16 @@ export class ContextMenu {
|
||||
public readonly litegraphMenu: Locator
|
||||
public readonly litegraphContextMenu: Locator
|
||||
public readonly menuItems: Locator
|
||||
protected readonly anyMenu: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.primeVueMenu = page.locator('.p-contextmenu, .p-menu')
|
||||
this.litegraphMenu = page.locator('.litemenu')
|
||||
this.litegraphContextMenu = page.locator('.litecontextmenu')
|
||||
this.menuItems = page.locator('.p-menuitem, .litemenu-entry')
|
||||
this.anyMenu = this.primeVueMenu
|
||||
.or(this.litegraphMenu)
|
||||
.or(this.litegraphContextMenu)
|
||||
}
|
||||
|
||||
async clickMenuItem(name: string): Promise<void> {
|
||||
@@ -36,16 +40,7 @@ export class ContextMenu {
|
||||
}
|
||||
|
||||
async isVisible(): Promise<boolean> {
|
||||
const primeVueVisible = await this.primeVueMenu
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const litegraphVisible = await this.litegraphMenu
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
const litegraphContextVisible = await this.litegraphContextMenu
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
return primeVueVisible || litegraphVisible || litegraphContextVisible
|
||||
return await this.anyMenu.isVisible()
|
||||
}
|
||||
|
||||
async assertHasItems(items: string[]): Promise<void> {
|
||||
@@ -58,7 +53,7 @@ export class ContextMenu {
|
||||
|
||||
async openFor(locator: Locator): Promise<this> {
|
||||
await locator.click({ button: 'right' })
|
||||
await expect.poll(() => this.isVisible()).toBe(true)
|
||||
await expect(this.anyMenu).toBeVisible()
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
public readonly allTab: Locator
|
||||
public readonly blueprintsTab: Locator
|
||||
public readonly sortButton: Locator
|
||||
public readonly nodePreview: Locator
|
||||
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'node-library')
|
||||
@@ -103,6 +104,7 @@ export class NodeLibrarySidebarTabV2 extends SidebarTab {
|
||||
this.allTab = this.getTab('All')
|
||||
this.blueprintsTab = this.getTab('Blueprints')
|
||||
this.sortButton = this.sidebarContent.getByRole('button', { name: 'Sort' })
|
||||
this.nodePreview = page.getByTestId(TestIds.sidebar.nodePreviewCard)
|
||||
}
|
||||
|
||||
getTab(name: string) {
|
||||
@@ -384,11 +386,14 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
return this.page.locator('.p-contextmenu').getByText(label)
|
||||
}
|
||||
|
||||
override async open() {
|
||||
override async open({ waitForAssets = true } = {}) {
|
||||
// Remove any toast notifications that may overlay the sidebar button
|
||||
await this.dismissToasts()
|
||||
await super.open()
|
||||
await this.generatedTab.waitFor({ state: 'visible' })
|
||||
if (waitForAssets) {
|
||||
await this.waitForAssets()
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss all visible toast notifications by clicking their close buttons. */
|
||||
|
||||
81
browser_tests/fixtures/components/SubgraphEditor.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import { comfyExpect as expect } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { dragByIndex } from '@e2e/fixtures/utils/dragAndDrop'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
export class SubgraphEditor {
|
||||
public readonly root: Locator
|
||||
public readonly promotionItems: Locator
|
||||
|
||||
constructor(protected readonly comfyPage: ComfyPage) {
|
||||
this.root = this.comfyPage.menu.propertiesPanel.root
|
||||
this.promotionItems = this.root.getByTestId(
|
||||
TestIds.subgraphEditor.widgetItem
|
||||
)
|
||||
}
|
||||
|
||||
async open(subgraphNode: Locator) {
|
||||
await new VueNodeFixture(subgraphNode).select()
|
||||
const menu = await this.comfyPage.contextMenu.openFor(subgraphNode)
|
||||
await menu.clickMenuItemExact('Edit Subgraph Widgets')
|
||||
await expect(this.root, 'Open Properties Panel').toBeVisible()
|
||||
}
|
||||
|
||||
resolveItem(options: {
|
||||
nodeName?: string
|
||||
nodeId?: string
|
||||
widgetName: string
|
||||
}): Locator {
|
||||
const nodeItems =
|
||||
options.nodeId !== undefined
|
||||
? this.comfyPage.page.locator(`[data-nodeid="${options.nodeId}"]`)
|
||||
: options.nodeName !== undefined
|
||||
? this.promotionItems.filter({
|
||||
has: this.comfyPage.page
|
||||
.getByTestId(TestIds.subgraphEditor.nodeName)
|
||||
.filter({ hasText: options.nodeName })
|
||||
})
|
||||
: this.promotionItems
|
||||
|
||||
return nodeItems.filter({
|
||||
has: this.comfyPage.page
|
||||
.getByTestId(TestIds.subgraphEditor.widgetLabel)
|
||||
.filter({ hasText: options.widgetName })
|
||||
})
|
||||
}
|
||||
|
||||
getToggleButton(item: Locator) {
|
||||
return item.getByTestId(TestIds.subgraphEditor.widgetToggle)
|
||||
}
|
||||
|
||||
async togglePromotionOnItem(item: Locator, toState?: boolean) {
|
||||
const toggleIcon = item.getByTestId(TestIds.subgraphEditor.iconEye)
|
||||
if (toState !== undefined) {
|
||||
const expectedIcon = `icon-[lucide--eye${toState ? '-off' : ''}]`
|
||||
await expect(toggleIcon).toContainClass(expectedIcon)
|
||||
}
|
||||
await toggleIcon.click()
|
||||
}
|
||||
|
||||
async togglePromotion(
|
||||
subgraphNode: Locator,
|
||||
options: {
|
||||
nodeName?: string
|
||||
nodeId?: string
|
||||
widgetName: string
|
||||
toState?: boolean
|
||||
}
|
||||
) {
|
||||
await this.open(subgraphNode)
|
||||
|
||||
const item = this.resolveItem(options)
|
||||
await this.togglePromotionOnItem(item, options.toState)
|
||||
}
|
||||
async dragItem(fromIndex: number, toIndex: number) {
|
||||
await dragByIndex(this.promotionItems, fromIndex, toIndex)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
|
||||
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
|
||||
const Local = TemplateIncludeOnDistributionEnum.Local
|
||||
|
||||
export function makeTemplate(
|
||||
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
|
||||
@@ -26,3 +31,33 @@ export function mockTemplateIndex(
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export const STABLE_CLOUD_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'cloud-stable',
|
||||
title: 'Cloud Stable',
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
|
||||
export const STABLE_DESKTOP_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'desktop-stable',
|
||||
title: 'Desktop Stable',
|
||||
includeOnDistributions: [Desktop]
|
||||
})
|
||||
|
||||
export const STABLE_LOCAL_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'local-stable',
|
||||
title: 'Local Stable',
|
||||
includeOnDistributions: [Local]
|
||||
})
|
||||
|
||||
export const STABLE_UNRESTRICTED_TEMPLATE: TemplateInfo = makeTemplate({
|
||||
name: 'unrestricted-stable',
|
||||
title: 'Unrestricted Stable'
|
||||
})
|
||||
|
||||
export const ALL_DISTRIBUTION_TEMPLATES: TemplateInfo[] = [
|
||||
STABLE_CLOUD_TEMPLATE,
|
||||
STABLE_DESKTOP_TEMPLATE,
|
||||
STABLE_LOCAL_TEMPLATE,
|
||||
STABLE_UNRESTRICTED_TEMPLATE
|
||||
]
|
||||
|
||||
@@ -2,34 +2,7 @@ import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
/**
|
||||
* Drag an element from one index to another within a list of locators.
|
||||
* Uses mousedown/mousemove/mouseup to trigger the DraggableList library.
|
||||
*
|
||||
* DraggableList toggles position when the dragged item's center crosses
|
||||
* past an idle item's center. To reliably land at the target position,
|
||||
* we overshoot slightly past the target's far edge.
|
||||
*/
|
||||
async function dragByIndex(items: Locator, fromIndex: number, toIndex: number) {
|
||||
const fromBox = await items.nth(fromIndex).boundingBox()
|
||||
const toBox = await items.nth(toIndex).boundingBox()
|
||||
if (!fromBox || !toBox) throw new Error('Item not visible for drag')
|
||||
|
||||
const draggingDown = toIndex > fromIndex
|
||||
const targetY = draggingDown
|
||||
? toBox.y + toBox.height * 0.9
|
||||
: toBox.y + toBox.height * 0.1
|
||||
|
||||
const page = items.page()
|
||||
await page.mouse.move(
|
||||
fromBox.x + fromBox.width / 2,
|
||||
fromBox.y + fromBox.height / 2
|
||||
)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(toBox.x + toBox.width / 2, targetY, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
import { dragByIndex } from '@e2e/fixtures/utils/dragAndDrop'
|
||||
|
||||
export class BuilderSelectHelper {
|
||||
/** All IoItem locators in the current step sidebar. */
|
||||
|
||||
@@ -6,6 +6,71 @@ import type { Locator, Page } from '@playwright/test'
|
||||
import type { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
|
||||
import { getMimeType } from '@e2e/fixtures/utils/mimeTypeUtil'
|
||||
|
||||
function readFilePayload(filePath: string) {
|
||||
const buffer = readFileSync(filePath)
|
||||
const bufferArray = [...new Uint8Array(buffer)]
|
||||
const fileName = basename(filePath)
|
||||
const fileType = getMimeType(fileName)
|
||||
|
||||
return { bufferArray, fileName, fileType }
|
||||
}
|
||||
|
||||
async function dispatchFilePaste(
|
||||
page: Page,
|
||||
payload: ReturnType<typeof readFilePayload>
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ bufferArray, fileName, fileType }) => {
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const target = document.activeElement ?? document
|
||||
target.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
}, payload)
|
||||
}
|
||||
|
||||
async function interceptNextFilePaste(
|
||||
page: Page,
|
||||
payload: ReturnType<typeof readFilePayload>
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ bufferArray, fileName, fileType }) => {
|
||||
document.addEventListener(
|
||||
'paste',
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
document.dispatchEvent(
|
||||
new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
)
|
||||
},
|
||||
{ capture: true, once: true }
|
||||
)
|
||||
}, payload)
|
||||
}
|
||||
|
||||
type PasteFileOptions = {
|
||||
mode?: 'keyboard' | 'direct'
|
||||
}
|
||||
|
||||
export class ClipboardHelper {
|
||||
constructor(
|
||||
private readonly keyboard: KeyboardHelper,
|
||||
@@ -20,43 +85,20 @@ export class ClipboardHelper {
|
||||
await this.keyboard.ctrlSend('KeyV', locator ?? null)
|
||||
}
|
||||
|
||||
async pasteFile(filePath: string): Promise<void> {
|
||||
const buffer = readFileSync(filePath)
|
||||
const bufferArray = [...new Uint8Array(buffer)]
|
||||
const fileName = basename(filePath)
|
||||
const fileType = getMimeType(fileName)
|
||||
async pasteFile(
|
||||
filePath: string,
|
||||
{ mode = 'keyboard' }: PasteFileOptions = {}
|
||||
): Promise<void> {
|
||||
const payload = readFilePayload(filePath)
|
||||
|
||||
// Register a one-time capturing-phase listener that intercepts the next
|
||||
// paste event and injects file data onto clipboardData.
|
||||
await this.page.evaluate(
|
||||
({ bufferArray, fileName, fileType }) => {
|
||||
document.addEventListener(
|
||||
'paste',
|
||||
(e: ClipboardEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
if (mode === 'keyboard') {
|
||||
await interceptNextFilePaste(this.page, payload)
|
||||
await this.paste()
|
||||
return
|
||||
}
|
||||
|
||||
const file = new File([new Uint8Array(bufferArray)], fileName, {
|
||||
type: fileType
|
||||
})
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
const syntheticEvent = new ClipboardEvent('paste', {
|
||||
clipboardData: dataTransfer,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
document.dispatchEvent(syntheticEvent)
|
||||
},
|
||||
{ capture: true, once: true }
|
||||
)
|
||||
},
|
||||
{ bufferArray, fileName, fileType }
|
||||
)
|
||||
|
||||
// Trigger a real Ctrl+V keystroke — the capturing listener above will
|
||||
// intercept it and re-dispatch with file data attached.
|
||||
await this.paste()
|
||||
// Browser clipboard APIs cannot reliably seed arbitrary files in tests.
|
||||
// Dispatch the app-level paste event with file clipboardData directly.
|
||||
await dispatchFilePaste(this.page, payload)
|
||||
}
|
||||
}
|
||||
|
||||