Compare commits
11 Commits
glary/raf-
...
glary/use-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1988cd49b | ||
|
|
0178003156 | ||
|
|
f9899fccaa | ||
|
|
027d21817d | ||
|
|
5e632ed274 | ||
|
|
258af63618 | ||
|
|
e57b6a52aa | ||
|
|
142c372823 | ||
|
|
e1dca0bcb2 | ||
|
|
82935499d8 | ||
|
|
9ef78eea0c |
@@ -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 |
|
||||
@@ -9,18 +9,13 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Discover** — Collect candidates from Slack bot + git log gap, then **reconcile both lists** (`reference/discovery.md`)
|
||||
2. **Pre-filter by path** — Auto-skip PRs whose changed files are entirely under `apps/website/`, `browser_tests/`, `.github/`, `packages/design-system/`, `packages/{cloud,registry}-types/`, `.claude/`, `docs/`. Don't read PR bodies for these — they don't ship to core ComfyUI users (`reference/analysis.md`)
|
||||
3. **Verify target file existence** — For each surviving candidate, run `git cat-file -e origin/$TARGET:$path` for primary changed files. If they don't exist on the target, auto-mark SKIP with reason `feature-not-on-branch`
|
||||
4. **Tiered triage** — Bucket into **Tier 1 (core editor must-haves)**, **Tier 2 (cloud-distribution only)**, **Tier 3 (skip)** before reviewing individually (`reference/analysis.md`)
|
||||
5. **Analyze** — Categorize remaining MUST/SHOULD, check deps (`reference/analysis.md`)
|
||||
6. **Human Review** — Present candidates in batches for interactive approval, with tier context attached (see Interactive Approval Flow)
|
||||
7. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
|
||||
8. **Test-then-resolve dry-run** — Classify clean vs conflict before committing time (`reference/execution.md`)
|
||||
9. **Execute** — Label-driven automation for clean PRs → worktree fallback for conflicts (`reference/execution.md`)
|
||||
10. **Public-API conflict review** — If conflict resolution touches a public LiteGraph callback, extension API, or `node.*` method, consult oracle for compat-regression review BEFORE pushing (`reference/execution.md`)
|
||||
11. **Verify** — Per-PR validation (typecheck + targeted tests + lint on changed files) AND per-wave verification (full typecheck + test:unit on branch HEAD)
|
||||
12. **Log & Report** — Generate session report + author accountability report + Slack status update (`reference/logging.md`)
|
||||
1. **Discover** — Collect candidates from Slack bot + git log gap (`reference/discovery.md`)
|
||||
2. **Analyze** — Categorize MUST/SHOULD/SKIP, check deps (`reference/analysis.md`)
|
||||
3. **Human Review** — Present candidates in batches for interactive approval (see Interactive Approval Flow)
|
||||
4. **Plan** — Order by dependency (leaf fixes first), group into waves per branch
|
||||
5. **Execute** — Label-driven automation → worktree fallback for conflicts (`reference/execution.md`)
|
||||
6. **Verify** — After each wave, verify branch integrity before proceeding
|
||||
7. **Log & Report** — Generate session report (`reference/logging.md`)
|
||||
|
||||
## System Context
|
||||
|
||||
@@ -112,35 +107,6 @@ Husky hooks fail in worktrees (can't find lint-staged config). Always use `git p
|
||||
|
||||
In the 2026-04-06 session: core/1.42 got 18/26 auto-PRs, cloud/1.42 got only 1/25. The cloud branch has more divergence. **Always plan for manual fallback** — don't assume automation will handle most PRs.
|
||||
|
||||
### Cherry-Picked Tests Can Reference Files Added By Earlier Unbackported PRs
|
||||
|
||||
A common conflict: PR A on main modifies a test file that was _added_ on main by an earlier PR B (not backported to the target). The cherry-pick of A reports "modify/delete" on B's test file because the file doesn't exist on the target. Adding the new file would smuggle in B's test scaffolding without B's runtime changes.
|
||||
|
||||
**Detection:** Conflict says `deleted in HEAD and modified in <PR>`. Verify with:
|
||||
|
||||
```bash
|
||||
git log --diff-filter=A --oneline origin/main -- path/to/test.ts
|
||||
```
|
||||
|
||||
If the introducing commit is **not** on the target branch, the test file isn't a real prerequisite for the runtime fix.
|
||||
|
||||
**Fix:** `git rm` the test file (drop it from the backport). Document in the commit body which PR introduced it on main and why dropping it is safe. The runtime fix itself usually doesn't depend on these tests — coverage exists at the integration layer.
|
||||
|
||||
### Backport-Only Compatibility Shims
|
||||
|
||||
When a PR's _mechanism_ relies on changes upstream that aren't on the older branch, a literal cherry-pick can recreate the original bug for any consumer still using the old contract. This is most dangerous for **public LiteGraph callbacks, extension APIs, and `node.*` methods** that custom-node packages depend on.
|
||||
|
||||
**Real example (#11541, core/1.43 backport):** The PR removed `LGraphNode.vue`'s legacy `handled === true` sync-return check from `handleDrop`, replacing it with `await node.onDragDrop(event, true)`. Safe on `main` because all in-repo `onDragDrop` handlers had migrated to participate in the new `claimEvent` flag. On `core/1.43`, `onDragDrop` is a public callback — custom-node packages with synchronous `onDragDrop` returning `true` would no longer have their event claimed, recreating the duplicate-node-creation bug the PR was fixing.
|
||||
|
||||
**Detection:** The PR's diff modifies a file that is part of a public extension API surface. Look for:
|
||||
|
||||
- `node.onXxx` callback assignments
|
||||
- Methods on `LGraphNode`, `LGraphCanvas`, `LGraph`, `Subgraph`
|
||||
- Public exports from `src/lib/litegraph/`
|
||||
- Type changes affecting `litegraph-augmentation.d.ts`
|
||||
|
||||
**Fix:** Add a backport-only compatibility shim that preserves the old contract while keeping the new fix. Document it explicitly in the commit body under a `## Backport-only compatibility fix` heading. Consult oracle for review before pushing — a bad shim is worse than no fix.
|
||||
|
||||
## Conflict Triage
|
||||
|
||||
**Always categorize before deciding to skip. High conflict count ≠ hard conflicts.**
|
||||
@@ -181,26 +147,6 @@ Skip these without discussion:
|
||||
- **Features not on target branch** — e.g., Painter, GLSLShader, appModeStore on core/1.40
|
||||
- **Cloud-only PRs on core/\* branches** — Team workspaces, cloud queue, cloud-only login. (Note: app mode and Firebase auth are NOT cloud-only — see Branch Scope Rules)
|
||||
|
||||
### Path Pre-Filter (run BEFORE reading PR bodies)
|
||||
|
||||
For 50+ candidate PRs, classify by changed paths first to skip the unproductive ones without spending time on triage. Run `git show --stat $SHA` (or `gh pr view --json files`) and bucket:
|
||||
|
||||
| Path prefix | Bucket | Reason |
|
||||
| ---------------------------------------------- | ---------------------- | ------------------------------------------------ |
|
||||
| `apps/website/` | SKIP | Marketing/platform site, not core ComfyUI bundle |
|
||||
| `apps/desktop-ui/` | SKIP for `core/*` | Desktop app, separate release cadence |
|
||||
| `browser_tests/` only (no `src/`) | SKIP | Test-only |
|
||||
| `.github/workflows/` only | SKIP | CI/release infra |
|
||||
| `packages/design-system/` only | SKIP | Design tokens, not core |
|
||||
| `packages/{cloud,registry,ingest}-types/` only | SKIP | Generated types |
|
||||
| `.claude/`, `.agents/`, `docs/` | SKIP | Agent / documentation |
|
||||
| `*.stories.ts` only | SKIP | Storybook only |
|
||||
| `src/` (core editor) | KEEP — analyze further | Runtime/editor code that requires full triage |
|
||||
|
||||
A PR touches multiple paths? Keep it if **any** changed file is under `src/` (or other core paths) and run normal analysis. Auto-skip is conservative — only skip when _all_ paths match the SKIP buckets.
|
||||
|
||||
This filter alone removes ~30-50% of candidates in a typical session, leaving only the PRs that need real triage.
|
||||
|
||||
## Wave Verification
|
||||
|
||||
After merging each wave of PRs to a target branch, verify branch integrity before proceeding:
|
||||
|
||||
@@ -39,89 +39,6 @@ Check before backporting — these don't exist on older branches:
|
||||
- **App builder** — check per branch
|
||||
- **appModeStore.ts** — not on core/1.40
|
||||
|
||||
### Verify Target File Existence (Run Before Cherry-Pick)
|
||||
|
||||
Before cherry-picking any PR, confirm the files it modifies actually exist on the target branch. If they don't, the PR's runtime fix is for a feature that hasn't been added yet — skip cleanly without attempting cherry-pick:
|
||||
|
||||
```bash
|
||||
# For each file the PR changes
|
||||
for f in $(gh pr view $PR --json files --jq '.files[].path' | grep -v "^browser_tests/\|\.test\." ); do
|
||||
if ! git cat-file -e origin/$TARGET:$f 2>/dev/null; then
|
||||
echo "MISSING on $TARGET: $f"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
If the _primary_ changed files (the runtime ones, not tests) are missing, mark the PR `SKIP / feature-not-on-branch`. This is faster than letting cherry-pick fail with modify/delete conflicts and gives a clean signal.
|
||||
|
||||
This check is the first thing that runs after the path pre-filter and BEFORE you spend time reading PR descriptions.
|
||||
|
||||
## Tiered Triage (Recommended for 30+ Candidates)
|
||||
|
||||
Before the interactive Y/N approval flow, bucket all surviving candidates into three tiers. This surfaces release-engineering decisions that a flat MUST/SHOULD list obscures:
|
||||
|
||||
### Tier 1 — Core Editor Must-Haves
|
||||
|
||||
User-facing bugs, crashes, data corruption, or security issues in code paths that exist on the target branch. These are the strongest backport candidates.
|
||||
|
||||
Indicators:
|
||||
|
||||
- `fix:` prefix and the bug is reproducible on the target branch
|
||||
- Crash guards, runtime null checks, race-condition fixes
|
||||
- Data-loss bugs (state not persisted, duplicates, drops)
|
||||
- Security hardening (CSRF, XSS, auth)
|
||||
- Vue Nodes 2.0 regression cluster (if the target ships Vue Nodes 2.0)
|
||||
- Subgraph correctness fixes
|
||||
- Public-API extension callback fixes
|
||||
|
||||
Recommend `Y` to user.
|
||||
|
||||
### Tier 2 — Cloud-Distribution Only
|
||||
|
||||
Bugs that only manifest on cloud-hosted distributions (Secrets panel, subscription flows, cloud signup, workspace tracking, etc.). Whether to backport depends on whether cloud ships from the target `core/*` branch in your release matrix.
|
||||
|
||||
Indicators:
|
||||
|
||||
- Files under `src/platform/secrets/`, `src/platform/subscription/`, signup flows
|
||||
- PR description mentions cloud staging issues
|
||||
- Fix gated behind cloud feature flags
|
||||
|
||||
Default: ask the cloud release rotation owner. If unsure, defer.
|
||||
|
||||
### Tier 3 — Skip
|
||||
|
||||
Path pre-filter caught most of these. The rest are PRs where the diff _touches_ `src/` but the practical impact is non-user-facing or scoped to features the target doesn't ship.
|
||||
|
||||
Indicators:
|
||||
|
||||
- All changes in test files even if the PR touched `src/` test files
|
||||
- Storybook stories only
|
||||
- Lint config / lint rule additions
|
||||
- Documentation comments
|
||||
- Internal refactors with no behavior change
|
||||
|
||||
### Presentation Format
|
||||
|
||||
When showing tier results to the user, format as:
|
||||
|
||||
```text
|
||||
Tier 1 (N PRs) — strong backport candidates
|
||||
- #11541 fix: stop duplicate node creation when dropping image on Vue nodes
|
||||
Why: Vue Nodes 2.0 regression — async onDragDrop bypassed handled-check, drops bubble to document, spawns extra LoadImage nodes
|
||||
- #10849 fix: store promoted widget values per SubgraphNode instance
|
||||
Why: Multiple instances overwriting each other's promoted widget values — data loss
|
||||
|
||||
Tier 2 (N PRs) — cloud-distribution release rotation should decide
|
||||
- #11636 fix: enable Chrome password autofill on signup form
|
||||
- ...
|
||||
|
||||
Tier 3 (N PRs) — skip recommended
|
||||
- #11586 fix: website polish (apps/website/ only)
|
||||
- ...
|
||||
```
|
||||
|
||||
Then run interactive Y/N over Tier 1 and Tier 2; Tier 3 gets confirmed-skip without per-PR review.
|
||||
|
||||
## Dep Refresh PRs
|
||||
|
||||
Always SKIP on stable branches. Risk of transitive dependency regressions outweighs audit cleanup benefit. If a specific CVE fix is needed, cherry-pick that individual fix instead.
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Discovery — Candidate Collection
|
||||
|
||||
**Run all sources, then reconcile.** No single source is authoritative:
|
||||
|
||||
- Slack bot may flag PRs that have already been backported (false positive)
|
||||
- Git gap may include PRs that don't need backport (test-only, design-system, website)
|
||||
- Bot can also miss PRs that landed without the right labels
|
||||
|
||||
## Source 1: Slack Backport-Checker Bot
|
||||
|
||||
Use `slackdump` skill to export `#frontend-releases` channel (C09K9TPU2G7):
|
||||
@@ -42,43 +36,7 @@ gh pr view $PR --json mergeCommit,title --jq '"Title: \(.title)\nMerge: \(.merge
|
||||
gh pr view $PR --json files --jq '.files[].path'
|
||||
```
|
||||
|
||||
## Source 4: Already-Backported PRs (cross-reference)
|
||||
|
||||
When the target branch already has some cherry-picks on it (e.g., partway through a release window), extract the originals to avoid re-backporting:
|
||||
|
||||
```bash
|
||||
# Get all original PR numbers already backported to TARGET since the last release tag
|
||||
git log --format="%H%n%B" $LAST_TAG..origin/$TARGET \
|
||||
| grep -oiE "(backport of|cherry.picked) #?[0-9]+" \
|
||||
| grep -oE "[0-9]+" \
|
||||
| sort -un > /tmp/already-backported.txt
|
||||
```
|
||||
|
||||
Subtract this list from your candidates.
|
||||
|
||||
## Reconciliation Workflow
|
||||
|
||||
```bash
|
||||
# 1. Slack bot list (parse from export)
|
||||
# /tmp/bot-flagged.txt — one PR# per line, sorted
|
||||
|
||||
# 2. Git gap fix/perf only
|
||||
MB=$(git merge-base origin/main origin/$TARGET)
|
||||
git log --format="%h|%s" $MB..origin/main \
|
||||
| grep -iE "^[a-f0-9]+\|(fix|perf)" \
|
||||
| grep -oE "#[0-9]+\)" | grep -oE "[0-9]+" \
|
||||
| sort -un > /tmp/gap-fixes.txt
|
||||
|
||||
# 3. Already backported (Source 4 above)
|
||||
|
||||
# 4. Candidates = (gap-fixes ∪ bot-flagged) − already-backported
|
||||
sort -u /tmp/gap-fixes.txt /tmp/bot-flagged.txt > /tmp/union.txt
|
||||
comm -23 /tmp/union.txt /tmp/already-backported.txt > /tmp/candidates.txt
|
||||
```
|
||||
|
||||
The result is the input to the path pre-filter (`SKILL.md` Quick Start step 2).
|
||||
|
||||
## Output: candidate_list.md
|
||||
|
||||
Table per target branch:
|
||||
| PR# | Title | Source (bot/gap/both) | Path bucket | Tier | Decision |
|
||||
| PR# | Title | Category | Flagged by Bot? | Decision |
|
||||
|
||||
@@ -6,43 +6,6 @@
|
||||
2. Medium gap next (quick win)
|
||||
3. Largest gap last (main effort)
|
||||
|
||||
## Step 0: Test-Then-Resolve Pre-Pass (Recommended)
|
||||
|
||||
Before triggering label-driven automation, run a dry-run cherry-pick loop to classify candidates. This is much faster than discovering conflicts after-the-fact across automation, manual cherry-picks, and CI failures.
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git worktree add /tmp/dryrun-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/dryrun-TARGET
|
||||
|
||||
CLEAN=()
|
||||
CONFLICT=()
|
||||
for pr in "${CANDIDATES[@]}"; do
|
||||
SHA=$(gh pr view $pr --json mergeCommit --jq '.mergeCommit.oid')
|
||||
git checkout -b dryrun-$pr origin/TARGET_BRANCH 2>/dev/null
|
||||
if git cherry-pick -m 1 $SHA 2>/dev/null; then
|
||||
CLEAN+=($pr)
|
||||
else
|
||||
CONFLICT+=($pr)
|
||||
git cherry-pick --abort
|
||||
fi
|
||||
git checkout --detach HEAD 2>/dev/null
|
||||
git branch -D dryrun-$pr 2>/dev/null
|
||||
done
|
||||
|
||||
echo "CLEAN (${#CLEAN[@]}): ${CLEAN[*]}"
|
||||
echo "CONFLICT (${#CONFLICT[@]}): ${CONFLICT[*]}"
|
||||
|
||||
cd -
|
||||
git worktree remove /tmp/dryrun-TARGET --force
|
||||
```
|
||||
|
||||
Use the result to:
|
||||
|
||||
- Send CLEAN PRs through label-driven automation (Step 1) — they'll typically self-merge
|
||||
- Reserve manual worktree time (Step 3) for CONFLICT PRs only
|
||||
- Surface PRs likely to need backport-only compat shims (CONFLICT files in `src/lib/litegraph/` or `src/scripts/app.ts`)
|
||||
|
||||
## Step 1: Label-Driven Automation (Batch)
|
||||
|
||||
```bash
|
||||
@@ -125,39 +88,6 @@ for PR in ${CONFLICT_PRS[@]}; do
|
||||
git add .
|
||||
GIT_EDITOR=true git cherry-pick --continue
|
||||
|
||||
# ── Public-API conflict review (REQUIRED for extension-API surfaces) ──
|
||||
# If the conflict resolution touched any of these surfaces, consult oracle
|
||||
# BEFORE pushing. A bad shim is worse than no fix:
|
||||
# - node.onXxx callback assignments (onDragDrop, onConnectionsChange, onRemoved, onConfigure, etc.)
|
||||
# - Methods on LGraphNode, LGraphCanvas, LGraph, Subgraph
|
||||
# - Public exports from src/lib/litegraph/
|
||||
# - Type changes in litegraph-augmentation.d.ts
|
||||
# If a public callback's signature/contract changed: add a backport-only
|
||||
# compatibility shim that preserves the OLD contract while keeping the
|
||||
# new fix. Document it in the commit body under
|
||||
# "## Backport-only compatibility fix". See SKILL.md gotcha section.
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Per-PR validation BEFORE push (catches issues earlier than wave verification).
|
||||
# Guard each targeted command against empty file lists — running `pnpm test:unit -- run`
|
||||
# with no arg matchers would run the full suite, and `pnpm exec eslint` with no args errors.
|
||||
pnpm typecheck
|
||||
|
||||
mapfile -t TEST_FILES < <(git diff --name-only HEAD~1 | grep -E '\.test\.ts$' || true)
|
||||
if [ ${#TEST_FILES[@]} -gt 0 ]; then
|
||||
pnpm test:unit -- run "${TEST_FILES[@]}"
|
||||
else
|
||||
echo "No changed test files — skipping targeted unit tests"
|
||||
fi
|
||||
|
||||
mapfile -t CODE_FILES < <(git diff --name-only HEAD~1 | grep -E '\.(ts|vue)$' || true)
|
||||
if [ ${#CODE_FILES[@]} -gt 0 ]; then
|
||||
pnpm exec eslint "${CODE_FILES[@]}"
|
||||
pnpm exec oxfmt --check "${CODE_FILES[@]}"
|
||||
else
|
||||
echo "No changed ts/vue files — skipping targeted lint/format"
|
||||
fi
|
||||
|
||||
git push origin backport-$PR-to-TARGET --no-verify
|
||||
NEW_PR=$(gh pr create --base TARGET_BRANCH --head backport-$PR-to-TARGET \
|
||||
--title "[backport TARGET] TITLE (#$PR)" \
|
||||
@@ -313,9 +243,6 @@ gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
|
||||
16. **Use `--no-verify` in worktrees** — husky hooks fail in `/tmp/` worktrees. Always push/commit with `--no-verify`.
|
||||
17. **Automation success varies by branch** — core/1.42 got 18/26 auto-PRs (69%), cloud/1.42 got 1/25 (4%). Cloud branches diverge more. Plan for manual fallback.
|
||||
18. **Test-then-resolve pattern** — for branches with low automation success, run a dry-run loop to classify clean vs conflict PRs before processing. This is much faster than resolving conflicts serially.
|
||||
19. **Public-API conflict resolutions need oracle review** — when a conflict touches `node.onXxx` callbacks, `LGraphNode`/`LGraphCanvas`/`LGraph`/`Subgraph` methods, or types in `litegraph-augmentation.d.ts`, consult oracle BEFORE pushing. Custom-node packages depend on these contracts. A literal cherry-pick of a refactor-style fix can silently break extensions still using the old contract — sometimes recreating the very bug the PR was fixing. Document any backport-only compatibility shim explicitly in the commit body.
|
||||
20. **Cherry-picked tests can require unbackported test scaffolding** — when a PR modifies a test file that was _added_ on main by an earlier unbackported PR, the cherry-pick reports modify/delete on that file. Drop it from the backport (`git rm`) and document which PR introduced it. Don't smuggle in test infrastructure without its runtime prerequisites.
|
||||
21. **Per-PR validation catches issues earlier than wave verification** — for high-stakes branches, run `pnpm typecheck && pnpm exec eslint <changed files> && pnpm exec oxfmt --check` per PR before pushing. Wave verification still matters (it catches cross-PR interactions), but per-PR makes attribution trivial when something fails.
|
||||
|
||||
## CI Failure Triage
|
||||
|
||||
@@ -341,40 +268,3 @@ Common failure categories:
|
||||
| Type error | Interface changed on main but not branch | May need manual adaptation |
|
||||
|
||||
**Never assume a failure is safe to skip.** Present all failures to the user with analysis.
|
||||
|
||||
## PR Body Template (Manual Cherry-Picks)
|
||||
|
||||
Manual cherry-pick PRs need detail beyond the automation's terse default. Use this template — reviewers will look here before re-deriving conflict-resolution logic from the diff.
|
||||
|
||||
```markdown
|
||||
Manual backport of #ORIG to `TARGET` for inclusion in `vX.Y.Z`.
|
||||
|
||||
Cherry-picked from upstream merge commit `SHORT_SHA`.
|
||||
|
||||
## Why
|
||||
|
||||
[1-2 sentences from the original PR's "Summary" — what bug, what fix mechanism]
|
||||
|
||||
## Conflict resolution
|
||||
|
||||
- **`path/to/file`** — [what conflicted on this branch] → [resolution chosen + why]
|
||||
- **`path/to/dropped-test.test.ts`** — added on main by unrelated PR #XXXX (not backported). Dropped from this backport; runtime fix intact.
|
||||
- [...]
|
||||
|
||||
## Backport-only compatibility fix (if applicable)
|
||||
|
||||
[If you added a shim that wasn't in the upstream PR, document it here — what extension surface, what contract, what the shim preserves, why the upstream version would have regressed it]
|
||||
|
||||
## Validation
|
||||
|
||||
- `pnpm typecheck` ✅
|
||||
- `pnpm test:unit -- run <targeted suites>` ✅ (N/N passing)
|
||||
- `pnpm exec eslint <changed files>` ✅ (0 errors)
|
||||
- `pnpm exec oxfmt --check` ✅ (clean)
|
||||
|
||||
[If manual e2e was skipped, explain why — e.g., requires live backend, headless not feasible. State that source is byte-identical to upstream + how long it's been baking on main.]
|
||||
|
||||
Original PR: #ORIG / Original commit: `FULL_SHA`
|
||||
```
|
||||
|
||||
The conflict-resolution section is non-negotiable — every conflict you resolved by hand needs a one-liner. This makes archaeology trivial six months later when someone asks "why does this look slightly different from main?"
|
||||
|
||||
@@ -114,7 +114,7 @@ await expect(async () => {
|
||||
## CI Debugging
|
||||
|
||||
1. Download artifacts from failed CI run
|
||||
2. Extract and view trace: `pnpm dlx playwright show-trace trace.zip`
|
||||
2. Extract and view trace: `npx playwright show-trace trace.zip`
|
||||
3. CI deploys HTML report to Cloudflare Pages (link in PR comment)
|
||||
4. Reproduce CI: `CI=true pnpm test:browser`
|
||||
5. Local runs: `pnpm test:browser:local`
|
||||
|
||||
@@ -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: |
|
||||
|
||||
19
.github/actions/cloud-nodes-pull/action.yaml
vendored
@@ -1,19 +0,0 @@
|
||||
name: Cloud Nodes Pull
|
||||
description: 'Refresh the apps/website cloud nodes snapshot from the Comfy Cloud /api/object_info endpoint'
|
||||
inputs:
|
||||
api_key:
|
||||
description: 'Comfy Cloud API key (WEBSITE_CLOUD_API_KEY).'
|
||||
required: true
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
# Note: this action assumes the frontend repo is checked out at the workspace root.
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Refresh cloud nodes snapshot
|
||||
shell: bash
|
||||
env:
|
||||
WEBSITE_CLOUD_API_KEY: ${{ inputs.api_key }}
|
||||
run: pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
|
||||
6
.github/workflows/ci-perf-report.yaml
vendored
@@ -54,14 +54,10 @@ jobs:
|
||||
- name: Start ComfyUI server
|
||||
uses: ./.github/actions/start-comfyui-server
|
||||
|
||||
# PRs run each test once to keep wall time bounded; main runs 3× so the
|
||||
# baseline saved to perf-data has enough samples to median over noise.
|
||||
- name: Run performance tests
|
||||
id: perf
|
||||
continue-on-error: true
|
||||
env:
|
||||
PERF_REPEAT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && '3' || '2' }}
|
||||
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=$PERF_REPEAT
|
||||
run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3
|
||||
|
||||
- name: Upload perf metrics
|
||||
if: always()
|
||||
|
||||
46
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -20,8 +20,6 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
has-coverage: ${{ steps.coverage-shards.outputs.has-coverage }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -39,33 +37,31 @@ jobs:
|
||||
path: temp/coverage-shards
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Detect shard coverage data
|
||||
id: coverage-shards
|
||||
run: |
|
||||
if [ -d temp/coverage-shards ] && find temp/coverage-shards -name 'coverage.lcov' -type f | grep -q .; then
|
||||
echo "has-coverage=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has-coverage=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No E2E coverage shard artifacts found; treating this run as skipped." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Install lcov
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: sudo apt-get install -y -qq lcov
|
||||
|
||||
- name: Merge shard coverage into single LCOV
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
mkdir -p coverage/playwright
|
||||
LCOV_FILES=$(find temp/coverage-shards -name 'coverage.lcov' -type f)
|
||||
if [ -z "$LCOV_FILES" ]; then
|
||||
echo "No coverage.lcov files found"
|
||||
touch coverage/playwright/coverage.lcov
|
||||
exit 0
|
||||
fi
|
||||
ADD_ARGS=""
|
||||
for f in $LCOV_FILES; do ADD_ARGS="$ADD_ARGS -a $f"; done
|
||||
lcov $ADD_ARGS -o coverage/playwright/coverage.lcov
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
|
||||
- name: Validate merged coverage
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
SHARD_COUNT=$(find temp/coverage-shards -name 'coverage.lcov' -type f | wc -l | tr -d ' ')
|
||||
if [ "$SHARD_COUNT" -eq 0 ]; then
|
||||
echo "::notice::No shard coverage files; upstream E2E was likely skipped."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MERGED_SF=$(grep -c '^SF:' coverage/playwright/coverage.lcov || echo 0)
|
||||
MERGED_LH=$(awk -F: '/^LH:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
|
||||
MERGED_LF=$(awk -F: '/^LF:/{s+=$2}END{print s+0}' coverage/playwright/coverage.lcov)
|
||||
@@ -86,7 +82,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload merged coverage data
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage
|
||||
@@ -95,7 +91,7 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload E2E coverage to Codecov
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
if: always()
|
||||
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
|
||||
with:
|
||||
files: coverage/playwright/coverage.lcov
|
||||
@@ -104,17 +100,20 @@ jobs:
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Generate HTML coverage report
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
if [ ! -s coverage/playwright/coverage.lcov ]; then
|
||||
echo "No coverage data; generating placeholder report."
|
||||
mkdir -p coverage/html
|
||||
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
|
||||
exit 0
|
||||
fi
|
||||
genhtml coverage/playwright/coverage.lcov \
|
||||
-o coverage/html \
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1 \
|
||||
--ignore-errors source,unmapped
|
||||
--precision 1
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage-html
|
||||
@@ -123,10 +122,7 @@ jobs:
|
||||
|
||||
deploy:
|
||||
needs: merge
|
||||
if: >
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
needs.merge.outputs.has-coverage == 'true' &&
|
||||
github.event.workflow_run.event == 'push'
|
||||
if: github.event.workflow_run.head_branch == 'main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
|
||||
14
.github/workflows/ci-vercel-website-preview.yaml
vendored
@@ -4,9 +4,6 @@ name: 'CI: Vercel Website Preview'
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches-ignore:
|
||||
- 'core/**'
|
||||
- 'cloud/**'
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
@@ -58,7 +55,6 @@ jobs:
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
run: vercel build
|
||||
|
||||
- name: Fetch head commit metadata
|
||||
@@ -152,20 +148,10 @@ jobs:
|
||||
- name: Pull Vercel environment information
|
||||
run: vercel pull --yes --environment=production
|
||||
|
||||
- name: Verify WEBSITE_CLOUD_API_KEY is present for production build
|
||||
env:
|
||||
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
run: |
|
||||
if [ -z "${WEBSITE_CLOUD_API_KEY:-}" ]; then
|
||||
echo "::error title=Missing WEBSITE_CLOUD_API_KEY::Production builds require WEBSITE_CLOUD_API_KEY so /cloud/supported-nodes is generated from fresh Cloud API data. Add it as a GitHub Actions repo secret and to the Vercel project environment. See apps/website/README.md."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build project artifacts
|
||||
env:
|
||||
WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
WEBSITE_CLOUD_API_KEY: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
run: vercel build --prod
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
|
||||
123
.github/workflows/model-page-discovery.yaml
vendored
@@ -1,123 +0,0 @@
|
||||
name: Model Page Discovery
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 9 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
discover:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Fetch model labels from hub API
|
||||
id: hub
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL 'https://comfy.org/api/hub/labels?type=model' -o hub-labels.json
|
||||
echo "Fetched $(jq '.labels | length' hub-labels.json) model labels from hub"
|
||||
|
||||
- name: Checkout ComfyUI_frontend
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: apps/website/src/config/generated-models.json
|
||||
|
||||
- name: Compare against existing models
|
||||
id: compare
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
HUB_SLUGS=$(jq -r '[.labels[].name]' hub-labels.json)
|
||||
|
||||
EXISTING_SLUGS=$(node -e "
|
||||
const fs = require('fs');
|
||||
const models = JSON.parse(
|
||||
fs.readFileSync(
|
||||
'apps/website/src/config/generated-models.json',
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
console.log(JSON.stringify(models.map(m => m.slug)));
|
||||
" 2>/dev/null || echo '[]')
|
||||
|
||||
ADDED_SLUGS=$(node -e "
|
||||
const hub = $HUB_SLUGS;
|
||||
const existing = new Set($EXISTING_SLUGS);
|
||||
console.log(JSON.stringify(hub.filter(s => !existing.has(s))));
|
||||
")
|
||||
|
||||
COUNT=$(node -e "console.log($ADDED_SLUGS.length)")
|
||||
echo "new_count=$COUNT" >> \$GITHUB_OUTPUT
|
||||
echo "new_slugs=$ADDED_SLUGS" >> \$GITHUB_OUTPUT
|
||||
|
||||
if [ "\$COUNT" -eq 0 ]; then
|
||||
echo "No new models found."
|
||||
else
|
||||
echo "Found \$COUNT new model(s)"
|
||||
fi
|
||||
|
||||
- name: Check for existing open discovery issue
|
||||
id: existing_issue
|
||||
if: steps.compare.outputs.new_count != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
COUNT=$(gh issue list \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--state open \
|
||||
--search 'in:title "New models detected"' \
|
||||
--json number \
|
||||
--jq 'length')
|
||||
echo "open_count=$COUNT" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Open GitHub issue for new models
|
||||
if: |
|
||||
steps.compare.outputs.new_count != '0' &&
|
||||
steps.existing_issue.outputs.open_count == '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NEW_SLUGS: ${{ steps.compare.outputs.new_slugs }}
|
||||
NEW_COUNT: ${{ steps.compare.outputs.new_count }}
|
||||
shell: bash
|
||||
run: |
|
||||
SLUG_LIST=$(node -e "
|
||||
const slugs = $NEW_SLUGS;
|
||||
console.log(slugs.map(s => '- \`' + s + '\`').join('\n'));
|
||||
")
|
||||
|
||||
gh issue create \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "New models detected — add to model pages" \
|
||||
--body "## $NEW_COUNT new model(s) found in hub
|
||||
|
||||
The weekly model discovery scan found model labels on the hub not yet in
|
||||
\`apps/website/src/config/generated-models.json\`.
|
||||
|
||||
### New slugs ($NEW_COUNT)
|
||||
|
||||
$SLUG_LIST
|
||||
|
||||
### Next steps
|
||||
|
||||
1. Review which of these warrant an SEO model page
|
||||
2. For local models: run \`SKIP_THUMBNAILS=1 pnpm generate:models\` and commit the result
|
||||
3. For partner/API models: add to \`API_PROVIDER_MAP\` in \`generate-models.ts\`, regenerate, commit
|
||||
|
||||
---
|
||||
*Generated by the [model-page-discovery workflow](https://github.com/$GITHUB_REPOSITORY/actions/workflows/model-page-discovery.yaml)*"
|
||||
|
||||
- name: Skip — open issue already exists
|
||||
if: |
|
||||
steps.compare.outputs.new_count != '0' &&
|
||||
steps.existing_issue.outputs.open_count != '0'
|
||||
run: echo "An open discovery issue already exists — skipping creation."
|
||||
|
||||
- name: No new models found
|
||||
if: steps.compare.outputs.new_count == '0'
|
||||
run: echo "No new models found — nothing to do."
|
||||
37
.github/workflows/release-website.yaml
vendored
@@ -1,6 +1,6 @@
|
||||
# Description: Manual workflow to refresh the apps/website Ashby roles and
|
||||
# cloud nodes snapshots and open a PR. Merging the PR triggers the existing
|
||||
# Vercel website production deploy via ci-vercel-website-preview.yaml.
|
||||
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
|
||||
# and open a PR. Merging the PR triggers the existing Vercel website production
|
||||
# deploy via ci-vercel-website-preview.yaml.
|
||||
name: 'Release: Website'
|
||||
|
||||
on:
|
||||
@@ -11,7 +11,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
refresh-snapshots:
|
||||
refresh-snapshot:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -31,39 +31,28 @@ jobs:
|
||||
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
|
||||
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
|
||||
|
||||
- name: Refresh cloud nodes snapshot
|
||||
uses: ./.github/actions/cloud-nodes-pull
|
||||
with:
|
||||
api_key: ${{ secrets.WEBSITE_CLOUD_API_KEY }}
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: 'chore(website): refresh Ashby and cloud nodes snapshots'
|
||||
title: 'chore(website): refresh Ashby and cloud nodes snapshots'
|
||||
commit-message: 'chore(website): refresh Ashby roles snapshot'
|
||||
title: 'chore(website): refresh Ashby roles snapshot'
|
||||
body: |
|
||||
Automated refresh of remote-data snapshots used by the website
|
||||
build:
|
||||
|
||||
- `apps/website/src/data/ashby-roles.snapshot.json` — Ashby job
|
||||
board API
|
||||
- `apps/website/src/data/cloud-nodes.snapshot.json` — Comfy Cloud
|
||||
`/api/object_info`
|
||||
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
|
||||
from the Ashby job board API.
|
||||
|
||||
**Flow:**
|
||||
1. `Release: Website` workflow ran (manual trigger).
|
||||
2. This PR opens with the regenerated snapshots.
|
||||
2. This PR opens with the regenerated snapshot.
|
||||
3. `CI: Vercel Website Preview` deploys a preview for review.
|
||||
4. Merging to `main` triggers the production Vercel deploy.
|
||||
|
||||
The snapshot fallback in `apps/website/src/utils/ashby.ts` and
|
||||
`apps/website/src/utils/cloudNodes.ts` remains intact: builds
|
||||
without the respective API keys continue to use the committed
|
||||
snapshot (with a warning annotation in CI).
|
||||
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
|
||||
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
|
||||
committed snapshot.
|
||||
|
||||
Triggered by workflow run `${{ github.run_id }}`.
|
||||
branch: chore/refresh-website-snapshots-${{ github.run_id }}
|
||||
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
|
||||
base: main
|
||||
labels: |
|
||||
Release:Website
|
||||
|
||||
@@ -85,15 +85,6 @@
|
||||
"typescript/no-unused-vars": "off",
|
||||
"unicorn/no-empty-file": "off",
|
||||
"vitest/require-mock-type-parameters": "off",
|
||||
"vitest/consistent-each-for": [
|
||||
"error",
|
||||
{
|
||||
"test": "for",
|
||||
"it": "for",
|
||||
"describe": "for",
|
||||
"suite": "for"
|
||||
}
|
||||
],
|
||||
"unicorn/no-new-array": "off",
|
||||
"unicorn/no-single-promise-in-promise-methods": "off",
|
||||
"unicorn/no-useless-fallback-in-spread": "off",
|
||||
|
||||
@@ -9,7 +9,6 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' }
|
||||
import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' }
|
||||
|
||||
import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' }
|
||||
import { getDefaultLocale } from '@frontend-locales/localeConfig'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
function buildLocale<
|
||||
@@ -168,7 +167,7 @@ const messages: Record<string, LocaleMessages> = {
|
||||
export const i18n = createI18n({
|
||||
// Must set `false`, as Vue I18n Legacy API is for Vue 2
|
||||
legacy: false,
|
||||
locale: getDefaultLocale(),
|
||||
locale: navigator.language.split('-')[0] || 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
// Ignore warnings for locale options as each option is in its own language.
|
||||
|
||||
@@ -113,50 +113,6 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
|
||||
The script exits non-zero on any non-fresh outcome so stale/empty
|
||||
snapshots can't be accidentally committed.
|
||||
|
||||
## Cloud nodes integration
|
||||
|
||||
`/cloud/supported-nodes` (and `/zh-CN/`) lists custom-node packs preinstalled on Comfy Cloud, joined with public metadata from the [ComfyUI Custom Node Registry](https://registry.comfy.org) ([`api.comfy.org`](https://api.comfy.org)). See [`src/pages/cloud/supported-nodes/AGENTS.md`](src/pages/cloud/supported-nodes/AGENTS.md) for the build pipeline, source-file map, and key invariants.
|
||||
|
||||
Build-time env var: `WEBSITE_CLOUD_API_KEY` (Cloud `/api/object_info` auth; the build falls back to the committed snapshot when unset). Must also be set in the Vercel project environment.
|
||||
|
||||
### Production strictness
|
||||
|
||||
`src/utils/cloudNodes.build.ts` throws when `fetchCloudNodesForBuild()` returns
|
||||
`{ status: 'stale' }` **and** `process.env.VERCEL_ENV === 'production'`. This
|
||||
prevents the production deploy from silently shipping an out-of-date snapshot
|
||||
when the Cloud API is unreachable or `WEBSITE_CLOUD_API_KEY` is missing. Preview
|
||||
and local builds continue to use the committed snapshot with a warning
|
||||
annotation.
|
||||
|
||||
### Required GitHub Actions / Vercel secrets
|
||||
|
||||
| Name | Where | Purpose |
|
||||
| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `WEBSITE_CLOUD_API_KEY` | GitHub Actions repo secret + Vercel project env | Auth for Cloud `/api/object_info`. Required for fresh production data. |
|
||||
|
||||
The `Release: Website` workflow uses the GitHub Actions secret to regenerate
|
||||
`apps/website/src/data/cloud-nodes.snapshot.json` via
|
||||
`.github/actions/cloud-nodes-pull/action.yaml`. The Vercel environment value is
|
||||
read at build time by `vercel build` in `ci-vercel-website-preview.yaml`; the
|
||||
`deploy-production` job hard-fails before `vercel build --prod` if the secret
|
||||
is missing.
|
||||
|
||||
### Refreshing the snapshot
|
||||
|
||||
To update the committed snapshot manually (e.g. after onboarding new packs
|
||||
to Comfy Cloud):
|
||||
|
||||
```bash
|
||||
WEBSITE_CLOUD_API_KEY=… \
|
||||
pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot
|
||||
git commit apps/website/src/data/cloud-nodes.snapshot.json
|
||||
```
|
||||
|
||||
The script exits non-zero on any non-fresh outcome so stale/empty snapshots
|
||||
can't be accidentally committed. Otherwise the `Release: Website` GitHub
|
||||
Actions workflow runs the same step on every manual dispatch and opens a PR
|
||||
with the refreshed snapshot.
|
||||
|
||||
## HubSpot contact form
|
||||
|
||||
The contact page uses HubSpot's hosted form embed for the interest form:
|
||||
@@ -190,4 +146,3 @@ renders the documented embed container.
|
||||
- `pnpm test:unit` — Vitest unit tests
|
||||
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
|
||||
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot
|
||||
- `pnpm cloud-nodes:refresh-snapshot` — refresh the committed cloud nodes snapshot
|
||||
|
||||
@@ -3,23 +3,6 @@ import sitemap from '@astrojs/sitemap'
|
||||
import vue from '@astrojs/vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
const LOCALES = ['en', 'zh-CN'] as const
|
||||
const DEFAULT_LOCALE = 'en'
|
||||
const PAYMENT_STATUSES = ['success', 'failed'] as const
|
||||
const LOCALE_PREFIXES = LOCALES.map((locale) =>
|
||||
locale === DEFAULT_LOCALE ? '' : `/${locale}`
|
||||
)
|
||||
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
|
||||
LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
|
||||
)
|
||||
)
|
||||
|
||||
function isExcludedFromSitemap(page: string): boolean {
|
||||
const pathname = new URL(page).pathname.replace(/\/$/, '')
|
||||
return SITEMAP_EXCLUDED_PATHNAMES.has(pathname)
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://comfy.org',
|
||||
output: 'static',
|
||||
@@ -34,12 +17,7 @@ export default defineConfig({
|
||||
assets: '_website'
|
||||
},
|
||||
devToolbar: { enabled: !process.env.NO_TOOLBAR },
|
||||
integrations: [
|
||||
vue(),
|
||||
sitemap({
|
||||
filter: (page) => !isExcludedFromSitemap(page)
|
||||
})
|
||||
],
|
||||
integrations: [vue(), sitemap()],
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
server: {
|
||||
@@ -49,8 +27,8 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
i18n: {
|
||||
locales: [...LOCALES],
|
||||
defaultLocale: DEFAULT_LOCALE,
|
||||
locales: ['en', 'zh-CN'],
|
||||
defaultLocale: 'en',
|
||||
routing: {
|
||||
prefixDefaultLocale: false
|
||||
}
|
||||
|
||||
@@ -23,50 +23,26 @@ test.describe('Careers page @smoke', () => {
|
||||
expect(await roles.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('clicking a department button scrolls to and activates that section', async ({
|
||||
page
|
||||
}) => {
|
||||
const rolesSection = page.getByTestId('careers-roles')
|
||||
await rolesSection.scrollIntoViewIfNeeded()
|
||||
await expect(rolesSection).toBeVisible()
|
||||
|
||||
const allCount = await page.getByTestId('careers-role-link').count()
|
||||
|
||||
const engineeringButton = page.getByRole('button', {
|
||||
name: 'ENGINEERING',
|
||||
exact: true
|
||||
})
|
||||
|
||||
// RolesSection is hydrated via `client:visible`. Once the button responds
|
||||
// to a click by flipping aria-pressed, Vue is hydrated and the rest of
|
||||
// the locator logic is in effect.
|
||||
await expect(async () => {
|
||||
await engineeringButton.click()
|
||||
await expect(engineeringButton).toHaveAttribute('aria-pressed', 'true', {
|
||||
timeout: 1_000
|
||||
})
|
||||
}).toPass({ timeout: 10_000 })
|
||||
|
||||
const engineeringSection = page.locator('#careers-dept-engineering')
|
||||
await expect(engineeringSection).toBeInViewport()
|
||||
|
||||
expect(await page.getByTestId('careers-role-link').count()).toBe(allCount)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Careers page role links', () => {
|
||||
test('each role links to the Ashby job description page, not the application form', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/careers')
|
||||
test('each role links to jobs.ashbyhq.com', async ({ page }) => {
|
||||
const roles = page.getByTestId('careers-role-link')
|
||||
const count = await roles.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const href = await roles.nth(i).getAttribute('href')
|
||||
expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//)
|
||||
expect(href).not.toMatch(/\/application\/?$/)
|
||||
}
|
||||
})
|
||||
|
||||
test('ENGINEERING category filter narrows the role list', async ({
|
||||
page
|
||||
}) => {
|
||||
const allCount = await page.getByTestId('careers-role-link').count()
|
||||
await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click()
|
||||
const engineeringLocator = page.getByTestId('careers-role-link')
|
||||
await expect(engineeringLocator.first()).toBeVisible()
|
||||
const engineeringCount = await engineeringLocator.count()
|
||||
expect(engineeringCount).toBeLessThanOrEqual(allCount)
|
||||
expect(engineeringCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Careers page (zh-CN) @smoke', () => {
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Cloud nodes page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/cloud/supported-nodes')
|
||||
})
|
||||
|
||||
test('has correct title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(
|
||||
'Custom-node packs on Comfy Cloud — supported by default'
|
||||
)
|
||||
})
|
||||
|
||||
test('renders at least one pack card', async ({ page }) => {
|
||||
const cards = page.getByTestId('cloud-node-pack-card')
|
||||
await expect(cards.first()).toBeVisible()
|
||||
expect(await cards.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('search input filters cards down', async ({ page }) => {
|
||||
const cards = page.getByTestId('cloud-node-pack-card')
|
||||
const initialCount = await cards.count()
|
||||
|
||||
await page.getByTestId('cloud-nodes-search').fill('impact')
|
||||
const filteredCards = page.getByTestId('cloud-node-pack-card')
|
||||
await expect(filteredCards.first()).toBeVisible()
|
||||
const filteredCount = await filteredCards.count()
|
||||
|
||||
expect(filteredCount).toBeGreaterThan(0)
|
||||
expect(filteredCount).toBeLessThanOrEqual(initialCount)
|
||||
})
|
||||
|
||||
test('renders banner image or fallback element', async ({ page }) => {
|
||||
const banners = page.getByTestId('cloud-node-pack-banner')
|
||||
await expect(banners.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens pack detail page from first card', async ({ page }) => {
|
||||
const firstCard = page.getByTestId('cloud-node-pack-card').first()
|
||||
await expect(firstCard).toBeVisible()
|
||||
|
||||
await firstCard.locator('a').first().click()
|
||||
|
||||
await expect(page).toHaveURL(/\/cloud\/supported-nodes\/[a-z0-9-]+$/)
|
||||
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
|
||||
})
|
||||
|
||||
test('direct pack detail route renders node entries', async ({ page }) => {
|
||||
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
|
||||
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
|
||||
await expect(
|
||||
page.getByTestId('cloud-node-pack-detail-node').first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('search with no matches shows empty state', async ({ page }) => {
|
||||
await page
|
||||
.getByTestId('cloud-nodes-search')
|
||||
.fill('zzzz-this-pack-does-not-exist')
|
||||
await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(0)
|
||||
await expect(page.getByText('No matching packs')).toBeVisible()
|
||||
})
|
||||
|
||||
test('clearing search restores the full list', async ({ page }) => {
|
||||
const cards = page.getByTestId('cloud-node-pack-card')
|
||||
const initialCount = await cards.count()
|
||||
|
||||
await page.getByTestId('cloud-nodes-search').fill('impact')
|
||||
await expect(cards.first()).toBeVisible()
|
||||
|
||||
await page.getByTestId('cloud-nodes-search').fill('')
|
||||
await expect(cards).toHaveCount(initialCount)
|
||||
})
|
||||
|
||||
test('search matches against node display names, not just pack names', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.getByTestId('cloud-nodes-search').fill('FaceDetailer')
|
||||
await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(1)
|
||||
await expect(
|
||||
page.getByTestId('cloud-node-pack-card-link').first()
|
||||
).toContainText('Impact Pack')
|
||||
})
|
||||
|
||||
test('switching sort to A → Z reorders cards alphabetically', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.locator('#cloud-nodes-sort').selectOption('az')
|
||||
const firstName = await page
|
||||
.getByTestId('cloud-node-pack-card-link')
|
||||
.first()
|
||||
.textContent()
|
||||
expect(firstName?.trim().toLowerCase().charAt(0)).toMatch(/^[a-c]/)
|
||||
})
|
||||
|
||||
test('list grid carries a localized aria-label', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('list', {
|
||||
name: 'Custom-node packs supported on Comfy Cloud'
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking the back link returns to the index from a detail page', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
|
||||
await page.getByRole('link', { name: 'Back to all packs' }).click()
|
||||
await expect(page).toHaveURL(/\/cloud\/supported-nodes\/?$/)
|
||||
await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('detail page renders publisher and external repo link', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/cloud/supported-nodes/comfyui-impact-pack')
|
||||
const repoLink = page.getByRole('link', {
|
||||
name: /github\.com\/ltdrdata\/ComfyUI-Impact-Pack/
|
||||
})
|
||||
await expect(repoLink).toBeVisible()
|
||||
await expect(repoLink).toHaveAttribute('rel', /noopener/)
|
||||
})
|
||||
|
||||
test('unknown pack slug 404s', async ({ page }) => {
|
||||
const response = await page.goto(
|
||||
'/cloud/supported-nodes/this-pack-does-not-exist'
|
||||
)
|
||||
expect(response?.status()).toBe(404)
|
||||
})
|
||||
|
||||
test('JSON-LD ItemList is emitted on the index page', async ({ page }) => {
|
||||
const jsonLd = page.locator('script[type="application/ld+json"]')
|
||||
const ldBlocks = await jsonLd.allTextContents()
|
||||
expect(ldBlocks.some((b) => b.includes('"@type":"ItemList"'))).toBeTruthy()
|
||||
})
|
||||
|
||||
test('JSON-LD payload escapes <-sequences', async ({ page }) => {
|
||||
const ldBlocks = await page
|
||||
.locator('script[type="application/ld+json"]')
|
||||
.allTextContents()
|
||||
for (const block of ldBlocks) {
|
||||
expect(block).not.toContain('</script')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cloud nodes page (zh-CN) @smoke', () => {
|
||||
test('renders localized title and packs', async ({ page }) => {
|
||||
await page.goto('/zh-CN/cloud/supported-nodes')
|
||||
await expect(page).toHaveTitle('Comfy Cloud 自定义节点包合集——开箱即用')
|
||||
await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible()
|
||||
await expect(
|
||||
page.getByTestId('cloud-node-pack-banner').first()
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens pack detail page from first card', async ({ page }) => {
|
||||
await page.goto('/zh-CN/cloud/supported-nodes')
|
||||
const firstCard = page.getByTestId('cloud-node-pack-card').first()
|
||||
await expect(firstCard).toBeVisible()
|
||||
|
||||
await firstCard.locator('a').first().click()
|
||||
|
||||
await expect(page).toHaveURL(/\/zh-CN\/cloud\/supported-nodes\/[a-z0-9-]+$/)
|
||||
await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const M4_PRO_14_INCH_VIEWPORT = { width: 2016, height: 1310 }
|
||||
const LAST_SECTION_HASH = '#contact'
|
||||
|
||||
test.describe(
|
||||
'ContentSection scroll-spy @smoke',
|
||||
{
|
||||
annotation: [
|
||||
{
|
||||
type: 'issue',
|
||||
description:
|
||||
'https://linear.app/comfyorg/issue/FE-604/bug-bottom-badge-not-activating-on-scroll-at-high-resolution-3024x1964'
|
||||
},
|
||||
{
|
||||
type: 'environment',
|
||||
description:
|
||||
'14" MacBook M4 Pro logical viewport reported in FE-604; /privacy-policy reproduces because of its short trailing sections'
|
||||
}
|
||||
]
|
||||
},
|
||||
() => {
|
||||
test.use({ viewport: M4_PRO_14_INCH_VIEWPORT })
|
||||
|
||||
test('activates the last badge when user scrolls to the bottom', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/privacy-policy')
|
||||
|
||||
const sidebarNav = page.getByRole('navigation', {
|
||||
name: 'Category filter'
|
||||
})
|
||||
const badges = sidebarNav.getByRole('button')
|
||||
const lastBadge = badges.last()
|
||||
|
||||
await expect(badges.first()).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect(lastBadge).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
await page.evaluate(() =>
|
||||
window.scrollTo(0, document.documentElement.scrollHeight)
|
||||
)
|
||||
|
||||
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('activates the last badge when page mounts already at the bottom via trailing hash', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(`/privacy-policy${LAST_SECTION_HASH}`)
|
||||
|
||||
const sidebarNav = page.getByRole('navigation', {
|
||||
name: 'Category filter'
|
||||
})
|
||||
const lastBadge = sidebarNav.getByRole('button').last()
|
||||
|
||||
await expect(lastBadge).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,33 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Customers @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/customers')
|
||||
})
|
||||
|
||||
test('hero image declares intrinsic dimensions so layout reserves space before load', async ({
|
||||
page
|
||||
}) => {
|
||||
const heroImage = page.locator('img[alt="Comfy 3D logo"]')
|
||||
await expect(heroImage).toBeVisible()
|
||||
await expect(heroImage).toHaveAttribute('width', /^\d+$/)
|
||||
await expect(heroImage).toHaveAttribute('height', /^\d+$/)
|
||||
|
||||
// Regression guard: an unloaded <img> without intrinsic dimensions
|
||||
// collapses to ~0px, then jumps to its natural size on load and pushes
|
||||
// the video below it. Reserved space must persist before bytes arrive.
|
||||
const heightWhileUnloaded = await page.evaluate(() => {
|
||||
const img = document.querySelector<HTMLImageElement>(
|
||||
'img[alt="Comfy 3D logo"]'
|
||||
)
|
||||
if (!img) return null
|
||||
img.removeAttribute('src')
|
||||
return img.getBoundingClientRect().height
|
||||
})
|
||||
|
||||
expect(heightWhileUnloaded).not.toBeNull()
|
||||
expect(heightWhileUnloaded!).toBeGreaterThan(100)
|
||||
})
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import { demos, getNextDemo } from '../src/config/demos'
|
||||
import { t } from '../src/i18n/translations'
|
||||
|
||||
const escapeRegExp = (value: string): string =>
|
||||
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
test.describe('Demo pages @smoke', () => {
|
||||
for (const demo of demos) {
|
||||
const nextDemo = getNextDemo(demo.slug)
|
||||
|
||||
test(`/demos/${demo.slug} renders hero, embed, transcript, and next-demo nav`, async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(`/demos/${demo.slug}`)
|
||||
|
||||
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')
|
||||
await expect(page.getByText('Coming Soon')).toBeVisible()
|
||||
})
|
||||
|
||||
test('non-existent demo returns 404', async ({ page }) => {
|
||||
const response = await page.goto('/demos/nonexistent')
|
||||
expect(response?.status()).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -46,7 +46,7 @@ test.describe('Download page @smoke', () => {
|
||||
await expect(githubBtn).toBeVisible()
|
||||
await expect(githubBtn).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/Comfy-Org/ComfyUI#installing'
|
||||
'https://github.com/Comfy-Org/ComfyUI'
|
||||
)
|
||||
|
||||
await context.close()
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { externalLinks } from '../src/config/routes'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const CLOUD_URL = externalLinks.cloud
|
||||
const PLATFORM_USAGE_URL = externalLinks.platformUsage
|
||||
const SUPPORT_URL = externalLinks.support
|
||||
const DOCS_SUBSCRIPTION_URL = externalLinks.docsSubscription
|
||||
|
||||
async function expectNoIndex(page: Page) {
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveAttribute(
|
||||
'content',
|
||||
'noindex, nofollow'
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Payment success page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/payment/success')
|
||||
})
|
||||
|
||||
test('has correct title and is noindex', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Payment Successful — Comfy')
|
||||
await expectNoIndex(page)
|
||||
})
|
||||
|
||||
test('shows success heading and subtitle', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Payment successful/i, level: 1 })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText(/Thanks for your purchase/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('primary CTA links to Comfy Cloud', async ({ page }) => {
|
||||
const cta = page.getByRole('link', { name: /CONTINUE TO COMFY CLOUD/i })
|
||||
await expect(cta).toBeVisible()
|
||||
await expect(cta).toHaveAttribute('href', CLOUD_URL)
|
||||
})
|
||||
|
||||
test('secondary CTA links to platform usage & payments page', async ({
|
||||
page
|
||||
}) => {
|
||||
const cta = page.getByRole('link', { name: /VIEW USAGE & PAYMENTS/i })
|
||||
await expect(cta).toBeVisible()
|
||||
await expect(cta).toHaveAttribute('href', PLATFORM_USAGE_URL)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Payment failed page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/payment/failed')
|
||||
})
|
||||
|
||||
test('has correct title and is noindex', async ({ page }) => {
|
||||
await expect(page).toHaveTitle('Payment Failed — Comfy')
|
||||
await expectNoIndex(page)
|
||||
})
|
||||
|
||||
test('shows failure heading and subtitle', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: /Unable to complete payment/i,
|
||||
level: 1
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(page.getByText(/payment didn't go through/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('primary CTA links to support help center', async ({ page }) => {
|
||||
const cta = page.getByRole('link', { name: /CONTACT SUPPORT/i })
|
||||
await expect(cta).toBeVisible()
|
||||
await expect(cta).toHaveAttribute('href', SUPPORT_URL)
|
||||
})
|
||||
|
||||
test('secondary CTA links to subscription docs', async ({ page }) => {
|
||||
const cta = page.getByRole('link', { name: /READ SUBSCRIPTION DOCS/i })
|
||||
await expect(cta).toBeVisible()
|
||||
await expect(cta).toHaveAttribute('href', DOCS_SUBSCRIPTION_URL)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Payment pages zh-CN @smoke', () => {
|
||||
test('zh-CN success page renders and links correctly', async ({ page }) => {
|
||||
await page.goto('/zh-CN/payment/success')
|
||||
await expect(page).toHaveTitle('支付成功 — Comfy')
|
||||
await expectNoIndex(page)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '支付成功', level: 1 })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('link', { name: '前往 COMFY CLOUD' })
|
||||
).toHaveAttribute('href', CLOUD_URL)
|
||||
await expect(
|
||||
page.getByRole('link', { name: '查看用量与支付' })
|
||||
).toHaveAttribute('href', PLATFORM_USAGE_URL)
|
||||
})
|
||||
|
||||
test('zh-CN failed page renders and links correctly', async ({ page }) => {
|
||||
await page.goto('/zh-CN/payment/failed')
|
||||
await expect(page).toHaveTitle('支付失败 — Comfy')
|
||||
await expectNoIndex(page)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '无法完成支付', level: 1 })
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: '联系支持' })).toHaveAttribute(
|
||||
'href',
|
||||
SUPPORT_URL
|
||||
)
|
||||
await expect(
|
||||
page.getByRole('link', { name: '查看订阅文档' })
|
||||
).toHaveAttribute('href', DOCS_SUBSCRIPTION_URL)
|
||||
})
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Pricing page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/cloud/pricing')
|
||||
})
|
||||
|
||||
test('shows the three paid tiers and Enterprise', async ({ page }) => {
|
||||
const pricingGrid = page
|
||||
.locator('section', {
|
||||
has: page.getByRole('heading', { name: /Pricing/i })
|
||||
})
|
||||
.locator('.lg\\:grid')
|
||||
|
||||
for (const label of ['STANDARD', 'CREATOR', 'PRO']) {
|
||||
await expect(
|
||||
pricingGrid.locator('span', { hasText: new RegExp(`^${label}$`) })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Looking for Enterprise Solutions/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('does not show the Free tier when SHOW_FREE_TIER is disabled', async ({
|
||||
page
|
||||
}) => {
|
||||
const pricingGrid = page
|
||||
.locator('section', {
|
||||
has: page.getByRole('heading', { name: /Pricing/i })
|
||||
})
|
||||
.locator('.lg\\:grid')
|
||||
|
||||
await expect(
|
||||
pricingGrid.locator('span', { hasText: /^FREE$/ })
|
||||
).toHaveCount(0)
|
||||
await expect(page.getByRole('link', { name: /^START FREE$/ })).toHaveCount(
|
||||
0
|
||||
)
|
||||
await expect(page.getByText(/Everything in Free, plus:/i)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cloud pricing teaser @smoke', () => {
|
||||
test('does not show the "Start free" tagline when SHOW_FREE_TIER is disabled', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/cloud')
|
||||
await expect(
|
||||
page.getByText(/Start free\.\s*Upgrade when you're ready\./i)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
@@ -48,105 +47,4 @@ test.describe('Mobile layout @mobile', () => {
|
||||
const mobileContainer = page.getByTestId('social-proof-mobile')
|
||||
await expect(mobileContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('SocialProofBar seamless marquee', () => {
|
||||
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
|
||||
|
||||
test('mobile forward marquee loops seamlessly', async ({ page }) => {
|
||||
const geometry = await measureMarqueeLoopGeometry(
|
||||
page,
|
||||
'[data-testid="social-proof-mobile"] .animate-marquee'
|
||||
)
|
||||
expectSeamlessForwardLoop(geometry)
|
||||
})
|
||||
|
||||
test('mobile reverse marquee loops seamlessly', async ({ page }) => {
|
||||
const geometry = await measureMarqueeLoopGeometry(
|
||||
page,
|
||||
'[data-testid="social-proof-mobile"] .animate-marquee-reverse'
|
||||
)
|
||||
expectSeamlessReverseLoop(geometry)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Desktop SocialProofBar @smoke', () => {
|
||||
test.use({ contextOptions: { reducedMotion: 'no-preference' } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
})
|
||||
|
||||
test('desktop marquee loops seamlessly', async ({ page }) => {
|
||||
const geometry = await measureMarqueeLoopGeometry(
|
||||
page,
|
||||
'[data-testid="social-proof-desktop"] .animate-marquee'
|
||||
)
|
||||
expectSeamlessForwardLoop(geometry)
|
||||
})
|
||||
})
|
||||
|
||||
type MarqueeGeometry = {
|
||||
copyWidths: number[]
|
||||
startPositions: number[]
|
||||
endPositions: number[]
|
||||
}
|
||||
|
||||
async function measureMarqueeLoopGeometry(
|
||||
page: Page,
|
||||
selector: string
|
||||
): Promise<MarqueeGeometry> {
|
||||
await page.locator(selector).first().waitFor()
|
||||
return page.evaluate((sel) => {
|
||||
const tracks = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(sel)
|
||||
).slice(0, 2)
|
||||
const firstAnimation = tracks[0]?.getAnimations()[0]
|
||||
if (!firstAnimation) {
|
||||
throw new Error(`No CSS animation found on ${sel}`)
|
||||
}
|
||||
const duration = firstAnimation.effect?.getTiming().duration
|
||||
if (typeof duration !== 'number' || duration <= 1) {
|
||||
throw new Error(
|
||||
`Animation on ${sel} has unusable duration: ${String(duration)}`
|
||||
)
|
||||
}
|
||||
const setAllTimes = (time: number) => {
|
||||
for (const track of tracks) {
|
||||
for (const anim of track.getAnimations()) {
|
||||
anim.currentTime = time
|
||||
}
|
||||
}
|
||||
void document.body.offsetWidth
|
||||
}
|
||||
const readX = () => tracks.map((track) => track.getBoundingClientRect().x)
|
||||
setAllTimes(0)
|
||||
const startPositions = readX()
|
||||
const copyWidths = tracks.map(
|
||||
(track) => track.getBoundingClientRect().width
|
||||
)
|
||||
setAllTimes(duration - 0.1)
|
||||
const endPositions = readX()
|
||||
return { copyWidths, startPositions, endPositions }
|
||||
}, selector)
|
||||
}
|
||||
|
||||
function expectTwoMatchingCopies(geometry: MarqueeGeometry) {
|
||||
const { copyWidths } = geometry
|
||||
expect(copyWidths.length, 'expected two duplicate marquee tracks').toBe(2)
|
||||
expect(copyWidths[0]).toBeGreaterThan(0)
|
||||
expect(copyWidths[1]).toBeCloseTo(copyWidths[0], 0)
|
||||
}
|
||||
|
||||
function expectSeamlessForwardLoop(geometry: MarqueeGeometry) {
|
||||
expectTwoMatchingCopies(geometry)
|
||||
// Copy 2 ends the cycle exactly where copy 1 started, so the restart
|
||||
// (when copy 1 jumps back to its start position) is visually indistinguishable.
|
||||
expect(geometry.endPositions[1]).toBeCloseTo(geometry.startPositions[0], 0)
|
||||
}
|
||||
|
||||
function expectSeamlessReverseLoop(geometry: MarqueeGeometry) {
|
||||
expectTwoMatchingCopies(geometry)
|
||||
// Reverse marquee: copy 1 ends the cycle where copy 2 started.
|
||||
expect(geometry.endPositions[0]).toBeCloseTo(geometry.startPositions[1], 0)
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ async function assertNoOverflow(page: Page) {
|
||||
}
|
||||
|
||||
async function navigateAndSettle(page: Page, url: string) {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForLoadState('load')
|
||||
await page.goto(url)
|
||||
await page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
test.describe('Home', { tag: '@visual' }, () => {
|
||||
@@ -126,7 +126,6 @@ test.describe('Overflow guards', { tag: '@visual' }, () => {
|
||||
const pages = [
|
||||
'/',
|
||||
'/cloud',
|
||||
'/cloud/enterprise',
|
||||
'/cloud/pricing',
|
||||
'/contact',
|
||||
'/download',
|
||||
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 96 KiB |
@@ -15,15 +15,11 @@
|
||||
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
|
||||
"test:visual": "playwright test --project visual",
|
||||
"test:visual:update": "playwright test --project visual --update-snapshots",
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
|
||||
"cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts",
|
||||
"generate:models": "tsx ./scripts/generate-models.ts"
|
||||
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/object-info-parser": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@vercel/analytics": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
@@ -31,7 +27,6 @@
|
||||
"gsap": "catalog:",
|
||||
"lenis": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"three": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -28,7 +28,7 @@ export default defineConfig({
|
||||
? [['html'], ['json', { outputFile: 'results.json' }]]
|
||||
: 'html',
|
||||
expect: {
|
||||
toHaveScreenshot: { maxDiffPixels: 100 }
|
||||
toHaveScreenshot: { maxDiffPixels: 50 }
|
||||
},
|
||||
...maybeLocalOptions,
|
||||
webServer: {
|
||||
|
||||
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 20 KiB |
@@ -29,30 +29,5 @@ Allow: /
|
||||
Disallow: /_astro/
|
||||
Disallow: /_website/
|
||||
Disallow: /_vercel/
|
||||
Disallow: /payment/
|
||||
|
||||
User-agent: GPTBot
|
||||
Allow: /
|
||||
|
||||
User-agent: OAI-SearchBot
|
||||
Allow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Allow: /
|
||||
|
||||
User-agent: ClaudeBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Claude-User
|
||||
Allow: /
|
||||
|
||||
User-agent: Claude-SearchBot
|
||||
Allow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://comfy.org/sitemap-index.xml
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const WORKFLOW_TEMPLATES_BASE =
|
||||
'https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates'
|
||||
|
||||
const TEMPLATES_DIR = fileURLToPath(
|
||||
new URL('../../../../workflow_templates/templates', import.meta.url)
|
||||
)
|
||||
|
||||
const QUANT_SUFFIXES = [
|
||||
'_fp8_e4m3fn_scaled',
|
||||
'_fp8_e4m3fn',
|
||||
'_fp8_scaled',
|
||||
'_fp4_mixed',
|
||||
'_fp8mixed',
|
||||
'_fp8',
|
||||
'_fp16',
|
||||
'_fp4',
|
||||
'_bf16',
|
||||
'_int8'
|
||||
]
|
||||
|
||||
interface RawModel {
|
||||
name: string
|
||||
url: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
interface ModelData {
|
||||
url: string
|
||||
directory: string
|
||||
templates: Set<string>
|
||||
firstTemplate?: string
|
||||
}
|
||||
|
||||
interface OutputModel {
|
||||
slug: string
|
||||
name: string
|
||||
huggingFaceUrl: string
|
||||
directory: string
|
||||
workflowCount: number
|
||||
displayName: string
|
||||
docsUrl?: string
|
||||
thumbnailUrl?: string
|
||||
canonicalSlug?: string
|
||||
}
|
||||
|
||||
// Maps api_*.json filename prefix to a canonical display name and slug.
|
||||
// Add entries here as new partner integrations land in workflow_templates.
|
||||
const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
|
||||
nano: { name: 'Nano Banana', slug: 'nano-banana' },
|
||||
kling: { name: 'Kling AI', slug: 'kling-ai' },
|
||||
kling2: { name: 'Kling AI', slug: 'kling-ai' },
|
||||
meshy: { name: 'Meshy AI', slug: 'meshy-ai' },
|
||||
luma: { name: 'Luma Dream Machine', slug: 'luma-dream-machine' },
|
||||
runway: { name: 'Runway', slug: 'runway' },
|
||||
vidu: { name: 'Vidu', slug: 'vidu' },
|
||||
bfl: { name: 'Flux (API)', slug: 'flux-api' },
|
||||
grok: { name: 'Grok Image', slug: 'grok-image' },
|
||||
stability: { name: 'Stability AI', slug: 'stability-ai' },
|
||||
bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
|
||||
bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
|
||||
google: { name: 'Gemini Image', slug: 'gemini-image' },
|
||||
hailuo: { name: 'Hailuo MiniMax', slug: 'hailuo-minimax' },
|
||||
ideogram: { name: 'Ideogram', slug: 'ideogram' },
|
||||
pixverse: { name: 'Pixverse', slug: 'pixverse' },
|
||||
rodin: { name: 'Rodin 3D', slug: 'rodin-3d' },
|
||||
magnific: { name: 'Magnific AI', slug: 'magnific-ai' },
|
||||
bria: { name: 'Bria AI', slug: 'bria-ai' },
|
||||
tripo: { name: 'Tripo 3D', slug: 'tripo-3d' },
|
||||
tripo3: { name: 'Tripo 3D', slug: 'tripo-3d' },
|
||||
hunyuan3d: { name: 'Hunyuan 3D', slug: 'hunyuan-3d' },
|
||||
recraft: { name: 'Recraft', slug: 'recraft' },
|
||||
topaz: { name: 'Topaz Labs', slug: 'topaz-labs' },
|
||||
moonvalley: { name: 'Moonvalley', slug: 'moonvalley' },
|
||||
ltxv: { name: 'LTX Video (API)', slug: 'ltxv-api' },
|
||||
openai: { name: 'OpenAI DALL-E', slug: 'openai-dall-e' },
|
||||
wan: { name: 'Wan (API)', slug: 'wan-api' },
|
||||
wan2: { name: 'Wan (API)', slug: 'wan-api' },
|
||||
veo2: { name: 'Veo 2', slug: 'veo-2' },
|
||||
veo3: { name: 'Veo 3', slug: 'veo-3' },
|
||||
flux2: { name: 'Flux 2 (API)', slug: 'flux-2-api' },
|
||||
wavespeed: { name: 'Wavespeed', slug: 'wavespeed' },
|
||||
wavespped: { name: 'Wavespeed', slug: 'wavespeed' }
|
||||
}
|
||||
|
||||
function stripExt(name: string): string {
|
||||
return name.replace(/\.(safetensors|ckpt|pt|bin)$/, '')
|
||||
}
|
||||
|
||||
function stripQuant(base: string): string {
|
||||
for (const suffix of QUANT_SUFFIXES) {
|
||||
if (base.endsWith(suffix)) return base.slice(0, -suffix.length)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function makeSlug(name: string): string {
|
||||
const base = stripExt(name)
|
||||
return base
|
||||
.toLowerCase()
|
||||
.replace(/[_.]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function makeDisplayName(name: string): string {
|
||||
const base = stripExt(name)
|
||||
return base
|
||||
.split(/[_-]/)
|
||||
.map((part) => {
|
||||
if (/^(fp\d+|bf\d+|int\d+)$/i.test(part)) return part.toUpperCase()
|
||||
if (/^(e4m3fn|scaled|mixed|fp8mixed)$/i.test(part)) return part
|
||||
if (/^\d+(\.\d+)?[bBkKmM]?$/.test(part)) return part
|
||||
return part.charAt(0).toUpperCase() + part.slice(1)
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function extractModels(
|
||||
obj: unknown,
|
||||
templateName: string,
|
||||
models: Map<string, ModelData>
|
||||
): void {
|
||||
if (obj === null || typeof obj !== 'object') return
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) extractModels(item, templateName, models)
|
||||
return
|
||||
}
|
||||
|
||||
const record = obj as Record<string, unknown>
|
||||
|
||||
if (Array.isArray(record['models'])) {
|
||||
for (const m of record['models'] as unknown[]) {
|
||||
if (m === null || typeof m !== 'object' || Array.isArray(m)) continue
|
||||
const model = m as Record<string, unknown>
|
||||
if (typeof model['name'] !== 'string') continue
|
||||
|
||||
const name = model['name']
|
||||
const url = typeof model['url'] === 'string' ? model['url'] : ''
|
||||
const directory =
|
||||
typeof model['directory'] === 'string' ? model['directory'] : ''
|
||||
|
||||
if (!models.has(name)) {
|
||||
models.set(name, {
|
||||
url,
|
||||
directory,
|
||||
templates: new Set(),
|
||||
firstTemplate: templateName
|
||||
})
|
||||
}
|
||||
models.get(name)!.templates.add(templateName)
|
||||
}
|
||||
}
|
||||
|
||||
for (const value of Object.values(record)) {
|
||||
extractModels(value, templateName, models)
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiModelData {
|
||||
slug: string
|
||||
name: string
|
||||
directory: 'partner_nodes'
|
||||
templateCount: number
|
||||
}
|
||||
|
||||
function extractApiModels(files: string[]): ApiModelData[] {
|
||||
const counts = new Map<string, number>()
|
||||
for (const file of files) {
|
||||
if (!file.startsWith('api_')) continue
|
||||
const prefix = file.slice(4).split('_')[0]
|
||||
const entry = API_PROVIDER_MAP[prefix]
|
||||
if (!entry) continue
|
||||
counts.set(entry.slug, (counts.get(entry.slug) ?? 0) + 1)
|
||||
}
|
||||
return [...counts.entries()].map(([slug, count]) => {
|
||||
const found = Object.values(API_PROVIDER_MAP).find((e) => e.slug === slug)!
|
||||
return {
|
||||
slug,
|
||||
name: found.name,
|
||||
directory: 'partner_nodes' as const,
|
||||
templateCount: count
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Reads all locale index.json files to build a map of
|
||||
// raw model filename → tutorialUrl. Index entries name the template file;
|
||||
// that file's embedded model objects give the actual filenames.
|
||||
function buildTutorialUrlMap(templatesDir: string): Map<string, string> {
|
||||
const map = new Map<string, string>()
|
||||
const indexFiles = readdirSync(templatesDir).filter(
|
||||
(f) =>
|
||||
f.startsWith('index') &&
|
||||
f.endsWith('.json') &&
|
||||
!f.includes('schema') &&
|
||||
!f.includes('logo')
|
||||
)
|
||||
// Collect template-name → tutorialUrl from all locale indexes (first wins)
|
||||
const templateTutorialMap = new Map<string, string>()
|
||||
const sorted = ['index.json', ...indexFiles.filter((f) => f !== 'index.json')]
|
||||
for (const file of sorted) {
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(readFileSync(join(templatesDir, file), 'utf8'))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!Array.isArray(data)) continue
|
||||
for (const cat of data as unknown[]) {
|
||||
if (typeof cat !== 'object' || cat === null) continue
|
||||
const templates = (cat as Record<string, unknown>)['templates']
|
||||
if (!Array.isArray(templates)) continue
|
||||
for (const t of templates) {
|
||||
if (typeof t !== 'object' || t === null) continue
|
||||
const entry = t as Record<string, unknown>
|
||||
const tutorialUrl =
|
||||
typeof entry['tutorialUrl'] === 'string'
|
||||
? entry['tutorialUrl']
|
||||
: undefined
|
||||
const templateName =
|
||||
typeof entry['name'] === 'string' ? entry['name'] : undefined
|
||||
if (
|
||||
tutorialUrl &&
|
||||
templateName &&
|
||||
!templateTutorialMap.has(templateName)
|
||||
) {
|
||||
templateTutorialMap.set(templateName, tutorialUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For each template with a tutorialUrl, open the template file and map
|
||||
// every embedded model filename to that tutorialUrl
|
||||
for (const [templateName, tutorialUrl] of templateTutorialMap) {
|
||||
const filePath = join(templatesDir, `${templateName}.json`)
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(readFileSync(filePath, 'utf8'))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
function extractModelNames(obj: unknown): void {
|
||||
if (obj === null || typeof obj !== 'object') return
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) extractModelNames(item)
|
||||
return
|
||||
}
|
||||
const record = obj as Record<string, unknown>
|
||||
if (Array.isArray(record['models'])) {
|
||||
for (const m of record['models'] as unknown[]) {
|
||||
if (m === null || typeof m !== 'object' || Array.isArray(m)) continue
|
||||
const model = m as Record<string, unknown>
|
||||
if (typeof model['name'] === 'string' && !map.has(model['name'])) {
|
||||
map.set(model['name'], tutorialUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const value of Object.values(record)) {
|
||||
extractModelNames(value)
|
||||
}
|
||||
}
|
||||
|
||||
extractModelNames(data)
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
function templateThumbnailUrl(
|
||||
firstTemplate: string | undefined,
|
||||
templatesDir: string
|
||||
): string | undefined {
|
||||
if (!firstTemplate) return undefined
|
||||
const base = firstTemplate.replace(/\.json$/, '')
|
||||
const localPath = join(templatesDir, `${base}-1.webp`)
|
||||
if (!existsSync(localPath)) return undefined
|
||||
return `${WORKFLOW_TEMPLATES_BASE}/${encodeURIComponent(base)}-1.webp`
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
const models = new Map<string, ModelData>()
|
||||
|
||||
const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith('.json'))
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(TEMPLATES_DIR, file)
|
||||
try {
|
||||
const raw = readFileSync(filePath, 'utf8')
|
||||
const data: unknown = JSON.parse(raw)
|
||||
extractModels(data, file, models)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse ${file}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const apiModels = extractApiModels(files)
|
||||
const tutorialUrlMap = buildTutorialUrlMap(TEMPLATES_DIR)
|
||||
|
||||
const sorted = [...models.entries()].sort(
|
||||
([, a], [, b]) => b.templates.size - a.templates.size
|
||||
)
|
||||
|
||||
// Build quant convergence map
|
||||
const groups = new Map<string, Array<[string, ModelData]>>()
|
||||
for (const [name, data] of sorted) {
|
||||
const base = stripExt(name)
|
||||
const canonicalBase = stripQuant(base)
|
||||
if (!groups.has(canonicalBase)) groups.set(canonicalBase, [])
|
||||
groups.get(canonicalBase)!.push([name, data])
|
||||
}
|
||||
|
||||
const canonicalMap = new Map<string, string | null>()
|
||||
for (const members of groups.values()) {
|
||||
if (members.length > 1) {
|
||||
const membersSorted = [...members].sort(
|
||||
([, a], [, b]) => b.templates.size - a.templates.size
|
||||
)
|
||||
const canonicalName = membersSorted[0][0]
|
||||
canonicalMap.set(canonicalName, null)
|
||||
for (const [name] of membersSorted.slice(1)) {
|
||||
canonicalMap.set(name, canonicalName)
|
||||
}
|
||||
} else {
|
||||
canonicalMap.set(members[0][0], null)
|
||||
}
|
||||
}
|
||||
|
||||
const output: OutputModel[] = sorted.map(([name, data]) => {
|
||||
const canonicalRaw = canonicalMap.get(name) ?? null
|
||||
const result: OutputModel = {
|
||||
slug: makeSlug(name),
|
||||
name,
|
||||
huggingFaceUrl: data.url,
|
||||
directory: data.directory,
|
||||
workflowCount: data.templates.size,
|
||||
displayName: makeDisplayName(name)
|
||||
}
|
||||
const docsUrl = tutorialUrlMap.get(name)
|
||||
if (docsUrl) result.docsUrl = docsUrl
|
||||
const thumb = templateThumbnailUrl(data.firstTemplate, TEMPLATES_DIR)
|
||||
if (thumb) result.thumbnailUrl = thumb
|
||||
if (canonicalRaw !== null) {
|
||||
result.canonicalSlug = makeSlug(canonicalRaw)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const apiOutput: OutputModel[] = apiModels
|
||||
.sort((a, b) => b.templateCount - a.templateCount)
|
||||
.map((m) => ({
|
||||
slug: m.slug,
|
||||
name: m.name,
|
||||
huggingFaceUrl: '',
|
||||
directory: m.directory,
|
||||
workflowCount: m.templateCount,
|
||||
displayName: m.name
|
||||
}))
|
||||
|
||||
const combined = [...apiOutput, ...output]
|
||||
|
||||
const withThumbs = combined.filter((m) => m.thumbnailUrl).length
|
||||
process.stdout.write(
|
||||
` ${withThumbs}/${combined.length} models have thumbnails\n`
|
||||
)
|
||||
|
||||
const defaultOut = join(
|
||||
fileURLToPath(new URL('.', import.meta.url)),
|
||||
'../src/config/generated-models.json'
|
||||
)
|
||||
const outputArg = process.argv[2] ?? defaultOut
|
||||
const json = JSON.stringify(combined, null, 2) + '\n'
|
||||
|
||||
writeFileSync(outputArg, json, 'utf8')
|
||||
process.stdout.write(
|
||||
`Written ${combined.length} models ` +
|
||||
`(${apiOutput.length} partner, ${output.length} local) to ${outputArg}\n`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
run()
|
||||
} catch (err) {
|
||||
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { renameSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { fetchCloudNodesForBuild } from '../src/utils/cloudNodes'
|
||||
|
||||
const snapshotPath = fileURLToPath(
|
||||
new URL('../src/data/cloud-nodes.snapshot.json', import.meta.url)
|
||||
)
|
||||
const tempPath = `${snapshotPath}.tmp`
|
||||
|
||||
const outcome = await fetchCloudNodesForBuild()
|
||||
|
||||
if (outcome.status !== 'fresh') {
|
||||
const reason = 'reason' in outcome ? outcome.reason : '(none)'
|
||||
console.error(
|
||||
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(outcome.snapshot, null, 2) + '\n'
|
||||
|
||||
writeFileSync(tempPath, serialized, 'utf8')
|
||||
renameSync(tempPath, snapshotPath)
|
||||
|
||||
const totalNodes = outcome.snapshot.packs.reduce(
|
||||
(n, pack) => n + pack.nodes.length,
|
||||
0
|
||||
)
|
||||
process.stdout.write(
|
||||
`Wrote snapshot with ${outcome.snapshot.packs.length} pack(s) and ${totalNodes} node(s) to ${snapshotPath}\n`
|
||||
)
|
||||
@@ -1,13 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useTemplateRefsList } from '@vueuse/core'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Department } from '../../data/roles'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { scrollTo } from '../../scripts/smoothScroll'
|
||||
import CategoryNav from '../common/CategoryNav.vue'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
|
||||
@@ -16,72 +13,24 @@ const { locale = 'en', departments = [] } = defineProps<{
|
||||
departments?: readonly Department[]
|
||||
}>()
|
||||
|
||||
const activeCategory = ref('all')
|
||||
|
||||
const visibleDepartments = computed(() =>
|
||||
departments.filter((d) => d.roles.length > 0)
|
||||
)
|
||||
|
||||
const categories = computed(() =>
|
||||
visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
|
||||
const categories = computed(() => [
|
||||
{ label: 'ALL', value: 'all' },
|
||||
...visibleDepartments.value.map((d) => ({ label: d.name, value: d.key }))
|
||||
])
|
||||
|
||||
const filteredDepartments = computed(() =>
|
||||
activeCategory.value === 'all'
|
||||
? visibleDepartments.value
|
||||
: visibleDepartments.value.filter((d) => d.key === activeCategory.value)
|
||||
)
|
||||
|
||||
const hasRoles = computed(() => visibleDepartments.value.length > 0)
|
||||
|
||||
const activeCategory = ref('')
|
||||
|
||||
const sectionRefs = useTemplateRefsList<HTMLElement>()
|
||||
|
||||
let isScrolling = false
|
||||
let pendingFrame = 0
|
||||
|
||||
const HEADER_OFFSET = -144
|
||||
const ACTIVATION_OFFSET = 300
|
||||
|
||||
const deptElementId = (key: string) => `careers-dept-${key}`
|
||||
|
||||
function pickActiveSection() {
|
||||
pendingFrame = 0
|
||||
if (isScrolling) return
|
||||
const sections = sectionRefs.value as HTMLElement[]
|
||||
if (sections.length === 0) return
|
||||
|
||||
let active = sections[0]
|
||||
for (const el of sections) {
|
||||
if (el.getBoundingClientRect().top - ACTIVATION_OFFSET <= 0) {
|
||||
active = el
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
activeCategory.value = active.id.replace(/^careers-dept-/, '')
|
||||
}
|
||||
|
||||
function scheduleUpdate() {
|
||||
if (pendingFrame !== 0) return
|
||||
pendingFrame = requestAnimationFrame(pickActiveSection)
|
||||
}
|
||||
|
||||
onMounted(pickActiveSection)
|
||||
useEventListener('scroll', scheduleUpdate, { passive: true })
|
||||
useEventListener('resize', scheduleUpdate, { passive: true })
|
||||
|
||||
function scrollToDepartment(deptKey: string) {
|
||||
activeCategory.value = deptKey
|
||||
isScrolling = true
|
||||
const el = document.getElementById(deptElementId(deptKey))
|
||||
if (!el) {
|
||||
isScrolling = false
|
||||
return
|
||||
}
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: () => {
|
||||
isScrolling = false
|
||||
pickActiveSection()
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -99,10 +48,9 @@ function scrollToDepartment(deptKey: string) {
|
||||
</h2>
|
||||
<CategoryNav
|
||||
v-if="hasRoles"
|
||||
v-model="activeCategory"
|
||||
:categories="categories"
|
||||
:model-value="activeCategory"
|
||||
class="mt-4"
|
||||
@update:model-value="scrollToDepartment"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,11 +65,9 @@ function scrollToDepartment(deptKey: string) {
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-for="dept in visibleDepartments"
|
||||
:id="deptElementId(dept.key)"
|
||||
:ref="sectionRefs.set"
|
||||
v-for="dept in filteredDepartments"
|
||||
:key="dept.key"
|
||||
class="mb-12 scroll-mt-24 last:mb-0 md:scroll-mt-36"
|
||||
class="mb-12 last:mb-0"
|
||||
>
|
||||
<SectionLabel>
|
||||
{{ dept.name }}
|
||||
@@ -130,7 +76,7 @@ function scrollToDepartment(deptKey: string) {
|
||||
<a
|
||||
v-for="role in dept.roles"
|
||||
:key="role.id"
|
||||
:href="role.jobUrl"
|
||||
:href="role.applyUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const photos = [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team0.webp',
|
||||
@@ -15,34 +17,45 @@ const photos = [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team3.webp',
|
||||
alt: 'Team on a boat'
|
||||
},
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team4.webp',
|
||||
alt: 'Teammates posing at a restaurant'
|
||||
},
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team5.webp',
|
||||
alt: 'Teammates at a social gathering'
|
||||
},
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team6.webp',
|
||||
alt: 'Team sailing at golden hour'
|
||||
},
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team7.webp',
|
||||
alt: 'Team on a sailboat at sunset'
|
||||
}
|
||||
]
|
||||
|
||||
const loopedPhotos = [...photos, ...photos, ...photos]
|
||||
|
||||
const scrollRef = ref<HTMLElement>()
|
||||
|
||||
function onScroll() {
|
||||
const el = scrollRef.value
|
||||
if (!el) return
|
||||
|
||||
const third = el.scrollWidth / 3
|
||||
const maxScroll = el.scrollWidth - el.clientWidth
|
||||
|
||||
if (el.scrollLeft >= maxScroll - 1) {
|
||||
el.scrollLeft -= third
|
||||
} else if (el.scrollLeft <= 1) {
|
||||
el.scrollLeft += third
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = scrollRef.value
|
||||
if (el) {
|
||||
el.scrollLeft = el.scrollWidth / 3
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="py-12 md:py-24">
|
||||
<div
|
||||
ref="scrollRef"
|
||||
class="flex gap-4 overflow-x-auto px-6 md:gap-6 md:px-20"
|
||||
style="scrollbar-width: none"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<div
|
||||
v-for="(photo, i) in photos"
|
||||
v-for="(photo, i) in loopedPhotos"
|
||||
:key="i"
|
||||
class="aspect-3/4 h-64 shrink-0 md:h-96"
|
||||
>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{
|
||||
locale?: Locale
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 pt-24 pb-14 md:px-20 md:pt-32 md:pb-20">
|
||||
<div class="mx-auto flex w-full max-w-6xl flex-col gap-7">
|
||||
<SectionLabel>
|
||||
{{ t('cloudNodes.hero.label', locale) }}
|
||||
</SectionLabel>
|
||||
|
||||
<h1
|
||||
class="text-primary-comfy-canvas max-w-4xl text-4xl/tight md:text-6xl"
|
||||
>
|
||||
{{ t('cloudNodes.hero.heading', locale) }}
|
||||
</h1>
|
||||
|
||||
<p class="text-primary-warm-gray max-w-3xl text-base/relaxed md:text-lg">
|
||||
{{ t('cloudNodes.hero.body', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,49 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { PackNode } from '../../data/cloudNodes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { useNodesByCategory } from '../../composables/useNodesByCategory'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en', nodes } = defineProps<{
|
||||
locale?: Locale
|
||||
nodes: readonly PackNode[]
|
||||
}>()
|
||||
|
||||
const { groupedNodes } = useNodesByCategory(() => nodes)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<details
|
||||
class="group border-primary-warm-gray/20 rounded-2xl border px-4 py-3"
|
||||
>
|
||||
<summary
|
||||
class="text-primary-comfy-canvas cursor-pointer list-none text-sm font-semibold"
|
||||
>
|
||||
{{ t('cloudNodes.card.nodesHeading', locale) }}
|
||||
</summary>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-5">
|
||||
<div
|
||||
v-for="group in groupedNodes"
|
||||
:key="group.category"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<h4
|
||||
class="text-primary-warm-gray text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ group.category }}
|
||||
</h4>
|
||||
<ul class="flex flex-col gap-1">
|
||||
<li
|
||||
v-for="node in group.nodes"
|
||||
:key="node.name"
|
||||
class="text-primary-comfy-canvas text-sm/relaxed"
|
||||
>
|
||||
{{ node.displayName }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
@@ -1,52 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useBannerImage } from '../../composables/useBannerImage'
|
||||
|
||||
const { bannerUrl, iconUrl, name } = defineProps<{
|
||||
bannerUrl?: string
|
||||
iconUrl?: string
|
||||
name: string
|
||||
}>()
|
||||
|
||||
const {
|
||||
DEFAULT_BANNER,
|
||||
isImageError,
|
||||
showDefaultBanner,
|
||||
imgSrc,
|
||||
onImageError
|
||||
} = useBannerImage({
|
||||
bannerUrl: () => bannerUrl,
|
||||
iconUrl: () => iconUrl
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="z-0 aspect-7/3 w-full overflow-hidden"
|
||||
data-testid="cloud-node-pack-banner"
|
||||
>
|
||||
<div v-if="showDefaultBanner" class="size-full">
|
||||
<img
|
||||
:src="DEFAULT_BANNER"
|
||||
:alt="`${name} banner`"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="relative size-full">
|
||||
<div
|
||||
v-if="imgSrc && !isImageError"
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
|
||||
:style="{ backgroundImage: `url(${imgSrc})`, filter: 'blur(10px)' }"
|
||||
/>
|
||||
<img
|
||||
:src="isImageError ? DEFAULT_BANNER : imgSrc"
|
||||
:alt="`${name} banner`"
|
||||
:class="
|
||||
isImageError
|
||||
? 'relative z-10 size-full object-cover'
|
||||
: 'relative z-10 size-full object-contain'
|
||||
"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,80 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Pack } from '../../data/cloudNodes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import NodeList from './NodeList.vue'
|
||||
import PackBanner from './PackBanner.vue'
|
||||
|
||||
const { locale = 'en', pack } = defineProps<{
|
||||
locale?: Locale
|
||||
pack: Pack
|
||||
}>()
|
||||
|
||||
const detailHref =
|
||||
locale === 'zh-CN'
|
||||
? `/zh-CN/cloud/supported-nodes/${pack.id}`
|
||||
: `/cloud/supported-nodes/${pack.id}`
|
||||
|
||||
function nodeCountLabel(nodeCount: number): string {
|
||||
const key =
|
||||
new Intl.PluralRules(locale).select(nodeCount) === 'one'
|
||||
? 'cloudNodes.card.nodeCountOne'
|
||||
: 'cloudNodes.card.nodeCountOther'
|
||||
return t(key, locale).replace('{count}', String(nodeCount))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="bg-transparency-white-t5 border-primary-warm-gray/20 flex h-full flex-col overflow-hidden rounded-3xl border"
|
||||
data-testid="cloud-node-pack-card"
|
||||
>
|
||||
<PackBanner
|
||||
:banner-url="pack.bannerUrl"
|
||||
:icon-url="pack.iconUrl"
|
||||
:name="pack.displayName"
|
||||
/>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-5 p-5 md:p-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-primary-comfy-canvas text-2xl/tight font-semibold">
|
||||
<a
|
||||
:href="detailHref"
|
||||
class="hover:text-primary-comfy-yellow"
|
||||
data-testid="cloud-node-pack-card-link"
|
||||
>
|
||||
{{ pack.displayName }}
|
||||
</a>
|
||||
</h3>
|
||||
<p class="text-primary-warm-gray text-sm/relaxed">
|
||||
{{
|
||||
pack.description ||
|
||||
t('cloudNodes.card.unavailableDescription', locale)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-sm">
|
||||
<a
|
||||
v-if="pack.repoUrl"
|
||||
:href="pack.repoUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 font-semibold underline"
|
||||
>
|
||||
{{ t('cloudNodes.card.viewRepo', locale) }}
|
||||
</a>
|
||||
<span v-else class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.card.viewRepo', locale) }}
|
||||
</span>
|
||||
<span class="text-primary-warm-gray">•</span>
|
||||
<span class="text-primary-comfy-canvas">{{
|
||||
nodeCountLabel(pack.nodes.length)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<NodeList :locale="locale" :nodes="pack.nodes" class="mt-auto" />
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -1,203 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Pack } from '../../data/cloudNodes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import {
|
||||
formatLocalizedMediumDate,
|
||||
formatLocalizedNumber
|
||||
} from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
|
||||
import { useNodesByCategory } from '../../composables/useNodesByCategory'
|
||||
import { t } from '../../i18n/translations'
|
||||
import PackBanner from './PackBanner.vue'
|
||||
|
||||
const { pack, locale = 'en' } = defineProps<{
|
||||
pack: Pack
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const backHref =
|
||||
locale === 'zh-CN' ? '/zh-CN/cloud/supported-nodes' : '/cloud/supported-nodes'
|
||||
|
||||
const { groupedNodes } = useNodesByCategory(() => pack.nodes)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="px-6 pb-20 md:px-20 md:pb-28"
|
||||
data-testid="cloud-node-pack-detail"
|
||||
>
|
||||
<div class="mx-auto flex w-full max-w-6xl flex-col gap-8">
|
||||
<a
|
||||
:href="backHref"
|
||||
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 w-fit text-sm font-semibold underline"
|
||||
>
|
||||
{{ t('cloudNodes.detail.back', locale) }}
|
||||
</a>
|
||||
|
||||
<div
|
||||
class="bg-transparency-white-t5 border-primary-warm-gray/20 overflow-hidden rounded-3xl border"
|
||||
>
|
||||
<PackBanner
|
||||
:banner-url="pack.bannerUrl"
|
||||
:icon-url="pack.iconUrl"
|
||||
:name="pack.displayName"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-7 p-5 md:p-6">
|
||||
<header class="flex flex-col gap-2">
|
||||
<h1
|
||||
class="text-primary-comfy-canvas text-3xl/tight font-semibold md:text-4xl"
|
||||
>
|
||||
{{ pack.displayName }}
|
||||
</h1>
|
||||
<p
|
||||
class="text-primary-warm-gray text-sm/relaxed md:text-base/relaxed"
|
||||
>
|
||||
{{
|
||||
pack.description ||
|
||||
t('cloudNodes.card.unavailableDescription', locale)
|
||||
}}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<dl class="grid grid-cols-1 gap-4 text-sm md:grid-cols-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.card.viewRepo', locale) }}
|
||||
</dt>
|
||||
<dd>
|
||||
<a
|
||||
v-if="pack.repoUrl"
|
||||
:href="pack.repoUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow hover:text-primary-comfy-yellow/85 font-semibold underline"
|
||||
>
|
||||
{{ pack.repoUrl }}
|
||||
</a>
|
||||
<span v-else class="text-primary-comfy-canvas">—</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.publisher', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ pack.publisher?.name || pack.publisher?.id || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.downloads', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ formatLocalizedNumber(pack.downloads, locale) }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.stars', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ formatLocalizedNumber(pack.githubStars, locale) }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.latestVersion', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ pack.latestVersion || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.license', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ pack.license || '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-primary-warm-gray">
|
||||
{{ t('cloudNodes.detail.lastUpdated', locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas">
|
||||
{{ formatLocalizedMediumDate(pack.lastUpdated, locale) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section class="flex flex-col gap-4">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-xl font-semibold md:text-2xl"
|
||||
>
|
||||
{{ t('cloudNodes.detail.nodesHeading', locale) }}
|
||||
</h2>
|
||||
|
||||
<section
|
||||
v-for="group in groupedNodes"
|
||||
:key="group.category"
|
||||
class="border-primary-warm-gray/20 rounded-2xl border p-4"
|
||||
>
|
||||
<h3 class="text-primary-comfy-canvas text-base font-semibold">
|
||||
{{ group.category }}
|
||||
</h3>
|
||||
<ul class="mt-3 flex flex-col gap-3">
|
||||
<li
|
||||
v-for="node in group.nodes"
|
||||
:key="node.name"
|
||||
class="border-primary-warm-gray/20 rounded-xl border p-3"
|
||||
data-testid="cloud-node-pack-detail-node"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="text-primary-comfy-canvas text-sm font-semibold"
|
||||
>
|
||||
{{ node.displayName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="node.experimental"
|
||||
:class="
|
||||
cn(
|
||||
'bg-primary-comfy-yellow/20 text-primary-comfy-canvas rounded-full px-2 py-0.5 text-xs font-semibold'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ t('cloudNodes.detail.experimental', locale) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="node.deprecated"
|
||||
:class="
|
||||
cn(
|
||||
'text-primary-comfy-canvas rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-semibold'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ t('cloudNodes.detail.deprecated', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="node.description"
|
||||
class="text-primary-warm-gray mt-2 text-sm/relaxed"
|
||||
>
|
||||
{{ node.description }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
@@ -1,113 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Pack } from '../../data/cloudNodes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { useFilteredPacks } from '../../composables/useFilteredPacks'
|
||||
import type { PackSortMode } from '../../composables/useFilteredPacks'
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
import PackCard from './PackCard.vue'
|
||||
|
||||
const { locale = 'en', packs } = defineProps<{
|
||||
locale?: Locale
|
||||
packs: readonly Pack[]
|
||||
}>()
|
||||
|
||||
const query = defineModel<string>('query', { default: '' })
|
||||
const sortMode = ref<PackSortMode>('downloads')
|
||||
|
||||
const { filteredPacks } = useFilteredPacks({
|
||||
packs: () => packs,
|
||||
query,
|
||||
sortMode
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-6 pb-20 md:px-20 md:pb-32">
|
||||
<div class="mx-auto flex w-full max-w-6xl flex-col gap-8">
|
||||
<div class="flex flex-col gap-3">
|
||||
<SectionLabel>
|
||||
{{ t('cloudNodes.hero.label', locale) }}
|
||||
</SectionLabel>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3xl/tight font-medium md:text-4xl"
|
||||
>
|
||||
{{ t('cloudNodes.section.heading', locale) }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<label for="cloud-nodes-search" class="sr-only">
|
||||
{{ t('cloudNodes.search.label', locale) }}
|
||||
</label>
|
||||
<input
|
||||
id="cloud-nodes-search"
|
||||
v-model="query"
|
||||
:placeholder="t('cloudNodes.search.placeholder', locale)"
|
||||
:class="
|
||||
cn(
|
||||
'bg-transparency-white-t5 border-primary-warm-gray/30 text-primary-comfy-canvas placeholder:text-primary-warm-gray/80 w-full rounded-2xl border px-4 py-3 text-sm md:max-w-md'
|
||||
)
|
||||
"
|
||||
data-testid="cloud-nodes-search"
|
||||
/>
|
||||
|
||||
<label for="cloud-nodes-sort" class="sr-only">
|
||||
{{ t('cloudNodes.sort.label', locale) }}
|
||||
</label>
|
||||
<select
|
||||
id="cloud-nodes-sort"
|
||||
v-model="sortMode"
|
||||
class="bg-transparency-white-t5 border-primary-warm-gray/30 text-primary-comfy-canvas w-full appearance-none rounded-2xl border bg-size-[0.65rem_0.65rem] bg-position-[right_1rem_center] bg-no-repeat py-3 pr-12 pl-4 text-sm md:w-64"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'url(\'data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 12 12%22 fill=%22%23a39b8d%22><path d=%22M6 9.2L1.4 4.6 2.8 3.2 6 6.4l3.2-3.2 1.4 1.4z%22/></svg>\')'
|
||||
}"
|
||||
>
|
||||
<option value="downloads">
|
||||
{{ t('cloudNodes.sort.downloads', locale) }}
|
||||
</option>
|
||||
<option value="mostNodes">
|
||||
{{ t('cloudNodes.sort.mostNodes', locale) }}
|
||||
</option>
|
||||
<option value="az">{{ t('cloudNodes.sort.az', locale) }}</option>
|
||||
<option value="recentlyUpdated">
|
||||
{{ t('cloudNodes.sort.recentlyUpdated', locale) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="filteredPacks.length === 0"
|
||||
class="text-primary-warm-gray rounded-2xl border border-dashed border-current/30 px-5 py-6 text-sm"
|
||||
>
|
||||
<span class="text-primary-comfy-canvas block text-base font-semibold">
|
||||
{{ t('cloudNodes.empty.heading', locale) }}
|
||||
</span>
|
||||
<span class="mt-2 block">{{ t('cloudNodes.empty.body', locale) }}</span>
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-1 gap-5 md:grid-cols-2"
|
||||
role="list"
|
||||
:aria-label="t('cloudNodes.list.ariaLabel', locale)"
|
||||
>
|
||||
<PackCard
|
||||
v-for="pack in filteredPacks"
|
||||
:key="pack.id"
|
||||
:pack="pack"
|
||||
:locale="locale"
|
||||
role="listitem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,11 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import {
|
||||
useEventListener,
|
||||
useIntersectionObserver,
|
||||
useTemplateRefsList
|
||||
} from '@vueuse/core'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
@@ -44,25 +40,13 @@ const activeSection = ref(sections[0]?.id ?? '')
|
||||
|
||||
const sectionRefs = useTemplateRefsList<HTMLElement>()
|
||||
let isScrolling = false
|
||||
let scrollSafetyTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const HEADER_OFFSET = -144
|
||||
const BOTTOM_THRESHOLD_PX = 4
|
||||
const SCROLL_SAFETY_MS = 1500
|
||||
|
||||
function clearScrollLock() {
|
||||
isScrolling = false
|
||||
if (scrollSafetyTimer !== undefined) {
|
||||
clearTimeout(scrollSafetyTimer)
|
||||
scrollSafetyTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
useIntersectionObserver(
|
||||
sectionRefs,
|
||||
(entries) => {
|
||||
if (isScrolling) return
|
||||
if (isAtBottom()) return
|
||||
let best: IntersectionObserverEntry | null = null
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
@@ -74,39 +58,22 @@ useIntersectionObserver(
|
||||
{ rootMargin: '-20% 0px -60% 0px' }
|
||||
)
|
||||
|
||||
function isAtBottom(): boolean {
|
||||
const scrollBottom = window.scrollY + window.innerHeight
|
||||
return (
|
||||
scrollBottom >= document.documentElement.scrollHeight - BOTTOM_THRESHOLD_PX
|
||||
)
|
||||
}
|
||||
|
||||
function activateLastIfAtBottom() {
|
||||
if (isScrolling) return
|
||||
if (!isAtBottom()) return
|
||||
const lastId = sections[sections.length - 1]?.id
|
||||
if (lastId) activeSection.value = lastId
|
||||
}
|
||||
|
||||
onMounted(activateLastIfAtBottom)
|
||||
useEventListener('scroll', activateLastIfAtBottom, { passive: true })
|
||||
|
||||
function scrollToSection(id: string) {
|
||||
activeSection.value = id
|
||||
clearScrollLock()
|
||||
isScrolling = true
|
||||
scrollSafetyTimer = setTimeout(clearScrollLock, SCROLL_SAFETY_MS)
|
||||
const el = document.getElementById(id)
|
||||
if (el) {
|
||||
scrollTo(el, {
|
||||
offset: HEADER_OFFSET,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: clearScrollLock
|
||||
onComplete: () => {
|
||||
isScrolling = false
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
clearScrollLock()
|
||||
isScrolling = false
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const { stars } = defineProps<{
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="`ComfyUI on GitHub — ${stars} stars`"
|
||||
class="hidden shrink-0 items-center gap-1 lg:flex"
|
||||
class="hidden shrink-0 items-center gap-2 lg:flex"
|
||||
>
|
||||
<NodeBadge
|
||||
:segments="[{ text: stars }]"
|
||||
@@ -22,7 +22,7 @@ const { stars } = defineProps<{
|
||||
size-class="h-5 sm:h-5"
|
||||
/>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow block size-6 shrink-0"
|
||||
class="bg-primary-comfy-yellow block size-7"
|
||||
aria-hidden="true"
|
||||
style="mask: url('/icons/social/github.svg') center / contain no-repeat"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -82,7 +82,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
|
||||
]
|
||||
}
|
||||
|
||||
const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
const contactColumn = {
|
||||
title: t('footer.contact', locale),
|
||||
links: [
|
||||
{ label: t('footer.sales', locale), href: routes.contact },
|
||||
@@ -91,11 +91,6 @@ const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
href: externalLinks.support,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: t('footer.cloudStatus', locale),
|
||||
href: externalLinks.cloudStatus,
|
||||
external: true
|
||||
},
|
||||
{ label: t('footer.press', locale), href: 'mailto:press@comfy.org' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,28 +14,23 @@ const logos = [
|
||||
'Ubisoft'
|
||||
]
|
||||
|
||||
const mobileRow1Logos = logos.slice(0, 6)
|
||||
const mobileRow2Logos = logos.slice(6)
|
||||
const desktopLogos = Array.from({ length: 4 }, () => logos).flat()
|
||||
const row1 = logos.slice(0, 6)
|
||||
const mobileRow1 = [...row1, ...row1]
|
||||
const row2 = logos.slice(6)
|
||||
const mobileRow2 = [...row2, ...row2]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="overflow-hidden py-12">
|
||||
<!-- Single row on desktop -->
|
||||
<div data-testid="social-proof-desktop" class="hidden w-max gap-2 md:flex">
|
||||
<div class="animate-marquee hidden items-center gap-2 md:flex">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-2"
|
||||
style="--marquee-gap: 0.5rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
v-for="(logo, i) in desktopLogos"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-20 w-50 shrink-0 items-center justify-center"
|
||||
>
|
||||
<div
|
||||
v-for="logo in logos"
|
||||
:key="logo"
|
||||
class="flex h-20 w-50 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,38 +39,22 @@ const mobileRow2Logos = logos.slice(6)
|
||||
data-testid="social-proof-mobile"
|
||||
class="flex flex-col gap-8 md:hidden"
|
||||
>
|
||||
<div class="flex w-max gap-8">
|
||||
<div class="animate-marquee flex items-center gap-8">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
v-for="(logo, i) in mobileRow1"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<div
|
||||
v-for="logo in mobileRow1Logos"
|
||||
:key="logo"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-max gap-8">
|
||||
<div class="animate-marquee-reverse flex items-center gap-8">
|
||||
<div
|
||||
v-for="copy in 2"
|
||||
:key="copy"
|
||||
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
|
||||
style="--marquee-gap: 2rem"
|
||||
:aria-hidden="copy === 2 ? 'true' : undefined"
|
||||
v-for="(logo, i) in mobileRow2"
|
||||
:key="`${logo}-${i}`"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<div
|
||||
v-for="logo in mobileRow2Logos"
|
||||
:key="logo"
|
||||
class="flex h-14 w-40 shrink-0 items-center justify-center"
|
||||
>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@ const progressPercent = computed(() => `${progress.value * 100}%`)
|
||||
<!-- Progress bar -->
|
||||
<div class="h-1 flex-1 rounded-full bg-white/20">
|
||||
<div
|
||||
class="bg-primary-comfy-yellow h-full rounded-full"
|
||||
class="bg-primary-comfy-yellow h-full rounded-full transition-all duration-200"
|
||||
:style="{ width: progressPercent }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useHeroAnimation } from '../../composables/useHeroAnimation'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { ScrollTrigger } from '../../scripts/gsapSetup'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
@@ -23,10 +22,6 @@ useHeroAnimation({
|
||||
logo: logoRef,
|
||||
video: videoRef
|
||||
})
|
||||
|
||||
function handleLogoLoad() {
|
||||
ScrollTrigger.refresh(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -42,10 +37,7 @@ function handleLogoLoad() {
|
||||
<img
|
||||
src="https://media.comfy.org/website/customers/c-projection.webp"
|
||||
alt="Comfy 3D logo"
|
||||
width="1568"
|
||||
height="1763"
|
||||
class="mx-auto h-auto w-full max-w-md lg:max-w-none"
|
||||
@load="handleLogoLoad"
|
||||
class="mx-auto w-full max-w-md lg:max-w-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
arcadeId,
|
||||
title,
|
||||
aspectRatio = 16 / 9,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
arcadeId: string
|
||||
title: string
|
||||
aspectRatio?: number
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const loaded = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-4 py-8 lg:px-20 lg:py-16"
|
||||
:aria-label="t('demos.embed.label', locale)"
|
||||
>
|
||||
<div
|
||||
class="relative mx-auto max-w-6xl overflow-hidden rounded-4xl border border-white/10"
|
||||
:style="{ aspectRatio }"
|
||||
>
|
||||
<div
|
||||
v-if="!loaded"
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-black/50"
|
||||
>
|
||||
<div
|
||||
class="border-primary-comfy-canvas/60 mb-4 size-10 animate-pulse rounded-full border-2"
|
||||
/>
|
||||
<p class="text-primary-warm-gray text-sm">
|
||||
{{ t('demos.loading', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
class="size-full"
|
||||
:src="`https://demo.arcade.software/${arcadeId}?embed&show_title=0`"
|
||||
:title="`${t('demos.embed.label', locale)}: ${title}`"
|
||||
loading="lazy"
|
||||
allow="clipboard-write"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
@load="loaded = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<noscript>
|
||||
<p class="text-primary-warm-gray mt-4 text-sm">
|
||||
{{ t('demos.noscript', locale) }}
|
||||
<a
|
||||
class="text-primary-comfy-yellow ml-2 underline"
|
||||
:href="`https://demo.arcade.software/${arcadeId}`"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{{ t('demos.noscript.link', locale) }}
|
||||
</a>
|
||||
</p>
|
||||
</noscript>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,60 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
label,
|
||||
title,
|
||||
description,
|
||||
difficulty,
|
||||
estimatedTime,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
label: string
|
||||
title: string
|
||||
description: string
|
||||
difficulty: 'beginner' | 'intermediate' | 'advanced'
|
||||
estimatedTime: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const difficultyKey = `demos.difficulty.${difficulty}` as TranslationKey
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pt-16 lg:px-20 lg:pt-40 lg:pb-8">
|
||||
<div class="mx-auto flex max-w-4xl flex-col items-center text-center">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
|
||||
<h1
|
||||
class="text-primary-comfy-canvas mt-4 text-3xl/tight font-light lg:text-5xl/tight"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-primary-warm-gray mt-6 max-w-xl text-sm/relaxed lg:text-base/relaxed"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<span
|
||||
class="bg-transparency-white-t4 text-primary-comfy-canvas rounded-full px-3 py-1 text-xs font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{{ t(difficultyKey, locale) }}
|
||||
</span>
|
||||
<span
|
||||
class="bg-transparency-white-t4 text-primary-comfy-canvas rounded-full px-3 py-1 text-xs font-semibold"
|
||||
>
|
||||
{{ t(estimatedTime as TranslationKey, locale) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,59 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
nextTitle,
|
||||
nextSlug,
|
||||
nextThumbnail,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
nextTitle: string
|
||||
nextSlug: string
|
||||
nextThumbnail: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const localePrefix = locale === 'en' ? '' : `/${locale}`
|
||||
const nextHref = `${localePrefix}/demos/${nextSlug}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-4 py-16 lg:px-20 lg:py-24">
|
||||
<h2 class="text-primary-comfy-canvas mb-10 text-2xl font-light lg:text-3xl">
|
||||
{{ t('demos.nav.nextDemo' as TranslationKey, locale) }}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
class="bg-transparency-white-t4 rounded-5xl mx-auto flex flex-col gap-8 p-2 lg:max-w-237.5 lg:flex-row lg:items-center"
|
||||
>
|
||||
<a :href="nextHref" class="shrink-0 lg:w-1/2">
|
||||
<img
|
||||
:src="nextThumbnail"
|
||||
:alt="nextTitle"
|
||||
class="w-full rounded-4xl object-cover"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<h3 class="text-primary-comfy-canvas text-xl font-light lg:text-2xl">
|
||||
{{ nextTitle }}
|
||||
</h3>
|
||||
|
||||
<a :href="nextHref" class="flex items-center gap-3">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex size-10 items-center justify-center rounded-full"
|
||||
>
|
||||
<span class="text-lg font-bold">›</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-canvas ppformula-text-center text-sm font-semibold tracking-wider uppercase"
|
||||
>
|
||||
{{ t('demos.nav.viewDemo' as TranslationKey, locale) }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,50 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { transcript, locale = 'en' } = defineProps<{
|
||||
transcript: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-4 py-8 lg:px-20 lg:py-12"
|
||||
:aria-label="t('demos.transcript.label', locale)"
|
||||
>
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary-comfy-canvas text-left"
|
||||
:aria-expanded="expanded"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<span class="text-sm font-semibold tracking-wide uppercase">
|
||||
{{ t('demos.transcript.label', locale) }}
|
||||
</span>
|
||||
<span class="text-primary-warm-gray ml-2 text-xs">
|
||||
{{ t('demos.transcript.note', locale) }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
role="region"
|
||||
:aria-label="t('demos.transcript.label', locale)"
|
||||
:class="
|
||||
cn(
|
||||
expanded ? 'mt-4' : 'sr-only',
|
||||
'text-primary-warm-gray text-sm/relaxed'
|
||||
)
|
||||
"
|
||||
v-html="transcript"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,31 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { useHeroLogo } from '../../composables/useHeroLogo'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const logoContainer = ref<HTMLElement>()
|
||||
const { loaded: logoLoaded } = useHeroLogo(logoContainer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="relative flex min-h-auto flex-col lg:flex-row lg:items-center"
|
||||
>
|
||||
<div
|
||||
ref="logoContainer"
|
||||
class="relative flex aspect-square w-full flex-1 items-center justify-center"
|
||||
>
|
||||
<img
|
||||
v-show="!logoLoaded"
|
||||
src="https://media.comfy.org/website/homepage/hero-logo-seq/Logo00.webp"
|
||||
alt="Comfy logo"
|
||||
class="w-3/5"
|
||||
<div class="relative flex-1">
|
||||
<video
|
||||
src="https://media.comfy.org/website/homepage/hero-logo-seq.webm"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const categories: Category[] = [
|
||||
{
|
||||
label: t('useCase.vfx', locale),
|
||||
leftSrc: 'https://media.comfy.org/website/homepage/use-case/left1.webm',
|
||||
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webm'
|
||||
rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webp'
|
||||
},
|
||||
{
|
||||
label: t('useCase.advertising', locale),
|
||||
|
||||
@@ -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,101 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import SectionLabel from '../common/SectionLabel.vue'
|
||||
|
||||
// Display-only thank-you / failure pages: payment state is verified
|
||||
// server-side via Stripe webhooks (see comfy-api). These pages exist
|
||||
// solely as the redirect target for Stripe Checkout.
|
||||
|
||||
type Status = 'success' | 'failed'
|
||||
|
||||
const { status, locale = 'en' } = defineProps<{
|
||||
status: Status
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const primaryHref =
|
||||
status === 'success' ? externalLinks.cloud : externalLinks.support
|
||||
const secondaryHref =
|
||||
status === 'success'
|
||||
? externalLinks.platformUsage
|
||||
: externalLinks.docsSubscription
|
||||
|
||||
const iconRingClass =
|
||||
status === 'success'
|
||||
? 'border-primary-comfy-yellow text-primary-comfy-yellow'
|
||||
: 'border-secondary-mauve text-secondary-mauve'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex min-h-[calc(100dvh-12rem)] items-center justify-center px-6 py-16 lg:py-24"
|
||||
>
|
||||
<div class="flex max-w-2xl flex-col items-center gap-6 text-center">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex size-20 items-center justify-center rounded-full border-2',
|
||||
iconRingClass
|
||||
)
|
||||
"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
v-if="status === 'success'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-10"
|
||||
>
|
||||
<path d="M5 12.5l4.5 4.5L19 7.5" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-10"
|
||||
>
|
||||
<path d="M6 6l12 12" />
|
||||
<path d="M18 6L6 18" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<SectionLabel>{{ t(`payment.${status}.label`, locale) }}</SectionLabel>
|
||||
|
||||
<h1
|
||||
class="text-primary-comfy-canvas text-4xl/tight font-light md:text-5xl/tight lg:text-6xl/tight"
|
||||
>
|
||||
{{ t(`payment.${status}.title`, locale) }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas/80 max-w-xl text-base font-light lg:text-lg"
|
||||
>
|
||||
{{ t(`payment.${status}.subtitle`, locale) }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="mt-2 flex flex-col items-stretch gap-3 sm:flex-row sm:items-center sm:justify-center"
|
||||
>
|
||||
<BrandButton :href="primaryHref" variant="solid" size="nav">
|
||||
{{ t(`payment.${status}.primaryCta`, locale) }}
|
||||
</BrandButton>
|
||||
<BrandButton :href="secondaryHref" variant="outline" size="nav">
|
||||
{{ t(`payment.${status}.secondaryCta`, locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -7,7 +7,6 @@ import { ref } from 'vue'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import PricingPlanFeatureList from './PricingPlanFeatureList.vue'
|
||||
import PricingTierCard from './PricingTierCard.vue'
|
||||
import { SHOW_FREE_TIER } from '../../config/features'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
@@ -38,23 +37,21 @@ interface PricingPlan {
|
||||
isEnterprise?: boolean
|
||||
}
|
||||
|
||||
const freePlan: PricingPlan = {
|
||||
id: 'free',
|
||||
labelKey: 'pricing.plan.free.label',
|
||||
summaryKey: 'pricing.plan.free.summary',
|
||||
priceKey: 'pricing.plan.free.price',
|
||||
creditsKey: 'pricing.plan.free.credits',
|
||||
estimateKey: 'pricing.plan.free.estimate',
|
||||
ctaKey: 'pricing.plan.free.cta',
|
||||
ctaHref: externalLinks.cloud,
|
||||
features: [
|
||||
{ text: 'pricing.plan.free.feature1' },
|
||||
{ text: 'pricing.plan.free.feature2' }
|
||||
]
|
||||
}
|
||||
|
||||
const plans: PricingPlan[] = [
|
||||
...(SHOW_FREE_TIER ? [freePlan] : []),
|
||||
{
|
||||
id: 'free',
|
||||
labelKey: 'pricing.plan.free.label',
|
||||
summaryKey: 'pricing.plan.free.summary',
|
||||
priceKey: 'pricing.plan.free.price',
|
||||
creditsKey: 'pricing.plan.free.credits',
|
||||
estimateKey: 'pricing.plan.free.estimate',
|
||||
ctaKey: 'pricing.plan.free.cta',
|
||||
ctaHref: externalLinks.cloud,
|
||||
features: [
|
||||
{ text: 'pricing.plan.free.feature1' },
|
||||
{ text: 'pricing.plan.free.feature2' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'standard',
|
||||
labelKey: 'pricing.plan.standard.label',
|
||||
@@ -64,9 +61,7 @@ const plans: PricingPlan[] = [
|
||||
estimateKey: 'pricing.plan.standard.estimate',
|
||||
ctaKey: 'pricing.plan.standard.cta',
|
||||
ctaHref: subscribeUrl('standard'),
|
||||
featureIntroKey: SHOW_FREE_TIER
|
||||
? 'pricing.plan.standard.featureIntro'
|
||||
: undefined,
|
||||
featureIntroKey: 'pricing.plan.standard.featureIntro',
|
||||
features: [
|
||||
{ text: 'pricing.plan.standard.feature1' },
|
||||
{ text: 'pricing.plan.standard.feature2' }
|
||||
@@ -82,10 +77,7 @@ const plans: PricingPlan[] = [
|
||||
ctaKey: 'pricing.plan.creator.cta',
|
||||
ctaHref: subscribeUrl('creator'),
|
||||
featureIntroKey: 'pricing.plan.creator.featureIntro',
|
||||
features: [
|
||||
{ text: 'pricing.plan.creator.feature1' },
|
||||
{ text: 'pricing.plan.creator.feature2' }
|
||||
],
|
||||
features: [{ text: 'pricing.plan.creator.feature1' }],
|
||||
isPopular: true
|
||||
},
|
||||
{
|
||||
@@ -98,10 +90,7 @@ const plans: PricingPlan[] = [
|
||||
ctaKey: 'pricing.plan.pro.cta',
|
||||
ctaHref: subscribeUrl('pro'),
|
||||
featureIntroKey: 'pricing.plan.pro.featureIntro',
|
||||
features: [
|
||||
{ text: 'pricing.plan.pro.feature1' },
|
||||
{ text: 'pricing.plan.pro.feature2' }
|
||||
]
|
||||
features: [{ text: 'pricing.plan.pro.feature1' }]
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
@@ -155,14 +144,9 @@ const activePlanIndex = ref(0)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: single card -->
|
||||
<!-- Desktop: 4-column grid / Mobile: single card -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:gap-2',
|
||||
standardPlans.length === 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3'
|
||||
)
|
||||
"
|
||||
class="rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:grid-cols-4 lg:gap-2"
|
||||
>
|
||||
<PricingTierCard v-for="plan in standardPlans" :key="plan.id">
|
||||
<!-- Label + badge -->
|
||||
@@ -233,18 +217,10 @@ const activePlanIndex = ref(0)
|
||||
|
||||
<!-- Features -->
|
||||
<div v-if="plan.features.length" class="px-6 py-3">
|
||||
<p
|
||||
v-if="plan.featureIntroKey"
|
||||
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
|
||||
>
|
||||
{{ t(plan.featureIntroKey, locale) }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-primary-comfy-canvas mb-2 text-sm font-semibold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
|
||||
<p class="text-primary-comfy-canvas mb-2 text-sm font-semibold">
|
||||
{{
|
||||
plan.featureIntroKey ? t(plan.featureIntroKey, locale) : ' '
|
||||
}}
|
||||
</p>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
|
||||
import { SHOW_FREE_TIER } from '../../../config/features'
|
||||
import { getRoutes } from '../../../config/routes'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
@@ -26,10 +25,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
{{ t('cloud.pricing.description', locale) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="SHOW_FREE_TIER"
|
||||
class="text-primary-comfy-ink mt-4 text-base font-bold"
|
||||
>
|
||||
<p class="text-primary-comfy-ink mt-4 text-base font-bold">
|
||||
{{ t('cloud.pricing.tagline', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -35,20 +35,20 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden pt-16 lg:flex-row-reverse lg:items-center lg:overflow-x-visible lg:overflow-y-clip lg:pt-[min(8vw,10rem)] lg:pb-[min(8vw,10rem)]"
|
||||
class="max-w-9xl relative mx-auto flex flex-col items-center overflow-hidden lg:flex-row-reverse lg:items-center lg:overflow-x-visible lg:overflow-y-clip lg:pb-[min(8vw,10rem)]"
|
||||
>
|
||||
<!-- Illustration (overlaps text slightly; stacks above on mobile, right on lg) -->
|
||||
<div
|
||||
class="aspect-square w-4/5 max-w-md scale-150 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-ml-12 lg:-translate-x-[10%] lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
|
||||
class="aspect-square w-4/5 max-w-md scale-150 self-center md:max-w-2xl lg:pointer-events-none lg:z-1 lg:-ml-12 lg:-translate-x-[10%] lg:translate-y-[40px] lg:self-center xl:size-[clamp(32rem,max(40vh,32vw),36rem)] xl:min-h-[min(32vw,24rem)] xl:min-w-[min(24vw,20rem)]"
|
||||
>
|
||||
<svg
|
||||
ref="svgRef"
|
||||
class="block size-full overflow-visible"
|
||||
class="block size-full"
|
||||
viewBox="0 0 1600 1046"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g>
|
||||
<g clip-path="url(#enterpriseHeroClip)">
|
||||
<rect width="1600" height="1046" fill="#211927" />
|
||||
<rect
|
||||
width="800"
|
||||
@@ -84,7 +84,7 @@ onMounted(() => {
|
||||
/>
|
||||
|
||||
<!-- Exploding block cluster -->
|
||||
<g class="block-cluster" clip-path="url(#enterpriseHeroBlockClip)">
|
||||
<g class="block-cluster">
|
||||
<path
|
||||
class="block-piece"
|
||||
d="M1018.44 635.715L1018.45 581.73C1018.46 574.554 1013.42 565.829 1007.21 562.242L960.479 535.262C956.544 532.99 949.469 533.096 945.535 535.368L898.79 562.373C892.576 565.963 887.537 574.691 887.535 581.867L887.52 635.852C887.519 640.395 890.967 646.574 894.902 648.845L941.632 675.825C947.845 679.412 957.918 679.409 964.132 675.819L1010.88 648.815C1014.82 646.538 1018.44 640.267 1018.44 635.715Z"
|
||||
@@ -353,7 +353,7 @@ onMounted(() => {
|
||||
<stop stop-color="#211927" stop-opacity="0" />
|
||||
<stop offset="1" stop-color="#211927" />
|
||||
</linearGradient>
|
||||
<clipPath id="enterpriseHeroBlockClip">
|
||||
<clipPath id="enterpriseHeroClip">
|
||||
<rect width="1600" height="1046" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
@@ -28,11 +28,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
<!-- CTA buttons -->
|
||||
<div class="mt-10 flex flex-col gap-4 lg:flex-row">
|
||||
<DownloadLocalButton :locale />
|
||||
<BrandButton
|
||||
:href="externalLinks.githubInstall"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
>
|
||||
<BrandButton :href="externalLinks.github" variant="outline" size="lg">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<i
|
||||
class="icon-mask size-5 -translate-y-px mask-[url('/icons/social/github.svg')]"
|
||||
|
||||
@@ -276,6 +276,29 @@ onUnmounted(() => {
|
||||
fill="#211927"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Left-edge fade -->
|
||||
<rect
|
||||
x="300"
|
||||
y="150"
|
||||
width="250"
|
||||
height="900"
|
||||
fill="url(#localHeroFadeLeft)"
|
||||
/>
|
||||
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="localHeroFadeLeft"
|
||||
x1="550"
|
||||
y1="600"
|
||||
x2="300"
|
||||
y2="600"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#211927" stop-opacity="0" />
|
||||
<stop offset="1" stop-color="#211927" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -300,7 +323,7 @@ onUnmounted(() => {
|
||||
<div class="mt-8 flex flex-col gap-4 lg:flex-row">
|
||||
<DownloadLocalButton :locale class="lg:min-w-60 lg:p-4" />
|
||||
<BrandButton
|
||||
:href="externalLinks.githubInstall"
|
||||
:href="externalLinks.github"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="lg:min-w-60 lg:p-4"
|
||||
|
||||
@@ -8,9 +8,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-transparency-white-t4 relative z-20 p-4 text-center lg:px-20 lg:py-8"
|
||||
>
|
||||
<section class="bg-transparency-white-t4 p-4 text-center lg:px-20 lg:py-8">
|
||||
<p
|
||||
class="text-primary-comfy-canvas relative z-10 text-lg font-semibold lg:text-sm lg:font-normal"
|
||||
>
|
||||
|
||||
@@ -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,328 +0,0 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import * as THREE from 'three'
|
||||
import { SVGLoader } from 'three/addons/loaders/SVGLoader.js'
|
||||
|
||||
import { prefersReducedMotion } from './useReducedMotion'
|
||||
|
||||
const IMAGE_COUNT = 16
|
||||
const BASE_URL = 'https://media.comfy.org/website/homepage/hero-logo-seq'
|
||||
|
||||
const SVG_MARKUP = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 375 404"><path fill="#000000" d="M296.597 302.576C297.299 300.205 297.682 297.705 297.682 295.078C297.682 280.529 285.938 268.736 271.45 268.736H153.883C147.564 268.8 142.395 263.673 142.395 257.328C142.395 256.174 142.586 255.084 142.841 254.059L174.499 143.309C175.839 138.438 180.307 134.849 185.541 134.849L303.554 134.72C328.446 134.72 349.444 117.864 355.763 94.8555L373.506 33.1353C374.081 30.9562 374.4 28.5848 374.4 26.2134C374.4 11.7288 362.72 0 348.295 0H205.518C180.754 0 159.819 16.7279 153.373 39.4804L141.373 81.5886C139.969 86.3954 135.565 89.9205 130.332 89.9205H96.0573C71.4845 89.9205 50.7412 106.328 44.1034 128.824L0.957382 280.144C0.319127 282.387 0 284.823 0 287.258C0 301.807 11.7439 313.6 26.2323 313.6H59.9321C66.2508 313.6 71.4207 318.727 71.4207 325.137C71.4207 326.226 71.293 327.316 70.9739 328.341L59.0385 370.065C58.4641 372.308 58.0811 374.615 58.0811 376.987C58.0811 391.471 69.7612 403.2 84.1857 403.2L227.027 403.072C251.855 403.072 272.79 386.28 279.172 363.399L296.533 302.64L296.597 302.576Z"/></svg>`
|
||||
|
||||
interface HeroLogoConfig {
|
||||
speed: number
|
||||
tiltX: number
|
||||
tiltZ: number
|
||||
zoom: number
|
||||
fov: number
|
||||
logoColor: string
|
||||
extrudeDepth: number
|
||||
cursorTiltStrength: number
|
||||
bgScale: number
|
||||
slideDuration: number
|
||||
}
|
||||
|
||||
const DEFAULTS: HeroLogoConfig = {
|
||||
speed: 1,
|
||||
tiltX: -0.1,
|
||||
tiltZ: -0.1,
|
||||
zoom: 7,
|
||||
fov: 50,
|
||||
logoColor: '#F2FF59',
|
||||
extrudeDepth: 200,
|
||||
cursorTiltStrength: 0.5,
|
||||
bgScale: 0.8,
|
||||
slideDuration: 0.4
|
||||
}
|
||||
|
||||
function buildImageUrls(): string[] {
|
||||
return Array.from({ length: IMAGE_COUNT }, (_, i) => {
|
||||
const index = String(i).padStart(5, '0')
|
||||
return `${BASE_URL}/image_sequence_${index}.webp`
|
||||
})
|
||||
}
|
||||
|
||||
function parseShapes(): THREE.Shape[] {
|
||||
const loader = new SVGLoader()
|
||||
const svgData = loader.parse(SVG_MARKUP)
|
||||
const shapes: THREE.Shape[] = []
|
||||
svgData.paths.forEach((path) => {
|
||||
shapes.push(...SVGLoader.createShapes(path))
|
||||
})
|
||||
return shapes
|
||||
}
|
||||
|
||||
function loadTextures(urls: string[]): Promise<THREE.Texture[]> {
|
||||
return Promise.all(
|
||||
urls.map(
|
||||
(url) =>
|
||||
new Promise<THREE.Texture | null>((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => {
|
||||
const tex = new THREE.Texture(img)
|
||||
tex.needsUpdate = true
|
||||
tex.colorSpace = THREE.SRGBColorSpace
|
||||
resolve(tex)
|
||||
}
|
||||
img.onerror = () => resolve(null)
|
||||
img.src = url
|
||||
})
|
||||
)
|
||||
).then((results) => results.filter((t): t is THREE.Texture => t !== null))
|
||||
}
|
||||
|
||||
export function useHeroLogo(
|
||||
containerRef: Ref<HTMLElement | undefined>,
|
||||
config: Partial<HeroLogoConfig> = {}
|
||||
) {
|
||||
const cfg = { ...DEFAULTS, ...config }
|
||||
const loaded = ref(false)
|
||||
let cleanup: (() => void) | undefined
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const container = containerRef.value
|
||||
if (!container || prefersReducedMotion()) return
|
||||
|
||||
const { width, height } = container.getBoundingClientRect()
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
stencil: true,
|
||||
alpha: true
|
||||
})
|
||||
renderer.setSize(width, height)
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
renderer.domElement.style.position = 'absolute'
|
||||
renderer.domElement.style.inset = '0'
|
||||
renderer.domElement.style.width = '100%'
|
||||
renderer.domElement.style.height = '100%'
|
||||
renderer.domElement.style.opacity = '0'
|
||||
renderer.domElement.setAttribute('aria-hidden', 'true')
|
||||
container.appendChild(renderer.domElement)
|
||||
|
||||
let disposed = false
|
||||
const teardowns: Array<() => void> = []
|
||||
cleanup = () => {
|
||||
disposed = true
|
||||
teardowns.forEach((fn) => fn())
|
||||
}
|
||||
teardowns.push(() => {
|
||||
renderer.dispose()
|
||||
renderer.domElement.remove()
|
||||
})
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
cfg.fov,
|
||||
width / height,
|
||||
0.1,
|
||||
1000
|
||||
)
|
||||
camera.position.z = cfg.zoom
|
||||
|
||||
// SVG shape
|
||||
const shapes = parseShapes()
|
||||
const tempGeo = new THREE.ShapeGeometry(shapes)
|
||||
tempGeo.computeBoundingBox()
|
||||
const bb = tempGeo.boundingBox!
|
||||
const cx = (bb.max.x + bb.min.x) / 2
|
||||
const cy = (bb.max.y + bb.min.y) / 2
|
||||
const scaleFactor = 3 / (bb.max.y - bb.min.y)
|
||||
tempGeo.dispose()
|
||||
|
||||
// Image sequence textures — load first frame eagerly, rest lazily
|
||||
const urls = buildImageUrls()
|
||||
const textures = await loadTextures(urls.slice(0, 1))
|
||||
if (disposed) return
|
||||
|
||||
renderer.domElement.style.opacity = '1'
|
||||
loaded.value = true
|
||||
|
||||
loadTextures(urls.slice(1)).then((rest) => {
|
||||
if (!disposed) textures.push(...rest)
|
||||
})
|
||||
|
||||
// Background plane (stencil read)
|
||||
const bgPlaneGeo = new THREE.PlaneGeometry(14, 14)
|
||||
const bgPlaneMat = new THREE.MeshBasicMaterial({
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
map: textures[0] ?? null,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
stencilWrite: true,
|
||||
stencilFunc: THREE.EqualStencilFunc,
|
||||
stencilRef: 1,
|
||||
stencilFail: THREE.KeepStencilOp,
|
||||
stencilZFail: THREE.KeepStencilOp,
|
||||
stencilZPass: THREE.KeepStencilOp
|
||||
})
|
||||
const bgPlane = new THREE.Mesh(bgPlaneGeo, bgPlaneMat)
|
||||
bgPlane.renderOrder = 1
|
||||
bgPlane.scale.set(cfg.bgScale, cfg.bgScale, 1)
|
||||
scene.add(bgPlane)
|
||||
|
||||
// Logo group
|
||||
const group = new THREE.Group()
|
||||
scene.add(group)
|
||||
|
||||
const s = scaleFactor
|
||||
const depth = cfg.extrudeDepth
|
||||
|
||||
// Front face
|
||||
const shapeGeo = new THREE.ShapeGeometry(shapes)
|
||||
shapeGeo.translate(-cx, -cy, 0)
|
||||
shapeGeo.scale(s, -s, s)
|
||||
const shapeMat = new THREE.MeshBasicMaterial({
|
||||
color: cfg.logoColor,
|
||||
side: THREE.DoubleSide,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
transparent: true
|
||||
})
|
||||
const logoMesh = new THREE.Mesh(shapeGeo, shapeMat)
|
||||
logoMesh.renderOrder = 2
|
||||
group.add(logoMesh)
|
||||
|
||||
// Extrusion stencil mask
|
||||
const extrudeGeo = new THREE.ExtrudeGeometry(shapes, {
|
||||
depth,
|
||||
bevelEnabled: false
|
||||
})
|
||||
extrudeGeo.translate(-cx, -cy, -depth)
|
||||
extrudeGeo.scale(s, -s, s)
|
||||
const extrudeMat = new THREE.MeshBasicMaterial({
|
||||
colorWrite: false,
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
stencilWrite: true,
|
||||
stencilRef: 1,
|
||||
stencilFunc: THREE.AlwaysStencilFunc,
|
||||
stencilZPass: THREE.ReplaceStencilOp,
|
||||
stencilFail: THREE.KeepStencilOp,
|
||||
stencilZFail: THREE.KeepStencilOp,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
const extrudeMesh = new THREE.Mesh(extrudeGeo, extrudeMat)
|
||||
extrudeMesh.renderOrder = 0
|
||||
group.add(extrudeMesh)
|
||||
|
||||
// Interaction
|
||||
let isDragging = false
|
||||
let previousX = 0
|
||||
let dragVelocity = 0
|
||||
let currentTiltX = 0
|
||||
let currentTiltY = 0
|
||||
let pointerX = 0
|
||||
let pointerY = 0
|
||||
let rotationT = 0
|
||||
let currentSlide = 0
|
||||
let slideTimer = 0
|
||||
let animationId = 0
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
pointerX = (e.clientX / window.innerWidth) * 2 - 1
|
||||
pointerY = (e.clientY / window.innerHeight) * 2 - 1
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
isDragging = true
|
||||
dragVelocity = 0
|
||||
previousX = e.clientX
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isDragging) return
|
||||
dragVelocity = (e.clientX - previousX) * 0.005
|
||||
rotationT += dragVelocity
|
||||
previousX = e.clientX
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
isDragging = false
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
const rect = container!.getBoundingClientRect()
|
||||
camera.aspect = rect.width / rect.height
|
||||
camera.updateProjectionMatrix()
|
||||
renderer.setSize(rect.width, rect.height)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
renderer.domElement.addEventListener('pointerdown', onPointerDown)
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('resize', onResize)
|
||||
|
||||
const clock = new THREE.Clock()
|
||||
|
||||
function animate() {
|
||||
if (disposed) return
|
||||
animationId = requestAnimationFrame(animate)
|
||||
const dt = clock.getDelta()
|
||||
|
||||
if (!isDragging && Math.abs(dragVelocity) > 0.0001) {
|
||||
dragVelocity *= 0.95
|
||||
rotationT += dragVelocity
|
||||
} else if (!isDragging) {
|
||||
dragVelocity = 0
|
||||
}
|
||||
|
||||
rotationT += cfg.speed * dt
|
||||
|
||||
currentTiltX += (pointerY - currentTiltX) * 0.08
|
||||
currentTiltY += (pointerX - currentTiltY) * 0.08
|
||||
|
||||
group.rotation.y = rotationT % (Math.PI * 2)
|
||||
group.rotation.x = cfg.tiltX - currentTiltX * cfg.cursorTiltStrength
|
||||
group.rotation.z = cfg.tiltZ
|
||||
|
||||
if (textures.length > 1) {
|
||||
slideTimer += dt
|
||||
if (slideTimer >= cfg.slideDuration) {
|
||||
slideTimer = 0
|
||||
currentSlide = (currentSlide + 1) % textures.length
|
||||
bgPlaneMat.map = textures[currentSlide]
|
||||
bgPlaneMat.needsUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
|
||||
animate()
|
||||
|
||||
teardowns.push(
|
||||
() => cancelAnimationFrame(animationId),
|
||||
() => window.removeEventListener('mousemove', onMouseMove),
|
||||
() =>
|
||||
renderer.domElement.removeEventListener('pointerdown', onPointerDown),
|
||||
() => window.removeEventListener('pointermove', onPointerMove),
|
||||
() => window.removeEventListener('pointerup', onPointerUp),
|
||||
() => window.removeEventListener('resize', onResize),
|
||||
() => bgPlaneGeo.dispose(),
|
||||
() => bgPlaneMat.dispose(),
|
||||
() => shapeGeo.dispose(),
|
||||
() => shapeMat.dispose(),
|
||||
() => extrudeGeo.dispose(),
|
||||
() => extrudeMat.dispose(),
|
||||
() => textures.forEach((tex) => tex.dispose())
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[useHeroLogo] initialization failed:', err)
|
||||
cleanup?.()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup?.()
|
||||
})
|
||||
|
||||
return { loaded }
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { PackNode } from '../data/cloudNodes'
|
||||
|
||||
import { useNodesByCategory } from './useNodesByCategory'
|
||||
|
||||
function node(name: string, displayName: string, category: string): PackNode {
|
||||
return { name, displayName, category }
|
||||
}
|
||||
|
||||
describe('useNodesByCategory', () => {
|
||||
it('groups nodes by category', () => {
|
||||
const { groupedNodes } = useNodesByCategory(() => [
|
||||
node('A', 'A', 'cat-1'),
|
||||
node('B', 'B', 'cat-2'),
|
||||
node('C', 'C', 'cat-1')
|
||||
])
|
||||
expect(groupedNodes.value).toHaveLength(2)
|
||||
expect(groupedNodes.value[0]).toMatchObject({
|
||||
category: 'cat-1',
|
||||
nodes: [
|
||||
expect.objectContaining({ name: 'A' }),
|
||||
expect.objectContaining({ name: 'C' })
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('sorts nodes alphabetically by display name within a category', () => {
|
||||
const { groupedNodes } = useNodesByCategory(() => [
|
||||
node('z', 'Zulu', 'x'),
|
||||
node('a', 'Alpha', 'x'),
|
||||
node('m', 'Mike', 'x')
|
||||
])
|
||||
expect(groupedNodes.value[0].nodes.map((n) => n.displayName)).toEqual([
|
||||
'Alpha',
|
||||
'Mike',
|
||||
'Zulu'
|
||||
])
|
||||
})
|
||||
|
||||
it('sorts categories alphabetically', () => {
|
||||
const { groupedNodes } = useNodesByCategory(() => [
|
||||
node('a', 'A', 'beta'),
|
||||
node('b', 'B', 'alpha'),
|
||||
node('c', 'C', 'gamma')
|
||||
])
|
||||
expect(groupedNodes.value.map((g) => g.category)).toEqual([
|
||||
'alpha',
|
||||
'beta',
|
||||
'gamma'
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to a placeholder for missing categories', () => {
|
||||
const { groupedNodes } = useNodesByCategory(() => [node('a', 'A', '')])
|
||||
expect(groupedNodes.value[0].category).toBe('—')
|
||||
})
|
||||
|
||||
it('reacts to ref changes', () => {
|
||||
const nodes = ref<PackNode[]>([node('a', 'A', 'x')])
|
||||
const { groupedNodes } = useNodesByCategory(nodes)
|
||||
expect(groupedNodes.value).toHaveLength(1)
|
||||
|
||||
nodes.value = [node('a', 'A', 'x'), node('b', 'B', 'y')]
|
||||
expect(groupedNodes.value).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import type { PackNode } from '../data/cloudNodes'
|
||||
|
||||
const UNCATEGORIZED = '—'
|
||||
|
||||
interface NodeCategoryGroup {
|
||||
category: string
|
||||
nodes: PackNode[]
|
||||
}
|
||||
|
||||
export function useNodesByCategory(
|
||||
nodes: MaybeRefOrGetter<readonly PackNode[]>
|
||||
) {
|
||||
const groupedNodes = computed<NodeCategoryGroup[]>(() => {
|
||||
const byCategory = new Map<string, PackNode[]>()
|
||||
|
||||
for (const node of toValue(nodes)) {
|
||||
const category = node.category || UNCATEGORIZED
|
||||
const existing = byCategory.get(category)
|
||||
if (existing) {
|
||||
existing.push(node)
|
||||
continue
|
||||
}
|
||||
byCategory.set(category, [node])
|
||||
}
|
||||
|
||||
return [...byCategory.entries()]
|
||||
.map(([category, items]) => ({
|
||||
category,
|
||||
nodes: [...items].sort((a, b) =>
|
||||
a.displayName.localeCompare(b.displayName)
|
||||
)
|
||||
}))
|
||||
.sort((a, b) => a.category.localeCompare(b.category))
|
||||
})
|
||||
|
||||
return { groupedNodes }
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { TranslationKey } from '../i18n/translations'
|
||||
|
||||
interface Demo {
|
||||
readonly slug: string
|
||||
readonly arcadeId: string
|
||||
readonly category: TranslationKey
|
||||
readonly title: TranslationKey
|
||||
readonly description: TranslationKey
|
||||
readonly ogImage: string
|
||||
readonly thumbnail: string
|
||||
readonly estimatedTime: TranslationKey
|
||||
readonly durationIso: string
|
||||
readonly difficulty: 'beginner' | 'intermediate' | 'advanced'
|
||||
readonly tags: readonly string[]
|
||||
readonly transcript?: TranslationKey
|
||||
readonly publishedDate: string
|
||||
readonly modifiedDate: string
|
||||
/**
|
||||
* Width / height of the Arcade demo's source recording (e.g. 1.93 for a
|
||||
* landscape screencast). Sizes the embed container to match so rounded
|
||||
* corners hug the content instead of empty letterbox space. Source from
|
||||
* Arcade's `_serializablePublicFlow.aspectRatio` (which is height/width —
|
||||
* invert it). Defaults to 16/9 if omitted.
|
||||
*/
|
||||
readonly aspectRatio?: number
|
||||
}
|
||||
|
||||
export const demos: readonly Demo[] = [
|
||||
{
|
||||
slug: 'image-to-video',
|
||||
arcadeId: 'F3CTalnGnR4R0qJIVMNX',
|
||||
category: 'demos.category.templates',
|
||||
title: 'demos.image-to-video.title',
|
||||
description: 'demos.image-to-video.description',
|
||||
transcript: 'demos.image-to-video.transcript',
|
||||
ogImage: '/images/demos/image-to-video-og.png',
|
||||
thumbnail: '/images/demos/image-to-video-thumb.webp',
|
||||
estimatedTime: 'demos.duration.2min',
|
||||
durationIso: 'PT2M',
|
||||
difficulty: 'beginner',
|
||||
tags: ['templates', 'image', 'video'],
|
||||
publishedDate: '2026-04-19',
|
||||
modifiedDate: '2026-04-19',
|
||||
aspectRatio: 1.931
|
||||
},
|
||||
{
|
||||
slug: 'workflow-templates',
|
||||
arcadeId: 'KhqcXDElnFWklo7ACBqE',
|
||||
category: 'demos.category.gettingStarted',
|
||||
title: 'demos.workflow-templates.title',
|
||||
description: 'demos.workflow-templates.description',
|
||||
transcript: 'demos.workflow-templates.transcript',
|
||||
ogImage: '/images/demos/workflow-templates-og.png',
|
||||
thumbnail: '/images/demos/workflow-templates-thumb.webp',
|
||||
estimatedTime: 'demos.duration.2min',
|
||||
durationIso: 'PT2M',
|
||||
difficulty: 'beginner',
|
||||
tags: ['getting-started', 'templates', 'workflow'],
|
||||
publishedDate: '2026-04-19',
|
||||
modifiedDate: '2026-04-19',
|
||||
aspectRatio: 1.931
|
||||
},
|
||||
{
|
||||
slug: 'community-workflows',
|
||||
arcadeId: 'mqZh17oWDuWIyhK0xwEV',
|
||||
category: 'demos.category.gettingStarted',
|
||||
title: 'demos.community-workflows.title',
|
||||
description: 'demos.community-workflows.description',
|
||||
transcript: 'demos.community-workflows.transcript',
|
||||
ogImage: '/images/demos/community-workflows-og.png',
|
||||
thumbnail: '/images/demos/community-workflows-thumb.webp',
|
||||
estimatedTime: 'demos.duration.2min',
|
||||
durationIso: 'PT2M',
|
||||
difficulty: 'beginner',
|
||||
tags: ['getting-started', 'community', 'workflow', 'hub'],
|
||||
publishedDate: '2026-05-04',
|
||||
modifiedDate: '2026-05-04',
|
||||
aspectRatio: 1.931
|
||||
}
|
||||
]
|
||||
|
||||
export function getDemoBySlug(slug: string): Demo | undefined {
|
||||
return demos.find((demo) => demo.slug === slug)
|
||||
}
|
||||
|
||||
export function getNextDemo(slug: string): Demo {
|
||||
if (demos.length === 0) {
|
||||
throw new Error('No demos configured')
|
||||
}
|
||||
const index = demos.findIndex((demo) => demo.slug === slug)
|
||||
if (index === -1) {
|
||||
throw new Error(`Unknown demo slug: ${slug}`)
|
||||
}
|
||||
return demos[(index + 1) % demos.length]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export const SHOW_FREE_TIER = false
|
||||
@@ -1,208 +0,0 @@
|
||||
interface ModelOverride {
|
||||
docsUrl?: string
|
||||
blogUrl?: string
|
||||
featured?: boolean
|
||||
// Slug used on comfy.org/workflows/model/{hubSlug}. Only set when the page exists.
|
||||
hubSlug?: string
|
||||
}
|
||||
|
||||
export const modelMetadata: Record<string, ModelOverride> = {
|
||||
'nano-banana': {
|
||||
docsUrl:
|
||||
'https://docs.comfy.org/tutorials/partner-nodes/google/nano-banana-pro',
|
||||
hubSlug: 'nano-banana',
|
||||
featured: true
|
||||
},
|
||||
'kling-ai': {
|
||||
docsUrl:
|
||||
'https://docs.comfy.org/tutorials/partner-nodes/kling/kling-motion-control',
|
||||
hubSlug: 'kling',
|
||||
featured: true
|
||||
},
|
||||
'meshy-ai': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/partner-nodes/meshy/meshy-6',
|
||||
hubSlug: 'meshy',
|
||||
featured: true
|
||||
},
|
||||
'openai-dall-e': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/partner-nodes/openai/dall-e-3',
|
||||
hubSlug: 'openai',
|
||||
featured: true
|
||||
},
|
||||
'ltxv-api': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/video/ltxv',
|
||||
hubSlug: 'ltx-2-3',
|
||||
featured: true
|
||||
},
|
||||
'wan-api': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan2_2',
|
||||
hubSlug: 'wan',
|
||||
featured: true
|
||||
},
|
||||
'wan-2-2': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan2_2',
|
||||
hubSlug: 'wan',
|
||||
featured: true
|
||||
},
|
||||
'wan-2-1': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan-video',
|
||||
hubSlug: 'wan',
|
||||
featured: true
|
||||
},
|
||||
'flux-1-kontext-dev': {
|
||||
docsUrl:
|
||||
'https://docs.comfy.org/tutorials/partner-nodes/black-forest-labs/flux-1-kontext',
|
||||
hubSlug: 'flux-1-kontext',
|
||||
featured: true
|
||||
},
|
||||
'flux1-dev': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/flux/flux-1-text-to-image',
|
||||
hubSlug: 'flux-1',
|
||||
featured: true
|
||||
},
|
||||
'flux1-schnell': {
|
||||
hubSlug: 'flux-1',
|
||||
featured: true
|
||||
},
|
||||
'hunyuan-video': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/video/hunyuan/hunyuan-video',
|
||||
hubSlug: 'hunyuan-video',
|
||||
featured: true
|
||||
},
|
||||
'hunyuan-3d': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/3d/hunyuan3D-2',
|
||||
hubSlug: 'hunyuan-3d',
|
||||
featured: true
|
||||
},
|
||||
vidu: {
|
||||
hubSlug: 'vidu',
|
||||
featured: true
|
||||
},
|
||||
runway: {
|
||||
hubSlug: 'runway',
|
||||
featured: true
|
||||
},
|
||||
'stability-ai': {
|
||||
hubSlug: 'stability',
|
||||
featured: true
|
||||
},
|
||||
'seedance-bytedance': {
|
||||
hubSlug: 'seedance',
|
||||
featured: true
|
||||
},
|
||||
'grok-image': {
|
||||
hubSlug: 'grok',
|
||||
featured: false
|
||||
},
|
||||
'luma-dream-machine': {
|
||||
hubSlug: 'luma',
|
||||
featured: false
|
||||
},
|
||||
moonvalley: {
|
||||
hubSlug: 'moonvalley',
|
||||
featured: false
|
||||
},
|
||||
'magnific-ai': {
|
||||
hubSlug: 'magnific',
|
||||
featured: false
|
||||
},
|
||||
pixverse: {
|
||||
hubSlug: 'pixverse',
|
||||
featured: false
|
||||
},
|
||||
'rodin-3d': {
|
||||
hubSlug: 'rodin',
|
||||
featured: false
|
||||
},
|
||||
recraft: {
|
||||
hubSlug: 'recraft',
|
||||
featured: false
|
||||
},
|
||||
'bria-ai': {
|
||||
hubSlug: 'bria',
|
||||
featured: false
|
||||
},
|
||||
'topaz-labs': {
|
||||
hubSlug: 'topaz',
|
||||
featured: false
|
||||
},
|
||||
wavespeed: {
|
||||
hubSlug: 'wavespeed',
|
||||
featured: false
|
||||
},
|
||||
ideogram: {
|
||||
hubSlug: 'ideogram',
|
||||
featured: false
|
||||
},
|
||||
'veo-2': {
|
||||
hubSlug: 'veo',
|
||||
featured: false
|
||||
},
|
||||
'veo-3': {
|
||||
hubSlug: 'veo',
|
||||
featured: false
|
||||
},
|
||||
'flux-2-api': {
|
||||
hubSlug: 'flux-2',
|
||||
featured: false
|
||||
},
|
||||
'ace-step-v1-3-5b': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/audio/ace-step/ace-step-v1',
|
||||
hubSlug: 'ace-step',
|
||||
featured: false
|
||||
},
|
||||
'hidream-i1-dev-fp8': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/image/hidream/hidream-i1',
|
||||
hubSlug: 'hidream',
|
||||
featured: false
|
||||
},
|
||||
'omnigen2-fp16': {
|
||||
hubSlug: 'omnigen',
|
||||
featured: false
|
||||
},
|
||||
'sd-xl-base-1-0': {
|
||||
hubSlug: 'sdxl',
|
||||
featured: false
|
||||
},
|
||||
'z-image-bf16': {
|
||||
hubSlug: 'z-image',
|
||||
featured: false
|
||||
},
|
||||
'z-image-turbo-bf16': {
|
||||
hubSlug: 'z-image',
|
||||
featured: false
|
||||
},
|
||||
'svd-xt': {
|
||||
hubSlug: 'svd',
|
||||
featured: false
|
||||
},
|
||||
'flux1-dev-kontext-fp8-scaled': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev',
|
||||
hubSlug: 'flux-1-kontext',
|
||||
featured: false
|
||||
},
|
||||
'ltx-2-19b-dev-fp8': {
|
||||
hubSlug: 'ltx-2',
|
||||
featured: false
|
||||
},
|
||||
'ltx-2-19b-distilled': {
|
||||
hubSlug: 'ltx-2',
|
||||
featured: false
|
||||
},
|
||||
'flux1-fill-dev': {
|
||||
hubSlug: 'flux-1',
|
||||
featured: false
|
||||
},
|
||||
'flux-2-klein-base-9b-fp8': {
|
||||
hubSlug: 'flux-2',
|
||||
featured: false
|
||||
},
|
||||
'qwen-image-fp8-e4m3fn': {
|
||||
hubSlug: 'qwen',
|
||||
featured: false
|
||||
},
|
||||
'qwen-image-edit-2509-fp8-e4m3fn': {
|
||||
hubSlug: 'qwen',
|
||||
featured: false
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import generatedModels from './generated-models.json'
|
||||
import { modelMetadata } from './model-metadata'
|
||||
|
||||
type ModelDirectory =
|
||||
| 'diffusion_models'
|
||||
| 'checkpoints'
|
||||
| 'loras'
|
||||
| 'controlnet'
|
||||
| 'clip_vision'
|
||||
| 'model_patches'
|
||||
| 'vae'
|
||||
| 'text_encoders'
|
||||
| 'audio_encoders'
|
||||
| 'latent_upscale_models'
|
||||
| 'upscale_models'
|
||||
| 'style_models'
|
||||
| 'partner_nodes'
|
||||
|
||||
interface Model {
|
||||
readonly slug: string
|
||||
readonly canonicalSlug?: string
|
||||
readonly name: string
|
||||
readonly displayName: string
|
||||
readonly directory: ModelDirectory
|
||||
readonly huggingFaceUrl: string
|
||||
readonly thumbnailUrl?: string
|
||||
readonly docsUrl?: string
|
||||
readonly blogUrl?: string
|
||||
readonly hubSlug?: string
|
||||
readonly featured: boolean
|
||||
readonly workflowCount: number
|
||||
}
|
||||
|
||||
export const models: readonly Model[] = (
|
||||
generatedModels as Array<{
|
||||
slug: string
|
||||
canonicalSlug?: string
|
||||
name: string
|
||||
displayName: string
|
||||
directory: string
|
||||
huggingFaceUrl: string
|
||||
docsUrl?: string
|
||||
thumbnailUrl?: string
|
||||
workflowCount: number
|
||||
}>
|
||||
).map((m) => ({
|
||||
slug: m.slug,
|
||||
...(m.canonicalSlug ? { canonicalSlug: m.canonicalSlug } : {}),
|
||||
name: m.name,
|
||||
displayName: m.displayName,
|
||||
directory: m.directory as ModelDirectory,
|
||||
huggingFaceUrl: m.huggingFaceUrl,
|
||||
...(m.docsUrl ? { docsUrl: m.docsUrl } : {}),
|
||||
...(m.thumbnailUrl ? { thumbnailUrl: m.thumbnailUrl } : {}),
|
||||
featured: false,
|
||||
workflowCount: m.workflowCount,
|
||||
...modelMetadata[m.slug]
|
||||
}))
|
||||
|
||||
const slugSet = new Set(models.map((m) => m.slug))
|
||||
if (slugSet.size !== models.length) {
|
||||
for (const model of models) {
|
||||
if (models.filter((m) => m.slug === model.slug).length > 1) {
|
||||
throw new Error(`Duplicate model slug: ${model.slug}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const model of models) {
|
||||
if (
|
||||
model.canonicalSlug !== undefined &&
|
||||
(!slugSet.has(model.canonicalSlug) || model.canonicalSlug === model.slug)
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid canonicalSlug "${model.canonicalSlug}" on "${model.slug}"`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function getModelBySlug(slug: string): Model | undefined {
|
||||
return models.find((m) => m.slug === slug)
|
||||
}
|
||||
@@ -11,11 +11,9 @@ const baseRoutes = {
|
||||
about: '/about',
|
||||
careers: '/careers',
|
||||
customers: '/customers',
|
||||
demos: '/demos',
|
||||
termsOfService: '/terms-of-service',
|
||||
privacyPolicy: '/privacy-policy',
|
||||
contact: '/contact',
|
||||
models: '/p/supported-models'
|
||||
contact: '/contact'
|
||||
} as const
|
||||
|
||||
type Routes = typeof baseRoutes
|
||||
@@ -32,15 +30,11 @@ export const externalLinks = {
|
||||
apiKeys: 'https://platform.comfy.org/profile/api-keys',
|
||||
blog: 'https://blog.comfy.org/',
|
||||
cloud: 'https://cloud.comfy.org',
|
||||
cloudStatus: 'https://status.comfy.org',
|
||||
discord: 'https://discord.com/invite/comfyorg',
|
||||
docs: 'https://docs.comfy.org/',
|
||||
docsApi: 'https://docs.comfy.org/api-reference/cloud',
|
||||
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
|
||||
github: 'https://github.com/Comfy-Org/ComfyUI',
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
support: 'https://support.comfy.org/hc/en-us',
|
||||
workflows: 'https://comfy.org/workflows',
|
||||
youtube: 'https://www.youtube.com/@ComfyOrg'
|
||||
|
||||
@@ -1,30 +1,37 @@
|
||||
{
|
||||
"fetchedAt": "2026-05-12T16:10:34.114Z",
|
||||
"fetchedAt": "2026-05-02T20:15:18.321Z",
|
||||
"departments": [
|
||||
{
|
||||
"name": "DESIGN",
|
||||
"key": "design",
|
||||
"roles": [
|
||||
{
|
||||
"id": "18743530eb448c99",
|
||||
"id": "e915f2c78b17f93b",
|
||||
"title": "Senior Product Designer",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3/application"
|
||||
},
|
||||
{
|
||||
"id": "8718d17012f26fa2",
|
||||
"id": "b9f9a23219be7cd4",
|
||||
"title": "Design Engineer",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
|
||||
},
|
||||
{
|
||||
"id": "547b6ba622c800a5",
|
||||
"title": "Senior Product Designer - Craft",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f/application"
|
||||
},
|
||||
{
|
||||
"id": "1e181b9ed8fb2e86",
|
||||
"id": "7bb02634a24763bc",
|
||||
"title": "Staff Product Designer - Systems",
|
||||
"department": "Design",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34/application"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -33,67 +40,67 @@
|
||||
"key": "engineering",
|
||||
"roles": [
|
||||
{
|
||||
"id": "6a6d865eeb3c10a8",
|
||||
"id": "102d58e35a8a9817",
|
||||
"title": "Senior Software Engineer, Frontend",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2/application"
|
||||
},
|
||||
{
|
||||
"id": "1b4f7f1da9616e14",
|
||||
"id": "d01d69fba7743905",
|
||||
"title": "Senior Software Engineer, Backend Generalist",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e/application"
|
||||
},
|
||||
{
|
||||
"id": "a6d8269c66e37c5c",
|
||||
"id": "f36f60cfd5bb5910",
|
||||
"title": "Senior/Staff Applied Machine Learning Engineer",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0/application"
|
||||
},
|
||||
{
|
||||
"id": "841da783e6e41928",
|
||||
"id": "9d8ec4c65e20b19e",
|
||||
"title": "Software Engineer, Frontend",
|
||||
"department": "Engineering",
|
||||
"location": "Remote",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40/application"
|
||||
},
|
||||
{
|
||||
"id": "5d01d58b03870d7a",
|
||||
"id": "be94b193d1f4d482",
|
||||
"title": "Tech Lead Manager, Frontend",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261/application"
|
||||
},
|
||||
{
|
||||
"id": "91604c4182a1bc3c",
|
||||
"id": "ab48f5db6bd1783c",
|
||||
"title": "Software Engineer, Core ComfyUI Contributor",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f/application"
|
||||
},
|
||||
{
|
||||
"id": "a1dbc0576ab14034",
|
||||
"id": "c5dff4ee628bdcd1",
|
||||
"title": "Software Engineer, ComfyUI Desktop",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0/application"
|
||||
},
|
||||
{
|
||||
"id": "0b8f4fecd89c3b11",
|
||||
"id": "4302a7aaa87e16e3",
|
||||
"title": "Product Manager, ComfyUI",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
|
||||
},
|
||||
{
|
||||
"id": "2f6bac39d723dfef",
|
||||
"id": "2eb53e8943cc9396",
|
||||
"title": "Growth Engineer",
|
||||
"department": "Engineering",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5/application"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -102,39 +109,39 @@
|
||||
"key": "marketing",
|
||||
"roles": [
|
||||
{
|
||||
"id": "23dd98cab77ff459",
|
||||
"id": "4c5d6afb78652df7",
|
||||
"title": "Freelance Motion Designer",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
|
||||
},
|
||||
{
|
||||
"id": "a998b9fc973ff3c0",
|
||||
"id": "0f5256cf302e552b",
|
||||
"title": "Creative Artist",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
|
||||
},
|
||||
{
|
||||
"id": "3e730938026d6e70",
|
||||
"id": "5746486d87874937",
|
||||
"title": "Graphic Designer",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
|
||||
},
|
||||
{
|
||||
"id": "6f771af6858283aa",
|
||||
"id": "b5803a0d4785d406",
|
||||
"title": "Lifecycle Growth Marketer",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64/application"
|
||||
},
|
||||
{
|
||||
"id": "527a47e82970afc1",
|
||||
"id": "130d7218d7895bdb",
|
||||
"title": "Partnership & Events Marketing Manager",
|
||||
"department": "Marketing",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c/application"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -143,18 +150,25 @@
|
||||
"key": "operations",
|
||||
"roles": [
|
||||
{
|
||||
"id": "0c6cc3685194ab7a",
|
||||
"title": "Head of Talent",
|
||||
"id": "ec68ae44dd5943c9",
|
||||
"title": "Talent Lead",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
|
||||
},
|
||||
{
|
||||
"id": "82bd6ed26adab1c3",
|
||||
"id": "16f556001ce1cef4",
|
||||
"title": "BizOps Strategist",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/145b8558-0ab4-43e8-8fac-b59059cf2537/application"
|
||||
},
|
||||
{
|
||||
"id": "8e773a72c1b8e099",
|
||||
"title": "Founding Customer Success Manager",
|
||||
"department": "Operations",
|
||||
"location": "San Francisco",
|
||||
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4"
|
||||
"applyUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4/application"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
{
|
||||
"fetchedAt": "2026-05-04T16:29:55.587Z",
|
||||
"packs": [
|
||||
{
|
||||
"id": "comfyui-impact-pack",
|
||||
"registryId": "comfyui-impact-pack",
|
||||
"displayName": "ComfyUI Impact Pack",
|
||||
"description": "Production-grade detailer, detector, and SEG (segmentation) tooling. The most-used pack for face restoration, region-based refinement, and iterative upscaling on Comfy Cloud.",
|
||||
"repoUrl": "https://github.com/ltdrdata/ComfyUI-Impact-Pack",
|
||||
"publisher": {
|
||||
"id": "drltdata",
|
||||
"name": "Dr.Lt.Data"
|
||||
},
|
||||
"downloads": 2618646,
|
||||
"githubStars": 3092,
|
||||
"latestVersion": "8.28.3",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-04-19T17:08:04.993918Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "FaceDetailer",
|
||||
"displayName": "FaceDetailer",
|
||||
"category": "ImpactPack/Detailer",
|
||||
"description": "Detect and refine faces with iterative passes."
|
||||
},
|
||||
{
|
||||
"name": "DetailerForEach",
|
||||
"displayName": "DetailerForEach",
|
||||
"category": "ImpactPack/Detailer",
|
||||
"description": "Run iterative detail refinement over detected SEG regions."
|
||||
},
|
||||
{
|
||||
"name": "UltralyticsDetectorProvider",
|
||||
"displayName": "UltralyticsDetectorProvider",
|
||||
"category": "ImpactPack/Detector",
|
||||
"description": "Provide detector models powered by Ultralytics YOLO."
|
||||
},
|
||||
{
|
||||
"name": "SAMLoader",
|
||||
"displayName": "SAMLoader",
|
||||
"category": "ImpactPack/Detector",
|
||||
"description": "Load Segment Anything models for high-fidelity masking."
|
||||
},
|
||||
{
|
||||
"name": "MaskToSEGS",
|
||||
"displayName": "MaskToSEGS",
|
||||
"category": "ImpactPack/Operation",
|
||||
"description": "Convert binary masks into SEGS regions for the detailer pipeline."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ComfyUI-Crystools",
|
||||
"registryId": "ComfyUI-Crystools",
|
||||
"displayName": "ComfyUI-Crystools",
|
||||
"description": "Live system monitoring (GPU, RAM, disk) and rich image inspection inside your workflow. The most-installed quality-of-life pack on the registry.",
|
||||
"iconUrl": "https://raw.githubusercontent.com/crystian/ComfyUI-Crystools/main/docs/screwdriver.png",
|
||||
"repoUrl": "https://github.com/crystian/ComfyUI-Crystools",
|
||||
"publisher": {
|
||||
"id": "crystian",
|
||||
"name": "Crystian"
|
||||
},
|
||||
"downloads": 1671447,
|
||||
"githubStars": 1855,
|
||||
"latestVersion": "1.27.4",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2025-10-26T19:11:09.943366Z",
|
||||
"supportedOs": ["OS Independent"],
|
||||
"supportedAccelerators": ["GPU :: NVIDIA CUDA"],
|
||||
"nodes": [
|
||||
{
|
||||
"name": "CCrystools_Show_Resources",
|
||||
"displayName": "CCrystools_Show_Resources",
|
||||
"category": "crystools/show",
|
||||
"description": "Display GPU, RAM and disk usage live in the workflow."
|
||||
},
|
||||
{
|
||||
"name": "CCrystools_Show_Image",
|
||||
"displayName": "CCrystools_Show_Image",
|
||||
"category": "crystools/show",
|
||||
"description": "Inspect images at full resolution with metadata overlays."
|
||||
},
|
||||
{
|
||||
"name": "CCrystools_Json",
|
||||
"displayName": "CCrystools_Json",
|
||||
"category": "crystools/json",
|
||||
"description": "Compose and parse JSON inline for advanced workflows."
|
||||
},
|
||||
{
|
||||
"name": "CCrystools_Pipe_To_Any",
|
||||
"displayName": "CCrystools_Pipe_To_Any",
|
||||
"category": "crystools/pipe",
|
||||
"description": "Convert a pipe bus into individual outputs."
|
||||
},
|
||||
{
|
||||
"name": "CCrystools_Save_Metadata",
|
||||
"displayName": "CCrystools_Save_Metadata",
|
||||
"category": "crystools/save",
|
||||
"description": "Save images with workflow metadata embedded."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rgthree-comfy",
|
||||
"registryId": "rgthree-comfy",
|
||||
"displayName": "rgthree-comfy",
|
||||
"description": "Quality-of-life nodes that make complex workflows readable: Power Lora Loader, group bypassers, smarter reroutes, and inline debug widgets.",
|
||||
"iconUrl": "https://comfy.rgthree.com/media/rgthree.svg",
|
||||
"repoUrl": "https://github.com/rgthree/rgthree-comfy",
|
||||
"publisher": {
|
||||
"id": "rgthree"
|
||||
},
|
||||
"downloads": 3025389,
|
||||
"githubStars": 3028,
|
||||
"latestVersion": "1.0.2604070017",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-04-07T04:19:24.689627Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Power Lora Loader (rgthree)",
|
||||
"displayName": "Power Lora Loader (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "Stack multiple LoRAs in a single, foldable widget."
|
||||
},
|
||||
{
|
||||
"name": "Fast Groups Bypasser (rgthree)",
|
||||
"displayName": "Fast Groups Bypasser (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "Toggle whole groups on or off without rewiring."
|
||||
},
|
||||
{
|
||||
"name": "Seed (rgthree)",
|
||||
"displayName": "Seed (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "A predictable seed control with quick reset."
|
||||
},
|
||||
{
|
||||
"name": "Reroute (rgthree)",
|
||||
"displayName": "Reroute (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "A clean reroute alternative with persistent labels."
|
||||
},
|
||||
{
|
||||
"name": "Display Any (rgthree)",
|
||||
"displayName": "Display Any (rgthree)",
|
||||
"category": "rgthree",
|
||||
"description": "Inspect any value with a compact debug widget."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "comfyui-kjnodes",
|
||||
"registryId": "comfyui-kjnodes",
|
||||
"displayName": "ComfyUI-KJNodes",
|
||||
"description": "Daily-driver utilities for image, latent and string handling: color matching, batch counters, resize helpers, and prompt presets.",
|
||||
"iconUrl": "https://avatars.githubusercontent.com/u/40791699",
|
||||
"repoUrl": "https://github.com/kijai/ComfyUI-KJNodes",
|
||||
"publisher": {
|
||||
"id": "kijai",
|
||||
"name": "Kijai"
|
||||
},
|
||||
"downloads": 3319866,
|
||||
"githubStars": 2544,
|
||||
"latestVersion": "1.3.9",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-04-24T09:32:28.326616Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "ColorMatch",
|
||||
"displayName": "ColorMatch",
|
||||
"category": "KJNodes/image",
|
||||
"description": "Match the colors of one image to another using statistics."
|
||||
},
|
||||
{
|
||||
"name": "ImageResizeKJ",
|
||||
"displayName": "ImageResizeKJ",
|
||||
"category": "KJNodes/image",
|
||||
"description": "Resize images with intuitive size and divisibility controls."
|
||||
},
|
||||
{
|
||||
"name": "StringConstantMultiline",
|
||||
"displayName": "StringConstantMultiline",
|
||||
"category": "KJNodes/string",
|
||||
"description": "A multi-line string constant suitable for prompts."
|
||||
},
|
||||
{
|
||||
"name": "EmptyLatentImagePresets",
|
||||
"displayName": "EmptyLatentImagePresets",
|
||||
"category": "KJNodes/latent",
|
||||
"description": "Quickly create empty latents at common resolutions."
|
||||
},
|
||||
{
|
||||
"name": "GetImageSizeAndCount",
|
||||
"displayName": "GetImageSizeAndCount",
|
||||
"category": "KJNodes/image",
|
||||
"description": "Read width, height and batch size from an image input."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "comfyui-easy-use",
|
||||
"registryId": "comfyui-easy-use",
|
||||
"displayName": "ComfyUI-Easy-Use",
|
||||
"description": "Simplified, opinionated nodes that bundle common patterns into single drop-ins — full loader, pre-sampling, easy KSampler, and XY plotting.",
|
||||
"iconUrl": "https://mintlify.s3.us-west-1.amazonaws.com/yolain/images/logo.svg",
|
||||
"repoUrl": "https://github.com/yolain/ComfyUI-Easy-Use",
|
||||
"publisher": {
|
||||
"id": "yolain",
|
||||
"name": "yolain"
|
||||
},
|
||||
"downloads": 2767609,
|
||||
"githubStars": 2500,
|
||||
"latestVersion": "1.3.6",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-01-23T06:19:17.505188Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "easy fullLoader",
|
||||
"displayName": "easy fullLoader",
|
||||
"category": "EasyUse/Loaders",
|
||||
"description": "Combined checkpoint, VAE and CLIP loader with sensible defaults."
|
||||
},
|
||||
{
|
||||
"name": "easy preSampling",
|
||||
"displayName": "easy preSampling",
|
||||
"category": "EasyUse/PreSampling",
|
||||
"description": "A unified pre-sampling node bundling common settings."
|
||||
},
|
||||
{
|
||||
"name": "easy kSampler",
|
||||
"displayName": "easy kSampler",
|
||||
"category": "EasyUse/KSampler",
|
||||
"description": "A simplified KSampler with extra quality-of-life options."
|
||||
},
|
||||
{
|
||||
"name": "easy showAnything",
|
||||
"displayName": "easy showAnything",
|
||||
"category": "EasyUse/Util",
|
||||
"description": "Display any value inline for debugging."
|
||||
},
|
||||
{
|
||||
"name": "easy XYPlot",
|
||||
"displayName": "easy XYPlot",
|
||||
"category": "EasyUse/XYPlot",
|
||||
"description": "Compose XY plots over arbitrary parameters."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "comfyui-advanced-controlnet",
|
||||
"registryId": "comfyui-advanced-controlnet",
|
||||
"displayName": "ComfyUI-Advanced-ControlNet",
|
||||
"description": "ControlNet with timestep keyframes, per-frame masks, and advanced strength scheduling — essential for animation and batched-latent workflows.",
|
||||
"repoUrl": "https://github.com/Kosinkadink/ComfyUI-Advanced-ControlNet",
|
||||
"publisher": {
|
||||
"id": "kosinkadink",
|
||||
"name": "Kosinkadink"
|
||||
},
|
||||
"downloads": 590539,
|
||||
"githubStars": 967,
|
||||
"latestVersion": "1.5.7",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2026-03-30T01:40:06.836236Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Apply Advanced ControlNet",
|
||||
"displayName": "Apply Advanced ControlNet",
|
||||
"category": "Adv-ControlNet/conditioning",
|
||||
"description": "Apply ControlNet with timestep keyframes and per-frame masks."
|
||||
},
|
||||
{
|
||||
"name": "ControlNetLoaderAdvanced",
|
||||
"displayName": "ControlNetLoaderAdvanced",
|
||||
"category": "Adv-ControlNet/loaders",
|
||||
"description": "Load ControlNet models with the advanced wrapper."
|
||||
},
|
||||
{
|
||||
"name": "Latent Keyframe Group",
|
||||
"displayName": "Latent Keyframe Group",
|
||||
"category": "Adv-ControlNet/keyframes",
|
||||
"description": "Schedule ControlNet strength over a batch of latents."
|
||||
},
|
||||
{
|
||||
"name": "Timestep Keyframe",
|
||||
"displayName": "Timestep Keyframe",
|
||||
"category": "Adv-ControlNet/keyframes",
|
||||
"description": "Set ControlNet strength at a specific timestep."
|
||||
},
|
||||
{
|
||||
"name": "Scaled Soft Mask",
|
||||
"displayName": "Scaled Soft Mask",
|
||||
"category": "Adv-ControlNet/masks",
|
||||
"description": "Apply a soft attention mask to ControlNet conditioning."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "was-node-suite-comfyui",
|
||||
"registryId": "was-node-suite-comfyui",
|
||||
"displayName": "WAS Node Suite",
|
||||
"description": "A broad utility suite covering image adjustments, compositing, text, math, and I/O — the original \"kitchen sink\" pack still relied on by thousands of workflows.",
|
||||
"repoUrl": "https://github.com/WASasquatch/was-node-suite-comfyui",
|
||||
"publisher": {
|
||||
"id": "was",
|
||||
"name": "WAS"
|
||||
},
|
||||
"downloads": 981051,
|
||||
"githubStars": 1777,
|
||||
"latestVersion": "1.0.1",
|
||||
"license": "See repository LICENSE",
|
||||
"lastUpdated": "2024-08-01T05:28:23.655235Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Image Filter Adjustments",
|
||||
"displayName": "Image Filter Adjustments",
|
||||
"category": "WAS Suite/Image/Adjustment",
|
||||
"description": "Adjust brightness, contrast, saturation and more."
|
||||
},
|
||||
{
|
||||
"name": "Image Blending Mode",
|
||||
"displayName": "Image Blending Mode",
|
||||
"category": "WAS Suite/Image/Compositing",
|
||||
"description": "Composite two images with Photoshop-style blend modes."
|
||||
},
|
||||
{
|
||||
"name": "Text String",
|
||||
"displayName": "Text String",
|
||||
"category": "WAS Suite/Text",
|
||||
"description": "A reusable text constant suitable for prompts."
|
||||
},
|
||||
{
|
||||
"name": "Number to Float",
|
||||
"displayName": "Number to Float",
|
||||
"category": "WAS Suite/Number",
|
||||
"description": "Cast integer or string values to a float."
|
||||
},
|
||||
{
|
||||
"name": "Image Save",
|
||||
"displayName": "Image Save",
|
||||
"category": "WAS Suite/IO",
|
||||
"description": "Save an image to disk with rich filename templating."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "comfyui_ipadapter_plus",
|
||||
"registryId": "comfyui_ipadapter_plus",
|
||||
"displayName": "ComfyUI_IPAdapter_plus",
|
||||
"description": "Reference-image conditioning with IPAdapter — style transfer, Face ID, and multi-image embeddings. The most-installed conditioning pack on the registry, used in countless portrait, product, and animation workflows.",
|
||||
"repoUrl": "https://github.com/cubiq/ComfyUI_IPAdapter_plus",
|
||||
"publisher": {
|
||||
"id": "matteo",
|
||||
"name": "Matteo"
|
||||
},
|
||||
"downloads": 1208394,
|
||||
"githubStars": 5938,
|
||||
"latestVersion": "2.0.0",
|
||||
"license": "GPL-3.0 license",
|
||||
"lastUpdated": "2024-06-05T06:57:13.485481Z",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "IPAdapterUnifiedLoader",
|
||||
"displayName": "IPAdapterUnifiedLoader",
|
||||
"category": "ipadapter",
|
||||
"description": "Load IPAdapter, image encoder and CLIP vision in one node."
|
||||
},
|
||||
{
|
||||
"name": "IPAdapterFaceID",
|
||||
"displayName": "IPAdapterFaceID",
|
||||
"category": "ipadapter/faceid",
|
||||
"description": "Apply Face ID embeddings for high-fidelity portrait reference."
|
||||
},
|
||||
{
|
||||
"name": "IPAdapterStyleComposition",
|
||||
"displayName": "IPAdapterStyleComposition",
|
||||
"category": "ipadapter",
|
||||
"description": "Reference an image for style without copying its content."
|
||||
},
|
||||
{
|
||||
"name": "IPAdapterAdvanced",
|
||||
"displayName": "IPAdapterAdvanced",
|
||||
"category": "ipadapter",
|
||||
"description": "Full-control IPAdapter with masking, weights, and noise injection."
|
||||
},
|
||||
{
|
||||
"name": "IPAdapterEncoder",
|
||||
"displayName": "IPAdapterEncoder",
|
||||
"category": "ipadapter/embeds",
|
||||
"description": "Encode reference images into IPAdapter embeddings for reuse."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
export interface PackNode {
|
||||
name: string
|
||||
displayName: string
|
||||
category: string
|
||||
description?: string
|
||||
deprecated?: boolean
|
||||
experimental?: boolean
|
||||
}
|
||||
|
||||
export interface Pack {
|
||||
id: string
|
||||
registryId?: string
|
||||
displayName: string
|
||||
description?: string
|
||||
bannerUrl?: string
|
||||
iconUrl?: string
|
||||
repoUrl?: string
|
||||
publisher?: {
|
||||
id: string
|
||||
name?: string
|
||||
}
|
||||
downloads?: number
|
||||
githubStars?: number
|
||||
latestVersion?: string
|
||||
license?: string
|
||||
lastUpdated?: string
|
||||
supportedOs?: string[]
|
||||
supportedAccelerators?: string[]
|
||||
nodes: PackNode[]
|
||||
}
|
||||
|
||||
export interface NodesSnapshot {
|
||||
fetchedAt: string
|
||||
packs: Pack[]
|
||||
}
|
||||
|
||||
export function isNodesSnapshot(value: unknown): value is NodesSnapshot {
|
||||
if (value === null || typeof value !== 'object') return false
|
||||
const candidate = value as { fetchedAt?: unknown; packs?: unknown }
|
||||
if (typeof candidate.fetchedAt !== 'string') return false
|
||||
if (!Array.isArray(candidate.packs)) return false
|
||||
|
||||
return candidate.packs.every((pack) => {
|
||||
if (pack === null || typeof pack !== 'object') return false
|
||||
const p = pack as { id?: unknown; displayName?: unknown; nodes?: unknown }
|
||||
return (
|
||||
typeof p.id === 'string' &&
|
||||
typeof p.displayName === 'string' &&
|
||||
Array.isArray(p.nodes)
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -3,7 +3,7 @@ export interface Role {
|
||||
title: string
|
||||
department: string
|
||||
location: string
|
||||
jobUrl: string
|
||||
applyUrl: string
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
|
||||
@@ -735,142 +735,6 @@ const translations = {
|
||||
'zh-CN': '免费试用 COMFY CLOUD'
|
||||
},
|
||||
|
||||
'cloudNodes.hero.label': {
|
||||
en: 'CLOUD NODES',
|
||||
'zh-CN': '云端节点目录'
|
||||
},
|
||||
'cloudNodes.hero.heading': {
|
||||
en: 'Run your favorite ComfyUI custom nodes on the cloud',
|
||||
'zh-CN': '在云端运行你喜爱的 ComfyUI 自定义节点'
|
||||
},
|
||||
'cloudNodes.hero.body': {
|
||||
en: 'Spin up workflows with hundreds of community-built nodes — detailers, ControlNet preprocessors, animation tools, and quality-of-life utilities — preinstalled on Comfy Cloud and ready to run on managed GPUs.',
|
||||
'zh-CN':
|
||||
'在 Comfy Cloud 托管 GPU 上即开即用,预装数百个社区节点——细节修复、ControlNet 预处理、动画工具与日常便利组件,应有尽有。'
|
||||
},
|
||||
'cloudNodes.section.heading': {
|
||||
en: 'Find a custom-node pack',
|
||||
'zh-CN': '查找自定义节点包'
|
||||
},
|
||||
'cloudNodes.search.placeholder': {
|
||||
en: 'Search packs or nodes',
|
||||
'zh-CN': '搜索节点包或节点名称'
|
||||
},
|
||||
'cloudNodes.sort.downloads': {
|
||||
en: 'Most installed',
|
||||
'zh-CN': '按安装量'
|
||||
},
|
||||
'cloudNodes.sort.mostNodes': {
|
||||
en: 'Most nodes',
|
||||
'zh-CN': '按节点数量'
|
||||
},
|
||||
'cloudNodes.sort.az': {
|
||||
en: 'A → Z',
|
||||
'zh-CN': '按名称 A → Z'
|
||||
},
|
||||
'cloudNodes.sort.recentlyUpdated': {
|
||||
en: 'Recently updated',
|
||||
'zh-CN': '最近更新'
|
||||
},
|
||||
'cloudNodes.search.label': {
|
||||
en: 'Search custom-node packs',
|
||||
'zh-CN': '搜索自定义节点包'
|
||||
},
|
||||
'cloudNodes.sort.label': {
|
||||
en: 'Sort packs',
|
||||
'zh-CN': '排序节点包'
|
||||
},
|
||||
'cloudNodes.list.ariaLabel': {
|
||||
en: 'Custom-node packs supported on Comfy Cloud',
|
||||
'zh-CN': 'Comfy Cloud 支持的自定义节点包'
|
||||
},
|
||||
'cloudNodes.meta.title': {
|
||||
en: 'Custom-node packs on Comfy Cloud — supported by default',
|
||||
'zh-CN': 'Comfy Cloud 自定义节点包合集——开箱即用'
|
||||
},
|
||||
'cloudNodes.meta.description': {
|
||||
en: 'Browse hundreds of ComfyUI custom-node packs preinstalled on Comfy Cloud. Detailers, ControlNet preprocessors, animation tools, samplers, and more — search by pack or by node name.',
|
||||
'zh-CN':
|
||||
'浏览 Comfy Cloud 预装的数百个 ComfyUI 自定义节点包:细节修复、ControlNet 预处理、动画工具、采样器等——按节点包或节点名搜索。'
|
||||
},
|
||||
'cloudNodes.detail.metaTitle': {
|
||||
en: '{pack} on Comfy Cloud',
|
||||
'zh-CN': '{pack}(Comfy Cloud)'
|
||||
},
|
||||
'cloudNodes.detail.metaDescription': {
|
||||
en: '{pack} is preinstalled on Comfy Cloud — {nodeCount} nodes ready to run on managed GPUs. {description}',
|
||||
'zh-CN':
|
||||
'{pack} 已预装于 Comfy Cloud——{nodeCount} 个节点可在托管 GPU 上即时运行。{description}'
|
||||
},
|
||||
'cloudNodes.empty.heading': {
|
||||
en: 'No matching packs',
|
||||
'zh-CN': '未找到匹配的节点包'
|
||||
},
|
||||
'cloudNodes.empty.body': {
|
||||
en: 'Try a different search term or clear your filters.',
|
||||
'zh-CN': '试试其他关键词,或清空筛选条件。'
|
||||
},
|
||||
'cloudNodes.card.nodeCountOne': {
|
||||
en: '{count} node',
|
||||
'zh-CN': '{count} 个节点'
|
||||
},
|
||||
'cloudNodes.card.nodeCountOther': {
|
||||
en: '{count} nodes',
|
||||
'zh-CN': '{count} 个节点'
|
||||
},
|
||||
'cloudNodes.card.viewRepo': {
|
||||
en: 'View repository',
|
||||
'zh-CN': '查看仓库'
|
||||
},
|
||||
'cloudNodes.card.unavailableDescription': {
|
||||
en: 'Description unavailable.',
|
||||
'zh-CN': '暂无描述信息。'
|
||||
},
|
||||
'cloudNodes.card.nodesHeading': {
|
||||
en: 'Included nodes',
|
||||
'zh-CN': '包含节点'
|
||||
},
|
||||
'cloudNodes.detail.back': {
|
||||
en: 'Back to all packs',
|
||||
'zh-CN': '返回所有节点包'
|
||||
},
|
||||
'cloudNodes.detail.publisher': {
|
||||
en: 'Publisher',
|
||||
'zh-CN': '发布者'
|
||||
},
|
||||
'cloudNodes.detail.downloads': {
|
||||
en: 'Downloads',
|
||||
'zh-CN': '下载量'
|
||||
},
|
||||
'cloudNodes.detail.stars': {
|
||||
en: 'GitHub stars',
|
||||
'zh-CN': 'GitHub 星标'
|
||||
},
|
||||
'cloudNodes.detail.latestVersion': {
|
||||
en: 'Latest version',
|
||||
'zh-CN': '最新版本'
|
||||
},
|
||||
'cloudNodes.detail.license': {
|
||||
en: 'License',
|
||||
'zh-CN': '许可证'
|
||||
},
|
||||
'cloudNodes.detail.lastUpdated': {
|
||||
en: 'Last updated',
|
||||
'zh-CN': '最后更新'
|
||||
},
|
||||
'cloudNodes.detail.deprecated': {
|
||||
en: 'Deprecated',
|
||||
'zh-CN': '已弃用'
|
||||
},
|
||||
'cloudNodes.detail.experimental': {
|
||||
en: 'Experimental',
|
||||
'zh-CN': '实验性'
|
||||
},
|
||||
'cloudNodes.detail.nodesHeading': {
|
||||
en: 'Nodes in this pack',
|
||||
'zh-CN': '此节点包中的节点'
|
||||
},
|
||||
|
||||
// Cloud – ReasonSection
|
||||
'cloud.reason.heading': {
|
||||
en: 'Why\nprofessionals\nchoose ',
|
||||
@@ -1255,10 +1119,6 @@ const translations = {
|
||||
en: 'Import your own LoRAs',
|
||||
'zh-CN': '导入你自己的 LoRA'
|
||||
},
|
||||
'pricing.plan.creator.feature2': {
|
||||
en: '3 concurrent API jobs',
|
||||
'zh-CN': '3 个并发 API 任务'
|
||||
},
|
||||
|
||||
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
|
||||
'pricing.plan.pro.summary': {
|
||||
@@ -1283,10 +1143,6 @@ const translations = {
|
||||
en: 'Longer workflow runtime (up to 1 hour)',
|
||||
'zh-CN': '更长工作流运行时长(最长 1 小时)'
|
||||
},
|
||||
'pricing.plan.pro.feature2': {
|
||||
en: '5 concurrent API jobs',
|
||||
'zh-CN': '5 个并发 API 任务'
|
||||
},
|
||||
|
||||
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
|
||||
'pricing.enterprise.heading': {
|
||||
@@ -1743,7 +1599,7 @@ const translations = {
|
||||
},
|
||||
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
|
||||
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
|
||||
'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
|
||||
'nav.blogs': { en: 'Blogs', 'zh-CN': '博客' },
|
||||
'nav.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
|
||||
'nav.discord': { en: 'Discord', 'zh-CN': 'Discord' },
|
||||
'nav.docs': { en: 'Docs', 'zh-CN': '文档' },
|
||||
@@ -1773,7 +1629,6 @@ const translations = {
|
||||
'footer.support': { en: 'Support', 'zh-CN': '支持' },
|
||||
'footer.sales': { en: 'Sales', 'zh-CN': '销售' },
|
||||
'footer.press': { en: 'Press', 'zh-CN': '媒体' },
|
||||
'footer.cloudStatus': { en: 'Cloud Status', 'zh-CN': '云端状态' },
|
||||
'footer.blog': { en: 'Blog', 'zh-CN': '博客' },
|
||||
'footer.location': {
|
||||
en: 'San Francisco, USA',
|
||||
@@ -3643,6 +3498,18 @@ const translations = {
|
||||
en: 'Dale Carman | Co-founder @ Groove Jones',
|
||||
'zh-CN': 'Dale Carman | Groove Jones 联合创始人'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-10.block.2.label': {
|
||||
en: 'GROOVE JONES CONTRIBUTORS',
|
||||
'zh-CN': 'GROOVE JONES 贡献者'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-10.block.2.name': {
|
||||
en: 'TBD',
|
||||
'zh-CN': '待补充'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-10.block.2.role': {
|
||||
en: 'TBD',
|
||||
'zh-CN': '待补充'
|
||||
},
|
||||
|
||||
// Contact – FormSection
|
||||
'contact.form.badge': {
|
||||
@@ -3675,94 +3542,6 @@ const translations = {
|
||||
'zh-CN': '我们会为您处理请求。'
|
||||
},
|
||||
|
||||
'demos.category.templates': { en: 'TEMPLATES', 'zh-CN': '模板' },
|
||||
'demos.category.gettingStarted': { en: 'GETTING STARTED', 'zh-CN': '入门' },
|
||||
|
||||
'demos.image-to-video.title': {
|
||||
en: 'Create a Video from an Image',
|
||||
'zh-CN': '从图片创建视频'
|
||||
},
|
||||
'demos.image-to-video.description': {
|
||||
en: 'Learn how to use the Image to Video workflow template in ComfyUI to generate short video clips from a single image.',
|
||||
'zh-CN':
|
||||
'了解如何使用 ComfyUI 中的图片转视频工作流模板,从单张图片生成短视频。'
|
||||
},
|
||||
'demos.image-to-video.transcript': {
|
||||
en: '<ol><li><strong>Open ComfyUI</strong> — Launch the application and you\'ll see the node-based workflow canvas where all your AI pipelines are built.</li><li><strong>Browse templates</strong> — Click the workflow templates button in the sidebar to browse available starting points.</li><li><strong>Select Image to Video</strong> — Find and select the "Image to Video" template from the list to load it onto your canvas.</li><li><strong>Upload your image</strong> — Click the image upload node and select the source image you want to animate.</li><li><strong>Run the workflow</strong> — Click the "Queue" button to execute the workflow and generate your video output.</li></ol>',
|
||||
'zh-CN':
|
||||
'<ol><li><strong>打开 ComfyUI</strong> — 启动应用程序,您将看到基于节点的工作流画布。</li><li><strong>浏览模板</strong> — 点击侧栏中的工作流模板按钮,浏览可用模板。</li><li><strong>选择图片转视频</strong> — 从列表中找到并选择"图片转视频"模板。</li><li><strong>上传图片</strong> — 点击图片上传节点,选择要动画化的源图片。</li><li><strong>运行工作流</strong> — 点击"排队"按钮执行工作流并生成视频输出。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.workflow-templates.title': {
|
||||
en: 'Browse Workflow Templates',
|
||||
'zh-CN': '浏览工作流模板'
|
||||
},
|
||||
'demos.workflow-templates.description': {
|
||||
en: "Explore ComfyUI's built-in workflow templates to quickly get started with common AI generation tasks.",
|
||||
'zh-CN': '探索 ComfyUI 内置的工作流模板,快速开始常见的 AI 生成任务。'
|
||||
},
|
||||
'demos.workflow-templates.transcript': {
|
||||
en: '<ol><li><strong>Open the template browser</strong> — Click the templates icon in the ComfyUI sidebar to open the template library.</li><li><strong>Browse categories</strong> — Templates are organized by task: image generation, video, upscaling, and more.</li><li><strong>Preview a template</strong> — Hover over any template to see a preview of its workflow and expected output.</li><li><strong>Load and customize</strong> — Click to load a template, then modify parameters to fit your needs.</li></ol>',
|
||||
'zh-CN':
|
||||
'<ol><li><strong>打开模板浏览器</strong> — 点击 ComfyUI 侧栏中的模板图标。</li><li><strong>浏览分类</strong> — 模板按任务分类:图像生成、视频、放大等。</li><li><strong>预览模板</strong> — 将鼠标悬停在模板上查看预览。</li><li><strong>加载并自定义</strong> — 点击加载模板,然后修改参数。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.community-workflows.title': {
|
||||
en: 'Explore and Use a Community Workflow from the Hub',
|
||||
'zh-CN': '探索并使用社区工作流'
|
||||
},
|
||||
'demos.community-workflows.description': {
|
||||
en: 'Discover how to find and get started with popular community workflows for generative AI projects.',
|
||||
'zh-CN': '了解如何查找并使用流行的社区工作流来构建生成式 AI 项目。'
|
||||
},
|
||||
'demos.community-workflows.transcript': {
|
||||
en: '<ol><li><strong>Open the Workflow Hub</strong> — From the ComfyUI sidebar, navigate to the community Workflow Hub to browse curated and trending workflows shared by the community.</li><li><strong>Browse popular workflows</strong> — Explore featured projects sorted by popularity, recency, and category to find one that matches your goal.</li><li><strong>Preview a workflow</strong> — Click a workflow card to see example outputs, required models, and a description of what it produces.</li><li><strong>Open in ComfyUI</strong> — Use the "Get Started" action to load the selected community workflow directly onto your canvas.</li><li><strong>Run and customize</strong> — Queue the workflow to generate your first result, then tweak prompts, models, and parameters to make it your own.</li></ol>',
|
||||
'zh-CN':
|
||||
'<ol><li><strong>打开工作流中心</strong> — 在 ComfyUI 侧栏中,进入社区工作流中心,浏览社区分享的精选和热门工作流。</li><li><strong>浏览热门工作流</strong> — 按热度、时间和分类浏览精选项目,找到符合需求的工作流。</li><li><strong>预览工作流</strong> — 点击工作流卡片,查看示例输出、所需模型和功能描述。</li><li><strong>在 ComfyUI 中打开</strong> — 使用"开始使用"按钮,将选中的社区工作流直接加载到画布。</li><li><strong>运行并自定义</strong> — 排队执行工作流以生成首个结果,然后调整提示词、模型和参数。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.nav.nextDemo': { en: "What's Next", 'zh-CN': '下一个演示' },
|
||||
'demos.nav.viewDemo': { en: 'View Demo', 'zh-CN': '查看演示' },
|
||||
'demos.nav.allDemos': { en: 'All Demos', 'zh-CN': '所有演示' },
|
||||
'demos.transcript.label': { en: 'Demo transcript', 'zh-CN': '演示文字记录' },
|
||||
'demos.transcript.note': {
|
||||
en: '(for accessibility & search)',
|
||||
'zh-CN': '(无障碍和搜索)'
|
||||
},
|
||||
'demos.loading': {
|
||||
en: 'Loading interactive demo…',
|
||||
'zh-CN': '正在加载互动演示…'
|
||||
},
|
||||
'demos.noscript': {
|
||||
en: 'This interactive demo requires JavaScript.',
|
||||
'zh-CN': '此互动演示需要 JavaScript。'
|
||||
},
|
||||
'demos.noscript.link': {
|
||||
en: 'View on Arcade →',
|
||||
'zh-CN': '在 Arcade 上查看 →'
|
||||
},
|
||||
'demos.duration.2min': { en: '~2 min', 'zh-CN': '~2 分钟' },
|
||||
'demos.difficulty.beginner': { en: 'Beginner', 'zh-CN': '入门' },
|
||||
'demos.difficulty.intermediate': {
|
||||
en: 'Intermediate',
|
||||
'zh-CN': '中级'
|
||||
},
|
||||
'demos.difficulty.advanced': { en: 'Advanced', 'zh-CN': '高级' },
|
||||
'demos.embed.label': {
|
||||
en: 'Interactive demo',
|
||||
'zh-CN': '互动演示'
|
||||
},
|
||||
'demos.comingSoon.title': {
|
||||
en: 'Coming Soon',
|
||||
'zh-CN': '即将推出'
|
||||
},
|
||||
'demos.comingSoon.body': {
|
||||
en: 'This page is being redesigned. Check back soon.',
|
||||
'zh-CN': '此页面正在重新设计中,请稍后再来。'
|
||||
},
|
||||
'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
|
||||
'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
|
||||
|
||||
'customers.story.whatsNext': {
|
||||
en: "What's next?",
|
||||
'zh-CN': '接下来看什么?'
|
||||
@@ -3813,103 +3592,6 @@ const translations = {
|
||||
'customers.feedback.role3': {
|
||||
en: 'Head of AI at Creative Studios',
|
||||
'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',
|
||||
'zh-CN': '支付'
|
||||
},
|
||||
'payment.success.title': {
|
||||
en: 'Payment successful',
|
||||
'zh-CN': '支付成功'
|
||||
},
|
||||
'payment.success.subtitle': {
|
||||
en: "Thanks for your purchase. Your account has been credited and you're ready to keep building.",
|
||||
'zh-CN': '感谢您的购买。您的账户已充值完成,可以继续创作了。'
|
||||
},
|
||||
'payment.success.primaryCta': {
|
||||
en: 'CONTINUE TO COMFY CLOUD',
|
||||
'zh-CN': '前往 COMFY CLOUD'
|
||||
},
|
||||
'payment.success.secondaryCta': {
|
||||
en: 'VIEW USAGE & PAYMENTS',
|
||||
'zh-CN': '查看用量与支付'
|
||||
},
|
||||
'payment.failed.label': {
|
||||
en: 'PAYMENT',
|
||||
'zh-CN': '支付'
|
||||
},
|
||||
'payment.failed.title': {
|
||||
en: 'Unable to complete payment',
|
||||
'zh-CN': '无法完成支付'
|
||||
},
|
||||
'payment.failed.subtitle': {
|
||||
en: "Your payment didn't go through and you have not been charged. Reach out to support or read the subscription docs if you need help.",
|
||||
'zh-CN':
|
||||
'您的支付未能完成,未发生扣款。如需帮助,请联系支持或查阅订阅文档。'
|
||||
},
|
||||
'payment.failed.primaryCta': {
|
||||
en: 'CONTACT SUPPORT',
|
||||
'zh-CN': '联系支持'
|
||||
},
|
||||
'payment.failed.secondaryCta': {
|
||||
en: 'READ SUBSCRIPTION DOCS',
|
||||
'zh-CN': '查看订阅文档'
|
||||
}
|
||||
} as const satisfies Record<string, Record<Locale, string>>
|
||||
|
||||
|
||||
@@ -5,13 +5,11 @@ 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 {
|
||||
title: string
|
||||
description?: string
|
||||
keywords?: string[]
|
||||
ogImage?: string
|
||||
noindex?: boolean
|
||||
}
|
||||
@@ -19,13 +17,10 @@ interface Props {
|
||||
const {
|
||||
title,
|
||||
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
|
||||
keywords,
|
||||
ogImage = 'https://media.comfy.org/website/comfy.webp',
|
||||
noindex = false,
|
||||
} = Astro.props
|
||||
|
||||
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
|
||||
|
||||
const siteBase = Astro.site ?? 'https://comfy.org'
|
||||
const canonicalURL = new URL(Astro.url.pathname, siteBase)
|
||||
const ogImageURL = new URL(ogImage, siteBase)
|
||||
@@ -67,7 +62,6 @@ const websiteJsonLd = {
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content={description} />
|
||||
{keywordsContent && <meta name="keywords" content={keywordsContent} />}
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<title>{title}</title>
|
||||
|
||||
@@ -95,11 +89,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 && (
|
||||
@@ -118,7 +109,6 @@ const websiteJsonLd = {
|
||||
)}
|
||||
|
||||
<ClientRouter />
|
||||
<slot name="head" />
|
||||
</head>
|
||||
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
|
||||
{gtmEnabled && (
|
||||
|
||||
@@ -7,14 +7,9 @@ import AudienceSection from '../../components/product/cloud/AudienceSection.vue'
|
||||
import PricingSection from '../../components/product/cloud/PricingSection.vue'
|
||||
import ProductCardsSection from '../../components/product/cloud/ProductCardsSection.vue'
|
||||
import FAQSection from '../../components/product/cloud/FAQSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Comfy Cloud — AI in the Cloud"
|
||||
description={t('cloud.hero.subtitle', 'en')}
|
||||
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'comfy cloud', 'comfy ui application', 'comfyui browser', 'cloud comfyui', 'managed comfyui']}
|
||||
>
|
||||
<BaseLayout title="Comfy Cloud — AI in the Cloud">
|
||||
<HeroSection />
|
||||
<ReasonSection />
|
||||
<AIModelsSection />
|
||||
|
||||
@@ -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.
|
||||