Compare commits
12 Commits
v1.45.6
...
ext-api/i-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdb61c16cd | ||
|
|
0f5e0303d5 | ||
|
|
c614243e36 | ||
|
|
e010c47110 | ||
|
|
192c102c7a | ||
|
|
58d6d2a157 | ||
|
|
e7f642765f | ||
|
|
96addd0e94 | ||
|
|
7200eb0dc4 | ||
|
|
e616a9386a | ||
|
|
fe6d4399c3 | ||
|
|
6dd361bbca |
@@ -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 |
|
||||
@@ -19,26 +19,15 @@ reviews:
|
||||
- name: End-to-end regression coverage for fixes
|
||||
mode: error
|
||||
instructions: |
|
||||
Use only PR metadata already available in the review context:
|
||||
- the PR title
|
||||
- commit subjects in this PR
|
||||
- The files changed in this PR relative to the PR base (equivalent to `base...head`)
|
||||
- the PR description.
|
||||
Do not rely on shell commands.
|
||||
Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR.
|
||||
If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
|
||||
Use only PR metadata already available in the review context: the PR title, commit subjects in this PR, the files changed in this PR relative to the PR base (equivalent to `base...head`), and the PR description.
|
||||
Do not rely on shell commands. Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing.
|
||||
|
||||
Fail if all of the following are true:
|
||||
1. The PR title and/or any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
|
||||
2. The PR changes files under `src/` or `packages/` related to the main frontend application but the PR does not change at least one file under `browser_tests/`.
|
||||
3. The PR description lacks a concrete explanation of why an end-to-end regression test was not added.
|
||||
|
||||
Do not fail if the changes are exclusively in `apps/website`, just documentation changes, or changes related to CI processes.
|
||||
The goal is to make sure that fixes include End-to-End regression tests. Do not insist on tests when the PR is not fixing a bug.
|
||||
|
||||
Pass otherwise.
|
||||
When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
|
||||
Pass if at least one of the following is true:
|
||||
1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`.
|
||||
2. The PR changes at least one file under `browser_tests/`.
|
||||
3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added.
|
||||
|
||||
Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical.
|
||||
- name: ADR compliance for entity/litegraph changes
|
||||
mode: warning
|
||||
instructions: |
|
||||
|
||||
88
.github/workflows/ci-tests-extension-api.yaml
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
# Description: Extension API test suite (I-TF) + compat-floor gate (I-TF.7)
|
||||
#
|
||||
# Runs on any PR touching extension-api declaration files, extension-api-v2
|
||||
# implementation/tests, or the touch-point DB/rollup (blast-radius changes).
|
||||
#
|
||||
# Two jobs:
|
||||
# test — vitest run against src/extension-api-v2/__tests__/
|
||||
# compat-floor — python scripts/check-compat-floor.py (exits 1 if any
|
||||
# blast_radius ≥ 2.0 category is missing a stub triple)
|
||||
#
|
||||
# The compat-floor job is the CI enforcement of PLAN.md §Compat-floor:
|
||||
# "Every blast_radius ≥ 2.0 pattern MUST pass v1 + v2 + migration before v2 ships."
|
||||
name: 'CI: Tests Extension API'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, dev*, core/*, extension-v2*]
|
||||
paths:
|
||||
- 'src/extension-api/**'
|
||||
- 'src/extension-api-v2/**'
|
||||
- 'packages/extension-api/**'
|
||||
- 'vitest.extension-api.config.mts'
|
||||
- 'research/touch-points/rollup.yaml'
|
||||
- 'research/touch-points/behavior-categories.yaml'
|
||||
- 'scripts/check-compat-floor.py'
|
||||
- 'pnpm-lock.yaml'
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
paths:
|
||||
- 'src/extension-api/**'
|
||||
- 'src/extension-api-v2/**'
|
||||
- 'packages/extension-api/**'
|
||||
- 'vitest.extension-api.config.mts'
|
||||
- 'research/touch-points/rollup.yaml'
|
||||
- 'research/touch-points/behavior-categories.yaml'
|
||||
- 'scripts/check-compat-floor.py'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Extension API tests (vitest)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Run extension-api test suite
|
||||
run: pnpm test:extension-api
|
||||
|
||||
- name: Run with coverage (push only)
|
||||
if: github.event_name == 'push'
|
||||
run: pnpm test:extension-api:coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: github.event_name == 'push'
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/lcov.info
|
||||
flags: extension-api
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
compat-floor:
|
||||
name: Compat-floor gate (blast_radius ≥ 2.0)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install PyYAML
|
||||
run: pip install pyyaml
|
||||
|
||||
- name: Check compat floor
|
||||
run: python3 scripts/check-compat-floor.py
|
||||
# Exits 1 if any blast_radius ≥ 2.0 behavior category is missing
|
||||
# any of its three stub files (v1/v2/migration). Enforces PLAN.md §Compat-floor.
|
||||
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."
|
||||
@@ -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",
|
||||
|
||||
@@ -172,7 +172,7 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
|
||||
16. Whenever a new piece of code is written, the author should ask themselves 'is there a simpler way to introduce the same functionality?'. If the answer is yes, the simpler course should be chosen
|
||||
17. [Refactoring](https://refactoring.com/catalog/) should be used to make complex code simpler
|
||||
18. Try to minimize the surface area (exported values) of each module and composable
|
||||
19. Don't use barrel files, e.g. `/some/package/index.ts` to re-export within `/src`
|
||||
19. Don't use barrel files, e.g. `/some/package/index.ts` to re-export within `/src`. **Exception**: `src/extension-api/index.ts` is the published npm package entry point (`@comfyorg/extension-api`) and is explicitly exempt from this rule.
|
||||
20. Keep functions short and functional
|
||||
21. Minimize [nesting](https://wiki.c2.com/?ArrowAntiPattern), e.g. `if () { ... }` or `for () { ... }`
|
||||
22. Avoid mutable state, prefer immutability and assignment at point of declaration
|
||||
|
||||
@@ -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,12 +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.
|
||||
|
||||
## HubSpot contact form
|
||||
|
||||
The contact page uses HubSpot's hosted form embed for the interest form:
|
||||
@@ -152,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
|
||||
|
||||
@@ -23,50 +23,26 @@ test.describe('Careers page @smoke', () => {
|
||||
expect(await roles.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('clicking a department button scrolls to and activates that section', async ({
|
||||
page
|
||||
}) => {
|
||||
const rolesSection = page.getByTestId('careers-roles')
|
||||
await rolesSection.scrollIntoViewIfNeeded()
|
||||
await expect(rolesSection).toBeVisible()
|
||||
|
||||
const allCount = await page.getByTestId('careers-role-link').count()
|
||||
|
||||
const engineeringButton = page.getByRole('button', {
|
||||
name: 'ENGINEERING',
|
||||
exact: true
|
||||
})
|
||||
|
||||
// RolesSection is hydrated via `client:visible`. Once the button responds
|
||||
// to a click by flipping aria-pressed, Vue is hydrated and the rest of
|
||||
// the locator logic is in effect.
|
||||
await expect(async () => {
|
||||
await engineeringButton.click()
|
||||
await expect(engineeringButton).toHaveAttribute('aria-pressed', 'true', {
|
||||
timeout: 1_000
|
||||
})
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
const engineeringSection = page.locator('#careers-dept-engineering')
|
||||
await expect(engineeringSection).toBeInViewport()
|
||||
|
||||
expect(await page.getByTestId('careers-role-link').count()).toBe(allCount)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Careers page role links', () => {
|
||||
test('each role links to the Ashby job description page, not the application form', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/careers')
|
||||
test('each role links to jobs.ashbyhq.com', async ({ page }) => {
|
||||
const roles = page.getByTestId('careers-role-link')
|
||||
const count = await roles.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const href = await roles.nth(i).getAttribute('href')
|
||||
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
|
||||
expect(href).not.toMatch(/\/application\/?$/)
|
||||
}
|
||||
})
|
||||
|
||||
test('ENGINEERING category filter narrows the role list', async ({
|
||||
page
|
||||
}) => {
|
||||
const allCount = await page.getByTestId('careers-role-link').count()
|
||||
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
|
||||
const engineeringLocator = page.getByTestId('careers-role-link')
|
||||
await expect(engineeringLocator.first()).toBeVisible()
|
||||
const engineeringCount = await engineeringLocator.count()
|
||||
expect(engineeringCount).toBeLessThanOrEqual(allCount)
|
||||
expect(engineeringCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Careers page (zh-CN) @smoke', () => {
|
||||
|
||||
@@ -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,61 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const M4_PRO_14_INCH_VIEWPORT = { width: 2016, height: 1310 }
|
||||
const LAST_SECTION_HASH = '#contact'
|
||||
|
||||
test.describe(
|
||||
'ContentSection scroll-spy @smoke',
|
||||
{
|
||||
annotation: [
|
||||
{
|
||||
type: 'issue',
|
||||
description:
|
||||
'https://linear.app/comfyorg/issue/FE-604/bug-bottom-badge-not-activating-on-scroll-at-high-resolution-3024x1964'
|
||||
},
|
||||
{
|
||||
type: 'environment',
|
||||
description:
|
||||
'14" MacBook M4 Pro logical viewport reported in FE-604; /privacy-policy reproduces because of its short trailing sections'
|
||||
}
|
||||
]
|
||||
},
|
||||
() => {
|
||||
test.use({ viewport: M4_PRO_14_INCH_VIEWPORT })
|
||||
|
||||
test('activates the last badge when user scrolls to the bottom', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/privacy-policy')
|
||||
|
||||
const sidebarNav = page.getByRole('navigation', {
|
||||
name: 'Category filter'
|
||||
})
|
||||
const badges = sidebarNav.getByRole('button')
|
||||
const lastBadge = badges.last()
|
||||
|
||||
await expect(badges.first()).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(lastBadge).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
await page.evaluate(() =>
|
||||
window.scrollTo(0, document.documentElement.scrollHeight)
|
||||
)
|
||||
|
||||
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('activates the last badge when page mounts already at the bottom via trailing hash', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(`/privacy-policy${LAST_SECTION_HASH}`)
|
||||
|
||||
const sidebarNav = page.getByRole('navigation', {
|
||||
name: 'Category filter'
|
||||
})
|
||||
const lastBadge = sidebarNav.getByRole('button').last()
|
||||
|
||||
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,71 +1,27 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import { demos, getNextDemo } from '../src/config/demos'
|
||||
import { t } from '../src/i18n/translations'
|
||||
|
||||
const escapeRegExp = (value: string): string =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
test.describe('Demo pages @smoke', () => {
|
||||
for (const demo of demos) {
|
||||
const nextDemo = getNextDemo(demo.slug)
|
||||
test('demo detail page renders hero and embed', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
'Create a Video from an Image'
|
||||
)
|
||||
const iframe = page.locator('iframe[title*="Interactive demo"]')
|
||||
await expect(iframe).toBeAttached()
|
||||
})
|
||||
|
||||
test(`/demos/${demo.slug} renders hero, embed, transcript, and next-demo nav`, async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(`/demos/${demo.slug}`)
|
||||
test('demo detail page has transcript section', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(
|
||||
page.getByRole('button', { name: /demo transcript/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
const heading = page.getByRole('heading', { level: 1 })
|
||||
await expect(heading).toBeVisible()
|
||||
await expect(heading).toContainText(t(demo.title, 'en'))
|
||||
|
||||
const ogImage = page.locator('head meta[property="og:image"]')
|
||||
await expect(ogImage).toHaveAttribute(
|
||||
'content',
|
||||
new RegExp(`${escapeRegExp(demo.slug)}-og\\.png`)
|
||||
)
|
||||
|
||||
const iframe = page.locator(
|
||||
`iframe[title*="${t('demos.embed.label', 'en')}"]`
|
||||
)
|
||||
await expect(iframe).toBeAttached()
|
||||
await expect(iframe).toHaveAttribute(
|
||||
'src',
|
||||
new RegExp(escapeRegExp(demo.arcadeId))
|
||||
)
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /demo transcript/i })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(
|
||||
page.getByText(t(nextDemo.title, 'en')).first()
|
||||
).toBeVisible()
|
||||
const nextThumb = page.locator(`img[src="${nextDemo.thumbnail}"]`).first()
|
||||
await expect(nextThumb).toBeAttached()
|
||||
await expect(nextThumb).toBeVisible()
|
||||
const naturalWidth = await nextThumb.evaluate(
|
||||
(img) => (img as HTMLImageElement).naturalWidth
|
||||
)
|
||||
expect(naturalWidth).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test(`/zh-CN/demos/${demo.slug} renders localized content`, async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(`/zh-CN/demos/${demo.slug}`)
|
||||
|
||||
await expect(page).toHaveURL(/\/zh-CN\/demos\//)
|
||||
|
||||
const heading = page.getByRole('heading', { level: 1 })
|
||||
await expect(heading).toContainText(t(demo.title, 'zh-CN'))
|
||||
await expect(heading).toContainText(/[\u4E00-\u9FFF]/)
|
||||
|
||||
await expect(
|
||||
page.getByText(t(nextDemo.title, 'zh-CN')).first()
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
test('demo detail page has next demo navigation', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(page.getByText(/what's next/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('demo library page renders', async ({ page }) => {
|
||||
await page.goto('/demos')
|
||||
@@ -76,4 +32,13 @@ test.describe('Demo pages @smoke', () => {
|
||||
const response = await page.goto('/demos/nonexistent')
|
||||
expect(response?.status()).toBe(404)
|
||||
})
|
||||
|
||||
test('zh-CN demo page renders localized content', async ({ page }) => {
|
||||
await page.goto('/zh-CN/demos/image-to-video')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
'从图片创建视频'
|
||||
)
|
||||
const nextDemoLink = page.locator('a[href*="/zh-CN/demos/"]').first()
|
||||
await expect(nextDemoLink).toBeAttached()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
@@ -48,105 +47,4 @@ test.describe('Mobile layout @mobile', () => {
|
||||
const mobileContainer = page.getByTestId('social-proof-mobile')
|
||||
await expect(mobileContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('SocialProofBar seamless marquee', () => {
|
||||
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
|
||||
|
||||
test('mobile forward marquee loops seamlessly', async ({ page }) => {
|
||||
const geometry = await measureMarqueeLoopGeometry(
|
||||
page,
|
||||
'[data-testid="social-proof-mobile"] .animate-marquee'
|
||||
)
|
||||
expectSeamlessForwardLoop(geometry)
|
||||
})
|
||||
|
||||
test('mobile reverse marquee loops seamlessly', async ({ page }) => {
|
||||
const geometry = await measureMarqueeLoopGeometry(
|
||||
page,
|
||||
'[data-testid="social-proof-mobile"] .animate-marquee-reverse'
|
||||
)
|
||||
expectSeamlessReverseLoop(geometry)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Desktop SocialProofBar @smoke', () => {
|
||||
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('desktop marquee loops seamlessly', async ({ page }) => {
|
||||
const geometry = await measureMarqueeLoopGeometry(
|
||||
page,
|
||||
'[data-testid="social-proof-desktop"] .animate-marquee'
|
||||
)
|
||||
expectSeamlessForwardLoop(geometry)
|
||||
})
|
||||
})
|
||||
|
||||
type MarqueeGeometry = {
|
||||
copyWidths: number[]
|
||||
startPositions: number[]
|
||||
endPositions: number[]
|
||||
}
|
||||
|
||||
async function measureMarqueeLoopGeometry(
|
||||
page: Page,
|
||||
selector: string
|
||||
): Promise<MarqueeGeometry> {
|
||||
await page.locator(selector).first().waitFor()
|
||||
return page.evaluate((sel) => {
|
||||
const tracks = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(sel)
|
||||
).slice(0, 2)
|
||||
const firstAnimation = tracks[0]?.getAnimations()[0]
|
||||
if (!firstAnimation) {
|
||||
throw new Error(`No CSS animation found on ${sel}`)
|
||||
}
|
||||
const duration = firstAnimation.effect?.getTiming().duration
|
||||
if (typeof duration !== 'number' || duration <= 1) {
|
||||
throw new Error(
|
||||
`Animation on ${sel} has unusable duration: ${String(duration)}`
|
||||
)
|
||||
}
|
||||
const setAllTimes = (time: number) => {
|
||||
for (const track of tracks) {
|
||||
for (const anim of track.getAnimations()) {
|
||||
anim.currentTime = time
|
||||
}
|
||||
}
|
||||
void document.body.offsetWidth
|
||||
}
|
||||
const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
|
||||
setAllTimes(0)
|
||||
const startPositions = readX()
|
||||
const copyWidths = tracks.map(
|
||||
(track) => track.getBoundingClientRect().width
|
||||
)
|
||||
setAllTimes(duration - 0.1)
|
||||
const endPositions = readX()
|
||||
return { copyWidths, startPositions, endPositions }
|
||||
}, selector)
|
||||
}
|
||||
|
||||
function expectTwoMatchingCopies(geometry: MarqueeGeometry) {
|
||||
const { copyWidths } = geometry
|
||||
expect(copyWidths.length, 'expected two duplicate marquee tracks').toBe(2)
|
||||
expect(copyWidths[0]).toBeGreaterThan(0)
|
||||
expect(copyWidths[1]).toBeCloseTo(copyWidths[0], 0)
|
||||
}
|
||||
|
||||
function expectSeamlessForwardLoop(geometry: MarqueeGeometry) {
|
||||
expectTwoMatchingCopies(geometry)
|
||||
// Copy 2 ends the cycle exactly where copy 1 started, so the restart
|
||||
// (when copy 1 jumps back to its start position) is visually indistinguishable.
|
||||
expect(geometry.endPositions[1]).toBeCloseTo(geometry.startPositions[0], 0)
|
||||
}
|
||||
|
||||
function expectSeamlessReverseLoop(geometry: MarqueeGeometry) {
|
||||
expectTwoMatchingCopies(geometry)
|
||||
// Reverse marquee: copy 1 ends the cycle where copy 2 started.
|
||||
expect(geometry.endPositions[0]).toBeCloseTo(geometry.startPositions[1], 0)
|
||||
}
|
||||
|
||||
@@ -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 |
|
Before Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 69 B |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 69 B |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 69 B |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 69 B |
@@ -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`
|
||||
)
|
||||
@@ -1,13 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useTemplateRefsList } from '@vueuse/core'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Department } from '../../data/roles'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { scrollTo } from '../../scripts/smoothScroll'
|
||||
import CategoryNav from '../common/CategoryNav.vue'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
|
||||
@@ -16,72 +13,24 @@ const { locale = 'en', departments = [] } = defineProps<{
|
||||
departments?: readonly Department[]
|
||||
}>()
|
||||
|
||||
const activeCategory = ref('all')
|
||||
|
||||
const visibleDepartments = computed(() =>
|
||||
departments.filter((d) => d.roles.length > 0)
|
||||
)
|
||||
|
||||
const categories = computed(() =>
|
||||
visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
|
||||
const categories = computed(() => [
|
||||
{ label: 'ALL', value: 'all' },
|
||||
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
|
||||
])
|
||||
|
||||
const filteredDepartments = computed(() =>
|
||||
activeCategory.value === 'all'
|
||||
? visibleDepartments.value
|
||||
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
|
||||
)
|
||||
|
||||
const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
|
||||
const activeCategory = ref('')
|
||||
|
||||
const sectionRefs = useTemplateRefsList<HTMLElement>()
|
||||
|
||||
let isScrolling = false
|
||||
let pendingFrame = 0
|
||||
|
||||
const HEADER_OFFSET = -144
|
||||
const ACTIVATION_OFFSET = 300
|
||||
|
||||
const deptElementId = (key: string) => `careers-dept-${key}`
|
||||
|
||||
function pickActiveSection() {
|
||||
pendingFrame = 0
|
||||
if (isScrolling) return
|
||||
const sections = sectionRefs.value as HTMLElement[]
|
||||
if (sections.length === 0) return
|
||||
|
||||
let active = sections[0]
|
||||
for (const el of sections) {
|
||||
if (el.getBoundingClientRect().top - ACTIVATION_OFFSET <= 0) {
|
||||
active = el
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
activeCategory.value = active.id.replace(/^careers-dept-/, '')
|
||||
}
|
||||
|
||||
function scheduleUpdate() {
|
||||
if (pendingFrame !== 0) return
|
||||
pendingFrame = requestAnimationFrame(pickActiveSection)
|
||||
}
|
||||
|
||||
onMounted(pickActiveSection)
|
||||
useEventListener('scroll', scheduleUpdate, { passive: true })
|
||||
useEventListener('resize', scheduleUpdate, { passive: true })
|
||||
|
||||
function scrollToDepartment(deptKey: string) {
|
||||
activeCategory.value = deptKey
|
||||
isScrolling = true
|
||||
const el = document.getElementById(deptElementId(deptKey))
|
||||
if (!el) {
|
||||
isScrolling = false
|
||||
return
|
||||
}
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: () => {
|
||||
isScrolling = false
|
||||
pickActiveSection()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -99,10 +48,9 @@ function scrollToDepartment(deptKey: string) {
|
||||
</h2>
|
||||
<CategoryNav
|
||||
v-if="hasRoles"
|
||||
v-model="activeCategory"
|
||||
:categories="categories"
|
||||
:model-value="activeCategory"
|
||||
class="mt-4"
|
||||
@update:model-value="scrollToDepartment"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,11 +65,9 @@ function scrollToDepartment(deptKey: string) {
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="dept in visibleDepartments"
|
||||
:id="deptElementId(dept.key)"
|
||||
:ref="sectionRefs.set"
|
||||
v-for="dept in filteredDepartments"
|
||||
:key="dept.key"
|
||||
class="mb-12 scroll-mt-24 last:mb-0 md:scroll-mt-36"
|
||||
class="mb-12 last:mb-0"
|
||||
>
|
||||
<SectionLabel>
|
||||
{{ dept.name }}
|
||||
@@ -130,7 +76,7 @@ function scrollToDepartment(deptKey: string) {
|
||||
<a
|
||||
v-for="role in dept.roles"
|
||||
:key="role.id"
|
||||
:href="role.jobUrl"
|
||||
:href="role.applyUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
|
||||
|
||||
@@ -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>
|
||||
@@ -1,11 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
useEventListener,
|
||||
useIntersectionObserver,
|
||||
useTemplateRefsList
|
||||
} from '@vueuse/core'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
@@ -44,25 +40,13 @@ const activeSection = ref(sections[0]?.id ?? '')
|
||||
|
||||
const sectionRefs = useTemplateRefsList<HTMLElement>()
|
||||
let isScrolling = false
|
||||
let scrollSafetyTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const HEADER_OFFSET = -144
|
||||
const BOTTOM_THRESHOLD_PX = 4
|
||||
const SCROLL_SAFETY_MS = 1500
|
||||
|
||||
function clearScrollLock() {
|
||||
isScrolling = false
|
||||
if (scrollSafetyTimer !== undefined) {
|
||||
clearTimeout(scrollSafetyTimer)
|
||||
scrollSafetyTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
useIntersectionObserver(
|
||||
sectionRefs,
|
||||
(entries) => {
|
||||
if (isScrolling) return
|
||||
if (isAtBottom()) return
|
||||
let best: IntersectionObserverEntry | null = null
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
@@ -74,39 +58,22 @@ useIntersectionObserver(
|
||||
{ rootMargin: '-20% 0px -60% 0px' }
|
||||
)
|
||||
|
||||
function isAtBottom(): boolean {
|
||||
const scrollBottom = window.scrollY + window.innerHeight
|
||||
return (
|
||||
scrollBottom >= document.documentElement.scrollHeight - BOTTOM_THRESHOLD_PX
|
||||
)
|
||||
}
|
||||
|
||||
function activateLastIfAtBottom() {
|
||||
if (isScrolling) return
|
||||
if (!isAtBottom()) return
|
||||
const lastId = sections[sections.length - 1]?.id
|
||||
if (lastId) activeSection.value = lastId
|
||||
}
|
||||
|
||||
onMounted(activateLastIfAtBottom)
|
||||
useEventListener('scroll', activateLastIfAtBottom, { passive: true })
|
||||
|
||||
function scrollToSection(id: string) {
|
||||
activeSection.value = id
|
||||
clearScrollLock()
|
||||
isScrolling = true
|
||||
scrollSafetyTimer = setTimeout(clearScrollLock, SCROLL_SAFETY_MS)
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: clearScrollLock
|
||||
onComplete: () => {
|
||||
isScrolling = false
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
clearScrollLock()
|
||||
isScrolling = false
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const {
|
||||
<img
|
||||
src="/icons/node-left.svg"
|
||||
alt=""
|
||||
class="-mx-px h-full w-auto self-stretch"
|
||||
class="-mx-px self-stretch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
@@ -38,7 +38,7 @@ const {
|
||||
v-if="i > 0"
|
||||
src="/icons/node-union.svg"
|
||||
alt=""
|
||||
class="-mx-px h-full w-auto self-stretch"
|
||||
class="-mx-px self-stretch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
@@ -72,7 +72,7 @@ const {
|
||||
<img
|
||||
src="/icons/node-right.svg"
|
||||
alt=""
|
||||
class="-mx-px h-full w-auto self-stretch"
|
||||
class="-mx-px self-stretch"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -14,28 +14,23 @@ const logos = [
|
||||
'Ubisoft'
|
||||
]
|
||||
|
||||
const mobileRow1Logos = logos.slice(0, 6)
|
||||
const mobileRow2Logos = logos.slice(6)
|
||||
const desktopLogos = Array.from({ length: 4 }, () => logos).flat()
|
||||
const row1 = logos.slice(0, 6)
|
||||
const mobileRow1 = [...row1, ...row1]
|
||||
const row2 = logos.slice(6)
|
||||
const mobileRow2 = [...row2, ...row2]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overflow-hidden py-12">
|
||||
<!-- Single row on desktop -->
|
||||
<div data-testid="social-proof-desktop" class="hidden w-max gap-2 md:flex">
|
||||
<div class="animate-marquee hidden items-center gap-2 md:flex">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-2"
|
||||
style="--marquee-gap: 0.5rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
v-for="(logo, i) in desktopLogos"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-20 w-50 shrink-0 items-center justify-center"
|
||||
>
|
||||
<div
|
||||
v-for="logo in logos"
|
||||
:key="logo"
|
||||
class="flex h-20 w-50 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,38 +39,22 @@ const mobileRow2Logos = logos.slice(6)
|
||||
data-testid="social-proof-mobile"
|
||||
class="flex flex-col gap-8 md:hidden"
|
||||
>
|
||||
<div class="flex w-max gap-8">
|
||||
<div class="animate-marquee flex items-center gap-8">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
v-for="(logo, i) in mobileRow1"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<div
|
||||
v-for="logo in mobileRow1Logos"
|
||||
:key="logo"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-max gap-8">
|
||||
<div class="animate-marquee-reverse flex items-center gap-8">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
v-for="(logo, i) in mobileRow2"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<div
|
||||
v-for="logo in mobileRow2Logos"
|
||||
:key="logo"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,12 +8,10 @@ import { t } from '../../i18n/translations'
|
||||
const {
|
||||
arcadeId,
|
||||
title,
|
||||
aspectRatio = 16 / 9,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
arcadeId: string
|
||||
title: string
|
||||
aspectRatio?: number
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
@@ -26,8 +24,7 @@ const loaded = ref(false)
|
||||
:aria-label="t('demos.embed.label', locale)"
|
||||
>
|
||||
<div
|
||||
class="relative mx-auto max-w-6xl overflow-hidden rounded-4xl border border-white/10"
|
||||
:style="{ aspectRatio }"
|
||||
class="relative mx-auto aspect-video max-w-6xl overflow-hidden rounded-4xl border border-white/10"
|
||||
>
|
||||
<div
|
||||
v-if="!loaded"
|
||||
|
||||
@@ -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>
|
||||
@@ -276,6 +276,29 @@ onUnmounted(() => {
|
||||
fill="#211927"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Left-edge fade -->
|
||||
<rect
|
||||
x="300"
|
||||
y="150"
|
||||
width="250"
|
||||
height="900"
|
||||
fill="url(#localHeroFadeLeft)"
|
||||
/>
|
||||
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="localHeroFadeLeft"
|
||||
x1="550"
|
||||
y1="600"
|
||||
x2="300"
|
||||
y2="600"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#211927" stop-opacity="0" />
|
||||
<stop offset="1" stop-color="#211927" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -15,14 +15,6 @@ interface Demo {
|
||||
readonly transcript?: TranslationKey
|
||||
readonly publishedDate: string
|
||||
readonly modifiedDate: string
|
||||
/**
|
||||
* Width / height of the Arcade demo's source recording (e.g. 1.93 for a
|
||||
* landscape screencast). Sizes the embed container to match so rounded
|
||||
* corners hug the content instead of empty letterbox space. Source from
|
||||
* Arcade's `_serializablePublicFlow.aspectRatio` (which is height/width —
|
||||
* invert it). Defaults to 16/9 if omitted.
|
||||
*/
|
||||
readonly aspectRatio?: number
|
||||
}
|
||||
|
||||
export const demos: readonly Demo[] = [
|
||||
@@ -40,8 +32,7 @@ export const demos: readonly Demo[] = [
|
||||
difficulty: 'beginner',
|
||||
tags: ['templates', 'image', 'video'],
|
||||
publishedDate: '2026-04-19',
|
||||
modifiedDate: '2026-04-19',
|
||||
aspectRatio: 1.931
|
||||
modifiedDate: '2026-04-19'
|
||||
},
|
||||
{
|
||||
slug: 'workflow-templates',
|
||||
@@ -57,25 +48,7 @@ export const demos: readonly Demo[] = [
|
||||
difficulty: 'beginner',
|
||||
tags: ['getting-started', 'templates', 'workflow'],
|
||||
publishedDate: '2026-04-19',
|
||||
modifiedDate: '2026-04-19',
|
||||
aspectRatio: 1.931
|
||||
},
|
||||
{
|
||||
slug: 'community-workflows',
|
||||
arcadeId: 'mqZh17oWDuWIyhK0xwEV',
|
||||
category: 'demos.category.gettingStarted',
|
||||
title: 'demos.community-workflows.title',
|
||||
description: 'demos.community-workflows.description',
|
||||
transcript: 'demos.community-workflows.transcript',
|
||||
ogImage: '/images/demos/community-workflows-og.png',
|
||||
thumbnail: '/images/demos/community-workflows-thumb.webp',
|
||||
estimatedTime: 'demos.duration.2min',
|
||||
durationIso: 'PT2M',
|
||||
difficulty: 'beginner',
|
||||
tags: ['getting-started', 'community', 'workflow', 'hub'],
|
||||
publishedDate: '2026-05-04',
|
||||
modifiedDate: '2026-05-04',
|
||||
aspectRatio: 1.931
|
||||
modifiedDate: '2026-04-19'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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,8 +14,7 @@ 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -735,142 +735,6 @@ const translations = {
|
||||
'zh-CN': '免费试用 COMFY CLOUD'
|
||||
},
|
||||
|
||||
'cloudNodes.hero.label': {
|
||||
en: 'CLOUD NODES',
|
||||
'zh-CN': '云端节点目录'
|
||||
},
|
||||
'cloudNodes.hero.heading': {
|
||||
en: 'Run your favorite ComfyUI custom nodes on the cloud',
|
||||
'zh-CN': '在云端运行你喜爱的 ComfyUI 自定义节点'
|
||||
},
|
||||
'cloudNodes.hero.body': {
|
||||
en: 'Spin up workflows with hundreds of community-built nodes — detailers, ControlNet preprocessors, animation tools, and quality-of-life utilities — preinstalled on Comfy Cloud and ready to run on managed GPUs.',
|
||||
'zh-CN':
|
||||
'在 Comfy Cloud 托管 GPU 上即开即用,预装数百个社区节点——细节修复、ControlNet 预处理、动画工具与日常便利组件,应有尽有。'
|
||||
},
|
||||
'cloudNodes.section.heading': {
|
||||
en: 'Find a custom-node pack',
|
||||
'zh-CN': '查找自定义节点包'
|
||||
},
|
||||
'cloudNodes.search.placeholder': {
|
||||
en: 'Search packs or nodes',
|
||||
'zh-CN': '搜索节点包或节点名称'
|
||||
},
|
||||
'cloudNodes.sort.downloads': {
|
||||
en: 'Most installed',
|
||||
'zh-CN': '按安装量'
|
||||
},
|
||||
'cloudNodes.sort.mostNodes': {
|
||||
en: 'Most nodes',
|
||||
'zh-CN': '按节点数量'
|
||||
},
|
||||
'cloudNodes.sort.az': {
|
||||
en: 'A → Z',
|
||||
'zh-CN': '按名称 A → Z'
|
||||
},
|
||||
'cloudNodes.sort.recentlyUpdated': {
|
||||
en: 'Recently updated',
|
||||
'zh-CN': '最近更新'
|
||||
},
|
||||
'cloudNodes.search.label': {
|
||||
en: 'Search custom-node packs',
|
||||
'zh-CN': '搜索自定义节点包'
|
||||
},
|
||||
'cloudNodes.sort.label': {
|
||||
en: 'Sort packs',
|
||||
'zh-CN': '排序节点包'
|
||||
},
|
||||
'cloudNodes.list.ariaLabel': {
|
||||
en: 'Custom-node packs supported on Comfy Cloud',
|
||||
'zh-CN': 'Comfy Cloud 支持的自定义节点包'
|
||||
},
|
||||
'cloudNodes.meta.title': {
|
||||
en: 'Custom-node packs on Comfy Cloud — supported by default',
|
||||
'zh-CN': 'Comfy Cloud 自定义节点包合集——开箱即用'
|
||||
},
|
||||
'cloudNodes.meta.description': {
|
||||
en: 'Browse hundreds of ComfyUI custom-node packs preinstalled on Comfy Cloud. Detailers, ControlNet preprocessors, animation tools, samplers, and more — search by pack or by node name.',
|
||||
'zh-CN':
|
||||
'浏览 Comfy Cloud 预装的数百个 ComfyUI 自定义节点包:细节修复、ControlNet 预处理、动画工具、采样器等——按节点包或节点名搜索。'
|
||||
},
|
||||
'cloudNodes.detail.metaTitle': {
|
||||
en: '{pack} on Comfy Cloud',
|
||||
'zh-CN': '{pack}(Comfy Cloud)'
|
||||
},
|
||||
'cloudNodes.detail.metaDescription': {
|
||||
en: '{pack} is preinstalled on Comfy Cloud — {nodeCount} nodes ready to run on managed GPUs. {description}',
|
||||
'zh-CN':
|
||||
'{pack} 已预装于 Comfy Cloud——{nodeCount} 个节点可在托管 GPU 上即时运行。{description}'
|
||||
},
|
||||
'cloudNodes.empty.heading': {
|
||||
en: 'No matching packs',
|
||||
'zh-CN': '未找到匹配的节点包'
|
||||
},
|
||||
'cloudNodes.empty.body': {
|
||||
en: 'Try a different search term or clear your filters.',
|
||||
'zh-CN': '试试其他关键词,或清空筛选条件。'
|
||||
},
|
||||
'cloudNodes.card.nodeCountOne': {
|
||||
en: '{count} node',
|
||||
'zh-CN': '{count} 个节点'
|
||||
},
|
||||
'cloudNodes.card.nodeCountOther': {
|
||||
en: '{count} nodes',
|
||||
'zh-CN': '{count} 个节点'
|
||||
},
|
||||
'cloudNodes.card.viewRepo': {
|
||||
en: 'View repository',
|
||||
'zh-CN': '查看仓库'
|
||||
},
|
||||
'cloudNodes.card.unavailableDescription': {
|
||||
en: 'Description unavailable.',
|
||||
'zh-CN': '暂无描述信息。'
|
||||
},
|
||||
'cloudNodes.card.nodesHeading': {
|
||||
en: 'Included nodes',
|
||||
'zh-CN': '包含节点'
|
||||
},
|
||||
'cloudNodes.detail.back': {
|
||||
en: 'Back to all packs',
|
||||
'zh-CN': '返回所有节点包'
|
||||
},
|
||||
'cloudNodes.detail.publisher': {
|
||||
en: 'Publisher',
|
||||
'zh-CN': '发布者'
|
||||
},
|
||||
'cloudNodes.detail.downloads': {
|
||||
en: 'Downloads',
|
||||
'zh-CN': '下载量'
|
||||
},
|
||||
'cloudNodes.detail.stars': {
|
||||
en: 'GitHub stars',
|
||||
'zh-CN': 'GitHub 星标'
|
||||
},
|
||||
'cloudNodes.detail.latestVersion': {
|
||||
en: 'Latest version',
|
||||
'zh-CN': '最新版本'
|
||||
},
|
||||
'cloudNodes.detail.license': {
|
||||
en: 'License',
|
||||
'zh-CN': '许可证'
|
||||
},
|
||||
'cloudNodes.detail.lastUpdated': {
|
||||
en: 'Last updated',
|
||||
'zh-CN': '最后更新'
|
||||
},
|
||||
'cloudNodes.detail.deprecated': {
|
||||
en: 'Deprecated',
|
||||
'zh-CN': '已弃用'
|
||||
},
|
||||
'cloudNodes.detail.experimental': {
|
||||
en: 'Experimental',
|
||||
'zh-CN': '实验性'
|
||||
},
|
||||
'cloudNodes.detail.nodesHeading': {
|
||||
en: 'Nodes in this pack',
|
||||
'zh-CN': '此节点包中的节点'
|
||||
},
|
||||
|
||||
// Cloud – ReasonSection
|
||||
'cloud.reason.heading': {
|
||||
en: 'Why\nprofessionals\nchoose ',
|
||||
@@ -3706,20 +3570,6 @@ const translations = {
|
||||
'<ol><li><strong>打开模板浏览器</strong> — 点击 ComfyUI 侧栏中的模板图标。</li><li><strong>浏览分类</strong> — 模板按任务分类:图像生成、视频、放大等。</li><li><strong>预览模板</strong> — 将鼠标悬停在模板上查看预览。</li><li><strong>加载并自定义</strong> — 点击加载模板,然后修改参数。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.community-workflows.title': {
|
||||
en: 'Explore and Use a Community Workflow from the Hub',
|
||||
'zh-CN': '探索并使用社区工作流'
|
||||
},
|
||||
'demos.community-workflows.description': {
|
||||
en: 'Discover how to find and get started with popular community workflows for generative AI projects.',
|
||||
'zh-CN': '了解如何查找并使用流行的社区工作流来构建生成式 AI 项目。'
|
||||
},
|
||||
'demos.community-workflows.transcript': {
|
||||
en: '<ol><li><strong>Open the Workflow Hub</strong> — From the ComfyUI sidebar, navigate to the community Workflow Hub to browse curated and trending workflows shared by the community.</li><li><strong>Browse popular workflows</strong> — Explore featured projects sorted by popularity, recency, and category to find one that matches your goal.</li><li><strong>Preview a workflow</strong> — Click a workflow card to see example outputs, required models, and a description of what it produces.</li><li><strong>Open in ComfyUI</strong> — Use the "Get Started" action to load the selected community workflow directly onto your canvas.</li><li><strong>Run and customize</strong> — Queue the workflow to generate your first result, then tweak prompts, models, and parameters to make it your own.</li></ol>',
|
||||
'zh-CN':
|
||||
'<ol><li><strong>打开工作流中心</strong> — 在 ComfyUI 侧栏中,进入社区工作流中心,浏览社区分享的精选和热门工作流。</li><li><strong>浏览热门工作流</strong> — 按热度、时间和分类浏览精选项目,找到符合需求的工作流。</li><li><strong>预览工作流</strong> — 点击工作流卡片,查看示例输出、所需模型和功能描述。</li><li><strong>在 ComfyUI 中打开</strong> — 使用"开始使用"按钮,将选中的社区工作流直接加载到画布。</li><li><strong>运行并自定义</strong> — 排队执行工作流以生成首个结果,然后调整提示词、模型和参数。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.nav.nextDemo': { en: "What's Next", 'zh-CN': '下一个演示' },
|
||||
'demos.nav.viewDemo': { en: 'View Demo', 'zh-CN': '查看演示' },
|
||||
'demos.nav.allDemos': { en: 'All Demos', 'zh-CN': '所有演示' },
|
||||
@@ -3814,60 +3664,6 @@ const translations = {
|
||||
'zh-CN': 'Creative Studios AI 负责人'
|
||||
},
|
||||
|
||||
// Models – UI keys
|
||||
'models.hero.eyebrow': {
|
||||
en: 'AI Model',
|
||||
'zh-CN': 'AI 模型'
|
||||
},
|
||||
'models.hero.primaryCta': {
|
||||
en: 'TRY IN COMFY',
|
||||
'zh-CN': '在 Comfy 中试用'
|
||||
},
|
||||
'models.hero.secondaryCta': {
|
||||
en: 'DOWNLOAD MODEL',
|
||||
'zh-CN': '下载模型'
|
||||
},
|
||||
'models.hero.cloudCta': {
|
||||
en: 'RUN ON CLOUD',
|
||||
'zh-CN': '云端运行'
|
||||
},
|
||||
'models.hero.tutorialCta': {
|
||||
en: 'VIEW TUTORIAL',
|
||||
'zh-CN': '查看教程'
|
||||
},
|
||||
'models.hero.blogLink': {
|
||||
en: 'Read blog post',
|
||||
'zh-CN': '阅读博客文章'
|
||||
},
|
||||
'models.hero.workflowCount': {
|
||||
en: '{count} workflows use this model',
|
||||
'zh-CN': '{count} 个工作流使用此模型'
|
||||
},
|
||||
'models.whatIs.heading': {
|
||||
en: 'What is {name}?',
|
||||
'zh-CN': '什么是 {name}?'
|
||||
},
|
||||
'models.whatIs.tutorialLink': {
|
||||
en: 'Read the full tutorial →',
|
||||
'zh-CN': '阅读完整教程 →'
|
||||
},
|
||||
'models.index.title': {
|
||||
en: 'Supported Models',
|
||||
'zh-CN': '支持的模型'
|
||||
},
|
||||
'models.index.subtitle': {
|
||||
en: "Run the world's leading AI models in ComfyUI",
|
||||
'zh-CN': '在 ComfyUI 中运行世界领先的 AI 模型'
|
||||
},
|
||||
'models.breadcrumb.home': {
|
||||
en: 'Home',
|
||||
'zh-CN': '首页'
|
||||
},
|
||||
'models.breadcrumb.models': {
|
||||
en: 'Supported Models',
|
||||
'zh-CN': '支持的模型'
|
||||
},
|
||||
|
||||
// Payment status pages
|
||||
'payment.success.label': {
|
||||
en: 'PAYMENT',
|
||||
|
||||
@@ -5,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 {
|
||||
@@ -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>
|
||||
@@ -121,7 +121,6 @@ const breadcrumbJsonLd = {
|
||||
<ArcadeEmbed
|
||||
arcadeId={demo.arcadeId}
|
||||
title={title}
|
||||
aspectRatio={demo.aspectRatio}
|
||||
client:load
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -122,7 +122,6 @@ const breadcrumbJsonLd = {
|
||||
<ArcadeEmbed
|
||||
arcadeId={demo.arcadeId}
|
||||
title={title}
|
||||
aspectRatio={demo.aspectRatio}
|
||||
locale="zh-CN"
|
||||
client:load
|
||||
/>
|
||||
|
||||
@@ -101,13 +101,13 @@
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee-reverse {
|
||||
0% {
|
||||
transform: translateX(calc(-100% - var(--marquee-gap, 0px)));
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
@@ -115,15 +115,11 @@
|
||||
}
|
||||
|
||||
@utility animate-marquee {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: marquee 30s linear infinite;
|
||||
}
|
||||
animation: marquee 30s linear infinite;
|
||||
}
|
||||
|
||||
@utility animate-marquee-reverse {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: marquee-reverse 30s linear infinite;
|
||||
}
|
||||
animation: marquee-reverse 30s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ripple-effect {
|
||||
|
||||
@@ -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,27 +0,0 @@
|
||||
import type { Pack } from '../data/cloudNodes'
|
||||
|
||||
import { fetchCloudNodesForBuild } from './cloudNodes'
|
||||
import { reportCloudNodesOutcome } from './cloudNodes.ci'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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}. ` +
|
||||
'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot.'
|
||||
)
|
||||
}
|
||||
|
||||
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,77 +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', async () => {
|
||||
const ids = Array.from({ length: 120 }, (_, i) => `pack-${i}`)
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = new URL(String(input))
|
||||
const batchIds = url.searchParams.getAll('node_id')
|
||||
return jsonResponse({
|
||||
nodes: batchIds.map((id) => ({ id, name: id })),
|
||||
total: batchIds.length,
|
||||
page: 1,
|
||||
limit: 50
|
||||
})
|
||||
})
|
||||
|
||||
const result = await fetchRegistryPacks(ids, {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3)
|
||||
expect(result.size).toBe(120)
|
||||
const firstCallUrl = new URL(String(fetchImpl.mock.calls[0]?.[0]))
|
||||
expect(firstCallUrl.origin).toBe(DEFAULT_REGISTRY_BASE_URL)
|
||||
expect(firstCallUrl.pathname).toBe('/nodes')
|
||||
expect(firstCallUrl.searchParams.getAll('node_id')).toHaveLength(50)
|
||||
})
|
||||
|
||||
it('retries a failed batch once and then succeeds', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>()
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 503 }))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
nodes: [{ id: 'pack-1', name: 'Pack One' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 50
|
||||
})
|
||||
)
|
||||
|
||||
const result = await fetchRegistryPacks(['pack-1'], {
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch
|
||||
})
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||
expect(result.get('pack-1')?.name).toBe('Pack One')
|
||||
})
|
||||
|
||||
it('returns an empty map when all batches fail (soft failure)', async () => {
|
||||
const ids = ['pack-a', 'pack-b']
|
||||
const fetchImpl = vi.fn(async () => new Response('{}', { status: 500 }))
|
||||
|
||||
const result = await fetchRegistryPacks(ids, {
|
||||
fetchImpl: fetchImpl as typeof fetch
|
||||
})
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,197 +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']
|
||||
|
||||
const RegistryPackSchema = z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
banner_url: z.string().optional(),
|
||||
repository: z.string().optional(),
|
||||
license: z.string().optional(),
|
||||
downloads: z.number().optional(),
|
||||
github_stars: z.number().optional(),
|
||||
created_at: z.string().optional(),
|
||||
supported_os: z.array(z.string()).optional(),
|
||||
supported_accelerators: z.array(z.string()).optional(),
|
||||
publisher: z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
latest_version: z
|
||||
.object({
|
||||
version: z.string().optional(),
|
||||
createdAt: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
.optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const RegistryListResponseSchema = z
|
||||
.object({
|
||||
nodes: z.array(RegistryPackSchema)
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
interface FetchRegistryOptions {
|
||||
baseUrl?: string
|
||||
timeoutMs?: number
|
||||
fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
export async function fetchRegistryPacks(
|
||||
packIds: readonly string[],
|
||||
options: FetchRegistryOptions = {}
|
||||
): Promise<Map<string, RegistryPack | null>> {
|
||||
const uniquePackIds = [...new Set(packIds.filter((id) => id.length > 0))]
|
||||
if (uniquePackIds.length === 0) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_BASE_URL
|
||||
const timeoutMs = clampTimeoutMs(options.timeoutMs)
|
||||
const fetchImpl = options.fetchImpl ?? fetch
|
||||
|
||||
const batches = chunk(uniquePackIds, BATCH_SIZE)
|
||||
const resolved = new Map<string, RegistryPack | null>()
|
||||
let successCount = 0
|
||||
let failureCount = 0
|
||||
|
||||
for (const batch of batches) {
|
||||
const nodes = await fetchBatchWithRetry(
|
||||
fetchImpl,
|
||||
baseUrl,
|
||||
batch,
|
||||
timeoutMs
|
||||
)
|
||||
if (!nodes) {
|
||||
failureCount += 1
|
||||
for (const packId of batch) {
|
||||
resolved.set(packId, null)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
successCount += 1
|
||||
const nodesById = new Map(
|
||||
nodes
|
||||
.map((node) => [node.id, node] as const)
|
||||
.filter(([id]) => typeof id === 'string' && id.length > 0)
|
||||
)
|
||||
|
||||
for (const packId of batch) {
|
||||
resolved.set(packId, nodesById.get(packId) ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
if (failureCount > 0) {
|
||||
console.warn(
|
||||
`[cloud-nodes] registry enrichment: ${successCount}/${batches.length} batches succeeded, ${failureCount} failed`
|
||||
)
|
||||
}
|
||||
|
||||
if (successCount === 0) {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
async function fetchBatchWithRetry(
|
||||
fetchImpl: typeof fetch,
|
||||
baseUrl: string,
|
||||
packIds: readonly string[],
|
||||
timeoutMs: number
|
||||
): Promise<RegistryPack[] | null> {
|
||||
const firstAttempt = await fetchBatch(fetchImpl, baseUrl, packIds, timeoutMs)
|
||||
if (firstAttempt.kind === 'ok') {
|
||||
return firstAttempt.nodes
|
||||
}
|
||||
if (!firstAttempt.retryable) {
|
||||
return null
|
||||
}
|
||||
|
||||
const secondAttempt = await fetchBatch(fetchImpl, baseUrl, packIds, timeoutMs)
|
||||
if (secondAttempt.kind === 'ok') {
|
||||
return secondAttempt.nodes
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type BatchResponse =
|
||||
| { kind: 'ok'; nodes: RegistryPack[] }
|
||||
| { kind: 'err'; retryable: boolean }
|
||||
|
||||
async function fetchBatch(
|
||||
fetchImpl: typeof fetch,
|
||||
baseUrl: string,
|
||||
packIds: readonly string[],
|
||||
timeoutMs: number
|
||||
): Promise<BatchResponse> {
|
||||
const params = new URLSearchParams()
|
||||
for (const packId of packIds) {
|
||||
params.append('node_id', packId)
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
const res = await fetchImpl(`${baseUrl}/nodes?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
kind: 'err',
|
||||
retryable: res.status === 429 || (res.status >= 500 && res.status < 600)
|
||||
}
|
||||
}
|
||||
|
||||
const rawBody: unknown = await res.json()
|
||||
const parsed = RegistryListResponseSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return { kind: 'err', retryable: false }
|
||||
}
|
||||
return { kind: 'ok', nodes: parsed.data.nodes as RegistryPack[] }
|
||||
} catch {
|
||||
return { kind: 'err', retryable: true }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function chunk<T>(values: readonly T[], size: number): T[][] {
|
||||
const chunks: T[][] = []
|
||||
for (let i = 0; i < values.length; i += size) {
|
||||
chunks.push(values.slice(i, i + size))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
function clampTimeoutMs(candidate: number | undefined): number {
|
||||
if (
|
||||
typeof candidate !== 'number' ||
|
||||
!Number.isFinite(candidate) ||
|
||||
candidate <= 0
|
||||
) {
|
||||
return DEFAULT_TIMEOUT_MS
|
||||
}
|
||||
return Math.floor(candidate)
|
||||
}
|
||||
@@ -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,42 +0,0 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 10,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["this-image-does-not-exist-deadbeef.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -470,7 +470,6 @@ const COLLECT_COVERAGE = process.env.COLLECT_COVERAGE === 'true'
|
||||
|
||||
export const comfyPageFixture = base.extend<{
|
||||
initialFeatureFlags: Record<string, unknown>
|
||||
initialSettings: Record<string, unknown>
|
||||
comfyPage: ComfyPage
|
||||
comfyMouse: ComfyMouse
|
||||
comfyFiles: ComfyFiles
|
||||
@@ -478,10 +477,6 @@ export const comfyPageFixture = base.extend<{
|
||||
// Allows configuring feature flags for tests with before initial setup:
|
||||
// `test.use({ initialFeatureFlags: { my_flag: true } })`.
|
||||
initialFeatureFlags: [{}, { option: true }],
|
||||
// Allows seeding user settings before initial page load:
|
||||
// `test.use({ initialSettings: { 'Comfy.Locale': 'zh' } })`. Merged on top of
|
||||
// the fixture's defaults so per-test values win.
|
||||
initialSettings: [{}, { option: true }],
|
||||
|
||||
page: async ({ page, browserName }, use) => {
|
||||
if (browserName !== 'chromium' || !COLLECT_COVERAGE) {
|
||||
@@ -499,11 +494,7 @@ export const comfyPageFixture = base.extend<{
|
||||
await mcr.add(coverage)
|
||||
},
|
||||
|
||||
comfyPage: async (
|
||||
{ page, request, initialFeatureFlags, initialSettings },
|
||||
use,
|
||||
testInfo
|
||||
) => {
|
||||
comfyPage: async ({ page, request, initialFeatureFlags }, use, testInfo) => {
|
||||
const comfyPage = new ComfyPage(page, request)
|
||||
|
||||
const { parallelIndex } = testInfo
|
||||
@@ -538,8 +529,7 @@ export const comfyPageFixture = base.extend<{
|
||||
// Disable errors tab to prevent missing model detection from
|
||||
// rendering error indicators on nodes during unrelated tests.
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': false,
|
||||
...(isVueNodes && { 'Comfy.VueNodes.Enabled': true }),
|
||||
...initialSettings
|
||||
...(isVueNodes && { 'Comfy.VueNodes.Enabled': true })
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
@@ -384,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,10 +1,6 @@
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
NodeError,
|
||||
NodeProgressState,
|
||||
PromptResponse
|
||||
} from '@/schemas/apiSchema'
|
||||
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
@@ -234,16 +230,6 @@ export class ExecutionHelper {
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `progress_state` WS event with per-node execution state. */
|
||||
progressState(jobId: string, nodes: Record<string, NodeProgressState>): void {
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'progress_state',
|
||||
data: { prompt_id: jobId, nodes }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a job by adding it to mock history, sending execution_success,
|
||||
* and triggering a history refresh via a status event.
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -14,7 +14,6 @@ export class VueNodeFixture {
|
||||
public readonly collapseIcon: Locator
|
||||
public readonly root: Locator
|
||||
public readonly widgets: Locator
|
||||
public readonly imagePreview: Locator
|
||||
|
||||
constructor(private readonly locator: Locator) {
|
||||
this.header = locator.locator('[data-testid^="node-header-"]')
|
||||
@@ -26,7 +25,6 @@ export class VueNodeFixture {
|
||||
this.collapseIcon = this.collapseButton.locator('i')
|
||||
this.root = locator
|
||||
this.widgets = this.locator.locator('.lg-node-widget')
|
||||
this.imagePreview = locator.locator('.image-preview')
|
||||
}
|
||||
|
||||
async getTitle(): Promise<string> {
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* FE-230: Deleting an asset must clear the Load Image node preview, widget
|
||||
* value, and mark the workflow dirty.
|
||||
*
|
||||
* Local run (requires cloud build of the frontend):
|
||||
* pnpm build:cloud
|
||||
* pnpm exec playwright test --project=cloud \
|
||||
* browser_tests/tests/assetDeleteClearsLoadImage.spec.ts --reporter=list
|
||||
*
|
||||
* The cloud project is required because input-asset deletion is gated on
|
||||
* `isCloud === true` (see `useMediaAssetActions.deleteAssetApi`).
|
||||
*/
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
STABLE_CHECKPOINT,
|
||||
STABLE_INPUT_IMAGE
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
// The asset name must match the dropped file so that the deletion flow's
|
||||
// widget-value matching (name + `name [input]`) actually targets the same
|
||||
// value the drag-and-drop set on the Load Image widget.
|
||||
const DROPPED_FILE = 'image64x64.webp'
|
||||
const TARGET_ASSET: Asset = {
|
||||
...STABLE_INPUT_IMAGE,
|
||||
name: DROPPED_FILE,
|
||||
mime_type: 'image/webp'
|
||||
}
|
||||
const SEEDED_ASSETS: Asset[] = [STABLE_CHECKPOINT, TARGET_ASSET]
|
||||
// MediaAssetCard renders the basename without extension, so card-text
|
||||
// matching uses the stripped form.
|
||||
const TARGET_CARD_TEXT = TARGET_ASSET.name.replace(/\.[^.]+$/, '')
|
||||
|
||||
type AssetMockApi = {
|
||||
readonly deleteCalls: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
const assetMockByPage = new WeakMap<Page, { deleteCalls: string[] }>()
|
||||
|
||||
function filterByTags(assets: Asset[], url: URL): Asset[] {
|
||||
const includeTags = parseTagParam(url.searchParams.get('include_tags'))
|
||||
const excludeTags = parseTagParam(url.searchParams.get('exclude_tags'))
|
||||
return assets.filter(
|
||||
(asset) =>
|
||||
includeTags.every((tag) => (asset.tags ?? []).includes(tag)) &&
|
||||
excludeTags.every((tag) => !(asset.tags ?? []).includes(tag))
|
||||
)
|
||||
}
|
||||
|
||||
function parseTagParam(value: string | null): string[] {
|
||||
return (
|
||||
value
|
||||
?.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
// Narrow the route patterns to the cloud API endpoints we actually want to
|
||||
// intercept. Using a broader pattern (e.g. `**/assets**`) collides with the
|
||||
// cloud build's static bundle paths under `/assets/*.js` and starves the app
|
||||
// of its own JavaScript, leaving it stuck on the loading splash.
|
||||
async function registerAssetMocks(
|
||||
page: Page,
|
||||
assets: Asset[],
|
||||
deleteCalls: string[]
|
||||
): Promise<void> {
|
||||
await page.route(/\/api\/assets(?:\?.*)?$/, (route: Route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
const filtered = filterByTags(assets, url)
|
||||
const body: ListAssetsResponse = {
|
||||
assets: filtered,
|
||||
total: filtered.length,
|
||||
has_more: false
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
})
|
||||
|
||||
await page.route(/\/api\/assets\/([^/?#]+)$/, (route: Route) => {
|
||||
const method = route.request().method()
|
||||
const id = new URL(route.request().url()).pathname.split('/').pop() ?? ''
|
||||
if (method === 'DELETE') {
|
||||
deleteCalls.push(id)
|
||||
return route.fulfill({ status: 204, body: '' })
|
||||
}
|
||||
if (method === 'GET') {
|
||||
const found = assets.find((asset) => asset.id === id)
|
||||
if (found) return route.fulfill({ status: 200, json: found })
|
||||
return route.fulfill({ status: 404, json: { error: 'Not found' } })
|
||||
}
|
||||
return route.fallback()
|
||||
})
|
||||
}
|
||||
|
||||
const baseTest = comfyPageFixture.extend<{ assetMock: AssetMockApi }>({
|
||||
page: async ({ page }, use) => {
|
||||
const deleteCalls: string[] = []
|
||||
await registerAssetMocks(page, SEEDED_ASSETS, deleteCalls)
|
||||
assetMockByPage.set(page, { deleteCalls })
|
||||
await use(page)
|
||||
assetMockByPage.delete(page)
|
||||
},
|
||||
assetMock: async ({ page }, use) => {
|
||||
const state = assetMockByPage.get(page)
|
||||
if (!state) throw new Error('assetMock state missing for page')
|
||||
await use({
|
||||
get deleteCalls() {
|
||||
return state.deleteCalls
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
baseTest.describe(
|
||||
'FE-230 asset delete clears Load Image preview',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
baseTest(
|
||||
'deleting an input asset clears widget value, preview cache, and marks workflow modified',
|
||||
async ({ comfyPage, assetMock }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
|
||||
// Drive the production drag-and-drop flow to point the Load Image
|
||||
// widget at the asset we are about to delete and populate the preview
|
||||
// cache. FE-230 is asserting that the deletion tears these down.
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
await comfyPage.dragDrop.dragAndDropFile(DROPPED_FILE, {
|
||||
dropPosition: { x, y },
|
||||
waitForUpload: true
|
||||
})
|
||||
const imageWidget = await loadImageNode.getWidget(0)
|
||||
await expect.poll(() => imageWidget.getValue()).toBe(DROPPED_FILE)
|
||||
|
||||
// Re-baseline the change tracker so the deletion-side mutation is the
|
||||
// only thing that can flip `isModified` later.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const tracker =
|
||||
window.app?.extensionManager?.workflow?.activeWorkflow
|
||||
?.changeTracker
|
||||
tracker?.reset?.()
|
||||
})
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
||||
.toBe(false)
|
||||
|
||||
// Drive the real production flow: assets sidebar → Imported tab →
|
||||
// right-click asset card → Delete → confirm dialog.
|
||||
const sidebar = comfyPage.menu.assetsTab
|
||||
// The default `open()` waits for assets on the Generated tab; we seed
|
||||
// only an input asset, so skip that wait and let `waitForAssets(1)`
|
||||
// gate on the Imported tab instead.
|
||||
await sidebar.open({ waitForAssets: false })
|
||||
await sidebar.switchToImported()
|
||||
await sidebar.waitForAssets(1)
|
||||
await sidebar.rightClickAsset(TARGET_CARD_TEXT)
|
||||
|
||||
const deleteMenuItem = sidebar.contextMenuItem('Delete')
|
||||
await expect(deleteMenuItem).toBeVisible()
|
||||
await deleteMenuItem.click()
|
||||
|
||||
await comfyPage.confirmDialog.click('delete')
|
||||
|
||||
// Mocked DELETE was issued.
|
||||
await expect
|
||||
.poll(() => assetMock.deleteCalls.includes(TARGET_ASSET.id))
|
||||
.toBe(true)
|
||||
|
||||
// Widget value was cleared.
|
||||
await expect.poll(() => imageWidget.getValue()).toBe('')
|
||||
|
||||
// Preview cache was cleared.
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((nodeId) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
return node?.imgs?.length ?? 0
|
||||
}, loadImageNode.id)
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
// Workflow was marked dirty by changeTracker.captureCanvasState().
|
||||
await expect
|
||||
.poll(() => comfyPage.workflow.isCurrentWorkflowModified())
|
||||
.toBe(true)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { CustomNodesI18n } from '@/schemas/apiSchema'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const NODE_TYPE = 'DevToolsNodeWithStringInput'
|
||||
const LOCALIZED_ZH = '本地化字符串输入 (ZH)'
|
||||
const LOCALIZED_ZH_TW = '本地化字串輸入 (ZH-TW)'
|
||||
const LOCALIZED_EN = 'Localized String Input (EN)'
|
||||
|
||||
async function routeCustomNodesI18n(page: Page, body: CustomNodesI18n) {
|
||||
await page.route('**/api/i18n', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Custom node locales loading',
|
||||
{ tag: ['@ui', '@vue-nodes'] },
|
||||
() => {
|
||||
test.describe('shipped base tag', () => {
|
||||
test.use({ initialSettings: { 'Comfy.Locale': 'zh' } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await routeCustomNodesI18n(page, {
|
||||
zh: { nodeDefs: { [NODE_TYPE]: { display_name: LOCALIZED_ZH } } }
|
||||
})
|
||||
})
|
||||
|
||||
// Regression test for PR #7214 (issue #7025): custom-node i18n data was
|
||||
// clobbered when a non-English locale was lazily loaded, so nodes from
|
||||
// custom packs lost their translated display_name on locale switch.
|
||||
test('preserves custom-node /api/i18n translation through lazy locale load', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.addNode(NODE_TYPE)
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeByTitle(LOCALIZED_ZH)
|
||||
).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('unsupported tag clamps to en', () => {
|
||||
// Regression test for PR #11712 (issue #10563): when Comfy.Locale holds
|
||||
// an unsupported tag, the boundary helper clamps it to 'en'. Custom-node
|
||||
// 'en' translations must still merge into the active locale messages.
|
||||
test.use({ initialSettings: { 'Comfy.Locale': 'de' } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await routeCustomNodesI18n(page, {
|
||||
en: { nodeDefs: { [NODE_TYPE]: { display_name: LOCALIZED_EN } } }
|
||||
})
|
||||
})
|
||||
|
||||
test('renders en custom-node translation when locale clamps to en', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.addNode(NODE_TYPE)
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeByTitle(LOCALIZED_EN)
|
||||
).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('regional tag preserved', () => {
|
||||
// Regression test for PR #11712: full-tag match must beat base-tag
|
||||
// fallback, so a shipped regional tag like 'zh-TW' is not collapsed to
|
||||
// its base ('zh'). Both keys are present in the payload — the active
|
||||
// locale must merge the regional variant.
|
||||
test.use({ initialSettings: { 'Comfy.Locale': 'zh-TW' } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await routeCustomNodesI18n(page, {
|
||||
zh: { nodeDefs: { [NODE_TYPE]: { display_name: LOCALIZED_ZH } } },
|
||||
'zh-TW': {
|
||||
nodeDefs: { [NODE_TYPE]: { display_name: LOCALIZED_ZH_TW } }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('uses zh-TW custom-node translation, not zh base-tag fallback', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.addNode(NODE_TYPE)
|
||||
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeByTitle(LOCALIZED_ZH_TW)
|
||||
).toHaveCount(1)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,47 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
// Regression test for https://github.com/Comfy-Org/ComfyUI_frontend/issues/10563
|
||||
//
|
||||
// Pins the end-to-end cascade through createI18n + coreSettings defaultValue +
|
||||
// GraphView watchEffect: when navigator.language base tag is unsupported (e.g.
|
||||
// 'de-DE') and Comfy.Locale is unset (fresh-install state), sidebar labels
|
||||
// must render translated strings, not literal i18n keys like
|
||||
// 'sideToolbar.labels.assets'.
|
||||
test.describe('i18n locale fallback', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
value: 'de-DE',
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
value: ['de-DE', 'de'],
|
||||
configurable: true
|
||||
})
|
||||
})
|
||||
// Default sidebar size on small viewports hides labels; force normal so
|
||||
// .side-bar-button-label is rendered for the assertion.
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.waitForAppReady()
|
||||
})
|
||||
|
||||
test('sidebar labels render translated strings, not raw i18n keys', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||
|
||||
const labelTexts = await page
|
||||
.getByTestId('side-toolbar')
|
||||
.locator('.side-bar-button-label')
|
||||
.allTextContents()
|
||||
|
||||
expect(labelTexts.length).toBeGreaterThan(0)
|
||||
for (const text of labelTexts) {
|
||||
expect(text).not.toContain('sideToolbar.labels')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 26 KiB |
@@ -8,9 +8,6 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
const DEPRECATED_NODE_TYPE = 'ImageBatch'
|
||||
const API_NODE_TYPE = 'FluxProUltraImageNode'
|
||||
|
||||
test.describe('Node Badge', { tag: ['@screenshot', '@smoke', '@node'] }, () => {
|
||||
test('Can add badge', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
@@ -144,73 +141,3 @@ test.describe(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
for (const vueEnabled of [false, true] as const) {
|
||||
const renderer = vueEnabled ? 'vue' : 'classic'
|
||||
const tag = vueEnabled
|
||||
? ['@vue-nodes', '@screenshot', '@node']
|
||||
: ['@screenshot', '@node']
|
||||
|
||||
test.describe(`Node lifecycle badge (${renderer})`, { tag }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
|
||||
})
|
||||
|
||||
for (const mode of [NodeBadgeMode.ShowAll, NodeBadgeMode.None] as const) {
|
||||
test(`renders deprecated node with mode=${mode}`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeBadge.NodeLifeCycleBadgeMode',
|
||||
mode
|
||||
)
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.nodeOps.addNode(DEPRECATED_NODE_TYPE, undefined, {
|
||||
x: 100,
|
||||
y: 100
|
||||
})
|
||||
await comfyPage.canvasOps.resetView()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
`node-lifecycle-${mode}-${renderer}.png`
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test.describe(`API pricing badge (${renderer})`, { tag }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasInfo', false)
|
||||
await comfyPage.page.evaluate((type) => {
|
||||
const registered = window.LiteGraph!.registered_node_types[type] as {
|
||||
nodeData?: { price_badge?: unknown }
|
||||
}
|
||||
if (!registered?.nodeData) throw new Error(`No nodeData for ${type}`)
|
||||
registered.nodeData.price_badge = {
|
||||
engine: 'jsonata',
|
||||
expr: "{'type': 'text', 'text': '99.9 credits/Run'}",
|
||||
depends_on: { widgets: [], inputs: [], input_groups: [] }
|
||||
}
|
||||
}, API_NODE_TYPE)
|
||||
})
|
||||
|
||||
for (const enabled of [true, false] as const) {
|
||||
test(`renders api node with showApiPricing=${enabled}`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.NodeBadge.ShowApiPricing',
|
||||
enabled
|
||||
)
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.nodeOps.addNode(API_NODE_TYPE, undefined, {
|
||||
x: 100,
|
||||
y: 100
|
||||
})
|
||||
await comfyPage.canvasOps.resetView()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
`api-pricing-${enabled ? 'on' : 'off'}-${renderer}.png`
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 43 KiB |
@@ -82,19 +82,6 @@ const JOB_GAMMA_DETAIL: JobDetail = {
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
workflow: {
|
||||
extra_data: {
|
||||
extra_pnginfo: {
|
||||
workflow: {
|
||||
version: 0.4,
|
||||
last_node_id: 0,
|
||||
last_link_id: 0,
|
||||
nodes: [],
|
||||
links: []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +112,7 @@ test.describe('Assets sidebar - empty states', () => {
|
||||
|
||||
test('Shows empty-state copy for generated tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open({ waitForAssets: false })
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
@@ -133,7 +120,7 @@ test.describe('Assets sidebar - empty states', () => {
|
||||
|
||||
test('Shows empty-state copy for imported tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open({ waitForAssets: false })
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
|
||||
@@ -142,7 +129,7 @@ test.describe('Assets sidebar - empty states', () => {
|
||||
|
||||
test('No asset cards are rendered when empty', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open({ waitForAssets: false })
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(0)
|
||||
})
|
||||
@@ -222,6 +209,7 @@ test.describe('Assets sidebar - grid view display', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.waitForAssets()
|
||||
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
@@ -282,6 +270,7 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
test('Can switch to list view via settings menu', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Open settings menu and select list view
|
||||
await tab.openSettingsMenu()
|
||||
@@ -294,6 +283,7 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
test('Can switch back to grid view', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Switch to list view
|
||||
await tab.openSettingsMenu()
|
||||
@@ -336,6 +326,7 @@ test.describe('Assets sidebar - search', () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
@@ -349,6 +340,7 @@ test.describe('Assets sidebar - search', () => {
|
||||
test('Clearing search restores all assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
@@ -363,6 +355,7 @@ test.describe('Assets sidebar - search', () => {
|
||||
test('Search with no matches shows empty state', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.searchInput.fill('nonexistent_file_xyz')
|
||||
await expect(tab.assetCards).toHaveCount(0)
|
||||
@@ -387,6 +380,7 @@ test.describe('Assets sidebar - selection', () => {
|
||||
test('Clicking an asset card selects it', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Click first asset card
|
||||
await tab.assetCards.first().click()
|
||||
@@ -398,6 +392,7 @@ test.describe('Assets sidebar - selection', () => {
|
||||
test('Ctrl+click adds to selection', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
|
||||
@@ -416,6 +411,7 @@ test.describe('Assets sidebar - selection', () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
@@ -427,6 +423,7 @@ test.describe('Assets sidebar - selection', () => {
|
||||
test('Deselect all clears selection', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
@@ -444,6 +441,7 @@ test.describe('Assets sidebar - selection', () => {
|
||||
test('Selection is cleared when switching tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
@@ -477,6 +475,7 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
test('Right-clicking an asset shows context menu', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Right-click first asset
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
@@ -491,6 +490,7 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
@@ -505,6 +505,7 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
@@ -519,6 +520,7 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
@@ -533,6 +535,7 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
@@ -547,6 +550,7 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
@@ -559,79 +563,12 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
await expect(tab.contextMenuItem('Export workflow')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Cancelling export-workflow filename prompt does not show an error toast', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// job-gamma is the first card; its detail carries a valid workflow so
|
||||
// extraction succeeds and the filename prompt opens.
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await tab.contextMenuItem('Export workflow').click()
|
||||
|
||||
const promptDialog = comfyPage.page.getByRole('dialog', {
|
||||
name: 'Export Workflow'
|
||||
})
|
||||
await expect(promptDialog).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(promptDialog).toBeHidden()
|
||||
|
||||
await expect(comfyPage.toast.toastErrors).toBeHidden({ timeout: 1500 })
|
||||
})
|
||||
|
||||
test('Confirming export-workflow prompt downloads the file and shows a success toast', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await tab.contextMenuItem('Export workflow').click()
|
||||
|
||||
const promptDialog = comfyPage.page.getByRole('dialog', {
|
||||
name: 'Export Workflow'
|
||||
})
|
||||
await expect(promptDialog).toBeVisible()
|
||||
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await promptDialog.getByRole('button', { name: 'Confirm' }).click()
|
||||
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBe('abstract_art.json')
|
||||
|
||||
await expect(comfyPage.toast.toastSuccesses).toBeVisible()
|
||||
})
|
||||
|
||||
test('Export-workflow shows a warning toast when the asset has no workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Strip the workflow field so extraction yields null and the export
|
||||
// action returns { success: false, error: 'No workflow…' }.
|
||||
const { workflow: _, ...detailWithoutWorkflow } = JOB_GAMMA_DETAIL
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', detailWithoutWorkflow)
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await tab.contextMenuItem('Export workflow').click()
|
||||
|
||||
// Filename prompt should be skipped: extraction fails before the prompt.
|
||||
await expect(comfyPage.toast.toastWarnings).toBeVisible()
|
||||
await expect(comfyPage.toast.toastSuccesses).toBeHidden({ timeout: 1500 })
|
||||
})
|
||||
|
||||
test('Bulk context menu shows when multiple assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
|
||||
@@ -686,6 +623,7 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
@@ -698,6 +636,7 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
@@ -708,6 +647,7 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
test('Selection count displays correct number', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select the two single-output assets (job-alpha, job-beta).
|
||||
// The count reflects total outputs, not cards — job-gamma has
|
||||
@@ -736,6 +676,7 @@ cloudTest.describe('Assets sidebar - cloud exports', { tag: '@cloud' }, () => {
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
@@ -760,6 +701,7 @@ cloudTest.describe('Assets sidebar - cloud exports', { tag: '@cloud' }, () => {
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards
|
||||
.first()
|
||||
@@ -795,6 +737,7 @@ cloudTest.describe('Assets sidebar - cloud exports', { tag: '@cloud' }, () => {
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.nth(1).click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
@@ -841,6 +784,7 @@ test.describe('Assets sidebar - pagination', () => {
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const req = await firstRequest
|
||||
const url = new URL(req.url())
|
||||
@@ -896,6 +840,7 @@ test.describe('Assets sidebar - delete confirmation', () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await tab.contextMenuItem('Delete').click()
|
||||
@@ -913,6 +858,7 @@ test.describe('Assets sidebar - delete confirmation', () => {
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
@@ -934,6 +880,7 @@ test.describe('Assets sidebar - delete confirmation', () => {
|
||||
test('Cancelling delete preserves asset', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
@@ -1030,6 +977,7 @@ test.describe('Assets sidebar - media type filter', () => {
|
||||
test('Unchecking image filter hides image assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = tab.assetCards
|
||||
await expect(
|
||||
@@ -1050,6 +998,7 @@ test.describe('Assets sidebar - media type filter', () => {
|
||||
test('Re-enabling filter restores hidden assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
|
||||
@@ -131,51 +131,48 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
|
||||
test('Falls back to English templates when locale file not found', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Pick a shipped LTR locale and simulate its template index returning 404.
|
||||
// (Previously this test used 'de', but unsupported locales are now
|
||||
// clamped to 'en' at boot so they never hit the template fallback path.
|
||||
// 'fa' would also work but flips document.dir to rtl, which can leak
|
||||
// into adjacent specs in the same worker.)
|
||||
const locale = 'tr'
|
||||
// Set locale to a language that doesn't have a template file
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', 'de') // German - no index.de.json exists
|
||||
|
||||
await comfyPage.page.route(
|
||||
`**/templates/index.${locale}.json`,
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: 'Not Found'
|
||||
})
|
||||
}
|
||||
// Wait for the German request (expected to 404)
|
||||
const germanRequestPromise = comfyPage.page.waitForRequest(
|
||||
'**/templates/index.de.json'
|
||||
)
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', (route) =>
|
||||
route.continue()
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.Locale', locale)
|
||||
|
||||
const localeRequestPromise = comfyPage.page.waitForRequest(
|
||||
`**/templates/index.${locale}.json`
|
||||
)
|
||||
// Wait for the fallback English request
|
||||
const englishRequestPromise = comfyPage.page.waitForRequest(
|
||||
'**/templates/index.json'
|
||||
)
|
||||
|
||||
// Intercept the German file to simulate a 404
|
||||
await comfyPage.page.route('**/templates/index.de.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: 'Not Found'
|
||||
})
|
||||
})
|
||||
|
||||
// Allow the English index to load normally
|
||||
await comfyPage.page.route('**/templates/index.json', (route) =>
|
||||
route.continue()
|
||||
)
|
||||
|
||||
// Load the templates dialog
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
const localeRequest = await localeRequestPromise
|
||||
// Verify German was requested first, then English as fallback
|
||||
const germanRequest = await germanRequestPromise
|
||||
const englishRequest = await englishRequestPromise
|
||||
|
||||
expect(localeRequest.url()).toContain(`templates/index.${locale}.json`)
|
||||
expect(germanRequest.url()).toContain('templates/index.de.json')
|
||||
expect(englishRequest.url()).toContain('templates/index.json')
|
||||
|
||||
// Assert on rendered content, not just the container — the container
|
||||
// testid is present even when the dialog body is empty, which would let
|
||||
// a regression where the fallback fetch succeeds but no cards render
|
||||
// pass silently.
|
||||
await expect(comfyPage.templates.allTemplateCards.first()).toBeVisible()
|
||||
// Verify English titles are shown as fallback
|
||||
await expect(
|
||||
comfyPage.page.getByRole('main').getByText('All Templates')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('template cards are dynamically sized and responsive', async ({
|
||||
|
||||
@@ -21,8 +21,9 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
})
|
||||
|
||||
const nodeId = String(loadImageNode.id)
|
||||
const { imagePreview } =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
const imagePreview = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.locator('.image-preview')
|
||||
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible({ timeout: 30_000 })
|
||||
@@ -43,25 +44,6 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
})
|
||||
|
||||
test('hides mask and download buttons when image is missing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'widgets/load_image_widget_missing_file'
|
||||
)
|
||||
|
||||
const { imagePreview } =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('Load Image')
|
||||
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.getByTestId('error-loading-image')).toBeVisible()
|
||||
|
||||
await imagePreview.getByRole('region').hover()
|
||||
|
||||
await expect(imagePreview.getByLabel('Edit or mask image')).toHaveCount(0)
|
||||
await expect(imagePreview.getByLabel('Download image')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('shows image context menu options', async ({ comfyPage }) => {
|
||||
const { nodeId } = await loadImageOnNode(comfyPage)
|
||||
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
import type { WebSocketRoute } from '@playwright/test'
|
||||
import { mergeTests } from '@playwright/test'
|
||||
import type { z } from 'zod'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import type {
|
||||
RawJobListItem,
|
||||
zJobsListResponse
|
||||
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
type JobsListResponse = z.infer<typeof zJobsListResponse>
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const KSAMPLER_NODE = '3'
|
||||
const EXECUTING_CLASS = /outline-node-stroke-executing/
|
||||
|
||||
const QUEUE_ROUTE = /\/api\/jobs\?[^/]*status=in_progress,pending/
|
||||
const HISTORY_ROUTE = /\/api\/jobs\?[^/]*status=completed/
|
||||
|
||||
function jobsResponse(jobs: RawJobListItem[]): JobsListResponse {
|
||||
return {
|
||||
jobs,
|
||||
pagination: { offset: 0, limit: 200, total: jobs.length, has_more: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function mockJobsRoute(
|
||||
comfyPage: ComfyPage,
|
||||
pattern: RegExp,
|
||||
body: string,
|
||||
status: number = 200
|
||||
): Promise<() => number> {
|
||||
let count = 0
|
||||
await comfyPage.page.route(pattern, async (route) => {
|
||||
count += 1
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
body
|
||||
})
|
||||
})
|
||||
return () => count
|
||||
}
|
||||
|
||||
const emptyJobsBody = JSON.stringify(jobsResponse([]))
|
||||
|
||||
type Scenario = {
|
||||
name: string
|
||||
/** Built per-test so it can incorporate the runtime-assigned jobId. */
|
||||
queueBody: (jobId: string) => string
|
||||
/** Whether the active job state should still be reflected after reconnect. */
|
||||
expectsActiveAfter: boolean
|
||||
}
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
name: 'clears stale active job when queue is empty after reconnect',
|
||||
queueBody: () => emptyJobsBody,
|
||||
expectsActiveAfter: false
|
||||
},
|
||||
{
|
||||
name: 'preserves active job when the job is still in the queue',
|
||||
queueBody: (jobId) =>
|
||||
JSON.stringify(
|
||||
jobsResponse([
|
||||
{ id: jobId, status: 'in_progress', create_time: Date.now() }
|
||||
])
|
||||
),
|
||||
expectsActiveAfter: true
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Stub the queue/history endpoints per `scenario`, close the WS, and wait
|
||||
* for the auto-reconnect to issue a fresh queue fetch.
|
||||
*/
|
||||
async function triggerReconnect(
|
||||
comfyPage: ComfyPage,
|
||||
ws: WebSocketRoute,
|
||||
scenario: Scenario,
|
||||
jobId: string
|
||||
): Promise<void> {
|
||||
await mockJobsRoute(comfyPage, HISTORY_ROUTE, emptyJobsBody)
|
||||
const queueFetches = await mockJobsRoute(
|
||||
comfyPage,
|
||||
QUEUE_ROUTE,
|
||||
scenario.queueBody(jobId)
|
||||
)
|
||||
const fetchesBeforeClose = queueFetches()
|
||||
await ws.close()
|
||||
await expect.poll(queueFetches).toBeGreaterThan(fetchesBeforeClose)
|
||||
}
|
||||
|
||||
test.describe('WebSocket reconnect with stale job', { tag: '@ui' }, () => {
|
||||
test.describe('app mode skeleton', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([[KSAMPLER_NODE, 'seed']])
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
})
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
test(scenario.name, async ({ comfyPage, getWebSocket }) => {
|
||||
const ws = await getWebSocket()
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
|
||||
const jobId = await exec.run()
|
||||
exec.executionStart(jobId)
|
||||
|
||||
// Skeleton visibility is the deterministic sync point: it appears
|
||||
// once both `storeJob` (HTTP) and `executionStart` (WS) have been
|
||||
// processed, regardless of arrival order.
|
||||
const firstSkeleton = comfyPage.appMode.outputHistory.skeletons.first()
|
||||
await expect(firstSkeleton).toBeVisible()
|
||||
|
||||
await triggerReconnect(comfyPage, ws, scenario, jobId)
|
||||
|
||||
if (scenario.expectsActiveAfter) {
|
||||
await expect(firstSkeleton).toBeVisible()
|
||||
} else {
|
||||
await expect(comfyPage.appMode.outputHistory.skeletons).toHaveCount(0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('preserves active job when the queue endpoint fails on reconnect', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
|
||||
const jobId = await exec.run()
|
||||
exec.executionStart(jobId)
|
||||
|
||||
const firstSkeleton = comfyPage.appMode.outputHistory.skeletons.first()
|
||||
await expect(firstSkeleton).toBeVisible()
|
||||
|
||||
await mockJobsRoute(comfyPage, HISTORY_ROUTE, emptyJobsBody)
|
||||
|
||||
// Prime queueStore.runningTasks with the active job — a WS status
|
||||
// event drives GraphView.onStatus -> queueStore.update().
|
||||
const primer = await mockJobsRoute(
|
||||
comfyPage,
|
||||
QUEUE_ROUTE,
|
||||
JSON.stringify(
|
||||
jobsResponse([
|
||||
{ id: jobId, status: 'in_progress', create_time: Date.now() }
|
||||
])
|
||||
)
|
||||
)
|
||||
exec.status(1)
|
||||
await expect.poll(primer).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Swap to a failing handler so the reconnect-driven fetch 500s.
|
||||
// The fix should preserve runningTasks from the priming call rather
|
||||
// than overwriting it with empty/error state.
|
||||
await comfyPage.page.unroute(QUEUE_ROUTE)
|
||||
const failed = await mockJobsRoute(comfyPage, QUEUE_ROUTE, '{}', 500)
|
||||
|
||||
const before = failed()
|
||||
await ws.close()
|
||||
await expect.poll(failed).toBeGreaterThan(before)
|
||||
|
||||
await expect(firstSkeleton).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('vue node executing class', { tag: '@vue-nodes' }, () => {
|
||||
for (const scenario of scenarios) {
|
||||
test(scenario.name, async ({ comfyPage, getWebSocket }) => {
|
||||
const ws = await getWebSocket()
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
|
||||
// The executing outline lives on the outer `[data-node-id]`
|
||||
// container, not the inner wrapper.
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(KSAMPLER_NODE)
|
||||
await expect(ksamplerNode).toBeVisible()
|
||||
|
||||
const jobId = await exec.run()
|
||||
exec.executionStart(jobId)
|
||||
exec.progressState(jobId, {
|
||||
[KSAMPLER_NODE]: {
|
||||
value: 0,
|
||||
max: 1,
|
||||
state: 'running',
|
||||
node_id: KSAMPLER_NODE,
|
||||
display_node_id: KSAMPLER_NODE,
|
||||
prompt_id: jobId
|
||||
}
|
||||
})
|
||||
|
||||
await expect(ksamplerNode).toHaveClass(EXECUTING_CLASS)
|
||||
|
||||
await triggerReconnect(comfyPage, ws, scenario, jobId)
|
||||
|
||||
if (scenario.expectsActiveAfter) {
|
||||
await expect(ksamplerNode).toHaveClass(EXECUTING_CLASS)
|
||||
} else {
|
||||
await expect(ksamplerNode).not.toHaveClass(EXECUTING_CLASS)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
187
docs/architecture/extension-api-v2/names-appendix.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Names That Must Agree Across Layers
|
||||
|
||||
**Task:** DOC1.E6
|
||||
**Date:** 2026-05-08
|
||||
**Patterns cross-walked:** S2.N16 (widget array access), S13.SC1 (ComfyNodeDef inspection), S15.OS1 (dynamic output mutation)
|
||||
|
||||
This appendix enumerates the terms that span at least two of the four layers — Python backend, v1 frontend (`ComfyExtension`/LiteGraph), v2 extension API (`NodeHandle`/`WidgetHandle`), and ECS World components — and calls out real inconsistencies where the same concept is named differently or the semantics diverge. Future contributors who rename or refactor any of these terms must propagate the change across all layers listed.
|
||||
|
||||
---
|
||||
|
||||
## Layers
|
||||
|
||||
| Layer | Owner | Primary source |
|
||||
|-------|-------|---------------|
|
||||
| **Python backend** | ComfyUI server | `NODE_CLASS_MAPPINGS`, `INPUT_TYPES`, `RETURN_TYPES` |
|
||||
| **v1 frontend** | LiteGraph / ComfyExtension | `src/types/comfy.ts`, `src/schemas/nodeDefSchema.ts`, `LGraphNode.ts` |
|
||||
| **v2 extension API** | This project | `src/extension-api/node.ts`, `src/extension-api/widget.ts` |
|
||||
| **ECS World** | Alex's branch (PR #11939) | `src/services/extension-api-service.ts` (stubs), `@/world/entityIds` |
|
||||
|
||||
---
|
||||
|
||||
## Term 1 — Node class identifier (`class_type` / `type` / `comfyClass`)
|
||||
|
||||
**What it is:** The string that identifies which Python class backs a node. Used to look up `object_info`, serialize the prompt, and match `INPUT_TYPES` definitions.
|
||||
|
||||
| Layer | Name | Value example | Notes |
|
||||
|-------|------|--------------|-------|
|
||||
| Python backend | Python class name | `'KSampler'` | The class registered in `NODE_CLASS_MAPPINGS` |
|
||||
| Execution prompt JSON (API format) | `class_type` | `"class_type": "KSampler"` | Key in the flat prompt dict |
|
||||
| UWF backend spec | `class_type` | `"class_type": "KSampler"` | Unchanged from API format (per `uwf-backend-data-model.md`) |
|
||||
| `ComfyNodeDef` (v1 schema) | `name` | `nodeData.name === 'KSampler'` | The `name` field in the server's `/object_info` response |
|
||||
| `LGraphNode` (v1) | `node.type` | `node.type === 'KSampler'` | LiteGraph's class string; set at registration |
|
||||
| v2 `NodeHandle` | `handle.type` | `handle.type === 'KSampler'` | `readonly type: string` in `src/extension-api/node.ts:260` |
|
||||
| v2 `NodeHandle` | `handle.comfyClass` | `handle.comfyClass === 'KSampler'` | `readonly comfyClass: string` in `src/extension-api/node.ts:269` |
|
||||
| ECS `NodeTypeData` component | `type` + `comfyClass` | both fields | Stub in `extension-api-service.ts:61–63` |
|
||||
|
||||
**⚠ Inconsistency — `type` vs `comfyClass`:** v2 `NodeHandle` exposes **both** `type` and `comfyClass` because for most nodes they are equal, but for virtual/reroute nodes `type` is the LiteGraph registration string while `comfyClass` is the actual Python class backing the node. Extensions that compare against ComfyUI node names should always use `comfyClass`. Extensions that filter by LiteGraph registration (for `nodeTypes:` filter in `defineNodeExtension`) should use `type`. The distinction must be preserved — collapsing them back to one field would break reroute/virtual-node detection.
|
||||
|
||||
**Rule:** `class_type` (wire format, Python, UWF) = `comfyClass` (v2 API) = `name` in `ComfyNodeDef` = `node.type` in LiteGraph for non-virtual nodes.
|
||||
|
||||
---
|
||||
|
||||
## Term 2 — Node display name (`display_name` / `title`)
|
||||
|
||||
**What it is:** The human-readable label shown in the node header and search results. Distinct from the class identifier.
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| Python backend | `NODE_DISPLAY_NAME_MAPPINGS[class]` (optional) | If absent, falls back to the class name |
|
||||
| `ComfyNodeDef` (v1 schema) | `display_name` | `nodeData.display_name` — always a string per zod schema (`src/schemas/nodeDefSchema.ts:279`) |
|
||||
| `LGraphNode` (v1) | `node.title` | Set during `nodeCreated`; extensions mutate `node.title` directly to rename nodes |
|
||||
| v2 `NodeHandle` | `handle.title` (getter) + `handle.setTitle(s)` | `src/extension-api/node.ts:304–315`; accessor pair per D3.3/D6 hybrid rule |
|
||||
| ECS `NodeVisualData` component | `title` | Field name consistent with LiteGraph |
|
||||
|
||||
**No inconsistency.** `display_name` lives only in the schema/backend layer; `title` is the runtime/frontend term. The two layers don't overlap. Extension authors should use `handle.setTitle()` in v2; `node.title =` is the v1 equivalent. Both are stable.
|
||||
|
||||
---
|
||||
|
||||
## Term 3 — Widget name (`name` / `input_name` / `widget.name`)
|
||||
|
||||
**What it is:** The stable per-node-instance identifier for a widget slot. Used as the key in `widgets_values_named`, `promoted_inputs`, and for `WidgetHandle` lookup.
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| Python backend `INPUT_TYPES` | Dict key (e.g. `'seed'`, `'steps'`) | The name as declared in the Python class |
|
||||
| UWF `promoted_inputs` | `input_name` | `{ node_id, input_name, display_name }` — snake_case to match Python origin |
|
||||
| UWF `spec.nodes.{id}.inputs` | Named key in the inputs object | e.g. `"seed": { "value": 123, "link": null }` |
|
||||
| v1 `LGraphNode.widgets` | `widget.name` | `widget.name === 'seed'` — used in `node.widgets.find(w => w.name === ...)` (S2.N16 pattern) |
|
||||
| v1 `node.widgets` iteration | position index (implicit) | `widgets_values` positional array — the root cause of widget shift bugs (S17.WV1) |
|
||||
| v2 `NodeHandle.widget(name)` | `name` argument | Lookup by name, not position (`src/extension-api/node.ts:383`) |
|
||||
| v2 `NodeHandle.addWidget(type, name, ...)` | `name` parameter | `src/extension-api/node.ts:396–403`; stable key for the widget's lifetime |
|
||||
| v2 `WidgetHandle.name` | `readonly name: string` | `src/extension-api/widget.ts:277` — "stable within the node's lifetime" |
|
||||
| ECS `WidgetComponentSchema` | `name` field (inferred) | Widget schema component expected to carry `name` per `widgetComponents` import |
|
||||
|
||||
**⚠ Inconsistency — positional array vs. named:** v1 serialization stores widget values as a positional array (`widgets_values: [123, 20, 7.5]`). v2 API and UWF both use `name` as the stable key. The bridge is Austin's PR #10392 (`widgets_values_named`). Until that merges, any code that reads `node.widgets[i]` by index is fragile; code that reads `widget.name` is UWF-safe. The v2 `WidgetHandle` **must** be looked up by name, never by index — this is enforced by the API shape.
|
||||
|
||||
**Rule:** Always use the Python-declared input name as the canonical widget identifier. Never use position. `widget.name` (v1) = `name` parameter (v2 addWidget) = `input_name` (UWF wire format).
|
||||
|
||||
---
|
||||
|
||||
## Term 4 — Widget type string (`type` / `widgetType` / widget constructor key)
|
||||
|
||||
**What it is:** The string describing what kind of widget a slot is (e.g. `'INT'`, `'STRING'`, `'COMBO'`, `'IMAGE'`). Controls which widget constructor is used and which validation rules apply.
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| Python backend | Return value of `INPUT_TYPES()` tuple: first element | e.g. `("INT", {"default": 42})` — the type string |
|
||||
| `ComfyNodeDef` / `InputSpec` | Type string as zod-inferred from the schema | `INPUT_TYPES['required']['steps'] = ['INT', {...}]` |
|
||||
| `zBaseInputOptions` | `widgetType` field (optional override) | `src/schemas/nodeDefSchema.ts:33` — overrides the slot type for widget selection; rare |
|
||||
| v1 `getCustomWidgets` return | Record key | `{ MY_WIDGET: constructor }` — extension-registered type strings |
|
||||
| v1 `node.addWidget(type, ...)` | `type` first arg | The LiteGraph widget constructor key |
|
||||
| v2 `NodeHandle.addWidget(type, name, ...)` | `type` first arg | `src/extension-api/node.ts:395, 403` |
|
||||
| v2 `WidgetHandle.type` | `readonly type: string` | `src/extension-api/widget.ts` line ~280 |
|
||||
| ECS `WidgetComponentSchema` | `type` field | Expected to match the Python-declared type string |
|
||||
|
||||
**⚠ Inconsistency — `widgetType` override:** The `widgetType` field in `zBaseInputOptions` is an override that makes the frontend render a different widget than the Python type implies. Extensions that inspect `nodeData.input.required[name][1].widgetType` to determine rendering (S13.SC1 pattern) must check this field **before** using the slot's primary type. The v2 `ComfyNodeDef`-inspection helper (`ctx.inspectNodeDef`) must resolve this correctly — it cannot just return `InputSpec[0]` (the Python type) as `widgetType`.
|
||||
|
||||
**Rule:** Python type string = v1 `widget.type` = v2 `WidgetHandle.type` = `widgetType` override if present (takes precedence).
|
||||
|
||||
---
|
||||
|
||||
## Term 5 — Slot type string (connection type / `'IMAGE'`, `'LATENT'`, etc.)
|
||||
|
||||
**What it is:** The type label on a node's input/output slot that governs which connections are valid. Distinct from widget type (a slot may be a pure connection point with no widget).
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| Python backend | `RETURN_TYPES` tuple element | e.g. `RETURN_TYPES = ('IMAGE', 'MASK')` |
|
||||
| Python backend `INPUT_TYPES` | First element of required/optional tuple | `'IMAGE'` means "must receive an IMAGE connection" |
|
||||
| UWF `spec.nodes.{id}.outputs` | `{ "name": "IMAGE" }` per output | Output type declarations (new in UWF — not in old API format) |
|
||||
| v1 `LGraphNode` slot | `slot.type` | String on the slot object; extensions read/mutate via S15.OS1 |
|
||||
| v1 `node.addInput(name, type)` | `type` second arg | e.g. `node.addInput('mask', 'MASK')` |
|
||||
| v1 `node.addOutput(name, type)` | `type` second arg | S15.OS1 pattern |
|
||||
| v2 `SlotInfo` | `readonly type: string` | `src/extension-api/node.ts:82–83` |
|
||||
| v2 slot events | `event.slot.type` | Available in `onSlotConnected`, `onSlotDisconnected` |
|
||||
|
||||
**⚠ Inconsistency — dynamic mutation (S15.OS1):** v1 allows `slot.type = 'IMAGE'` and `node.outputs[i].type = newType` at runtime. v2 restricts this: output types must be declared in `INPUT_TYPES` / schema; runtime mutation is only via `node.declareOutputs(spec)` (proposed, not yet implemented). This is an intentional breaking change. The UWF spec formalizes this by requiring `spec.nodes.{id}.outputs` to be declared at save time, not derived from runtime state.
|
||||
|
||||
**Rule:** Slot type strings are uppercase by convention (matching Python `RETURN_TYPES`). v2 enforces schema-declaration; mutation-at-runtime is deprecated.
|
||||
|
||||
---
|
||||
|
||||
## Term 6 — Node output name (`output_name` / `RETURN_NAMES`)
|
||||
|
||||
**What it is:** Optional human-readable names for a node's output slots. Not the type — the label shown on the output connector.
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| Python backend | `RETURN_NAMES` class attribute | e.g. `RETURN_NAMES = ('upscaled_image', 'mask')` — optional |
|
||||
| `ComfyNodeDef` schema | `output_name` | `z.array(z.string()).optional()` in `src/schemas/nodeDefSchema.ts:275` |
|
||||
| v1 `LGraphNode` | `output.name` | String on the slot object; `node.outputs[i].name` |
|
||||
| v2 `SlotInfo` | `readonly name: string` | `src/extension-api/node.ts:80–81` — same field name |
|
||||
|
||||
**No inconsistency.** `RETURN_NAMES` → `output_name` in the schema → `slot.name` at runtime. All three refer to the same string. Field name shifts from snake_case (`output_name`) in the schema to camelCase-neutral (`name`) on the slot object — consistent with the rest of the frontend.
|
||||
|
||||
---
|
||||
|
||||
## Term 7 — Extension name (`ComfyExtension.name` / `ExtensionOptions.name`)
|
||||
|
||||
**What it is:** The unique identifier for an extension used for hook ordering (D10b), scope registry keys, deprecation telemetry, and conflict detection.
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| v1 `ComfyExtension` | `name: string` | Required field; `src/types/comfy.ts:108`; typically a dotted namespace like `'Comfy.Sidebar'` |
|
||||
| v2 `ExtensionOptions` | `name: string` | Required field; `src/extension-api/lifecycle.ts`; same semantic |
|
||||
| ECS scope registry | `extensionName` | Key component of `NodeInstanceScope`; used in `${extensionName}:${nodeEntityId}` scope key |
|
||||
| D6 telemetry | `apiVersion` | Separate field added by I-EXT.3 to `ExtensionOptions` for version tracking — not a replacement for `name` |
|
||||
|
||||
**No inconsistency.** Same field name and semantics across v1 and v2. The scope registry key format is `${extensionName}:${nodeEntityId}` — both components must be stable.
|
||||
|
||||
**Rule:** Extension names should follow the dotted-namespace convention (e.g. `'MyPublisher.MyExtension'`) to avoid collisions. This is currently advisory, not enforced.
|
||||
|
||||
---
|
||||
|
||||
## Term 8 — Node input display name (`display_name` / widget label)
|
||||
|
||||
**What it is:** The label shown next to the widget in the UI. Distinct from the internal `name` (key) used for serialization.
|
||||
|
||||
| Layer | Name | Notes |
|
||||
|-------|------|-------|
|
||||
| Python backend | `display_name` in input options dict | `INPUT_TYPES()['required']['steps'] = ['INT', {'display_name': 'Steps'}]` |
|
||||
| `zBaseInputOptions` schema | `display_name: z.string().optional()` | `src/schemas/nodeDefSchema.ts:27` |
|
||||
| UWF `promoted_inputs` | `display_name` field | `{ node_id, input_name, display_name }` — the UI label for app-mode promoted inputs |
|
||||
| v1 `widget` | `widget.label` | Optional; falls back to `widget.name` if absent. Inspected in S2.N16 patterns |
|
||||
| v2 `WidgetHandle` | `label` getter | `src/extension-api/widget.ts:355` — "Defaults to the widget name" |
|
||||
|
||||
**No inconsistency** in naming — all layers call it `display_name` (schema/wire) or `label` (runtime). The two forms are consistent: `display_name` is the static schema-declared label; `label` is the runtime-settable display string. v2 exposes `label` as a settable accessor; the Python-declared `display_name` becomes its initial value.
|
||||
|
||||
---
|
||||
|
||||
## Summary: real inconsistencies to track
|
||||
|
||||
| # | Inconsistency | Risk | Resolution |
|
||||
|---|--------------|------|-----------|
|
||||
| 1 | `type` vs `comfyClass` on `NodeHandle` — two fields, must not collapse | Medium | Document: use `comfyClass` for Python identity, `type` for LiteGraph registration. Enforced by distinct fields. |
|
||||
| 2 | Widget identity: positional index (v1 `widgets_values`) vs name key (v2 / UWF) | **HIGH** | Bridge: Austin's PR #10392 (`widgets_values_named`). v2 `WidgetHandle` is name-only. Never look up by position in v2 code. |
|
||||
| 3 | `widgetType` override in `InputSpec` takes precedence over slot type for rendering | Medium | `ctx.inspectNodeDef` must resolve `widgetType` before returning slot type. Do not skip this field. |
|
||||
| 4 | Slot type mutation (S15.OS1): `slot.type = X` is valid v1, banned in v2/UWF | Medium | v2 must not expose a `setType()` mutator on `SlotInfo`. Schema-declare outputs; UWF enforces at save time. |
|
||||
|
||||
---
|
||||
|
||||
## Cross-references
|
||||
|
||||
- **S2.N16** — widget array iteration/mutation (`node.widgets[i]`, `node.widgets.find(w => w.name === ...)`): the `name` field is the stable key; position is not. v2 forces name-based lookup.
|
||||
- **S13.SC1** — `ComfyNodeDef` inspection: callers must resolve `widgetType` override (Term 4) and understand `display_name` vs runtime `label` (Term 8). The `ctx.inspectNodeDef` typed helper (D4 G1 BLOCKER) wraps this correctly.
|
||||
- **S15.OS1** — dynamic output mutation: slot `type` strings are the agreed layer (Term 5), but v1 allows mutation that v2/UWF forbids. Track in I-PG.B2 as `strangler-bridge` until UWF Phase 3 covers output schema declaration.
|
||||
- **UWF backend data model** — `class_type`, `input_name`, `display_name` snake_case keys mirror Python origin. v2 API uses camelCase (`comfyClass`, widget `name`, widget `label`) per JS convention. No semantic difference; only case convention changes at the API boundary.
|
||||
@@ -34,7 +34,7 @@ function formatAndEslint(fileNames: string[]) {
|
||||
const joinedPaths = toJoinedRelativePaths(fileNames)
|
||||
return [
|
||||
`pnpm exec oxfmt --write ${joinedPaths}`,
|
||||
`pnpm exec oxlint --type-aware --fix ${joinedPaths}`,
|
||||
`pnpm exec oxlint --fix ${joinedPaths}`,
|
||||
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
|
||||
]
|
||||
}
|
||||
|
||||