Compare commits
2 Commits
feat/model
...
glary/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3a1997604 | ||
|
|
6f748be56f |
@@ -1,173 +0,0 @@
|
||||
---
|
||||
name: add-model-page
|
||||
description: 'add, update, or remove a model page entry on the comfy org website. creates a PR to Comfy-Org/ComfyUI_frontend apps/website folder with the change and posts a Vercel preview link back to Slack.'
|
||||
---
|
||||
|
||||
# add-model-page
|
||||
|
||||
add, update, or remove model pages in the ComfyUI website.
|
||||
|
||||
## Trigger phrases
|
||||
|
||||
- `Add a model page for <model-name>`
|
||||
- `Update the model page for <model-name>`
|
||||
- `Remove <model-name> from model pages`
|
||||
|
||||
## Phase 1 — Parse the request
|
||||
|
||||
Extract:
|
||||
|
||||
- **action**: `add` | `update` | `remove`
|
||||
- **model-name**: raw string (e.g. `flux1-schnell`, `flux1_dev.safetensors`)
|
||||
|
||||
Normalize to a slug: lowercase, replace `_` and `.` with `-`, strip file extensions.
|
||||
Example: `flux1_dev.safetensors` → `flux1-dev`
|
||||
|
||||
## Architecture overview
|
||||
|
||||
Models come from two sources merged at build time:
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `apps/website/src/config/generated-models.json` | Auto-generated from workflow_templates (slug, name, directory, huggingFaceUrl, workflowCount, displayName, thumbnailUrl, docsUrl) |
|
||||
| `apps/website/src/config/model-metadata.ts` | Hand-curated overrides (docsUrl, blogUrl, featured) — only add entries that need overrides |
|
||||
| `apps/website/src/config/models.ts` | Merges the two above; exports typed `Model[]` |
|
||||
|
||||
To regenerate the JSON from workflow_templates:
|
||||
|
||||
```bash
|
||||
pnpm tsx apps/website/scripts/generate-models.ts
|
||||
```
|
||||
|
||||
This writes `apps/website/src/config/generated-models.json` directly.
|
||||
Thumbnails are populated from local `.webp` files in `workflow_templates/templates/` — no network access needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Gather model data (ADD / UPDATE)
|
||||
|
||||
Run the generator to get fresh data, then find the model:
|
||||
|
||||
```bash
|
||||
pnpm tsx apps/website/scripts/generate-models.ts
|
||||
jq '.[] | select(.slug | contains("MODEL_SLUG"))' \
|
||||
apps/website/src/config/generated-models.json
|
||||
```
|
||||
|
||||
The JSON fields are:
|
||||
|
||||
- `slug` — URL slug
|
||||
- `name` — exact filename or display name for partner nodes
|
||||
- `huggingFaceUrl` — download URL (empty for partner nodes)
|
||||
- `directory` — `diffusion_models` | `loras` | … | `partner_nodes`
|
||||
- `workflowCount` — integer
|
||||
- `displayName` — human-readable name
|
||||
|
||||
If no match and it is a known API/partner model, add it to `API_PROVIDER_MAP` in
|
||||
`generate-models.ts` and re-run. Otherwise tell the user.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Check for existing entry
|
||||
|
||||
```bash
|
||||
jq --arg slug "${SLUG}" '.[] | select(.slug == $slug)' \
|
||||
apps/website/src/config/generated-models.json
|
||||
```
|
||||
|
||||
- Match found + action is `add` → switch to UPDATE flow automatically
|
||||
- No match + action is `update` → stop and tell the user
|
||||
|
||||
---
|
||||
|
||||
## Phase 4A — ADD: new partner/API model not in workflow_templates
|
||||
|
||||
For partner nodes (no local file), add an entry to `API_PROVIDER_MAP` in
|
||||
`apps/website/scripts/generate-models.ts`:
|
||||
|
||||
```typescript
|
||||
mymodel: { name: 'My Model', slug: 'my-model' },
|
||||
```
|
||||
|
||||
Then re-run `pnpm tsx apps/website/scripts/generate-models.ts` — it will appear
|
||||
in `generated-models.json` automatically.
|
||||
|
||||
If you also want a `docsUrl`, `blogUrl`, or a link to the hub model page, add an entry to `model-metadata.ts`:
|
||||
|
||||
```typescript
|
||||
'my-model': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/...',
|
||||
blogUrl: 'https://blog.comfy.org/...',
|
||||
hubSlug: 'my-model', // slug at comfy.org/workflows/model/{hubSlug} — only set if the page exists (returns 200)
|
||||
featured: true
|
||||
}
|
||||
```
|
||||
|
||||
No changes to `models.ts` or `translations.ts` are needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4B — UPDATE: edit existing entry
|
||||
|
||||
Only `model-metadata.ts` needs editing for most updates (docsUrl, blogUrl,
|
||||
featured). For `displayName` or `directory` changes, edit the entry directly in
|
||||
`generated-models.json` (until the next generator run would overwrite it — then
|
||||
fix the source in `generate-models.ts`).
|
||||
|
||||
---
|
||||
|
||||
## Phase 4C — REMOVE: delete entry
|
||||
|
||||
Remove the entry from `generated-models.json` (or mark it with `canonicalSlug`
|
||||
pointing to the replacement). No translation file changes needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Verify TypeScript
|
||||
|
||||
```bash
|
||||
pnpm typecheck 2>&1 | grep -E "error|warning" | head -20
|
||||
```
|
||||
|
||||
Fix any type errors before proceeding. Common issues:
|
||||
|
||||
- `ModelDirectory` type not matching a new `directory` value — add it to the union
|
||||
- JSON import shape mismatch — `generated-models.json` must match `OutputModel`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Create PR
|
||||
|
||||
```bash
|
||||
BRANCH="add-model-page-MODEL-SLUG" # or update- / remove-
|
||||
git checkout -b $BRANCH
|
||||
git add apps/website/src/config/generated-models.json \
|
||||
apps/website/scripts/generate-models.ts \
|
||||
apps/website/src/config/model-metadata.ts
|
||||
git commit -m "feat(models): add model page for MODEL-SLUG"
|
||||
git push -u origin $BRANCH
|
||||
gh pr create \
|
||||
--title "Add model page: MODEL-SLUG" \
|
||||
--body "$(cat <<'EOF'
|
||||
Adds a new model page entry for MODEL-SLUG.
|
||||
|
||||
## Changes
|
||||
- `generated-models.json`: regenerated with new entry (workflowCount N, directory DIRECTORY)
|
||||
- `model-metadata.ts`: editorial overrides (docsUrl, featured) if needed
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
For UPDATE use branch `update-model-page-MODEL-SLUG`.
|
||||
For REMOVE use `remove-model-page-MODEL-SLUG`.
|
||||
|
||||
---
|
||||
|
||||
## Error states
|
||||
|
||||
| Situation | Response |
|
||||
| ------------------------------- | ---------------------------------------------------------------- |
|
||||
| Model not in workflow templates | Ask user to verify spelling or add it manually as a partner node |
|
||||
| Slug already exists (add) | Switch to update flow automatically |
|
||||
| Slug not found (update/remove) | Stop and ask user to confirm |
|
||||
| Typecheck fails | Fix the error before pushing |
|
||||
@@ -41,10 +41,6 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
# Enable PostHog debug logging in the browser console.
|
||||
# VITE_POSTHOG_DEBUG=true
|
||||
|
||||
# Override staging comfy-api / comfy-platform base URLs.
|
||||
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
|
||||
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org
|
||||
|
||||
# Sentry ENV vars replace with real ones for debugging
|
||||
# SENTRY_AUTH_TOKEN=private-token # get from sentry
|
||||
# SENTRY_ORG=comfy-org
|
||||
|
||||
19
.github/actions/cloud-nodes-pull/action.yaml
vendored
@@ -1,19 +0,0 @@
|
||||
name: Cloud Nodes Pull
|
||||
description: 'Refresh the apps/website cloud nodes snapshot from the Comfy Cloud /api/object_info endpoint'
|
||||
inputs:
|
||||
api_key:
|
||||
description: 'Comfy Cloud API key (WEBSITE_CLOUD_API_KEY).'
|
||||
required: true
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
# Note: this action assumes the frontend repo is checked out at the workspace root.
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Refresh cloud nodes snapshot
|
||||
shell: bash
|
||||
env:
|
||||
WEBSITE_CLOUD_API_KEY: ${{ inputs.api_key }}
|
||||
run: pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
|
||||
12
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -106,12 +106,17 @@ jobs:
|
||||
- name: Generate HTML coverage report
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
if [ ! -s coverage/playwright/coverage.lcov ]; then
|
||||
echo "No coverage data; generating placeholder report."
|
||||
mkdir -p coverage/html
|
||||
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
|
||||
exit 0
|
||||
fi
|
||||
genhtml coverage/playwright/coverage.lcov \
|
||||
-o coverage/html \
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source,unmapped
|
||||
--precision 1
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
@@ -125,8 +130,7 @@ jobs:
|
||||
needs: merge
|
||||
if: >
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
needs.merge.outputs.has-coverage == 'true' &&
|
||||
github.event.workflow_run.event == 'push'
|
||||
needs.merge.outputs.has-coverage == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
|
||||
11
.github/workflows/ci-vercel-website-preview.yaml
vendored
@@ -58,7 +58,6 @@ jobs:
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
run: vercel build
|
||||
|
||||
- name: Fetch head commit metadata
|
||||
@@ -152,20 +151,10 @@ jobs:
|
||||
- name: Pull Vercel environment information
|
||||
run: vercel pull --yes --environment=production
|
||||
|
||||
- name: Verify WEBSITE_CLOUD_API_KEY is present for production build
|
||||
env:
|
||||
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
run: |
|
||||
if [ -z "${WEBSITE_CLOUD_API_KEY:-}" ]; then
|
||||
echo "::error title=Missing WEBSITE_CLOUD_API_KEY::Production builds require WEBSITE_CLOUD_API_KEY so /cloud/supported-nodes is generated from fresh Cloud API data. Add it as a GitHub Actions repo secret and to the Vercel project environment. See apps/website/README.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build project artifacts
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
run: vercel build --prod
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
|
||||
123
.github/workflows/model-page-discovery.yaml
vendored
@@ -1,123 +0,0 @@
|
||||
name: Model Page Discovery
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 9 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
discover:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Fetch model labels from hub API
|
||||
id: hub
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL 'https://comfy.org/api/hub/labels?type=model' -o hub-labels.json
|
||||
echo "Fetched $(jq '.labels | length' hub-labels.json) model labels from hub"
|
||||
|
||||
- name: Checkout ComfyUI_frontend
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: apps/website/src/config/generated-models.json
|
||||
|
||||
- name: Compare against existing models
|
||||
id: compare
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
HUB_SLUGS=$(jq -r '[.labels[].name]' hub-labels.json)
|
||||
|
||||
EXISTING_SLUGS=$(node -e "
|
||||
const fs = require('fs');
|
||||
const models = JSON.parse(
|
||||
fs.readFileSync(
|
||||
'apps/website/src/config/generated-models.json',
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
console.log(JSON.stringify(models.map(m => m.slug)));
|
||||
" 2>/dev/null || echo '[]')
|
||||
|
||||
ADDED_SLUGS=$(node -e "
|
||||
const hub = $HUB_SLUGS;
|
||||
const existing = new Set($EXISTING_SLUGS);
|
||||
console.log(JSON.stringify(hub.filter(s => !existing.has(s))));
|
||||
")
|
||||
|
||||
COUNT=$(node -e "console.log($ADDED_SLUGS.length)")
|
||||
echo "new_count=$COUNT" >> \$GITHUB_OUTPUT
|
||||
echo "new_slugs=$ADDED_SLUGS" >> \$GITHUB_OUTPUT
|
||||
|
||||
if [ "\$COUNT" -eq 0 ]; then
|
||||
echo "No new models found."
|
||||
else
|
||||
echo "Found \$COUNT new model(s)"
|
||||
fi
|
||||
|
||||
- name: Check for existing open discovery issue
|
||||
id: existing_issue
|
||||
if: steps.compare.outputs.new_count != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
COUNT=$(gh issue list \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--state open \
|
||||
--search 'in:title "New models detected"' \
|
||||
--json number \
|
||||
--jq 'length')
|
||||
echo "open_count=$COUNT" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Open GitHub issue for new models
|
||||
if: |
|
||||
steps.compare.outputs.new_count != '0' &&
|
||||
steps.existing_issue.outputs.open_count == '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NEW_SLUGS: ${{ steps.compare.outputs.new_slugs }}
|
||||
NEW_COUNT: ${{ steps.compare.outputs.new_count }}
|
||||
shell: bash
|
||||
run: |
|
||||
SLUG_LIST=$(node -e "
|
||||
const slugs = $NEW_SLUGS;
|
||||
console.log(slugs.map(s => '- \`' + s + '\`').join('\n'));
|
||||
")
|
||||
|
||||
gh issue create \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "New models detected — add to model pages" \
|
||||
--body "## $NEW_COUNT new model(s) found in hub
|
||||
|
||||
The weekly model discovery scan found model labels on the hub not yet in
|
||||
\`apps/website/src/config/generated-models.json\`.
|
||||
|
||||
### New slugs ($NEW_COUNT)
|
||||
|
||||
$SLUG_LIST
|
||||
|
||||
### Next steps
|
||||
|
||||
1. Review which of these warrant an SEO model page
|
||||
2. For local models: run \`SKIP_THUMBNAILS=1 pnpm generate:models\` and commit the result
|
||||
3. For partner/API models: add to \`API_PROVIDER_MAP\` in \`generate-models.ts\`, regenerate, commit
|
||||
|
||||
---
|
||||
*Generated by the [model-page-discovery workflow](https://github.com/$GITHUB_REPOSITORY/actions/workflows/model-page-discovery.yaml)*"
|
||||
|
||||
- name: Skip — open issue already exists
|
||||
if: |
|
||||
steps.compare.outputs.new_count != '0' &&
|
||||
steps.existing_issue.outputs.open_count != '0'
|
||||
run: echo "An open discovery issue already exists — skipping creation."
|
||||
|
||||
- name: No new models found
|
||||
if: steps.compare.outputs.new_count == '0'
|
||||
run: echo "No new models found — nothing to do."
|
||||
37
.github/workflows/release-website.yaml
vendored
@@ -1,6 +1,6 @@
|
||||
# Description: Manual workflow to refresh the apps/website Ashby roles and
|
||||
# cloud nodes snapshots and open a PR. Merging the PR triggers the existing
|
||||
# Vercel website production deploy via ci-vercel-website-preview.yaml.
|
||||
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
|
||||
# and open a PR. Merging the PR triggers the existing Vercel website production
|
||||
# deploy via ci-vercel-website-preview.yaml.
|
||||
name: 'Release: Website'
|
||||
|
||||
on:
|
||||
@@ -11,7 +11,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
refresh-snapshots:
|
||||
refresh-snapshot:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -31,39 +31,28 @@ jobs:
|
||||
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
|
||||
- name: Refresh cloud nodes snapshot
|
||||
uses: ./.github/actions/cloud-nodes-pull
|
||||
with:
|
||||
api_key: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: 'chore(website): refresh Ashby and cloud nodes snapshots'
|
||||
title: 'chore(website): refresh Ashby and cloud nodes snapshots'
|
||||
commit-message: 'chore(website): refresh Ashby roles snapshot'
|
||||
title: 'chore(website): refresh Ashby roles snapshot'
|
||||
body: |
|
||||
Automated refresh of remote-data snapshots used by the website
|
||||
build:
|
||||
|
||||
- `apps/website/src/data/ashby-roles.snapshot.json` — Ashby job
|
||||
board API
|
||||
- `apps/website/src/data/cloud-nodes.snapshot.json` — Comfy Cloud
|
||||
`/api/object_info`
|
||||
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
|
||||
from the Ashby job board API.
|
||||
|
||||
**Flow:**
|
||||
1. `Release: Website` workflow ran (manual trigger).
|
||||
2. This PR opens with the regenerated snapshots.
|
||||
2. This PR opens with the regenerated snapshot.
|
||||
3. `CI: Vercel Website Preview` deploys a preview for review.
|
||||
4. Merging to `main` triggers the production Vercel deploy.
|
||||
|
||||
The snapshot fallback in `apps/website/src/utils/ashby.ts` and
|
||||
`apps/website/src/utils/cloudNodes.ts` remains intact: builds
|
||||
without the respective API keys continue to use the committed
|
||||
snapshot (with a warning annotation in CI).
|
||||
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
|
||||
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
|
||||
committed snapshot.
|
||||
|
||||
Triggered by workflow run `${{ github.run_id }}`.
|
||||
branch: chore/refresh-website-snapshots-${{ github.run_id }}
|
||||
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
|
||||
base: main
|
||||
labels: |
|
||||
Release:Website
|
||||
|
||||
@@ -85,15 +85,6 @@
|
||||
"typescript/no-unused-vars": "off",
|
||||
"unicorn/no-empty-file": "off",
|
||||
"vitest/require-mock-type-parameters": "off",
|
||||
"vitest/consistent-each-for": [
|
||||
"error",
|
||||
{
|
||||
"test": "for",
|
||||
"it": "for",
|
||||
"describe": "for",
|
||||
"suite": "for"
|
||||
}
|
||||
],
|
||||
"unicorn/no-new-array": "off",
|
||||
"unicorn/no-single-promise-in-promise-methods": "off",
|
||||
"unicorn/no-useless-fallback-in-spread": "off",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<tr
|
||||
class="border-y border-solid border-neutral-700"
|
||||
class="border-neutral-700 border-solid border-y"
|
||||
:class="{
|
||||
'opacity-50': runner.resolved,
|
||||
'opacity-75': isLoading && runner.resolved
|
||||
}"
|
||||
>
|
||||
<td class="w-16 text-center">
|
||||
<td class="text-center w-16">
|
||||
<TaskListStatusIcon :state="runner.state" :loading="isLoading" />
|
||||
</td>
|
||||
<td>
|
||||
@@ -14,7 +14,7 @@
|
||||
{{ task.name }}
|
||||
</p>
|
||||
<Button
|
||||
class="mx-2 inline-block"
|
||||
class="inline-block mx-2"
|
||||
type="button"
|
||||
:icon="PrimeIcons.INFO_CIRCLE"
|
||||
severity="secondary"
|
||||
@@ -22,11 +22,11 @@
|
||||
@click="toggle"
|
||||
/>
|
||||
|
||||
<Popover ref="infoPopover" class="m-1 block max-w-64 min-w-32">
|
||||
<Popover ref="infoPopover" class="block m-1 max-w-64 min-w-32">
|
||||
<span class="whitespace-pre-line">{{ task.description }}</span>
|
||||
</Popover>
|
||||
</td>
|
||||
<td class="px-4 text-right">
|
||||
<td class="text-right px-4">
|
||||
<Button
|
||||
:icon="task.button?.icon"
|
||||
:label="task.button?.text"
|
||||
|
||||
@@ -9,7 +9,6 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' }
|
||||
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
|
||||
|
||||
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
|
||||
import { getDefaultLocale } from '@frontend-locales/localeConfig'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
function buildLocale<
|
||||
@@ -168,7 +167,7 @@ const messages: Record<string, LocaleMessages> = {
|
||||
export const i18n = createI18n({
|
||||
// Must set `false`, as Vue I18n Legacy API is for Vue 2
|
||||
legacy: false,
|
||||
locale: getDefaultLocale(),
|
||||
locale: navigator.language.split('-')[0] || 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
// Ignore warnings for locale options as each option is in its own language.
|
||||
|
||||
@@ -113,50 +113,6 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
|
||||
The script exits non-zero on any non-fresh outcome so stale/empty
|
||||
snapshots can't be accidentally committed.
|
||||
|
||||
## Cloud nodes integration
|
||||
|
||||
`/cloud/supported-nodes` (and `/zh-CN/`) lists custom-node packs preinstalled on Comfy Cloud, joined with public metadata from the [ComfyUI Custom Node Registry](https://registry.comfy.org) ([`api.comfy.org`](https://api.comfy.org)). See [`src/pages/cloud/supported-nodes/AGENTS.md`](src/pages/cloud/supported-nodes/AGENTS.md) for the build pipeline, source-file map, and key invariants.
|
||||
|
||||
Build-time env var: `WEBSITE_CLOUD_API_KEY` (Cloud `/api/object_info` auth; the build falls back to the committed snapshot when unset). Must also be set in the Vercel project environment.
|
||||
|
||||
### Production strictness
|
||||
|
||||
`src/utils/cloudNodes.build.ts` throws when `fetchCloudNodesForBuild()` returns
|
||||
`{ status: 'stale' }` **and** `process.env.VERCEL_ENV === 'production'`. This
|
||||
prevents the production deploy from silently shipping an out-of-date snapshot
|
||||
when the Cloud API is unreachable or `WEBSITE_CLOUD_API_KEY` is missing. Preview
|
||||
and local builds continue to use the committed snapshot with a warning
|
||||
annotation.
|
||||
|
||||
### Required GitHub Actions / Vercel secrets
|
||||
|
||||
| Name | Where | Purpose |
|
||||
| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `WEBSITE_CLOUD_API_KEY` | GitHub Actions repo secret + Vercel project env | Auth for Cloud `/api/object_info`. Required for fresh production data. |
|
||||
|
||||
The `Release: Website` workflow uses the GitHub Actions secret to regenerate
|
||||
`apps/website/src/data/cloud-nodes.snapshot.json` via
|
||||
`.github/actions/cloud-nodes-pull/action.yaml`. The Vercel environment value is
|
||||
read at build time by `vercel build` in `ci-vercel-website-preview.yaml`; the
|
||||
`deploy-production` job hard-fails before `vercel build --prod` if the secret
|
||||
is missing.
|
||||
|
||||
### Refreshing the snapshot
|
||||
|
||||
To update the committed snapshot manually (e.g. after onboarding new packs
|
||||
to Comfy Cloud):
|
||||
|
||||
```bash
|
||||
WEBSITE_CLOUD_API_KEY=… \
|
||||
pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
|
||||
git commit apps/website/src/data/cloud-nodes.snapshot.json
|
||||
```
|
||||
|
||||
The script exits non-zero on any non-fresh outcome so stale/empty snapshots
|
||||
can't be accidentally committed. Otherwise the `Release: Website` GitHub
|
||||
Actions workflow runs the same step on every manual dispatch and opens a PR
|
||||
with the refreshed snapshot.
|
||||
|
||||
## HubSpot contact form
|
||||
|
||||
The contact page uses HubSpot's hosted form embed for the interest form:
|
||||
@@ -190,4 +146,3 @@ renders the documented embed container.
|
||||
- `pnpm test:unit` — Vitest unit tests
|
||||
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
|
||||
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot
|
||||
- `pnpm cloud-nodes:refresh-snapshot` — refresh the committed cloud nodes snapshot
|
||||
|
||||
@@ -28,8 +28,7 @@ export default defineConfig({
|
||||
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
|
||||
'/customers/moment-factory/',
|
||||
'/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui':
|
||||
'/customers/series-entertainment/',
|
||||
'/zh-CN/terms-of-service': '/terms-of-service'
|
||||
'/customers/series-entertainment/'
|
||||
},
|
||||
build: {
|
||||
assets: '_website'
|
||||
|
||||
@@ -23,6 +23,15 @@ test.describe('Careers page @smoke', () => {
|
||||
expect(await roles.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('each role links to jobs.ashbyhq.com', async ({ page }) => {
|
||||
const roles = page.getByTestId('careers-role-link')
|
||||
const count = await roles.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const href = await roles.nth(i).getAttribute('href')
|
||||
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
|
||||
}
|
||||
})
|
||||
|
||||
test('clicking a department button scrolls to and activates that section', async ({
|
||||
page
|
||||
}) => {
|
||||
@@ -54,21 +63,6 @@ test.describe('Careers page @smoke', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Careers page role links', () => {
|
||||
test('each role links to the Ashby job description page, not the application form', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/careers')
|
||||
const roles = page.getByTestId('careers-role-link')
|
||||
const count = await roles.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const href = await roles.nth(i).getAttribute('href')
|
||||
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
|
||||
expect(href).not.toMatch(/\/application\/?$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Careers page (zh-CN) @smoke', () => {
|
||||
test('renders localized heading and roles', async ({ page }) => {
|
||||
await page.goto('/zh-CN/careers')
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Cloud nodes page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/cloud/supported-nodes')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(
|
||||
'Custom-node packs on Comfy Cloud — supported by default'
|
||||
)
|
||||
})
|
||||
|
||||
test('renders at least one pack card', async ({ page }) => {
|
||||
const cards = page.getByTestId('cloud-node-pack-card')
|
||||
await expect(cards.first()).toBeVisible()
|
||||
expect(await cards.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('search input filters cards down', async ({ page }) => {
|
||||
const cards = page.getByTestId('cloud-node-pack-card')
|
||||
const initialCount = await cards.count()
|
||||
|
||||
await page.getByTestId('cloud-nodes-search').fill('impact')
|
||||
const filteredCards = page.getByTestId('cloud-node-pack-card')
|
||||
await expect(filteredCards.first()).toBeVisible()
|
||||
const filteredCount = await filteredCards.count()
|
||||
|
||||
expect(filteredCount).toBeGreaterThan(0)
|
||||
expect(filteredCount).toBeLessThanOrEqual(initialCount)
|
||||
})
|
||||
|
||||
test('renders banner image or fallback element', async ({ page }) => {
|
||||
const banners = page.getByTestId('cloud-node-pack-banner')
|
||||
await expect(banners.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens pack detail page from first card', async ({ page }) => {
|
||||
const firstCard = page.getByTestId('cloud-node-pack-card').first()
|
||||
await expect(firstCard).toBeVisible()
|
||||
|
||||
await firstCard.locator('a').first().click()
|
||||
|
||||
await expect(page).toHaveURL(/\/cloud\/supported-nodes\/[a-z0-9-]+$/)
|
||||
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
|
||||
})
|
||||
|
||||
test('direct pack detail route renders node entries', async ({ page }) => {
|
||||
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
|
||||
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
|
||||
await expect(
|
||||
page.getByTestId('cloud-node-pack-detail-node').first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('search with no matches shows empty state', async ({ page }) => {
|
||||
await page
|
||||
.getByTestId('cloud-nodes-search')
|
||||
.fill('zzzz-this-pack-does-not-exist')
|
||||
await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(0)
|
||||
await expect(page.getByText('No matching packs')).toBeVisible()
|
||||
})
|
||||
|
||||
test('clearing search restores the full list', async ({ page }) => {
|
||||
const cards = page.getByTestId('cloud-node-pack-card')
|
||||
const initialCount = await cards.count()
|
||||
|
||||
await page.getByTestId('cloud-nodes-search').fill('impact')
|
||||
await expect(cards.first()).toBeVisible()
|
||||
|
||||
await page.getByTestId('cloud-nodes-search').fill('')
|
||||
await expect(cards).toHaveCount(initialCount)
|
||||
})
|
||||
|
||||
test('search matches against node display names, not just pack names', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.getByTestId('cloud-nodes-search').fill('FaceDetailer')
|
||||
await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(1)
|
||||
await expect(
|
||||
page.getByTestId('cloud-node-pack-card-link').first()
|
||||
).toContainText('Impact Pack')
|
||||
})
|
||||
|
||||
test('switching sort to A → Z reorders cards alphabetically', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.locator('#cloud-nodes-sort').selectOption('az')
|
||||
const firstName = await page
|
||||
.getByTestId('cloud-node-pack-card-link')
|
||||
.first()
|
||||
.textContent()
|
||||
expect(firstName?.trim().toLowerCase().charAt(0)).toMatch(/^[a-c]/)
|
||||
})
|
||||
|
||||
test('list grid carries a localized aria-label', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('list', {
|
||||
name: 'Custom-node packs supported on Comfy Cloud'
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking the back link returns to the index from a detail page', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
|
||||
await page.getByRole('link', { name: 'Back to all packs' }).click()
|
||||
await expect(page).toHaveURL(/\/cloud\/supported-nodes\/?$/)
|
||||
await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('detail page renders publisher and external repo link', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
|
||||
const repoLink = page.getByRole('link', {
|
||||
name: /github\.com\/ltdrdata\/ComfyUI-Impact-Pack/
|
||||
})
|
||||
await expect(repoLink).toBeVisible()
|
||||
await expect(repoLink).toHaveAttribute('rel', /noopener/)
|
||||
})
|
||||
|
||||
test('unknown pack slug 404s', async ({ page }) => {
|
||||
const response = await page.goto(
|
||||
'/cloud/supported-nodes/this-pack-does-not-exist'
|
||||
)
|
||||
expect(response?.status()).toBe(404)
|
||||
})
|
||||
|
||||
test('JSON-LD ItemList is emitted on the index page', async ({ page }) => {
|
||||
const jsonLd = page.locator('script[type="application/ld+json"]')
|
||||
const ldBlocks = await jsonLd.allTextContents()
|
||||
expect(ldBlocks.some((b) => b.includes('"@type":"ItemList"'))).toBeTruthy()
|
||||
})
|
||||
|
||||
test('JSON-LD payload escapes <-sequences', async ({ page }) => {
|
||||
const ldBlocks = await page
|
||||
.locator('script[type="application/ld+json"]')
|
||||
.allTextContents()
|
||||
for (const block of ldBlocks) {
|
||||
expect(block).not.toContain('</script')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cloud nodes page (zh-CN) @smoke', () => {
|
||||
test('renders localized title and packs', async ({ page }) => {
|
||||
await page.goto('/zh-CN/cloud/supported-nodes')
|
||||
await expect(page).toHaveTitle('Comfy Cloud 自定义节点包合集——开箱即用')
|
||||
await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible()
|
||||
await expect(
|
||||
page.getByTestId('cloud-node-pack-banner').first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens pack detail page from first card', async ({ page }) => {
|
||||
await page.goto('/zh-CN/cloud/supported-nodes')
|
||||
const firstCard = page.getByTestId('cloud-node-pack-card').first()
|
||||
await expect(firstCard).toBeVisible()
|
||||
|
||||
await firstCard.locator('a').first().click()
|
||||
|
||||
await expect(page).toHaveURL(/\/zh-CN\/cloud\/supported-nodes\/[a-z0-9-]+$/)
|
||||
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Pricing page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/cloud/pricing')
|
||||
})
|
||||
|
||||
test('shows the three paid tiers and Enterprise', async ({ page }) => {
|
||||
const pricingGrid = page
|
||||
.locator('section', {
|
||||
has: page.getByRole('heading', { name: /Pricing/i })
|
||||
})
|
||||
.locator('.lg\\:grid')
|
||||
|
||||
for (const label of ['STANDARD', 'CREATOR', 'PRO']) {
|
||||
await expect(
|
||||
pricingGrid.locator('span', { hasText: new RegExp(`^${label}$`) })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Looking for Enterprise Solutions/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('does not show the Free tier when SHOW_FREE_TIER is disabled', async ({
|
||||
page
|
||||
}) => {
|
||||
const pricingGrid = page
|
||||
.locator('section', {
|
||||
has: page.getByRole('heading', { name: /Pricing/i })
|
||||
})
|
||||
.locator('.lg\\:grid')
|
||||
|
||||
await expect(
|
||||
pricingGrid.locator('span', { hasText: /^FREE$/ })
|
||||
).toHaveCount(0)
|
||||
await expect(page.getByRole('link', { name: /^START FREE$/ })).toHaveCount(
|
||||
0
|
||||
)
|
||||
await expect(page.getByText(/Everything in Free, plus:/i)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cloud pricing teaser @smoke', () => {
|
||||
test('does not show the "Start free" tagline when SHOW_FREE_TIER is disabled', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/cloud')
|
||||
await expect(
|
||||
page.getByText(/Start free\.\s*Upgrade when you're ready\./i)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 99 KiB |
@@ -15,15 +15,11 @@
|
||||
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
|
||||
"test:visual": "playwright test --project visual",
|
||||
"test:visual:update": "playwright test --project visual --update-snapshots",
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
|
||||
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
|
||||
"generate:models": "tsx ./scripts/generate-models.ts"
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/object-info-parser": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@vercel/analytics": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
|
||||
|
Before Width: | Height: | Size: 81 KiB |
@@ -1,14 +0,0 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<style>
|
||||
.bg { fill: #000000; }
|
||||
.fg { fill: #F2FF59; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bg { fill: #F2FF59; }
|
||||
.fg { fill: #000000; }
|
||||
}
|
||||
</style>
|
||||
<circle class="bg" cx="24" cy="24" r="24"/>
|
||||
<g transform="translate(7.8 6.72) scale(0.72)">
|
||||
<path class="fg" d="M35.6487 36.021C35.733 35.7387 35.7791 35.4411 35.7791 35.1283C35.7791 33.3963 34.3675 31.9924 32.6262 31.9924H18.4956C17.7361 32 17.1147 31.3896 17.1147 30.6342C17.1147 30.4969 17.1377 30.3672 17.1684 30.2451L20.9734 17.0606C21.1345 16.4807 21.6715 16.0534 22.3005 16.0534L36.4848 16.0382C39.4766 16.0382 42.0005 14.0315 42.76 11.2923L44.8926 3.94468C44.9616 3.68526 45 3.40296 45 3.12065C45 1.39628 43.5961 0 41.8624 0L24.7017 0C21.7252 0 19.209 1.99142 18.4342 4.70005L16.992 9.71292C16.8232 10.2852 16.2939 10.7048 15.6648 10.7048H11.5453C8.59189 10.7048 6.0987 12.6581 5.30089 15.3362L0.11507 33.3505C0.0383566 33.6175 0 33.9075 0 34.1974C0 35.9294 1.41152 37.3333 3.15292 37.3333H7.20338C7.96284 37.3333 8.58421 37.9437 8.58421 38.7067C8.58421 38.8364 8.56887 38.9661 8.53051 39.0882L7.09598 44.0553C7.02694 44.3224 6.98091 44.597 6.98091 44.8794C6.98091 46.6037 8.38476 48 10.1185 48L27.2869 47.9847C30.2711 47.9847 32.7873 45.9857 33.5544 43.2618L35.641 36.0286L35.6487 36.021Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,396 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const WORKFLOW_TEMPLATES_BASE =
|
||||
'https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates'
|
||||
|
||||
const TEMPLATES_DIR = fileURLToPath(
|
||||
new URL('../../../../workflow_templates/templates', import.meta.url)
|
||||
)
|
||||
|
||||
const QUANT_SUFFIXES = [
|
||||
'_fp8_e4m3fn_scaled',
|
||||
'_fp8_e4m3fn',
|
||||
'_fp8_scaled',
|
||||
'_fp4_mixed',
|
||||
'_fp8mixed',
|
||||
'_fp8',
|
||||
'_fp16',
|
||||
'_fp4',
|
||||
'_bf16',
|
||||
'_int8'
|
||||
]
|
||||
|
||||
interface RawModel {
|
||||
name: string
|
||||
url: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
interface ModelData {
|
||||
url: string
|
||||
directory: string
|
||||
templates: Set<string>
|
||||
firstTemplate?: string
|
||||
}
|
||||
|
||||
interface OutputModel {
|
||||
slug: string
|
||||
name: string
|
||||
huggingFaceUrl: string
|
||||
directory: string
|
||||
workflowCount: number
|
||||
displayName: string
|
||||
docsUrl?: string
|
||||
thumbnailUrl?: string
|
||||
canonicalSlug?: string
|
||||
}
|
||||
|
||||
// Maps api_*.json filename prefix to a canonical display name and slug.
|
||||
// Add entries here as new partner integrations land in workflow_templates.
|
||||
const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
|
||||
nano: { name: 'Nano Banana', slug: 'nano-banana' },
|
||||
kling: { name: 'Kling AI', slug: 'kling-ai' },
|
||||
kling2: { name: 'Kling AI', slug: 'kling-ai' },
|
||||
meshy: { name: 'Meshy AI', slug: 'meshy-ai' },
|
||||
luma: { name: 'Luma Dream Machine', slug: 'luma-dream-machine' },
|
||||
runway: { name: 'Runway', slug: 'runway' },
|
||||
vidu: { name: 'Vidu', slug: 'vidu' },
|
||||
bfl: { name: 'Flux (API)', slug: 'flux-api' },
|
||||
grok: { name: 'Grok Image', slug: 'grok-image' },
|
||||
stability: { name: 'Stability AI', slug: 'stability-ai' },
|
||||
bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
|
||||
bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
|
||||
google: { name: 'Gemini Image', slug: 'gemini-image' },
|
||||
hailuo: { name: 'Hailuo MiniMax', slug: 'hailuo-minimax' },
|
||||
ideogram: { name: 'Ideogram', slug: 'ideogram' },
|
||||
pixverse: { name: 'Pixverse', slug: 'pixverse' },
|
||||
rodin: { name: 'Rodin 3D', slug: 'rodin-3d' },
|
||||
magnific: { name: 'Magnific AI', slug: 'magnific-ai' },
|
||||
bria: { name: 'Bria AI', slug: 'bria-ai' },
|
||||
tripo: { name: 'Tripo 3D', slug: 'tripo-3d' },
|
||||
tripo3: { name: 'Tripo 3D', slug: 'tripo-3d' },
|
||||
hunyuan3d: { name: 'Hunyuan 3D', slug: 'hunyuan-3d' },
|
||||
recraft: { name: 'Recraft', slug: 'recraft' },
|
||||
topaz: { name: 'Topaz Labs', slug: 'topaz-labs' },
|
||||
moonvalley: { name: 'Moonvalley', slug: 'moonvalley' },
|
||||
ltxv: { name: 'LTX Video (API)', slug: 'ltxv-api' },
|
||||
openai: { name: 'OpenAI DALL-E', slug: 'openai-dall-e' },
|
||||
wan: { name: 'Wan (API)', slug: 'wan-api' },
|
||||
wan2: { name: 'Wan (API)', slug: 'wan-api' },
|
||||
veo2: { name: 'Veo 2', slug: 'veo-2' },
|
||||
veo3: { name: 'Veo 3', slug: 'veo-3' },
|
||||
flux2: { name: 'Flux 2 (API)', slug: 'flux-2-api' },
|
||||
wavespeed: { name: 'Wavespeed', slug: 'wavespeed' },
|
||||
wavespped: { name: 'Wavespeed', slug: 'wavespeed' }
|
||||
}
|
||||
|
||||
function stripExt(name: string): string {
|
||||
return name.replace(/\.(safetensors|ckpt|pt|bin)$/, '')
|
||||
}
|
||||
|
||||
function stripQuant(base: string): string {
|
||||
for (const suffix of QUANT_SUFFIXES) {
|
||||
if (base.endsWith(suffix)) return base.slice(0, -suffix.length)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function makeSlug(name: string): string {
|
||||
const base = stripExt(name)
|
||||
return base
|
||||
.toLowerCase()
|
||||
.replace(/[_.]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function makeDisplayName(name: string): string {
|
||||
const base = stripExt(name)
|
||||
return base
|
||||
.split(/[_-]/)
|
||||
.map((part) => {
|
||||
if (/^(fp\d+|bf\d+|int\d+)$/i.test(part)) return part.toUpperCase()
|
||||
if (/^(e4m3fn|scaled|mixed|fp8mixed)$/i.test(part)) return part
|
||||
if (/^\d+(\.\d+)?[bBkKmM]?$/.test(part)) return part
|
||||
return part.charAt(0).toUpperCase() + part.slice(1)
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function extractModels(
|
||||
obj: unknown,
|
||||
templateName: string,
|
||||
models: Map<string, ModelData>
|
||||
): void {
|
||||
if (obj === null || typeof obj !== 'object') return
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) extractModels(item, templateName, models)
|
||||
return
|
||||
}
|
||||
|
||||
const record = obj as Record<string, unknown>
|
||||
|
||||
if (Array.isArray(record['models'])) {
|
||||
for (const m of record['models'] as unknown[]) {
|
||||
if (m === null || typeof m !== 'object' || Array.isArray(m)) continue
|
||||
const model = m as Record<string, unknown>
|
||||
if (typeof model['name'] !== 'string') continue
|
||||
|
||||
const name = model['name']
|
||||
const url = typeof model['url'] === 'string' ? model['url'] : ''
|
||||
const directory =
|
||||
typeof model['directory'] === 'string' ? model['directory'] : ''
|
||||
|
||||
if (!models.has(name)) {
|
||||
models.set(name, {
|
||||
url,
|
||||
directory,
|
||||
templates: new Set(),
|
||||
firstTemplate: templateName
|
||||
})
|
||||
}
|
||||
models.get(name)!.templates.add(templateName)
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
extractModels(value, templateName, models)
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiModelData {
|
||||
slug: string
|
||||
name: string
|
||||
directory: 'partner_nodes'
|
||||
templateCount: number
|
||||
}
|
||||
|
||||
function extractApiModels(files: string[]): ApiModelData[] {
|
||||
const counts = new Map<string, number>()
|
||||
for (const file of files) {
|
||||
if (!file.startsWith('api_')) continue
|
||||
const prefix = file.slice(4).split('_')[0]
|
||||
const entry = API_PROVIDER_MAP[prefix]
|
||||
if (!entry) continue
|
||||
counts.set(entry.slug, (counts.get(entry.slug) ?? 0) + 1)
|
||||
}
|
||||
return [...counts.entries()].map(([slug, count]) => {
|
||||
const found = Object.values(API_PROVIDER_MAP).find((e) => e.slug === slug)!
|
||||
return {
|
||||
slug,
|
||||
name: found.name,
|
||||
directory: 'partner_nodes' as const,
|
||||
templateCount: count
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Reads all locale index.json files to build a map of
|
||||
// raw model filename → tutorialUrl. Index entries name the template file;
|
||||
// that file's embedded model objects give the actual filenames.
|
||||
function buildTutorialUrlMap(templatesDir: string): Map<string, string> {
|
||||
const map = new Map<string, string>()
|
||||
const indexFiles = readdirSync(templatesDir).filter(
|
||||
(f) =>
|
||||
f.startsWith('index') &&
|
||||
f.endsWith('.json') &&
|
||||
!f.includes('schema') &&
|
||||
!f.includes('logo')
|
||||
)
|
||||
// Collect template-name → tutorialUrl from all locale indexes (first wins)
|
||||
const templateTutorialMap = new Map<string, string>()
|
||||
const sorted = ['index.json', ...indexFiles.filter((f) => f !== 'index.json')]
|
||||
for (const file of sorted) {
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(readFileSync(join(templatesDir, file), 'utf8'))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!Array.isArray(data)) continue
|
||||
for (const cat of data as unknown[]) {
|
||||
if (typeof cat !== 'object' || cat === null) continue
|
||||
const templates = (cat as Record<string, unknown>)['templates']
|
||||
if (!Array.isArray(templates)) continue
|
||||
for (const t of templates) {
|
||||
if (typeof t !== 'object' || t === null) continue
|
||||
const entry = t as Record<string, unknown>
|
||||
const tutorialUrl =
|
||||
typeof entry['tutorialUrl'] === 'string'
|
||||
? entry['tutorialUrl']
|
||||
: undefined
|
||||
const templateName =
|
||||
typeof entry['name'] === 'string' ? entry['name'] : undefined
|
||||
if (
|
||||
tutorialUrl &&
|
||||
templateName &&
|
||||
!templateTutorialMap.has(templateName)
|
||||
) {
|
||||
templateTutorialMap.set(templateName, tutorialUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For each template with a tutorialUrl, open the template file and map
|
||||
// every embedded model filename to that tutorialUrl
|
||||
for (const [templateName, tutorialUrl] of templateTutorialMap) {
|
||||
const filePath = join(templatesDir, `${templateName}.json`)
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(readFileSync(filePath, 'utf8'))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
function extractModelNames(obj: unknown): void {
|
||||
if (obj === null || typeof obj !== 'object') return
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) extractModelNames(item)
|
||||
return
|
||||
}
|
||||
const record = obj as Record<string, unknown>
|
||||
if (Array.isArray(record['models'])) {
|
||||
for (const m of record['models'] as unknown[]) {
|
||||
if (m === null || typeof m !== 'object' || Array.isArray(m)) continue
|
||||
const model = m as Record<string, unknown>
|
||||
if (typeof model['name'] === 'string' && !map.has(model['name'])) {
|
||||
map.set(model['name'], tutorialUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const value of Object.values(record)) {
|
||||
extractModelNames(value)
|
||||
}
|
||||
}
|
||||
|
||||
extractModelNames(data)
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
function templateThumbnailUrl(
|
||||
firstTemplate: string | undefined,
|
||||
templatesDir: string
|
||||
): string | undefined {
|
||||
if (!firstTemplate) return undefined
|
||||
const base = firstTemplate.replace(/\.json$/, '')
|
||||
const localPath = join(templatesDir, `${base}-1.webp`)
|
||||
if (!existsSync(localPath)) return undefined
|
||||
return `${WORKFLOW_TEMPLATES_BASE}/${encodeURIComponent(base)}-1.webp`
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
const models = new Map<string, ModelData>()
|
||||
|
||||
const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith('.json'))
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(TEMPLATES_DIR, file)
|
||||
try {
|
||||
const raw = readFileSync(filePath, 'utf8')
|
||||
const data: unknown = JSON.parse(raw)
|
||||
extractModels(data, file, models)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse ${file}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const apiModels = extractApiModels(files)
|
||||
const tutorialUrlMap = buildTutorialUrlMap(TEMPLATES_DIR)
|
||||
|
||||
const sorted = [...models.entries()].sort(
|
||||
([, a], [, b]) => b.templates.size - a.templates.size
|
||||
)
|
||||
|
||||
// Build quant convergence map
|
||||
const groups = new Map<string, Array<[string, ModelData]>>()
|
||||
for (const [name, data] of sorted) {
|
||||
const base = stripExt(name)
|
||||
const canonicalBase = stripQuant(base)
|
||||
if (!groups.has(canonicalBase)) groups.set(canonicalBase, [])
|
||||
groups.get(canonicalBase)!.push([name, data])
|
||||
}
|
||||
|
||||
const canonicalMap = new Map<string, string | null>()
|
||||
for (const members of groups.values()) {
|
||||
if (members.length > 1) {
|
||||
const membersSorted = [...members].sort(
|
||||
([, a], [, b]) => b.templates.size - a.templates.size
|
||||
)
|
||||
const canonicalName = membersSorted[0][0]
|
||||
canonicalMap.set(canonicalName, null)
|
||||
for (const [name] of membersSorted.slice(1)) {
|
||||
canonicalMap.set(name, canonicalName)
|
||||
}
|
||||
} else {
|
||||
canonicalMap.set(members[0][0], null)
|
||||
}
|
||||
}
|
||||
|
||||
const output: OutputModel[] = sorted.map(([name, data]) => {
|
||||
const canonicalRaw = canonicalMap.get(name) ?? null
|
||||
const result: OutputModel = {
|
||||
slug: makeSlug(name),
|
||||
name,
|
||||
huggingFaceUrl: data.url,
|
||||
directory: data.directory,
|
||||
workflowCount: data.templates.size,
|
||||
displayName: makeDisplayName(name)
|
||||
}
|
||||
const docsUrl = tutorialUrlMap.get(name)
|
||||
if (docsUrl) result.docsUrl = docsUrl
|
||||
const thumb = templateThumbnailUrl(data.firstTemplate, TEMPLATES_DIR)
|
||||
if (thumb) result.thumbnailUrl = thumb
|
||||
if (canonicalRaw !== null) {
|
||||
result.canonicalSlug = makeSlug(canonicalRaw)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const apiOutput: OutputModel[] = apiModels
|
||||
.sort((a, b) => b.templateCount - a.templateCount)
|
||||
.map((m) => ({
|
||||
slug: m.slug,
|
||||
name: m.name,
|
||||
huggingFaceUrl: '',
|
||||
directory: m.directory,
|
||||
workflowCount: m.templateCount,
|
||||
displayName: m.name
|
||||
}))
|
||||
|
||||
const combined = [...apiOutput, ...output]
|
||||
|
||||
const withThumbs = combined.filter((m) => m.thumbnailUrl).length
|
||||
process.stdout.write(
|
||||
` ${withThumbs}/${combined.length} models have thumbnails\n`
|
||||
)
|
||||
|
||||
const defaultOut = join(
|
||||
fileURLToPath(new URL('.', import.meta.url)),
|
||||
'../src/config/generated-models.json'
|
||||
)
|
||||
const outputArg = process.argv[2] ?? defaultOut
|
||||
const json = JSON.stringify(combined, null, 2) + '\n'
|
||||
|
||||
writeFileSync(outputArg, json, 'utf8')
|
||||
process.stdout.write(
|
||||
`Written ${combined.length} models ` +
|
||||
`(${apiOutput.length} partner, ${output.length} local) to ${outputArg}\n`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
run()
|
||||
} catch (err) {
|
||||
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { renameSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { fetchCloudNodesForBuild } from '../src/utils/cloudNodes'
|
||||
|
||||
const snapshotPath = fileURLToPath(
|
||||
new URL('../src/data/cloud-nodes.snapshot.json', import.meta.url)
|
||||
)
|
||||
const tempPath = `${snapshotPath}.tmp`
|
||||
|
||||
const outcome = await fetchCloudNodesForBuild()
|
||||
|
||||
if (outcome.status !== 'fresh') {
|
||||
const reason = 'reason' in outcome ? outcome.reason : '(none)'
|
||||
console.error(
|
||||
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(outcome.snapshot, null, 2) + '\n'
|
||||
|
||||
writeFileSync(tempPath, serialized, 'utf8')
|
||||
renameSync(tempPath, snapshotPath)
|
||||
|
||||
const totalNodes = outcome.snapshot.packs.reduce(
|
||||
(n, pack) => n + pack.nodes.length,
|
||||
0
|
||||
)
|
||||
process.stdout.write(
|
||||
`Wrote snapshot with ${outcome.snapshot.packs.length} pack(s) and ${totalNodes} node(s) to ${snapshotPath}\n`
|
||||
)
|
||||
@@ -130,7 +130,7 @@ function scrollToDepartment(deptKey: string) {
|
||||
<a
|
||||
v-for="role in dept.roles"
|
||||
:key="role.id"
|
||||
:href="role.jobUrl"
|
||||
:href="role.applyUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{
|
||||
locale?: Locale
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 pt-24 pb-14 md:px-20 md:pt-32 md:pb-20">
|
||||
<div class="mx-auto flex w-full max-w-6xl flex-col gap-7">
|
||||
<SectionLabel>
|
||||
{{ t('cloudNodes.hero.label', locale) }}
|
||||
</SectionLabel>
|
||||
|
||||
<h1
|
||||
class="text-primary-comfy-canvas max-w-4xl text-4xl/tight md:text-6xl"
|
||||
>
|
||||
{{ t('cloudNodes.hero.heading', locale) }}
|
||||
</h1>
|
||||
|
||||
<p class="text-primary-warm-gray max-w-3xl text-base/relaxed md:text-lg">
|
||||
{{ t('cloudNodes.hero.body', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,49 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { PackNode } from '../../data/cloudNodes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { useNodesByCategory } from '../../composables/useNodesByCategory'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en', nodes } = defineProps<{
|
||||
locale?: Locale
|
||||
nodes: readonly PackNode[]
|
||||
}>()
|
||||
|
||||
const { groupedNodes } = useNodesByCategory(() => nodes)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<details
|
||||
class="group border-primary-warm-gray/20 rounded-2xl border px-4 py-3"
|
||||
>
|
||||
<summary
|
||||
class="text-primary-comfy-canvas cursor-pointer list-none text-sm font-semibold"
|
||||
>
|
||||
{{ t('cloudNodes.card.nodesHeading', locale) }}
|
||||
</summary>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-5">
|
||||
<div
|
||||
v-for="group in groupedNodes"
|
||||
:key="group.category"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<h4
|
||||
class="text-primary-warm-gray text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ group.category }}
|
||||
</h4>
|
||||
<ul class="flex flex-col gap-1">
|
||||
<li
|
||||
v-for="node in group.nodes"
|
||||
:key="node.name"
|
||||
class="text-primary-comfy-canvas text-sm/relaxed"
|
||||
>
|
||||
{{ node.displayName }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
@@ -1,52 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useBannerImage } from '../../composables/useBannerImage'
|
||||
|
||||
const { bannerUrl, iconUrl, name } = defineProps<{
|
||||
bannerUrl?: string
|
||||
iconUrl?: string
|
||||
name: string
|
||||
}>()
|
||||
|
||||
const {
|
||||
DEFAULT_BANNER,
|
||||
isImageError,
|
||||
showDefaultBanner,
|
||||
imgSrc,
|
||||
onImageError
|
||||
} = useBannerImage({
|
||||
bannerUrl: () => bannerUrl,
|
||||
iconUrl: () => iconUrl
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="z-0 aspect-7/3 w-full overflow-hidden"
|
||||
data-testid="cloud-node-pack-banner"
|
||||
>
|
||||
<div v-if="showDefaultBanner" class="size-full">
|
||||
<img
|
||||
:src="DEFAULT_BANNER"
|
||||
:alt="`${name} banner`"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="relative size-full">
|
||||
<div
|
||||
v-if="imgSrc && !isImageError"
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
|
||||
:style="{ backgroundImage: `url(${imgSrc})`, filter: 'blur(10px)' }"
|
||||
/>
|
||||
<img
|
||||
:src="isImageError ? DEFAULT_BANNER : imgSrc"
|
||||
:alt="`${name} banner`"
|
||||
:class="
|
||||
isImageError
|
||||
? 'relative z-10 size-full object-cover'
|
||||
: 'relative z-10 size-full object-contain'
|
||||
"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,80 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Pack } from '../../data/cloudNodes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import NodeList from './NodeList.vue'
|
||||
import PackBanner from './PackBanner.vue'
|
||||
|
||||
const { locale = 'en', pack } = defineProps<{
|
||||
locale?: Locale
|
||||
pack: Pack
|
||||
}>()
|
||||
|
||||
const detailHref =
|
||||
locale === 'zh-CN'
|
||||
? `/zh-CN/cloud/supported-nodes/${pack.id}`
|
||||
: `/cloud/supported-nodes/${pack.id}`
|
||||
|
||||
function nodeCountLabel(nodeCount: number): string {
|
||||
const key =
|
||||
new Intl.PluralRules(locale).select(nodeCount) === 'one'
|
||||
? 'cloudNodes.card.nodeCountOne'
|
||||
: 'cloudNodes.card.nodeCountOther'
|
||||
return t(key, locale).replace('{count}', String(nodeCount))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="bg-transparency-white-t5 border-primary-warm-gray/20 flex h-full flex-col overflow-hidden rounded-3xl border"
|
||||
data-testid="cloud-node-pack-card"
|
||||
>
|
||||
<PackBanner
|
||||
:banner-url="pack.bannerUrl"
|
||||
:icon-url="pack.iconUrl"
|
||||
:name="pack.displayName"
|
||||
/>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-5 p-5 md:p-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-primary-comfy-canvas text-2xl/tight font-semibold">
|
||||
<a
|
||||
:href="detailHref"
|
||||
class="hover:text-primary-comfy-yellow"
|
||||
data-testid="cloud-node-pack-card-link"
|
||||
>
|
||||
{{ pack.displayName }}
|
||||
</a>
|
||||
</h3>
|
||||
<p class="text-primary-warm-gray text-sm/relaxed">
|
||||
{{
|
||||
pack.description ||
|
||||
t('cloudNodes.card.unavailableDescription', locale)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm">
|
||||
<a
|
||||
v-if="pack.repoUrl"
|
||||
:href="pack.repoUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 font-semibold underline"
|
||||
>
|
||||
{{ t('cloudNodes.card.viewRepo', locale) }}
|
||||
</a>
|
||||
<span v-else class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.card.viewRepo', locale) }}
|
||||
</span>
|
||||
<span class="text-primary-warm-gray">•</span>
|
||||
<span class="text-primary-comfy-canvas">{{
|
||||
nodeCountLabel(pack.nodes.length)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<NodeList :locale="locale" :nodes="pack.nodes" class="mt-auto" />
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -1,203 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Pack } from '../../data/cloudNodes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import {
|
||||
formatLocalizedMediumDate,
|
||||
formatLocalizedNumber
|
||||
} from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
|
||||
import { useNodesByCategory } from '../../composables/useNodesByCategory'
|
||||
import { t } from '../../i18n/translations'
|
||||
import PackBanner from './PackBanner.vue'
|
||||
|
||||
const { pack, locale = 'en' } = defineProps<{
|
||||
pack: Pack
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const backHref =
|
||||
locale === 'zh-CN' ? '/zh-CN/cloud/supported-nodes' : '/cloud/supported-nodes'
|
||||
|
||||
const { groupedNodes } = useNodesByCategory(() => pack.nodes)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="px-6 pb-20 md:px-20 md:pb-28"
|
||||
data-testid="cloud-node-pack-detail"
|
||||
>
|
||||
<div class="mx-auto flex w-full max-w-6xl flex-col gap-8">
|
||||
<a
|
||||
:href="backHref"
|
||||
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 w-fit text-sm font-semibold underline"
|
||||
>
|
||||
{{ t('cloudNodes.detail.back', locale) }}
|
||||
</a>
|
||||
|
||||
<div
|
||||
class="bg-transparency-white-t5 border-primary-warm-gray/20 overflow-hidden rounded-3xl border"
|
||||
>
|
||||
<PackBanner
|
||||
:banner-url="pack.bannerUrl"
|
||||
:icon-url="pack.iconUrl"
|
||||
:name="pack.displayName"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-7 p-5 md:p-6">
|
||||
<header class="flex flex-col gap-2">
|
||||
<h1
|
||||
class="text-primary-comfy-canvas text-3xl/tight font-semibold md:text-4xl"
|
||||
>
|
||||
{{ pack.displayName }}
|
||||
</h1>
|
||||
<p
|
||||
class="text-primary-warm-gray text-sm/relaxed md:text-base/relaxed"
|
||||
>
|
||||
{{
|
||||
pack.description ||
|
||||
t('cloudNodes.card.unavailableDescription', locale)
|
||||
}}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<dl class="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.card.viewRepo', locale) }}
|
||||
</dt>
|
||||
<dd>
|
||||
<a
|
||||
v-if="pack.repoUrl"
|
||||
:href="pack.repoUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 font-semibold underline"
|
||||
>
|
||||
{{ pack.repoUrl }}
|
||||
</a>
|
||||
<span v-else class="text-primary-comfy-canvas">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.publisher', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ pack.publisher?.name || pack.publisher?.id || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.downloads', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ formatLocalizedNumber(pack.downloads, locale) }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.stars', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ formatLocalizedNumber(pack.githubStars, locale) }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.latestVersion', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ pack.latestVersion || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.license', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ pack.license || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.lastUpdated', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ formatLocalizedMediumDate(pack.lastUpdated, locale) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section class="flex flex-col gap-4">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-xl font-semibold md:text-2xl"
|
||||
>
|
||||
{{ t('cloudNodes.detail.nodesHeading', locale) }}
|
||||
</h2>
|
||||
|
||||
<section
|
||||
v-for="group in groupedNodes"
|
||||
:key="group.category"
|
||||
class="border-primary-warm-gray/20 rounded-2xl border p-4"
|
||||
>
|
||||
<h3 class="text-primary-comfy-canvas text-base font-semibold">
|
||||
{{ group.category }}
|
||||
</h3>
|
||||
<ul class="mt-3 flex flex-col gap-3">
|
||||
<li
|
||||
v-for="node in group.nodes"
|
||||
:key="node.name"
|
||||
class="border-primary-warm-gray/20 rounded-xl border p-3"
|
||||
data-testid="cloud-node-pack-detail-node"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="text-primary-comfy-canvas text-sm font-semibold"
|
||||
>
|
||||
{{ node.displayName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="node.experimental"
|
||||
:class="
|
||||
cn(
|
||||
'bg-primary-comfy-yellow/20 text-primary-comfy-canvas rounded-full px-2 py-0.5 text-xs font-semibold'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ t('cloudNodes.detail.experimental', locale) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="node.deprecated"
|
||||
:class="
|
||||
cn(
|
||||
'text-primary-comfy-canvas rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-semibold'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ t('cloudNodes.detail.deprecated', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="node.description"
|
||||
class="text-primary-warm-gray mt-2 text-sm/relaxed"
|
||||
>
|
||||
{{ node.description }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -1,113 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Pack } from '../../data/cloudNodes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { useFilteredPacks } from '../../composables/useFilteredPacks'
|
||||
import type { PackSortMode } from '../../composables/useFilteredPacks'
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
import PackCard from './PackCard.vue'
|
||||
|
||||
const { locale = 'en', packs } = defineProps<{
|
||||
locale?: Locale
|
||||
packs: readonly Pack[]
|
||||
}>()
|
||||
|
||||
const query = defineModel<string>('query', { default: '' })
|
||||
const sortMode = ref<PackSortMode>('downloads')
|
||||
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs: () => packs,
|
||||
query,
|
||||
sortMode
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 pb-20 md:px-20 md:pb-32">
|
||||
<div class="mx-auto flex w-full max-w-6xl flex-col gap-8">
|
||||
<div class="flex flex-col gap-3">
|
||||
<SectionLabel>
|
||||
{{ t('cloudNodes.hero.label', locale) }}
|
||||
</SectionLabel>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3xl/tight font-medium md:text-4xl"
|
||||
>
|
||||
{{ t('cloudNodes.section.heading', locale) }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<label for="cloud-nodes-search" class="sr-only">
|
||||
{{ t('cloudNodes.search.label', locale) }}
|
||||
</label>
|
||||
<input
|
||||
id="cloud-nodes-search"
|
||||
v-model="query"
|
||||
:placeholder="t('cloudNodes.search.placeholder', locale)"
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t5 border-primary-warm-gray/30 text-primary-comfy-canvas placeholder:text-primary-warm-gray/80 w-full rounded-2xl border px-4 py-3 text-sm md:max-w-md'
|
||||
)
|
||||
"
|
||||
data-testid="cloud-nodes-search"
|
||||
/>
|
||||
|
||||
<label for="cloud-nodes-sort" class="sr-only">
|
||||
{{ t('cloudNodes.sort.label', locale) }}
|
||||
</label>
|
||||
<select
|
||||
id="cloud-nodes-sort"
|
||||
v-model="sortMode"
|
||||
class="bg-transparency-white-t5 border-primary-warm-gray/30 text-primary-comfy-canvas w-full appearance-none rounded-2xl border bg-size-[0.65rem_0.65rem] bg-position-[right_1rem_center] bg-no-repeat py-3 pr-12 pl-4 text-sm md:w-64"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'url(\'data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 12 12%22 fill=%22%23a39b8d%22><path d=%22M6 9.2L1.4 4.6 2.8 3.2 6 6.4l3.2-3.2 1.4 1.4z%22/></svg>\')'
|
||||
}"
|
||||
>
|
||||
<option value="downloads">
|
||||
{{ t('cloudNodes.sort.downloads', locale) }}
|
||||
</option>
|
||||
<option value="mostNodes">
|
||||
{{ t('cloudNodes.sort.mostNodes', locale) }}
|
||||
</option>
|
||||
<option value="az">{{ t('cloudNodes.sort.az', locale) }}</option>
|
||||
<option value="recentlyUpdated">
|
||||
{{ t('cloudNodes.sort.recentlyUpdated', locale) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="filteredPacks.length === 0"
|
||||
class="text-primary-warm-gray rounded-2xl border border-dashed border-current/30 px-5 py-6 text-sm"
|
||||
>
|
||||
<span class="text-primary-comfy-canvas block text-base font-semibold">
|
||||
{{ t('cloudNodes.empty.heading', locale) }}
|
||||
</span>
|
||||
<span class="mt-2 block">{{ t('cloudNodes.empty.body', locale) }}</span>
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-1 gap-5 md:grid-cols-2"
|
||||
role="list"
|
||||
:aria-label="t('cloudNodes.list.ariaLabel', locale)"
|
||||
>
|
||||
<PackCard
|
||||
v-for="pack in filteredPacks"
|
||||
:key="pack.id"
|
||||
:pack="pack"
|
||||
:locale="locale"
|
||||
role="listitem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -82,7 +82,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
|
||||
]
|
||||
}
|
||||
|
||||
const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
const contactColumn = {
|
||||
title: t('footer.contact', locale),
|
||||
links: [
|
||||
{ label: t('footer.sales', locale), href: routes.contact },
|
||||
@@ -91,11 +91,6 @@ const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
href: externalLinks.support,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('footer.cloudStatus', locale),
|
||||
href: externalLinks.cloudStatus,
|
||||
external: true
|
||||
},
|
||||
{ label: t('footer.press', locale), href: 'mailto:press@comfy.org' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { GalleryItem } from '../gallery/GallerySection.vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import GalleryCard from '../gallery/GalleryCard.vue'
|
||||
import GalleryDetailModal from '../gallery/GalleryDetailModal.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const modelName = 'Grok'
|
||||
const ctaHref = 'https://comfy.org/workflows/model/grok'
|
||||
|
||||
const items: GalleryItem[] = [
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
|
||||
title: 'Until Our Eye Interlink harajuku',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'ThinkDiffusion',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.thinkdiffusion.com/studio#success-stories-anta'
|
||||
},
|
||||
{
|
||||
image: 'https://media.comfy.org/website/gallery/gallery.webp',
|
||||
title: 'Amber Astronaut',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: '',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/cigarette.webm',
|
||||
title: 'Autopoiesis',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: 'Visual Frisson',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.instagram.com/visualfrisson/?hl=en'
|
||||
},
|
||||
{
|
||||
video: 'https://media.comfy.org/videos/compressed_512/kyrie.webm',
|
||||
title: 'Origins',
|
||||
userAlias: 'ShaneF Motion Design',
|
||||
teamAlias: 'ThinkDiffusion',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://vimeo.com/1021360563'
|
||||
},
|
||||
{
|
||||
image: 'https://media.comfy.org/website/gallery/desert.webp',
|
||||
title: 'Desert Landing',
|
||||
userAlias: 'Yogo',
|
||||
teamAlias: '',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
|
||||
}
|
||||
]
|
||||
|
||||
const modalOpen = ref(false)
|
||||
const modalIndex = ref(0)
|
||||
|
||||
function openDetail(index: number) {
|
||||
modalIndex.value = index
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
const title = t('models.list.creations.title', locale).replace(
|
||||
'{name}',
|
||||
modelName
|
||||
)
|
||||
const ctaLabel = t('models.list.creations.cta', locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
data-testid="model-creations"
|
||||
class="flex flex-col items-center px-4 pt-12 pb-20 lg:px-20"
|
||||
>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas max-w-4xl text-center text-3xl font-light tracking-tight lg:text-5xl"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<BrandButton
|
||||
:href="ctaHref"
|
||||
variant="solid"
|
||||
size="lg"
|
||||
class="mt-16 px-8 py-4 uppercase"
|
||||
>
|
||||
{{ ctaLabel }}
|
||||
</BrandButton>
|
||||
|
||||
<div class="mt-20 hidden w-full flex-col gap-2 lg:flex">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<GalleryCard
|
||||
v-for="(item, i) in items.slice(0, 2)"
|
||||
:key="i"
|
||||
:item
|
||||
:locale
|
||||
@click="openDetail(i)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="items.length > 2" class="grid grid-cols-3 gap-2">
|
||||
<GalleryCard
|
||||
v-for="(item, i) in items.slice(2, 5)"
|
||||
:key="i + 2"
|
||||
:item
|
||||
:locale
|
||||
@click="openDetail(i + 2)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-5xl bg-transparency-white-t4 mt-12 flex w-full flex-col gap-6 p-2 lg:hidden"
|
||||
>
|
||||
<GalleryCard
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
:item
|
||||
:locale
|
||||
mobile
|
||||
@click="openDetail(i)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GalleryDetailModal
|
||||
v-if="modalOpen"
|
||||
:items
|
||||
:initial-index="modalIndex"
|
||||
:locale
|
||||
@close="modalOpen = false"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,137 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
displayName,
|
||||
huggingFaceUrl,
|
||||
docsUrl,
|
||||
blogUrl,
|
||||
hubSlug,
|
||||
workflowCount,
|
||||
directory
|
||||
} = defineProps<{
|
||||
displayName: string
|
||||
huggingFaceUrl: string
|
||||
docsUrl?: string
|
||||
blogUrl?: string
|
||||
hubSlug?: string
|
||||
workflowCount: number
|
||||
directory: string
|
||||
}>()
|
||||
|
||||
const workflowsUrl = hubSlug
|
||||
? `https://www.comfy.org/workflows/model/${hubSlug}`
|
||||
: null
|
||||
|
||||
const dirDisplayMap: Record<string, string> = {
|
||||
diffusion_models: 'Diffusion Model',
|
||||
checkpoints: 'Checkpoint',
|
||||
loras: 'LoRA',
|
||||
controlnet: 'ControlNet',
|
||||
clip_vision: 'CLIP Vision',
|
||||
model_patches: 'Model Patch',
|
||||
vae: 'VAE',
|
||||
text_encoders: 'Text Encoder',
|
||||
audio_encoders: 'Audio Encoder',
|
||||
latent_upscale_models: 'Latent Upscale Model',
|
||||
upscale_models: 'Upscale Model',
|
||||
style_models: 'Style Model',
|
||||
partner_nodes: 'Partner Node'
|
||||
}
|
||||
|
||||
const eyebrow = dirDisplayMap[directory] ?? directory
|
||||
const isPartnerNode = directory === 'partner_nodes'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="
|
||||
cn(
|
||||
'mx-auto flex max-w-7xl flex-col gap-8 px-6 py-16',
|
||||
'lg:flex-row lg:items-center lg:gap-16 lg:px-8 lg:py-24'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex max-w-2xl flex-1 flex-col gap-6">
|
||||
<p
|
||||
class="text-sm font-medium uppercase tracking-widest text-primary-comfy-yellow"
|
||||
>
|
||||
{{ eyebrow }}
|
||||
</p>
|
||||
|
||||
<h1 class="text-4xl font-bold text-primary-comfy-canvas lg:text-6xl">
|
||||
{{ displayName }} in ComfyUI
|
||||
</h1>
|
||||
|
||||
<p class="text-sm text-primary-comfy-canvas/60">
|
||||
{{
|
||||
t('models.hero.workflowCount').replace(
|
||||
'{count}',
|
||||
String(workflowCount)
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<BrandButton
|
||||
v-if="workflowsUrl"
|
||||
:href="workflowsUrl"
|
||||
variant="solid"
|
||||
size="lg"
|
||||
class="w-full uppercase sm:w-auto sm:min-w-48"
|
||||
>
|
||||
{{ t('models.hero.primaryCta') }}
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
v-if="!isPartnerNode && huggingFaceUrl"
|
||||
:href="huggingFaceUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:variant="workflowsUrl ? 'outline' : 'solid'"
|
||||
size="lg"
|
||||
class="w-full uppercase sm:w-auto sm:min-w-48"
|
||||
>
|
||||
{{ t('models.hero.secondaryCta') }}
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
v-if="!workflowsUrl"
|
||||
href="https://www.comfy.org/cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:variant="huggingFaceUrl && !isPartnerNode ? 'outline' : 'solid'"
|
||||
size="lg"
|
||||
class="w-full uppercase sm:w-auto sm:min-w-48"
|
||||
>
|
||||
{{ t('models.hero.cloudCta') }}
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
v-if="docsUrl"
|
||||
:href="docsUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="w-full uppercase sm:w-auto sm:min-w-48"
|
||||
>
|
||||
{{ t('models.hero.tutorialCta') }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
|
||||
<div v-if="blogUrl" class="text-sm text-primary-comfy-canvas/60">
|
||||
<a
|
||||
:href="blogUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-primary-comfy-canvas underline"
|
||||
>
|
||||
{{ t('models.hero.blogLink') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,50 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
modelName,
|
||||
ctaHref,
|
||||
mediaSrc,
|
||||
mediaAlt = ''
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
modelName: string
|
||||
ctaHref: string
|
||||
mediaSrc: string
|
||||
mediaAlt?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col items-center px-6 pt-36 pb-16 text-center">
|
||||
<h1
|
||||
class="text-primary-comfy-canvas max-w-4xl text-4xl font-light tracking-tight lg:text-6xl"
|
||||
>
|
||||
{{ modelName }} in <span class="text-primary-comfy-yellow">ComfyUI</span>
|
||||
</h1>
|
||||
<p
|
||||
class="text-primary-comfy-canvas mt-6 max-w-2xl text-sm text-pretty lg:text-base"
|
||||
>
|
||||
{{ t('hero.subtitle', locale) }}
|
||||
</p>
|
||||
<BrandButton
|
||||
:href="ctaHref"
|
||||
variant="solid"
|
||||
size="lg"
|
||||
class="mt-10 px-8 py-4 uppercase"
|
||||
>
|
||||
{{ t('models.list.heroCta', locale).replace('{name}', modelName) }}
|
||||
</BrandButton>
|
||||
<div class="mt-16 w-full max-w-5xl">
|
||||
<img
|
||||
:src="mediaSrc"
|
||||
:alt="mediaAlt"
|
||||
class="rounded-4.5xl size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,114 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import ShowcaseCard from './ShowcaseCard.vue'
|
||||
|
||||
export type ModelCard = {
|
||||
titleKey: TranslationKey
|
||||
slug: string
|
||||
imageSrc: string
|
||||
badgeIcon?: string
|
||||
badgeText?: string
|
||||
layoutClass: string
|
||||
objectPosition?: string
|
||||
}
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const routes = getRoutes(locale)
|
||||
|
||||
const modelCards: ModelCard[] = [
|
||||
{
|
||||
titleKey: 'models.showcase.card.grokImagine',
|
||||
slug: 'grok-image',
|
||||
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/grok-video.webm',
|
||||
badgeIcon: '/icons/ai-models/grok.svg',
|
||||
layoutClass: 'lg:col-span-6'
|
||||
},
|
||||
{
|
||||
titleKey: 'models.showcase.card.nanoBananaPro',
|
||||
slug: 'nano-banana',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/nano-banana-pro.webp',
|
||||
badgeIcon: '/icons/ai-models/gemini.svg',
|
||||
layoutClass: 'lg:col-span-6',
|
||||
objectPosition: 'center 20%'
|
||||
},
|
||||
{
|
||||
titleKey: 'models.showcase.card.ltx23',
|
||||
slug: 'ltxv-api',
|
||||
imageSrc: 'https://media.comfy.org/website/gallery/desert.webp',
|
||||
badgeText: 'ltx',
|
||||
layoutClass: 'lg:col-span-4'
|
||||
},
|
||||
{
|
||||
titleKey: 'models.showcase.card.qwenAdvancedEdit',
|
||||
slug: 'qwen-image-fp8-e4m3fn',
|
||||
imageSrc:
|
||||
'https://media.comfy.org/website/cloud/ai-models/qwen-image-edit.webp',
|
||||
badgeIcon: '/icons/ai-models/qwen.svg',
|
||||
layoutClass: 'lg:col-span-4'
|
||||
},
|
||||
{
|
||||
titleKey: 'models.showcase.card.wan22TextToVideo',
|
||||
slug: 'wan-api',
|
||||
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/wan-22.webm',
|
||||
badgeIcon: '/icons/ai-models/wan.svg',
|
||||
layoutClass: 'lg:col-span-4'
|
||||
}
|
||||
]
|
||||
|
||||
function modelHref(slug: string): string {
|
||||
return `${routes.models}/${slug}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-4 py-24 lg:px-20 lg:py-40">
|
||||
<div class="mx-auto flex w-full max-w-7xl flex-col items-center">
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-center text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('models.showcase.label', locale) }}
|
||||
</p>
|
||||
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3.5xl/tight mt-8 max-w-4xl text-center font-light whitespace-pre-line lg:text-5xl"
|
||||
>
|
||||
{{ t('models.showcase.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas mt-8 max-w-xl text-center text-sm font-light lg:text-base/snug"
|
||||
>
|
||||
{{ t('models.showcase.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
<div class="mt-24 w-full">
|
||||
<div class="bg-transparency-white-t4 rounded-4xl p-2 lg:p-1.5">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
|
||||
<ShowcaseCard
|
||||
v-for="card in modelCards"
|
||||
:key="card.titleKey"
|
||||
:card="card"
|
||||
:href="modelHref(card.slug)"
|
||||
:locale="locale"
|
||||
:class="card.layoutClass"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BrandButton
|
||||
:href="externalLinks.workflows"
|
||||
variant="outline"
|
||||
class="mt-8 w-full max-w-md text-center lg:w-auto"
|
||||
>
|
||||
{{ t('models.showcase.cta', locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,96 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { ModelCard } from './ModelsShowcaseSection.vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
card,
|
||||
href,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
card: ModelCard
|
||||
href: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const badgeBase =
|
||||
'bg-white/20 text-white backdrop-blur-sm transition-colors duration-300 ease-in-out group-hover:bg-primary-comfy-yellow group-hover:text-primary-comfy-ink'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
:href="href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group relative h-80 cursor-pointer overflow-hidden rounded-4xl bg-black/40"
|
||||
>
|
||||
<video
|
||||
v-if="card.imageSrc.endsWith('.webm')"
|
||||
:src="card.imageSrc"
|
||||
:aria-label="t(card.titleKey, locale)"
|
||||
:style="
|
||||
card.objectPosition
|
||||
? { objectPosition: card.objectPosition }
|
||||
: undefined
|
||||
"
|
||||
class="size-full object-cover transition-transform duration-600 ease-in-out group-hover:scale-105"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="card.imageSrc"
|
||||
:alt="t(card.titleKey, locale)"
|
||||
:style="
|
||||
card.objectPosition
|
||||
? { objectPosition: card.objectPosition }
|
||||
: undefined
|
||||
"
|
||||
class="size-full object-cover transition-transform duration-600 ease-in-out group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 bg-linear-to-t from-black/50 via-black/5 to-black/35"
|
||||
/>
|
||||
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'absolute top-5 right-5 flex h-12 min-w-12 items-center justify-center px-3 lg:top-6 lg:right-6',
|
||||
badgeBase,
|
||||
'rounded-2xl'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-if="card.badgeIcon"
|
||||
class="inline-block size-6 bg-current"
|
||||
:style="{
|
||||
maskImage: `url(${card.badgeIcon})`,
|
||||
maskSize: 'contain',
|
||||
maskRepeat: 'no-repeat',
|
||||
maskPosition: 'center'
|
||||
}"
|
||||
/>
|
||||
<span
|
||||
v-else-if="card.badgeText"
|
||||
class="text-xs font-bold tracking-wider lowercase"
|
||||
>
|
||||
{{ card.badgeText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-primary-warm-white absolute inset-x-6 bottom-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
|
||||
>
|
||||
{{ t(card.titleKey, locale) }}
|
||||
</p>
|
||||
</a>
|
||||
</template>
|
||||
@@ -7,7 +7,6 @@ import { ref } from 'vue'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
|
||||
import PricingTierCard from './PricingTierCard.vue'
|
||||
import { SHOW_FREE_TIER } from '../../config/features'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
@@ -38,23 +37,21 @@ interface PricingPlan {
|
||||
isEnterprise?: boolean
|
||||
}
|
||||
|
||||
const freePlan: PricingPlan = {
|
||||
id: 'free',
|
||||
labelKey: 'pricing.plan.free.label',
|
||||
summaryKey: 'pricing.plan.free.summary',
|
||||
priceKey: 'pricing.plan.free.price',
|
||||
creditsKey: 'pricing.plan.free.credits',
|
||||
estimateKey: 'pricing.plan.free.estimate',
|
||||
ctaKey: 'pricing.plan.free.cta',
|
||||
ctaHref: externalLinks.cloud,
|
||||
features: [
|
||||
{ text: 'pricing.plan.free.feature1' },
|
||||
{ text: 'pricing.plan.free.feature2' }
|
||||
]
|
||||
}
|
||||
|
||||
const plans: PricingPlan[] = [
|
||||
...(SHOW_FREE_TIER ? [freePlan] : []),
|
||||
{
|
||||
id: 'free',
|
||||
labelKey: 'pricing.plan.free.label',
|
||||
summaryKey: 'pricing.plan.free.summary',
|
||||
priceKey: 'pricing.plan.free.price',
|
||||
creditsKey: 'pricing.plan.free.credits',
|
||||
estimateKey: 'pricing.plan.free.estimate',
|
||||
ctaKey: 'pricing.plan.free.cta',
|
||||
ctaHref: externalLinks.cloud,
|
||||
features: [
|
||||
{ text: 'pricing.plan.free.feature1' },
|
||||
{ text: 'pricing.plan.free.feature2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'standard',
|
||||
labelKey: 'pricing.plan.standard.label',
|
||||
@@ -64,9 +61,7 @@ const plans: PricingPlan[] = [
|
||||
estimateKey: 'pricing.plan.standard.estimate',
|
||||
ctaKey: 'pricing.plan.standard.cta',
|
||||
ctaHref: subscribeUrl('standard'),
|
||||
featureIntroKey: SHOW_FREE_TIER
|
||||
? 'pricing.plan.standard.featureIntro'
|
||||
: undefined,
|
||||
featureIntroKey: 'pricing.plan.standard.featureIntro',
|
||||
features: [
|
||||
{ text: 'pricing.plan.standard.feature1' },
|
||||
{ text: 'pricing.plan.standard.feature2' }
|
||||
@@ -155,14 +150,9 @@ const activePlanIndex = ref(0)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: single card -->
|
||||
<!-- Desktop: 4-column grid / Mobile: single card -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:gap-2',
|
||||
standardPlans.length === 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3'
|
||||
)
|
||||
"
|
||||
class="rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:grid-cols-4 lg:gap-2"
|
||||
>
|
||||
<PricingTierCard v-for="plan in standardPlans" :key="plan.id">
|
||||
<!-- Label + badge -->
|
||||
@@ -233,18 +223,10 @@ const activePlanIndex = ref(0)
|
||||
|
||||
<!-- Features -->
|
||||
<div v-if="plan.features.length" class="px-6 py-3">
|
||||
<p
|
||||
v-if="plan.featureIntroKey"
|
||||
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
|
||||
>
|
||||
{{ t(plan.featureIntroKey, locale) }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
||||
<p class="text-primary-comfy-canvas mb-2 text-sm font-semibold">
|
||||
{{
|
||||
plan.featureIntroKey ? t(plan.featureIntroKey, locale) : ' '
|
||||
}}
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { SHOW_FREE_TIER } from '../../../config/features'
|
||||
import { getRoutes } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
@@ -26,10 +25,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
{{ t('cloud.pricing.description', locale) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="SHOW_FREE_TIER"
|
||||
class="text-primary-comfy-ink mt-4 text-base font-bold"
|
||||
>
|
||||
<p class="text-primary-comfy-ink mt-4 text-base font-bold">
|
||||
{{ t('cloud.pricing.tagline', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useBannerImage } from './useBannerImage'
|
||||
|
||||
describe('useBannerImage', () => {
|
||||
it('shows the default banner when neither url is provided', () => {
|
||||
const { showDefaultBanner, imgSrc } = useBannerImage({})
|
||||
expect(showDefaultBanner.value).toBe(true)
|
||||
expect(imgSrc.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prefers bannerUrl over iconUrl when both are provided', () => {
|
||||
const { showDefaultBanner, imgSrc } = useBannerImage({
|
||||
bannerUrl: 'https://example.com/banner.png',
|
||||
iconUrl: 'https://example.com/icon.png'
|
||||
})
|
||||
expect(showDefaultBanner.value).toBe(false)
|
||||
expect(imgSrc.value).toBe('https://example.com/banner.png')
|
||||
})
|
||||
|
||||
it('falls back to iconUrl when bannerUrl is missing', () => {
|
||||
const { imgSrc } = useBannerImage({
|
||||
iconUrl: 'https://example.com/icon.png'
|
||||
})
|
||||
expect(imgSrc.value).toBe('https://example.com/icon.png')
|
||||
})
|
||||
|
||||
it('reactively updates when sources change', () => {
|
||||
const banner = ref<string | undefined>(undefined)
|
||||
const { showDefaultBanner, imgSrc } = useBannerImage({ bannerUrl: banner })
|
||||
|
||||
expect(showDefaultBanner.value).toBe(true)
|
||||
banner.value = 'https://example.com/new.png'
|
||||
expect(showDefaultBanner.value).toBe(false)
|
||||
expect(imgSrc.value).toBe('https://example.com/new.png')
|
||||
})
|
||||
|
||||
it('flips isImageError when onImageError is called', () => {
|
||||
const { isImageError, onImageError } = useBannerImage({
|
||||
bannerUrl: 'x'
|
||||
})
|
||||
expect(isImageError.value).toBe(false)
|
||||
onImageError()
|
||||
expect(isImageError.value).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes the default banner constant for consumers', () => {
|
||||
const { DEFAULT_BANNER } = useBannerImage({})
|
||||
expect(DEFAULT_BANNER).toBe('/assets/images/fallback-gradient-avatar.svg')
|
||||
})
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import { computed, ref, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
|
||||
|
||||
interface UseBannerImageInput {
|
||||
bannerUrl?: MaybeRefOrGetter<string | undefined>
|
||||
iconUrl?: MaybeRefOrGetter<string | undefined>
|
||||
}
|
||||
|
||||
export function useBannerImage({ bannerUrl, iconUrl }: UseBannerImageInput) {
|
||||
const isImageError = ref(false)
|
||||
|
||||
const showDefaultBanner = computed(
|
||||
() => !toValue(bannerUrl) && !toValue(iconUrl)
|
||||
)
|
||||
const imgSrc = computed(() => toValue(bannerUrl) || toValue(iconUrl))
|
||||
|
||||
function onImageError() {
|
||||
isImageError.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
DEFAULT_BANNER,
|
||||
isImageError,
|
||||
showDefaultBanner,
|
||||
imgSrc,
|
||||
onImageError
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Pack, PackNode } from '../data/cloudNodes'
|
||||
|
||||
import { useFilteredPacks } from './useFilteredPacks'
|
||||
import type { PackSortMode } from './useFilteredPacks'
|
||||
|
||||
function pack(overrides: Partial<Pack> = {}): Pack {
|
||||
return {
|
||||
id: overrides.id ?? 'pack',
|
||||
displayName: overrides.displayName ?? 'Pack',
|
||||
nodes: overrides.nodes ?? [],
|
||||
downloads: overrides.downloads,
|
||||
lastUpdated: overrides.lastUpdated,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function node(name: string, displayName: string): PackNode {
|
||||
return { name, displayName, category: 'x' }
|
||||
}
|
||||
|
||||
describe('useFilteredPacks', () => {
|
||||
const packs: readonly Pack[] = [
|
||||
pack({
|
||||
id: 'a',
|
||||
displayName: 'Alpha',
|
||||
downloads: 100,
|
||||
lastUpdated: '2025-01-01T00:00:00Z',
|
||||
nodes: [node('aa', 'Aardvark')]
|
||||
}),
|
||||
pack({
|
||||
id: 'b',
|
||||
displayName: 'Beta',
|
||||
downloads: 300,
|
||||
lastUpdated: '2025-06-01T00:00:00Z',
|
||||
nodes: [node('bb', 'Beaver'), node('bb2', 'Bumblebee')]
|
||||
}),
|
||||
pack({
|
||||
id: 'c',
|
||||
displayName: 'Gamma',
|
||||
downloads: 200,
|
||||
lastUpdated: '2025-03-01T00:00:00Z',
|
||||
nodes: [
|
||||
node('cc', 'Cat'),
|
||||
node('cc2', 'Crocodile'),
|
||||
node('cc3', 'Capybara')
|
||||
]
|
||||
})
|
||||
]
|
||||
|
||||
it('sorts by downloads desc by default', () => {
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs,
|
||||
query: '',
|
||||
sortMode: 'downloads' as PackSortMode
|
||||
})
|
||||
expect(filteredPacks.value.map((p) => p.id)).toEqual(['b', 'c', 'a'])
|
||||
})
|
||||
|
||||
it('sorts most-nodes places highest count first', () => {
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs,
|
||||
query: '',
|
||||
sortMode: 'mostNodes' as PackSortMode
|
||||
})
|
||||
expect(filteredPacks.value.map((p) => p.id)).toEqual(['c', 'b', 'a'])
|
||||
})
|
||||
|
||||
it('sorts A → Z by display name', () => {
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs,
|
||||
query: '',
|
||||
sortMode: 'az' as PackSortMode
|
||||
})
|
||||
expect(filteredPacks.value.map((p) => p.displayName)).toEqual([
|
||||
'Alpha',
|
||||
'Beta',
|
||||
'Gamma'
|
||||
])
|
||||
})
|
||||
|
||||
it('sorts recently updated newest first', () => {
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs,
|
||||
query: '',
|
||||
sortMode: 'recentlyUpdated' as PackSortMode
|
||||
})
|
||||
expect(filteredPacks.value.map((p) => p.id)).toEqual(['b', 'c', 'a'])
|
||||
})
|
||||
|
||||
it('treats invalid lastUpdated as 0', () => {
|
||||
const broken = [
|
||||
pack({ id: 'x', lastUpdated: 'nonsense' }),
|
||||
pack({ id: 'y', lastUpdated: '2025-01-01T00:00:00Z' })
|
||||
]
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs: broken,
|
||||
query: '',
|
||||
sortMode: 'recentlyUpdated' as PackSortMode
|
||||
})
|
||||
expect(filteredPacks.value[0].id).toBe('y')
|
||||
})
|
||||
|
||||
it('matches the search query against pack display names', () => {
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs,
|
||||
query: 'beta',
|
||||
sortMode: 'az' as PackSortMode
|
||||
})
|
||||
expect(filteredPacks.value.map((p) => p.id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('matches the search query against node display names', () => {
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs,
|
||||
query: 'CAPYBARA',
|
||||
sortMode: 'az' as PackSortMode
|
||||
})
|
||||
expect(filteredPacks.value.map((p) => p.id)).toEqual(['c'])
|
||||
})
|
||||
|
||||
it('returns empty when nothing matches', () => {
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs,
|
||||
query: 'zzz-no-such-thing',
|
||||
sortMode: 'az' as PackSortMode
|
||||
})
|
||||
expect(filteredPacks.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reacts when the query ref changes', () => {
|
||||
const query = ref('beta')
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs,
|
||||
query,
|
||||
sortMode: 'az' as PackSortMode
|
||||
})
|
||||
expect(filteredPacks.value).toHaveLength(1)
|
||||
query.value = ''
|
||||
expect(filteredPacks.value).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import type { Pack } from '../data/cloudNodes'
|
||||
|
||||
export type PackSortMode = 'downloads' | 'mostNodes' | 'az' | 'recentlyUpdated'
|
||||
|
||||
interface UseFilteredPacksInput {
|
||||
packs: MaybeRefOrGetter<readonly Pack[]>
|
||||
query: MaybeRefOrGetter<string>
|
||||
sortMode: MaybeRefOrGetter<PackSortMode>
|
||||
}
|
||||
|
||||
function matchesQuery(pack: Pack, normalizedQuery: string): boolean {
|
||||
if (pack.displayName.toLowerCase().includes(normalizedQuery)) return true
|
||||
return pack.nodes.some((node) =>
|
||||
node.displayName.toLowerCase().includes(normalizedQuery)
|
||||
)
|
||||
}
|
||||
|
||||
function safeTimestamp(value: string | undefined): number {
|
||||
if (!value) return 0
|
||||
const ts = Date.parse(value)
|
||||
return Number.isNaN(ts) ? 0 : ts
|
||||
}
|
||||
|
||||
export function useFilteredPacks(input: UseFilteredPacksInput) {
|
||||
const filteredPacks = computed<Pack[]>(() => {
|
||||
const allPacks = toValue(input.packs)
|
||||
const normalizedQuery = toValue(input.query).trim().toLowerCase()
|
||||
|
||||
const matching =
|
||||
normalizedQuery.length === 0
|
||||
? [...allPacks]
|
||||
: allPacks.filter((pack) => matchesQuery(pack, normalizedQuery))
|
||||
|
||||
const mode = toValue(input.sortMode)
|
||||
if (mode === 'az') {
|
||||
return matching.sort((a, b) => a.displayName.localeCompare(b.displayName))
|
||||
}
|
||||
if (mode === 'recentlyUpdated') {
|
||||
return matching.sort(
|
||||
(a, b) => safeTimestamp(b.lastUpdated) - safeTimestamp(a.lastUpdated)
|
||||
)
|
||||
}
|
||||
if (mode === 'mostNodes') {
|
||||
return matching.sort((a, b) => b.nodes.length - a.nodes.length)
|
||||
}
|
||||
return matching.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0))
|
||||
})
|
||||
|
||||
return { filteredPacks }
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { PackNode } from '../data/cloudNodes'
|
||||
|
||||
import { useNodesByCategory } from './useNodesByCategory'
|
||||
|
||||
function node(name: string, displayName: string, category: string): PackNode {
|
||||
return { name, displayName, category }
|
||||
}
|
||||
|
||||
describe('useNodesByCategory', () => {
|
||||
it('groups nodes by category', () => {
|
||||
const { groupedNodes } = useNodesByCategory(() => [
|
||||
node('A', 'A', 'cat-1'),
|
||||
node('B', 'B', 'cat-2'),
|
||||
node('C', 'C', 'cat-1')
|
||||
])
|
||||
expect(groupedNodes.value).toHaveLength(2)
|
||||
expect(groupedNodes.value[0]).toMatchObject({
|
||||
category: 'cat-1',
|
||||
nodes: [
|
||||
expect.objectContaining({ name: 'A' }),
|
||||
expect.objectContaining({ name: 'C' })
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('sorts nodes alphabetically by display name within a category', () => {
|
||||
const { groupedNodes } = useNodesByCategory(() => [
|
||||
node('z', 'Zulu', 'x'),
|
||||
node('a', 'Alpha', 'x'),
|
||||
node('m', 'Mike', 'x')
|
||||
])
|
||||
expect(groupedNodes.value[0].nodes.map((n) => n.displayName)).toEqual([
|
||||
'Alpha',
|
||||
'Mike',
|
||||
'Zulu'
|
||||
])
|
||||
})
|
||||
|
||||
it('sorts categories alphabetically', () => {
|
||||
const { groupedNodes } = useNodesByCategory(() => [
|
||||
node('a', 'A', 'beta'),
|
||||
node('b', 'B', 'alpha'),
|
||||
node('c', 'C', 'gamma')
|
||||
])
|
||||
expect(groupedNodes.value.map((g) => g.category)).toEqual([
|
||||
'alpha',
|
||||
'beta',
|
||||
'gamma'
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to a placeholder for missing categories', () => {
|
||||
const { groupedNodes } = useNodesByCategory(() => [node('a', 'A', '')])
|
||||
expect(groupedNodes.value[0].category).toBe('—')
|
||||
})
|
||||
|
||||
it('reacts to ref changes', () => {
|
||||
const nodes = ref<PackNode[]>([node('a', 'A', 'x')])
|
||||
const { groupedNodes } = useNodesByCategory(nodes)
|
||||
expect(groupedNodes.value).toHaveLength(1)
|
||||
|
||||
nodes.value = [node('a', 'A', 'x'), node('b', 'B', 'y')]
|
||||
expect(groupedNodes.value).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import type { PackNode } from '../data/cloudNodes'
|
||||
|
||||
const UNCATEGORIZED = '—'
|
||||
|
||||
interface NodeCategoryGroup {
|
||||
category: string
|
||||
nodes: PackNode[]
|
||||
}
|
||||
|
||||
export function useNodesByCategory(
|
||||
nodes: MaybeRefOrGetter<readonly PackNode[]>
|
||||
) {
|
||||
const groupedNodes = computed<NodeCategoryGroup[]>(() => {
|
||||
const byCategory = new Map<string, PackNode[]>()
|
||||
|
||||
for (const node of toValue(nodes)) {
|
||||
const category = node.category || UNCATEGORIZED
|
||||
const existing = byCategory.get(category)
|
||||
if (existing) {
|
||||
existing.push(node)
|
||||
continue
|
||||
}
|
||||
byCategory.set(category, [node])
|
||||
}
|
||||
|
||||
return [...byCategory.entries()]
|
||||
.map(([category, items]) => ({
|
||||
category,
|
||||
nodes: [...items].sort((a, b) =>
|
||||
a.displayName.localeCompare(b.displayName)
|
||||
)
|
||||
}))
|
||||
.sort((a, b) => a.category.localeCompare(b.category))
|
||||
})
|
||||
|
||||
return { groupedNodes }
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export const SHOW_FREE_TIER = false
|
||||
@@ -1,208 +0,0 @@
|
||||
interface ModelOverride {
|
||||
docsUrl?: string
|
||||
blogUrl?: string
|
||||
featured?: boolean
|
||||
// Slug used on comfy.org/workflows/model/{hubSlug}. Only set when the page exists.
|
||||
hubSlug?: string
|
||||
}
|
||||
|
||||
export const modelMetadata: Record<string, ModelOverride> = {
|
||||
'nano-banana': {
|
||||
docsUrl:
|
||||
'https://docs.comfy.org/tutorials/partner-nodes/google/nano-banana-pro',
|
||||
hubSlug: 'nano-banana',
|
||||
featured: true
|
||||
},
|
||||
'kling-ai': {
|
||||
docsUrl:
|
||||
'https://docs.comfy.org/tutorials/partner-nodes/kling/kling-motion-control',
|
||||
hubSlug: 'kling',
|
||||
featured: true
|
||||
},
|
||||
'meshy-ai': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/partner-nodes/meshy/meshy-6',
|
||||
hubSlug: 'meshy',
|
||||
featured: true
|
||||
},
|
||||
'openai-dall-e': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/partner-nodes/openai/dall-e-3',
|
||||
hubSlug: 'openai',
|
||||
featured: true
|
||||
},
|
||||
'ltxv-api': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/video/ltxv',
|
||||
hubSlug: 'ltx-2-3',
|
||||
featured: true
|
||||
},
|
||||
'wan-api': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan2_2',
|
||||
hubSlug: 'wan',
|
||||
featured: true
|
||||
},
|
||||
'wan-2-2': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan2_2',
|
||||
hubSlug: 'wan',
|
||||
featured: true
|
||||
},
|
||||
'wan-2-1': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan-video',
|
||||
hubSlug: 'wan',
|
||||
featured: true
|
||||
},
|
||||
'flux-1-kontext-dev': {
|
||||
docsUrl:
|
||||
'https://docs.comfy.org/tutorials/partner-nodes/black-forest-labs/flux-1-kontext',
|
||||
hubSlug: 'flux-1-kontext',
|
||||
featured: true
|
||||
},
|
||||
'flux1-dev': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/flux/flux-1-text-to-image',
|
||||
hubSlug: 'flux-1',
|
||||
featured: true
|
||||
},
|
||||
'flux1-schnell': {
|
||||
hubSlug: 'flux-1',
|
||||
featured: true
|
||||
},
|
||||
'hunyuan-video': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/video/hunyuan/hunyuan-video',
|
||||
hubSlug: 'hunyuan-video',
|
||||
featured: true
|
||||
},
|
||||
'hunyuan-3d': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/3d/hunyuan3D-2',
|
||||
hubSlug: 'hunyuan-3d',
|
||||
featured: true
|
||||
},
|
||||
vidu: {
|
||||
hubSlug: 'vidu',
|
||||
featured: true
|
||||
},
|
||||
runway: {
|
||||
hubSlug: 'runway',
|
||||
featured: true
|
||||
},
|
||||
'stability-ai': {
|
||||
hubSlug: 'stability',
|
||||
featured: true
|
||||
},
|
||||
'seedance-bytedance': {
|
||||
hubSlug: 'seedance',
|
||||
featured: true
|
||||
},
|
||||
'grok-image': {
|
||||
hubSlug: 'grok',
|
||||
featured: false
|
||||
},
|
||||
'luma-dream-machine': {
|
||||
hubSlug: 'luma',
|
||||
featured: false
|
||||
},
|
||||
moonvalley: {
|
||||
hubSlug: 'moonvalley',
|
||||
featured: false
|
||||
},
|
||||
'magnific-ai': {
|
||||
hubSlug: 'magnific',
|
||||
featured: false
|
||||
},
|
||||
pixverse: {
|
||||
hubSlug: 'pixverse',
|
||||
featured: false
|
||||
},
|
||||
'rodin-3d': {
|
||||
hubSlug: 'rodin',
|
||||
featured: false
|
||||
},
|
||||
recraft: {
|
||||
hubSlug: 'recraft',
|
||||
featured: false
|
||||
},
|
||||
'bria-ai': {
|
||||
hubSlug: 'bria',
|
||||
featured: false
|
||||
},
|
||||
'topaz-labs': {
|
||||
hubSlug: 'topaz',
|
||||
featured: false
|
||||
},
|
||||
wavespeed: {
|
||||
hubSlug: 'wavespeed',
|
||||
featured: false
|
||||
},
|
||||
ideogram: {
|
||||
hubSlug: 'ideogram',
|
||||
featured: false
|
||||
},
|
||||
'veo-2': {
|
||||
hubSlug: 'veo',
|
||||
featured: false
|
||||
},
|
||||
'veo-3': {
|
||||
hubSlug: 'veo',
|
||||
featured: false
|
||||
},
|
||||
'flux-2-api': {
|
||||
hubSlug: 'flux-2',
|
||||
featured: false
|
||||
},
|
||||
'ace-step-v1-3-5b': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/audio/ace-step/ace-step-v1',
|
||||
hubSlug: 'ace-step',
|
||||
featured: false
|
||||
},
|
||||
'hidream-i1-dev-fp8': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/image/hidream/hidream-i1',
|
||||
hubSlug: 'hidream',
|
||||
featured: false
|
||||
},
|
||||
'omnigen2-fp16': {
|
||||
hubSlug: 'omnigen',
|
||||
featured: false
|
||||
},
|
||||
'sd-xl-base-1-0': {
|
||||
hubSlug: 'sdxl',
|
||||
featured: false
|
||||
},
|
||||
'z-image-bf16': {
|
||||
hubSlug: 'z-image',
|
||||
featured: false
|
||||
},
|
||||
'z-image-turbo-bf16': {
|
||||
hubSlug: 'z-image',
|
||||
featured: false
|
||||
},
|
||||
'svd-xt': {
|
||||
hubSlug: 'svd',
|
||||
featured: false
|
||||
},
|
||||
'flux1-dev-kontext-fp8-scaled': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev',
|
||||
hubSlug: 'flux-1-kontext',
|
||||
featured: false
|
||||
},
|
||||
'ltx-2-19b-dev-fp8': {
|
||||
hubSlug: 'ltx-2',
|
||||
featured: false
|
||||
},
|
||||
'ltx-2-19b-distilled': {
|
||||
hubSlug: 'ltx-2',
|
||||
featured: false
|
||||
},
|
||||
'flux1-fill-dev': {
|
||||
hubSlug: 'flux-1',
|
||||
featured: false
|
||||
},
|
||||
'flux-2-klein-base-9b-fp8': {
|
||||
hubSlug: 'flux-2',
|
||||
featured: false
|
||||
},
|
||||
'qwen-image-fp8-e4m3fn': {
|
||||
hubSlug: 'qwen',
|
||||
featured: false
|
||||
},
|
||||
'qwen-image-edit-2509-fp8-e4m3fn': {
|
||||
hubSlug: 'qwen',
|
||||
featured: false
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import generatedModels from './generated-models.json'
|
||||
import { modelMetadata } from './model-metadata'
|
||||
|
||||
type ModelDirectory =
|
||||
| 'diffusion_models'
|
||||
| 'checkpoints'
|
||||
| 'loras'
|
||||
| 'controlnet'
|
||||
| 'clip_vision'
|
||||
| 'model_patches'
|
||||
| 'vae'
|
||||
| 'text_encoders'
|
||||
| 'audio_encoders'
|
||||
| 'latent_upscale_models'
|
||||
| 'upscale_models'
|
||||
| 'style_models'
|
||||
| 'partner_nodes'
|
||||
|
||||
interface Model {
|
||||
readonly slug: string
|
||||
readonly canonicalSlug?: string
|
||||
readonly name: string
|
||||
readonly displayName: string
|
||||
readonly directory: ModelDirectory
|
||||
readonly huggingFaceUrl: string
|
||||
readonly thumbnailUrl?: string
|
||||
readonly docsUrl?: string
|
||||
readonly blogUrl?: string
|
||||
readonly hubSlug?: string
|
||||
readonly featured: boolean
|
||||
readonly workflowCount: number
|
||||
}
|
||||
|
||||
export const models: readonly Model[] = (
|
||||
generatedModels as Array<{
|
||||
slug: string
|
||||
canonicalSlug?: string
|
||||
name: string
|
||||
displayName: string
|
||||
directory: string
|
||||
huggingFaceUrl: string
|
||||
docsUrl?: string
|
||||
thumbnailUrl?: string
|
||||
workflowCount: number
|
||||
}>
|
||||
).map((m) => ({
|
||||
slug: m.slug,
|
||||
...(m.canonicalSlug ? { canonicalSlug: m.canonicalSlug } : {}),
|
||||
name: m.name,
|
||||
displayName: m.displayName,
|
||||
directory: m.directory as ModelDirectory,
|
||||
huggingFaceUrl: m.huggingFaceUrl,
|
||||
...(m.docsUrl ? { docsUrl: m.docsUrl } : {}),
|
||||
...(m.thumbnailUrl ? { thumbnailUrl: m.thumbnailUrl } : {}),
|
||||
featured: false,
|
||||
workflowCount: m.workflowCount,
|
||||
...modelMetadata[m.slug]
|
||||
}))
|
||||
|
||||
const slugSet = new Set(models.map((m) => m.slug))
|
||||
if (slugSet.size !== models.length) {
|
||||
for (const model of models) {
|
||||
if (models.filter((m) => m.slug === model.slug).length > 1) {
|
||||
throw new Error(`Duplicate model slug: ${model.slug}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const model of models) {
|
||||
if (
|
||||
model.canonicalSlug !== undefined &&
|
||||
(!slugSet.has(model.canonicalSlug) || model.canonicalSlug === model.slug)
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid canonicalSlug "${model.canonicalSlug}" on "${model.slug}"`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function getModelBySlug(slug: string): Model | undefined {
|
||||
return models.find((m) => m.slug === slug)
|
||||
}
|
||||
@@ -14,22 +14,16 @@ const baseRoutes = {
|
||||
demos: '/demos',
|
||||
termsOfService: '/terms-of-service',
|
||||
privacyPolicy: '/privacy-policy',
|
||||
contact: '/contact',
|
||||
models: '/p/supported-models'
|
||||
contact: '/contact'
|
||||
} as const
|
||||
|
||||
type Routes = typeof baseRoutes
|
||||
|
||||
const localeInvariantRouteKeys = new Set<keyof Routes>(['termsOfService'])
|
||||
|
||||
export function getRoutes(locale: Locale = 'en'): Routes {
|
||||
if (locale === 'en') return baseRoutes
|
||||
const prefix = `/${locale}`
|
||||
return Object.fromEntries(
|
||||
Object.entries(baseRoutes).map(([k, v]) => [
|
||||
k,
|
||||
localeInvariantRouteKeys.has(k as keyof Routes) ? v : `${prefix}${v}`
|
||||
])
|
||||
Object.entries(baseRoutes).map(([k, v]) => [k, `${prefix}${v}`])
|
||||
) as unknown as Routes
|
||||
}
|
||||
|
||||
@@ -37,7 +31,6 @@ export const externalLinks = {
|
||||
apiKeys: 'https://platform.comfy.org/profile/api-keys',
|
||||
blog: 'https://blog.comfy.org/',
|
||||
cloud: 'https://cloud.comfy.org',
|
||||
cloudStatus: 'https://status.comfy.org',
|
||||
discord: 'https://discord.com/invite/comfyorg',
|
||||
docs: 'https://docs.comfy.org/',
|
||||
docsApi: 'https://docs.comfy.org/api-reference/cloud',
|
||||
|
||||
@@ -1,30 +1,37 @@
|
||||
{
|
||||
"fetchedAt": "2026-05-12T16:10:34.114Z",
|
||||
"fetchedAt": "2026-05-02T20:15:18.321Z",
|
||||
"departments": [
|
||||
{
|
||||
"name": "DESIGN",
|
||||
"key": "design",
|
||||
"roles": [
|
||||
{
|
||||
"id": "18743530eb448c99",
|
||||
"id": "e915f2c78b17f93b",
|
||||
"title": "Senior Product Designer",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3/application"
|
||||
},
|
||||
{
|
||||
"id": "8718d17012f26fa2",
|
||||
"id": "b9f9a23219be7cd4",
|
||||
"title": "Design Engineer",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
|
||||
},
|
||||
{
|
||||
"id": "547b6ba622c800a5",
|
||||
"title": "Senior Product Designer - Craft",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f/application"
|
||||
},
|
||||
{
|
||||
"id": "1e181b9ed8fb2e86",
|
||||
"id": "7bb02634a24763bc",
|
||||
"title": "Staff Product Designer - Systems",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34/application"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -33,67 +40,67 @@
|
||||
"key": "engineering",
|
||||
"roles": [
|
||||
{
|
||||
"id": "6a6d865eeb3c10a8",
|
||||
"id": "102d58e35a8a9817",
|
||||
"title": "Senior Software Engineer, Frontend",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2/application"
|
||||
},
|
||||
{
|
||||
"id": "1b4f7f1da9616e14",
|
||||
"id": "d01d69fba7743905",
|
||||
"title": "Senior Software Engineer, Backend Generalist",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e/application"
|
||||
},
|
||||
{
|
||||
"id": "a6d8269c66e37c5c",
|
||||
"id": "f36f60cfd5bb5910",
|
||||
"title": "Senior/Staff Applied Machine Learning Engineer",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0/application"
|
||||
},
|
||||
{
|
||||
"id": "841da783e6e41928",
|
||||
"id": "9d8ec4c65e20b19e",
|
||||
"title": "Software Engineer, Frontend",
|
||||
"department": "Engineering",
|
||||
"location": "Remote",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40/application"
|
||||
},
|
||||
{
|
||||
"id": "5d01d58b03870d7a",
|
||||
"id": "be94b193d1f4d482",
|
||||
"title": "Tech Lead Manager, Frontend",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261/application"
|
||||
},
|
||||
{
|
||||
"id": "91604c4182a1bc3c",
|
||||
"id": "ab48f5db6bd1783c",
|
||||
"title": "Software Engineer, Core ComfyUI Contributor",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f/application"
|
||||
},
|
||||
{
|
||||
"id": "a1dbc0576ab14034",
|
||||
"id": "c5dff4ee628bdcd1",
|
||||
"title": "Software Engineer, ComfyUI Desktop",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0/application"
|
||||
},
|
||||
{
|
||||
"id": "0b8f4fecd89c3b11",
|
||||
"id": "4302a7aaa87e16e3",
|
||||
"title": "Product Manager, ComfyUI",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
|
||||
},
|
||||
{
|
||||
"id": "2f6bac39d723dfef",
|
||||
"id": "2eb53e8943cc9396",
|
||||
"title": "Growth Engineer",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5/application"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -102,39 +109,39 @@
|
||||
"key": "marketing",
|
||||
"roles": [
|
||||
{
|
||||
"id": "23dd98cab77ff459",
|
||||
"id": "4c5d6afb78652df7",
|
||||
"title": "Freelance Motion Designer",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
|
||||
},
|
||||
{
|
||||
"id": "a998b9fc973ff3c0",
|
||||
"id": "0f5256cf302e552b",
|
||||
"title": "Creative Artist",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
|
||||
},
|
||||
{
|
||||
"id": "3e730938026d6e70",
|
||||
"id": "5746486d87874937",
|
||||
"title": "Graphic Designer",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
|
||||
},
|
||||
{
|
||||
"id": "6f771af6858283aa",
|
||||
"id": "b5803a0d4785d406",
|
||||
"title": "Lifecycle Growth Marketer",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64/application"
|
||||
},
|
||||
{
|
||||
"id": "527a47e82970afc1",
|
||||
"id": "130d7218d7895bdb",
|
||||
"title": "Partnership & Events Marketing Manager",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c/application"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -143,18 +150,25 @@
|
||||
"key": "operations",
|
||||
"roles": [
|
||||
{
|
||||
"id": "0c6cc3685194ab7a",
|
||||
"title": "Head of Talent",
|
||||
"id": "ec68ae44dd5943c9",
|
||||
"title": "Talent Lead",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
|
||||
},
|
||||
{
|
||||
"id": "82bd6ed26adab1c3",
|
||||
"id": "16f556001ce1cef4",
|
||||
"title": "BizOps Strategist",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/145b8558-0ab4-43e8-8fac-b59059cf2537/application"
|
||||
},
|
||||
{
|
||||
"id": "8e773a72c1b8e099",
|
||||
"title": "Founding Customer Success Manager",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4/application"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
{
|
||||
"fetchedAt": "2026-05-04T16:29:55.587Z",
|
||||
"packs": [
|
||||
{
|
||||
"id": "comfyui-impact-pack",
|
||||
"registryId": "comfyui-impact-pack",
|
||||
"displayName": "ComfyUI Impact Pack",
|
||||
"description": "Production-grade detailer, detector, and SEG (segmentation) tooling. The most-used pack for face restoration, region-based refinement, and iterative upscaling on Comfy Cloud.",
|
||||
"repoUrl": "https://github.com/ltdrdata/ComfyUI-Impact-Pack",
|
||||
"publisher": {
|
||||
"id": "drltdata",
|
||||
"name": "Dr.Lt.Data"
|
||||
},
|
||||
"downloads": 2618646,
|
||||
"githubStars": 3092,
|
||||
"latestVersion": "8.28.3",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-04-19T17:08:04.993918Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "FaceDetailer",
|
||||
"displayName": "FaceDetailer",
|
||||
"category": "ImpactPack/Detailer",
|
||||
"description": "Detect and refine faces with iterative passes."
|
||||
},
|
||||
{
|
||||
"name": "DetailerForEach",
|
||||
"displayName": "DetailerForEach",
|
||||
"category": "ImpactPack/Detailer",
|
||||
"description": "Run iterative detail refinement over detected SEG regions."
|
||||
},
|
||||
{
|
||||
"name": "UltralyticsDetectorProvider",
|
||||
"displayName": "UltralyticsDetectorProvider",
|
||||
"category": "ImpactPack/Detector",
|
||||
"description": "Provide detector models powered by Ultralytics YOLO."
|
||||
},
|
||||
{
|
||||
"name": "SAMLoader",
|
||||
"displayName": "SAMLoader",
|
||||
"category": "ImpactPack/Detector",
|
||||
"description": "Load Segment Anything models for high-fidelity masking."
|
||||
},
|
||||
{
|
||||
"name": "MaskToSEGS",
|
||||
"displayName": "MaskToSEGS",
|
||||
"category": "ImpactPack/Operation",
|
||||
"description": "Convert binary masks into SEGS regions for the detailer pipeline."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ComfyUI-Crystools",
|
||||
"registryId": "ComfyUI-Crystools",
|
||||
"displayName": "ComfyUI-Crystools",
|
||||
"description": "Live system monitoring (GPU, RAM, disk) and rich image inspection inside your workflow. The most-installed quality-of-life pack on the registry.",
|
||||
"iconUrl": "https://raw.githubusercontent.com/crystian/ComfyUI-Crystools/main/docs/screwdriver.png",
|
||||
"repoUrl": "https://github.com/crystian/ComfyUI-Crystools",
|
||||
"publisher": {
|
||||
"id": "crystian",
|
||||
"name": "Crystian"
|
||||
},
|
||||
"downloads": 1671447,
|
||||
"githubStars": 1855,
|
||||
"latestVersion": "1.27.4",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2025-10-26T19:11:09.943366Z",
|
||||
"supportedOs": ["OS Independent"],
|
||||
"supportedAccelerators": ["GPU :: NVIDIA CUDA"],
|
||||
"nodes": [
|
||||
{
|
||||
"name": "CCrystools_Show_Resources",
|
||||
"displayName": "CCrystools_Show_Resources",
|
||||
"category": "crystools/show",
|
||||
"description": "Display GPU, RAM and disk usage live in the workflow."
|
||||
},
|
||||
{
|
||||
"name": "CCrystools_Show_Image",
|
||||
"displayName": "CCrystools_Show_Image",
|
||||
"category": "crystools/show",
|
||||
"description": "Inspect images at full resolution with metadata overlays."
|
||||
},
|
||||
{
|
||||
"name": "CCrystools_Json",
|
||||
"displayName": "CCrystools_Json",
|
||||
"category": "crystools/json",
|
||||
"description": "Compose and parse JSON inline for advanced workflows."
|
||||
},
|
||||
{
|
||||
"name": "CCrystools_Pipe_To_Any",
|
||||
"displayName": "CCrystools_Pipe_To_Any",
|
||||
"category": "crystools/pipe",
|
||||
"description": "Convert a pipe bus into individual outputs."
|
||||
},
|
||||
{
|
||||
"name": "CCrystools_Save_Metadata",
|
||||
"displayName": "CCrystools_Save_Metadata",
|
||||
"category": "crystools/save",
|
||||
"description": "Save images with workflow metadata embedded."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rgthree-comfy",
|
||||
"registryId": "rgthree-comfy",
|
||||
"displayName": "rgthree-comfy",
|
||||
"description": "Quality-of-life nodes that make complex workflows readable: Power Lora Loader, group bypassers, smarter reroutes, and inline debug widgets.",
|
||||
"iconUrl": "https://comfy.rgthree.com/media/rgthree.svg",
|
||||
"repoUrl": "https://github.com/rgthree/rgthree-comfy",
|
||||
"publisher": {
|
||||
"id": "rgthree"
|
||||
},
|
||||
"downloads": 3025389,
|
||||
"githubStars": 3028,
|
||||
"latestVersion": "1.0.2604070017",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-04-07T04:19:24.689627Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Power Lora Loader (rgthree)",
|
||||
"displayName": "Power Lora Loader (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "Stack multiple LoRAs in a single, foldable widget."
|
||||
},
|
||||
{
|
||||
"name": "Fast Groups Bypasser (rgthree)",
|
||||
"displayName": "Fast Groups Bypasser (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "Toggle whole groups on or off without rewiring."
|
||||
},
|
||||
{
|
||||
"name": "Seed (rgthree)",
|
||||
"displayName": "Seed (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "A predictable seed control with quick reset."
|
||||
},
|
||||
{
|
||||
"name": "Reroute (rgthree)",
|
||||
"displayName": "Reroute (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "A clean reroute alternative with persistent labels."
|
||||
},
|
||||
{
|
||||
"name": "Display Any (rgthree)",
|
||||
"displayName": "Display Any (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "Inspect any value with a compact debug widget."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "comfyui-kjnodes",
|
||||
"registryId": "comfyui-kjnodes",
|
||||
"displayName": "ComfyUI-KJNodes",
|
||||
"description": "Daily-driver utilities for image, latent and string handling: color matching, batch counters, resize helpers, and prompt presets.",
|
||||
"iconUrl": "https://avatars.githubusercontent.com/u/40791699",
|
||||
"repoUrl": "https://github.com/kijai/ComfyUI-KJNodes",
|
||||
"publisher": {
|
||||
"id": "kijai",
|
||||
"name": "Kijai"
|
||||
},
|
||||
"downloads": 3319866,
|
||||
"githubStars": 2544,
|
||||
"latestVersion": "1.3.9",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-04-24T09:32:28.326616Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "ColorMatch",
|
||||
"displayName": "ColorMatch",
|
||||
"category": "KJNodes/image",
|
||||
"description": "Match the colors of one image to another using statistics."
|
||||
},
|
||||
{
|
||||
"name": "ImageResizeKJ",
|
||||
"displayName": "ImageResizeKJ",
|
||||
"category": "KJNodes/image",
|
||||
"description": "Resize images with intuitive size and divisibility controls."
|
||||
},
|
||||
{
|
||||
"name": "StringConstantMultiline",
|
||||
"displayName": "StringConstantMultiline",
|
||||
"category": "KJNodes/string",
|
||||
"description": "A multi-line string constant suitable for prompts."
|
||||
},
|
||||
{
|
||||
"name": "EmptyLatentImagePresets",
|
||||
"displayName": "EmptyLatentImagePresets",
|
||||
"category": "KJNodes/latent",
|
||||
"description": "Quickly create empty latents at common resolutions."
|
||||
},
|
||||
{
|
||||
"name": "GetImageSizeAndCount",
|
||||
"displayName": "GetImageSizeAndCount",
|
||||
"category": "KJNodes/image",
|
||||
"description": "Read width, height and batch size from an image input."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "comfyui-easy-use",
|
||||
"registryId": "comfyui-easy-use",
|
||||
"displayName": "ComfyUI-Easy-Use",
|
||||
"description": "Simplified, opinionated nodes that bundle common patterns into single drop-ins — full loader, pre-sampling, easy KSampler, and XY plotting.",
|
||||
"iconUrl": "https://mintlify.s3.us-west-1.amazonaws.com/yolain/images/logo.svg",
|
||||
"repoUrl": "https://github.com/yolain/ComfyUI-Easy-Use",
|
||||
"publisher": {
|
||||
"id": "yolain",
|
||||
"name": "yolain"
|
||||
},
|
||||
"downloads": 2767609,
|
||||
"githubStars": 2500,
|
||||
"latestVersion": "1.3.6",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-01-23T06:19:17.505188Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "easy fullLoader",
|
||||
"displayName": "easy fullLoader",
|
||||
"category": "EasyUse/Loaders",
|
||||
"description": "Combined checkpoint, VAE and CLIP loader with sensible defaults."
|
||||
},
|
||||
{
|
||||
"name": "easy preSampling",
|
||||
"displayName": "easy preSampling",
|
||||
"category": "EasyUse/PreSampling",
|
||||
"description": "A unified pre-sampling node bundling common settings."
|
||||
},
|
||||
{
|
||||
"name": "easy kSampler",
|
||||
"displayName": "easy kSampler",
|
||||
"category": "EasyUse/KSampler",
|
||||
"description": "A simplified KSampler with extra quality-of-life options."
|
||||
},
|
||||
{
|
||||
"name": "easy showAnything",
|
||||
"displayName": "easy showAnything",
|
||||
"category": "EasyUse/Util",
|
||||
"description": "Display any value inline for debugging."
|
||||
},
|
||||
{
|
||||
"name": "easy XYPlot",
|
||||
"displayName": "easy XYPlot",
|
||||
"category": "EasyUse/XYPlot",
|
||||
"description": "Compose XY plots over arbitrary parameters."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "comfyui-advanced-controlnet",
|
||||
"registryId": "comfyui-advanced-controlnet",
|
||||
"displayName": "ComfyUI-Advanced-ControlNet",
|
||||
"description": "ControlNet with timestep keyframes, per-frame masks, and advanced strength scheduling — essential for animation and batched-latent workflows.",
|
||||
"repoUrl": "https://github.com/Kosinkadink/ComfyUI-Advanced-ControlNet",
|
||||
"publisher": {
|
||||
"id": "kosinkadink",
|
||||
"name": "Kosinkadink"
|
||||
},
|
||||
"downloads": 590539,
|
||||
"githubStars": 967,
|
||||
"latestVersion": "1.5.7",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-03-30T01:40:06.836236Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Apply Advanced ControlNet",
|
||||
"displayName": "Apply Advanced ControlNet",
|
||||
"category": "Adv-ControlNet/conditioning",
|
||||
"description": "Apply ControlNet with timestep keyframes and per-frame masks."
|
||||
},
|
||||
{
|
||||
"name": "ControlNetLoaderAdvanced",
|
||||
"displayName": "ControlNetLoaderAdvanced",
|
||||
"category": "Adv-ControlNet/loaders",
|
||||
"description": "Load ControlNet models with the advanced wrapper."
|
||||
},
|
||||
{
|
||||
"name": "Latent Keyframe Group",
|
||||
"displayName": "Latent Keyframe Group",
|
||||
"category": "Adv-ControlNet/keyframes",
|
||||
"description": "Schedule ControlNet strength over a batch of latents."
|
||||
},
|
||||
{
|
||||
"name": "Timestep Keyframe",
|
||||
"displayName": "Timestep Keyframe",
|
||||
"category": "Adv-ControlNet/keyframes",
|
||||
"description": "Set ControlNet strength at a specific timestep."
|
||||
},
|
||||
{
|
||||
"name": "Scaled Soft Mask",
|
||||
"displayName": "Scaled Soft Mask",
|
||||
"category": "Adv-ControlNet/masks",
|
||||
"description": "Apply a soft attention mask to ControlNet conditioning."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "was-node-suite-comfyui",
|
||||
"registryId": "was-node-suite-comfyui",
|
||||
"displayName": "WAS Node Suite",
|
||||
"description": "A broad utility suite covering image adjustments, compositing, text, math, and I/O — the original \"kitchen sink\" pack still relied on by thousands of workflows.",
|
||||
"repoUrl": "https://github.com/WASasquatch/was-node-suite-comfyui",
|
||||
"publisher": {
|
||||
"id": "was",
|
||||
"name": "WAS"
|
||||
},
|
||||
"downloads": 981051,
|
||||
"githubStars": 1777,
|
||||
"latestVersion": "1.0.1",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2024-08-01T05:28:23.655235Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Image Filter Adjustments",
|
||||
"displayName": "Image Filter Adjustments",
|
||||
"category": "WAS Suite/Image/Adjustment",
|
||||
"description": "Adjust brightness, contrast, saturation and more."
|
||||
},
|
||||
{
|
||||
"name": "Image Blending Mode",
|
||||
"displayName": "Image Blending Mode",
|
||||
"category": "WAS Suite/Image/Compositing",
|
||||
"description": "Composite two images with Photoshop-style blend modes."
|
||||
},
|
||||
{
|
||||
"name": "Text String",
|
||||
"displayName": "Text String",
|
||||
"category": "WAS Suite/Text",
|
||||
"description": "A reusable text constant suitable for prompts."
|
||||
},
|
||||
{
|
||||
"name": "Number to Float",
|
||||
"displayName": "Number to Float",
|
||||
"category": "WAS Suite/Number",
|
||||
"description": "Cast integer or string values to a float."
|
||||
},
|
||||
{
|
||||
"name": "Image Save",
|
||||
"displayName": "Image Save",
|
||||
"category": "WAS Suite/IO",
|
||||
"description": "Save an image to disk with rich filename templating."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "comfyui_ipadapter_plus",
|
||||
"registryId": "comfyui_ipadapter_plus",
|
||||
"displayName": "ComfyUI_IPAdapter_plus",
|
||||
"description": "Reference-image conditioning with IPAdapter — style transfer, Face ID, and multi-image embeddings. The most-installed conditioning pack on the registry, used in countless portrait, product, and animation workflows.",
|
||||
"repoUrl": "https://github.com/cubiq/ComfyUI_IPAdapter_plus",
|
||||
"publisher": {
|
||||
"id": "matteo",
|
||||
"name": "Matteo"
|
||||
},
|
||||
"downloads": 1208394,
|
||||
"githubStars": 5938,
|
||||
"latestVersion": "2.0.0",
|
||||
"license": "GPL-3.0 license",
|
||||
"lastUpdated": "2024-06-05T06:57:13.485481Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "IPAdapterUnifiedLoader",
|
||||
"displayName": "IPAdapterUnifiedLoader",
|
||||
"category": "ipadapter",
|
||||
"description": "Load IPAdapter, image encoder and CLIP vision in one node."
|
||||
},
|
||||
{
|
||||
"name": "IPAdapterFaceID",
|
||||
"displayName": "IPAdapterFaceID",
|
||||
"category": "ipadapter/faceid",
|
||||
"description": "Apply Face ID embeddings for high-fidelity portrait reference."
|
||||
},
|
||||
{
|
||||
"name": "IPAdapterStyleComposition",
|
||||
"displayName": "IPAdapterStyleComposition",
|
||||
"category": "ipadapter",
|
||||
"description": "Reference an image for style without copying its content."
|
||||
},
|
||||
{
|
||||
"name": "IPAdapterAdvanced",
|
||||
"displayName": "IPAdapterAdvanced",
|
||||
"category": "ipadapter",
|
||||
"description": "Full-control IPAdapter with masking, weights, and noise injection."
|
||||
},
|
||||
{
|
||||
"name": "IPAdapterEncoder",
|
||||
"displayName": "IPAdapterEncoder",
|
||||
"category": "ipadapter/embeds",
|
||||
"description": "Encode reference images into IPAdapter embeddings for reuse."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
export interface PackNode {
|
||||
name: string
|
||||
displayName: string
|
||||
category: string
|
||||
description?: string
|
||||
deprecated?: boolean
|
||||
experimental?: boolean
|
||||
}
|
||||
|
||||
export interface Pack {
|
||||
id: string
|
||||
registryId?: string
|
||||
displayName: string
|
||||
description?: string
|
||||
bannerUrl?: string
|
||||
iconUrl?: string
|
||||
repoUrl?: string
|
||||
publisher?: {
|
||||
id: string
|
||||
name?: string
|
||||
}
|
||||
downloads?: number
|
||||
githubStars?: number
|
||||
latestVersion?: string
|
||||
license?: string
|
||||
lastUpdated?: string
|
||||
supportedOs?: string[]
|
||||
supportedAccelerators?: string[]
|
||||
nodes: PackNode[]
|
||||
}
|
||||
|
||||
export interface NodesSnapshot {
|
||||
fetchedAt: string
|
||||
packs: Pack[]
|
||||
}
|
||||
|
||||
export function isNodesSnapshot(value: unknown): value is NodesSnapshot {
|
||||
if (value === null || typeof value !== 'object') return false
|
||||
const candidate = value as { fetchedAt?: unknown; packs?: unknown }
|
||||
if (typeof candidate.fetchedAt !== 'string') return false
|
||||
if (!Array.isArray(candidate.packs)) return false
|
||||
|
||||
return candidate.packs.every((pack) => {
|
||||
if (pack === null || typeof pack !== 'object') return false
|
||||
const p = pack as { id?: unknown; displayName?: unknown; nodes?: unknown }
|
||||
return (
|
||||
typeof p.id === 'string' &&
|
||||
typeof p.displayName === 'string' &&
|
||||
Array.isArray(p.nodes)
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -3,7 +3,7 @@ export interface Role {
|
||||
title: string
|
||||
department: string
|
||||
location: string
|
||||
jobUrl: string
|
||||
applyUrl: string
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
|
||||
@@ -5,7 +5,6 @@ import '../styles/global.css'
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import SiteFooter from '../components/common/SiteFooter.vue'
|
||||
import SiteNav from '../components/common/SiteNav.vue'
|
||||
import { escapeJsonLd } from '../utils/escapeJsonLd'
|
||||
import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
|
||||
interface Props {
|
||||
@@ -71,7 +70,7 @@ const websiteJsonLd = {
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<title>{title}</title>
|
||||
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" href="/icons/logomark.svg" type="image/svg+xml" />
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
<link rel="preconnect" href="https://www.googletagmanager.com" />
|
||||
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
|
||||
@@ -95,11 +94,8 @@ const websiteJsonLd = {
|
||||
<meta name="twitter:image" content={ogImageURL.href} />
|
||||
|
||||
<!-- Structured Data -->
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(organizationJsonLd)} />
|
||||
<script is:inline type="application/ld+json" set:html={escapeJsonLd(websiteJsonLd)} />
|
||||
<slot name="head" />
|
||||
|
||||
<slot name="head" />
|
||||
<script is:inline type="application/ld+json" set:html={JSON.stringify(organizationJsonLd)} />
|
||||
<script is:inline type="application/ld+json" set:html={JSON.stringify(websiteJsonLd)} />
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
{gtmEnabled && (
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../components/cloud-nodes/HeroSection.vue'
|
||||
import PackGridSection from '../../components/cloud-nodes/PackGridSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../utils/escapeJsonLd'
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL('/cloud/supported-nodes', siteBase).href
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Custom-node packs supported on Comfy Cloud',
|
||||
url: pageUrl,
|
||||
numberOfItems: packs.length,
|
||||
itemListElement: packs.map((pack, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href,
|
||||
name: pack.displayName,
|
||||
image: pack.bannerUrl || pack.iconUrl
|
||||
}))
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('cloudNodes.meta.title', 'en')}
|
||||
description={t('cloudNodes.meta.description', 'en')}
|
||||
>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(itemListJsonLd)}
|
||||
/>
|
||||
<HeroSection client:visible />
|
||||
<PackGridSection packs={packs} client:visible />
|
||||
</BaseLayout>
|
||||
@@ -1,48 +0,0 @@
|
||||
# Cloud Nodes Pages
|
||||
|
||||
Build-time catalog of custom-node packs preinstalled on Comfy Cloud. Index at `/cloud/supported-nodes`, per-pack details at `/cloud/supported-nodes/[pack]`, both also under `/zh-CN/`.
|
||||
|
||||
## Sources
|
||||
|
||||
- **Cloud `/api/object_info`** — authoritative list of nodes available on Comfy Cloud (auth: `WEBSITE_CLOUD_API_KEY`)
|
||||
- **ComfyUI Custom Node Registry** ([dashboard](https://registry.comfy.org), API at `https://api.comfy.org/nodes`) — public pack metadata (banner, icon, description, downloads, stars, license, version, repo, publisher)
|
||||
|
||||
The registry is the same one the in-app Manager dialog reads from. For reference and additional reading, see the existing client wrappers in `src/`:
|
||||
|
||||
- [`src/services/comfyRegistryService.ts`](../../../../../../src/services/comfyRegistryService.ts) — typed wrappers around `/nodes`, `/nodes/search`, `/nodes/{id}`, `/nodes/{id}/versions/{version}/comfy-nodes`, etc.
|
||||
- [`src/stores/comfyRegistryStore.ts`](../../../../../../src/stores/comfyRegistryStore.ts) — cached store + `getPacksByIds` batch helper
|
||||
- [`packages/registry-types/src/comfyRegistryTypes.ts`](../../../../../../packages/registry-types/src/comfyRegistryTypes.ts) — generated OpenAPI types
|
||||
- Public docs: <https://docs.comfy.org/registry>
|
||||
|
||||
## Build pipeline
|
||||
|
||||
| File | Role |
|
||||
| ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| [`pages/cloud/supported-nodes.astro`](./supported-nodes.astro) and [`[pack].astro`](./supported-nodes/%5Bpack%5D.astro) | Page shells (and `zh-CN` twins) |
|
||||
| [`utils/cloudNodes.build.ts`](../../../utils/cloudNodes.build.ts) | `loadPacksForBuild()` shared by index + detail routes |
|
||||
| [`utils/cloudNodes.ts`](../../../utils/cloudNodes.ts) | Cloud `object_info` fetcher with retry, sanitization, snapshot fallback |
|
||||
| [`utils/cloudNodes.registry.ts`](../../../utils/cloudNodes.registry.ts) | Registry enrichment (batches of 50, soft-fail) |
|
||||
| [`utils/cloudNodes.ci.ts`](../../../utils/cloudNodes.ci.ts) | GitHub Actions annotations + step summary |
|
||||
| [`utils/escapeJsonLd.ts`](../../../utils/escapeJsonLd.ts) | XSS-safe `<script type="application/ld+json">` serializer |
|
||||
| [`composables/useFilteredPacks.ts`](../../../composables/useFilteredPacks.ts) | Search + sort logic for the index page |
|
||||
| [`composables/useNodesByCategory.ts`](../../../composables/useNodesByCategory.ts) | Group nodes by category, alphabetized |
|
||||
| [`scripts/refresh-cloud-nodes-snapshot.ts`](../../../../scripts/refresh-cloud-nodes-snapshot.ts) | Manual `pnpm cloud-nodes:refresh-snapshot` |
|
||||
| [`packages/object-info-parser`](../../../../../../packages/object-info-parser) | Shared Zod schemas, classifier, `sanitizeUserContent` |
|
||||
|
||||
## Key invariants
|
||||
|
||||
- **Sanitization is mandatory.** The cloud `/api/object_info` endpoint mixes the calling user's uploaded files into combo input lists; `sanitizeUserContent()` strips them before the snapshot is written. Tests in [`packages/object-info-parser/src/__tests__/sanitizeUserContent.test.ts`](../../../../../../packages/object-info-parser/src/__tests__/sanitizeUserContent.test.ts).
|
||||
- **All remote data is Zod-validated.** `cloudNodes.schema.ts` validates the cloud envelope; `@comfyorg/object-info-parser` validates each node def; `cloudNodes.registry.ts` validates the registry response shape with a passthrough Zod schema (defense-in-depth on top of the generated OpenAPI types). The fetcher never trusts a network response.
|
||||
- **JSON-LD must be escaped.** Pack metadata is registry-controlled; route every `<script type="application/ld+json">` payload through `escapeJsonLd()`.
|
||||
- **Index and detail share one fetch.** Both call `loadPacksForBuild()` so the static routes can never diverge from the rendered list.
|
||||
- **No `PUBLIC_` prefix on env vars.** Astro inlines `PUBLIC_*` into the client bundle; build-time secrets never start with that prefix.
|
||||
|
||||
## Why `Pack` is a domain projection, not the raw registry `Node`
|
||||
|
||||
`apps/website/src/data/cloudNodes.ts` defines its own `Pack` and `PackNode` types instead of re-exporting `components['schemas']['Node']` from `@comfyorg/registry-types`. That is intentional:
|
||||
|
||||
- A `Pack` joins **two upstream sources** — the cloud `object_info` shape (which has `python_module`, `category`, `display_name`, etc.) and the registry `Node` shape (which has `banner_url`, `downloads`, `github_stars`, etc.). Neither shape on its own captures both.
|
||||
- A `Pack` is **post-sanitization**. Combo input lists, user-uploaded filenames, and any other build-time-stripped data are guaranteed gone by the time the type appears. The raw `Node` carries fields we never expose.
|
||||
- A `Pack` adds **safe-URL invariants** (banner / icon / repo all flow through `safeExternalUrl()` before becoming part of the type), which the registry's optional `string` fields don't encode.
|
||||
|
||||
The `Pack`/`PackNode` types live next to the snapshot they describe; the validated build-time projections feed Astro's static routes directly. The OpenAPI-generated `Node` type is still consumed inside `cloudNodes.registry.ts` as `RegistryPack` for the upstream fetch.
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
|
||||
import type { Pack } from '../../../data/cloudNodes'
|
||||
|
||||
import PackDetail from '../../../components/cloud-nodes/PackDetail.vue'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const packs = await loadPacksForBuild()
|
||||
return packs.map((pack) => ({
|
||||
params: { pack: pack.id },
|
||||
props: { pack }
|
||||
}))
|
||||
}
|
||||
|
||||
const { pack } = Astro.props as { pack: Pack }
|
||||
const fallbackDescription = t('cloudNodes.card.unavailableDescription', 'en')
|
||||
const description = pack.description || fallbackDescription
|
||||
const title = t('cloudNodes.detail.metaTitle', 'en').replace(
|
||||
'{pack}',
|
||||
pack.displayName
|
||||
)
|
||||
const metaDescription = t('cloudNodes.detail.metaDescription', 'en')
|
||||
.replace('{pack}', pack.displayName)
|
||||
.replace('{nodeCount}', String(pack.nodes.length))
|
||||
.replace('{description}', description)
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href
|
||||
|
||||
const softwareJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: pack.displayName,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'ComfyUI custom-node pack',
|
||||
operatingSystem: 'Comfy Cloud (managed)',
|
||||
url: pageUrl,
|
||||
description,
|
||||
image: pack.bannerUrl || pack.iconUrl,
|
||||
softwareVersion: pack.latestVersion,
|
||||
license: pack.license,
|
||||
codeRepository: pack.repoUrl,
|
||||
author: pack.publisher?.name
|
||||
? { '@type': 'Person', name: pack.publisher.name }
|
||||
: undefined,
|
||||
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(softwareJsonLd)}
|
||||
/>
|
||||
<PackDetail pack={pack} />
|
||||
</BaseLayout>
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ModelsHeroSection from '../components/models/ModelsHeroSection.vue'
|
||||
import ModelCreationsSection from '../components/models/ModelCreationsSection.vue'
|
||||
import ModelsShowcaseSection from '../components/models/ModelsShowcaseSection.vue'
|
||||
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Models — Comfy"
|
||||
description="Run the world's leading AI models in ComfyUI. Browse every supported model with community workflow templates ready to run."
|
||||
>
|
||||
<ModelsHeroSection
|
||||
modelName="Grok Imagine"
|
||||
ctaHref="/p/supported-models/grok-image"
|
||||
mediaSrc="https://media.comfy.org/website/gallery/gallery.webp"
|
||||
mediaAlt="Grok Imagine output created with ComfyUI"
|
||||
/>
|
||||
<ModelCreationsSection client:load />
|
||||
<ModelsShowcaseSection />
|
||||
<ProductShowcaseSection client:load />
|
||||
</BaseLayout>
|
||||
@@ -1,185 +0,0 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import ModelHeroSection from '../../../components/models/ModelHeroSection.vue'
|
||||
import { models, getModelBySlug } from '../../../config/models'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return models.map((model) => ({
|
||||
params: { slug: model.slug }
|
||||
}))
|
||||
}
|
||||
|
||||
const { slug } = Astro.params
|
||||
const model = getModelBySlug(slug as string)!
|
||||
|
||||
if (model.canonicalSlug) {
|
||||
return Astro.redirect(`/p/supported-models/${model.canonicalSlug}`, 301)
|
||||
}
|
||||
|
||||
const { displayName } = model
|
||||
const canonicalURL = new URL(`/p/supported-models/${model.slug}`, Astro.site)
|
||||
|
||||
const dirDescriptions: Record<string, string> = {
|
||||
diffusion_models: 'a diffusion model that generates images or video from text and image prompts',
|
||||
checkpoints: 'an all-in-one checkpoint model that bundles a diffusion model, text encoder, and VAE',
|
||||
loras: 'a LoRA (Low-Rank Adaptation) that fine-tunes an existing model for a specific style or subject',
|
||||
controlnet: 'a ControlNet that steers image generation using structural guides like depth maps, edges, or poses',
|
||||
clip_vision: 'a CLIP Vision encoder that converts images into embeddings for conditioning or style transfer',
|
||||
vae: 'a VAE (Variational Autoencoder) that encodes and decodes latent representations',
|
||||
text_encoders: 'a text encoder that converts prompts into embeddings used to guide generation',
|
||||
audio_encoders: 'an audio encoder that converts audio into embeddings for audio-conditioned generation',
|
||||
upscale_models: 'an upscale model that increases image resolution while preserving or enhancing detail',
|
||||
latent_upscale_models: 'a latent upscale model that refines latents at higher resolution before decoding',
|
||||
style_models: 'a style model that transfers artistic style onto generated images',
|
||||
model_patches: 'a model patch that modifies or extends the behavior of an existing base model',
|
||||
partner_nodes: 'a cloud API model accessible through ComfyUI partner nodes without local hardware requirements'
|
||||
}
|
||||
|
||||
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
|
||||
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
|
||||
|
||||
const softwareAppJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: displayName,
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
operatingSystem: 'Any',
|
||||
url: canonicalURL.href,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('models.breadcrumb.home'),
|
||||
item: 'https://comfy.org'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('models.breadcrumb.models'),
|
||||
item: 'https://comfy.org/p/supported-models'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: displayName
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: [
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `What is ${displayName}?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: whatIsDescription
|
||||
}
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `How do I use ${displayName} in ComfyUI?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: model.docsUrl
|
||||
? `Follow the step-by-step tutorial at ${model.docsUrl}. You can also load any of the ${model.workflowCount} community workflow templates that use ${displayName} directly in ComfyUI.`
|
||||
: `Open ComfyUI and browse the ${model.workflowCount} community workflow templates that use ${displayName}. Load one as a starting point, then customize the nodes and parameters to fit your use case.`
|
||||
}
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `How many ComfyUI workflows use ${displayName}?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`
|
||||
}
|
||||
},
|
||||
{
|
||||
'@type': 'Question',
|
||||
name: `Is ${displayName} free to use in ComfyUI?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: `ComfyUI is free and open source. ${model.huggingFaceUrl ? `${displayName} weights are available to download from Hugging Face.` : `${displayName} is available as a cloud API through Comfy Cloud.`} You only pay for compute when running on Comfy Cloud; local inference on your own hardware is always free.`
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const pageTitle = `${displayName} in ComfyUI`
|
||||
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${pageTitle} — Comfy`}
|
||||
description={pageDescription}
|
||||
ogImage={model.thumbnailUrl}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(softwareAppJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(faqJsonLd)}
|
||||
/>
|
||||
</Fragment>
|
||||
|
||||
<ModelHeroSection
|
||||
displayName={displayName}
|
||||
huggingFaceUrl={model.huggingFaceUrl}
|
||||
docsUrl={model.docsUrl}
|
||||
blogUrl={model.blogUrl}
|
||||
hubSlug={model.hubSlug}
|
||||
workflowCount={model.workflowCount}
|
||||
directory={model.directory}
|
||||
/>
|
||||
|
||||
<section class="mx-auto max-w-7xl px-6 py-16 lg:px-8">
|
||||
<h2 class="text-2xl font-bold text-primary-comfy-canvas lg:text-3xl">
|
||||
{t('models.whatIs.heading').replace('{name}', displayName)}
|
||||
</h2>
|
||||
<p class="mt-4 max-w-3xl text-base/relaxed text-primary-comfy-canvas/70 lg:text-lg/relaxed">
|
||||
{whatIsDescription}
|
||||
</p>
|
||||
{model.docsUrl && (
|
||||
<a
|
||||
href={model.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-6 inline-block text-primary-comfy-yellow hover:underline"
|
||||
>
|
||||
{t('models.whatIs.tutorialLink')}
|
||||
</a>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{/* TODO: workflow gallery — pulls from hub API */}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
{/* TODO: shared HowComfyWorks component */}
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { models } from '../../../config/models'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
const title = t('models.index.title')
|
||||
const subtitle = t('models.index.subtitle')
|
||||
|
||||
const dirLabel: Record<string, string> = {
|
||||
diffusion_models: 'Diffusion',
|
||||
checkpoints: 'Checkpoint',
|
||||
loras: 'LoRA',
|
||||
controlnet: 'ControlNet',
|
||||
clip_vision: 'CLIP Vision',
|
||||
model_patches: 'Patch',
|
||||
vae: 'VAE',
|
||||
text_encoders: 'Text Encoder',
|
||||
audio_encoders: 'Audio Encoder',
|
||||
latent_upscale_models: 'Latent Upscale',
|
||||
upscale_models: 'Upscale',
|
||||
style_models: 'Style',
|
||||
partner_nodes: 'Partner Node'
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${title} — Comfy`}
|
||||
description={subtitle}
|
||||
>
|
||||
<div class="mx-auto max-w-7xl px-6 py-16 lg:px-8 lg:py-24">
|
||||
<header class="mb-12">
|
||||
<p
|
||||
class="mb-2 text-sm font-medium uppercase tracking-widest text-primary-comfy-yellow"
|
||||
>
|
||||
{t('models.hero.eyebrow')}
|
||||
</p>
|
||||
<h1 class="text-4xl font-bold text-primary-comfy-canvas lg:text-6xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p class="mt-4 max-w-2xl text-lg text-primary-comfy-canvas/70">
|
||||
{subtitle}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{models.map((model) => (
|
||||
<li>
|
||||
<a
|
||||
href={`/p/supported-models/${model.slug}`}
|
||||
class="flex h-full flex-col gap-3 rounded-xl border border-white/10 p-5 transition-colors hover:border-white/25 hover:bg-white/5"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="text-base font-semibold leading-tight text-primary-comfy-canvas">
|
||||
{model.displayName}
|
||||
</span>
|
||||
<span class="shrink-0 rounded-full bg-white/10 px-2 py-0.5 text-xs font-medium text-primary-comfy-canvas/70">
|
||||
{dirLabel[model.directory] ?? model.directory}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-auto text-xs text-primary-comfy-canvas/50">
|
||||
{model.workflowCount} workflows
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -2,17 +2,13 @@
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ContentSection from '../components/common/ContentSection.vue'
|
||||
import HeroSection from '../components/legal/HeroSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Terms of Service — Comfy"
|
||||
description="Terms of Service governing use of the Comfy Products, including Comfy Cloud, Comfy API, and Comfy Enterprise."
|
||||
description="Terms of Service for ComfyUI and related Comfy services."
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="Terms of Service" />
|
||||
<p class="text-primary-warm-gray mt-2 text-center text-sm">
|
||||
{t('tos.effectiveDateLabel')}: {t('tos.effectiveDate')}
|
||||
</p>
|
||||
<ContentSection prefix="tos" client:load />
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import HeroSection from '../../../components/cloud-nodes/HeroSection.vue'
|
||||
import PackGridSection from '../../../components/cloud-nodes/PackGridSection.vue'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../utils/escapeJsonLd'
|
||||
|
||||
const packs = await loadPacksForBuild()
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL('/zh-CN/cloud/supported-nodes', siteBase).href
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Comfy Cloud 支持的自定义节点包',
|
||||
url: pageUrl,
|
||||
numberOfItems: packs.length,
|
||||
itemListElement: packs.map((pack, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href,
|
||||
name: pack.displayName,
|
||||
image: pack.bannerUrl || pack.iconUrl
|
||||
}))
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('cloudNodes.meta.title', 'zh-CN')}
|
||||
description={t('cloudNodes.meta.description', 'zh-CN')}
|
||||
>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(itemListJsonLd)}
|
||||
/>
|
||||
<HeroSection locale="zh-CN" client:visible />
|
||||
<PackGridSection locale="zh-CN" packs={packs} client:visible />
|
||||
</BaseLayout>
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
|
||||
import type { Pack } from '../../../../data/cloudNodes'
|
||||
|
||||
import PackDetail from '../../../../components/cloud-nodes/PackDetail.vue'
|
||||
import BaseLayout from '../../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../../i18n/translations'
|
||||
import { loadPacksForBuild } from '../../../../utils/cloudNodes.build'
|
||||
import { escapeJsonLd } from '../../../../utils/escapeJsonLd'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
const packs = await loadPacksForBuild()
|
||||
return packs.map((pack) => ({
|
||||
params: { pack: pack.id },
|
||||
props: { pack }
|
||||
}))
|
||||
}
|
||||
|
||||
const { pack } = Astro.props as { pack: Pack }
|
||||
const fallbackDescription = t('cloudNodes.card.unavailableDescription', 'zh-CN')
|
||||
const description = pack.description || fallbackDescription
|
||||
const title = t('cloudNodes.detail.metaTitle', 'zh-CN').replace(
|
||||
'{pack}',
|
||||
pack.displayName
|
||||
)
|
||||
const metaDescription = t('cloudNodes.detail.metaDescription', 'zh-CN')
|
||||
.replace('{pack}', pack.displayName)
|
||||
.replace('{nodeCount}', String(pack.nodes.length))
|
||||
.replace('{description}', description)
|
||||
|
||||
const siteBase = Astro.site ?? new URL('https://comfy.org')
|
||||
const pageUrl = new URL(`/zh-CN/cloud/supported-nodes/${pack.id}`, siteBase).href
|
||||
|
||||
const softwareJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: pack.displayName,
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
applicationSubCategory: 'ComfyUI custom-node pack',
|
||||
operatingSystem: 'Comfy Cloud (managed)',
|
||||
url: pageUrl,
|
||||
description,
|
||||
image: pack.bannerUrl || pack.iconUrl,
|
||||
softwareVersion: pack.latestVersion,
|
||||
license: pack.license,
|
||||
codeRepository: pack.repoUrl,
|
||||
author: pack.publisher?.name
|
||||
? { '@type': 'Person', name: pack.publisher.name }
|
||||
: undefined,
|
||||
offers: { '@type': 'Offer', price: 0, priceCurrency: 'USD' }
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={metaDescription} ogImage={pack.bannerUrl}>
|
||||
<script
|
||||
is:inline
|
||||
slot="head"
|
||||
type="application/ld+json"
|
||||
set:html={escapeJsonLd(softwareJsonLd)}
|
||||
/>
|
||||
<PackDetail pack={pack} locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ModelsHeroSection from '../../components/models/ModelsHeroSection.vue'
|
||||
import ModelCreationsSection from '../../components/models/ModelCreationsSection.vue'
|
||||
import ModelsShowcaseSection from '../../components/models/ModelsShowcaseSection.vue'
|
||||
import ProductShowcaseSection from '../../components/home/ProductShowcaseSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="模型 — Comfy"
|
||||
description="在 ComfyUI 中运行世界领先的 AI 模型。浏览所有支持的模型及社区工作流模板。"
|
||||
>
|
||||
<ModelsHeroSection
|
||||
locale="zh-CN"
|
||||
modelName="Grok Imagine"
|
||||
ctaHref="/zh-CN/p/supported-models/grok-image"
|
||||
mediaSrc="https://media.comfy.org/website/gallery/gallery.webp"
|
||||
mediaAlt="Grok Imagine output created with ComfyUI"
|
||||
/>
|
||||
<ModelCreationsSection client:load locale="zh-CN" />
|
||||
<ModelsShowcaseSection locale="zh-CN" />
|
||||
<ProductShowcaseSection client:load locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
14
apps/website/src/pages/zh-CN/terms-of-service.astro
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ContentSection from '../../components/common/ContentSection.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="服务条款 — Comfy"
|
||||
description="ComfyUI 及相关 Comfy 服务的服务条款。"
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="服务条款" />
|
||||
<ContentSection prefix="tos" locale="zh-CN" client:load />
|
||||
</BaseLayout>
|
||||
@@ -22,7 +22,7 @@ function baseSnapshot(): RolesSnapshot {
|
||||
title: 'Design Engineer',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
jobUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
|
||||
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -50,7 +50,7 @@ function freshOutcome(droppedCount = 0): FetchOutcome {
|
||||
title: 'Design Engineer',
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
jobUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
|
||||
applyUrl: 'https://jobs.ashbyhq.com/comfy-org/x'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ function makeSnapshot(roleCount = 2): RolesSnapshot {
|
||||
title: `Snapshot Role ${i}`,
|
||||
department: 'Engineering',
|
||||
location: 'San Francisco',
|
||||
jobUrl: `https://jobs.ashbyhq.com/comfy-org/snapshot-${i}`
|
||||
applyUrl: `https://jobs.ashbyhq.com/comfy-org/snapshot-${i}`
|
||||
}))
|
||||
return {
|
||||
fetchedAt: '2026-04-01T00:00:00.000Z',
|
||||
@@ -85,7 +85,26 @@ describe('fetchRolesForBuild', () => {
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.droppedCount).toBe(0)
|
||||
expect(outcome.snapshot.departments).toHaveLength(1)
|
||||
expect(outcome.snapshot.departments[0]!.roles[0]!.jobUrl).toBe(
|
||||
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toMatch(
|
||||
/design-engineer\/apply$/
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to jobUrl when applyUrl is missing and keeps the role', async () => {
|
||||
const job = validJob()
|
||||
delete (job as Record<string, unknown>).applyUrl
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({ apiVersion: '1', jobs: [job] })
|
||||
)
|
||||
const outcome = await fetchRolesForBuild({
|
||||
apiKey: KEY,
|
||||
boardName: BOARD,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.snapshot.departments[0]!.roles[0]!.applyUrl).toBe(
|
||||
'https://jobs.ashbyhq.com/comfy-org/design-engineer'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -243,13 +243,13 @@ function groupByDepartment(jobs: readonly AshbyJobPosting[]): Department[] {
|
||||
}
|
||||
|
||||
function toDomainRole(job: AshbyJobPosting, department: string): Role {
|
||||
const jobUrl = job.jobUrl
|
||||
const applyUrl = job.applyUrl ?? job.jobUrl
|
||||
return {
|
||||
id: createHash('sha1').update(jobUrl).digest('hex').slice(0, 16),
|
||||
id: createHash('sha1').update(applyUrl).digest('hex').slice(0, 16),
|
||||
title: job.title,
|
||||
department: capitalize(department),
|
||||
location: (job.location ?? '').trim() || DEFAULT_LOCATION,
|
||||
jobUrl
|
||||
applyUrl
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { FetchOutcome } from './cloudNodes'
|
||||
import type { NodesSnapshot } from '../data/cloudNodes'
|
||||
|
||||
import {
|
||||
reportCloudNodesOutcome,
|
||||
resetCloudNodesReporterForTests
|
||||
} from './cloudNodes.ci'
|
||||
|
||||
function baseSnapshot(): NodesSnapshot {
|
||||
return {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
packs: [
|
||||
{
|
||||
id: 'pack-id',
|
||||
displayName: 'Pack',
|
||||
nodes: [
|
||||
{
|
||||
name: 'NodeClass',
|
||||
displayName: 'Node Class',
|
||||
category: 'misc'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function freshOutcome(droppedCount = 0): FetchOutcome {
|
||||
return {
|
||||
status: 'fresh',
|
||||
droppedCount,
|
||||
droppedNodes:
|
||||
droppedCount === 0
|
||||
? []
|
||||
: [{ name: 'BadNode', reason: 'invalid display_name' }],
|
||||
snapshot: baseSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
describe('reportCloudNodesOutcome', () => {
|
||||
let writeSpy: ReturnType<typeof vi.spyOn>
|
||||
let summaryDir: string
|
||||
let summaryPath: string
|
||||
const originalSummary = process.env.GITHUB_STEP_SUMMARY
|
||||
|
||||
beforeEach(() => {
|
||||
resetCloudNodesReporterForTests()
|
||||
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
summaryDir = mkdtempSync(join(tmpdir(), 'cloud-nodes-summary-'))
|
||||
summaryPath = join(summaryDir, 'summary.md')
|
||||
writeFileSync(summaryPath, '')
|
||||
process.env.GITHUB_STEP_SUMMARY = summaryPath
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
writeSpy.mockRestore()
|
||||
rmSync(summaryDir, { recursive: true, force: true })
|
||||
if (originalSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY
|
||||
else process.env.GITHUB_STEP_SUMMARY = originalSummary
|
||||
})
|
||||
|
||||
it('emits nothing on a clean fresh outcome', () => {
|
||||
reportCloudNodesOutcome(freshOutcome(0))
|
||||
expect(writeSpy).not.toHaveBeenCalled()
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Fresh')
|
||||
})
|
||||
|
||||
it('emits exactly one set of annotations across repeated calls', () => {
|
||||
reportCloudNodesOutcome(freshOutcome(1))
|
||||
reportCloudNodesOutcome(freshOutcome(1))
|
||||
expect(writeSpy).toHaveBeenCalledTimes(1)
|
||||
const annotation = writeSpy.mock.calls[0]?.[0] as string
|
||||
expect(annotation).toContain(
|
||||
'::warning title=Cloud nodes: dropped 1 invalid'
|
||||
)
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Dropped')
|
||||
})
|
||||
|
||||
it('emits ::error for auth failures in a stale outcome', () => {
|
||||
reportCloudNodesOutcome({
|
||||
status: 'stale',
|
||||
reason: 'HTTP 401 Unauthorized',
|
||||
snapshot: baseSnapshot()
|
||||
})
|
||||
const annotation = writeSpy.mock.calls[0]?.[0] as string
|
||||
expect(annotation).toContain(
|
||||
'::error title=Cloud nodes authentication failed'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits ::warning for missing-env stale outcomes', () => {
|
||||
reportCloudNodesOutcome({
|
||||
status: 'stale',
|
||||
reason: 'missing WEBSITE_CLOUD_API_KEY',
|
||||
snapshot: baseSnapshot()
|
||||
})
|
||||
const annotation = writeSpy.mock.calls[0]?.[0] as string
|
||||
expect(annotation).toContain('::warning title=Cloud nodes integration')
|
||||
})
|
||||
|
||||
it('emits ::error for a failed outcome and writes no fresh-only sections', () => {
|
||||
reportCloudNodesOutcome({
|
||||
status: 'failed',
|
||||
reason: 'HTTP 500 Server Error'
|
||||
})
|
||||
const annotation = writeSpy.mock.calls[0]?.[0] as string
|
||||
expect(annotation).toContain('::error title=Cloud nodes fetch failed')
|
||||
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
|
||||
})
|
||||
|
||||
it('does not throw when GITHUB_STEP_SUMMARY is not set', () => {
|
||||
delete process.env.GITHUB_STEP_SUMMARY
|
||||
expect(() => reportCloudNodesOutcome(freshOutcome(0))).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,115 +0,0 @@
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
import type { FetchOutcome } from './cloudNodes'
|
||||
|
||||
let hasReported = false
|
||||
|
||||
export function resetCloudNodesReporterForTests(): void {
|
||||
hasReported = false
|
||||
}
|
||||
|
||||
export function reportCloudNodesOutcome(outcome: FetchOutcome): void {
|
||||
if (hasReported) return
|
||||
hasReported = true
|
||||
|
||||
const lines = buildAnnotations(outcome)
|
||||
for (const line of lines) {
|
||||
process.stdout.write(`${line}\n`)
|
||||
}
|
||||
|
||||
const summaryPath = process.env.GITHUB_STEP_SUMMARY
|
||||
if (summaryPath) {
|
||||
try {
|
||||
appendFileSync(summaryPath, buildStepSummary(outcome))
|
||||
} catch {
|
||||
// Writing the summary is best-effort; do not fail the build if the
|
||||
// runner's summary file is unavailable (e.g. local dev).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildAnnotations(outcome: FetchOutcome): string[] {
|
||||
if (outcome.status === 'fresh') {
|
||||
if (outcome.droppedCount === 0) return []
|
||||
const nodeCount = outcome.droppedCount === 1 ? 'node' : 'nodes'
|
||||
const drops = outcome.droppedNodes
|
||||
.map((d) => {
|
||||
const name = escapeAnnotation(d.name ? `"${d.name}"` : '(unnamed)')
|
||||
const reason = escapeAnnotation(d.reason)
|
||||
return ` - ${name}: ${reason}`
|
||||
})
|
||||
.join('%0A')
|
||||
return [
|
||||
`::warning title=Cloud nodes: dropped ${outcome.droppedCount} invalid ${nodeCount}::Dropped nodes:%0A${drops}%0A%0AAction items:%0A 1. Verify node definitions returned by cloud /api/object_info.%0A 2. If a valid node shape changed, update @comfyorg/object-info-parser/src/schemas/nodeDefSchema.ts and add tests.%0A 3. Dropped nodes are not shown on /cloud/supported-nodes until fixed.`
|
||||
]
|
||||
}
|
||||
|
||||
if (outcome.status === 'stale') {
|
||||
return [staleAnnotation(outcome.reason)]
|
||||
}
|
||||
|
||||
return [
|
||||
`::error title=Cloud nodes fetch failed and no snapshot is available::Cannot build cloud nodes page without data.%0A%0AReason: ${escapeAnnotation(outcome.reason)}%0A%0AAction items:%0A 1. Run \`pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot\` locally with a valid WEBSITE_CLOUD_API_KEY.%0A 2. Commit apps/website/src/data/cloud-nodes.snapshot.json.%0A 3. Push and re-run CI.`
|
||||
]
|
||||
}
|
||||
|
||||
function staleAnnotation(reason: string): string {
|
||||
const escaped = escapeAnnotation(reason)
|
||||
if (reason.startsWith('missing ')) {
|
||||
return `::warning title=Cloud nodes integration::${escaped}. Falling back to committed snapshot.%0A%0AAction items:%0A 1. If you're a contributor without key access, this is expected. The snapshot will be used.%0A 2. If this is CI, check that the \`WEBSITE_CLOUD_API_KEY\` secret exists in the repo and is referenced in .github/workflows/ci-website-build.yaml.`
|
||||
}
|
||||
if (reason.startsWith('HTTP 401') || reason.startsWith('HTTP 403')) {
|
||||
return `::error title=Cloud nodes authentication failed::${escaped}. The WEBSITE_CLOUD_API_KEY is missing, invalid, or revoked. Build continues with the last-known-good snapshot.%0A%0AAction items:%0A 1. Verify the Cloud API key is active and scoped for /api/object_info.%0A 2. Update the \`WEBSITE_CLOUD_API_KEY\` secret in GitHub Actions and Vercel.%0A 3. Re-run this workflow.`
|
||||
}
|
||||
if (reason.startsWith('envelope')) {
|
||||
return `::error title=Cloud nodes schema mismatch::${escaped}. The Cloud API contract likely changed. Build continues with the snapshot, but future updates will fail until schema is fixed.%0A%0AAction items:%0A 1. Check cloud/services/ingest/openapi.yaml for /api/object_info changes.%0A 2. Update apps/website/src/utils/cloudNodes.schema.ts and @comfyorg/object-info-parser schema as needed.`
|
||||
}
|
||||
return `::warning title=Cloud nodes API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check cloud service health.%0A 2. Re-run this workflow once cloud.comfy.org is healthy.`
|
||||
}
|
||||
|
||||
function escapeAnnotation(value: string): string {
|
||||
return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
|
||||
}
|
||||
|
||||
function buildStepSummary(outcome: FetchOutcome): string {
|
||||
const header = '## ☁️ Cloud nodes\n'
|
||||
const rows: Array<[string, string]> = []
|
||||
|
||||
if (outcome.status === 'fresh') {
|
||||
rows.push(['Status', '✅ Fresh (fetched from Cloud API)'])
|
||||
rows.push(['Packs', String(outcome.snapshot.packs.length)])
|
||||
rows.push([
|
||||
'Nodes',
|
||||
String(outcome.snapshot.packs.reduce((n, p) => n + p.nodes.length, 0))
|
||||
])
|
||||
rows.push(['Dropped', String(outcome.droppedCount)])
|
||||
} else if (outcome.status === 'stale') {
|
||||
rows.push(['Status', '⚠️ Stale (using snapshot — Cloud fetch failed)'])
|
||||
rows.push(['Packs', String(outcome.snapshot.packs.length)])
|
||||
rows.push([
|
||||
'Nodes',
|
||||
String(outcome.snapshot.packs.reduce((n, p) => n + p.nodes.length, 0))
|
||||
])
|
||||
rows.push(['Reason', outcome.reason])
|
||||
rows.push(['Snapshot age', describeSnapshotAge(outcome.snapshot.fetchedAt)])
|
||||
} else {
|
||||
rows.push(['Status', '❌ Failed (no snapshot available)'])
|
||||
rows.push(['Reason', outcome.reason])
|
||||
}
|
||||
|
||||
const table =
|
||||
'| | |\n|---|---|\n' +
|
||||
rows.map(([k, v]) => `| **${k}** | ${v} |`).join('\n') +
|
||||
'\n'
|
||||
|
||||
return `${header}${table}\n`
|
||||
}
|
||||
|
||||
function describeSnapshotAge(fetchedAt: string): string {
|
||||
const fetched = new Date(fetchedAt).getTime()
|
||||
if (Number.isNaN(fetched)) return 'unknown'
|
||||
const days = Math.floor((Date.now() - fetched) / 86_400_000)
|
||||
if (days <= 0) return 'today'
|
||||
if (days === 1) return '1 day'
|
||||
return `${days} days`
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_REGISTRY_BASE_URL,
|
||||
fetchRegistryPacks
|
||||
} from './cloudNodes.registry'
|
||||
|
||||
function jsonResponse(
|
||||
body: unknown,
|
||||
init: Partial<ResponseInit> = {}
|
||||
): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...init
|
||||
})
|
||||
}
|
||||
|
||||
describe('fetchRegistryPacks', () => {
|
||||
it('requests node ids in batches of 50 with matching limit param', 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)
|
||||
expect(firstCallUrl.searchParams.get('limit')).toBe('50')
|
||||
const lastCallUrl = new URL(String(fetchImpl.mock.calls[2]?.[0]))
|
||||
expect(lastCallUrl.searchParams.getAll('node_id')).toHaveLength(20)
|
||||
expect(lastCallUrl.searchParams.get('limit')).toBe('20')
|
||||
})
|
||||
|
||||
it('survives the server defaulting to a small page size (regression for missing limit)', async () => {
|
||||
// Mock applies the server's pre-fix behavior: default limit=10 silently
|
||||
// truncates batches with more node_id filters than the page size.
|
||||
const ids = Array.from({ length: 30 }, (_, i) => `pack-${i}`)
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
const requestedLimit = Number(url.searchParams.get('limit') ?? '10')
|
||||
const batchIds = url.searchParams
|
||||
.getAll('node_id')
|
||||
.slice(0, requestedLimit)
|
||||
return jsonResponse({
|
||||
nodes: batchIds.map((id) => ({ id, name: id })),
|
||||
total: batchIds.length,
|
||||
page: 1,
|
||||
limit: requestedLimit
|
||||
})
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacks(ids, {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
const enriched = [...result.values()].filter((pack) => pack !== null)
|
||||
expect(enriched).toHaveLength(30)
|
||||
})
|
||||
|
||||
it('accepts null values for optional registry fields and normalizes them to undefined', async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
jsonResponse({
|
||||
nodes: [
|
||||
{
|
||||
id: 'pack-with-nulls',
|
||||
name: 'Pack With Nulls',
|
||||
description: null,
|
||||
icon: null,
|
||||
banner_url: null,
|
||||
supported_os: null,
|
||||
supported_accelerators: null,
|
||||
publisher: null,
|
||||
latest_version: null,
|
||||
downloads: 42
|
||||
}
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 50
|
||||
})
|
||||
)
|
||||
|
||||
const result = await fetchRegistryPacks(['pack-with-nulls'], {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
const pack = result.get('pack-with-nulls')
|
||||
expect(pack).not.toBeNull()
|
||||
expect(pack?.downloads).toBe(42)
|
||||
expect(pack?.description).toBeUndefined()
|
||||
expect(pack?.supported_os).toBeUndefined()
|
||||
expect(pack?.supported_accelerators).toBeUndefined()
|
||||
expect(pack?.publisher).toBeUndefined()
|
||||
expect(pack?.latest_version).toBeUndefined()
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -1,211 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { components } from '@comfyorg/registry-types'
|
||||
|
||||
export const DEFAULT_REGISTRY_BASE_URL = 'https://api.comfy.org'
|
||||
const DEFAULT_TIMEOUT_MS = 5_000
|
||||
const BATCH_SIZE = 50
|
||||
|
||||
export type RegistryPack = components['schemas']['Node']
|
||||
|
||||
function nullToUndefined<T>(value: T | null | undefined): T | undefined {
|
||||
return value ?? undefined
|
||||
}
|
||||
|
||||
const optionalString = z.string().nullish().transform(nullToUndefined)
|
||||
const optionalNumber = z.number().nullish().transform(nullToUndefined)
|
||||
const optionalStringArray = z
|
||||
.array(z.string())
|
||||
.nullish()
|
||||
.transform(nullToUndefined)
|
||||
|
||||
const RegistryPackSchema = z
|
||||
.object({
|
||||
id: optionalString,
|
||||
name: optionalString,
|
||||
description: optionalString,
|
||||
icon: optionalString,
|
||||
banner_url: optionalString,
|
||||
repository: optionalString,
|
||||
license: optionalString,
|
||||
downloads: optionalNumber,
|
||||
github_stars: optionalNumber,
|
||||
created_at: optionalString,
|
||||
supported_os: optionalStringArray,
|
||||
supported_accelerators: optionalStringArray,
|
||||
publisher: z
|
||||
.object({
|
||||
id: optionalString,
|
||||
name: optionalString
|
||||
})
|
||||
.passthrough()
|
||||
.nullish()
|
||||
.transform(nullToUndefined),
|
||||
latest_version: z
|
||||
.object({
|
||||
version: optionalString,
|
||||
createdAt: optionalString
|
||||
})
|
||||
.passthrough()
|
||||
.nullish()
|
||||
.transform(nullToUndefined)
|
||||
})
|
||||
.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()
|
||||
params.set('limit', String(packIds.length))
|
||||
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)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const CloudNodesEnvelopeSchema = z.record(z.unknown())
|
||||
@@ -1,309 +0,0 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodesSnapshot } from '../data/cloudNodes'
|
||||
import type * as ObjectInfoParser from '@comfyorg/object-info-parser'
|
||||
|
||||
const fetchRegistryPacksMock = vi.hoisted(() => vi.fn(async () => new Map()))
|
||||
const sanitizeCallSpy = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./cloudNodes.registry', () => ({
|
||||
DEFAULT_REGISTRY_BASE_URL: 'https://api.comfy.org',
|
||||
fetchRegistryPacks: fetchRegistryPacksMock
|
||||
}))
|
||||
|
||||
vi.mock('@comfyorg/object-info-parser', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as typeof ObjectInfoParser
|
||||
return {
|
||||
...actual,
|
||||
sanitizeUserContent: (
|
||||
defs: Parameters<typeof actual.sanitizeUserContent>[0]
|
||||
) => {
|
||||
sanitizeCallSpy(defs)
|
||||
return actual.sanitizeUserContent(defs)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
fetchCloudNodesForBuild,
|
||||
resetCloudNodesFetcherForTests
|
||||
} from './cloudNodes'
|
||||
|
||||
const BASE_URL = 'https://cloud.test'
|
||||
const KEY = 'cloud-secret'
|
||||
|
||||
function validNode(
|
||||
overrides: Partial<Record<string, unknown>> = {}
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
name: 'ImpactNode',
|
||||
display_name: 'Impact Node',
|
||||
description: 'Node description',
|
||||
category: 'impact/testing',
|
||||
output_node: false,
|
||||
python_module: 'custom_nodes.comfyui-impact-pack.nodes',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...init
|
||||
})
|
||||
}
|
||||
|
||||
function makeSnapshot(packCount = 1): NodesSnapshot {
|
||||
const packs = Array.from({ length: packCount }, (_, i) => ({
|
||||
id: `snapshot-pack-${i}`,
|
||||
displayName: `Snapshot Pack ${i}`,
|
||||
nodes: [
|
||||
{
|
||||
name: `SnapshotNode${i}`,
|
||||
displayName: `Snapshot Node ${i}`,
|
||||
category: 'snapshot'
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
return {
|
||||
fetchedAt: '2026-04-01T00:00:00.000Z',
|
||||
packs
|
||||
}
|
||||
}
|
||||
|
||||
function withSnapshotDir(snapshot: NodesSnapshot | null): URL {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cloud-nodes-test-'))
|
||||
const file = join(dir, 'cloud-nodes.snapshot.json')
|
||||
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
|
||||
return pathToFileURL(file)
|
||||
}
|
||||
|
||||
describe('fetchCloudNodesForBuild', () => {
|
||||
const savedCloudApiKey = process.env.WEBSITE_CLOUD_API_KEY
|
||||
|
||||
beforeEach(() => {
|
||||
resetCloudNodesFetcherForTests()
|
||||
fetchRegistryPacksMock.mockReset()
|
||||
fetchRegistryPacksMock.mockResolvedValue(new Map())
|
||||
sanitizeCallSpy.mockReset()
|
||||
delete process.env.WEBSITE_CLOUD_API_KEY
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey
|
||||
})
|
||||
|
||||
it('returns fresh when API succeeds', async () => {
|
||||
fetchRegistryPacksMock.mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'comfyui-impact-pack',
|
||||
{
|
||||
id: 'comfyui-impact-pack',
|
||||
name: 'ComfyUI Impact Pack',
|
||||
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
|
||||
}
|
||||
]
|
||||
])
|
||||
)
|
||||
|
||||
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.droppedCount).toBe(0)
|
||||
expect(outcome.snapshot.packs).toHaveLength(1)
|
||||
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
|
||||
'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
|
||||
)
|
||||
})
|
||||
|
||||
it('drops invalid nodes individually and keeps valid nodes', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({
|
||||
ValidNode: validNode({ name: 'ValidNode' }),
|
||||
BrokenNode: {
|
||||
name: 'BrokenNode',
|
||||
python_module: 'custom_nodes.some-pack'
|
||||
}
|
||||
})
|
||||
)
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(outcome.status).toBe('fresh')
|
||||
if (outcome.status !== 'fresh') return
|
||||
expect(outcome.droppedCount).toBe(1)
|
||||
expect(outcome.droppedNodes[0]?.name).toBe('BrokenNode')
|
||||
expect(outcome.snapshot.packs[0]?.nodes).toHaveLength(1)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('applies sanitizer before grouping', async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
response({
|
||||
LoadImage: validNode({
|
||||
name: 'LoadImage',
|
||||
python_module: 'nodes',
|
||||
input: {
|
||||
required: {
|
||||
image: [['private.png', 'public.webp'], {}]
|
||||
}
|
||||
}
|
||||
}),
|
||||
ImpactNode: validNode({
|
||||
input: {
|
||||
required: {
|
||||
choice: [['safe', 'movie.mov'], {}]
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(sanitizeCallSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns stale with missing env when snapshot is present', async () => {
|
||||
const snapshot = makeSnapshot()
|
||||
const snapshotUrl = withSnapshotDir(snapshot)
|
||||
const fetchImpl = vi.fn()
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^missing /)
|
||||
expect(fetchImpl).not.toHaveBeenCalled()
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns failed when env and snapshot are missing', async () => {
|
||||
const snapshotUrl = withSnapshotDir(null)
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
snapshotUrl,
|
||||
fetchImpl: vi.fn() as unknown as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('failed')
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('does not retry on HTTP 401', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({}, { status: 401 }))
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^HTTP 401/)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('retries 5xx then falls back to snapshot', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response({}, { status: 503 }))
|
||||
const sleep = vi.fn(async () => undefined)
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
retryDelaysMs: [1, 1, 1],
|
||||
sleep,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(4)
|
||||
expect(sleep).toHaveBeenCalledTimes(3)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('falls back to snapshot on envelope schema mismatch', async () => {
|
||||
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
||||
const fetchImpl = vi.fn(async () => response(['unexpected-array-envelope']))
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
snapshotUrl,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('stale')
|
||||
if (outcome.status !== 'stale') return
|
||||
expect(outcome.reason).toMatch(/^envelope schema/)
|
||||
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('memoizes within a single process', async () => {
|
||||
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
||||
const opts = {
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
}
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
fetchCloudNodesForBuild(opts),
|
||||
fetchCloudNodesForBuild(opts)
|
||||
])
|
||||
|
||||
expect(a).toBe(b)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws when called twice with materially different options', async () => {
|
||||
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
||||
await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
fetchCloudNodesForBuild({
|
||||
apiKey: 'different-key',
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
).toThrow(/called twice with different options/)
|
||||
})
|
||||
|
||||
it('returns fresh even when registry enrichment fails', async () => {
|
||||
fetchRegistryPacksMock.mockResolvedValue(new Map())
|
||||
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
||||
const outcome = await fetchCloudNodesForBuild({
|
||||
apiKey: KEY,
|
||||
baseUrl: BASE_URL,
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
expect(outcome.status).toBe('fresh')
|
||||
})
|
||||
})
|
||||
@@ -1,355 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
import {
|
||||
groupNodesByPack,
|
||||
sanitizeUserContent,
|
||||
validateComfyNodeDef
|
||||
} from '@comfyorg/object-info-parser'
|
||||
|
||||
import type { RegistryPack } from './cloudNodes.registry'
|
||||
import type { NodesSnapshot, Pack, PackNode } from '../data/cloudNodes'
|
||||
|
||||
import bundledSnapshot from '../data/cloud-nodes.snapshot.json' with { type: 'json' }
|
||||
import { isNodesSnapshot } from '../data/cloudNodes'
|
||||
import { fetchRegistryPacks } from './cloudNodes.registry'
|
||||
import { CloudNodesEnvelopeSchema } from './cloudNodes.schema'
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
|
||||
|
||||
export interface DroppedNode {
|
||||
name: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type FetchOutcome =
|
||||
| {
|
||||
status: 'fresh'
|
||||
snapshot: NodesSnapshot
|
||||
droppedCount: number
|
||||
droppedNodes: DroppedNode[]
|
||||
}
|
||||
| { status: 'stale'; snapshot: NodesSnapshot; reason: string }
|
||||
| { status: 'failed'; reason: string }
|
||||
|
||||
interface FetchCloudNodesOptions {
|
||||
apiKey?: string
|
||||
baseUrl?: string
|
||||
timeoutMs?: number
|
||||
retryDelaysMs?: readonly number[]
|
||||
fetchImpl?: typeof fetch
|
||||
snapshotUrl?: URL
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
let inflight: Promise<FetchOutcome> | undefined
|
||||
let inflightOptions: FetchCloudNodesOptions | undefined
|
||||
|
||||
export function resetCloudNodesFetcherForTests(): void {
|
||||
inflight = undefined
|
||||
inflightOptions = undefined
|
||||
}
|
||||
|
||||
function optionsDifferMaterially(
|
||||
a: FetchCloudNodesOptions,
|
||||
b: FetchCloudNodesOptions
|
||||
): boolean {
|
||||
return (
|
||||
a.apiKey !== b.apiKey ||
|
||||
a.baseUrl !== b.baseUrl ||
|
||||
a.timeoutMs !== b.timeoutMs ||
|
||||
a.snapshotUrl?.href !== b.snapshotUrl?.href
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchCloudNodesForBuild(
|
||||
options: FetchCloudNodesOptions = {}
|
||||
): Promise<FetchOutcome> {
|
||||
if (inflight && inflightOptions) {
|
||||
if (optionsDifferMaterially(inflightOptions, options)) {
|
||||
throw new Error(
|
||||
'fetchCloudNodesForBuild called twice with different options; call resetCloudNodesFetcherForTests() between distinct configurations'
|
||||
)
|
||||
}
|
||||
return inflight
|
||||
}
|
||||
inflightOptions = options
|
||||
inflight = doFetchCloudNodesForBuild(options)
|
||||
return inflight
|
||||
}
|
||||
|
||||
async function doFetchCloudNodesForBuild(
|
||||
options: FetchCloudNodesOptions
|
||||
): Promise<FetchOutcome> {
|
||||
const apiKey = options.apiKey ?? process.env.WEBSITE_CLOUD_API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
return fallback('missing WEBSITE_CLOUD_API_KEY', options.snapshotUrl)
|
||||
}
|
||||
|
||||
const result = await tryFetchAndParse(apiKey, options)
|
||||
if (result.kind === 'ok') {
|
||||
return {
|
||||
status: 'fresh',
|
||||
snapshot: {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
packs: result.packs
|
||||
},
|
||||
droppedCount: result.droppedNodes.length,
|
||||
droppedNodes: result.droppedNodes
|
||||
}
|
||||
}
|
||||
|
||||
return fallback(result.reason, options.snapshotUrl)
|
||||
}
|
||||
|
||||
async function fallback(
|
||||
reason: string,
|
||||
snapshotUrl: URL | undefined
|
||||
): Promise<FetchOutcome> {
|
||||
const snapshot = await readSnapshot(snapshotUrl)
|
||||
if (snapshot) return { status: 'stale', snapshot, reason }
|
||||
return { status: 'failed', reason }
|
||||
}
|
||||
|
||||
interface FetchOk {
|
||||
kind: 'ok'
|
||||
packs: Pack[]
|
||||
droppedNodes: DroppedNode[]
|
||||
}
|
||||
|
||||
interface FetchErr {
|
||||
kind: 'err'
|
||||
reason: string
|
||||
}
|
||||
|
||||
async function tryFetchAndParse(
|
||||
apiKey: string,
|
||||
options: FetchCloudNodesOptions
|
||||
): Promise<FetchOk | FetchErr> {
|
||||
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
const retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS
|
||||
const fetchImpl = options.fetchImpl ?? fetch
|
||||
const sleep = options.sleep ?? defaultSleep
|
||||
|
||||
const url = `${baseUrl}/api/object_info`
|
||||
|
||||
let lastReason = 'unknown error'
|
||||
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
|
||||
if (attempt > 0) await sleep(retryDelaysMs[attempt - 1])
|
||||
|
||||
const response = await callOnce(fetchImpl, url, apiKey, timeoutMs)
|
||||
if (response.kind === 'err') {
|
||||
lastReason = response.reason
|
||||
if (!response.retryable) return response
|
||||
continue
|
||||
}
|
||||
|
||||
const envelope = CloudNodesEnvelopeSchema.safeParse(response.body)
|
||||
if (!envelope.success) {
|
||||
return {
|
||||
kind: 'err',
|
||||
reason: `envelope schema validation failed: ${envelope.error.issues
|
||||
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
|
||||
.join('; ')}`
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(envelope.data).length === 0) {
|
||||
return {
|
||||
kind: 'err',
|
||||
reason:
|
||||
'envelope schema validation failed: <root>: expected non-empty object'
|
||||
}
|
||||
}
|
||||
|
||||
return parseCloudNodes(envelope.data, options)
|
||||
}
|
||||
|
||||
return { kind: 'err', reason: lastReason }
|
||||
}
|
||||
|
||||
type CallResponse =
|
||||
| { kind: 'ok'; body: unknown }
|
||||
| { kind: 'err'; reason: string; retryable: boolean }
|
||||
|
||||
async function callOnce(
|
||||
fetchImpl: typeof fetch,
|
||||
url: string,
|
||||
apiKey: string,
|
||||
timeoutMs: number
|
||||
): Promise<CallResponse> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const res = await fetchImpl(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-API-Key': apiKey
|
||||
},
|
||||
signal: controller.signal
|
||||
})
|
||||
if (res.ok) {
|
||||
return { kind: 'ok', body: await res.json() }
|
||||
}
|
||||
const retryable =
|
||||
res.status === 429 || (res.status >= 500 && res.status < 600)
|
||||
return {
|
||||
kind: 'err',
|
||||
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
|
||||
retryable
|
||||
}
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof Error
|
||||
? `network error: ${error.message}`
|
||||
: 'network error'
|
||||
return { kind: 'err', reason, retryable: true }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function parseCloudNodes(
|
||||
envelope: Record<string, unknown>,
|
||||
options: FetchCloudNodesOptions
|
||||
): Promise<FetchOk> {
|
||||
const validDefs: Record<string, ReturnType<typeof validateComfyNodeDef>> = {}
|
||||
const droppedNodes: DroppedNode[] = []
|
||||
|
||||
for (const [name, rawDef] of Object.entries(envelope)) {
|
||||
let validationError = 'unknown validation error'
|
||||
const parsed = validateComfyNodeDef(rawDef, (error) => {
|
||||
validationError = error
|
||||
})
|
||||
if (!parsed) {
|
||||
droppedNodes.push({ name, reason: validationError })
|
||||
continue
|
||||
}
|
||||
validDefs[name] = parsed
|
||||
}
|
||||
|
||||
const sanitizedDefs = sanitizeUserContent(
|
||||
validDefs as Record<string, NonNullable<(typeof validDefs)[string]>>
|
||||
)
|
||||
const grouped = groupNodesByPack(sanitizedDefs)
|
||||
|
||||
let registryMap = new Map<string, RegistryPack | null>()
|
||||
try {
|
||||
registryMap = await fetchRegistryPacks(
|
||||
grouped.map((pack) => pack.id),
|
||||
{ fetchImpl: options.fetchImpl }
|
||||
)
|
||||
} catch {
|
||||
registryMap = new Map()
|
||||
}
|
||||
|
||||
const packs = grouped.map((pack) =>
|
||||
toDomainPack(
|
||||
pack.id,
|
||||
pack.displayName,
|
||||
pack.nodes,
|
||||
registryMap.get(pack.id)
|
||||
)
|
||||
)
|
||||
|
||||
return { kind: 'ok', packs, droppedNodes }
|
||||
}
|
||||
|
||||
function safeExternalUrl(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
|
||||
if (!url.host) return undefined
|
||||
return url.toString()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function toDomainPack(
|
||||
packId: string,
|
||||
fallbackDisplayName: string,
|
||||
nodes: Array<{
|
||||
className: string
|
||||
def: {
|
||||
display_name: string
|
||||
category: string
|
||||
description: string
|
||||
deprecated?: boolean
|
||||
experimental?: boolean
|
||||
}
|
||||
}>,
|
||||
registryPack: RegistryPack | null | undefined
|
||||
): Pack {
|
||||
return {
|
||||
id: packId,
|
||||
registryId: registryPack?.id,
|
||||
displayName: registryPack?.name?.trim() || fallbackDisplayName || packId,
|
||||
description: registryPack?.description?.trim() || undefined,
|
||||
bannerUrl: safeExternalUrl(registryPack?.banner_url),
|
||||
iconUrl: safeExternalUrl(registryPack?.icon),
|
||||
repoUrl: safeExternalUrl(registryPack?.repository),
|
||||
publisher: registryPack?.publisher?.id
|
||||
? {
|
||||
id: registryPack.publisher.id,
|
||||
name: registryPack.publisher.name
|
||||
}
|
||||
: undefined,
|
||||
downloads: registryPack?.downloads,
|
||||
githubStars: registryPack?.github_stars,
|
||||
latestVersion: registryPack?.latest_version?.version,
|
||||
license: registryPack?.license,
|
||||
lastUpdated:
|
||||
registryPack?.latest_version?.createdAt ?? registryPack?.created_at,
|
||||
supportedOs: registryPack?.supported_os,
|
||||
supportedAccelerators: registryPack?.supported_accelerators,
|
||||
nodes: nodes
|
||||
.map((node) => toDomainNode(node.className, node.def))
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName))
|
||||
}
|
||||
}
|
||||
|
||||
function toDomainNode(
|
||||
className: string,
|
||||
def: {
|
||||
display_name: string
|
||||
category: string
|
||||
description: string
|
||||
deprecated?: boolean
|
||||
experimental?: boolean
|
||||
}
|
||||
): PackNode {
|
||||
return {
|
||||
name: className,
|
||||
displayName: def.display_name,
|
||||
category: def.category,
|
||||
description: def.description || undefined,
|
||||
deprecated: def.deprecated,
|
||||
experimental: def.experimental
|
||||
}
|
||||
}
|
||||
|
||||
async function readSnapshot(
|
||||
snapshotUrl: URL | undefined
|
||||
): Promise<NodesSnapshot | null> {
|
||||
if (!snapshotUrl) {
|
||||
return isNodesSnapshot(bundledSnapshot) ? bundledSnapshot : null
|
||||
}
|
||||
try {
|
||||
const text = await readFile(snapshotUrl, 'utf8')
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (isNodesSnapshot(parsed)) return parsed
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Serialize an object to a JSON string safe to embed inside an inline
|
||||
* `<script type="application/ld+json">` tag.
|
||||
*
|
||||
* Pack metadata flows in from the public ComfyUI registry, where any user
|
||||
* can publish a pack with a `displayName`, `description`, or
|
||||
* `publisher.name` that contains `</script>` or `<!--`. Without escaping,
|
||||
* those sequences would close the surrounding `<script>` tag and allow
|
||||
* stored XSS.
|
||||
*
|
||||
* Escapes:
|
||||
* - Every `<` (covers `</script>`, `<script`, `<!--`)
|
||||
* - U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR (which are
|
||||
* line terminators in HTML script context but valid characters
|
||||
* elsewhere in JSON, so `JSON.stringify` does not escape them)
|
||||
*
|
||||
* Reference: https://v8.dev/features/subsume-json
|
||||
*/
|
||||
export function escapeJsonLd(value: unknown): string {
|
||||
return JSON.stringify(value)
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/\u2028/g, '\\u2028')
|
||||
.replace(/\u2029/g, '\\u2029')
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
{
|
||||
"id": "06e5b524-5a40-40b9-b561-199dfab18cf0",
|
||||
"revision": 0,
|
||||
"last_node_id": 12,
|
||||
"last_link_id": 10,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "KSampler",
|
||||
"pos": [230, 110],
|
||||
"size": [270, 317.5666809082031],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "PrimitiveFloat",
|
||||
"pos": [-80.55032348632812, 375.2260443115233],
|
||||
"size": [270, 80.23332977294922],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": [10]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveFloat"
|
||||
},
|
||||
"widgets_values": [0]
|
||||
}
|
||||
],
|
||||
"links": [[10, 11, 0, 10, 4, "FLOAT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8264462809917354,
|
||||
"offset": [1335.8909766107738, 692.7345403667316]
|
||||
},
|
||||
"frontendVersion": "1.45.4"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CurveEditor",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 400],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "histogram",
|
||||
"type": "HISTOGRAM",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "curve",
|
||||
"type": "CURVE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CurveEditor"
|
||||
},
|
||||
"widgets_values": [
|
||||
{
|
||||
"points": [
|
||||
[0.2, 0.7],
|
||||
[0.8, 0.3]
|
||||
],
|
||||
"interpolation": "linear"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -285,12 +285,10 @@ export class ComfyPage {
|
||||
|
||||
async setup({
|
||||
clearStorage = true,
|
||||
mockReleases = true,
|
||||
url
|
||||
mockReleases = true
|
||||
}: {
|
||||
clearStorage?: boolean
|
||||
mockReleases?: boolean
|
||||
url?: string
|
||||
} = {}) {
|
||||
// Mock release endpoint to prevent changelog popups (before navigation)
|
||||
if (mockReleases) {
|
||||
@@ -322,7 +320,7 @@ export class ComfyPage {
|
||||
}, this.id)
|
||||
}
|
||||
|
||||
await this.goto({ url })
|
||||
await this.goto()
|
||||
|
||||
await this.page.waitForFunction(() => document.fonts.ready)
|
||||
await this.waitForAppReady()
|
||||
@@ -349,8 +347,8 @@ export class ComfyPage {
|
||||
return assetPath(fileName)
|
||||
}
|
||||
|
||||
async goto({ url }: { url?: string } = {}) {
|
||||
await this.page.goto(url ? new URL(url, this.url).toString() : this.url)
|
||||
async goto() {
|
||||
await this.page.goto(this.url)
|
||||
}
|
||||
|
||||
async nextFrame() {
|
||||
|
||||
@@ -246,18 +246,4 @@ 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,16 +6,12 @@ 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> {
|
||||
@@ -40,7 +36,16 @@ export class ContextMenu {
|
||||
}
|
||||
|
||||
async isVisible(): Promise<boolean> {
|
||||
return await this.anyMenu.isVisible()
|
||||
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
|
||||
}
|
||||
|
||||
async assertHasItems(items: string[]): Promise<void> {
|
||||
@@ -53,7 +58,7 @@ export class ContextMenu {
|
||||
|
||||
async openFor(locator: Locator): Promise<this> {
|
||||
await locator.click({ button: 'right' })
|
||||
await expect(this.anyMenu).toBeVisible()
|
||||
await expect.poll(() => this.isVisible()).toBe(true)
|
||||
return this
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,11 @@ import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class QueuePanel {
|
||||
readonly overlayToggle: Locator
|
||||
readonly overlay: Locator
|
||||
readonly moreOptionsButton: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.overlayToggle = page.getByTestId(TestIds.queue.overlayToggle)
|
||||
this.overlay = page.getByTestId(TestIds.queue.progressOverlay)
|
||||
this.moreOptionsButton = this.overlay.getByLabel(/More options/i)
|
||||
this.moreOptionsButton = page.getByLabel(/More options/i).first()
|
||||
}
|
||||
|
||||
async openClearHistoryDialog() {
|
||||
|
||||
@@ -95,7 +95,6 @@ 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')
|
||||
@@ -104,7 +103,6 @@ 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) {
|
||||
@@ -386,14 +384,11 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
return this.page.locator('.p-contextmenu').getByText(label)
|
||||
}
|
||||
|
||||
override async open({ waitForAssets = true } = {}) {
|
||||
override async open() {
|
||||
// Remove any toast notifications that may overlay the sidebar button
|
||||
await this.dismissToasts()
|
||||
await super.open()
|
||||
await this.generatedTab.waitFor({ state: 'visible' })
|
||||
if (waitForAssets) {
|
||||
await this.waitForAssets()
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss all visible toast notifications by clicking their close buttons. */
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
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,11 +2,6 @@ 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'>
|
||||
@@ -31,33 +26,3 @@ 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,7 +2,34 @@ import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { dragByIndex } from '@e2e/fixtures/utils/dragAndDrop'
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
|
||||
export class BuilderSelectHelper {
|
||||
/** All IoItem locators in the current step sidebar. */
|
||||
|
||||
@@ -6,71 +6,6 @@ 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,
|
||||
@@ -85,20 +20,43 @@ export class ClipboardHelper {
|
||||
await this.keyboard.ctrlSend('KeyV', locator ?? null)
|
||||
}
|
||||
|
||||
async pasteFile(
|
||||
filePath: string,
|
||||
{ mode = 'keyboard' }: PasteFileOptions = {}
|
||||
): Promise<void> {
|
||||
const payload = readFilePayload(filePath)
|
||||
async pasteFile(filePath: string): Promise<void> {
|
||||
const buffer = readFileSync(filePath)
|
||||
const bufferArray = [...new Uint8Array(buffer)]
|
||||
const fileName = basename(filePath)
|
||||
const fileType = getMimeType(fileName)
|
||||
|
||||
if (mode === 'keyboard') {
|
||||
await interceptNextFilePaste(this.page, payload)
|
||||
await this.paste()
|
||||
return
|
||||
}
|
||||
// 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()
|
||||
|
||||
// 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)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
176
browser_tests/fixtures/helpers/JobsApiMock.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsListResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
|
||||
const defaultJobsListLimit = 100
|
||||
|
||||
export type MockJobRecord = {
|
||||
listItem: JobEntry
|
||||
detail: JobDetailResponse
|
||||
}
|
||||
|
||||
function parsePositiveIntegerParam(url: URL, name: string): number | undefined {
|
||||
const value = Number(url.searchParams.get(name))
|
||||
|
||||
return Number.isInteger(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function getJobIdFromRequest(route: Route): string | null {
|
||||
const url = new URL(route.request().url())
|
||||
const jobId = url.pathname.split('/').at(-1)
|
||||
|
||||
return jobId ? decodeURIComponent(jobId) : null
|
||||
}
|
||||
|
||||
export class JobsApiMock {
|
||||
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private jobsById = new Map<string, MockJobRecord>()
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockJobs(jobs: MockJobRecord[]): Promise<void> {
|
||||
this.jobsById = new Map(
|
||||
jobs.map(
|
||||
(job) => [job.listItem.id, job] satisfies [string, MockJobRecord]
|
||||
)
|
||||
)
|
||||
await this.ensureRoutesRegistered()
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.jobsById.clear()
|
||||
|
||||
if (this.listRouteHandler) {
|
||||
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
|
||||
this.listRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.detailRouteHandler) {
|
||||
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
this.detailRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.historyRouteHandler) {
|
||||
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRoutesRegistered(): Promise<void> {
|
||||
if (!this.listRouteHandler) {
|
||||
this.listRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses = url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.map((status) => status.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
let filteredJobs = Array.from(
|
||||
this.jobsById.values(),
|
||||
({ listItem }) => listItem
|
||||
)
|
||||
|
||||
if (statuses?.length) {
|
||||
filteredJobs = filteredJobs.filter((job) =>
|
||||
statuses.includes(job.status)
|
||||
)
|
||||
}
|
||||
|
||||
const offset = parsePositiveIntegerParam(url, 'offset') ?? 0
|
||||
const limit =
|
||||
parsePositiveIntegerParam(url, 'limit') ?? defaultJobsListLimit
|
||||
const total = filteredJobs.length
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
const response = {
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
} satisfies JobsListResponse
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.detailRouteHandler) {
|
||||
this.detailRouteHandler = async (route: Route) => {
|
||||
const jobId = getJobIdFromRequest(route)
|
||||
const job = jobId ? this.jobsById.get(jobId) : undefined
|
||||
|
||||
if (!job) {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Job not found' })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(job.detail)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.historyRouteHandler) {
|
||||
this.historyRouteHandler = async (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
const requestBody = request.postDataJSON() as
|
||||
| { delete?: string[]; clear?: boolean }
|
||||
| undefined
|
||||
|
||||
if (requestBody?.clear) {
|
||||
this.jobsById = new Map(
|
||||
Array.from(this.jobsById).filter(([, job]) => {
|
||||
const status = job.listItem.status
|
||||
|
||||
return status === 'pending' || status === 'in_progress'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (requestBody?.delete?.length) {
|
||||
for (const jobId of requestBody.delete) {
|
||||
this.jobsById.delete(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(historyRoutePattern, this.historyRouteHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,19 @@
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import type { Page, Route, WebSocketRoute } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { LogsRawResponse } from '@/schemas/apiSchema'
|
||||
|
||||
const RAW_LOGS_URL = '**/internal/logs/raw**'
|
||||
const SUBSCRIBE_LOGS_URL = '**/internal/logs/subscribe**'
|
||||
|
||||
export class LogsTerminalHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockRawLogs(messages: string[]): Promise<() => number> {
|
||||
let count = 0
|
||||
await this.page.unroute(RAW_LOGS_URL)
|
||||
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
|
||||
count += 1
|
||||
await route.fulfill({
|
||||
async mockRawLogs(messages: string[]) {
|
||||
await this.page.route('**/internal/logs/raw**', (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(LogsTerminalHelper.buildRawLogsResponse(messages))
|
||||
})
|
||||
})
|
||||
return () => count
|
||||
)
|
||||
}
|
||||
|
||||
async mockRawLogsPending(messages: string[] = []): Promise<() => void> {
|
||||
@@ -28,8 +21,7 @@ export class LogsTerminalHelper {
|
||||
const pending = new Promise<void>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
await this.page.unroute(RAW_LOGS_URL)
|
||||
await this.page.route(RAW_LOGS_URL, async (route: Route) => {
|
||||
await this.page.route('**/internal/logs/raw**', async (route: Route) => {
|
||||
await pending
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -41,39 +33,15 @@ export class LogsTerminalHelper {
|
||||
}
|
||||
|
||||
async mockRawLogsError() {
|
||||
await this.page.unroute(RAW_LOGS_URL)
|
||||
await this.page.route(RAW_LOGS_URL, (route: Route) =>
|
||||
await this.page.route('**/internal/logs/raw**', (route: Route) =>
|
||||
route.fulfill({ status: 500, body: 'Internal Server Error' })
|
||||
)
|
||||
}
|
||||
|
||||
async mockSubscribeLogs(): Promise<() => number> {
|
||||
let count = 0
|
||||
await this.page.unroute(SUBSCRIBE_LOGS_URL)
|
||||
await this.page.route(SUBSCRIBE_LOGS_URL, async (route: Route) => {
|
||||
count += 1
|
||||
await route.fulfill({ status: 200, body: '' })
|
||||
})
|
||||
return () => count
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the frontend to reconnect by closing the proxied WebSocket. The
|
||||
* api layer reschedules a fresh `WebSocket(...)`, the routeWebSocket
|
||||
* handler fires again, and on `open` with `isReconnect=true` it dispatches
|
||||
* `'reconnected'`, which triggers the logs-terminal resync.
|
||||
*
|
||||
* Use the resync's `subscribeLogs(true)` HTTP call as the sync point — by
|
||||
* the time the count goes up, the new socket is open and resync has
|
||||
* completed enough to assert against the terminal.
|
||||
*/
|
||||
async triggerReconnect(
|
||||
ws: WebSocketRoute,
|
||||
subscribeFetches: () => number
|
||||
): Promise<void> {
|
||||
const before = subscribeFetches()
|
||||
await ws.close()
|
||||
await expect.poll(subscribeFetches).toBeGreaterThan(before)
|
||||
async mockSubscribeLogs() {
|
||||
await this.page.route('**/internal/logs/subscribe**', (route: Route) =>
|
||||
route.fulfill({ status: 200, body: '' })
|
||||
)
|
||||
}
|
||||
|
||||
static buildWsLogFrame(messages: string[]): string {
|
||||
|
||||
@@ -9,18 +9,12 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyW
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
export class SubgraphHelper {
|
||||
public readonly editor: SubgraphEditor
|
||||
|
||||
constructor(private readonly comfyPage: ComfyPage) {
|
||||
this.editor = new SubgraphEditor(comfyPage)
|
||||
}
|
||||
constructor(private readonly comfyPage: ComfyPage) {}
|
||||
|
||||
private get page(): Page {
|
||||
return this.comfyPage.page
|
||||
@@ -242,17 +236,6 @@ export class SubgraphHelper {
|
||||
return new SubgraphSlotReference('output', slotName || '', this.comfyPage)
|
||||
}
|
||||
|
||||
async getInputBounds(): Promise<Position & Size> {
|
||||
return await this.comfyPage.page.evaluate(() => {
|
||||
const graph = app!.canvas.graph as Subgraph
|
||||
const inputNode = graph.inputNode
|
||||
const [x, y] = app!.canvas.ds.convertOffsetToCanvas(inputNode.pos)
|
||||
const width = inputNode.size[0] * app!.canvas.ds.scale
|
||||
const height = inputNode.size[1] * app!.canvas.ds.scale
|
||||
return { x, y, width, height }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a regular node output to a subgraph input.
|
||||
* This creates a new input slot on the subgraph if targetInputName is not provided.
|
||||
@@ -344,23 +327,6 @@ export class SubgraphHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async promoteWidget(nodeLocator: Locator, widgetName: string): Promise<void> {
|
||||
const widget = nodeLocator.getByLabel(widgetName, { exact: true })
|
||||
await this.comfyPage.contextMenu
|
||||
.openFor(widget)
|
||||
.then((m) => m.clickMenuItemExact(`Promote Widget: ${widgetName}`))
|
||||
}
|
||||
|
||||
async unpromoteWidget(
|
||||
nodeLocator: Locator,
|
||||
widgetName: string
|
||||
): Promise<void> {
|
||||
const widget = nodeLocator.getByLabel(widgetName, { exact: true })
|
||||
await this.comfyPage.contextMenu
|
||||
.openFor(widget)
|
||||
.then((m) => m.clickMenuItemExact(`Un-Promote Widget: ${widgetName}`))
|
||||
}
|
||||
|
||||
async isInSubgraph(): Promise<boolean> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import {
|
||||
makeTemplate,
|
||||
mockTemplateIndex
|
||||
} from '@e2e/fixtures/data/templateFixtures'
|
||||
|
||||
/**
|
||||
* Generate N deterministic templates, optionally restricted to a distribution.
|
||||
*
|
||||
* Lives here (not in `fixtures/data/`) because `fixtures/data/` is reserved
|
||||
* for static test data with no executable fixture logic.
|
||||
*/
|
||||
function generateTemplates(
|
||||
count: number,
|
||||
distribution?: TemplateIncludeOnDistributionEnum
|
||||
): TemplateInfo[] {
|
||||
const slug = distribution ?? 'unrestricted'
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
makeTemplate({
|
||||
name: `gen-${slug}-${String(i + 1).padStart(3, '0')}`,
|
||||
title: `Generated ${slug} ${i + 1}`,
|
||||
...(distribution ? { includeOnDistributions: [distribution] } : {})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export interface TemplateConfig {
|
||||
readonly templates: readonly TemplateInfo[]
|
||||
readonly index: readonly WorkflowTemplates[] | null
|
||||
}
|
||||
|
||||
function emptyConfig(): TemplateConfig {
|
||||
return { templates: [], index: null }
|
||||
}
|
||||
|
||||
export type TemplateOperator = (config: TemplateConfig) => TemplateConfig
|
||||
|
||||
function cloneTemplates(templates: readonly TemplateInfo[]): TemplateInfo[] {
|
||||
return templates.map((t) => structuredClone(t))
|
||||
}
|
||||
|
||||
function cloneIndex(
|
||||
index: readonly WorkflowTemplates[] | null
|
||||
): WorkflowTemplates[] | null {
|
||||
return index ? index.map((m) => structuredClone(m)) : null
|
||||
}
|
||||
|
||||
function addTemplates(
|
||||
config: TemplateConfig,
|
||||
templates: TemplateInfo[]
|
||||
): TemplateConfig {
|
||||
return { ...config, templates: [...config.templates, ...templates] }
|
||||
}
|
||||
|
||||
export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
|
||||
return (config) => addTemplates(config, templates)
|
||||
}
|
||||
|
||||
export function withTemplate(template: TemplateInfo): TemplateOperator {
|
||||
return (config) => addTemplates(config, [template])
|
||||
}
|
||||
|
||||
export function withCloudTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Cloud)
|
||||
)
|
||||
}
|
||||
|
||||
export function withDesktopTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Desktop)
|
||||
)
|
||||
}
|
||||
|
||||
export function withLocalTemplates(count: number): TemplateOperator {
|
||||
return (config) =>
|
||||
addTemplates(
|
||||
config,
|
||||
generateTemplates(count, TemplateIncludeOnDistributionEnum.Local)
|
||||
)
|
||||
}
|
||||
|
||||
export function withUnrestrictedTemplates(count: number): TemplateOperator {
|
||||
return (config) => addTemplates(config, generateTemplates(count))
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the index payload entirely. Useful when a test needs a custom
|
||||
* `WorkflowTemplates[]` shape (e.g. multiple modules).
|
||||
*/
|
||||
export function withRawIndex(index: WorkflowTemplates[]): TemplateOperator {
|
||||
return (config) => ({ ...config, index })
|
||||
}
|
||||
|
||||
export class TemplateHelper {
|
||||
private templates: TemplateInfo[]
|
||||
private index: WorkflowTemplates[] | null
|
||||
private routeHandlers: Array<{
|
||||
pattern: string
|
||||
handler: (route: Route) => Promise<void>
|
||||
}> = []
|
||||
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
config: TemplateConfig = emptyConfig()
|
||||
) {
|
||||
this.templates = cloneTemplates(config.templates)
|
||||
this.index = cloneIndex(config.index)
|
||||
}
|
||||
|
||||
configure(...operators: TemplateOperator[]): void {
|
||||
const config = operators.reduce<TemplateConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
emptyConfig()
|
||||
)
|
||||
this.templates = cloneTemplates(config.templates)
|
||||
this.index = cloneIndex(config.index)
|
||||
}
|
||||
|
||||
async mock(): Promise<void> {
|
||||
await this.mockIndex()
|
||||
await this.mockThumbnails()
|
||||
}
|
||||
|
||||
async mockIndex(): Promise<void> {
|
||||
const indexHandler = async (route: Route) => {
|
||||
const payload = this.index ?? mockTemplateIndex(this.templates)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
const indexPattern = '**/templates/index.json'
|
||||
this.routeHandlers.push({ pattern: indexPattern, handler: indexHandler })
|
||||
await this.page.route(indexPattern, indexHandler)
|
||||
}
|
||||
|
||||
async mockThumbnails(): Promise<void> {
|
||||
const thumbnailHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
path: 'browser_tests/assets/example.webp',
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
}
|
||||
const thumbnailPattern = '**/templates/**.webp'
|
||||
this.routeHandlers.push({
|
||||
pattern: thumbnailPattern,
|
||||
handler: thumbnailHandler
|
||||
})
|
||||
await this.page.route(thumbnailPattern, thumbnailHandler)
|
||||
}
|
||||
|
||||
getTemplates(): TemplateInfo[] {
|
||||
return cloneTemplates(this.templates)
|
||||
}
|
||||
|
||||
get templateCount(): number {
|
||||
return this.templates.length
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
for (const { pattern, handler } of this.routeHandlers) {
|
||||
await this.page.unroute(pattern, handler)
|
||||
}
|
||||
this.routeHandlers = []
|
||||
this.templates = []
|
||||
this.index = null
|
||||
}
|
||||
}
|
||||
|
||||
export function createTemplateHelper(
|
||||
page: Page,
|
||||
...operators: TemplateOperator[]
|
||||
): TemplateHelper {
|
||||
const config = operators.reduce<TemplateConfig>(
|
||||
(cfg, op) => op(cfg),
|
||||
emptyConfig()
|
||||
)
|
||||
return new TemplateHelper(page, config)
|
||||
}
|
||||
@@ -4,16 +4,10 @@ import type { Locator, Page } from '@playwright/test'
|
||||
export class ToastHelper {
|
||||
public readonly visibleToasts: Locator
|
||||
public readonly toastErrors: Locator
|
||||
public readonly toastSuccesses: Locator
|
||||
public readonly toastWarnings: Locator
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.visibleToasts = page.locator('.p-toast-message:visible')
|
||||
this.toastErrors = page.locator('.p-toast-message.p-toast-message-error')
|
||||
this.toastSuccesses = page.locator(
|
||||
'.p-toast-message.p-toast-message-success'
|
||||
)
|
||||
this.toastWarnings = page.locator('.p-toast-message.p-toast-message-warn')
|
||||
}
|
||||
|
||||
async closeToasts(requireCount = 0): Promise<void> {
|
||||
|
||||
15
browser_tests/fixtures/jobsApiMockFixture.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import { JobsApiMock } from '@e2e/fixtures/helpers/JobsApiMock'
|
||||
|
||||
export const jobsApiMockFixture = base.extend<{
|
||||
jobsApi: JobsApiMock
|
||||
}>({
|
||||
jobsApi: async ({ page }, use) => {
|
||||
const jobsApi = new JobsApiMock(page)
|
||||
|
||||
await use(jobsApi)
|
||||
|
||||
await jobsApi.clear()
|
||||
}
|
||||
})
|
||||