mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 13:32:11 +00:00
Compare commits
44 Commits
austin/cre
...
ext-api/i-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24d893d401 | ||
|
|
3d09f89251 | ||
|
|
dd5335df7c | ||
|
|
ee0537fdb5 | ||
|
|
d5d5692928 | ||
|
|
e56187adf3 | ||
|
|
446d0a216e | ||
|
|
bd4d195230 | ||
|
|
ff314491da | ||
|
|
b3b3b10fea | ||
|
|
fa3229b402 | ||
|
|
2f102353fa | ||
|
|
df921f3512 | ||
|
|
a058a410ac | ||
|
|
300be13a4c | ||
|
|
f069f540ce | ||
|
|
d4323d7ab1 | ||
|
|
c5d7fb113f | ||
|
|
6345359ca8 | ||
|
|
b2e9c8f749 | ||
|
|
20daf22a68 | ||
|
|
f83510a223 | ||
|
|
ba636765a7 | ||
|
|
d0614e595f | ||
|
|
8e71fd0436 | ||
|
|
8bc2ff0800 | ||
|
|
ceec47df88 | ||
|
|
aa0b00953b | ||
|
|
7b9ea4a01f | ||
|
|
e292976f8d | ||
|
|
a0478f66ea | ||
|
|
52146d918f | ||
|
|
fa0079dfb5 | ||
|
|
f10990df3a | ||
|
|
ccfd53bdf5 | ||
|
|
8da221b5db | ||
|
|
e74250fd8a | ||
|
|
bf272a784d | ||
|
|
e25e210933 | ||
|
|
5b05f2b793 | ||
|
|
3476d06fc9 | ||
|
|
c1748c6fe3 | ||
|
|
9a6fff645d | ||
|
|
2de2e07b36 |
@@ -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: |
|
||||
|
||||
123
.github/workflows/model-page-discovery.yaml
vendored
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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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:",
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 81 KiB |
@@ -1,396 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const WORKFLOW_TEMPLATES_BASE =
|
||||
'https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates'
|
||||
|
||||
const TEMPLATES_DIR = fileURLToPath(
|
||||
new URL('../../../../workflow_templates/templates', import.meta.url)
|
||||
)
|
||||
|
||||
const QUANT_SUFFIXES = [
|
||||
'_fp8_e4m3fn_scaled',
|
||||
'_fp8_e4m3fn',
|
||||
'_fp8_scaled',
|
||||
'_fp4_mixed',
|
||||
'_fp8mixed',
|
||||
'_fp8',
|
||||
'_fp16',
|
||||
'_fp4',
|
||||
'_bf16',
|
||||
'_int8'
|
||||
]
|
||||
|
||||
interface RawModel {
|
||||
name: string
|
||||
url: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
interface ModelData {
|
||||
url: string
|
||||
directory: string
|
||||
templates: Set<string>
|
||||
firstTemplate?: string
|
||||
}
|
||||
|
||||
interface OutputModel {
|
||||
slug: string
|
||||
name: string
|
||||
huggingFaceUrl: string
|
||||
directory: string
|
||||
workflowCount: number
|
||||
displayName: string
|
||||
docsUrl?: string
|
||||
thumbnailUrl?: string
|
||||
canonicalSlug?: string
|
||||
}
|
||||
|
||||
// Maps api_*.json filename prefix to a canonical display name and slug.
|
||||
// Add entries here as new partner integrations land in workflow_templates.
|
||||
const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
|
||||
nano: { name: 'Nano Banana', slug: 'nano-banana' },
|
||||
kling: { name: 'Kling AI', slug: 'kling-ai' },
|
||||
kling2: { name: 'Kling AI', slug: 'kling-ai' },
|
||||
meshy: { name: 'Meshy AI', slug: 'meshy-ai' },
|
||||
luma: { name: 'Luma Dream Machine', slug: 'luma-dream-machine' },
|
||||
runway: { name: 'Runway', slug: 'runway' },
|
||||
vidu: { name: 'Vidu', slug: 'vidu' },
|
||||
bfl: { name: 'Flux (API)', slug: 'flux-api' },
|
||||
grok: { name: 'Grok Image', slug: 'grok-image' },
|
||||
stability: { name: 'Stability AI', slug: 'stability-ai' },
|
||||
bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
|
||||
bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
|
||||
google: { name: 'Gemini Image', slug: 'gemini-image' },
|
||||
hailuo: { name: 'Hailuo MiniMax', slug: 'hailuo-minimax' },
|
||||
ideogram: { name: 'Ideogram', slug: 'ideogram' },
|
||||
pixverse: { name: 'Pixverse', slug: 'pixverse' },
|
||||
rodin: { name: 'Rodin 3D', slug: 'rodin-3d' },
|
||||
magnific: { name: 'Magnific AI', slug: 'magnific-ai' },
|
||||
bria: { name: 'Bria AI', slug: 'bria-ai' },
|
||||
tripo: { name: 'Tripo 3D', slug: 'tripo-3d' },
|
||||
tripo3: { name: 'Tripo 3D', slug: 'tripo-3d' },
|
||||
hunyuan3d: { name: 'Hunyuan 3D', slug: 'hunyuan-3d' },
|
||||
recraft: { name: 'Recraft', slug: 'recraft' },
|
||||
topaz: { name: 'Topaz Labs', slug: 'topaz-labs' },
|
||||
moonvalley: { name: 'Moonvalley', slug: 'moonvalley' },
|
||||
ltxv: { name: 'LTX Video (API)', slug: 'ltxv-api' },
|
||||
openai: { name: 'OpenAI DALL-E', slug: 'openai-dall-e' },
|
||||
wan: { name: 'Wan (API)', slug: 'wan-api' },
|
||||
wan2: { name: 'Wan (API)', slug: 'wan-api' },
|
||||
veo2: { name: 'Veo 2', slug: 'veo-2' },
|
||||
veo3: { name: 'Veo 3', slug: 'veo-3' },
|
||||
flux2: { name: 'Flux 2 (API)', slug: 'flux-2-api' },
|
||||
wavespeed: { name: 'Wavespeed', slug: 'wavespeed' },
|
||||
wavespped: { name: 'Wavespeed', slug: 'wavespeed' }
|
||||
}
|
||||
|
||||
function stripExt(name: string): string {
|
||||
return name.replace(/\.(safetensors|ckpt|pt|bin)$/, '')
|
||||
}
|
||||
|
||||
function stripQuant(base: string): string {
|
||||
for (const suffix of QUANT_SUFFIXES) {
|
||||
if (base.endsWith(suffix)) return base.slice(0, -suffix.length)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function makeSlug(name: string): string {
|
||||
const base = stripExt(name)
|
||||
return base
|
||||
.toLowerCase()
|
||||
.replace(/[_.]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function makeDisplayName(name: string): string {
|
||||
const base = stripExt(name)
|
||||
return base
|
||||
.split(/[_-]/)
|
||||
.map((part) => {
|
||||
if (/^(fp\d+|bf\d+|int\d+)$/i.test(part)) return part.toUpperCase()
|
||||
if (/^(e4m3fn|scaled|mixed|fp8mixed)$/i.test(part)) return part
|
||||
if (/^\d+(\.\d+)?[bBkKmM]?$/.test(part)) return part
|
||||
return part.charAt(0).toUpperCase() + part.slice(1)
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function extractModels(
|
||||
obj: unknown,
|
||||
templateName: string,
|
||||
models: Map<string, ModelData>
|
||||
): void {
|
||||
if (obj === null || typeof obj !== 'object') return
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) extractModels(item, templateName, models)
|
||||
return
|
||||
}
|
||||
|
||||
const record = obj as Record<string, unknown>
|
||||
|
||||
if (Array.isArray(record['models'])) {
|
||||
for (const m of record['models'] as unknown[]) {
|
||||
if (m === null || typeof m !== 'object' || Array.isArray(m)) continue
|
||||
const model = m as Record<string, unknown>
|
||||
if (typeof model['name'] !== 'string') continue
|
||||
|
||||
const name = model['name']
|
||||
const url = typeof model['url'] === 'string' ? model['url'] : ''
|
||||
const directory =
|
||||
typeof model['directory'] === 'string' ? model['directory'] : ''
|
||||
|
||||
if (!models.has(name)) {
|
||||
models.set(name, {
|
||||
url,
|
||||
directory,
|
||||
templates: new Set(),
|
||||
firstTemplate: templateName
|
||||
})
|
||||
}
|
||||
models.get(name)!.templates.add(templateName)
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
extractModels(value, templateName, models)
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiModelData {
|
||||
slug: string
|
||||
name: string
|
||||
directory: 'partner_nodes'
|
||||
templateCount: number
|
||||
}
|
||||
|
||||
function extractApiModels(files: string[]): ApiModelData[] {
|
||||
const counts = new Map<string, number>()
|
||||
for (const file of files) {
|
||||
if (!file.startsWith('api_')) continue
|
||||
const prefix = file.slice(4).split('_')[0]
|
||||
const entry = API_PROVIDER_MAP[prefix]
|
||||
if (!entry) continue
|
||||
counts.set(entry.slug, (counts.get(entry.slug) ?? 0) + 1)
|
||||
}
|
||||
return [...counts.entries()].map(([slug, count]) => {
|
||||
const found = Object.values(API_PROVIDER_MAP).find((e) => e.slug === slug)!
|
||||
return {
|
||||
slug,
|
||||
name: found.name,
|
||||
directory: 'partner_nodes' as const,
|
||||
templateCount: count
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Reads all locale index.json files to build a map of
|
||||
// raw model filename → tutorialUrl. Index entries name the template file;
|
||||
// that file's embedded model objects give the actual filenames.
|
||||
function buildTutorialUrlMap(templatesDir: string): Map<string, string> {
|
||||
const map = new Map<string, string>()
|
||||
const indexFiles = readdirSync(templatesDir).filter(
|
||||
(f) =>
|
||||
f.startsWith('index') &&
|
||||
f.endsWith('.json') &&
|
||||
!f.includes('schema') &&
|
||||
!f.includes('logo')
|
||||
)
|
||||
// Collect template-name → tutorialUrl from all locale indexes (first wins)
|
||||
const templateTutorialMap = new Map<string, string>()
|
||||
const sorted = ['index.json', ...indexFiles.filter((f) => f !== 'index.json')]
|
||||
for (const file of sorted) {
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(readFileSync(join(templatesDir, file), 'utf8'))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!Array.isArray(data)) continue
|
||||
for (const cat of data as unknown[]) {
|
||||
if (typeof cat !== 'object' || cat === null) continue
|
||||
const templates = (cat as Record<string, unknown>)['templates']
|
||||
if (!Array.isArray(templates)) continue
|
||||
for (const t of templates) {
|
||||
if (typeof t !== 'object' || t === null) continue
|
||||
const entry = t as Record<string, unknown>
|
||||
const tutorialUrl =
|
||||
typeof entry['tutorialUrl'] === 'string'
|
||||
? entry['tutorialUrl']
|
||||
: undefined
|
||||
const templateName =
|
||||
typeof entry['name'] === 'string' ? entry['name'] : undefined
|
||||
if (
|
||||
tutorialUrl &&
|
||||
templateName &&
|
||||
!templateTutorialMap.has(templateName)
|
||||
) {
|
||||
templateTutorialMap.set(templateName, tutorialUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For each template with a tutorialUrl, open the template file and map
|
||||
// every embedded model filename to that tutorialUrl
|
||||
for (const [templateName, tutorialUrl] of templateTutorialMap) {
|
||||
const filePath = join(templatesDir, `${templateName}.json`)
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(readFileSync(filePath, 'utf8'))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
function extractModelNames(obj: unknown): void {
|
||||
if (obj === null || typeof obj !== 'object') return
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) extractModelNames(item)
|
||||
return
|
||||
}
|
||||
const record = obj as Record<string, unknown>
|
||||
if (Array.isArray(record['models'])) {
|
||||
for (const m of record['models'] as unknown[]) {
|
||||
if (m === null || typeof m !== 'object' || Array.isArray(m)) continue
|
||||
const model = m as Record<string, unknown>
|
||||
if (typeof model['name'] === 'string' && !map.has(model['name'])) {
|
||||
map.set(model['name'], tutorialUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const value of Object.values(record)) {
|
||||
extractModelNames(value)
|
||||
}
|
||||
}
|
||||
|
||||
extractModelNames(data)
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
function templateThumbnailUrl(
|
||||
firstTemplate: string | undefined,
|
||||
templatesDir: string
|
||||
): string | undefined {
|
||||
if (!firstTemplate) return undefined
|
||||
const base = firstTemplate.replace(/\.json$/, '')
|
||||
const localPath = join(templatesDir, `${base}-1.webp`)
|
||||
if (!existsSync(localPath)) return undefined
|
||||
return `${WORKFLOW_TEMPLATES_BASE}/${encodeURIComponent(base)}-1.webp`
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
const models = new Map<string, ModelData>()
|
||||
|
||||
const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith('.json'))
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(TEMPLATES_DIR, file)
|
||||
try {
|
||||
const raw = readFileSync(filePath, 'utf8')
|
||||
const data: unknown = JSON.parse(raw)
|
||||
extractModels(data, file, models)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse ${file}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const apiModels = extractApiModels(files)
|
||||
const tutorialUrlMap = buildTutorialUrlMap(TEMPLATES_DIR)
|
||||
|
||||
const sorted = [...models.entries()].sort(
|
||||
([, a], [, b]) => b.templates.size - a.templates.size
|
||||
)
|
||||
|
||||
// Build quant convergence map
|
||||
const groups = new Map<string, Array<[string, ModelData]>>()
|
||||
for (const [name, data] of sorted) {
|
||||
const base = stripExt(name)
|
||||
const canonicalBase = stripQuant(base)
|
||||
if (!groups.has(canonicalBase)) groups.set(canonicalBase, [])
|
||||
groups.get(canonicalBase)!.push([name, data])
|
||||
}
|
||||
|
||||
const canonicalMap = new Map<string, string | null>()
|
||||
for (const members of groups.values()) {
|
||||
if (members.length > 1) {
|
||||
const membersSorted = [...members].sort(
|
||||
([, a], [, b]) => b.templates.size - a.templates.size
|
||||
)
|
||||
const canonicalName = membersSorted[0][0]
|
||||
canonicalMap.set(canonicalName, null)
|
||||
for (const [name] of membersSorted.slice(1)) {
|
||||
canonicalMap.set(name, canonicalName)
|
||||
}
|
||||
} else {
|
||||
canonicalMap.set(members[0][0], null)
|
||||
}
|
||||
}
|
||||
|
||||
const output: OutputModel[] = sorted.map(([name, data]) => {
|
||||
const canonicalRaw = canonicalMap.get(name) ?? null
|
||||
const result: OutputModel = {
|
||||
slug: makeSlug(name),
|
||||
name,
|
||||
huggingFaceUrl: data.url,
|
||||
directory: data.directory,
|
||||
workflowCount: data.templates.size,
|
||||
displayName: makeDisplayName(name)
|
||||
}
|
||||
const docsUrl = tutorialUrlMap.get(name)
|
||||
if (docsUrl) result.docsUrl = docsUrl
|
||||
const thumb = templateThumbnailUrl(data.firstTemplate, TEMPLATES_DIR)
|
||||
if (thumb) result.thumbnailUrl = thumb
|
||||
if (canonicalRaw !== null) {
|
||||
result.canonicalSlug = makeSlug(canonicalRaw)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const apiOutput: OutputModel[] = apiModels
|
||||
.sort((a, b) => b.templateCount - a.templateCount)
|
||||
.map((m) => ({
|
||||
slug: m.slug,
|
||||
name: m.name,
|
||||
huggingFaceUrl: '',
|
||||
directory: m.directory,
|
||||
workflowCount: m.templateCount,
|
||||
displayName: m.name
|
||||
}))
|
||||
|
||||
const combined = [...apiOutput, ...output]
|
||||
|
||||
const withThumbs = combined.filter((m) => m.thumbnailUrl).length
|
||||
process.stdout.write(
|
||||
` ${withThumbs}/${combined.length} models have thumbnails\n`
|
||||
)
|
||||
|
||||
const defaultOut = join(
|
||||
fileURLToPath(new URL('.', import.meta.url)),
|
||||
'../src/config/generated-models.json'
|
||||
)
|
||||
const outputArg = process.argv[2] ?? defaultOut
|
||||
const json = JSON.stringify(combined, null, 2) + '\n'
|
||||
|
||||
writeFileSync(outputArg, json, 'utf8')
|
||||
process.stdout.write(
|
||||
`Written ${combined.length} models ` +
|
||||
`(${apiOutput.length} partner, ${output.length} local) to ${outputArg}\n`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
run()
|
||||
} catch (err) {
|
||||
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { renameSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { fetchCloudNodesForBuild } from '../src/utils/cloudNodes'
|
||||
|
||||
const snapshotPath = fileURLToPath(
|
||||
new URL('../src/data/cloud-nodes.snapshot.json', import.meta.url)
|
||||
)
|
||||
const tempPath = `${snapshotPath}.tmp`
|
||||
|
||||
const outcome = await fetchCloudNodesForBuild()
|
||||
|
||||
if (outcome.status !== 'fresh') {
|
||||
const reason = 'reason' in outcome ? outcome.reason : '(none)'
|
||||
console.error(
|
||||
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(outcome.snapshot, null, 2) + '\n'
|
||||
|
||||
writeFileSync(tempPath, serialized, 'utf8')
|
||||
renameSync(tempPath, snapshotPath)
|
||||
|
||||
const totalNodes = outcome.snapshot.packs.reduce(
|
||||
(n, pack) => n + pack.nodes.length,
|
||||
0
|
||||
)
|
||||
process.stdout.write(
|
||||
`Wrote snapshot with ${outcome.snapshot.packs.length} pack(s) and ${totalNodes} node(s) to ${snapshotPath}\n`
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
displayName,
|
||||
huggingFaceUrl,
|
||||
docsUrl,
|
||||
blogUrl,
|
||||
hubSlug,
|
||||
workflowCount,
|
||||
directory
|
||||
} = defineProps<{
|
||||
displayName: string
|
||||
huggingFaceUrl: string
|
||||
docsUrl?: string
|
||||
blogUrl?: string
|
||||
hubSlug?: string
|
||||
workflowCount: number
|
||||
directory: string
|
||||
}>()
|
||||
|
||||
const workflowsUrl = hubSlug
|
||||
? `https://www.comfy.org/workflows/model/${hubSlug}`
|
||||
: null
|
||||
|
||||
const dirDisplayMap: Record<string, string> = {
|
||||
diffusion_models: 'Diffusion Model',
|
||||
checkpoints: 'Checkpoint',
|
||||
loras: 'LoRA',
|
||||
controlnet: 'ControlNet',
|
||||
clip_vision: 'CLIP Vision',
|
||||
model_patches: 'Model Patch',
|
||||
vae: 'VAE',
|
||||
text_encoders: 'Text Encoder',
|
||||
audio_encoders: 'Audio Encoder',
|
||||
latent_upscale_models: 'Latent Upscale Model',
|
||||
upscale_models: 'Upscale Model',
|
||||
style_models: 'Style Model',
|
||||
partner_nodes: 'Partner Node'
|
||||
}
|
||||
|
||||
const eyebrow = dirDisplayMap[directory] ?? directory
|
||||
const isPartnerNode = directory === 'partner_nodes'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="
|
||||
cn(
|
||||
'mx-auto flex max-w-7xl flex-col gap-8 px-6 py-16',
|
||||
'lg:flex-row lg:items-center lg:gap-16 lg:px-8 lg:py-24'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex max-w-2xl flex-1 flex-col gap-6">
|
||||
<p
|
||||
class="text-sm font-medium uppercase tracking-widest text-primary-comfy-yellow"
|
||||
>
|
||||
{{ eyebrow }}
|
||||
</p>
|
||||
|
||||
<h1 class="text-4xl font-bold text-primary-comfy-canvas lg:text-6xl">
|
||||
{{ displayName }} in ComfyUI
|
||||
</h1>
|
||||
|
||||
<p class="text-sm text-primary-comfy-canvas/60">
|
||||
{{
|
||||
t('models.hero.workflowCount').replace(
|
||||
'{count}',
|
||||
String(workflowCount)
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-3 sm:flex-row">
|
||||
<BrandButton
|
||||
v-if="workflowsUrl"
|
||||
:href="workflowsUrl"
|
||||
variant="solid"
|
||||
size="lg"
|
||||
class="w-full uppercase sm:w-auto sm:min-w-48"
|
||||
>
|
||||
{{ t('models.hero.primaryCta') }}
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
v-if="!isPartnerNode && huggingFaceUrl"
|
||||
:href="huggingFaceUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:variant="workflowsUrl ? 'outline' : 'solid'"
|
||||
size="lg"
|
||||
class="w-full uppercase sm:w-auto sm:min-w-48"
|
||||
>
|
||||
{{ t('models.hero.secondaryCta') }}
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
v-if="!workflowsUrl"
|
||||
href="https://www.comfy.org/cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:variant="huggingFaceUrl && !isPartnerNode ? 'outline' : 'solid'"
|
||||
size="lg"
|
||||
class="w-full uppercase sm:w-auto sm:min-w-48"
|
||||
>
|
||||
{{ t('models.hero.cloudCta') }}
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
v-if="docsUrl"
|
||||
:href="docsUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="w-full uppercase sm:w-auto sm:min-w-48"
|
||||
>
|
||||
{{ t('models.hero.tutorialCta') }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
|
||||
<div v-if="blogUrl" class="text-sm text-primary-comfy-canvas/60">
|
||||
<a
|
||||
:href="blogUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-primary-comfy-canvas underline"
|
||||
>
|
||||
{{ t('models.hero.blogLink') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,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 }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,5 +1,5 @@
|
||||
{
|
||||
"fetchedAt": "2026-05-12T16:10:34.114Z",
|
||||
"fetchedAt": "2026-05-02T20:15:18.321Z",
|
||||
"departments": [
|
||||
{
|
||||
"name": "DESIGN",
|
||||
@@ -12,6 +12,13 @@
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3/application"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
@@ -144,11 +151,18 @@
|
||||
"roles": [
|
||||
{
|
||||
"id": "ec68ae44dd5943c9",
|
||||
"title": "Head of Talent",
|
||||
"title": "Talent Lead",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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 ',
|
||||
@@ -3814,60 +3678,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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 ({
|
||||
|
||||
108
docs/adr/0010-deprecate-node-level-serialization-control.md
Normal file
108
docs/adr/0010-deprecate-node-level-serialization-control.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# 10. Deprecate Node-Level Serialization Control
|
||||
|
||||
Date: 2026-05-12
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The v2 extension API initially included `node.on('beforeSerialize', handler)` as a migration path from v1 patterns like `node.onSerialize` and `nodeType.prototype.serialize` patching. This allowed extensions to:
|
||||
|
||||
1. **Append extra fields** to the serialized node object
|
||||
2. **Transform the entire serialized object** via a replace function
|
||||
|
||||
However, during design review (PR #12142), we questioned whether node-level serialization control is the right abstraction:
|
||||
|
||||
### The Problem
|
||||
|
||||
Node-level serialization control is fundamentally **wrong-layered**:
|
||||
|
||||
- **Extension state should live in widgets**, not as arbitrary fields on the node
|
||||
- Widget-level `beforeSerialize` already handles all legitimate use cases
|
||||
- Node-level hooks encourage storing extension state in ad-hoc `node.properties` or custom fields, which:
|
||||
- Breaks the clean separation between framework concerns and extension concerns
|
||||
- Creates hidden dependencies between serialization format and extension behavior
|
||||
- Makes migration and format evolution harder
|
||||
|
||||
### v1 Usage Analysis
|
||||
|
||||
Touch-point audit of `nodeType.prototype.serialize` and `node.onSerialize` patterns in the wild:
|
||||
|
||||
| Use Case | Proper v2 Alternative |
|
||||
| --------------------------- | --------------------------------------------------- |
|
||||
| Store extension state | Use widget values with `beforeSerialize` |
|
||||
| Persist per-instance config | Use `widget.setOption()` → `widget_options` sidecar |
|
||||
| Add metadata for export | Use a dedicated extension state widget |
|
||||
| Transform output format | Framework concern, not extension concern |
|
||||
|
||||
No use case requires node-level control that can't be better served by widget-level APIs.
|
||||
|
||||
## Decision
|
||||
|
||||
**Deprecate `node.on('beforeSerialize')`** — mark as `@deprecated` with clear guidance pointing to widget-level alternatives. Remove in v1.0.
|
||||
|
||||
Widget-level serialization control (`widget.on('beforeSerialize')`) remains fully supported as the correct abstraction.
|
||||
|
||||
### Migration Path
|
||||
|
||||
Extensions currently using `node.on('beforeSerialize')` should:
|
||||
|
||||
1. **Store state in widgets** instead of arbitrary node fields
|
||||
2. **Use `widget.on('beforeSerialize')`** to control serialization per-widget
|
||||
3. **Use `widget.setOption()`** for per-instance configuration
|
||||
|
||||
Example migration:
|
||||
|
||||
```ts
|
||||
// BEFORE (v1 / deprecated v2)
|
||||
node.on('beforeSerialize', (e) => {
|
||||
e.data['my_extension_state'] = computeState()
|
||||
})
|
||||
|
||||
// AFTER (recommended v2)
|
||||
const stateWidget = node.addWidget('STRING', '_my_state', '', {
|
||||
hidden: true,
|
||||
serialize: true
|
||||
})
|
||||
stateWidget.on('beforeSerialize', (e) => {
|
||||
e.setSerializedValue(JSON.stringify(computeState()))
|
||||
})
|
||||
```
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Add `@deprecated` tag to `node.on('beforeSerialize')` with migration guidance
|
||||
2. Add console.warn when the deprecated event is used (dev mode only)
|
||||
3. Update documentation to recommend widget-level patterns
|
||||
4. Remove `NodeBeforeSerializeEvent` type and handler in v1.0
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Cleaner architecture**: Extension state flows through widgets, the designed data channel
|
||||
- **Better debuggability**: Widget values are visible in workflow JSON at predictable locations
|
||||
- **Easier migration**: Future format changes only need to consider widget serialization
|
||||
- **Reduced API surface**: One less event type to maintain and document
|
||||
|
||||
### Negative
|
||||
|
||||
- **Migration burden**: Extensions using node-level serialization must refactor
|
||||
- **Potential edge cases**: Some exotic use cases may require workarounds
|
||||
|
||||
### Risk Mitigation
|
||||
|
||||
- Deprecation warning gives extension authors runway to migrate
|
||||
- Widget-level APIs are already more capable than node-level alternatives
|
||||
- The `@deprecated` tag and docs provide clear migration path
|
||||
|
||||
## Notes
|
||||
|
||||
This decision was made during design review of PR #12142 (ext-api foundation). See `design-review-12142.md` Topic 11 for the full discussion thread.
|
||||
|
||||
Related decisions:
|
||||
|
||||
- Widget-level `beforeSerialize` remains the primary extension serialization hook
|
||||
- `setSerializeEnabled()` remains for simple static opt-out cases
|
||||
111
docs/adr/0011-immutability-via-fresh-copies.md
Normal file
111
docs/adr/0011-immutability-via-fresh-copies.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# 11. Immutability Enforcement via Fresh Copies
|
||||
|
||||
Date: 2026-05-12
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The extension API exposes collection-returning methods like `widgets()`, `inputs()`, `outputs()`, and object-returning methods like `getProperties()`. These methods need immutability guarantees to prevent extensions from accidentally or intentionally mutating internal state.
|
||||
|
||||
### The Problem
|
||||
|
||||
Without runtime immutability enforcement:
|
||||
|
||||
- Extensions could push items into `widgets()` array, corrupting internal state
|
||||
- Mutations to returned objects would silently affect internal data
|
||||
- Debugging would be difficult — state corruption could surface far from the mutation site
|
||||
- Internal framework code might inadvertently rely on returned arrays being stable
|
||||
|
||||
TypeScript's `readonly` modifier and JSDoc annotations provide compile-time protection, but:
|
||||
|
||||
- JavaScript consumers have no protection
|
||||
- Type assertions can bypass readonly
|
||||
- Agent-generated code may not respect type hints
|
||||
|
||||
### Options Considered
|
||||
|
||||
| Option | Pros | Cons |
|
||||
| ------------------------ | --------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| **1. `Object.freeze()`** | Runtime immutability, throws on mutation | Performance overhead, nested objects need deep freeze |
|
||||
| **2. Return fresh copy** | Simple, functional style, no mutation affects source | Slight memory overhead, multiple calls = multiple arrays |
|
||||
| **3. Proxy wrapper** | Helpful error messages, can intercept specific operations | Complexity, performance overhead, harder to debug |
|
||||
| **4. TypeScript only** | Zero runtime cost | No protection for JS consumers, can be bypassed |
|
||||
| **5. Private fields** | True encapsulation | Blocks read access too, not suitable for APIs |
|
||||
|
||||
## Decision
|
||||
|
||||
**Return fresh copies** (Option 2) for all collection-returning and object-returning methods in the extension API.
|
||||
|
||||
### Implementation Pattern
|
||||
|
||||
```ts
|
||||
// CORRECT: Return fresh copy
|
||||
widgets(): readonly WidgetHandle[] {
|
||||
const container = world.getComponent(nodeId, WidgetComponentContainer)
|
||||
return (container?.widgetIds ?? []).map(createWidgetHandle)
|
||||
// Each call creates new array — mutations don't affect internal state
|
||||
}
|
||||
|
||||
getProperties(): Record<string, unknown> {
|
||||
return { ...world.getComponent(nodeId, NodeTypeKey)?.properties }
|
||||
// Shallow copy — mutations don't affect source
|
||||
}
|
||||
```
|
||||
|
||||
### Scope
|
||||
|
||||
Apply this pattern to:
|
||||
|
||||
- `NodeHandle.widgets()` — returns fresh `WidgetHandle[]`
|
||||
- `NodeHandle.inputs()` — returns fresh `SlotInfo[]`
|
||||
- `NodeHandle.outputs()` — returns fresh `SlotInfo[]`
|
||||
- `NodeHandle.getProperties()` — returns fresh `Record<string, unknown>`
|
||||
- `WidgetHandle` methods that return objects (if any)
|
||||
- Any future collection/object-returning methods
|
||||
|
||||
### Internal Callers
|
||||
|
||||
Framework-internal code must also use mutation APIs rather than mutating returned collections:
|
||||
|
||||
```ts
|
||||
// WRONG: Mutating returned array
|
||||
const widgets = node.widgets()
|
||||
widgets.push(newWidget) // No effect on node!
|
||||
|
||||
// CORRECT: Use mutation API
|
||||
node.addWidget(type, name, value, options)
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **True immutability**: Mutations to returned data never affect internal state
|
||||
- **Predictable behavior**: Each call returns fresh data reflecting current state
|
||||
- **Simple mental model**: "This is your copy, do what you want with it"
|
||||
- **JavaScript-safe**: Works regardless of TypeScript types
|
||||
|
||||
### Negative
|
||||
|
||||
- **Memory overhead**: Multiple calls create multiple arrays (usually negligible)
|
||||
- **No mutation detection**: Extensions silently get isolated copies, won't know their mutations are ignored
|
||||
- **Fresh reference each call**: Cannot use `===` to detect changes (use deep comparison or events)
|
||||
|
||||
### Mitigations
|
||||
|
||||
- Document that returned collections are snapshots
|
||||
- Use events (`valueChange`, `propertyChange`) to observe changes
|
||||
- The memory overhead is negligible for typical widget/slot counts
|
||||
|
||||
## Notes
|
||||
|
||||
This decision was made during design review of PR #12142 (ext-api foundation). See `design-review-12142.md` Topic 14 for the full discussion thread.
|
||||
|
||||
The alternative of `Object.freeze()` was rejected because:
|
||||
|
||||
- It requires deep freezing for nested objects
|
||||
- Performance overhead for each call
|
||||
- Fresh copies achieve the same goal more simply
|
||||
138
docs/adr/0012-pure-function-loader-pattern.md
Normal file
138
docs/adr/0012-pure-function-loader-pattern.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 12. Pure Function Loader Pattern for Extension Registration
|
||||
|
||||
Date: 2026-05-12
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The v2 extension API needs a mechanism for extensions to register themselves with the runtime. Two broad approaches exist:
|
||||
|
||||
### Side-Effect Registration (Vue 2 Plugin Pattern)
|
||||
|
||||
```ts
|
||||
// Extension self-registers at import time
|
||||
import { app } from '@comfyorg/core'
|
||||
|
||||
app.use({
|
||||
install(app) {
|
||||
app.component('MyWidget', MyWidget)
|
||||
app.directive('my-directive', myDirective)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
Problems:
|
||||
|
||||
- **Import order matters**: If extension A depends on extension B being registered first, import order must be carefully managed
|
||||
- **Hard to test**: Side effects at import time make mocking difficult; tests must manipulate module cache
|
||||
- **Hard to tree-shake**: Bundlers can't eliminate unused extensions — the import executes
|
||||
- **Timing coupling**: Registration and activation are conflated; can't collect extensions first, then activate later
|
||||
|
||||
### Pure Function + Loader Pattern
|
||||
|
||||
```ts
|
||||
// Extension declares intent — no side effects
|
||||
export default defineNode({
|
||||
name: 'my-extension',
|
||||
nodeTypes: ['MyNode'],
|
||||
nodeCreated(handle) {
|
||||
// ...
|
||||
}
|
||||
})
|
||||
|
||||
// App bootstrap activates all registered extensions
|
||||
startExtensionSystem()
|
||||
```
|
||||
|
||||
## Decision
|
||||
|
||||
**Adopt the pure function + loader pattern** for v2 extension registration.
|
||||
|
||||
### Implementation
|
||||
|
||||
```ts
|
||||
// Extension Registry (data collection only)
|
||||
const nodeExtensions: NodeExtensionOptions[] = []
|
||||
|
||||
export function defineNode(options: NodeExtensionOptions): void {
|
||||
nodeExtensions.push(options)
|
||||
}
|
||||
|
||||
// Loader (activation)
|
||||
export function startExtensionSystem(): void {
|
||||
const world = getWorld()
|
||||
watch(
|
||||
() => world.entitiesWith(NodeTypeKey),
|
||||
(nodeEntityIds) => {
|
||||
for (const id of nodeEntityIds) {
|
||||
mountExtensionsForNode(id)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Key Properties
|
||||
|
||||
1. **Pure registration**: `defineNode()` has no side effects beyond pushing to an array. It doesn't touch the World, DOM, or any reactive state.
|
||||
|
||||
2. **Centralized activation**: `startExtensionSystem()` is called exactly once during app bootstrap. This single entry point controls when the extension system "goes live".
|
||||
|
||||
3. **Reactive mounting**: The loader watches the World for entity changes. Extensions are mounted/unmounted in response to ECS state, not imperative calls.
|
||||
|
||||
4. **Order independence**: Extensions can be defined in any order. The loader sorts by name (lexicographic, see D10b) for deterministic execution.
|
||||
|
||||
### Registration Flow
|
||||
|
||||
```
|
||||
Extension files App bootstrap World
|
||||
| | |
|
||||
| defineNode({...}) | |
|
||||
|--------------------->| |
|
||||
| (push to array) | |
|
||||
| | |
|
||||
| | startExtensionSystem()
|
||||
| |------------------>|
|
||||
| | (watch for NodeType entities)
|
||||
| | |
|
||||
| | NodeType added |
|
||||
| |<------------------|
|
||||
| | |
|
||||
| | mountExtensionsForNode(id)
|
||||
| | (runs setup) |
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Testability**: Extensions are plain objects; tests can construct them without side effects. `_clearExtensionsForTesting()` resets state between tests.
|
||||
- **Tree-shakeable**: Bundlers can eliminate unused extension files if their exports are never referenced.
|
||||
- **Order independent**: No import order bugs — the loader handles activation order.
|
||||
- **Lazy activation**: Registration is instant; activation only happens when `startExtensionSystem()` is called.
|
||||
- **SSR friendly**: Pure functions don't execute browser-only code at import time.
|
||||
|
||||
### Negative
|
||||
|
||||
- **Manual bootstrap**: App must call `startExtensionSystem()` — forgetting it silently disables extensions.
|
||||
- **Two-step mental model**: Developers must understand "register" vs "activate" phases.
|
||||
|
||||
### Mitigations
|
||||
|
||||
- App bootstrap is a well-defined location; the call is hard to miss.
|
||||
- Clear documentation and starter templates include the bootstrap call.
|
||||
- Dev-mode warnings if extensions are defined but the system never starts.
|
||||
|
||||
## Notes
|
||||
|
||||
This pattern aligns with modern framework conventions:
|
||||
|
||||
- **Vite plugins**: `vite.config.ts` collects plugins as an array; Vite activates them at build time.
|
||||
- **Vue 3 Composition API**: `setup()` returns reactive state; the framework activates it.
|
||||
- **React hooks**: Pure functions declare effects; React schedules them.
|
||||
|
||||
The key insight is separating **declaration** (what do I want?) from **execution** (make it happen). This separation enables testing, lazy loading, and predictable behavior.
|
||||
@@ -8,16 +8,19 @@ An Architecture Decision Record captures an important architectural decision mad
|
||||
|
||||
## ADR Index
|
||||
|
||||
| ADR | Title | Status | Date |
|
||||
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
|
||||
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
|
||||
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
|
||||
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
|
||||
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
|
||||
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
|
||||
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
|
||||
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
|
||||
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
|
||||
| ADR | Title | Status | Date |
|
||||
| ---------------------------------------------------------- | ------------------------------------------ | -------- | ---------- |
|
||||
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
|
||||
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
|
||||
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
|
||||
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
|
||||
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
|
||||
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
|
||||
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
|
||||
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
|
||||
| [0010](0010-deprecate-node-level-serialization-control.md) | Deprecate Node-Level Serialization Control | Accepted | 2026-05-12 |
|
||||
| [0011](0011-immutability-via-fresh-copies.md) | Immutability Enforcement via Fresh Copies | Accepted | 2026-05-12 |
|
||||
| [0012](0012-pure-function-loader-pattern.md) | Pure Function Loader Pattern | Accepted | 2026-05-12 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
|
||||
93
docs/research/coordinate-systems.md
Normal file
93
docs/research/coordinate-systems.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Research: Canvas vs Client/Pixel Coordinate Usage
|
||||
|
||||
Date: 2026-05-12
|
||||
|
||||
## Question
|
||||
|
||||
How should the extension API handle coordinate systems? Should it expose canvas coordinates, screen/client coordinates, or both?
|
||||
|
||||
## Coordinate Systems in ComfyUI
|
||||
|
||||
### 1. Canvas Space (Logical Units)
|
||||
|
||||
Node positions and sizes are in canvas logical units:
|
||||
|
||||
- Independent of zoom/pan
|
||||
- `[0, 0]` is the canvas origin
|
||||
- Moving a node to `[100, 200]` places it at canvas position (100, 200) regardless of viewport state
|
||||
|
||||
### 2. Screen/Client Space (Pixels)
|
||||
|
||||
DOM elements use pixel coordinates relative to the viewport:
|
||||
|
||||
- Affected by zoom/pan/scroll
|
||||
- `clientX`/`clientY` from mouse events
|
||||
- `getBoundingClientRect()` returns pixel values
|
||||
|
||||
### 3. Widget Height (Pixels)
|
||||
|
||||
DOM widgets reserve height in pixels:
|
||||
|
||||
```ts
|
||||
addDOMWidget({ name: 'preview', element: img, height: 200 }) // 200px
|
||||
```
|
||||
|
||||
## Current Extension API
|
||||
|
||||
| Method | Coordinate System | Notes |
|
||||
| -------------------------- | ----------------- | ----------------------------------------- |
|
||||
| `getPosition()` | Canvas | Returns `[x, y]` in canvas units |
|
||||
| `setPosition()` | Canvas | Accepts `[x, y]` in canvas units |
|
||||
| `getSize()` | Canvas | Returns `[width, height]` in canvas units |
|
||||
| `setSize()` | Canvas | Accepts `[width, height]` in canvas units |
|
||||
| `addDOMWidget({ height })` | Pixels | Reserved height in pixels |
|
||||
| `widget.setHeight(px)` | Pixels | Widget height in pixels |
|
||||
|
||||
## Analysis
|
||||
|
||||
### When Extensions Need Canvas Coordinates
|
||||
|
||||
1. **Node positioning**: Placing nodes relative to each other
|
||||
2. **Layout algorithms**: Auto-arranging nodes in a pattern
|
||||
3. **Collision detection**: Checking if nodes overlap
|
||||
|
||||
### When Extensions Need Screen Coordinates
|
||||
|
||||
1. **Custom overlays**: Drawing UI at a specific screen location
|
||||
2. **Drag-and-drop from external sources**: Converting mouse position to canvas position
|
||||
3. **Context menus**: Positioning menus near the cursor
|
||||
|
||||
### Current State
|
||||
|
||||
The extension API currently exposes:
|
||||
|
||||
- **Canvas coordinates** for node position/size — appropriate, as these are logical values
|
||||
- **Pixel values** for DOM widget height — appropriate, as these are DOM measurements
|
||||
|
||||
**Missing**: No conversion helpers between canvas and screen coordinates.
|
||||
|
||||
## Recommendation
|
||||
|
||||
**The current approach is appropriate.** Extensions that manipulate node positions should work in canvas space. This is the natural abstraction — extensions shouldn't need to account for zoom/pan when laying out nodes.
|
||||
|
||||
### For Advanced Cases
|
||||
|
||||
Extensions needing coordinate conversion (e.g., custom overlays) should either:
|
||||
|
||||
1. **Use LiteGraph's existing transform utilities** (available on `app.canvas`)
|
||||
2. **Access the transform state** via a future canvas API (not part of node/widget handles)
|
||||
|
||||
### Why Not Expose Conversion Helpers on NodeHandle?
|
||||
|
||||
- **Wrong abstraction level**: Coordinate conversion is a canvas concern, not a node concern
|
||||
- **State dependency**: Conversion requires current zoom/pan state, which changes frequently
|
||||
- **Rare use case**: Most extensions work entirely in canvas space
|
||||
|
||||
## Future Considerations
|
||||
|
||||
If multiple extensions need coordinate conversion, consider:
|
||||
|
||||
1. **Canvas API**: `canvas.screenToCanvas(point)` / `canvas.canvasToScreen(point)`
|
||||
2. **Events with both coordinates**: `positionChanged` could include both canvas and screen positions
|
||||
|
||||
For now, no changes are needed — the current API serves the common cases well.
|
||||
93
docs/research/dom-widget-convergence.md
Normal file
93
docs/research/dom-widget-convergence.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Research: DOM Widget Convergence with Base Widget
|
||||
|
||||
Date: 2026-05-12
|
||||
|
||||
## Question
|
||||
|
||||
Should DOM widgets be unified with base widgets, or kept as a separate concept?
|
||||
|
||||
## Current State
|
||||
|
||||
### Creation APIs
|
||||
|
||||
- `node.addWidget(type, name, value, options)` — creates a standard widget
|
||||
- `node.addDOMWidget({ name, element, height })` — creates a DOM-backed widget
|
||||
|
||||
### Internal Implementation
|
||||
|
||||
Both use the same underlying `CreateWidget` command:
|
||||
|
||||
```ts
|
||||
addWidget(type, name, defaultValue, options) {
|
||||
return dispatch({ type: 'CreateWidget', widgetType: type, ... })
|
||||
}
|
||||
|
||||
addDOMWidget(opts) {
|
||||
return dispatch({ type: 'CreateWidget', widgetType: 'DOM', ... })
|
||||
}
|
||||
```
|
||||
|
||||
DOM widgets are just widgets with `widgetType: 'DOM'` and an element reference.
|
||||
|
||||
### Shared WidgetHandle Interface
|
||||
|
||||
Both widget types share the same `WidgetHandle` interface:
|
||||
|
||||
| Method | Standard Widget | DOM Widget |
|
||||
| -------------------------------- | --------------- | ----------------------- |
|
||||
| `entityId`, `name`, `widgetType` | ✓ | ✓ |
|
||||
| `getValue()` / `setValue()` | ✓ (scalar) | ✓ (often unused) |
|
||||
| `isHidden()` / `setHidden()` | ✓ | ✓ |
|
||||
| `isDisabled()` / `setDisabled()` | ✓ | ✓ |
|
||||
| `setHeight(px)` | no-op | ✓ (updates reservation) |
|
||||
| `on('valueChange')` | ✓ | ✓ |
|
||||
| `getOption()` / `setOption()` | ✓ | ✓ |
|
||||
|
||||
## Analysis
|
||||
|
||||
### Arguments FOR Full Convergence
|
||||
|
||||
1. **Single mental model**: Extensions learn one widget concept, not two.
|
||||
2. **Consistent behavior**: All widgets appear in `node.widgets()`, serialize the same way.
|
||||
3. **Simpler API surface**: Fewer methods to document and maintain.
|
||||
|
||||
### Arguments FOR Keeping Separate APIs
|
||||
|
||||
1. **Different ergonomics**: Standard widgets are data-driven (name, value, options); DOM widgets are element-driven (pass an HTMLElement).
|
||||
2. **Type safety**: `addDOMWidget` can require `element: HTMLElement` at compile time; merging would make it optional with runtime checks.
|
||||
3. **Clear intent**: Separate APIs signal different use cases.
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Keep the current partial convergence.** The implementation is unified (`CreateWidget` command), but the creation APIs remain separate for ergonomic reasons.
|
||||
|
||||
### Rationale
|
||||
|
||||
1. **Creation differs, usage is unified.** Extensions create DOM widgets differently (need an element), but interact with them the same way (via `WidgetHandle`).
|
||||
|
||||
2. **Type safety is valuable.** `addDOMWidget({ element })` is clearer than `addWidget('DOM', name, null, { element })`.
|
||||
|
||||
3. **Already well-integrated.** DOM widgets appear in `node.widgets()`, get the same events, and use the same serialization infrastructure.
|
||||
|
||||
### What "Convergence" Means Here
|
||||
|
||||
The widgets are already converged at:
|
||||
|
||||
- **Entity level**: Same `WidgetEntityId` brand
|
||||
- **Interface level**: Same `WidgetHandle` type
|
||||
- **Command level**: Same `CreateWidget` command internally
|
||||
|
||||
The APIs are intentionally separate at:
|
||||
|
||||
- **Creation level**: `addWidget` vs `addDOMWidget`
|
||||
|
||||
This is the right split — unified where it matters (runtime behavior), separate where it improves DX (creation ergonomics).
|
||||
|
||||
## Future Considerations
|
||||
|
||||
If we add more widget creation patterns (e.g., `addCanvasWidget`, `addThreeJSWidget`), we might consider:
|
||||
|
||||
1. **Factory pattern**: `node.widgets.create('DOM', { element })` / `node.widgets.create('INT', { min, max })`
|
||||
2. **Builder pattern**: `node.addWidget('DOM').withElement(el).withHeight(200).build()`
|
||||
|
||||
For now, two explicit methods (`addWidget`, `addDOMWidget`) serve the common cases well.
|
||||
112
docs/research/identity-encapsulation.md
Normal file
112
docs/research/identity-encapsulation.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Research: Identity Encapsulation in the Extension API
|
||||
|
||||
Date: 2026-05-12
|
||||
|
||||
## Question
|
||||
|
||||
When do extensions need access to raw entity IDs (`NodeEntityId`, `WidgetEntityId`, `SlotEntityId`)? Should these be exposed or hidden?
|
||||
|
||||
## Current State
|
||||
|
||||
The v2 extension API exposes entity IDs as read-only properties:
|
||||
|
||||
```ts
|
||||
interface NodeHandle {
|
||||
readonly entityId: NodeEntityId
|
||||
// ...
|
||||
}
|
||||
|
||||
interface WidgetHandle {
|
||||
readonly entityId: WidgetEntityId
|
||||
// ...
|
||||
}
|
||||
|
||||
interface SlotInfo {
|
||||
readonly entityId: SlotEntityId
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
All IDs are **branded types** to prevent accidental mixing at compile time.
|
||||
|
||||
## Use Cases for Raw Entity IDs
|
||||
|
||||
### 1. Per-Instance State Mapping
|
||||
|
||||
Extensions maintaining external state per node:
|
||||
|
||||
```ts
|
||||
const nodeCache = new Map<NodeEntityId, CachedData>()
|
||||
|
||||
defineNode({
|
||||
name: 'my-cache-extension',
|
||||
nodeCreated(handle) {
|
||||
nodeCache.set(handle.entityId, computeExpensiveData())
|
||||
onNodeRemoved(() => nodeCache.delete(handle.entityId))
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 2. Logging and Debugging
|
||||
|
||||
```ts
|
||||
node.on('executed', (e) => {
|
||||
console.log(`[${node.entityId}] Output:`, e.output)
|
||||
})
|
||||
```
|
||||
|
||||
### 3. Inter-Extension Communication
|
||||
|
||||
Extensions that need to coordinate across multiple nodes:
|
||||
|
||||
```ts
|
||||
// Extension A stores data
|
||||
globalState.set(nodeA.entityId, data)
|
||||
|
||||
// Extension B retrieves it
|
||||
const data = globalState.get(nodeB.entityId)
|
||||
```
|
||||
|
||||
### 4. External System Interop
|
||||
|
||||
Extensions integrating with analytics, debugging tools, or external services that need stable node identifiers.
|
||||
|
||||
## Analysis
|
||||
|
||||
### Arguments FOR Exposing Entity IDs
|
||||
|
||||
1. **Legitimate need exists** — The use cases above are real and common.
|
||||
2. **Branded types prevent misuse** — Can't accidentally use `NodeEntityId` where `WidgetEntityId` is expected.
|
||||
3. **Read-only access** — Extensions can't mutate the ID or corrupt internal state.
|
||||
4. **Opaque value** — The format (`node:<graphUuid>:<localId>`) is an implementation detail; extensions should treat it as an opaque string.
|
||||
|
||||
### Arguments AGAINST Exposing Entity IDs
|
||||
|
||||
1. **Format coupling** — Extensions might parse the ID string and break if format changes.
|
||||
2. **Internal detail leakage** — Knowing the ID scheme reveals ECS architecture.
|
||||
3. **Future migration friction** — Changing ID representation requires careful deprecation.
|
||||
|
||||
### Mitigations
|
||||
|
||||
- **Document as opaque**: JSDoc clearly states IDs are opaque, not to be parsed.
|
||||
- **Branded types**: TypeScript prevents misuse across entity categories.
|
||||
- **Phase A format**: Current format includes graph UUID + local ID; this can evolve via semver.
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Keep exposing entity IDs.** The use cases are legitimate, the branded types provide safety, and the read-only nature limits risk. Document that IDs are opaque strings — extensions should never parse or construct them.
|
||||
|
||||
### Guidelines for Extension Authors
|
||||
|
||||
1. **Use IDs only for keying** — Maps, Sets, logging, external system references.
|
||||
2. **Never parse IDs** — The format is an implementation detail subject to change.
|
||||
3. **Prefer handles over IDs** — When passing references between functions, use the handle object, not the raw ID.
|
||||
4. **Clean up on removal** — Always use `onNodeRemoved()` to clean up Maps keyed by entityId.
|
||||
|
||||
## Future Considerations
|
||||
|
||||
If the ID format needs to change significantly, the branded types allow us to:
|
||||
|
||||
1. Introduce a new branded type (e.g., `NodeEntityIdV2`)
|
||||
2. Deprecate the old ID with migration guidance
|
||||
3. Keep both supported during a transition period
|
||||
121
docs/research/serialization-context.md
Normal file
121
docs/research/serialization-context.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Research: Serialization Context Simplification
|
||||
|
||||
Date: 2026-05-12
|
||||
|
||||
## Question
|
||||
|
||||
Can the serialization context be simplified from 4 values to fewer?
|
||||
|
||||
Current contexts:
|
||||
|
||||
- `'workflow'` — saving workflow to disk
|
||||
- `'prompt'` — queueing a run (API call)
|
||||
- `'clone'` — copy/paste operation
|
||||
- `'subgraph-promote'` — widget becoming subgraph IO
|
||||
|
||||
## Use Case Analysis
|
||||
|
||||
### Context: 'workflow'
|
||||
|
||||
**Purpose**: Full persistence of user's work.
|
||||
|
||||
**What extensions need**: Serialize everything the user configured.
|
||||
|
||||
**Example**: A widget storing user preferences needs to include all settings.
|
||||
|
||||
### Context: 'prompt'
|
||||
|
||||
**Purpose**: Sending data to the backend for execution.
|
||||
|
||||
**What extensions need**:
|
||||
|
||||
- Transform values (dynamic prompts → resolved text)
|
||||
- Skip preview-only widgets
|
||||
- Materialize async sources (webcam → frame data)
|
||||
|
||||
**Example**:
|
||||
|
||||
```ts
|
||||
widget.on('beforeSerialize', async (e) => {
|
||||
if (e.context === 'prompt') {
|
||||
e.setSerializedValue(await captureFrame())
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Context: 'clone'
|
||||
|
||||
**Purpose**: Copy/paste should yield independent copy.
|
||||
|
||||
**What extensions need**: Reset instance-specific state while keeping user settings.
|
||||
|
||||
**Example**: A random seed widget might want a new seed on paste.
|
||||
|
||||
### Context: 'subgraph-promote'
|
||||
|
||||
**Purpose**: Widget becomes an input/output on a subgraph.
|
||||
|
||||
**What extensions need**: Convert internal representation to subgraph IO format.
|
||||
|
||||
**Example**: Internal state becomes an exposed parameter.
|
||||
|
||||
## Simplification Options
|
||||
|
||||
### Option A: Keep All 4 (Current State)
|
||||
|
||||
| Pro | Con |
|
||||
| ---------------------------------------- | ----------------- |
|
||||
| Each context has distinct semantics | 4 cases to handle |
|
||||
| Type system enforces valid values | More complex API |
|
||||
| Clear intent for each serialization path | |
|
||||
|
||||
### Option B: Collapse to 2 ('persist' | 'execute')
|
||||
|
||||
```ts
|
||||
context: 'persist' | 'execute'
|
||||
// 'persist' = workflow, clone, subgraph-promote
|
||||
// 'execute' = prompt
|
||||
```
|
||||
|
||||
| Pro | Con |
|
||||
| ------------------------------------------ | ------------------------------- |
|
||||
| Simpler mental model | Loses clone/promote distinction |
|
||||
| Most extensions only care about this split | Can't reset seed on clone |
|
||||
|
||||
### Option C: Remove Context Entirely
|
||||
|
||||
Extensions always transform regardless of context. The framework handles differences.
|
||||
|
||||
| Pro | Con |
|
||||
| ---------------------------- | ---------------------------------------------- |
|
||||
| Simplest API | Loses control for edge cases |
|
||||
| Framework handles all nuance | Some extensions need context-specific behavior |
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Keep all 4 contexts.** The use cases are genuinely different:
|
||||
|
||||
1. **workflow vs prompt**: Very common distinction. Dynamic prompts only process on prompt; preview widgets skip prompt. This is the most important split.
|
||||
|
||||
2. **clone**: Less common, but needed for stateful widgets (random seeds, generated IDs, captured frames).
|
||||
|
||||
3. **subgraph-promote**: Specialized, but necessary for the subgraph feature to work correctly.
|
||||
|
||||
### Rationale
|
||||
|
||||
- Extensions that don't care can ignore the context.
|
||||
- Extensions that do care have the information they need.
|
||||
- The 4 values map to 4 distinct operations in the framework.
|
||||
- Collapsing contexts would remove functionality with no real simplification gain.
|
||||
|
||||
### Mitigation for Complexity
|
||||
|
||||
- Document common patterns clearly
|
||||
- Most extensions only need: `if (context === 'prompt')`
|
||||
- Provide examples in JSDoc
|
||||
|
||||
## Note on Deprecation
|
||||
|
||||
The `NodeBeforeSerializeEvent` is deprecated (ADR-0010). The `WidgetBeforeSerializeEvent` remains supported and uses the same 4 contexts.
|
||||
|
||||
Since node-level serialization is being removed, this research applies to widget-level serialization only.
|
||||
148
docs/research/widget-state-categories.md
Normal file
148
docs/research/widget-state-categories.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Widget State Categories
|
||||
|
||||
Date: 2026-05-12
|
||||
|
||||
## Overview
|
||||
|
||||
Widget state in the v2 extension API is organized into distinct categories, each with different characteristics for mutability, persistence, and event handling.
|
||||
|
||||
## Categories
|
||||
|
||||
### 1. Identity (Read-Only Invariants)
|
||||
|
||||
Set at construction, never change.
|
||||
|
||||
| Property | Type | Notes |
|
||||
| ------------ | ---------------- | ------------------------------------ |
|
||||
| `entityId` | `WidgetEntityId` | Branded, stable for widget lifetime |
|
||||
| `name` | `string` | Widget name as registered |
|
||||
| `widgetType` | `string` | e.g., `'INT'`, `'STRING'`, `'COMBO'` |
|
||||
| `label` | `string` | Display label, defaults to `name` |
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- No setters exist for these properties
|
||||
- Extensions cannot modify identity after creation
|
||||
- Attempting to change identity is a design error
|
||||
|
||||
### 2. Value (First-Class, Every Widget)
|
||||
|
||||
The primary user-edited data.
|
||||
|
||||
| Method | Notes |
|
||||
| ------------------- | ----------------------------------- |
|
||||
| `getValue()` | Returns current value |
|
||||
| `setValue(v)` | Dispatches `SetWidgetValue` command |
|
||||
| `on('valueChange')` | Fires on value mutation |
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Type varies by widget type (`number` for INT, `string` for STRING, etc.)
|
||||
- Persisted to `widgets_values` in workflow JSON
|
||||
- Included in API prompt by default (unless `setSerializeEnabled(false)`)
|
||||
- Changes are undo-able via command dispatch
|
||||
|
||||
### 3. Properties (First-Class, Every Widget)
|
||||
|
||||
Common properties all widgets share.
|
||||
|
||||
| Property | Getter | Setter | Event |
|
||||
| ----------- | ---------------------- | ------------------------ | ---------------- |
|
||||
| `hidden` | `isHidden()` | `setHidden(b)` | `propertyChange` |
|
||||
| `disabled` | `isDisabled()` | `setDisabled(b)` | `propertyChange` |
|
||||
| `serialize` | `isSerializeEnabled()` | `setSerializeEnabled(b)` | `propertyChange` |
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Boolean values only
|
||||
- `hidden` affects UI visibility, not serialization
|
||||
- `disabled` makes widget read-only in UI
|
||||
- `serialize` controls inclusion in workflow/prompt output
|
||||
- Changes fire `propertyChange`, not `valueChange`
|
||||
|
||||
### 4. Options Bag (Type-Specific)
|
||||
|
||||
Per-instance overrides for type-specific configuration.
|
||||
|
||||
| Method | Notes |
|
||||
| ----------------------- | ---------------------------------------------- |
|
||||
| `getOption(key)` | Returns per-instance override or class default |
|
||||
| `setOption(key, value)` | Persists to `widget_options` sidecar |
|
||||
| `on('optionChange')` | Fires on option mutation |
|
||||
|
||||
**Common options by widget type:**
|
||||
|
||||
| Widget Type | Options |
|
||||
| ----------- | ---------------------------------- |
|
||||
| INT, FLOAT | `min`, `max`, `step`, `precision` |
|
||||
| STRING | `multiline`, `placeholder`, `rows` |
|
||||
| COMBO | `values` |
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Options are JSON-serializable values
|
||||
- Persisted separately from `widgets_values` (additive, backward-compatible)
|
||||
- Extensions can add custom options
|
||||
- Option keys should be documented per widget type
|
||||
|
||||
### 5. DOM-Specific
|
||||
|
||||
Properties unique to DOM widgets.
|
||||
|
||||
| Method | Notes |
|
||||
| --------------- | ------------------------------------------ |
|
||||
| `setHeight(px)` | Updates reserved height, triggers relayout |
|
||||
|
||||
**Constraints:**
|
||||
|
||||
- Only meaningful for `addDOMWidget()` widgets
|
||||
- No-op for non-DOM widgets
|
||||
- Measured in pixels (screen space)
|
||||
- No event fired; relayout is automatic
|
||||
|
||||
## Category Interaction Rules
|
||||
|
||||
### Event Separation
|
||||
|
||||
Each category has its own event:
|
||||
|
||||
| Category | Event |
|
||||
| ---------- | ---------------- |
|
||||
| Value | `valueChange` |
|
||||
| Properties | `propertyChange` |
|
||||
| Options | `optionChange` |
|
||||
|
||||
**Rule**: Events do not cross categories. Changing `hidden` does not fire `valueChange`.
|
||||
|
||||
### Serialization Behavior
|
||||
|
||||
| Category | Serialization |
|
||||
| ---------- | ---------------------------------------------------------------- |
|
||||
| Identity | Not serialized (derived from node type) |
|
||||
| Value | `widgets_values` array |
|
||||
| Properties | `hidden`/`disabled` not persisted; `serialize` affects inclusion |
|
||||
| Options | `widget_options` sidecar object |
|
||||
|
||||
### Mutability Summary
|
||||
|
||||
| Category | Mutable | Undo-able | Fires Event |
|
||||
| ---------- | ------- | --------- | ---------------- |
|
||||
| Identity | ✗ | — | — |
|
||||
| Value | ✓ | ✓ | `valueChange` |
|
||||
| Properties | ✓ | ✓ | `propertyChange` |
|
||||
| Options | ✓ | ✓ | `optionChange` |
|
||||
| DOM Height | ✓ | ✗ | — |
|
||||
|
||||
## Agent Implementation Notes
|
||||
|
||||
Agents working with widget state should:
|
||||
|
||||
1. **Respect category boundaries**: Don't try to `setValue()` to change visibility; use `setHidden()`.
|
||||
|
||||
2. **Use appropriate events**: Listen to `propertyChange` for UI state, `valueChange` for data.
|
||||
|
||||
3. **Handle type-specific options carefully**: Check widget type before accessing type-specific options.
|
||||
|
||||
4. **Preserve identity invariants**: Never try to change `entityId`, `name`, `widgetType`, or `label`.
|
||||
|
||||
5. **Consider serialization context**: Options persist to a sidecar; values persist to the main array.
|
||||
@@ -9,6 +9,10 @@ const config: KnipConfig = {
|
||||
'src/assets/css/style.css',
|
||||
'src/scripts/ui/menu/index.ts',
|
||||
'src/types/index.ts',
|
||||
// Public extension API surface — published package entry point.
|
||||
// Per AGENTS.md, this barrel is the explicit exception to the
|
||||
// no-barrel-files-in-src rule because it IS the package entry.
|
||||
'src/extension-api/index.ts',
|
||||
'src/storybook/mocks/**/*.ts'
|
||||
],
|
||||
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**']
|
||||
@@ -60,7 +64,19 @@ const config: KnipConfig = {
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js',
|
||||
// Devtools extensions, included dynamically
|
||||
'tools/devtools/web/**'
|
||||
'tools/devtools/web/**',
|
||||
// Deprecated stub re-exporting from `@/extension-api`. Will be removed
|
||||
// once PKG2 (`@comfyorg/extension-api`) ships and downstream imports
|
||||
// migrate to the package path.
|
||||
'src/types/extensionV2.ts',
|
||||
// D18 Phase 1 scaffolding — empty registries the loader will populate
|
||||
// in Phase 2 once side-effect registration moves out of
|
||||
// extension-api-service. See decisions/D18-pure-functions-loader-registration.md.
|
||||
'src/services/registries/**',
|
||||
// D18 Phase 1 — brand symbol + isBrandedExtension guard. Currently
|
||||
// consumed only by the define* call sites inside extension-api-service;
|
||||
// the type-guard and getBrandKind are exported for the Phase 2 loader.
|
||||
'src/extension-api/brand.ts'
|
||||
],
|
||||
vite: {
|
||||
config: ['vite?(.*).config.mts']
|
||||
@@ -79,7 +95,15 @@ const config: KnipConfig = {
|
||||
tags: [
|
||||
'-knipIgnoreUnusedButUsedByCustomNodes',
|
||||
'-knipIgnoreUnusedButUsedByVueNodesBranch',
|
||||
'-knipIgnoreUsedByStackedPR'
|
||||
'-knipIgnoreUsedByStackedPR',
|
||||
// Public API surface consumed externally by extension authors and the
|
||||
// TypeDoc docgen pipeline (PKG2). Mark exports with @publicAPI when they
|
||||
// are part of `@comfyorg/extension-api` but not internally referenced.
|
||||
'-publicAPI',
|
||||
// Per D20, the three *EntityId brand re-exports in src/extension-api/{node,widget}.ts
|
||||
// are demoted to @internal — they stay available for internal package modules
|
||||
// but are removed from the public barrel and from TypeDoc output.
|
||||
'-internal'
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.45.5",
|
||||
"version": "1.45.4",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -48,6 +48,7 @@
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:unit": "nx run test",
|
||||
"test:extension-api": "[ -f vitest.extension-api.config.mts ] && vitest run --config vitest.extension-api.config.mts || echo 'SKIP: vitest.extension-api.config.mts not found'",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
|
||||
"typecheck:desktop": "nx run @comfyorg/desktop-ui:typecheck",
|
||||
@@ -60,7 +61,6 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/object-info-parser": "workspace:*",
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
|
||||
|
||||
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
|
||||
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,create-video,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
|
||||
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
|
||||
|
||||
@custom-variant touch (@media (hover: none));
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 21H5C3.89543 21 3 20.1046 3 19V5C3 3.89543 3.89543 3 5 3H19C20.1046 3 21 3.89543 21 5V15M9 8L15 12L9 16V8ZM18.141 18.816l-.784 1.83a.334.334 0 01-.614 0l-.785-1.83a.333.333 0 00-.175-.176l-1.831-.784a.334.334 0 010-.614l1.831-.785a.333.333 0 00.175-.175l.785-1.831a.334.334 0 01.614 0l.784 1.831a.334.334 0 00.176.175l1.83.785c.27.116.27.498 0 .614l-1.83.784a.334.334 0 00-.176.176Z" stroke="#8A8A8A" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 585 B |
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "@comfyorg/object-info-parser",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared object_info schemas and helpers",
|
||||
"license": "GPL-3.0-only",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run --config ./vitest.config.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
"zod-validation-error": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:shared",
|
||||
"type:util"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { groupNodesByPack } from '../helpers/groupNodesByPack'
|
||||
import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
|
||||
|
||||
function makeNodeDef(
|
||||
name: string,
|
||||
pythonModule: string,
|
||||
displayName = name
|
||||
): ComfyNodeDef {
|
||||
return {
|
||||
name,
|
||||
display_name: displayName,
|
||||
description: '',
|
||||
category: 'test',
|
||||
output_node: false,
|
||||
python_module: pythonModule
|
||||
}
|
||||
}
|
||||
|
||||
describe('groupNodesByPack', () => {
|
||||
it('excludes core nodes and groups custom nodes by pack id', () => {
|
||||
const grouped = groupNodesByPack({
|
||||
CoreNode: makeNodeDef('CoreNode', 'nodes'),
|
||||
ImpactA: makeNodeDef(
|
||||
'ImpactA',
|
||||
'custom_nodes.comfyui-impact-pack.nodes',
|
||||
'Impact A'
|
||||
),
|
||||
ImpactB: makeNodeDef(
|
||||
'ImpactB',
|
||||
'custom_nodes.comfyui-impact-pack.nodes',
|
||||
'Impact B'
|
||||
),
|
||||
AuxNode: makeNodeDef(
|
||||
'AuxNode',
|
||||
'custom_nodes.comfyui-controlnet-aux.nodes',
|
||||
'Aux Node'
|
||||
)
|
||||
})
|
||||
|
||||
expect(grouped).toHaveLength(2)
|
||||
expect(grouped.map((pack) => pack.id)).toEqual([
|
||||
'comfyui-controlnet-aux',
|
||||
'comfyui-impact-pack'
|
||||
])
|
||||
expect(
|
||||
grouped.find((pack) => pack.id === 'comfyui-impact-pack')?.nodes
|
||||
).toHaveLength(2)
|
||||
expect(
|
||||
grouped.find((pack) => pack.id === 'comfyui-controlnet-aux')?.nodes
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,83 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { sanitizeUserContent } from '../helpers/sanitizeUserContent'
|
||||
import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
|
||||
|
||||
function makeNodeDef(
|
||||
name: string,
|
||||
pythonModule: string,
|
||||
input: ComfyNodeDef['input']
|
||||
): ComfyNodeDef {
|
||||
return {
|
||||
name,
|
||||
display_name: name,
|
||||
description: '',
|
||||
category: 'test',
|
||||
output_node: false,
|
||||
python_module: pythonModule,
|
||||
input
|
||||
}
|
||||
}
|
||||
|
||||
describe('sanitizeUserContent', () => {
|
||||
it('strips known user filenames from combo inputs across all nodes', () => {
|
||||
const defs = {
|
||||
CustomCombo: makeNodeDef('CustomCombo', 'custom_nodes.some-pack', {
|
||||
required: {
|
||||
choice: [['my-secret.png', 'safe-option', 'video.mp4', 42], {}],
|
||||
choiceV2: ['COMBO', { options: ['a.jpg', 'keep-me', 'b'] }]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const sanitized = sanitizeUserContent(defs)
|
||||
const required = sanitized.CustomCombo.input?.required
|
||||
expect(required?.choice).toEqual([['safe-option', 42], {}])
|
||||
expect(required?.choiceV2).toEqual(['COMBO', { options: ['keep-me', 'b'] }])
|
||||
})
|
||||
|
||||
it('zeros combo lists for known upload nodes in required/optional/hidden sections', () => {
|
||||
const defs = {
|
||||
LoadImage: makeNodeDef('LoadImage', 'nodes', {
|
||||
required: {
|
||||
image: [['personal.png', 'public.png'], { image_upload: true }]
|
||||
},
|
||||
optional: {
|
||||
mask: ['COMBO', { options: ['another.jpg', 'value'] }]
|
||||
},
|
||||
hidden: {
|
||||
cache: [['movie.mov', 'keep'], {}],
|
||||
hiddenV2: ['COMBO', { options: ['private.wav', 'other'] }]
|
||||
}
|
||||
}),
|
||||
LoadVideo: makeNodeDef('LoadVideo', 'nodes', {
|
||||
required: {
|
||||
video: [['clip.mp4', 'clip2.webm'], {}]
|
||||
}
|
||||
}),
|
||||
LoadAudio: makeNodeDef('LoadAudio', 'nodes', {
|
||||
required: {
|
||||
audio: [['song.mp3', 'song.flac'], {}]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const sanitized = sanitizeUserContent(defs)
|
||||
|
||||
expect(sanitized.LoadImage.input?.required?.image).toEqual([
|
||||
[],
|
||||
{ image_upload: true }
|
||||
])
|
||||
expect(sanitized.LoadImage.input?.optional?.mask).toEqual([
|
||||
'COMBO',
|
||||
{ options: [] }
|
||||
])
|
||||
expect(sanitized.LoadImage.input?.hidden?.cache).toEqual([[], {}])
|
||||
expect(sanitized.LoadImage.input?.hidden?.hiddenV2).toEqual([
|
||||
'COMBO',
|
||||
{ options: [] }
|
||||
])
|
||||
expect(sanitized.LoadVideo.input?.required?.video).toEqual([[], {}])
|
||||
expect(sanitized.LoadAudio.input?.required?.audio).toEqual([[], {}])
|
||||
})
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
export const BLUEPRINT_CATEGORY = 'Subgraph Blueprints'
|
||||
|
||||
export enum NodeSourceType {
|
||||
Core = 'core',
|
||||
CustomNodes = 'custom_nodes',
|
||||
Blueprint = 'blueprint',
|
||||
Essentials = 'essentials',
|
||||
Unknown = 'unknown'
|
||||
}
|
||||
export const CORE_NODE_MODULES = ['nodes', 'comfy_extras', 'comfy_api_nodes']
|
||||
|
||||
export type NodeSource = {
|
||||
type: NodeSourceType
|
||||
className: string
|
||||
displayText: string
|
||||
badgeText: string
|
||||
}
|
||||
|
||||
const UNKNOWN_NODE_SOURCE: NodeSource = {
|
||||
type: NodeSourceType.Unknown,
|
||||
className: 'comfy-unknown',
|
||||
displayText: 'Unknown',
|
||||
badgeText: '?'
|
||||
}
|
||||
|
||||
function shortenNodeName(name: string) {
|
||||
return name
|
||||
.replace(/^(ComfyUI-|ComfyUI_|Comfy-|Comfy_)/, '')
|
||||
.replace(/(-ComfyUI|_ComfyUI|-Comfy|_Comfy)$/, '')
|
||||
}
|
||||
|
||||
export function getNodeSource(
|
||||
python_module?: string,
|
||||
essentials_category?: string
|
||||
): NodeSource {
|
||||
if (!python_module) {
|
||||
return UNKNOWN_NODE_SOURCE
|
||||
}
|
||||
const modules = python_module.split('.')
|
||||
if (essentials_category) {
|
||||
const moduleName = modules[1] ?? modules[0] ?? 'essentials'
|
||||
const displayName = shortenNodeName(moduleName.split('@')[0])
|
||||
return {
|
||||
type: NodeSourceType.Essentials,
|
||||
className: 'comfy-essentials',
|
||||
displayText: displayName,
|
||||
badgeText: displayName
|
||||
}
|
||||
} else if (CORE_NODE_MODULES.includes(modules[0])) {
|
||||
return {
|
||||
type: NodeSourceType.Core,
|
||||
className: 'comfy-core',
|
||||
displayText: 'Comfy Core',
|
||||
badgeText: '🦊'
|
||||
}
|
||||
} else if (modules[0] === 'blueprint') {
|
||||
return {
|
||||
type: NodeSourceType.Blueprint,
|
||||
className: 'blueprint',
|
||||
displayText: 'Blueprint',
|
||||
badgeText: 'bp'
|
||||
}
|
||||
} else if (modules[0] === 'custom_nodes') {
|
||||
const moduleName = modules[1]
|
||||
if (!moduleName) {
|
||||
return UNKNOWN_NODE_SOURCE
|
||||
}
|
||||
const customNodeName = moduleName.split('@')[0]
|
||||
const displayName = shortenNodeName(customNodeName)
|
||||
return {
|
||||
type: NodeSourceType.CustomNodes,
|
||||
className: 'comfy-custom-nodes',
|
||||
displayText: displayName,
|
||||
badgeText: displayName
|
||||
}
|
||||
} else {
|
||||
return UNKNOWN_NODE_SOURCE
|
||||
}
|
||||
}
|
||||
|
||||
interface NodeDefLike {
|
||||
nodeSource: NodeSource
|
||||
}
|
||||
|
||||
export function isEssentialNode(node: NodeDefLike): boolean {
|
||||
return node.nodeSource.type === NodeSourceType.Essentials
|
||||
}
|
||||
|
||||
export function isCustomNode(node: NodeDefLike): boolean {
|
||||
return node.nodeSource.type === NodeSourceType.CustomNodes
|
||||
}
|
||||
|
||||
export enum NodeBadgeMode {
|
||||
None = 'None',
|
||||
ShowAll = 'Show all',
|
||||
HideBuiltIn = 'Hide built-in'
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { getNodeSource, NodeSourceType } from '../classifiers/nodeSource'
|
||||
import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
|
||||
|
||||
export interface PackedNode {
|
||||
className: string
|
||||
def: ComfyNodeDef
|
||||
}
|
||||
|
||||
export interface NodePack {
|
||||
id: string
|
||||
displayName: string
|
||||
nodes: PackedNode[]
|
||||
}
|
||||
|
||||
export function groupNodesByPack(
|
||||
defs: Record<string, ComfyNodeDef>
|
||||
): NodePack[] {
|
||||
const byPackId = new Map<string, NodePack>()
|
||||
|
||||
for (const [className, def] of Object.entries(defs)) {
|
||||
const source = getNodeSource(def.python_module, def.essentials_category)
|
||||
if (source.type !== NodeSourceType.CustomNodes) {
|
||||
continue
|
||||
}
|
||||
|
||||
const packId = def.python_module.split('.')[1]?.split('@')[0]
|
||||
if (!packId) {
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = byPackId.get(packId)
|
||||
const node = { className, def }
|
||||
|
||||
if (existing) {
|
||||
existing.nodes.push(node)
|
||||
continue
|
||||
}
|
||||
|
||||
byPackId.set(packId, {
|
||||
id: packId,
|
||||
displayName: source.displayText,
|
||||
nodes: [node]
|
||||
})
|
||||
}
|
||||
|
||||
return [...byPackId.values()].sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import type {
|
||||
ComfyNodeDef,
|
||||
ComfyInputsSpec,
|
||||
InputSpec,
|
||||
ComboInputSpec,
|
||||
ComboInputSpecV2
|
||||
} from '../schemas/nodeDefSchema'
|
||||
import {
|
||||
isComboInputSpecV1,
|
||||
isComboInputSpecV2
|
||||
} from '../schemas/nodeDefSchema'
|
||||
|
||||
const USER_CONTENT_REGEX =
|
||||
/\.(png|jpe?g|webp|gif|mp4|mov|webm|wav|mp3|flac|ogg|safetensors|ckpt|pt)$/i
|
||||
|
||||
const KNOWN_USER_UPLOAD_NODES = new Set([
|
||||
'LoadImage',
|
||||
'LoadImageMask',
|
||||
'LoadImageOutput',
|
||||
'LoadVideo',
|
||||
'LoadAudio'
|
||||
])
|
||||
|
||||
export function sanitizeUserContent(
|
||||
defs: Record<string, ComfyNodeDef>
|
||||
): Record<string, ComfyNodeDef> {
|
||||
const nextEntries = Object.entries(defs).map(([className, def]) => [
|
||||
className,
|
||||
sanitizeNode(def)
|
||||
])
|
||||
return Object.fromEntries(nextEntries) as Record<string, ComfyNodeDef>
|
||||
}
|
||||
|
||||
function sanitizeNode(def: ComfyNodeDef): ComfyNodeDef {
|
||||
if (!def.input) return def
|
||||
|
||||
const shouldClearAllComboOptions =
|
||||
def.python_module === 'nodes' && KNOWN_USER_UPLOAD_NODES.has(def.name)
|
||||
|
||||
return {
|
||||
...def,
|
||||
input: {
|
||||
...def.input,
|
||||
required: sanitizeInputSpecSection(
|
||||
def.input.required,
|
||||
shouldClearAllComboOptions
|
||||
),
|
||||
optional: sanitizeInputSpecSection(
|
||||
def.input.optional,
|
||||
shouldClearAllComboOptions
|
||||
),
|
||||
hidden: sanitizeHiddenSection(
|
||||
def.input.hidden,
|
||||
shouldClearAllComboOptions
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeInputSpecSection(
|
||||
section: ComfyInputsSpec['required'] | ComfyInputsSpec['optional'],
|
||||
forceEmpty: boolean
|
||||
): ComfyInputsSpec['required'] | ComfyInputsSpec['optional'] {
|
||||
if (!section) return section
|
||||
|
||||
const nextEntries = Object.entries(section).map(([key, value]) => {
|
||||
const sanitized = sanitizeInputSpec(value, forceEmpty) as InputSpec
|
||||
return [key, sanitized] as const
|
||||
})
|
||||
|
||||
return Object.fromEntries(nextEntries)
|
||||
}
|
||||
|
||||
function sanitizeHiddenSection(
|
||||
section: ComfyInputsSpec['hidden'],
|
||||
forceEmpty: boolean
|
||||
): ComfyInputsSpec['hidden'] {
|
||||
if (!section) return section
|
||||
|
||||
const nextEntries = Object.entries(section).map(([key, value]) => [
|
||||
key,
|
||||
sanitizeInputSpec(value, forceEmpty)
|
||||
])
|
||||
|
||||
return Object.fromEntries(nextEntries)
|
||||
}
|
||||
|
||||
function sanitizeInputSpec(inputSpec: unknown, forceEmpty: boolean): unknown {
|
||||
if (!Array.isArray(inputSpec)) {
|
||||
return inputSpec
|
||||
}
|
||||
|
||||
if (isComboInputSpecV1(inputSpec as InputSpec)) {
|
||||
return sanitizeComboInputSpecV1(inputSpec as ComboInputSpec, forceEmpty)
|
||||
}
|
||||
|
||||
if (isComboInputSpecV2(inputSpec as InputSpec)) {
|
||||
return sanitizeComboInputSpecV2(inputSpec as ComboInputSpecV2, forceEmpty)
|
||||
}
|
||||
|
||||
return inputSpec
|
||||
}
|
||||
|
||||
function sanitizeComboInputSpecV1(
|
||||
inputSpec: ComboInputSpec,
|
||||
forceEmpty: boolean
|
||||
): ComboInputSpec {
|
||||
const [comboValues, options] = inputSpec
|
||||
const sanitizedValues = forceEmpty ? [] : filterComboValues(comboValues)
|
||||
return [sanitizedValues, options]
|
||||
}
|
||||
|
||||
function sanitizeComboInputSpecV2(
|
||||
inputSpec: ComboInputSpecV2,
|
||||
forceEmpty: boolean
|
||||
): ComboInputSpecV2 {
|
||||
const [comboTag, options] = inputSpec
|
||||
if (!options?.options) {
|
||||
return inputSpec
|
||||
}
|
||||
|
||||
const nextOptions = {
|
||||
...options,
|
||||
options: forceEmpty ? [] : filterComboValues(options.options)
|
||||
}
|
||||
|
||||
return [comboTag, nextOptions]
|
||||
}
|
||||
|
||||
function filterComboValues(values: (number | string)[]): (number | string)[] {
|
||||
return values.filter((value) =>
|
||||
typeof value === 'string' ? !USER_CONTENT_REGEX.test(value) : true
|
||||
)
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './schemas/nodeDefSchema'
|
||||
export * from './classifiers/nodeSource'
|
||||
export * from './helpers/groupNodesByPack'
|
||||
export * from './helpers/sanitizeUserContent'
|
||||
@@ -1,384 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
const CONTROL_OPTIONS = [
|
||||
'fixed',
|
||||
'increment',
|
||||
'decrement',
|
||||
'randomize'
|
||||
] as const
|
||||
const RESULT_ITEM_TYPE = z.enum(['input', 'output', 'temp'])
|
||||
|
||||
const zComboOption = z.union([z.string(), z.number()])
|
||||
const zRemoteWidgetConfig = z.object({
|
||||
route: z.string().url().or(z.string().startsWith('/')),
|
||||
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
|
||||
response_key: z.string().optional(),
|
||||
query_params: z.record(z.string(), z.string()).optional(),
|
||||
refresh_button: z.boolean().optional(),
|
||||
control_after_refresh: z.enum(['first', 'last']).optional(),
|
||||
timeout: z.number().gte(0).optional(),
|
||||
max_retries: z.number().gte(0).optional()
|
||||
})
|
||||
const zMultiSelectOption = z.object({
|
||||
placeholder: z.string().optional(),
|
||||
chip: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zBaseInputOptions = z
|
||||
.object({
|
||||
default: z.any().optional(),
|
||||
defaultInput: z.boolean().optional(),
|
||||
display_name: z.string().optional(),
|
||||
forceInput: z.boolean().optional(),
|
||||
tooltip: z.string().optional(),
|
||||
socketless: z.boolean().optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
advanced: z.boolean().optional(),
|
||||
widgetType: z.string().optional(),
|
||||
/** Backend-only properties. */
|
||||
rawLink: z.boolean().optional(),
|
||||
lazy: z.boolean().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
const zNumericInputOptions = zBaseInputOptions.extend({
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
step: z.number().optional(),
|
||||
/** Note: Many node authors are using INT/FLOAT to pass list of INT/FLOAT. */
|
||||
default: z.union([z.number(), z.array(z.number())]).optional(),
|
||||
display: z.enum(['slider', 'number', 'knob', 'gradientslider']).optional()
|
||||
})
|
||||
|
||||
export const zIntInputOptions = zNumericInputOptions.extend({
|
||||
/**
|
||||
* If true, a linked widget will be added to the node to select the mode
|
||||
* of `control_after_generate`.
|
||||
*/
|
||||
control_after_generate: z
|
||||
.union([z.boolean(), z.enum(CONTROL_OPTIONS)])
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const zColorStop = z.object({
|
||||
offset: z.number(),
|
||||
color: z.tuple([z.number(), z.number(), z.number()])
|
||||
})
|
||||
|
||||
export const zFloatInputOptions = zNumericInputOptions.extend({
|
||||
round: z.union([z.number(), z.literal(false)]).optional(),
|
||||
gradient_stops: z.array(zColorStop).optional()
|
||||
})
|
||||
|
||||
export const zBooleanInputOptions = zBaseInputOptions.extend({
|
||||
label_on: z.string().optional(),
|
||||
label_off: z.string().optional(),
|
||||
default: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zStringInputOptions = zBaseInputOptions.extend({
|
||||
default: z.string().optional(),
|
||||
multiline: z.boolean().optional(),
|
||||
dynamicPrompts: z.boolean().optional(),
|
||||
|
||||
// Multiline-only fields
|
||||
defaultVal: z.string().optional(),
|
||||
placeholder: z.string().optional()
|
||||
})
|
||||
|
||||
export const zComboInputOptions = zBaseInputOptions.extend({
|
||||
control_after_generate: z
|
||||
.union([z.boolean(), z.enum(CONTROL_OPTIONS)])
|
||||
.optional(),
|
||||
image_upload: z.boolean().optional(),
|
||||
image_folder: RESULT_ITEM_TYPE.optional(),
|
||||
allow_batch: z.boolean().optional(),
|
||||
video_upload: z.boolean().optional(),
|
||||
audio_upload: z.boolean().optional(),
|
||||
mesh_upload: z.boolean().optional(),
|
||||
upload_subfolder: z.string().optional(),
|
||||
animated_image_upload: z.boolean().optional(),
|
||||
options: z.array(zComboOption).optional(),
|
||||
remote: zRemoteWidgetConfig.optional(),
|
||||
/** Whether the widget is a multi-select widget. */
|
||||
multi_select: zMultiSelectOption.optional()
|
||||
})
|
||||
|
||||
const zIntInputSpec = z.tuple([z.literal('INT'), zIntInputOptions.optional()])
|
||||
const zFloatInputSpec = z.tuple([
|
||||
z.literal('FLOAT'),
|
||||
zFloatInputOptions.optional()
|
||||
])
|
||||
const zBooleanInputSpec = z.tuple([
|
||||
z.literal('BOOLEAN'),
|
||||
zBooleanInputOptions.optional()
|
||||
])
|
||||
const zStringInputSpec = z.tuple([
|
||||
z.literal('STRING'),
|
||||
zStringInputOptions.optional()
|
||||
])
|
||||
/**
|
||||
* Legacy combo syntax.
|
||||
* @deprecated Use `zComboInputSpecV2` instead.
|
||||
*/
|
||||
const zComboInputSpec = z.tuple([
|
||||
z.array(zComboOption),
|
||||
zComboInputOptions.optional()
|
||||
])
|
||||
const zComboInputSpecV2 = z.tuple([
|
||||
z.literal('COMBO'),
|
||||
zComboInputOptions.optional()
|
||||
])
|
||||
|
||||
export function isComboInputSpecV1(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is ComboInputSpec {
|
||||
return Array.isArray(inputSpec[0])
|
||||
}
|
||||
|
||||
export function isIntInputSpec(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is IntInputSpec {
|
||||
return inputSpec[0] === 'INT'
|
||||
}
|
||||
|
||||
export function isFloatInputSpec(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is FloatInputSpec {
|
||||
return inputSpec[0] === 'FLOAT'
|
||||
}
|
||||
|
||||
export function isComboInputSpecV2(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is ComboInputSpecV2 {
|
||||
return inputSpec[0] === 'COMBO'
|
||||
}
|
||||
|
||||
export function isComboInputSpec(
|
||||
inputSpec: InputSpec
|
||||
): inputSpec is ComboInputSpec | ComboInputSpecV2 {
|
||||
return isComboInputSpecV1(inputSpec) || isComboInputSpecV2(inputSpec)
|
||||
}
|
||||
|
||||
export function isMediaUploadComboInput(inputSpec: InputSpec): boolean {
|
||||
const [inputName, inputOptions] = inputSpec
|
||||
if (!inputOptions) return false
|
||||
|
||||
const isUploadInput =
|
||||
inputOptions['image_upload'] === true ||
|
||||
inputOptions['video_upload'] === true ||
|
||||
inputOptions['animated_image_upload'] === true
|
||||
|
||||
return (
|
||||
isUploadInput && (isComboInputSpecV1(inputSpec) || inputName === 'COMBO')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of an input spec.
|
||||
*
|
||||
* @param inputSpec - The input spec to get the type of.
|
||||
* @returns The type of the input spec.
|
||||
*/
|
||||
export function getInputSpecType(inputSpec: InputSpec): string {
|
||||
return isComboInputSpec(inputSpec) ? 'COMBO' : inputSpec[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the combo options from a combo input spec.
|
||||
*
|
||||
* @param inputSpec - The input spec to get the combo options from.
|
||||
* @returns The combo options.
|
||||
*/
|
||||
export function getComboSpecComboOptions(
|
||||
inputSpec: ComboInputSpec | ComboInputSpecV2
|
||||
): (number | string)[] {
|
||||
return (
|
||||
(isComboInputSpecV2(inputSpec) ? inputSpec[1]?.options : inputSpec[0]) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
const excludedLiterals = new Set(['INT', 'FLOAT', 'BOOLEAN', 'STRING', 'COMBO'])
|
||||
const zCustomInputSpec = z.tuple([
|
||||
z.string().refine((value) => !excludedLiterals.has(value)),
|
||||
zBaseInputOptions.optional()
|
||||
])
|
||||
|
||||
const zInputSpec = z.union([
|
||||
zIntInputSpec,
|
||||
zFloatInputSpec,
|
||||
zBooleanInputSpec,
|
||||
zStringInputSpec,
|
||||
zComboInputSpec,
|
||||
zComboInputSpecV2,
|
||||
zCustomInputSpec
|
||||
])
|
||||
|
||||
export const zComfyInputsSpec = z.object({
|
||||
required: z.record(zInputSpec).optional(),
|
||||
optional: z.record(zInputSpec).optional(),
|
||||
// Frontend repo is not using it, but some custom nodes are using the
|
||||
// hidden field to pass various values.
|
||||
hidden: z.record(z.any()).optional()
|
||||
})
|
||||
|
||||
const zComfyNodeDataType = z.string()
|
||||
const zComfyComboOutput = z.array(zComboOption)
|
||||
export const zComfyOutputTypesSpec = z.array(
|
||||
z.union([zComfyNodeDataType, zComfyComboOutput])
|
||||
)
|
||||
|
||||
/**
|
||||
* Widget dependency with type information.
|
||||
* Provides strong type enforcement for JSONata evaluation context.
|
||||
*/
|
||||
const zWidgetDependency = z.object({
|
||||
name: z.string(),
|
||||
type: z.string()
|
||||
})
|
||||
|
||||
export type WidgetDependency = z.infer<typeof zWidgetDependency>
|
||||
|
||||
/**
|
||||
* Schema for price badge depends_on field.
|
||||
* Specifies which widgets and inputs the pricing expression depends on.
|
||||
* Widgets must be specified as objects with name and type.
|
||||
*/
|
||||
const zPriceBadgeDepends = z.object({
|
||||
widgets: z.array(zWidgetDependency).optional().default([]),
|
||||
inputs: z.array(z.string()).optional().default([]),
|
||||
/**
|
||||
* Autogrow input group names to track.
|
||||
* For each group, the count of connected inputs will be available in the
|
||||
* JSONata context as `g.<groupName>`.
|
||||
* Example: `input_groups: ["reference_videos"]` makes `g.reference_videos`
|
||||
* available with the count of connected inputs like `reference_videos.character1`, etc.
|
||||
*/
|
||||
input_groups: z.array(z.string()).optional().default([])
|
||||
})
|
||||
|
||||
/**
|
||||
* Schema for price badge definition.
|
||||
* Used to calculate and display pricing information for API nodes.
|
||||
* The `expr` field contains a JSONata expression that returns a PricingResult.
|
||||
*/
|
||||
const zPriceBadge = z.object({
|
||||
engine: z.literal('jsonata').optional().default('jsonata'),
|
||||
depends_on: zPriceBadgeDepends
|
||||
.optional()
|
||||
.default({ widgets: [], inputs: [], input_groups: [] }),
|
||||
expr: z.string()
|
||||
})
|
||||
|
||||
export type PriceBadge = z.infer<typeof zPriceBadge>
|
||||
|
||||
export const zComfyNodeDef = z.object({
|
||||
input: zComfyInputsSpec.optional(),
|
||||
output: zComfyOutputTypesSpec.optional(),
|
||||
output_is_list: z.array(z.boolean()).optional(),
|
||||
output_name: z.array(z.string()).optional(),
|
||||
output_tooltips: z.array(z.string()).optional(),
|
||||
output_matchtypes: z.array(z.string().optional()).optional(),
|
||||
name: z.string(),
|
||||
display_name: z.string(),
|
||||
description: z.string(),
|
||||
help: z.string().optional(),
|
||||
category: z.string(),
|
||||
main_category: z.string().optional(),
|
||||
output_node: z.boolean(),
|
||||
python_module: z.string(),
|
||||
deprecated: z.boolean().optional(),
|
||||
experimental: z.boolean().optional(),
|
||||
dev_only: z.boolean().optional(),
|
||||
/**
|
||||
* Whether the node is an API node. Running API nodes requires login to
|
||||
* Comfy Org account.
|
||||
* https://docs.comfy.org/tutorials/api-nodes/overview
|
||||
*/
|
||||
api_node: z.boolean().optional(),
|
||||
/**
|
||||
* Specifies the order of inputs for each input category.
|
||||
* Used to ensure consistent widget ordering regardless of JSON serialization.
|
||||
* Keys are 'required', 'optional', etc., values are arrays of input names.
|
||||
*/
|
||||
input_order: z.record(z.array(z.string())).optional(),
|
||||
/**
|
||||
* Alternative names for search. Useful for synonyms, abbreviations,
|
||||
* or old names after renaming a node.
|
||||
*/
|
||||
search_aliases: z.array(z.string()).optional(),
|
||||
/**
|
||||
* Price badge definition for API nodes.
|
||||
* Contains a JSONata expression to calculate pricing based on widget values
|
||||
* and input connectivity.
|
||||
*/
|
||||
price_badge: zPriceBadge.optional(),
|
||||
/** Category for the Essentials tab. If set, the node appears in Essentials. */
|
||||
essentials_category: z.string().optional(),
|
||||
/** Whether the blueprint is a global/installed blueprint (not user-created). */
|
||||
isGlobal: z.boolean().optional()
|
||||
})
|
||||
|
||||
export const zAutogrowOptions = z.object({
|
||||
...zBaseInputOptions.shape,
|
||||
template: z.object({
|
||||
input: zComfyInputsSpec,
|
||||
names: z.array(z.string()).optional(),
|
||||
max: z.number().optional(),
|
||||
//Backend defines as mandatory with min 1, Frontend is more forgiving
|
||||
min: z.number().optional(),
|
||||
prefix: z.string().optional()
|
||||
})
|
||||
})
|
||||
|
||||
export const zDynamicComboInputSpec = z.tuple([
|
||||
z.literal('COMFY_DYNAMICCOMBO_V3'),
|
||||
zBaseInputOptions.extend({
|
||||
options: z.array(
|
||||
z.object({
|
||||
inputs: zComfyInputsSpec,
|
||||
key: z.string()
|
||||
})
|
||||
)
|
||||
})
|
||||
])
|
||||
|
||||
export const zMatchTypeOptions = z.object({
|
||||
...zBaseInputOptions.shape,
|
||||
type: z.literal('COMFY_MATCHTYPE_V3'),
|
||||
template: z.object({
|
||||
allowed_types: z.string(),
|
||||
template_id: z.string()
|
||||
})
|
||||
})
|
||||
|
||||
// `/object_info`
|
||||
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
|
||||
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
|
||||
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
|
||||
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
|
||||
|
||||
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
|
||||
export type NumericInputOptions = z.infer<typeof zNumericInputOptions>
|
||||
|
||||
export type IntInputSpec = z.infer<typeof zIntInputSpec>
|
||||
export type FloatInputSpec = z.infer<typeof zFloatInputSpec>
|
||||
export type ComboInputSpec = z.infer<typeof zComboInputSpec>
|
||||
export type ComboInputSpecV2 = z.infer<typeof zComboInputSpecV2>
|
||||
export type InputSpec = z.infer<typeof zInputSpec>
|
||||
|
||||
export function validateComfyNodeDef(
|
||||
data: unknown,
|
||||
onError: (error: string) => void = console.warn
|
||||
): ComfyNodeDef | null {
|
||||
const result = zComfyNodeDef.safeParse(data)
|
||||
if (!result.success) {
|
||||
const zodError = fromZodError(result.error)
|
||||
onError(
|
||||
`Invalid ComfyNodeDef: ${JSON.stringify(data)}\n${zodError.message}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*", "vitest.config.ts"]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/__tests__/**/*.test.ts'],
|
||||
globals: false
|
||||
}
|
||||
})
|
||||
@@ -3,8 +3,6 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
formatLocalizedMediumDate,
|
||||
formatLocalizedNumber,
|
||||
getFilePathSeparatorVariants,
|
||||
getFilenameDetails,
|
||||
getMediaTypeFromFilename,
|
||||
@@ -430,34 +428,4 @@ describe('formatUtil', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatLocalizedNumber', () => {
|
||||
it('formats numbers using the given locale', () => {
|
||||
expect(formatLocalizedNumber(2618646, 'en')).toBe('2,618,646')
|
||||
expect(formatLocalizedNumber(2618646, 'de')).toBe('2.618.646')
|
||||
})
|
||||
|
||||
it('returns an em-dash for undefined / NaN / Infinity', () => {
|
||||
expect(formatLocalizedNumber(undefined, 'en')).toBe('—')
|
||||
expect(formatLocalizedNumber(Number.NaN, 'en')).toBe('—')
|
||||
expect(formatLocalizedNumber(Number.POSITIVE_INFINITY, 'en')).toBe('—')
|
||||
})
|
||||
|
||||
it('formats zero as "0"', () => {
|
||||
expect(formatLocalizedNumber(0, 'en')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatLocalizedMediumDate', () => {
|
||||
it('formats an ISO date with the medium style', () => {
|
||||
expect(formatLocalizedMediumDate('2026-04-19T00:00:00Z', 'en')).toMatch(
|
||||
/Apr \d{1,2}, 2026/
|
||||
)
|
||||
})
|
||||
|
||||
it('returns an em-dash for undefined or unparseable input', () => {
|
||||
expect(formatLocalizedMediumDate(undefined, 'en')).toBe('—')
|
||||
expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -675,32 +675,3 @@ export function formatTime(seconds: number): string {
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number with the given BCP-47 locale.
|
||||
* Returns an em-dash for non-numeric, NaN, or infinite inputs.
|
||||
*/
|
||||
export function formatLocalizedNumber(
|
||||
value: number | undefined,
|
||||
locale: string
|
||||
): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
|
||||
return new Intl.NumberFormat(locale).format(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO 8601 date string with the given BCP-47 locale using the
|
||||
* `medium` date style (e.g. "Apr 19, 2026"). Returns an em-dash for missing
|
||||
* or unparseable inputs.
|
||||
*/
|
||||
export function formatLocalizedMediumDate(
|
||||
value: string | undefined,
|
||||
locale: string
|
||||
): string {
|
||||
if (!value) return '—'
|
||||
const timestamp = Date.parse(value)
|
||||
if (Number.isNaN(timestamp)) return '—'
|
||||
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(
|
||||
timestamp
|
||||
)
|
||||
}
|
||||
|
||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@@ -437,9 +437,6 @@ importers:
|
||||
'@comfyorg/design-system':
|
||||
specifier: workspace:*
|
||||
version: link:packages/design-system
|
||||
'@comfyorg/object-info-parser':
|
||||
specifier: workspace:*
|
||||
version: link:packages/object-info-parser
|
||||
'@comfyorg/registry-types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/registry-types
|
||||
@@ -949,12 +946,6 @@ importers:
|
||||
'@comfyorg/design-system':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/design-system
|
||||
'@comfyorg/object-info-parser':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/object-info-parser
|
||||
'@comfyorg/shared-frontend-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared-frontend-utils
|
||||
'@comfyorg/tailwind-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/tailwind-utils
|
||||
@@ -1049,22 +1040,6 @@ importers:
|
||||
specifier: 0.93.0
|
||||
version: 0.93.0(magicast@0.5.1)(typescript@5.9.3)
|
||||
|
||||
packages/object-info-parser:
|
||||
dependencies:
|
||||
zod:
|
||||
specifier: 'catalog:'
|
||||
version: 3.25.76
|
||||
zod-validation-error:
|
||||
specifier: 'catalog:'
|
||||
version: 3.3.0(zod@3.25.76)
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
|
||||
|
||||
packages/registry-types: {}
|
||||
|
||||
packages/shared-frontend-utils:
|
||||
|
||||
915
research/touch-points/behavior-categories.yaml
Normal file
915
research/touch-points/behavior-categories.yaml
Normal file
@@ -0,0 +1,915 @@
|
||||
meta:
|
||||
schema_version: 1
|
||||
generated_from:
|
||||
- database.yaml
|
||||
- rollup.yaml
|
||||
- star-cache.yaml
|
||||
generated_by: scripts/build-behavior-categories.py (I-TF.1)
|
||||
source_pattern_count: 62
|
||||
category_count: 37
|
||||
usage_weight_formula: sum_over_members(blast_radius * occurrences)
|
||||
exemplar_ranking: repo_stars desc, then pattern blast_radius desc; distinct (repo, pattern_id)
|
||||
notes:
|
||||
- Categories cluster by intent, not by surface_family. S2 is split into creation / teardown / hydration / interaction /
|
||||
drawing / connection / serialization / properties.
|
||||
- S1 hooks are merged with their prototype-patching equivalents where intent matches (BC.20 node-type reg, BC.22 menus,
|
||||
BC.03 hydration).
|
||||
- S8.P1 isVirtualNode is a registration-time flag, so it lives in BC.20 alongside the node-type registration hooks.
|
||||
- S10.D3 setSize and S15.OS1 dynamic outputs join S10.D1 in BC.09 dynamic-slot-mutation since they all describe runtime
|
||||
topology mutation.
|
||||
- S14.ID1 NodeLocatorId joins S11.G2 graph enumeration in BC.29 because both are about cross-scope node identity/resolution.
|
||||
- S11.G1/G3/G4 (version, batching, setDirtyCanvas) collapse into BC.30 graph change-tracking — the v2 reactivity story replaces
|
||||
all three.
|
||||
- BC.21 (S1.H2 getCustomWidgets) has only 2 evidence rows in database.yaml; this is the 'small family — 2 + 1 minor variant'
|
||||
acceptance carve-out. The two exemplars are kept as-is, no synthetic third row.
|
||||
- BC.31 and BC.32 added 2026-05-08 from Notion API usage research (notion-api-research-evidence.yaml staging).
|
||||
S16 is a new surface family (DOM injection) not previously tracked. S16.VUE1 grouped with BC.32 (embedded runtimes).
|
||||
S3.C2 (ContextMenu replacement) added to BC.06 member list.
|
||||
- Notion source also upgrades occurrence signal on BC.01/BC.02/BC.04/BC.06/BC.07/BC.09/BC.26/BC.29/BC.30 — reflected
|
||||
in staging file; usage_weight values below are NOT yet updated (need re-run of rollup-blast-radius.py after merge).
|
||||
- BC.33 (cross-ext DOM widget obs), BC.34 (settings dialog), BC.35 (pre-queue validation) added 2026-05-08 from Notion COM-3668.
|
||||
- BC.36 (PrimeVue widget API surface) added 2026-05-08 from Notion Widget Component APIs page; was erroneously numbered BC.33 — corrected.
|
||||
- BC.37 (VueNode bridge timing) added 2026-05-08 from Notion Frontend Architecture page (3536d73d). Captures the
|
||||
nodeCreated→VueNode-not-yet-mounted hazard and the waitForLoad3d deferral pattern as a concrete test fixture.
|
||||
categories:
|
||||
- category_id: BC.01
|
||||
name: 'Node lifecycle: creation'
|
||||
intent: Hooks fired when a node is constructed or attached to the graph (per-instance setup).
|
||||
notes: >-
|
||||
nodeCreated fires BEFORE the VueNode Vue component mounts. Extensions that need to access
|
||||
VueNode-backed state (DOM widgets, Three.js renderers, etc.) must defer to onNodeMounted
|
||||
(v2) or waitForLoad3d-style callbacks (v1). See BC.37 for the deferred-mount bridge pattern.
|
||||
Source: Notion Frontend Architecture page (2026-05-08).
|
||||
member_pattern_ids:
|
||||
- S2.N1
|
||||
- S2.N8
|
||||
usage_weight: 37.56
|
||||
exemplars:
|
||||
- pattern_id: S2.N1
|
||||
repo: Comfy-Org/ComfyUI_frontend
|
||||
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/saveImageExtraOutput.ts#L31
|
||||
stars: 1787
|
||||
- pattern_id: S2.N8
|
||||
repo: Azornes/Comfyui-LayerForge
|
||||
url: https://github.com/Azornes/Comfyui-LayerForge/blob/main/src/CanvasView.ts#L1401
|
||||
stars: 313
|
||||
- pattern_id: S2.N1
|
||||
repo: SKBv0/ComfyUI_SpideyReroute
|
||||
url: https://github.com/SKBv0/ComfyUI_SpideyReroute/blob/main/js/SpideyReroute.js#L41
|
||||
stars: 13
|
||||
- category_id: BC.02
|
||||
name: 'Node lifecycle: teardown'
|
||||
intent: Single de-facto teardown surface for cleaning up DOM widgets, intervals, and observers when a node is removed.
|
||||
member_pattern_ids:
|
||||
- S2.N4
|
||||
usage_weight: 29.35
|
||||
exemplars:
|
||||
- pattern_id: S2.N4
|
||||
repo: Lightricks/ComfyUI-LTXVideo
|
||||
url: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L137
|
||||
stars: 3581
|
||||
- pattern_id: S2.N4
|
||||
repo: kijai/ComfyUI-KJNodes
|
||||
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/help_popup.js#L348
|
||||
stars: 2568
|
||||
- pattern_id: S2.N4
|
||||
repo: Comfy-Org/ComfyUI_frontend
|
||||
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/docs/architecture/ecs-migration-plan.md#L587
|
||||
stars: 1787
|
||||
- category_id: BC.03
|
||||
name: 'Node lifecycle: hydration from saved workflows'
|
||||
intent: React when a node is rehydrated from a stored workflow; the working replacement for the unused loadedGraphNode hook.
|
||||
member_pattern_ids:
|
||||
- S1.H1
|
||||
- S2.N7
|
||||
usage_weight: 15.42
|
||||
exemplars:
|
||||
- pattern_id: S1.H1
|
||||
repo: Comfy-Org/ComfyUI_frontend
|
||||
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
|
||||
stars: 1787
|
||||
- pattern_id: S1.H1
|
||||
repo: sofakid/dandy
|
||||
url: https://github.com/sofakid/dandy/blob/main/web/main.js#L114
|
||||
stars: 54
|
||||
- pattern_id: S2.N7
|
||||
repo: akawana/ComfyUI-Folded-Prompts
|
||||
url: https://github.com/akawana/ComfyUI-Folded-Prompts/blob/main/js/FPFoldedPrompts.js#L1265
|
||||
stars: 4
|
||||
- category_id: BC.04
|
||||
name: 'Node interaction: pointer, selection, resize'
|
||||
intent: 'User-driven per-node events: mouse down for custom click regions, selection focus, and resize feedback for relayout.'
|
||||
member_pattern_ids:
|
||||
- S2.N10
|
||||
- S2.N17
|
||||
- S2.N19
|
||||
usage_weight: 38.07
|
||||
exemplars:
|
||||
- pattern_id: S2.N10
|
||||
repo: diodiogod/TTS-Audio-Suite
|
||||
url: https://github.com/diodiogod/TTS-Audio-Suite/blob/main/web/chatterbox_voice_capture.js#L202
|
||||
stars: 906
|
||||
- pattern_id: S2.N10
|
||||
repo: melMass/comfy_mtb
|
||||
url: https://github.com/melMass/comfy_mtb/blob/main/web/comfy_shared.js#L1047
|
||||
stars: 702
|
||||
- pattern_id: S2.N10
|
||||
repo: pixaroma/ComfyUI-Pixaroma
|
||||
url: https://github.com/pixaroma/ComfyUI-Pixaroma/blob/main/js/compare/index.js#L360
|
||||
stars: 137
|
||||
- category_id: BC.05
|
||||
name: Custom DOM widgets and node sizing
|
||||
intent: Contribute DOM-backed widgets and override computeSize so the node reserves the right area for them.
|
||||
member_pattern_ids:
|
||||
- S4.W2
|
||||
- S2.N11
|
||||
usage_weight: 33.35
|
||||
exemplars:
|
||||
- pattern_id: S4.W2
|
||||
repo: Lightricks/ComfyUI-LTXVideo
|
||||
url: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L218
|
||||
stars: 3581
|
||||
- pattern_id: S4.W2
|
||||
repo: kijai/ComfyUI-KJNodes
|
||||
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/editors/editor_base.js#L511
|
||||
stars: 2568
|
||||
- pattern_id: S2.N11
|
||||
repo: o-l-l-i/ComfyUI-Olm-ImageAdjust
|
||||
url: https://github.com/o-l-l-i/ComfyUI-Olm-ImageAdjust/blob/main/web/olm_imageadjust.js#L319
|
||||
stars: 45
|
||||
- category_id: BC.06
|
||||
name: Custom canvas drawing (per-node and canvas-level)
|
||||
intent:
|
||||
Per-node onDrawForeground and full LGraphCanvas.prototype overrides for badges, indicators, keyboard, and custom
|
||||
render passes. Includes global ContextMenu replacement (S3.C2) as the most destructive canvas-level override.
|
||||
v1_scope_note: >-
|
||||
Simon Tranter (COM-3668, 2025-05-12) explicitly vetoed canvas drawing overrides as "too hacky/specific
|
||||
to implement APIs for". Confirmed out of v2 v1 scope. S3.C* patterns remain in DB for blast-radius
|
||||
tracking and strangler-fig planning but v2 need not replace them 1:1. Supports D9 Phase C deferral.
|
||||
member_pattern_ids:
|
||||
- S2.N9
|
||||
- S3.C1
|
||||
- S3.C2
|
||||
usage_weight: 58.97
|
||||
exemplars:
|
||||
- pattern_id: S3.C1
|
||||
repo: kijai/ComfyUI-KJNodes
|
||||
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1256
|
||||
stars: 2568
|
||||
- pattern_id: S3.C1
|
||||
repo: Comfy-Org/ComfyUI_frontend
|
||||
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/simpleTouchSupport.ts#L174
|
||||
stars: 1787
|
||||
- pattern_id: S3.C1
|
||||
repo: melMass/comfy_mtb
|
||||
url: https://github.com/melMass/comfy_mtb/blob/main/web/note_plus.js#L1
|
||||
stars: 702
|
||||
- category_id: BC.07
|
||||
name: Connection observation, intercept, and veto
|
||||
intent:
|
||||
Subscribe to link connect/disconnect events on a node and intercept incoming/outgoing connections before they are
|
||||
wired to refuse them, mutate slots, or coerce types.
|
||||
member_pattern_ids:
|
||||
- S2.N3
|
||||
- S2.N12
|
||||
- S2.N13
|
||||
usage_weight: 51.08
|
||||
exemplars:
|
||||
- pattern_id: S2.N13
|
||||
repo: rgthree/rgthree-comfy
|
||||
url: https://github.com/rgthree/rgthree-comfy/blob/main/web/comfyui/node_mode_relay.js#L90
|
||||
stars: 3049
|
||||
- pattern_id: S2.N12
|
||||
repo: kijai/ComfyUI-KJNodes
|
||||
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/jsnodes.js#L152
|
||||
stars: 2568
|
||||
- pattern_id: S2.N12
|
||||
repo: Comfy-Org/ComfyUI_frontend
|
||||
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/core/graph/widgets/dynamicWidgets.ts#L539
|
||||
stars: 1787
|
||||
- category_id: BC.08
|
||||
name: Programmatic linking
|
||||
intent: Extensions wire connections from code (workflow templates, auto-routing).
|
||||
member_pattern_ids:
|
||||
- S10.D2
|
||||
usage_weight: 11.81
|
||||
exemplars:
|
||||
- pattern_id: S10.D2
|
||||
repo: MockbaTheBorg/ComfyUI-Mockba
|
||||
url: https://github.com/MockbaTheBorg/ComfyUI-Mockba/blob/main/js/slider.js#L1
|
||||
stars: 1
|
||||
- pattern_id: S10.D2
|
||||
repo: vjumpkung/comfyui-infinitetalk-native-sampler
|
||||
url: https://github.com/vjumpkung/comfyui-infinitetalk-native-sampler/blob/main/README.md#L1
|
||||
stars: 1
|
||||
- pattern_id: S10.D2
|
||||
repo: goodtab/ComfyUI-Custom-Scripts
|
||||
url: https://github.com/goodtab/ComfyUI-Custom-Scripts/blob/main/web/js/quickNodes.js#L138
|
||||
stars: 0
|
||||
- category_id: BC.09
|
||||
name: Dynamic slot and output mutation
|
||||
intent: Grow/shrink inputs and outputs at runtime, with the obligatory computeSize+setSize reflow that follows.
|
||||
member_pattern_ids:
|
||||
- S10.D1
|
||||
- S10.D3
|
||||
- S15.OS1
|
||||
usage_weight: 38.63
|
||||
exemplars:
|
||||
- pattern_id: S10.D1
|
||||
repo: Comfy-Org/ComfyUI_frontend
|
||||
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/lib/litegraph/src/canvas/LinkConnector.core.test.ts#L121
|
||||
stars: 1787
|
||||
- pattern_id: S10.D1
|
||||
repo: r-vage/ComfyUI_Eclipse
|
||||
url: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-mode-nodes.js#L42
|
||||
stars: 19
|
||||
- pattern_id: S15.OS1
|
||||
repo: yorkane/ComfyUI-KYNode
|
||||
url: https://github.com/yorkane/ComfyUI-KYNode/blob/main/web/python-editor.js#L243
|
||||
stars: 10
|
||||
- category_id: BC.10
|
||||
name: Widget value subscription
|
||||
intent: Subscribe to widget value changes either at the widget (callback chain) or node (onWidgetChanged) level.
|
||||
member_pattern_ids:
|
||||
- S4.W1
|
||||
- S2.N14
|
||||
usage_weight: 32.22
|
||||
exemplars:
|
||||
- pattern_id: S2.N14
|
||||
repo: Comfy-Org/ComfyUI_frontend
|
||||
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/widgetInputs.ts#L317
|
||||
stars: 1787
|
||||
- pattern_id: S4.W1
|
||||
repo: crom8505/ComfyUI-Dynamic-Sigmas
|
||||
url: https://github.com/crom8505/ComfyUI-Dynamic-Sigmas/blob/main/web/js/graph_sigmas.js#L79
|
||||
stars: 8
|
||||
- pattern_id: S4.W1
|
||||
repo: 834t/ComfyUI_834t_scene_composer
|
||||
url: https://github.com/834t/ComfyUI_834t_scene_composer/blob/main/js/b34t_scene_composer.js#L148
|
||||
stars: 5
|
||||
- category_id: BC.11
|
||||
name: Widget imperative state writes
|
||||
intent: Imperatively mutate widget value, COMBO option lists, or the node.widgets array (insert/remove/reorder).
|
||||
member_pattern_ids:
|
||||
- S4.W4
|
||||
- S4.W5
|
||||
- S2.N16
|
||||
usage_weight: 28.42
|
||||
exemplars:
|
||||
- pattern_id: S2.N16
|
||||
repo: r-vage/ComfyUI_Eclipse
|
||||
url: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-set-get.js#L9
|
||||
stars: 19
|
||||
- pattern_id: S4.W4
|
||||
repo: EnragedAntelope/EA_LMStudio
|
||||
url: https://github.com/EnragedAntelope/EA_LMStudio/blob/main/web/ea_lmstudio.js#L11
|
||||
stars: 7
|
||||
- pattern_id: S4.W4
|
||||
repo: zzggi2024/shaobkj
|
||||
url: https://github.com/zzggi2024/shaobkj/blob/main/js/dynamic_inputs.js#L374
|
||||
stars: 1
|
||||
- category_id: BC.12
|
||||
name: Per-widget serialization transform
|
||||
intent: Transform a widget's value at workflow-serialization time (dynamic prompts, hidden state, expand-on-save).
|
||||
notes: >-
|
||||
widget.options.serialize===false widgets (e.g. control_after_generate) still occupy a widgets_values
|
||||
slot and still fire serializeValue — excluded only from the backend prompt by graphToPrompt(). Test
|
||||
triple must cover this case explicitly. PR #10392 widgets_values_named is the v2 migration path;
|
||||
WidgetHandle identity must be by name not position. See research/architecture/widget-serialization-historical-analysis.md.
|
||||
member_pattern_ids:
|
||||
- S4.W3
|
||||
usage_weight: 27.94
|
||||
exemplars:
|
||||
- pattern_id: S4.W3
|
||||
repo: Comfy-Org/ComfyUI_frontend
|
||||
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/helpers/painter.ts#L70
|
||||
stars: 1787
|
||||
- pattern_id: S4.W3
|
||||
repo: Raykosan/ComfyUI_RaykoStudio
|
||||
url: https://github.com/Raykosan/ComfyUI_RaykoStudio/blob/main/web/rayko_lora_widget.js#L31
|
||||
stars: 45
|
||||
- pattern_id: S4.W3
|
||||
repo: 834t/ComfyUI_834t_scene_composer
|
||||
url: https://github.com/834t/ComfyUI_834t_scene_composer/blob/main/js/b34t_scene_composer.js#L135
|
||||
stars: 5
|
||||
- category_id: BC.13
|
||||
name: Per-node serialization interception
|
||||
intent: Intercept node-level serialize/onSerialize to inject custom workflow JSON fields.
|
||||
notes: >-
|
||||
Root cause: widgets_values is positional — prototype.serialize patchers consume/produce this array
|
||||
directly. Three index-drift sources: control_after_generate slot occupancy, extension-injected
|
||||
widgets, V3 IO.MultiType topology-dependent widget count. NaN→null pipeline produces silent
|
||||
corruption (backend crash is first visible symptom). Test triple must cover: (a) positional v1
|
||||
compat, (b) named-map v2 round-trip parity, (c) null-in-numeric-widget logs warning + substitutes
|
||||
default. PR #11884 guard, PR #10392 named map. See research/architecture/widget-serialization-historical-analysis.md.
|
||||
member_pattern_ids:
|
||||
- S2.N6
|
||||
- S2.N15
|
||||
usage_weight: 47.07
|
||||
exemplars:
|
||||
- pattern_id: S2.N15
|
||||
repo: Azornes/Comfyui-LayerForge
|
||||
url: https://github.com/Azornes/Comfyui-LayerForge/blob/main/js/CanvasView.js#L1438
|
||||
stars: 313
|
||||
- pattern_id: S2.N15
|
||||
repo: IAMCCS/IAMCCS-nodes
|
||||
url: https://github.com/IAMCCS/IAMCCS-nodes/blob/main/web/iamccs_wan_motion_presets.js#L598
|
||||
stars: 92
|
||||
- pattern_id: S2.N15
|
||||
repo: DazzleNodes/ComfyUI-Smart-Resolution-Calc
|
||||
url: https://github.com/DazzleNodes/ComfyUI-Smart-Resolution-Calc/blob/main/web/utils/serialization.js#L32
|
||||
stars: 7
|
||||
- category_id: BC.14
|
||||
name: Workflow → API serialization interception (graphToPrompt)
|
||||
intent: Patch app.graphToPrompt to resolve virtual nodes, inject custom metadata, or rewrite the API payload before submit.
|
||||
member_pattern_ids:
|
||||
- S6.A1
|
||||
usage_weight: 46.66
|
||||
exemplars:
|
||||
- pattern_id: S6.A1
|
||||
repo: Comfy-Org/ComfyUI-Manager
|
||||
url: https://github.com/Comfy-Org/ComfyUI-Manager/blob/main/js/components-manager.js#L781
|
||||
stars: 14554
|
||||
- pattern_id: S6.A1
|
||||
repo: kijai/ComfyUI-KJNodes
|
||||
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1406
|
||||
stars: 2568
|
||||
- pattern_id: S6.A1
|
||||
repo: m3rr/h4_Live
|
||||
url: https://github.com/m3rr/h4_Live/blob/main/js/h4_datastream.js#L23
|
||||
stars: 2
|
||||
- category_id: BC.15
|
||||
name: Workflow loading into the editor
|
||||
intent: External/embed scenario where a workflow JSON is pushed into the running editor via app.loadGraphData.
|
||||
member_pattern_ids:
|
||||
- S6.A2
|
||||
usage_weight: 20.31
|
||||
exemplars:
|
||||
- pattern_id: S6.A2
|
||||
repo: Comfy-Org/ComfyUI_frontend
|
||||
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/fixtures/helpers/WorkflowHelper.ts#L215
|
||||
stars: 1787
|
||||
- pattern_id: S6.A2
|
||||
repo: BennyKok/comfyui-deploy
|
||||
url: https://github.com/BennyKok/comfyui-deploy/blob/main/web-plugin/workflow-list.js#L456
|
||||
stars: 1507
|
||||
- pattern_id: S6.A2
|
||||
repo: ketle-man/ComfyUI-Workflow-Studio
|
||||
url: https://github.com/ketle-man/ComfyUI-Workflow-Studio/blob/main/static/js/workflow-tab.js#L67
|
||||
stars: 2
|
||||
- category_id: BC.16
|
||||
name: Execution output consumption (per-node)
|
||||
intent: Consume backend execution output on a specific node (text, JSON, image) to drive display.
|
||||
member_pattern_ids:
|
||||
- S2.N2
|
||||
usage_weight: 5.74
|
||||
exemplars:
|
||||
- pattern_id: S2.N2
|
||||
repo: andreszs/ComfyUI-Ultralytics-Studio
|
||||
url: https://github.com/andreszs/ComfyUI-Ultralytics-Studio/blob/main/js/show_string.js#L9
|
||||
stars: 3
|
||||
- pattern_id: S2.N2
|
||||
repo: AlexZ1967/ComfyUI_ALEXZ_tools
|
||||
url: https://github.com/AlexZ1967/ComfyUI_ALEXZ_tools/blob/main/web/show_json.js#L49
|
||||
stars: 0
|
||||
- pattern_id: S2.N2
|
||||
repo: becky3/comfyui-workspace
|
||||
url: https://github.com/becky3/comfyui-workspace/blob/main/custom_nodes/ComfyUI-Becky3-Common/js/show_text.js#L33
|
||||
stars: 0
|
||||
- category_id: BC.17
|
||||
name: Backend execution lifecycle and progress events
|
||||
intent: Subscribe to api.addEventListener for execution_*, progress, status, and reconnecting events.
|
||||
member_pattern_ids:
|
||||
- S5.A1
|
||||
- S5.A2
|
||||
- S5.A3
|
||||
usage_weight: 51.25
|
||||
exemplars:
|
||||
- pattern_id: S5.A2
|
||||
repo: AIGODLIKE/AIGODLIKE-ComfyUI-Studio
|
||||
url: https://github.com/AIGODLIKE/AIGODLIKE-ComfyUI-Studio/blob/main/loader/components/public/iconRenderer.js#L39
|
||||
stars: 405
|
||||
- pattern_id: S5.A3
|
||||
repo: kyuz0/amd-strix-halo-comfyui-toolboxes
|
||||
url: https://github.com/kyuz0/amd-strix-halo-comfyui-toolboxes/blob/main/scripts/benchmark_workflows.py#L52
|
||||
stars: 109
|
||||
- pattern_id: S5.A1
|
||||
repo: ShakerSmith/ShakerNodesSuite
|
||||
url: https://github.com/ShakerSmith/ShakerNodesSuite/blob/main/js/shaker_preview_ui.js#L58
|
||||
stars: 8
|
||||
- category_id: BC.18
|
||||
name: Backend HTTP calls
|
||||
intent: Call ComfyAPI.fetchApi as the canonical authenticated path to backend HTTP endpoints.
|
||||
member_pattern_ids:
|
||||
- S6.A3
|
||||
usage_weight: 22.74
|
||||
exemplars:
|
||||
- pattern_id: S6.A3
|
||||
repo: Comfy-Org/ComfyUI_frontend
|
||||
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/components/common/BackgroundImageUpload.vue#L61
|
||||
stars: 1787
|
||||
- pattern_id: S6.A3
|
||||
repo: akawana/ComfyUI-Folded-Prompts
|
||||
url: https://github.com/akawana/ComfyUI-Folded-Prompts/blob/main/js/FPFoldedPrompts.js#L1227
|
||||
stars: 4
|
||||
- pattern_id: S6.A3
|
||||
repo: zhupeter010903/ComfyUI-XYZ-prompt-library
|
||||
url: https://github.com/zhupeter010903/ComfyUI-XYZ-prompt-library/blob/main/js/prompt_library_window.js#L1379
|
||||
stars: 1
|
||||
- category_id: BC.19
|
||||
name: Workflow execution trigger
|
||||
intent: Trigger or intercept queuePrompt for sidebar Run buttons, auth tokens, or payload mutation.
|
||||
member_pattern_ids:
|
||||
- S6.A4
|
||||
usage_weight: 12.65
|
||||
exemplars:
|
||||
- pattern_id: S6.A4
|
||||
repo: MajoorWaldi/ComfyUI-Majoor-AssetsManager
|
||||
url: https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/blob/main/js/features/viewer/workflowSidebar/sidebarRunButton.js#L317
|
||||
stars: 97
|
||||
- pattern_id: S6.A4
|
||||
repo: gigici/ComfyUI_BlendPack
|
||||
url: https://github.com/gigici/ComfyUI_BlendPack/blob/main/js/ui/NodeUI.js#L99
|
||||
stars: 1
|
||||
- pattern_id: S6.A4
|
||||
repo: rohapa/comfyui-replay
|
||||
url: https://github.com/rohapa/comfyui-replay/blob/main/README.md#L497
|
||||
stars: 0
|
||||
- category_id: BC.20
|
||||
name: Custom node-type registration (frontend-only / virtual)
|
||||
intent: Register pure-frontend or fully virtual node types and mark them with isVirtualNode so the backend ignores them.
|
||||
member_pattern_ids:
|
||||
- S1.H5
|
||||
- S1.H6
|
||||
- S8.P1
|
||||
usage_weight: 27.49
|
||||
exemplars:
|
||||
- pattern_id: S1.H6
|
||||
repo: Comfy-Org/ComfyUI_frontend
|
||||
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/rerouteNode.ts
|
||||
stars: 1787
|
||||
- pattern_id: S1.H5
|
||||
repo: Comfy-Org/ComfyUI_frontend
|
||||
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
|
||||
stars: 1787
|
||||
- pattern_id: S1.H6
|
||||
repo: sofakid/dandy
|
||||
url: https://github.com/sofakid/dandy/blob/main/web/main.js#L111
|
||||
stars: 54
|
||||
- category_id: BC.21
|
||||
name: Custom widget-type registration
|
||||
intent: Register new widget types (color picker, file uploader, custom inputs) via getCustomWidgets.
|
||||
member_pattern_ids:
|
||||
- S1.H2
|
||||
usage_weight: 7.17
|
||||
exemplars:
|
||||
- pattern_id: S1.H2
|
||||
repo: Comfy-Org/ComfyUI_frontend
|
||||
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
|
||||
stars: 1787
|
||||
- pattern_id: S1.H2
|
||||
repo: haohaocreates/PR-rk-comfy-nodes-36d8f0a5
|
||||
url: https://github.com/haohaocreates/PR-rk-comfy-nodes-36d8f0a5/blob/main/web/rk_nodes.ts#L22
|
||||
stars: 0
|
||||
- category_id: BC.22
|
||||
name: Context menu contributions (node and canvas)
|
||||
intent:
|
||||
Contribute right-click menu items at both the node and canvas scope, including legacy prototype patches and the
|
||||
supported v1 hooks.
|
||||
member_pattern_ids:
|
||||
- S2.N5
|
||||
- S1.H3
|
||||
- S1.H4
|
||||
usage_weight: 19.53
|
||||
exemplars:
|
||||
- pattern_id: S1.H3
|
||||
repo: Comfy-Org/ComfyUI_frontend
|
||||
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
|
||||
stars: 1787
|
||||
- pattern_id: S1.H4
|
||||
repo: Comfy-Org/ComfyUI_frontend
|
||||
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
|
||||
stars: 1787
|
||||
- pattern_id: S1.H3
|
||||
repo: r-vage/ComfyUI_Eclipse
|
||||
url: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-canvas-utils.js#L2
|
||||
stars: 19
|
||||
- category_id: BC.23
|
||||
name: Node property bag mutations
|
||||
intent: React to mutations of node.properties — the persistent property bag that survives serialization.
|
||||
member_pattern_ids:
|
||||
- S2.N18
|
||||
usage_weight: 14.42
|
||||
exemplars:
|
||||
- pattern_id: S2.N18
|
||||
repo: rgthree/rgthree-comfy
|
||||
url: https://github.com/rgthree/rgthree-comfy/blob/main/src_web/comfyui/seed.ts#L78
|
||||
stars: 3049
|
||||
- pattern_id: S2.N18
|
||||
repo: rgthree/rgthree-comfy
|
||||
url: https://github.com/rgthree/rgthree-comfy/blob/main/web/comfyui/seed.js#L26
|
||||
stars: 3049
|
||||
- pattern_id: S2.N18
|
||||
repo: rgthree/rgthree-comfy
|
||||
url: https://github.com/rgthree/rgthree-comfy/blob/main/web/comfyui/power_primitive.js#L142
|
||||
stars: 3049
|
||||
- category_id: BC.24
|
||||
name: Node-def schema inspection
|
||||
intent: Branch on ComfyNodeDef shape (input.required/optional/hidden, output, output_node, category) to drive UI.
|
||||
member_pattern_ids:
|
||||
- S13.SC1
|
||||
usage_weight: 22.43
|
||||
exemplars:
|
||||
- pattern_id: S13.SC1
|
||||
repo: BennyKok/comfyui-deploy
|
||||
url: https://github.com/BennyKok/comfyui-deploy/blob/main/web-plugin/index.js#L1
|
||||
stars: 1507
|
||||
- pattern_id: S13.SC1
|
||||
repo: StableLlama/ComfyUI-basic_data_handling
|
||||
url: https://github.com/StableLlama/ComfyUI-basic_data_handling/blob/main/web/js/dynamicnode.js#L1
|
||||
stars: 43
|
||||
- pattern_id: S13.SC1
|
||||
repo: xeinherjer-dev/ComfyUI-XENodes
|
||||
url: https://github.com/xeinherjer-dev/ComfyUI-XENodes/blob/main/web/js/combo_selector.js#L1
|
||||
stars: 1
|
||||
- category_id: BC.25
|
||||
name: Shell UI registration (commands, sidebars, toasts)
|
||||
intent: Declarative shell-UI contributions through extensionManager / commandManager / sidebarTab / bottomPanel.
|
||||
member_pattern_ids:
|
||||
- S12.UI1
|
||||
usage_weight: 10.98
|
||||
exemplars:
|
||||
- pattern_id: S12.UI1
|
||||
repo: robertvoy/ComfyUI-Distributed
|
||||
url: https://github.com/robertvoy/ComfyUI-Distributed/blob/main/web/main.js#L269
|
||||
stars: 544
|
||||
- pattern_id: S12.UI1
|
||||
repo: maxi45274/ComfyUI_LinkFX
|
||||
url: https://github.com/maxi45274/ComfyUI_LinkFX/blob/main/js/LinkFX.js#L707
|
||||
stars: 3
|
||||
- pattern_id: S12.UI1
|
||||
repo: criskb/Comfypencil
|
||||
url: https://github.com/criskb/Comfypencil/blob/main/web/comfy_pencil_extension.js#L955
|
||||
stars: 0
|
||||
- category_id: BC.26
|
||||
name: Globals as ABI (window.LiteGraph, window.comfyAPI)
|
||||
intent: Reach into the global namespace for LiteGraph constructors/enums or the module-as-global comfyAPI registry.
|
||||
member_pattern_ids:
|
||||
- S7.G1
|
||||
usage_weight: 27.0
|
||||
exemplars:
|
||||
- pattern_id: S7.G1
|
||||
repo: ryanontheinside/ComfyUI_RyanOnTheInside
|
||||
url: https://github.com/ryanontheinside/ComfyUI_RyanOnTheInside/blob/main/web/js/index.js#L1
|
||||
stars: 801
|
||||
- pattern_id: S7.G1
|
||||
repo: ArtHommage/HommageTools
|
||||
url: https://github.com/ArtHommage/HommageTools/blob/main/web/js/index.js#L1
|
||||
stars: 4
|
||||
- pattern_id: S7.G1
|
||||
repo: PROJECTMAD/PROJECT-MAD-NODES
|
||||
url: https://github.com/PROJECTMAD/PROJECT-MAD-NODES/blob/main/web/js/index.js#L1
|
||||
stars: 4
|
||||
- category_id: BC.27
|
||||
name: LiteGraph entity direct manipulation (reroute, group, link, slot)
|
||||
intent: Direct read/mutation of reroutes, groups, links, and slots — no public extension API exists today.
|
||||
member_pattern_ids:
|
||||
- S9.R1
|
||||
- S9.G1
|
||||
- S9.L1
|
||||
- S9.S1
|
||||
usage_weight: 39.37
|
||||
exemplars:
|
||||
- pattern_id: S9.R1
|
||||
repo: nodetool-ai/nodetool
|
||||
url: https://github.com/nodetool-ai/nodetool/blob/main/subgraphs.md#L1
|
||||
stars: 330
|
||||
- pattern_id: S9.S1
|
||||
repo: nodetool-ai/nodetool
|
||||
url: https://github.com/nodetool-ai/nodetool/blob/main/subgraphs.md#L267
|
||||
stars: 330
|
||||
- pattern_id: S9.S1
|
||||
repo: Stibo/comfyui-nifty-nodes
|
||||
url: https://github.com/Stibo/comfyui-nifty-nodes/blob/main/js/nifty_nodes.js#L112
|
||||
stars: 3
|
||||
- category_id: BC.28
|
||||
name: Subgraph fan-out via set/get virtual nodes
|
||||
intent: Fan out a single named value across the graph without explicit links (KJNodes-style Set/Get nodes).
|
||||
member_pattern_ids:
|
||||
- S9.SG1
|
||||
usage_weight: 16.89
|
||||
exemplars:
|
||||
- pattern_id: S9.SG1
|
||||
repo: kijai/ComfyUI-KJNodes
|
||||
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1406
|
||||
stars: 2568
|
||||
- pattern_id: S9.SG1
|
||||
repo: krismasdev/ComfyUI-Flux-Continuum
|
||||
url: https://github.com/krismasdev/ComfyUI-Flux-Continuum/blob/main/web/hint.js#L1
|
||||
stars: 0
|
||||
- pattern_id: S9.SG1
|
||||
repo: SpaceWarpStudio/ComfyUI-SetInputGetOutput
|
||||
url: https://github.com/SpaceWarpStudio/ComfyUI-SetInputGetOutput/blob/main/web/js/setinputgetoutput.js#L1
|
||||
stars: 0
|
||||
- category_id: BC.29
|
||||
name: Graph enumeration, mutation, and cross-scope identity
|
||||
intent:
|
||||
Enumerate or mutate the node set (graph.add/remove/findNodesByType/serialize/configure) and resolve cross-subgraph
|
||||
references via NodeLocatorId / NodeExecutionId.
|
||||
member_pattern_ids:
|
||||
- S11.G2
|
||||
- S14.ID1
|
||||
usage_weight: 23.56
|
||||
exemplars:
|
||||
- pattern_id: S11.G2
|
||||
repo: yolain/ComfyUI-Easy-Use
|
||||
url: https://github.com/yolain/ComfyUI-Easy-Use/blob/main/web_version/v1/js/easy/easyExtraMenu.js#L439
|
||||
stars: 2503
|
||||
- pattern_id: S11.G2
|
||||
repo: Comfy-Org/ComfyUI_frontend
|
||||
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/tests/workflowPersistence.spec.ts#L351
|
||||
stars: 1787
|
||||
- pattern_id: S11.G2
|
||||
repo: r-vage/ComfyUI_Eclipse
|
||||
url: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-ui-enhancements.js#L29
|
||||
stars: 19
|
||||
- category_id: BC.30
|
||||
name: Graph change tracking, batching, and reactivity flush
|
||||
intent:
|
||||
'Coordinate graph-level change: graph._version monotonic counter, beforeChange/afterChange batching, and the imperative
|
||||
setDirtyCanvas redraw flush.'
|
||||
member_pattern_ids:
|
||||
- S11.G1
|
||||
- S11.G3
|
||||
- S11.G4
|
||||
usage_weight: 34.38
|
||||
exemplars:
|
||||
- pattern_id: S11.G3
|
||||
repo: nodetool-ai/nodetool
|
||||
url: https://github.com/nodetool-ai/nodetool/blob/main/subgraphs.md#L1
|
||||
stars: 330
|
||||
- pattern_id: S11.G4
|
||||
repo: akawana/ComfyUI-Folded-Prompts
|
||||
url: https://github.com/akawana/ComfyUI-Folded-Prompts/blob/main/js/FPFoldedPrompts.js#L776
|
||||
stars: 4
|
||||
- pattern_id: S11.G3
|
||||
repo: linjm8780860/ljm_comfyui
|
||||
url: https://github.com/linjm8780860/ljm_comfyui/blob/main/src/utils/vintageClipboard.ts#L1
|
||||
stars: 0
|
||||
|
||||
- category_id: BC.31
|
||||
name: DOM injection and style management
|
||||
intent:
|
||||
Extensions add UI chrome, toolbars, and style overrides directly into the document outside any provided API —
|
||||
style tags into head, arbitrary elements into body, innerHTML rendering, and external script loading.
|
||||
member_pattern_ids:
|
||||
- S16.DOM1
|
||||
- S16.DOM2
|
||||
- S16.DOM3
|
||||
- S16.DOM4
|
||||
usage_weight: 0.0
|
||||
notes:
|
||||
'usage_weight pending rollup-blast-radius.py re-run after database.yaml merge (I-N4.1). Notion counts: DOM1=354
|
||||
occ, DOM2=364 occ, DOM3=443 occ, DOM4=232 occ across ~81 packages — among the highest raw occurrence counts in
|
||||
the entire dataset. v2 replacements: injectStyles(), addPanel(), addToolbarItem(), safe HTML rendering API.'
|
||||
exemplars:
|
||||
- pattern_id: S16.DOM1
|
||||
repo: kijai/ComfyUI-KJNodes
|
||||
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/help_popup.js
|
||||
stars: 2568
|
||||
- pattern_id: S16.DOM2
|
||||
repo: yolain/ComfyUI-Easy-Use
|
||||
url: https://github.com/yolain/ComfyUI-Easy-Use/blob/main/web_version/v1/js/easy/easy.js
|
||||
stars: 2503
|
||||
- pattern_id: S16.DOM3
|
||||
repo: '(aggregate — Notion §2.3)'
|
||||
url: https://www.notion.so/comfy-org/ComfyUI-Custom-Node-Frontend-API-Usage-Research-3356d73d365080dbaacafe8e52d52692
|
||||
stars: 0
|
||||
|
||||
- category_id: BC.32
|
||||
name: Embedded framework runtimes and Vue widget bundling
|
||||
intent:
|
||||
Extensions bundle their own copy of Vue (or another framework) inside a DOM widget, bypassing the host app
|
||||
instance and losing access to shared stores, i18n, and theme.
|
||||
member_pattern_ids:
|
||||
- S16.VUE1
|
||||
usage_weight: 0.0
|
||||
notes:
|
||||
'usage_weight pending rollup-blast-radius.py re-run. 9 packages confirmed (Notion §2.9). v2 replacement:
|
||||
registerVueWidget(nodeType, name, Component) sharing host Vue instance — already in plans/P1 §5 Custom widget type.
|
||||
This BC provides the evidence base for that P1 design decision.'
|
||||
exemplars:
|
||||
- pattern_id: S16.VUE1
|
||||
repo: ComfyUI-NKD-Sigmas-Curve
|
||||
url: https://www.notion.so/comfy-org/ComfyUI-Custom-Node-Frontend-API-Usage-Research-3356d73d365080dbaacafe8e52d52692
|
||||
stars: 0
|
||||
- pattern_id: S16.VUE1
|
||||
repo: '(aggregate — 9 packages, Notion §2.9)'
|
||||
url: https://www.notion.so/comfy-org/ComfyUI-Custom-Node-Frontend-API-Usage-Research-3356d73d365080dbaacafe8e52d52692
|
||||
stars: 0
|
||||
|
||||
# ── Categories added 2026-05-08 from Notion COM-3668 (Simon Tranter, Custom Scripts API requirements) ──
|
||||
|
||||
- category_id: BC.33
|
||||
name: Cross-extension DOM widget creation observation
|
||||
intent:
|
||||
An extension observes when *any* DOM widget is created (by any other extension) so it can attach its own
|
||||
listeners — the mechanism the Autocomplete extension needs to wire its input handler to every text widget.
|
||||
member_pattern_ids:
|
||||
- S4.W6
|
||||
usage_weight: 0.0
|
||||
notes: >-
|
||||
Identified from COM-3668. Distinct from BC.05 (creating DOM widgets) and BC.10 (subscribing to value changes).
|
||||
Gap: no v1 hook fires for cross-extension widget creation observation. v2 shape: onDOMWidgetCreated(handler)
|
||||
in defineExtension setup context. usage_weight pending blast-radius re-run.
|
||||
source: notion-COM-3668
|
||||
exemplars:
|
||||
- pattern_id: S4.W6
|
||||
repo: goodtab/ComfyUI-Custom-Scripts
|
||||
url: https://github.com/goodtab/ComfyUI-Custom-Scripts
|
||||
stars: 0
|
||||
|
||||
- category_id: BC.34
|
||||
name: Settings-panel custom dialog integration
|
||||
intent: Extensions open custom modal dialogs triggered from the settings panel, rather than injecting raw DOM.
|
||||
member_pattern_ids:
|
||||
- S12.UI3
|
||||
usage_weight: 0.0
|
||||
notes: >-
|
||||
Identified from COM-3668. Currently worked around via S16.DOM3 innerHTML injection. Distinct from S12.UI1
|
||||
(sidebar/command registration) — this is about dialog lifecycle tied to settings entries. v2 shape:
|
||||
app.ui.openDialog(Component) or settings entry type 'dialog-trigger'. usage_weight pending blast-radius re-run.
|
||||
source: notion-COM-3668
|
||||
exemplars:
|
||||
- pattern_id: S12.UI3
|
||||
repo: goodtab/ComfyUI-Custom-Scripts
|
||||
url: https://github.com/goodtab/ComfyUI-Custom-Scripts
|
||||
stars: 0
|
||||
|
||||
- category_id: BC.35
|
||||
name: Pre-queue widget validation
|
||||
intent:
|
||||
Validate widget values before a workflow is submitted and surface typed errors to the user — rejecting
|
||||
the queue rather than silently mutating or failing.
|
||||
member_pattern_ids:
|
||||
- S6.A5
|
||||
usage_weight: 0.0
|
||||
notes: >-
|
||||
Identified from COM-3668. Currently worked around via S6.A4 queuePrompt monkey-patching (silent_breakage=true
|
||||
when multiple extensions patch). Distinct from D5 beforeSerialize (transforms values) and BC.19 (triggers
|
||||
execution). v2 needs explicit beforeQueue event with event.reject(message). usage_weight pending re-run.
|
||||
source: notion-COM-3668
|
||||
exemplars:
|
||||
- pattern_id: S6.A5
|
||||
repo: goodtab/ComfyUI-Custom-Scripts
|
||||
url: https://github.com/goodtab/ComfyUI-Custom-Scripts
|
||||
stars: 0
|
||||
|
||||
- category_id: BC.36
|
||||
name: PrimeVue widget component API surface
|
||||
intent: >-
|
||||
Custom node authors configuring widget behavior via per-component prop subsets — the v2 replacement
|
||||
for direct widget.options mutation (S4.W4, S4.W1) and DOM widget construction (S4.W5, S4.W6).
|
||||
15 PrimeVue components are the authoritative widget-kind enumeration for v2.
|
||||
member_pattern_ids:
|
||||
- S4.W1
|
||||
- S4.W4
|
||||
- S4.W5
|
||||
notes: >-
|
||||
Source: Notion page "Widget Component APIs" (2026-05-08). 15 components: Button, InputText, Select,
|
||||
ColorPicker, MultiSelect, SelectButton, Slider, Textarea, ToggleSwitch, Chart, Image, ImageCompare,
|
||||
Galleria, FileUpload, TreeSelect. Exclusion rule (Pablo): strip style/class/dt/pt/*Class/*Style.
|
||||
ToggleSwitch is the only component with completed Pick<> types so far (WIP).
|
||||
Informs: D7 typed options bags (future pivot), I-TF.2 widget-kind test triples,
|
||||
PKG2 WidgetHandle.getOption key surface. disabled/readonly map to D7 first-class fields,
|
||||
not options bag.
|
||||
usage_weight: 0.0
|
||||
exemplars:
|
||||
- pattern_id: S4.W4
|
||||
repo: '(see database.yaml S4.W4 exemplars — widget.options.values mutation)'
|
||||
url: https://www.notion.so/comfy-org/Widget-Component-APIs-2126d73d365080b0bf30f241c09dd756
|
||||
stars: 0
|
||||
|
||||
- category_id: BC.37
|
||||
name: VueNode bridge timing — deferred mount access
|
||||
intent: >-
|
||||
Extensions that register in nodeCreated but need to access Vue-component-backed state
|
||||
(Three.js renderer, DOM widget, ComponentWidgetImpl value) must defer until the Vue
|
||||
component's onMounted fires. The v1 pattern is waitForLoad3d(node, cb); the v2 pattern
|
||||
is onNodeMounted(() => { ... }) inside defineNodeExtension.
|
||||
member_pattern_ids:
|
||||
- S4.W5
|
||||
notes: >-
|
||||
Source: Notion Frontend Architecture page 3536d73d (2026-05-08). nodeCreated gives the
|
||||
LiteGraph node; the VueNode Vue component has NOT mounted yet. waitForLoad3d in
|
||||
src/extensions/core/Load3D is the canonical v1 fixture. ComponentWidgetImpl dual-identity:
|
||||
LiteGraph side (value/callback/name) vs Vue side (props/emits/lifecycle).
|
||||
v2 contract: onNodeMounted() hook fires after Vue component mount — this is the correct
|
||||
timing for accessing VueNode-backed resources.
|
||||
Informs: I-SR.2.B2 (NodeInstanceScope must not sync-access VueNode at setup time),
|
||||
I-TF.3.C1 (harness must simulate two-phase mount), I-TF.2 test triple for BC.37.
|
||||
D8 relevance: app.rootGraph is not reactive (confirmed by this doc) — the exact gap D8 solves.
|
||||
usage_weight: 0.0
|
||||
source: notion-frontend-architecture-3536d73d
|
||||
exemplars:
|
||||
- pattern_id: S4.W5
|
||||
repo: Comfy-Org/ComfyUI_frontend
|
||||
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/load3d.ts
|
||||
stars: 1787
|
||||
|
||||
- category_id: BC.38
|
||||
name: Canvas mode observation
|
||||
intent: >-
|
||||
Detect and react to ComfyUI canvas mode transitions (graph / app / builder:inputs /
|
||||
builder:outputs / builder:arrange). Custom nodes that adapt rendering, widget resize
|
||||
behavior, or read-only state across modes need a stable event — not polling or heuristics
|
||||
against internal Pinia store state.
|
||||
member_pattern_ids:
|
||||
- S17.AM1
|
||||
mechanism: absent-api
|
||||
notes: >-
|
||||
appModeStore is a Pinia composable; JS extensions cannot use Vue composables. v2 gap:
|
||||
no node.on('canvasModeChanged') exists yet in node.ts — distinct from NodeModeChangedEvent
|
||||
(execution mode only). v2 contract: app-level or node-level canvasModeChanged event.
|
||||
Flagged: Terry DX walkthrough A.1. Informs: node.ts overloads (add canvasModeChanged
|
||||
or document as known gap), I-TF.2 test triple for BC.38.
|
||||
usage_weight: 0.0
|
||||
source: notion-pain-point-assessment
|
||||
exemplars:
|
||||
- pattern_id: S17.AM1
|
||||
repo: (first-principles assessment — Terry Jia)
|
||||
url: https://www.notion.so/comfy-org/Develop-a-custom-node-from-scratch-pain-point-assessment-33c6d73d365080f49126c0b5affa7559
|
||||
stars: 0
|
||||
|
||||
- category_id: BC.39
|
||||
name: Subgraph boundary event propagation
|
||||
intent: >-
|
||||
Custom node callbacks (onExecuted, MatchType, autogrow onConnectionsChange, promoted widget
|
||||
callbacks) that must propagate across subgraph boundaries. Four distinct silent-failure modes
|
||||
when custom nodes are placed inside subgraphs.
|
||||
member_pattern_ids:
|
||||
- S17.SB1
|
||||
mechanism: absent-api
|
||||
notes: >-
|
||||
Requires D9 Phase B (post-Alex rebase on #11939). ECS substrate must forward SubgraphNode
|
||||
execution events from internal nodes. MatchType and autogrow propagation require subgraph
|
||||
boundary awareness in World dispatcher. Blocked: I-PG.B1. Short-term: @experimental on
|
||||
affected NodeHandle events; subgraphCompatible flag in NodeExtensionOptions.
|
||||
Intersects: ADR 0006 (I-NEW.1), Austin's fix-linked-widget-promotion.
|
||||
Flagged: Terry DX walkthrough A.2.
|
||||
usage_weight: 0.0
|
||||
source: notion-pain-point-assessment
|
||||
exemplars:
|
||||
- pattern_id: S17.SB1
|
||||
repo: (first-principles assessment — Terry Jia)
|
||||
url: https://www.notion.so/comfy-org/Develop-a-custom-node-from-scratch-pain-point-assessment-33c6d73d365080f49126c0b5affa7559
|
||||
stars: 0
|
||||
|
||||
- category_id: BC.40
|
||||
name: File upload and asset URL construction
|
||||
intent: >-
|
||||
Upload files to ComfyUI backend and construct retrieval URLs. 32+ packages duplicate this
|
||||
pattern from scratch — FormData construction, fetchApi('/upload/image'), /view?filename URL
|
||||
assembly. A helper API would collapse this to comfyAPI.uploadFile() + comfyAPI.getFileUrl().
|
||||
member_pattern_ids:
|
||||
- S17.FA1
|
||||
mechanism: absent-api
|
||||
notes: >-
|
||||
Out of scope for @comfyorg/extension-api (node extension surface). Belongs in future
|
||||
@comfyorg/comfy-api package. 32+ packages affected; 9 implement video upload variants.
|
||||
Upload timeout hardcoded 120s; large 3D/video fail silently. No temp file lifecycle.
|
||||
Document as known gap in src/extension-api/README.md.
|
||||
Flagged: Terry DX walkthrough A.3.
|
||||
usage_weight: 0.0
|
||||
source: notion-pain-point-assessment
|
||||
exemplars:
|
||||
- pattern_id: S17.FA1
|
||||
repo: (first-principles assessment — Terry Jia)
|
||||
url: https://www.notion.so/comfy-org/Develop-a-custom-node-from-scratch-pain-point-assessment-33c6d73d365080f49126c0b5affa7559
|
||||
stars: 0
|
||||
|
||||
- category_id: BC.41
|
||||
name: Widget values positional serialization fragility
|
||||
intent: >-
|
||||
Widget values serialized as positional array [v1, v2, v3] instead of named dict.
|
||||
Any input definition change (add, reorder, rename, remove, required→optional) silently
|
||||
misaligns values when loading existing workflows. Root cause of #1 user complaint:
|
||||
"my workflow broke after I updated the custom node."
|
||||
member_pattern_ids:
|
||||
- S17.WV1
|
||||
mechanism: positional-array
|
||||
notes: >-
|
||||
Blocked on workflow-schema-migration (out of v2 surface scope). D7 Part 4 (4→2
|
||||
serialization collapse) + beforeSerialize as partial mitigation. Long-term fix: named dict
|
||||
format { widgetName: value } — breaking JSON schema change requiring versioning +
|
||||
migrateWidgetValues() callback. PR #10392 added widgets_values_named opt-in; PR #11884
|
||||
null guard. v2 contract: name-keyed identity (WidgetHandle by name not position).
|
||||
Intersects: ADR 0006, widget-serialization-historical-analysis.md, Austin's work.
|
||||
Flagged: Terry DX walkthrough A.4.
|
||||
usage_weight: 0.0
|
||||
source: notion-pain-point-assessment
|
||||
exemplars:
|
||||
- pattern_id: S17.WV1
|
||||
repo: Comfy-Org/ComfyUI_frontend
|
||||
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/utils/nodeDefOrderingUtil.ts
|
||||
stars: 1787
|
||||
1185
research/touch-points/rollup.yaml
Normal file
1185
research/touch-points/rollup.yaml
Normal file
File diff suppressed because it is too large
Load Diff
117
scripts/check-compat-floor.py
Normal file
117
scripts/check-compat-floor.py
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Compat-floor gate: Verify all high-impact behavior categories have test triples.
|
||||
|
||||
Per PLAN.md §Compat-floor: "Every blast_radius ≥ 2.0 pattern MUST pass v1 + v2 +
|
||||
migration tests before v2 ships."
|
||||
|
||||
This script:
|
||||
1. Reads research/touch-points/behavior-categories.yaml
|
||||
2. Finds all categories with usage_weight >= 2.0 (blast_radius threshold)
|
||||
3. Checks that each has all three test files: bc-XX.v1.test.ts, bc-XX.v2.test.ts, bc-XX.migration.test.ts
|
||||
4. Exits 0 if all present, exits 1 if any missing (fails CI)
|
||||
|
||||
Usage: python3 scripts/check-compat-floor.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print("ERROR: PyYAML not installed. Run: pip install pyyaml", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
COMPAT_FLOOR_THRESHOLD = 2.0
|
||||
BEHAVIOR_CATEGORIES_PATH = Path("research/touch-points/behavior-categories.yaml")
|
||||
TESTS_DIR = Path("src/extension-api-v2/__tests__")
|
||||
|
||||
def main():
|
||||
# Check that behavior-categories.yaml exists
|
||||
if not BEHAVIOR_CATEGORIES_PATH.exists():
|
||||
print(f"ERROR: {BEHAVIOR_CATEGORIES_PATH} not found", file=sys.stderr)
|
||||
print(" Run scripts/build-behavior-categories.py first or copy from workspace", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Skip check if tests directory doesn't exist (tests only in tf branch)
|
||||
if not TESTS_DIR.exists():
|
||||
print(f"SKIP: {TESTS_DIR} not found — compat-floor tests not yet added to this branch")
|
||||
print(" The compat-floor gate only enforces on branches with extension-api-v2 tests.")
|
||||
sys.exit(0)
|
||||
|
||||
# Load categories
|
||||
with open(BEHAVIOR_CATEGORIES_PATH, "r") as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
categories = data.get("categories", [])
|
||||
|
||||
# Find categories above compat floor
|
||||
above_floor = []
|
||||
for cat in categories:
|
||||
cat_id = cat.get("category_id", "")
|
||||
usage_weight = cat.get("usage_weight", 0)
|
||||
if usage_weight >= COMPAT_FLOOR_THRESHOLD:
|
||||
above_floor.append({
|
||||
"id": cat_id,
|
||||
"name": cat.get("name", ""),
|
||||
"usage_weight": usage_weight
|
||||
})
|
||||
|
||||
print(f"Compat-floor check: {len(above_floor)} categories with usage_weight >= {COMPAT_FLOOR_THRESHOLD}")
|
||||
print()
|
||||
|
||||
# Check each category for test triples
|
||||
missing = []
|
||||
for cat in above_floor:
|
||||
cat_id = cat["id"]
|
||||
# Extract number from BC.XX
|
||||
num_str = cat_id.replace("BC.", "").zfill(2)
|
||||
|
||||
required_files = [
|
||||
f"bc-{num_str}.v1.test.ts",
|
||||
f"bc-{num_str}.v2.test.ts",
|
||||
f"bc-{num_str}.migration.test.ts"
|
||||
]
|
||||
|
||||
cat_missing = []
|
||||
for fname in required_files:
|
||||
fpath = TESTS_DIR / fname
|
||||
if not fpath.exists():
|
||||
cat_missing.append(fname)
|
||||
|
||||
if cat_missing:
|
||||
missing.append({
|
||||
"category": cat_id,
|
||||
"name": cat["name"],
|
||||
"usage_weight": cat["usage_weight"],
|
||||
"missing": cat_missing
|
||||
})
|
||||
status = "❌ MISSING"
|
||||
else:
|
||||
status = "✅"
|
||||
|
||||
print(f" {cat_id} ({cat['usage_weight']:.2f}) {cat['name'][:40]:<40} {status}")
|
||||
if cat_missing:
|
||||
for m in cat_missing:
|
||||
print(f" └─ {m}")
|
||||
|
||||
print()
|
||||
|
||||
if missing:
|
||||
print(f"FAIL: {len(missing)} categories missing test files", file=sys.stderr)
|
||||
print()
|
||||
print("Per PLAN.md §Compat-floor, all blast_radius >= 2.0 categories", file=sys.stderr)
|
||||
print("must have complete test triples (v1, v2, migration) before v2 ships.", file=sys.stderr)
|
||||
print()
|
||||
print("Missing files:", file=sys.stderr)
|
||||
for m in missing:
|
||||
for f in m["missing"]:
|
||||
print(f" - {TESTS_DIR / f}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"PASS: All {len(above_floor)} compat-floor categories have test triples")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -42,7 +42,7 @@ describe('Badge', () => {
|
||||
})
|
||||
|
||||
describe('twMerge preserves color alongside text-3xs font size', () => {
|
||||
it.for([
|
||||
it.each([
|
||||
['default', 'text-white'],
|
||||
['secondary', 'text-white'],
|
||||
['warn', 'text-white'],
|
||||
@@ -50,7 +50,7 @@ describe('Badge', () => {
|
||||
['contrast', 'text-base-background']
|
||||
] as const)(
|
||||
'%s severity retains its text color class',
|
||||
([severity, expectedColor]) => {
|
||||
(severity, expectedColor) => {
|
||||
const classes = badgeVariants({ severity, variant: 'label' })
|
||||
expect(classes).toContain(expectedColor)
|
||||
expect(classes).toContain('text-3xs')
|
||||
|
||||
@@ -14,12 +14,10 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<FormSearchInput
|
||||
v-model="searchInput"
|
||||
:searcher="applySearchQuery"
|
||||
:debounce-ms="400"
|
||||
:debounce-max-wait-ms="4000"
|
||||
class="h-10 max-w-96 flex-1"
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
size="lg"
|
||||
class="max-w-96 flex-1"
|
||||
autofocus
|
||||
/>
|
||||
</template>
|
||||
@@ -412,7 +410,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import Tag from '@/components/chip/Tag.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
|
||||
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
|
||||
@@ -572,25 +570,6 @@ const {
|
||||
loadFuseOptions
|
||||
} = useTemplateFiltering(navigationFilteredTemplates)
|
||||
|
||||
/**
|
||||
* Raw search input bound to the search box. The actual `searchQuery` consumed
|
||||
* by the filtering composable is only updated via `applySearchQuery` after the
|
||||
* debounce settles, keeping Fuse/grid re-renders off the keystroke critical path.
|
||||
*/
|
||||
const searchInput = ref(searchQuery.value)
|
||||
|
||||
const applySearchQuery = async (query: string) => {
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the visible search input when `searchQuery` is reset externally
|
||||
* (e.g. via the "Clear Filters" button).
|
||||
*/
|
||||
watch(searchQuery, (value) => {
|
||||
if (value !== searchInput.value) searchInput.value = value
|
||||
})
|
||||
|
||||
/**
|
||||
* Coordinates state between the selected navigation item and the sort order to
|
||||
* create deterministic, predictable behavior.
|
||||
|
||||
@@ -251,10 +251,10 @@ describe('Load3DControls', () => {
|
||||
await user.click(screen.getByRole('button', { name: label }))
|
||||
}
|
||||
|
||||
it.for([
|
||||
it.each([
|
||||
['Model', 'model-controls'],
|
||||
['Camera', 'camera-controls']
|
||||
])('%s category renders only %s', async ([label, testId]) => {
|
||||
])('%s category renders only %s', async (label, testId) => {
|
||||
const { user } = renderControls()
|
||||
await selectCategory(user, label)
|
||||
|
||||
@@ -315,12 +315,12 @@ describe('Load3DControls', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.for([
|
||||
it.each([
|
||||
['Gizmo', 'gizmo-controls', 'canUseGizmo' as const],
|
||||
['Export', 'export-controls', 'canExport' as const]
|
||||
])(
|
||||
'hides the %s panel when its capability flips off at runtime',
|
||||
async ([label, testId, capabilityProp]) => {
|
||||
async (label, testId, capabilityProp) => {
|
||||
const { user, rerender } = renderControls()
|
||||
|
||||
await openMenu(user)
|
||||
|
||||
@@ -97,13 +97,13 @@ describe('GizmoControls', () => {
|
||||
expect(emitted().toggleGizmo).toEqual([[false]])
|
||||
})
|
||||
|
||||
it.for([
|
||||
it.each([
|
||||
['Translate', 'translate'],
|
||||
['Rotate', 'rotate'],
|
||||
['Scale', 'scale']
|
||||
] as const)(
|
||||
'sets mode to %s and emits setGizmoMode when clicked',
|
||||
async ([label, mode]) => {
|
||||
async (label, mode) => {
|
||||
const { user, gizmoConfig, emitted } = renderComponent({ enabled: true })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: label }))
|
||||
|
||||
@@ -107,7 +107,7 @@ describe('LightControls', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.for(['normal', 'wireframe'] as const)(
|
||||
it.each(['normal', 'wireframe'] as const)(
|
||||
'hides the intensity control when materialMode is %s',
|
||||
(mode) => {
|
||||
renderComponent({ materialMode: mode })
|
||||
|
||||
@@ -94,13 +94,13 @@ describe('ViewerGizmoControls', () => {
|
||||
expect(enabled.value).toBe(false)
|
||||
})
|
||||
|
||||
it.for([
|
||||
it.each([
|
||||
['Translate', 'translate'],
|
||||
['Rotate', 'rotate'],
|
||||
['Scale', 'scale']
|
||||
] as const)(
|
||||
'updates mode to %s when its toggle item is clicked',
|
||||
async ([label, expected]) => {
|
||||
async (label, expected) => {
|
||||
const { user, mode } = renderComponent({
|
||||
enabled: true,
|
||||
mode: 'translate'
|
||||
|
||||
@@ -111,29 +111,26 @@ describe('ImageLayerSettingsPanel', () => {
|
||||
})
|
||||
|
||||
describe('blend mode select', () => {
|
||||
it.for([
|
||||
it.each([
|
||||
['black', MaskBlendMode.Black],
|
||||
['white', MaskBlendMode.White],
|
||||
['negative', MaskBlendMode.Negative],
|
||||
['unknown-fallback', MaskBlendMode.Black]
|
||||
] as const)(
|
||||
'should map %s to MaskBlendMode.%s',
|
||||
async ([raw, expected]) => {
|
||||
const { container } = renderPanel()
|
||||
const select = container.querySelector('select') as HTMLSelectElement
|
||||
] as const)('should map %s to MaskBlendMode.%s', async (raw, expected) => {
|
||||
const { container } = renderPanel()
|
||||
const select = container.querySelector('select') as HTMLSelectElement
|
||||
|
||||
Object.defineProperty(select, 'value', {
|
||||
value: raw,
|
||||
configurable: true
|
||||
})
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
Object.defineProperty(select, 'value', {
|
||||
value: raw,
|
||||
configurable: true
|
||||
})
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(mockStore.maskBlendMode).toBe(expected)
|
||||
expect(mockUpdateMaskColor).toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
expect(mockStore.maskBlendMode).toBe(expected)
|
||||
expect(mockUpdateMaskColor).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('layer visibility checkboxes', () => {
|
||||
|
||||
@@ -63,13 +63,13 @@ describe('PointerZone', () => {
|
||||
})
|
||||
|
||||
describe('pointer event forwarding', () => {
|
||||
it.for([
|
||||
it.each([
|
||||
['pointerdown', 'handlePointerDown'],
|
||||
['pointermove', 'handlePointerMove'],
|
||||
['pointerup', 'handlePointerUp']
|
||||
] as const)(
|
||||
'should forward %s to toolManager.%s',
|
||||
async ([eventName, handlerName]) => {
|
||||
async (eventName, handlerName) => {
|
||||
renderZone()
|
||||
const zone = getZone()
|
||||
|
||||
@@ -103,13 +103,13 @@ describe('PointerZone', () => {
|
||||
})
|
||||
|
||||
describe('touch event forwarding', () => {
|
||||
it.for([
|
||||
it.each([
|
||||
['touchstart', 'handleTouchStart'],
|
||||
['touchmove', 'handleTouchMove'],
|
||||
['touchend', 'handleTouchEnd']
|
||||
] as const)(
|
||||
'should forward %s to panZoom.%s',
|
||||
async ([eventName, handlerName]) => {
|
||||
async (eventName, handlerName) => {
|
||||
renderZone()
|
||||
const zone = getZone()
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('ToolPanel', () => {
|
||||
})
|
||||
|
||||
describe('current tool highlight', () => {
|
||||
it.for([Tools.MaskPen, Tools.Eraser, Tools.PaintPen] as const)(
|
||||
it.each([Tools.MaskPen, Tools.Eraser, Tools.PaintPen] as const)(
|
||||
'should mark the %s button as selected when it is the current tool',
|
||||
(tool) => {
|
||||
mockStore.currentTool = tool
|
||||
|
||||
@@ -130,14 +130,14 @@ describe('TopBarHeader', () => {
|
||||
})
|
||||
|
||||
describe('canvas transform buttons', () => {
|
||||
it.for([
|
||||
it.each([
|
||||
['Rotate Left', 'rotateCounterclockwise'],
|
||||
['Rotate Right', 'rotateClockwise'],
|
||||
['Mirror Horizontal', 'mirrorHorizontal'],
|
||||
['Mirror Vertical', 'mirrorVertical']
|
||||
] as const)(
|
||||
'should call canvasTransform.%s when %s button is clicked',
|
||||
async ([label, method]) => {
|
||||
async (label, method) => {
|
||||
const user = userEvent.setup()
|
||||
renderHeader()
|
||||
|
||||
@@ -147,14 +147,14 @@ describe('TopBarHeader', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.for([
|
||||
it.each([
|
||||
['Rotate Left', 'rotateCounterclockwise', 'Rotate left failed:'],
|
||||
['Rotate Right', 'rotateClockwise', 'Rotate right failed:'],
|
||||
['Mirror Horizontal', 'mirrorHorizontal', 'Mirror horizontal failed:'],
|
||||
['Mirror Vertical', 'mirrorVertical', 'Mirror vertical failed:']
|
||||
] as const)(
|
||||
'should swallow and log errors from %s',
|
||||
async ([label, method, expectedMsg]) => {
|
||||
async (label, method, expectedMsg) => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
mockCanvasTransform[method].mockRejectedValueOnce(new Error('boom'))
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -55,7 +55,7 @@ describe(NodeSearchFilterBar, () => {
|
||||
const buttonTexts = () =>
|
||||
screen.getAllByRole('button').map((b) => b.textContent?.trim())
|
||||
|
||||
it.for([
|
||||
it.each([
|
||||
{ prop: 'hasFavorites', label: 'Bookmarked' },
|
||||
{ prop: 'hasBlueprintNodes', label: 'Blueprints' },
|
||||
{ prop: 'hasEssentialNodes', label: 'Essentials' },
|
||||
|
||||
@@ -68,7 +68,7 @@ const isOpen = ref(false)
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered',
|
||||
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-node-component-surface pr-2 outline-none hover:bg-component-node-widget-background-hovered',
|
||||
isOpen && 'border-node-stroke',
|
||||
$props.class
|
||||
)
|
||||
@@ -91,7 +91,7 @@ const isOpen = ref(false)
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
|
||||
class="flex flex-1 items-center justify-between pl-1 text-xs text-node-component-slot-text"
|
||||
>
|
||||
<template v-if="displayMode === 'hex'">
|
||||
<span>{{ displayHex }}</span>
|
||||
|
||||
@@ -21,11 +21,6 @@ import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNod
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { seedRequiredInputMissingNodeError } from '@/utils/__tests__/executionErrorTestUtils'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Connection error clearing via onConnectionsChange', () => {
|
||||
beforeEach(() => {
|
||||
@@ -352,90 +347,6 @@ describe('installErrorClearingHooks lifecycle', () => {
|
||||
installErrorClearingHooks(graph)
|
||||
expect(node.onConnectionsChange).toBe(chainedAfterFirst)
|
||||
})
|
||||
|
||||
it('scans added-node missing models after widget values are restored', async () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
node.type = 'CheckpointLoaderSimple'
|
||||
const widget = node.addWidget('combo', 'ckpt_name', '', () => undefined, {
|
||||
values: []
|
||||
})
|
||||
|
||||
graph.add(node)
|
||||
widget.value = 'fake_model.safetensors'
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(useMissingModelStore().missingModelCandidates).toEqual([
|
||||
expect.objectContaining({ name: 'fake_model.safetensors' })
|
||||
])
|
||||
})
|
||||
|
||||
it('scans added-node missing models before the deferred media scan', async () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
const modelScan = vi
|
||||
.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
.mockImplementation((_rootGraph, node) => [
|
||||
{
|
||||
nodeId: String(node.id),
|
||||
nodeType: node.type,
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'fake_model.safetensors',
|
||||
directory: 'checkpoints',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate
|
||||
])
|
||||
const mediaScan = vi
|
||||
.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
|
||||
.mockReturnValue([])
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('CheckpointLoaderSimple')
|
||||
node.type = 'CheckpointLoaderSimple'
|
||||
graph.add(node)
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(modelScan).toHaveBeenCalledOnce()
|
||||
expect(useMissingModelStore().missingModelCandidates).toEqual([
|
||||
expect.objectContaining({ name: 'fake_model.safetensors' })
|
||||
])
|
||||
expect(mediaScan).not.toHaveBeenCalled()
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mediaScan).toHaveBeenCalledTimes(1)
|
||||
expect(modelScan.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mediaScan.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('does not surface added-node missing media when upload state is marked between deferred scans', async () => {
|
||||
const graph = new LGraph()
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
vi.spyOn(missingModelScan, 'scanNodeModelCandidates').mockReturnValue([])
|
||||
const mediaScan = vi.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const node = new LGraphNode('LoadVideo')
|
||||
node.type = 'LoadVideo'
|
||||
node.addWidget('combo', 'file', 'uploading.mp4', () => undefined, {
|
||||
values: []
|
||||
})
|
||||
|
||||
graph.add(node)
|
||||
await Promise.resolve()
|
||||
node.isUploading = true
|
||||
await Promise.resolve()
|
||||
|
||||
expect(useMissingMediaStore().missingMediaCandidates).toBeNull()
|
||||
expect(mediaScan).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('onNodeRemoved clears missing asset errors by execution ID', () => {
|
||||
@@ -700,6 +611,7 @@ describe('realtime scan verifies pending cloud candidates', () => {
|
||||
|
||||
describe('realtime verification staleness guards', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
||||
})
|
||||
@@ -859,6 +771,7 @@ describe('realtime verification staleness guards', () => {
|
||||
|
||||
describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(false)
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user