diff --git a/.claude/skills/add-model-page/SKILL.md b/.claude/skills/add-model-page/SKILL.md new file mode 100644 index 0000000000..0b7fe4b81e --- /dev/null +++ b/.claude/skills/add-model-page/SKILL.md @@ -0,0 +1,173 @@ +--- +name: add-model-page +description: 'add, update, or remove a model page entry on the comfy org website. creates a PR to Comfy-Org/ComfyUI_frontend apps/website folder with the change and posts a Vercel preview link back to Slack.' +--- + +# add-model-page + +add, update, or remove model pages in the ComfyUI website. + +## Trigger phrases + +- `Add a model page for ` +- `Update the model page for ` +- `Remove 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 | diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 20af82bec6..35c5d31183 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -19,15 +19,26 @@ reviews: - name: End-to-end regression coverage for fixes mode: error instructions: | - Use only PR metadata already available in the review context: the PR title, commit subjects in this PR, the files changed in this PR relative to the PR base (equivalent to `base...head`), and the PR description. - Do not rely on shell commands. Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing. + Use only PR metadata already available in the review context: + - the PR title + - commit subjects in this PR + - The files changed in this PR relative to the PR base (equivalent to `base...head`) + - the PR description. + Do not rely on shell commands. + Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. + If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing. - Pass if at least one of the following is true: - 1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`. - 2. The PR changes at least one file under `browser_tests/`. - 3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added. + Fail if all of the following are true: + 1. The PR title and/or any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`. + 2. The PR changes files under `src/` or `packages/` related to the main frontend application but the PR does not change at least one file under `browser_tests/`. + 3. The PR description lacks a concrete explanation of why an end-to-end regression test was not added. + + Do not fail if the changes are exclusively in `apps/website`, just documentation changes, or changes related to CI processes. + The goal is to make sure that fixes include End-to-End regression tests. Do not insist on tests when the PR is not fixing a bug. + + Pass otherwise. + When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical. - Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical. - name: ADR compliance for entity/litegraph changes mode: warning instructions: | diff --git a/.github/workflows/model-page-discovery.yaml b/.github/workflows/model-page-discovery.yaml new file mode 100644 index 0000000000..457c7546aa --- /dev/null +++ b/.github/workflows/model-page-discovery.yaml @@ -0,0 +1,123 @@ +name: Model Page Discovery + +on: + schedule: + - cron: '0 9 * * 1' + workflow_dispatch: + +jobs: + discover: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + + steps: + - name: Fetch model labels from hub API + id: hub + shell: bash + run: | + set -euo pipefail + curl -fsSL 'https://comfy.org/api/hub/labels?type=model' -o hub-labels.json + echo "Fetched $(jq '.labels | length' hub-labels.json) model labels from hub" + + - name: Checkout ComfyUI_frontend + uses: actions/checkout@v6 + with: + sparse-checkout: apps/website/src/config/generated-models.json + + - name: Compare against existing models + id: compare + shell: bash + run: | + set -euo pipefail + + HUB_SLUGS=$(jq -r '[.labels[].name]' hub-labels.json) + + EXISTING_SLUGS=$(node -e " + const fs = require('fs'); + const models = JSON.parse( + fs.readFileSync( + 'apps/website/src/config/generated-models.json', + 'utf8' + ) + ); + console.log(JSON.stringify(models.map(m => m.slug))); + " 2>/dev/null || echo '[]') + + ADDED_SLUGS=$(node -e " + const hub = $HUB_SLUGS; + const existing = new Set($EXISTING_SLUGS); + console.log(JSON.stringify(hub.filter(s => !existing.has(s)))); + ") + + COUNT=$(node -e "console.log($ADDED_SLUGS.length)") + echo "new_count=$COUNT" >> \$GITHUB_OUTPUT + echo "new_slugs=$ADDED_SLUGS" >> \$GITHUB_OUTPUT + + if [ "\$COUNT" -eq 0 ]; then + echo "No new models found." + else + echo "Found \$COUNT new model(s)" + fi + + - name: Check for existing open discovery issue + id: existing_issue + if: steps.compare.outputs.new_count != '0' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + COUNT=$(gh issue list \ + --repo "$GITHUB_REPOSITORY" \ + --state open \ + --search 'in:title "New models detected"' \ + --json number \ + --jq 'length') + echo "open_count=$COUNT" >> $GITHUB_OUTPUT + + - name: Open GitHub issue for new models + if: | + steps.compare.outputs.new_count != '0' && + steps.existing_issue.outputs.open_count == '0' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEW_SLUGS: ${{ steps.compare.outputs.new_slugs }} + NEW_COUNT: ${{ steps.compare.outputs.new_count }} + shell: bash + run: | + SLUG_LIST=$(node -e " + const slugs = $NEW_SLUGS; + console.log(slugs.map(s => '- \`' + s + '\`').join('\n')); + ") + + gh issue create \ + --repo "$GITHUB_REPOSITORY" \ + --title "New models detected — add to model pages" \ + --body "## $NEW_COUNT new model(s) found in hub + + The weekly model discovery scan found model labels on the hub not yet in + \`apps/website/src/config/generated-models.json\`. + + ### New slugs ($NEW_COUNT) + + $SLUG_LIST + + ### Next steps + + 1. Review which of these warrant an SEO model page + 2. For local models: run \`SKIP_THUMBNAILS=1 pnpm generate:models\` and commit the result + 3. For partner/API models: add to \`API_PROVIDER_MAP\` in \`generate-models.ts\`, regenerate, commit + + --- + *Generated by the [model-page-discovery workflow](https://github.com/$GITHUB_REPOSITORY/actions/workflows/model-page-discovery.yaml)*" + + - name: Skip — open issue already exists + if: | + steps.compare.outputs.new_count != '0' && + steps.existing_issue.outputs.open_count != '0' + run: echo "An open discovery issue already exists — skipping creation." + + - name: No new models found + if: steps.compare.outputs.new_count == '0' + run: echo "No new models found — nothing to do." diff --git a/.oxlintrc.json b/.oxlintrc.json index 19700f80c2..66142ab5ca 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -85,6 +85,15 @@ "typescript/no-unused-vars": "off", "unicorn/no-empty-file": "off", "vitest/require-mock-type-parameters": "off", + "vitest/consistent-each-for": [ + "error", + { + "test": "for", + "it": "for", + "describe": "for", + "suite": "for" + } + ], "unicorn/no-new-array": "off", "unicorn/no-single-promise-in-promise-methods": "off", "unicorn/no-useless-fallback-in-spread": "off", diff --git a/apps/desktop-ui/src/i18n.ts b/apps/desktop-ui/src/i18n.ts index 52db23e091..fa3367286d 100644 --- a/apps/desktop-ui/src/i18n.ts +++ b/apps/desktop-ui/src/i18n.ts @@ -9,6 +9,7 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' } import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' } import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' } +import { getDefaultLocale } from '@frontend-locales/localeConfig' import { createI18n } from 'vue-i18n' function buildLocale< @@ -167,7 +168,7 @@ const messages: Record = { export const i18n = createI18n({ // Must set `false`, as Vue I18n Legacy API is for Vue 2 legacy: false, - locale: navigator.language.split('-')[0] || 'en', + locale: getDefaultLocale(), fallbackLocale: 'en', messages, // Ignore warnings for locale options as each option is in its own language. diff --git a/apps/website/README.md b/apps/website/README.md index 047940ed02..94468614c4 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -113,6 +113,12 @@ 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: @@ -146,3 +152,4 @@ renders the documented embed container. - `pnpm test:unit` — Vitest unit tests - `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first) - `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot +- `pnpm cloud-nodes:refresh-snapshot` — refresh the committed cloud nodes snapshot diff --git a/apps/website/e2e/careers.spec.ts b/apps/website/e2e/careers.spec.ts index 6f97c99f86..22263c1bfc 100644 --- a/apps/website/e2e/careers.spec.ts +++ b/apps/website/e2e/careers.spec.ts @@ -23,26 +23,50 @@ test.describe('Careers page @smoke', () => { expect(await roles.count()).toBeGreaterThan(0) }) - test('each role links to jobs.ashbyhq.com', async ({ page }) => { + test('clicking a department button scrolls to and activates that section', async ({ + page + }) => { + const rolesSection = page.getByTestId('careers-roles') + await rolesSection.scrollIntoViewIfNeeded() + await expect(rolesSection).toBeVisible() + + const allCount = await page.getByTestId('careers-role-link').count() + + const engineeringButton = page.getByRole('button', { + name: 'ENGINEERING', + exact: true + }) + + // RolesSection is hydrated via `client:visible`. Once the button responds + // to a click by flipping aria-pressed, Vue is hydrated and the rest of + // the locator logic is in effect. + await expect(async () => { + await engineeringButton.click() + await expect(engineeringButton).toHaveAttribute('aria-pressed', 'true', { + timeout: 1_000 + }) + }).toPass({ timeout: 10_000 }) + + const engineeringSection = page.locator('#careers-dept-engineering') + await expect(engineeringSection).toBeInViewport() + + expect(await page.getByTestId('careers-role-link').count()).toBe(allCount) + }) +}) + +test.describe('Careers page role links', () => { + test('each role links to the Ashby job description page, not the application form', async ({ + page + }) => { + await page.goto('/careers') const roles = page.getByTestId('careers-role-link') const count = await roles.count() for (let i = 0; i < count; i++) { const href = await roles.nth(i).getAttribute('href') expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//) + expect(href).not.toMatch(/\/application\/?$/) } }) - - test('ENGINEERING category filter narrows the role list', async ({ - page - }) => { - const allCount = await page.getByTestId('careers-role-link').count() - await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click() - const engineeringLocator = page.getByTestId('careers-role-link') - await expect(engineeringLocator.first()).toBeVisible() - const engineeringCount = await engineeringLocator.count() - expect(engineeringCount).toBeLessThanOrEqual(allCount) - expect(engineeringCount).toBeGreaterThan(0) - }) }) test.describe('Careers page (zh-CN) @smoke', () => { diff --git a/apps/website/e2e/cloud-nodes.spec.ts b/apps/website/e2e/cloud-nodes.spec.ts new file mode 100644 index 0000000000..9f99bf453b --- /dev/null +++ b/apps/website/e2e/cloud-nodes.spec.ts @@ -0,0 +1,169 @@ +import { expect } from '@playwright/test' + +import { test } from './fixtures/blockExternalMedia' + +test.describe('Cloud nodes page @smoke', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/cloud/supported-nodes') + }) + + test('has correct title', async ({ page }) => { + await expect(page).toHaveTitle( + 'Custom-node packs on Comfy Cloud — supported by default' + ) + }) + + test('renders at least one pack card', async ({ page }) => { + const cards = page.getByTestId('cloud-node-pack-card') + await expect(cards.first()).toBeVisible() + expect(await cards.count()).toBeGreaterThan(0) + }) + + test('search input filters cards down', async ({ page }) => { + const cards = page.getByTestId('cloud-node-pack-card') + const initialCount = await cards.count() + + await page.getByTestId('cloud-nodes-search').fill('impact') + const filteredCards = page.getByTestId('cloud-node-pack-card') + await expect(filteredCards.first()).toBeVisible() + const filteredCount = await filteredCards.count() + + expect(filteredCount).toBeGreaterThan(0) + expect(filteredCount).toBeLessThanOrEqual(initialCount) + }) + + test('renders banner image or fallback element', async ({ page }) => { + const banners = page.getByTestId('cloud-node-pack-banner') + await expect(banners.first()).toBeVisible() + }) + + test('opens pack detail page from first card', async ({ page }) => { + const firstCard = page.getByTestId('cloud-node-pack-card').first() + await expect(firstCard).toBeVisible() + + await firstCard.locator('a').first().click() + + await expect(page).toHaveURL(/\/cloud\/supported-nodes\/[a-z0-9-]+$/) + await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible() + }) + + test('direct pack detail route renders node entries', async ({ page }) => { + await page.goto('/cloud/supported-nodes/comfyui-impact-pack') + await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible() + await expect( + page.getByTestId('cloud-node-pack-detail-node').first() + ).toBeVisible() + }) + + test('search with no matches shows empty state', async ({ page }) => { + await page + .getByTestId('cloud-nodes-search') + .fill('zzzz-this-pack-does-not-exist') + await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(0) + await expect(page.getByText('No matching packs')).toBeVisible() + }) + + test('clearing search restores the full list', async ({ page }) => { + const cards = page.getByTestId('cloud-node-pack-card') + const initialCount = await cards.count() + + await page.getByTestId('cloud-nodes-search').fill('impact') + await expect(cards.first()).toBeVisible() + + await page.getByTestId('cloud-nodes-search').fill('') + await expect(cards).toHaveCount(initialCount) + }) + + test('search matches against node display names, not just pack names', async ({ + page + }) => { + await page.getByTestId('cloud-nodes-search').fill('FaceDetailer') + await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(1) + await expect( + page.getByTestId('cloud-node-pack-card-link').first() + ).toContainText('Impact Pack') + }) + + test('switching sort to A → Z reorders cards alphabetically', async ({ + page + }) => { + await page.locator('#cloud-nodes-sort').selectOption('az') + const firstName = await page + .getByTestId('cloud-node-pack-card-link') + .first() + .textContent() + expect(firstName?.trim().toLowerCase().charAt(0)).toMatch(/^[a-c]/) + }) + + test('list grid carries a localized aria-label', async ({ page }) => { + await expect( + page.getByRole('list', { + name: 'Custom-node packs supported on Comfy Cloud' + }) + ).toBeVisible() + }) + + test('clicking the back link returns to the index from a detail page', async ({ + page + }) => { + await page.goto('/cloud/supported-nodes/comfyui-impact-pack') + await page.getByRole('link', { name: 'Back to all packs' }).click() + await expect(page).toHaveURL(/\/cloud\/supported-nodes\/?$/) + await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible() + }) + + test('detail page renders publisher and external repo link', async ({ + page + }) => { + await page.goto('/cloud/supported-nodes/comfyui-impact-pack') + const repoLink = page.getByRole('link', { + name: /github\.com\/ltdrdata\/ComfyUI-Impact-Pack/ + }) + await expect(repoLink).toBeVisible() + await expect(repoLink).toHaveAttribute('rel', /noopener/) + }) + + test('unknown pack slug 404s', async ({ page }) => { + const response = await page.goto( + '/cloud/supported-nodes/this-pack-does-not-exist' + ) + expect(response?.status()).toBe(404) + }) + + test('JSON-LD ItemList is emitted on the index page', async ({ page }) => { + const jsonLd = page.locator('script[type="application/ld+json"]') + const ldBlocks = await jsonLd.allTextContents() + expect(ldBlocks.some((b) => b.includes('"@type":"ItemList"'))).toBeTruthy() + }) + + test('JSON-LD payload escapes <-sequences', async ({ page }) => { + const ldBlocks = await page + .locator('script[type="application/ld+json"]') + .allTextContents() + for (const block of ldBlocks) { + expect(block).not.toContain(' { + 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() + }) +}) diff --git a/apps/website/e2e/content-section.spec.ts b/apps/website/e2e/content-section.spec.ts new file mode 100644 index 0000000000..6d1a6d9265 --- /dev/null +++ b/apps/website/e2e/content-section.spec.ts @@ -0,0 +1,61 @@ +import { expect } from '@playwright/test' + +import { test } from './fixtures/blockExternalMedia' + +const M4_PRO_14_INCH_VIEWPORT = { width: 2016, height: 1310 } +const LAST_SECTION_HASH = '#contact' + +test.describe( + 'ContentSection scroll-spy @smoke', + { + annotation: [ + { + type: 'issue', + description: + 'https://linear.app/comfyorg/issue/FE-604/bug-bottom-badge-not-activating-on-scroll-at-high-resolution-3024x1964' + }, + { + type: 'environment', + description: + '14" MacBook M4 Pro logical viewport reported in FE-604; /privacy-policy reproduces because of its short trailing sections' + } + ] + }, + () => { + test.use({ viewport: M4_PRO_14_INCH_VIEWPORT }) + + test('activates the last badge when user scrolls to the bottom', async ({ + page + }) => { + await page.goto('/privacy-policy') + + const sidebarNav = page.getByRole('navigation', { + name: 'Category filter' + }) + const badges = sidebarNav.getByRole('button') + const lastBadge = badges.last() + + await expect(badges.first()).toHaveAttribute('aria-pressed', 'true') + await expect(lastBadge).toHaveAttribute('aria-pressed', 'false') + + await page.evaluate(() => + window.scrollTo(0, document.documentElement.scrollHeight) + ) + + await expect(lastBadge).toHaveAttribute('aria-pressed', 'true') + }) + + test('activates the last badge when page mounts already at the bottom via trailing hash', async ({ + page + }) => { + await page.goto(`/privacy-policy${LAST_SECTION_HASH}`) + + const sidebarNav = page.getByRole('navigation', { + name: 'Category filter' + }) + const lastBadge = sidebarNav.getByRole('button').last() + + await expect(lastBadge).toHaveAttribute('aria-pressed', 'true') + }) + } +) diff --git a/apps/website/e2e/demos.spec.ts b/apps/website/e2e/demos.spec.ts index edd97f5daf..bbf333e3e6 100644 --- a/apps/website/e2e/demos.spec.ts +++ b/apps/website/e2e/demos.spec.ts @@ -1,27 +1,71 @@ import { expect, test } from '@playwright/test' +import { demos, getNextDemo } from '../src/config/demos' +import { t } from '../src/i18n/translations' + +const escapeRegExp = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + test.describe('Demo pages @smoke', () => { - test('demo detail page renders hero and embed', async ({ page }) => { - await page.goto('/demos/image-to-video') - await expect(page.getByRole('heading', { level: 1 })).toBeVisible() - await expect(page.getByRole('heading', { level: 1 })).toContainText( - 'Create a Video from an Image' - ) - const iframe = page.locator('iframe[title*="Interactive demo"]') - await expect(iframe).toBeAttached() - }) + for (const demo of demos) { + const nextDemo = getNextDemo(demo.slug) - test('demo detail page has transcript section', async ({ page }) => { - await page.goto('/demos/image-to-video') - await expect( - page.getByRole('button', { name: /demo transcript/i }) - ).toBeVisible() - }) + test(`/demos/${demo.slug} renders hero, embed, transcript, and next-demo nav`, async ({ + page + }) => { + await page.goto(`/demos/${demo.slug}`) - test('demo detail page has next demo navigation', async ({ page }) => { - await page.goto('/demos/image-to-video') - await expect(page.getByText(/what's next/i)).toBeVisible() - }) + const heading = page.getByRole('heading', { level: 1 }) + await expect(heading).toBeVisible() + await expect(heading).toContainText(t(demo.title, 'en')) + + const ogImage = page.locator('head meta[property="og:image"]') + await expect(ogImage).toHaveAttribute( + 'content', + new RegExp(`${escapeRegExp(demo.slug)}-og\\.png`) + ) + + const iframe = page.locator( + `iframe[title*="${t('demos.embed.label', 'en')}"]` + ) + await expect(iframe).toBeAttached() + await expect(iframe).toHaveAttribute( + 'src', + new RegExp(escapeRegExp(demo.arcadeId)) + ) + + await expect( + page.getByRole('button', { name: /demo transcript/i }) + ).toBeVisible() + + await expect( + page.getByText(t(nextDemo.title, 'en')).first() + ).toBeVisible() + const nextThumb = page.locator(`img[src="${nextDemo.thumbnail}"]`).first() + await expect(nextThumb).toBeAttached() + await expect(nextThumb).toBeVisible() + const naturalWidth = await nextThumb.evaluate( + (img) => (img as HTMLImageElement).naturalWidth + ) + expect(naturalWidth).toBeGreaterThan(1) + }) + + test(`/zh-CN/demos/${demo.slug} renders localized content`, async ({ + page + }) => { + await page.goto(`/zh-CN/demos/${demo.slug}`) + + await expect(page).toHaveURL(/\/zh-CN\/demos\//) + + const heading = page.getByRole('heading', { level: 1 }) + await expect(heading).toContainText(t(demo.title, 'zh-CN')) + await expect(heading).toContainText(/[\u4E00-\u9FFF]/) + + await expect( + page.getByText(t(nextDemo.title, 'zh-CN')).first() + ).toBeVisible() + }) + } test('demo library page renders', async ({ page }) => { await page.goto('/demos') @@ -32,13 +76,4 @@ test.describe('Demo pages @smoke', () => { const response = await page.goto('/demos/nonexistent') expect(response?.status()).toBe(404) }) - - test('zh-CN demo page renders localized content', async ({ page }) => { - await page.goto('/zh-CN/demos/image-to-video') - await expect(page.getByRole('heading', { level: 1 })).toContainText( - '从图片创建视频' - ) - const nextDemoLink = page.locator('a[href*="/zh-CN/demos/"]').first() - await expect(nextDemoLink).toBeAttached() - }) }) diff --git a/apps/website/e2e/pricing.spec.ts b/apps/website/e2e/pricing.spec.ts new file mode 100644 index 0000000000..05520f0e5b --- /dev/null +++ b/apps/website/e2e/pricing.spec.ts @@ -0,0 +1,56 @@ +import { expect } from '@playwright/test' + +import { test } from './fixtures/blockExternalMedia' + +test.describe('Pricing page @smoke', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/cloud/pricing') + }) + + test('shows the three paid tiers and Enterprise', async ({ page }) => { + const pricingGrid = page + .locator('section', { + has: page.getByRole('heading', { name: /Pricing/i }) + }) + .locator('.lg\\:grid') + + for (const label of ['STANDARD', 'CREATOR', 'PRO']) { + await expect( + pricingGrid.locator('span', { hasText: new RegExp(`^${label}$`) }) + ).toBeVisible() + } + + await expect( + page.getByRole('heading', { name: /Looking for Enterprise Solutions/i }) + ).toBeVisible() + }) + + test('does not show the Free tier when SHOW_FREE_TIER is disabled', async ({ + page + }) => { + const pricingGrid = page + .locator('section', { + has: page.getByRole('heading', { name: /Pricing/i }) + }) + .locator('.lg\\:grid') + + await expect( + pricingGrid.locator('span', { hasText: /^FREE$/ }) + ).toHaveCount(0) + await expect(page.getByRole('link', { name: /^START FREE$/ })).toHaveCount( + 0 + ) + await expect(page.getByText(/Everything in Free, plus:/i)).toHaveCount(0) + }) +}) + +test.describe('Cloud pricing teaser @smoke', () => { + test('does not show the "Start free" tagline when SHOW_FREE_TIER is disabled', async ({ + page + }) => { + await page.goto('/cloud') + await expect( + page.getByText(/Start free\.\s*Upgrade when you're ready\./i) + ).toHaveCount(0) + }) +}) diff --git a/apps/website/e2e/responsive.spec.ts b/apps/website/e2e/responsive.spec.ts index c77e8e4ec7..6488170457 100644 --- a/apps/website/e2e/responsive.spec.ts +++ b/apps/website/e2e/responsive.spec.ts @@ -1,3 +1,4 @@ +import type { Page } from '@playwright/test' import { expect } from '@playwright/test' import { test } from './fixtures/blockExternalMedia' @@ -47,4 +48,105 @@ test.describe('Mobile layout @mobile', () => { const mobileContainer = page.getByTestId('social-proof-mobile') await expect(mobileContainer).toBeVisible() }) + + test.describe('SocialProofBar seamless marquee', () => { + test.use({ contextOptions: { reducedMotion: 'no-preference' } }) + + test('mobile forward marquee loops seamlessly', async ({ page }) => { + const geometry = await measureMarqueeLoopGeometry( + page, + '[data-testid="social-proof-mobile"] .animate-marquee' + ) + expectSeamlessForwardLoop(geometry) + }) + + test('mobile reverse marquee loops seamlessly', async ({ page }) => { + const geometry = await measureMarqueeLoopGeometry( + page, + '[data-testid="social-proof-mobile"] .animate-marquee-reverse' + ) + expectSeamlessReverseLoop(geometry) + }) + }) }) + +test.describe('Desktop SocialProofBar @smoke', () => { + test.use({ contextOptions: { reducedMotion: 'no-preference' } }) + + test.beforeEach(async ({ page }) => { + await page.goto('/') + }) + + test('desktop marquee loops seamlessly', async ({ page }) => { + const geometry = await measureMarqueeLoopGeometry( + page, + '[data-testid="social-proof-desktop"] .animate-marquee' + ) + expectSeamlessForwardLoop(geometry) + }) +}) + +type MarqueeGeometry = { + copyWidths: number[] + startPositions: number[] + endPositions: number[] +} + +async function measureMarqueeLoopGeometry( + page: Page, + selector: string +): Promise { + await page.locator(selector).first().waitFor() + return page.evaluate((sel) => { + const tracks = Array.from( + document.querySelectorAll(sel) + ).slice(0, 2) + const firstAnimation = tracks[0]?.getAnimations()[0] + if (!firstAnimation) { + throw new Error(`No CSS animation found on ${sel}`) + } + const duration = firstAnimation.effect?.getTiming().duration + if (typeof duration !== 'number' || duration <= 1) { + throw new Error( + `Animation on ${sel} has unusable duration: ${String(duration)}` + ) + } + const setAllTimes = (time: number) => { + for (const track of tracks) { + for (const anim of track.getAnimations()) { + anim.currentTime = time + } + } + void document.body.offsetWidth + } + const readX = () => tracks.map((track) => track.getBoundingClientRect().x) + setAllTimes(0) + const startPositions = readX() + const copyWidths = tracks.map( + (track) => track.getBoundingClientRect().width + ) + setAllTimes(duration - 0.1) + const endPositions = readX() + return { copyWidths, startPositions, endPositions } + }, selector) +} + +function expectTwoMatchingCopies(geometry: MarqueeGeometry) { + const { copyWidths } = geometry + expect(copyWidths.length, 'expected two duplicate marquee tracks').toBe(2) + expect(copyWidths[0]).toBeGreaterThan(0) + expect(copyWidths[1]).toBeCloseTo(copyWidths[0], 0) +} + +function expectSeamlessForwardLoop(geometry: MarqueeGeometry) { + expectTwoMatchingCopies(geometry) + // Copy 2 ends the cycle exactly where copy 1 started, so the restart + // (when copy 1 jumps back to its start position) is visually indistinguishable. + expect(geometry.endPositions[1]).toBeCloseTo(geometry.startPositions[0], 0) +} + +function expectSeamlessReverseLoop(geometry: MarqueeGeometry) { + expectTwoMatchingCopies(geometry) + // Reverse marquee: copy 1 ends the cycle where copy 2 started. + expect(geometry.endPositions[0]).toBeCloseTo(geometry.startPositions[1], 0) +} diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-1-sm-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-1-sm-visual-linux.png index 20d8be7838..13f924097d 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-1-sm-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-1-sm-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-2-md-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-2-md-visual-linux.png index 718fb32ef7..70b26c7ca3 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-2-md-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-2-md-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-3-lg-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-3-lg-visual-linux.png index dcec9b89a8..d5129a7fb1 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-3-lg-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-3-lg-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-4-xl-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-4-xl-visual-linux.png index ba920f716c..d6f83d0fa2 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-4-xl-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-4-xl-visual-linux.png differ diff --git a/apps/website/package.json b/apps/website/package.json index 4146bf0ec8..8a34eca2cc 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -15,11 +15,15 @@ "test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test", "test:visual": "playwright test --project visual", "test:visual:update": "playwright test --project visual --update-snapshots", - "ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts" + "ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts", + "cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts", + "generate:models": "tsx ./scripts/generate-models.ts" }, "dependencies": { "@astrojs/sitemap": "catalog:", "@comfyorg/design-system": "workspace:*", + "@comfyorg/object-info-parser": "workspace:*", + "@comfyorg/shared-frontend-utils": "workspace:*", "@comfyorg/tailwind-utils": "workspace:*", "@vercel/analytics": "catalog:", "@vueuse/core": "catalog:", diff --git a/apps/website/public/assets/images/fallback-gradient-avatar.svg b/apps/website/public/assets/images/fallback-gradient-avatar.svg new file mode 100644 index 0000000000..90b860f619 --- /dev/null +++ b/apps/website/public/assets/images/fallback-gradient-avatar.svg @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apps/website/public/images/demos/community-workflows-og.png b/apps/website/public/images/demos/community-workflows-og.png new file mode 100644 index 0000000000..7e46105c88 Binary files /dev/null and b/apps/website/public/images/demos/community-workflows-og.png differ diff --git a/apps/website/public/images/demos/community-workflows-thumb.webp b/apps/website/public/images/demos/community-workflows-thumb.webp new file mode 100644 index 0000000000..0e2427c14c Binary files /dev/null and b/apps/website/public/images/demos/community-workflows-thumb.webp differ diff --git a/apps/website/public/images/demos/image-to-video-og.png b/apps/website/public/images/demos/image-to-video-og.png index 329dfe2e68..144ca94c41 100644 Binary files a/apps/website/public/images/demos/image-to-video-og.png and b/apps/website/public/images/demos/image-to-video-og.png differ diff --git a/apps/website/public/images/demos/image-to-video-thumb.webp b/apps/website/public/images/demos/image-to-video-thumb.webp index 329dfe2e68..24119e7b64 100644 Binary files a/apps/website/public/images/demos/image-to-video-thumb.webp and b/apps/website/public/images/demos/image-to-video-thumb.webp differ diff --git a/apps/website/public/images/demos/workflow-templates-og.png b/apps/website/public/images/demos/workflow-templates-og.png index 329dfe2e68..f13a49c448 100644 Binary files a/apps/website/public/images/demos/workflow-templates-og.png and b/apps/website/public/images/demos/workflow-templates-og.png differ diff --git a/apps/website/public/images/demos/workflow-templates-thumb.webp b/apps/website/public/images/demos/workflow-templates-thumb.webp index 329dfe2e68..7c2e78ab4a 100644 Binary files a/apps/website/public/images/demos/workflow-templates-thumb.webp and b/apps/website/public/images/demos/workflow-templates-thumb.webp differ diff --git a/apps/website/scripts/generate-models.ts b/apps/website/scripts/generate-models.ts new file mode 100644 index 0000000000..a546f608be --- /dev/null +++ b/apps/website/scripts/generate-models.ts @@ -0,0 +1,396 @@ +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const WORKFLOW_TEMPLATES_BASE = + 'https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates' + +const TEMPLATES_DIR = fileURLToPath( + new URL('../../../../workflow_templates/templates', import.meta.url) +) + +const QUANT_SUFFIXES = [ + '_fp8_e4m3fn_scaled', + '_fp8_e4m3fn', + '_fp8_scaled', + '_fp4_mixed', + '_fp8mixed', + '_fp8', + '_fp16', + '_fp4', + '_bf16', + '_int8' +] + +interface RawModel { + name: string + url: string + directory: string +} + +interface ModelData { + url: string + directory: string + templates: Set + 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 = { + 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 +): 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 + + 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 + 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() + 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 { + const map = new Map() + 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() + 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)['templates'] + if (!Array.isArray(templates)) continue + for (const t of templates) { + if (typeof t !== 'object' || t === null) continue + const entry = t as Record + 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 + 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 + 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() + + 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>() + 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() + 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) +} diff --git a/apps/website/scripts/refresh-cloud-nodes-snapshot.ts b/apps/website/scripts/refresh-cloud-nodes-snapshot.ts new file mode 100644 index 0000000000..4817bb2168 --- /dev/null +++ b/apps/website/scripts/refresh-cloud-nodes-snapshot.ts @@ -0,0 +1,32 @@ +import { renameSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +import { fetchCloudNodesForBuild } from '../src/utils/cloudNodes' + +const snapshotPath = fileURLToPath( + new URL('../src/data/cloud-nodes.snapshot.json', import.meta.url) +) +const tempPath = `${snapshotPath}.tmp` + +const outcome = await fetchCloudNodesForBuild() + +if (outcome.status !== 'fresh') { + const reason = 'reason' in outcome ? outcome.reason : '(none)' + console.error( + `Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}` + ) + process.exit(1) +} + +const serialized = JSON.stringify(outcome.snapshot, null, 2) + '\n' + +writeFileSync(tempPath, serialized, 'utf8') +renameSync(tempPath, snapshotPath) + +const totalNodes = outcome.snapshot.packs.reduce( + (n, pack) => n + pack.nodes.length, + 0 +) +process.stdout.write( + `Wrote snapshot with ${outcome.snapshot.packs.length} pack(s) and ${totalNodes} node(s) to ${snapshotPath}\n` +) diff --git a/apps/website/src/components/careers/RolesSection.vue b/apps/website/src/components/careers/RolesSection.vue index db6053a3e1..f57a400f02 100644 --- a/apps/website/src/components/careers/RolesSection.vue +++ b/apps/website/src/components/careers/RolesSection.vue @@ -1,10 +1,13 @@