diff --git a/.claude/skills/add-model-page/SKILL.md b/.claude/skills/add-model-page/SKILL.md new file mode 100644 index 0000000000..0b7fe4b81e --- /dev/null +++ b/.claude/skills/add-model-page/SKILL.md @@ -0,0 +1,173 @@ +--- +name: add-model-page +description: 'add, update, or remove a model page entry on the comfy org website. creates a PR to Comfy-Org/ComfyUI_frontend apps/website folder with the change and posts a Vercel preview link back to Slack.' +--- + +# add-model-page + +add, update, or remove model pages in the ComfyUI website. + +## Trigger phrases + +- `Add a model page for ` +- `Update the model page for ` +- `Remove from model pages` + +## Phase 1 — Parse the request + +Extract: + +- **action**: `add` | `update` | `remove` +- **model-name**: raw string (e.g. `flux1-schnell`, `flux1_dev.safetensors`) + +Normalize to a slug: lowercase, replace `_` and `.` with `-`, strip file extensions. +Example: `flux1_dev.safetensors` → `flux1-dev` + +## Architecture overview + +Models come from two sources merged at build time: + +| File | Purpose | +| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `apps/website/src/config/generated-models.json` | Auto-generated from workflow_templates (slug, name, directory, huggingFaceUrl, workflowCount, displayName, thumbnailUrl, docsUrl) | +| `apps/website/src/config/model-metadata.ts` | Hand-curated overrides (docsUrl, blogUrl, featured) — only add entries that need overrides | +| `apps/website/src/config/models.ts` | Merges the two above; exports typed `Model[]` | + +To regenerate the JSON from workflow_templates: + +```bash +pnpm tsx apps/website/scripts/generate-models.ts +``` + +This writes `apps/website/src/config/generated-models.json` directly. +Thumbnails are populated from local `.webp` files in `workflow_templates/templates/` — no network access needed. + +--- + +## Phase 2 — Gather model data (ADD / UPDATE) + +Run the generator to get fresh data, then find the model: + +```bash +pnpm tsx apps/website/scripts/generate-models.ts +jq '.[] | select(.slug | contains("MODEL_SLUG"))' \ + apps/website/src/config/generated-models.json +``` + +The JSON fields are: + +- `slug` — URL slug +- `name` — exact filename or display name for partner nodes +- `huggingFaceUrl` — download URL (empty for partner nodes) +- `directory` — `diffusion_models` | `loras` | … | `partner_nodes` +- `workflowCount` — integer +- `displayName` — human-readable name + +If no match and it is a known API/partner model, add it to `API_PROVIDER_MAP` in +`generate-models.ts` and re-run. Otherwise tell the user. + +--- + +## Phase 3 — Check for existing entry + +```bash +jq --arg slug "${SLUG}" '.[] | select(.slug == $slug)' \ + apps/website/src/config/generated-models.json +``` + +- Match found + action is `add` → switch to UPDATE flow automatically +- No match + action is `update` → stop and tell the user + +--- + +## Phase 4A — ADD: new partner/API model not in workflow_templates + +For partner nodes (no local file), add an entry to `API_PROVIDER_MAP` in +`apps/website/scripts/generate-models.ts`: + +```typescript +mymodel: { name: 'My Model', slug: 'my-model' }, +``` + +Then re-run `pnpm tsx apps/website/scripts/generate-models.ts` — it will appear +in `generated-models.json` automatically. + +If you also want a `docsUrl`, `blogUrl`, or a link to the hub model page, add an entry to `model-metadata.ts`: + +```typescript +'my-model': { + docsUrl: 'https://docs.comfy.org/tutorials/...', + blogUrl: 'https://blog.comfy.org/...', + hubSlug: 'my-model', // slug at comfy.org/workflows/model/{hubSlug} — only set if the page exists (returns 200) + featured: true +} +``` + +No changes to `models.ts` or `translations.ts` are needed. + +--- + +## Phase 4B — UPDATE: edit existing entry + +Only `model-metadata.ts` needs editing for most updates (docsUrl, blogUrl, +featured). For `displayName` or `directory` changes, edit the entry directly in +`generated-models.json` (until the next generator run would overwrite it — then +fix the source in `generate-models.ts`). + +--- + +## Phase 4C — REMOVE: delete entry + +Remove the entry from `generated-models.json` (or mark it with `canonicalSlug` +pointing to the replacement). No translation file changes needed. + +--- + +## Phase 5 — Verify TypeScript + +```bash +pnpm typecheck 2>&1 | grep -E "error|warning" | head -20 +``` + +Fix any type errors before proceeding. Common issues: + +- `ModelDirectory` type not matching a new `directory` value — add it to the union +- JSON import shape mismatch — `generated-models.json` must match `OutputModel` + +--- + +## Phase 6 — Create PR + +```bash +BRANCH="add-model-page-MODEL-SLUG" # or update- / remove- +git checkout -b $BRANCH +git add apps/website/src/config/generated-models.json \ + apps/website/scripts/generate-models.ts \ + apps/website/src/config/model-metadata.ts +git commit -m "feat(models): add model page for MODEL-SLUG" +git push -u origin $BRANCH +gh pr create \ + --title "Add model page: MODEL-SLUG" \ + --body "$(cat <<'EOF' +Adds a new model page entry for MODEL-SLUG. + +## Changes +- `generated-models.json`: regenerated with new entry (workflowCount N, directory DIRECTORY) +- `model-metadata.ts`: editorial overrides (docsUrl, featured) if needed +EOF +)" +``` + +For UPDATE use branch `update-model-page-MODEL-SLUG`. +For REMOVE use `remove-model-page-MODEL-SLUG`. + +--- + +## Error states + +| Situation | Response | +| ------------------------------- | ---------------------------------------------------------------- | +| Model not in workflow templates | Ask user to verify spelling or add it manually as a partner node | +| Slug already exists (add) | Switch to update flow automatically | +| Slug not found (update/remove) | Stop and ask user to confirm | +| Typecheck fails | Fix the error before pushing | diff --git a/.claude/skills/backport-management/SKILL.md b/.claude/skills/backport-management/SKILL.md index 138eb98a84..de77272667 100644 --- a/.claude/skills/backport-management/SKILL.md +++ b/.claude/skills/backport-management/SKILL.md @@ -9,13 +9,18 @@ Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release br ## Quick Start -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`) +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`) ## System Context @@ -107,6 +112,35 @@ 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 `. 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.** @@ -147,6 +181,26 @@ 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: diff --git a/.claude/skills/backport-management/reference/analysis.md b/.claude/skills/backport-management/reference/analysis.md index dd6a41b394..705448b51f 100644 --- a/.claude/skills/backport-management/reference/analysis.md +++ b/.claude/skills/backport-management/reference/analysis.md @@ -39,6 +39,89 @@ 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. diff --git a/.claude/skills/backport-management/reference/discovery.md b/.claude/skills/backport-management/reference/discovery.md index ac826ce764..ab28e0815f 100644 --- a/.claude/skills/backport-management/reference/discovery.md +++ b/.claude/skills/backport-management/reference/discovery.md @@ -1,5 +1,11 @@ # 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): @@ -36,7 +42,43 @@ 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 | Category | Flagged by Bot? | Decision | +| PR# | Title | Source (bot/gap/both) | Path bucket | Tier | Decision | diff --git a/.claude/skills/backport-management/reference/execution.md b/.claude/skills/backport-management/reference/execution.md index 2bc6f9f78a..0195091e71 100644 --- a/.claude/skills/backport-management/reference/execution.md +++ b/.claude/skills/backport-management/reference/execution.md @@ -6,6 +6,43 @@ 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 @@ -88,6 +125,39 @@ 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)" \ @@ -243,6 +313,9 @@ 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 && 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 @@ -268,3 +341,40 @@ 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 ` ✅ (N/N passing) +- `pnpm exec eslint ` ✅ (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?" diff --git a/.claude/skills/bug-dump-ingest/SKILL.md b/.claude/skills/bug-dump-ingest/SKILL.md deleted file mode 100644 index 413737835f..0000000000 --- a/.claude/skills/bug-dump-ingest/SKILL.md +++ /dev/null @@ -1,695 +0,0 @@ ---- -name: bug-dump-ingest -description: 'Syncs the #bug-dump Slack channel into Linear as the system of record AND auto-fixes verified real bugs via red-green-fix. Every Linear operation (create, search, link, label) is performed by posting an @Linear mention in the bug-dump thread — no Linear MCP, no API key. Flow: fetch → mandatory dedupe gate (@Linear search + gh PR search) → false-defect verification → post @Linear create in thread (tool call) → parse bot card for FE-NNNN + URL → post :white_check_mark: confirmation reply → if candidate is a verified real bug with no dedupe hit and no open PR, invoke red-green-fix automatically to produce failing test + fix + PR. Respects team emoji scheme (:white_check_mark: ticket created, :pr-open: PR open, :question: needs context, :repeat: duplicate). Use when asked to sync #bug-dump to Linear, triage slack bugs, run a bug-dump sweep, or ingest bug reports. Triggers on: bug-dump, sync bug-dump, ingest bugs, triage slack bugs, bug sweep.' ---- - -# Bug Dump Ingest - -**Primary job: sync `#bug-dump` (Slack: `C0A4XMHANP3`) into Linear as the source of truth, then auto-fix the verified real bugs.** Linear is where status, labels, and follow-up triage happen — this skill gets every bug into Linear with enough context that a downstream agent or human can work from Linear alone. **Every Linear action is performed by mentioning `@Linear` in the bug-dump thread**; there is no Linear MCP and no API key path. When pre-flight verification confirms a candidate is a real bug (not dedupe, not already in a PR, not out of scope), the skill then invokes `red-green-fix` automatically. - -```text -fetch → pre-flight dedupe gate (@Linear search + gh) → verify false defects → present approvals - → POST "@Linear create ..." thread reply via slack_send_message (mandatory tool call) - → poll slack_read_thread → parse Linear bot card for FE-NNNN + URL - → POST :white_check_mark: confirmation thread reply via slack_send_message - → if verification = "real bug" AND no dedupe AND no open PR: - invoke Skill(skill="red-green-fix") → POST :pr-open: thread reply -``` - -### Non-negotiable rules - -1. **Linear actions are Slack tool calls.** The skill MUST drive Linear by calling `mcp__plugin_slack_slack__slack_send_message` with `thread_ts` set and text that mentions `@Linear`. There is no MCP-direct path and no API-key path. Printing `@Linear create ...` into the Claude CLI response is NOT a substitute — the Slack thread reply is what triggers the Linear bot, and its card is the canonical receipt. -2. **Dedupe is a gate, not a suggestion.** No candidate is proposed for creation until `@Linear search` AND `gh pr` search have been run and recorded. A hit short-circuits creation to `L` (link) or `pr-open`. -3. **Auto-fix real bugs.** When the dedupe gate is clean AND false-defect verification is clean AND the candidate isn't on the handoff-exclusion list (see § Handoff conditions), after Linear creation the skill invokes `red-green-fix` via the `Skill` tool — without waiting for an extra human prompt. - -### What the skill cannot do - -The Slack MCP exposes no `reactions.add` tool, so the skill cannot put a `:white_check_mark:` reaction on the parent message. The thread reply with the leading `:white_check_mark:` emoji is the skill's canonical marker; a human can additionally add the parent reaction for channel visibility (see § Parent reaction — optional visibility nudge). Both are respected by Processed Detection. - -## Team emoji scheme - -| Emoji | Meaning | Who adds it | Skill behavior | -| -------------------- | ------------------ | ------------------------------------------------------ | ---------------------------------------------- | -| `:white_check_mark:` | Ticket created | Human on parent (after skill files); also in bot reply | Skip in future sweeps | -| `:pr-open:` | PR open | Human | Skip creation; include PR link in approval row | -| `:question:` | Needs more context | Human | Skip creation; agent may ask for clarification | -| `:repeat:` | Duplicate | Human | Skip creation; link existing Linear issue | - -## Design Priority - -Optimize for **coverage, label quality, and proven fixes** over fix-path cleverness. Linear is the downstream triage surface — once every bug is there with status, labels, and context, agents and humans can work from Linear alone. A Linear ticket with a wrong severity is cheap to fix; a Slack-only bug is invisible to downstream tooling; a "filed but not fixed" real regression wastes a human turn that the skill could have spent on a red-green PR. - -## Quick Start - -1. **Scope** — default window: messages in the last 48h. Override with `--since YYYY-MM-DD` or a Slack permalink list. -2. **Fetch** — `slack_read_channel` for `C0A4XMHANP3`; `slack_read_thread` per message with replies. -3. **Filter** — drop already-processed (see Processed Detection). -4. **Classify** — bug / discussion / meta (see Classification Rules). -5. **Pre-flight dedupe gate (MANDATORY)** — for every bug candidate, run `@Linear search` AND `gh pr` search BEFORE proposing (see § Pre-flight Dedupe Gate). A hit means the candidate goes into the batch as `L` (link) or `pr-open`, not as a new create. -6. **Verify false defects** — per candidate, run quick checks before proposing (see False-Defect Verification). -7. **Extract** — normalize to ticket schema (see Ticket Schema). -8. **Human approval** — batch table, collect Y/N/?/S/L/R per candidate (see Interactive Approval). Default recommendation for clean candidates is `Y` (file + auto-fix). -9. **Post `@Linear create` thread reply — MANDATORY TOOL CALL** — for each approved `Y`/`L` row, call `mcp__plugin_slack_slack__slack_send_message` with `channel_id=C0A4XMHANP3`, `thread_ts=`, and text starting with `@Linear create` (see § Linear Slack Bot Integration). Do NOT print the command into chat as a substitute. -10. **Capture the Linear bot card** — poll `slack_read_thread` up to 3× with ~3s spacing, parse the first Linear-app reply for the `FE-NNNN` identifier and `https://linear.app/...` URL. No URL = not ingested; never fabricate one. -11. **Post `:white_check_mark:` confirmation reply — MANDATORY TOOL CALL** — call `slack_send_message` again with text starting with `:white_check_mark: Filed to Linear: ` so future sweeps can detect the marker via `has::white_check_mark: from:me`. Record both `ts` values in the session log. -12. **Auto-fix (clean candidates only)** — if dedupe gate is clean AND false-defect verification is clean AND the candidate isn't on the Handoff-Exclusion list, immediately invoke the `red-green-fix` skill via the `Skill` tool. See § Fix Workflow for the exact call contract. -13. **Log** — append to session log; update `processed.json`. - -## System Context - -| Item | Value | -| ------------------ | -------------------------------------------------------------------------------------------------------------------------- | -| Source channel | `#bug-dump` (`C0A4XMHANP3`) | -| Destination | Linear `Frontend Engineering` team, via the Linear Slack app (`@Linear`). Team is named in every `@Linear create` message. | -| Default state | `Triage` — every `@Linear create` message includes `Status: Triage` | -| State dir | `~/temp/bug-dump-ingest/` | -| Processed registry | `~/temp/bug-dump-ingest/processed.json` | -| Session log | `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md` | -| Drafts (failure) | `~/temp/bug-dump-ingest/drafts/*.md` — written only when `@Linear` never replies, so the human can retry manually | - -## Label Taxonomy - -Every created Linear issue MUST get the following labels, passed as a comma-separated list in the `Labels:` line of the `@Linear create` message. The Linear Slack app creates missing labels on first use: - -| Label kind | Values | Source | -| ------------ | ------------------------------------------------------------------------------ | ------------------------- | -| `source:` | `source:bug-dump` | Always (marks Slack sync) | -| `area:` | `area:ui`, `area:node-system`, `area:workflow`, `area:cloud`, `area:templates` | Area Heuristics | -| `env:` | `env:cloud-prod`, `env:cloud-dev`, `env:local`, `env:electron` | Env Heuristics | -| `severity:` | `sev:high`, `sev:medium`, `sev:low` | Severity Heuristics | -| `reporter:` | `reporter:` (kebab-case) | From message author | -| Status flags | `needs-repro`, `needs-backend`, `regression`, `pr-open` | When applicable | - -Label rules: - -- Always include `source:bug-dump`, exactly one `area:`, at least one `env:` (or `env:unknown`), exactly one `severity:`, exactly one `reporter:`. -- `needs-repro` — set when repro steps were ambiguous; signals "human should confirm before fix". -- `needs-backend` — set when fix is clearly in ComfyUI backend, not this frontend repo. -- `regression` — set when the bug mentions a version/upgrade correlation. -- `pr-open` — set instead of creating a fresh ticket when a fix PR already exists; the Linear issue becomes a tracker. - -Labels are the primary affordance for downstream triage — invest in getting them right, not just in the title. - -## Processed Detection - -A top-level message is considered already-handled (skip creation) if ANY of: - -- Its timestamp appears in `processed.json`. -- It carries a `:white_check_mark:` reaction on the parent — ticket already created. -- It carries a `:pr-open:` reaction — fix PR is open; skill records the PR link in the session log rather than creating a fresh Linear issue. -- It carries a `:repeat:` reaction — duplicate; skill attempts to find the original Linear issue and link it in the session log. -- It carries a `:question:` reaction — needs more context; skill skips creation and records for follow-up. -- Its thread contains a reply with a `https://linear.app/` URL (fetch via `slack_read_thread`). -- Its thread contains a reply starting with `:white_check_mark:` from the skill's bot user. -- It is a system/meta message (`has joined the channel`, bot-only message). -- Its thread already contains resolution confirmation (`"solved"`, `"resolved"`, `:done:` reaction from the reporter) AND has no fix PR referenced — treat as "resolved without ticket, skip". - -Never re-ingest a message already marked in any of the above ways. - -Filter query for Slack search-based sweeps: - -```text -in:<#C0A4XMHANP3> -has::white_check_mark: -has::pr-open: -has::repeat: -has::question: after:YYYY-MM-DD -``` - -## False-Defect Verification - -Before a candidate hits the approval batch, run cheap checks to demote obvious non-bugs. Goal: keep the approval table high-signal. This is not a full repro — just fast heuristics that catch the top false-positive classes. - -| Check | Command / Signal | Demote-to | -| ---------------------------------------- | ---------------------------------------------------------------- | ---------- | -| Reporter self-resolved in same msg | "no action needed", "solved", "nvm", "fixed it" | `resolved` | -| Reporter self-resolved in thread | `slack_read_thread` → reporter's last reply contains "solved" | `resolved` | -| Fix PR merged on main | `gh search prs "in:title " --state merged --limit 3` | `fixed` | -| Fix PR open (already-filed) | `gh search prs "" --state open --limit 3` | `pr-open` | -| Linear issue exists (open) | Linear `searchIssues` on title keywords → any open match | `dedupe` | -| Behavior is documented / intended | grep `docs/` and `src/locales/en/*.json` for the feature | `expected` | -| Not reproducible — feature doesn't exist | grep `src/` for mentioned component/feature → 0 hits | `stale` | -| Env drift only (local setup issue) | Thread contains "my machine", "my setup", "proxy" without others | `env` | - -For each demoted candidate, record the demotion reason in the approval table as `Verify: ` so the human can override if they disagree. Never hard-skip based on verification alone — always show the row with the demotion. - -### Recommended verify commands - -```bash -# 1. Search recent PRs for the feature in question -gh search prs "" --repo Comfy-Org/ComfyUI_frontend --limit 5 - -# 2. Grep for the feature / component mentioned -rg -l "" src/ apps/ - -# 3. Check if it's a known i18n / documented setting -rg "" src/locales/en/ docs/ -``` - -Keep verification under ~30s per candidate. If it takes longer, propose a ticket and let the human decide — don't let verification become the bottleneck. - -## Classification Rules - -For each unprocessed top-level message, decide: - -| Class | Signal | Action | -| ----------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------- | -| **bug** | Describes unexpected behavior, visual glitch, error, regression, crash. Usually has repro steps or media. | Propose Linear ticket | -| **discussion** | Design question, rollout thoughts, team chatter, PR planning (e.g. "how about we make a PR to do...") | Skip | -| **question** | User asking if something is expected or known | Skip unless answered = bug | -| **meta** | Channel joins, bot messages, cross-posts without content | Skip | -| **already-filed** | Thread shows PR already open OR existing Linear link | Skip, log with existing link | - -When ambiguous, default to **bug** and let the human decide in the approval batch. - -## Ticket Schema - -Normalize each bug to this shape before presenting: - -```json -{ - "slack_ts": "1776639963.837519", - "slack_permalink": "https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776639963837519", - "reporter": "Ali Ranjah (wavey)", - "title": "Unet model dropdown missing selected model", - "description": "Body with repro steps, env, attachments list, thread summary", - "env": ["cloud prod"], - "severity": "low | medium | high", - "area": "ui | node-system | workflow | cloud | templates | unknown", - "attachments": [{ "name": "...", "id": "F...", "type": "image/png" }], - "thread_resolution": "solved | open | none" -} -``` - -Keep descriptions copy-paste friendly: lead with repro bullets, then env, then "See Slack: ". Attach thread summary only if it adds context beyond the top-level message. - -### Severity Heuristics - -- **high** — crash, data loss, blocks a template or core feature, affects paying users broadly (e.g. "job ends in 30m on Pro", "widget values reset"). -- **medium** — visible regression, template error, wrong pricing, broken UX on a common path. -- **low** — cosmetic, single-template edge case, minor tooltip/boundary issue. - -When unsure, mark `medium` and flag for human in the approval batch. - -### Area Heuristics - -- `ui` — visual glitches, palette issues, popover clipping, dropdown styling. -- `node-system` — canvas perf, reroute, node drag, widget rendering, undo. -- `workflow` — template failures, save/load, refresh regressions. -- `cloud` — jobs, pricing, assets, auth, queue. -- `templates` — specific template errors. - -## Pre-flight Dedupe Gate (MANDATORY) - -Before any candidate enters the approval table, run BOTH checks below and record the result in the row's `Dedup` and `PR` columns. This is a hard gate — no candidate may be proposed for creation without a verdict. - -### Check 1 — Open Linear issues (via `@Linear search`) - -Extract 3-5 keyword terms from the proposed title (strip stopwords). Post a search command to the bug-dump thread — use a scratch thread if no parent `ts` is available yet, but prefer the candidate's own parent thread so the search card becomes part of that thread's audit trail: - -```text -mcp__plugin_slack_slack__slack_send_message({ - channel_id: "C0A4XMHANP3", - thread_ts: "", - text: "@Linear search \nTeam: Frontend Engineering\nStatus: open" -}) -``` - -Poll `slack_read_thread` for up to 10s; parse the Linear app's card reply for `FE-NNNN` identifiers and URLs. Run the search twice with different keyword subsets if the first returns zero hits — reworded titles are the top false-negative class. - -If `@Linear search` is not supported by the workspace's Linear app version, fall back to a Slack search for prior `@Linear` card replies in the channel: - -```text -mcp__plugin_slack_slack__slack_search_public({ - query: "in:<#C0A4XMHANP3> from:@Linear " -}) -``` - -This scans past Linear bot replies in the channel — any reply containing a matching `FE-NNNN` URL is a candidate duplicate. Record which dedupe path was used in the session log. - -Treat a hit as a duplicate if any of: - -- Title overlap ≥ 80% (after lowercasing + stopword removal) -- Same reporter + same component reference in description -- Same stack trace or error code - -**Verdict:** set `Dedup: FE-NNNN` and default recommendation to `L` (link, don't create). The human may still override to `Y` to file a separate ticket. - -### Check 2 — Open or merged fix PRs on GitHub - -```bash -# Open PRs matching title keywords -gh pr list --repo Comfy-Org/ComfyUI_frontend --state open \ - --search " " --limit 5 \ - --json number,title,url,createdAt - -# Recent merged fixes (last 30d) — catches "already fixed, waiting to ship" -gh pr list --repo Comfy-Org/ComfyUI_frontend --state merged \ - --search " merged:>=" --limit 5 \ - --json number,title,url,mergedAt -``` - -Treat a hit as a match if the PR title/body mentions the same component or bug phrase and the PR is unmerged or merged within the window covering the reporter's observation. - -**Verdict:** - -- Open PR match → set `PR: #NNNN (open)`, recommendation `pr-open` (file Linear with `pr-open` label linking the PR, skip auto-fix). -- Merged PR match → set `PR: #NNNN (merged)`, recommendation `fixed` (demote in verify, usually skip; human can override if the reporter claims the fix didn't land). - -### Failure handling - -If either check errors (Linear Slack app silent or not in channel, `gh` auth expired), DO NOT proceed to proposal — stop the sweep, report the failure to the user, and let them decide whether to re-run or manually dedupe. A silent skip of dedupe is never acceptable; it's the single biggest source of duplicate tickets. - -Log each dedupe query + top hits in `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md` under a per-candidate `Dedup trace:` block so the human can audit. - -## Interactive Approval - -Present candidates in batches of 5-10. Table format (10 columns): - -```text - # | Slack (author, time) | Proposed title | Env | Sev | Area | Dedup | PR | Verify | Rec -----+------------------------+-----------------------------------------+------------+------+------------+------------+---------------+-------------+----- - 1 | wavey, 04-20 08:06 | Unet dropdown missing selected model | cloud prod | low | ui | - | - | resolved | N - 2 | Denys, 04-18 05:45 | Pro plan jobs end at 30 minutes | cloud prod | high | cloud | - | - | clean | Y - 3 | Terry Jia, 04-18 12:52 | Nodes 2.0 canvas lag on large workflows | - | high | node-system| FE-4521 | - | clean | L - 4 | Pablo, 04-17 08:52 | Multi-asset delete popup shows hashes | cloud prod | low | ui | - | #11402 (open) | clean | pr-open -``` - -Each row MUST show: Slack author + date, proposed title, env tags, severity, area, **dedupe status from the Pre-flight Dedupe Gate**, **open/merged PR hit from the Pre-flight Dedupe Gate**, verify tag (from False-Defect Verification), and agent recommendation. - -### Default recommendation logic - -The skill computes `Rec` deterministically from the gate results: - -- `L` — Dedupe hit on open Linear issue. -- `pr-open` — Open GitHub PR hit. -- `fixed` — Merged PR hit within the reporter's observation window. -- `N` — Verify tag is `resolved`, `expected`, `stale`, or `env` only. -- `?` — Repro incomplete or classification ambiguous. -- `Y` — Everything clean AND candidate is not on the § Handoff-Exclusion list. This is the "file + auto-fix" path. -- `Y (file-only)` — Clean but on the handoff-exclusion list (e.g. touches LGraphNode, needs backend). File Linear, skip auto-fix. - -### Response format - -- `Y` — default path: create Linear ticket, post `:white_check_mark:` thread reply, AND if the candidate is eligible (dedupe clean, verify clean, not on handoff-exclusion list), immediately invoke `red-green-fix` via the `Skill` tool. See § Fix Workflow. -- `S` — **skip auto-fix** for this row: create Linear ticket + thread reply only, do NOT run red-green-fix. Use when the human knows a specific person is already investigating or wants to batch fixes. -- `N` — skip entirely (log reason in session file). -- `?` — mark as needs-context; skill posts a thread reply asking for repro details and prompts the human to add `:question:` to the parent. -- `L` — link to existing Linear issue instead of creating (skill asks which one if the Pre-flight Dedupe Gate didn't return an exact match). -- `R` — duplicate of another bug-dump message; skill links the two and prompts the human for `:repeat:` on the parent. -- `E` — edit proposed title/description before creating (skill shows draft for inline tweaks). -- Bulk responses accepted: `1 N, 2 Y, 3 L FE-4521, 4 pr-open #11402, 5 ?` — any row omitted from the response is treated as its computed `Rec` default. - -Do not post any `@Linear create` messages until all candidates in the batch have a terminal decision. Auto-fix invocations run sequentially AFTER every `@Linear create` has produced a parsed `FE-NNNN`, so every `red-green-fix` call has a `Fixes FE-NNNN` to put in the PR body. - -## Linear Slack Bot Integration (@Linear) - -Every Linear action — create, search, link, label, status change — is performed by posting a message to the candidate's thread in `#bug-dump` that mentions `@Linear`. The Linear Slack app parses the mention and responds with a card in the same thread. There is no Linear MCP path and no `LINEAR_API_KEY` path; see `reference/linear-api.md` § "Why no direct API path" for the rationale. - -### Prerequisites - -- The Comfy Slack workspace already has the Linear Slack app installed (this is how humans add `@Linear` mentions today). -- Channel `C0A4XMHANP3` is connected to the `Frontend Engineering` Linear team. -- No per-machine setup. If a `@Linear` invocation produces no bot reply, the app is not in the channel — surface to the human, do NOT retry silently. - -### Create an issue - -For each approved `Y` candidate, call: - -```text -mcp__plugin_slack_slack__slack_send_message({ - channel_id: "C0A4XMHANP3", - thread_ts: "", - text: "@Linear create\nTeam: Frontend Engineering\nTitle: \nStatus: Triage\nLabels: source:bug-dump, area:<area>, env:<env>, sev:<severity>, reporter:<handle>\n\n<description>\n\nSource: <slack-permalink>" -}) -``` - -Rules: - -- First line MUST be `@Linear create` — this is the command token. -- `Team: Frontend Engineering` is required on every create — without it the bot falls back to the workspace default, which may route to a different team. -- `Status: Triage` pins the initial state (per § System Context). -- `Labels:` — comma-separated, full `source:bug-dump, area:*, env:*, sev:*, reporter:*` set per § Label Taxonomy. Missing labels are auto-created by the Linear Slack app on first use. -- Description body is markdown — see `reference/linear-api.md` § "Description body template" and `reference/schema.md` for per-field extraction. -- Use real newlines (not literal `\n`) when constructing the text. - -After the tool call returns, poll `slack_read_thread` for the Linear app's reply card (up to 3× with ~3s spacing). Parse the card for: - -- An `FE-NNNN` identifier -- A `https://linear.app/<org>/issue/FE-NNNN` URL - -The URL is the ingested receipt. The skill then posts the `:white_check_mark:` confirmation reply (§ Slack Thread Reply). - -### Search (dedupe) - -See § Pre-flight Dedupe Gate § Check 1 for the search command shape and handling of the bot's reply. The search is a tool call in the candidate's thread — not a chat aside. - -### Link an existing issue (`L` response) - -When the human picks `L FE-4521` for a row, do NOT post `@Linear create`. Instead: - -```text -mcp__plugin_slack_slack__slack_send_message({ - channel_id: "C0A4XMHANP3", - thread_ts: "<parent-ts>", - text: "@Linear link FE-4521" -}) -``` - -The bot replies with the linked issue card. Then post the `:white_check_mark:` confirmation reply (adjusted to say `Linked to Linear:` rather than `Filed to Linear:`) so Processed Detection still matches. - -### Label / status updates - -When a later sweep needs to flip a ticket (e.g. a PR opened after initial ingest, so add `pr-open` and link): - -```text -mcp__plugin_slack_slack__slack_send_message({ - channel_id: "C0A4XMHANP3", - thread_ts: "<parent-ts>", - text: "@Linear FE-4521 add-labels pr-open" -}) -``` - -Status changes are rarely driven by this skill directly — Linear auto-moves issues to `In Review` when a PR with `Fixes FE-NNNN` is opened, and the `red-green-fix` skill handles that PR body. - -### Captured fields per create - -Every successful create must produce, via the Linear bot's reply card: - -- `identifier` — e.g. `FE-4710`, used in `Fixes <LIN-ID>` references and session log -- `url` — `https://linear.app/.../issue/FE-4710`, included verbatim in the `:white_check_mark:` reply -- `ts` of the Linear bot's card reply — recorded in session log for audit - -If the card is missing the URL or identifier, fall through to the failure path below — do NOT fabricate either value. - -### Failure path - -If the Linear bot does not reply within the poll window, OR replies with a parse error (`couldn't parse`, `no team matched`, `failed`): - -1. Write a draft markdown file to `~/temp/bug-dump-ingest/drafts/NN-short-slug.md` containing the full `@Linear create` text that was sent plus any partial bot reply. -2. Post a thread reply that is explicit about the failure — do NOT include `:white_check_mark:` or a fake Linear URL: - ```text - :warning: bug-dump-ingest: @Linear did not respond. Drafted at ~/temp/bug-dump-ingest/drafts/<slug>.md — please file manually and reply with the FE-NNNN. - ``` -3. Skip auto-fix for this candidate (no Linear ID = no `Fixes` reference). -4. Log the failure in the session log. - -Never invent a Linear URL. Never post `:white_check_mark: Filed to Linear: ...` without a real URL parsed from a real Linear bot card. - -## Slack Thread Reply (Ingested Marker) — MANDATORY TOOL CALL - -Every approved candidate produces **two** mandatory `slack_send_message` calls in the parent thread: - -1. The `@Linear create` (or `@Linear link`) command — see § Linear Slack Bot Integration. -2. The `:white_check_mark:` confirmation reply described below, posted after a real `FE-NNNN` + URL have been parsed from the Linear bot's card. - -The second reply is what future sweeps grep for via `has::white_check_mark: from:me`. Even though the Linear bot's own card already contains the URL, the `:white_check_mark:` prefix is the canonical Processed Detection marker — without it, a future sweep may re-ingest the same bug. - -The skill is not done with a candidate until BOTH calls have succeeded. If either fails, do not claim the candidate is ingested. - -### Required call shape - -```text -mcp__plugin_slack_slack__slack_send_message({ - channel_id: "C0A4XMHANP3", - thread_ts: "<parent-message-ts>", // dotted form, e.g. "1776714531.990509" - text: ":white_check_mark: Filed to Linear: <LINEAR_URL>\nReporter: <@USER_ID>\nSev: <severity> • Area: <area>" -}) -``` - -Rules: - -- `thread_ts` MUST be the parent message ts — never the channel ts, never omitted. An omitted `thread_ts` posts at channel level, which pollutes `#bug-dump` and breaks Processed Detection. -- The text MUST start with `:white_check_mark:` followed by a space and `Filed to Linear:`. This exact prefix is what future sweeps grep for via `has::white_check_mark: from:me`. -- The Linear URL MUST be present. No URL = not ingested; future sweeps will re-file the same bug. -- Plain text only — no markdown tables, no bold, no code fences. Slack renders the emoji shortcode into a real `:white_check_mark:` only when the message is plain text. -- Capture the returned `ts` and record it in the session log for audit. - -### NEVER-do list (common failure mode) - -- **Do NOT** print `@Linear create ...` or `:white_check_mark: Filed to Linear: <URL>` into the Claude CLI chat response as a substitute for calling `slack_send_message`. The CLI output is not seen by Slack. If you find yourself typing either into a plain assistant message, stop and issue the tool call instead. -- **Do NOT** claim the thread reply was posted until the `slack_send_message` tool call has returned a success with a `ts`. If the tool call errors, surface the error and halt the batch — do not fabricate a reply. -- **Do NOT** use any other tool (e.g. `slack_schedule_message`, `slack_send_message_draft`) as a substitute. Only an immediate `slack_send_message` with `thread_ts` set counts — the Linear Slack app does not trigger on scheduled/draft messages. -- **Do NOT** substitute any direct Linear API call (MCP, GraphQL, curl) for the `@Linear` mention. The Slack thread is intentionally the single audit trail. - -### Fix-path reply (after red-green-fix opens a PR) - -When `red-green-fix` returns a PR URL for an auto-fixed candidate, the skill MUST post a second thread reply on the same parent — again via `slack_send_message`: - -```text -mcp__plugin_slack_slack__slack_send_message({ - channel_id: "C0A4XMHANP3", - thread_ts: "<same parent ts>", - text: ":pr-open: Fix PR: <PR_URL>\nRed-green verified: <unit|e2e> test proves the regression.\nFixes <LIN-ID>" -}) -``` - -Same "tool call, not chat output" rule applies. - -### Parent reaction — optional visibility nudge (not on critical path) - -The Slack MCP does not expose `reactions.add`, so the skill cannot set a `:white_check_mark:` reaction on the parent. The thread reply above is sufficient for Processed Detection; the parent reaction is a human-only "visible in channel" nudge. At the end of the run, the skill MAY print a compact list for the human: - -```text -Optional: add :white_check_mark: to parent messages for in-channel visibility. - LIN-4710 → <permalink> - LIN-4711 → <permalink> -``` - -This is a convenience, not a deliverable — a missing parent reaction does not cause re-ingestion. - -## Fix Workflow (auto-invoke red-green-fix) - -For every `Y` row whose `Rec` resolved to auto-fix (dedupe clean, verify clean, not on handoff-exclusion list), the skill MUST — after Linear creation and the `:white_check_mark:` thread reply — invoke the `red-green-fix` skill via the `Skill` tool. This is a real tool call, not a narrative handoff. - -### Required Skill tool call - -```text -Skill({ - skill: "red-green-fix", - args: "<composed prompt — see below>" -}) -``` - -Compose `args` as a single self-contained prompt so the sub-invocation has everything it needs without re-reading the Linear issue: - -```text -Bug: <title> -Linear: <LIN-ID> (<LINEAR_URL>) -Source: Slack <permalink> -Reporter: <display-name> -Env: <env tags> -Area: <area> -Branch: fix/<lin-id-lowercase>-<short-slug> - -Repro: -1. <step> -2. <step> - -Expected: <expected behavior> -Actual: <actual behavior> - -Test layer (inferred from area): -- ui → Vitest colocated + Playwright e2e tagged @regression -- node-system → Playwright e2e primarily -- workflow / templates → Playwright e2e -- cloud → Vitest if client-side; otherwise STOP and label the Linear issue "needs-backend" - -Test naming: -- describe('<LIN-ID>: <one-line bug summary>', ...) -- Playwright test title must include the LIN-ID. - -PR body must include: -- "Fixes <LIN-ID>" -- "Source: Slack <permalink>" - -Follow the red-green-fix two-commit sequence exactly. Do NOT skip the red commit. -``` - -The skill MUST wait for `red-green-fix` to return before moving to the next candidate. Process one auto-fix at a time so branch state is deterministic. - -### Verifying the invocation ran - -After the `Skill` call returns, the skill MUST confirm at least one of: - -1. A new git branch named `fix/<lin-id>-*` exists (`git branch --list "fix/<lin-id>-*"`). -2. A PR URL is present in `red-green-fix`'s return payload. - -If neither is true, the invocation silently no-op'd. Log the failure to the session log as `auto-fix skipped: invocation returned without branch or PR` and continue — do NOT post the `:pr-open:` thread reply. - -### Inputs summary - -- **Bug description** — the Linear description (includes repro, env, source permalink). -- **Linear ID** — inserted into the PR body as `Fixes <LIN-ID>`. -- **Branch name** — `fix/<lin-id>-<short-slug>` (e.g. `fix/lin-4711-pro-plan-30min-timeout`). -- **Test layer** — inferred from `area`: - - `ui` → unit (Vitest) + e2e (Playwright) - - `node-system` → e2e primarily; unit if isolable - - `workflow` / `templates` → e2e - - `cloud` → unit if client-side logic, otherwise flag "backend — out of scope for this repo" - -### Handoff-Exclusion list (do NOT auto-invoke red-green-fix) - -These rows still get a Linear ticket + `:white_check_mark:` thread reply, but the skill MUST skip the `Skill(skill="red-green-fix")` call and instead post a thread nudge explaining why: - -- Repro steps are incomplete (no clear numbered steps, no env) — reply in thread: "Need clearer repro before I can write a failing test. What's the shortest path to reproduce?" -- Fix requires backend / ComfyUI repo changes (not frontend) — label Linear `needs-backend`. -- Linear ticket was dedupe-linked rather than newly created — existing owner may already be fixing. -- Severity is cosmetic AND reporter hasn't asked for a fix — file ticket only. -- Fix would touch `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph` god-objects (ADR-0003/0008 — always human decision). -- Pre-flight Dedupe Gate found an open PR (`pr-open`) or a matching merged PR (`fixed`). - -When a row is excluded, record the reason in the session log under `auto-fix excluded: <reason>`. - -### Test authoring rules - -Both tests MUST be written in the "red" commit BEFORE any fix code (per red-green-fix). Rules specific to bug-dump ingestion: - -- **Unit test (Vitest)** — colocated next to the implementation, `<file>.test.ts`. Exercise the specific logic path reproduced by the reporter. One `describe` block named after the Linear ID: - - ```typescript - // src/components/node/UnetDropdown.test.ts - describe('LIN-4710: unet dropdown missing selected model', () => { - it('includes the currently-selected model in the list even when not in available models', () => { - // ... - }) - }) - ``` - -- **E2E test (Playwright)** — under `browser_tests/tests/`, follow `writing-playwright-tests` skill. Tag with `@regression` and include the Linear ID in the test title: - - ```typescript - test.describe( - 'LIN-4710 unet dropdown regression', - { tag: ['@regression'] }, - () => { - test('keeps selected model visible in the dropdown', async ({ - comfyPage - }) => { - // ... - }) - } - ) - ``` - -- **Mock data types** — follow `docs/guidance/playwright.md`: mock responses typed from `packages/ingest-types`, `packages/registry-types`, `src/schemas/` — never `as any`. - -(The Handoff-Exclusion list above governs when `red-green-fix` is NOT invoked.) - -### PR body template - -The red-green-fix skill's PR template is extended with a `Source` line: - -```markdown -## Summary - -<Root cause> - -- Fixes LIN-NNN -- Source: Slack <permalink> - -## Red-Green Verification - -| Commit | CI Status | Purpose | -| ------------------------------------------ | -------------------- | ------------------------------- | -| `test: LIN-NNN add failing test for <bug>` | :red_circle: Red | Proves the test catches the bug | -| `fix: <bug summary>` | :green_circle: Green | Proves the fix resolves the bug | - -## Test Plan - -- [ ] Unit regression test passes locally -- [ ] E2E regression test passes locally (if UI) -- [ ] Manual repro no longer reproduces -- [ ] Linear ticket linked -``` - -After the PR merges, post the second thread reply on Slack (see Slack Thread Reply § Fix-path reply). - -## Emoji Reaction Hints (read-only) - -The agent cannot add reactions, but respects human-set reactions when filtering. The canonical team scheme (primary): - -| Reaction | Meaning | Action | -| -------------------- | ------------------ | -------------------------------------------------------- | -| `:white_check_mark:` | Ticket created | Skip — already ingested | -| `:pr-open:` | PR open | Skip creation; record PR link in session log | -| `:question:` | Needs more context | Skip creation; agent may post a thread reply asking | -| `:repeat:` | Duplicate | Skip creation; link existing Linear issue in session log | - -Incidental reactions observed in the channel — treat as soft hints only, do NOT skip solely on these: - -| Reaction | Meaning | Action | -| -------- | ------------------- | -------------------------------------------------- | -| `:eyes:` | Someone is triaging | Still ingestable | -| `:done:` | Reporter resolved | Demote to `resolved` in verify, but still show row | -| `:+1:` | Acknowledged | Ignore | - -Approval-table response code `R` (new) corresponds to `:repeat:` — if you pick `R`, the skill treats it as duplicate and asks for the target Linear ID. - -## Session Log - -Append to `~/temp/bug-dump-ingest/session-YYYY-MM-DD.md`: - -```text -Bug Dump Ingest Session -- 2026-04-20 11:40 KST - -Window: 2026-04-18 00:00 — 2026-04-20 12:00 KST -Scanned: 28 top-level messages -Skipped (meta/discussion/processed): 14 -Proposed: 14 -Approved: 11 -Created in Linear: 10 -Draft-only (creation failed): 1 -Linked-only (dedupe): 1 -Thread replies posted: 11 - -Created: -- LIN-4710 Unet model dropdown missing selected model -- wavey -- low/ui -- LIN-4711 Pro plan jobs end at 30 minutes -- Denys -- high/cloud -- ... - -Skipped with reason: -- 1776592837.616399 -- design discussion in thread, not a bug -- ... -``` - -## Gotchas - -### Thread summaries, not raw dumps - -Pulling the full thread often adds noise. Summarize replies to: (a) confirmed reproductions by other users, (b) env/version details added in replies, (c) links to related PRs/commits. Drop emojis-only replies, joined-channel notifications, and off-topic chatter. - -### Cross-posts are not bugs - -When the top-level message is just a link to a Slack message in another channel (e.g. "X posting" with a URL and nothing else), follow the link to the original source and ingest from there — do NOT create a ticket from the cross-post itself. - -### Resolved-in-thread messages - -If the reporter replies `"No action needed, this is solved"` (see wavey 2026-04-20 08:06), mark the ticket for SKIP in the approval table, not auto-skip. The human may still want a regression test ticket. - -### Permalinks - -Construct Slack permalinks as: - -```text -https://comfy-organization.slack.com/archives/{CHANNEL_ID}/p{TS_WITH_DOT_REMOVED} -``` - -E.g. `1776510375.473579` → `p1776510375473579`. - -### Attachment handling - -Slack file IDs (e.g. `F0AT...`) are private. Do NOT link them directly in Linear. Instead, list the filename and type in the Linear description and include the Slack permalink — anyone with Slack access can see the attachments from the thread. - -### No auto-create without approval - -Never create Linear issues without a human `Y`. This is a hard rule — the skill exists to reduce human toil, not to replace triage judgment. - -## Reference Files - -- `reference/linear-api.md` — `@Linear` Slack bot command reference (create, search, link, labels, status). -- `reference/schema.md` — full ticket schema with field-by-field extraction notes. -- `reference/examples.md` — worked examples drawn from real #bug-dump messages. -- `reference/verify-commands.md` — cookbook of false-defect verification commands per bug class. - -## Related Skills - -- `red-green-fix` — auto-invoked via the `Skill` tool for every eligible `Y` candidate to produce a failing test + fix + PR with the red-green CI proof. -- `writing-playwright-tests` — used by red-green-fix when an e2e test is needed. -- `hardening-flaky-e2e-tests` — if the e2e test added in the fix PR starts flaking, jump to this skill. diff --git a/.claude/skills/bug-dump-ingest/reference/examples.md b/.claude/skills/bug-dump-ingest/reference/examples.md deleted file mode 100644 index 4fc54a4f14..0000000000 --- a/.claude/skills/bug-dump-ingest/reference/examples.md +++ /dev/null @@ -1,123 +0,0 @@ -# Worked Examples - -Real #bug-dump messages (2026-04-17 → 2026-04-20) normalized through the skill. - -## Example 1 — Clean bug with repro - -**Source message** (wavey, 2026-04-20 08:06): - -> unet model dropdown doesnt display all available models, think this is part of a larger issue with model dropdowns.. -> -> • open flux.2 klein 4b image edit template -> • open unet drop down --> notice selected model isnt present in the list, even though its selected -> • execute (to check if it flags the model as missing) --> notice it still runs -> No action needed, this is solved - -**Thread resolution**: "No action needed, this is solved" — reporter resolved it in the same message. - -**Classification**: bug, but `thread_resolution = solved`. Flag for human. - -**Approval row**: - -```text - 1 | wavey, 04-20 08:06 | Unet dropdown missing selected model | cloud | low | ui | N | N (reporter marked solved) -``` - -Default recommendation: `N`. If human overrides to `Y`, file with a "Regression test" label so QA still tracks it. - ---- - -## Example 2 — Clear high-severity cloud bug - -**Source message** (Denys Puziak, 2026-04-18 05:45): - -> I see two reports about jobs ending in 30 minutes while the user is on the Pro plan -> cc @Hunter -> https://discord.com/channels/.../1494078128971055145 - -**Classification**: bug, `env: [cloud prod]` (Pro plan = cloud), `severity: high` (paying users), `area: cloud`. - -**Proposed title**: `Pro plan jobs end at 30 minutes` - -**Description** (excerpt): - -```markdown -**Reporter:** Denys Puziak -**Env:** cloud prod -**Severity (proposed):** high -**Area:** cloud - -## Repro - -1. User on Pro plan submits a job -2. Job ends at 30 minutes instead of the Pro plan limit - -## Notes - -- Two user reports aggregated by Denys -- cc'd @Hunter - -## Source - -Slack: <permalink> -Discord thread: https://discord.com/channels/.../1494078128971055145 -``` - ---- - -## Example 3 — Not a bug (discussion) - -**Source message** (Christian Byrne, 2026-04-19 19:00): - -> @Glary-Bot okay option A is clearly superior and I feel embarrassed I didn't see that line myself... - -**Classification**: discussion (design review chatter). Skip. Log reason in session file. - ---- - -## Example 4 — Meta-action / PR planning - -**Source message** (Christian Byrne, 2026-04-19 09:30): - -> @Glary-Bot how about we make a PR to do: -> -> 1. Audit the rest of the codebase... -> 2. Create a helper in src/base... - -**Classification**: discussion (PR-plan proposal). Skip. - ---- - -## Example 5 — Performance regression - -**Source message** (Terry Jia, 2026-04-18 12:52): - -> With Nodes 2.0, large workflows (hundreds of nodes) make the canvas extremely laggy and unusable for actual work — switching tabs takes several seconds or more. Switching back to Litegraph, performance is significantly better. - -**Classification**: bug, `area: node-system`, `severity: high`. - -**Dedupe**: Post `@Linear search nodes 2.0 performance canvas lag` (Team: Frontend Engineering, Status: open) in the candidate's thread. Likely matches exist — flag `Dedup? ?` and ask human which ticket to link to. - ---- - -## Example 6 — Reporter says it's a question, not a report - -**Source message** (Luke, 2026-04-17 08:27): - -> Is NodeInfo supposed to show information or docs about the node? It just brings up the node sidebar - -**Classification**: question → ambiguous. Read thread. If replies confirm "that's unexpected, should show docs", upgrade to bug. If "yes that's intended", skip. - -Default recommendation in the approval batch: `?` (needs expansion). - ---- - -## Example 7 — Bug with PR already in flight - -**Source message** (Pablo, 2026-04-17 08:52): - -> when deleting multiple assets on cloud -> the confirmation popup still has the assets hashes as names instead of the display name - -**Reaction**: `pr-open (1)` — someone's opened a PR. - -**Classification**: `already-filed` branch. Skip creation; in the session log, note "PR already open". If the human wants a tracking Linear ticket anyway, still fileable with a link to the PR. diff --git a/.claude/skills/bug-dump-ingest/reference/linear-api.md b/.claude/skills/bug-dump-ingest/reference/linear-api.md deleted file mode 100644 index 57986740df..0000000000 --- a/.claude/skills/bug-dump-ingest/reference/linear-api.md +++ /dev/null @@ -1,160 +0,0 @@ -# Linear Slack Bot (@Linear) Reference - -The skill drives Linear exclusively through the Linear Slack app (`@Linear`). **There is no Linear MCP, no `LINEAR_API_KEY`, no GraphQL.** Every Linear read/write happens as a Slack message that mentions `@Linear` in the `#bug-dump` thread, and the Linear Slack app performs the action and posts a reply card containing the issue URL. - -## Why Slack-only - -- The `#bug-dump` thread is already the source of truth; keeping the entire lifecycle (report → ticket → PR → resolution) in one thread means Processed Detection can grep the thread instead of a separate registry. -- No API key rotation, no MCP server install, no OAuth browser flow — works on any machine that already has the Slack MCP configured. -- The Linear Slack app's reply card (with issue URL, title, status, and assignee) IS the canonical receipt; the skill records its `ts` in the session log. - -## Prerequisites (one-time, per workspace) - -The Comfy Slack workspace must already have the Linear Slack app installed (it is — that's how humans use `@Linear` reactions today) and `#bug-dump` (channel `C0A4XMHANP3`) must have Linear enabled for the `Frontend Engineering` team. Nothing else to configure. If a `@Linear` invocation silently does nothing, the bot isn't present in the channel — surface that to the human rather than re-trying. - -## Supported operations - -Every operation is a `mcp__plugin_slack_slack__slack_send_message` call with `channel_id=C0A4XMHANP3` and `thread_ts=<parent-ts>`. The `text` is a natural-language instruction to the Linear bot. Keep the text concise — Linear parses the first line as the command intent. - -### 1. Create an issue from the thread - -```text -mcp__plugin_slack_slack__slack_send_message({ - channel_id: "C0A4XMHANP3", - thread_ts: "<parent-ts>", - text: "@Linear create\nTeam: Frontend Engineering\nTitle: <title>\nStatus: Triage\nLabels: source:bug-dump, area:<area>, env:<env>, sev:<severity>, reporter:<handle>\n\n<description body>\n\nSource: <slack-permalink>" -}) -``` - -Rules: - -- Start with `@Linear create` on its own line — this is the command token the bot keys on. -- Always specify `Team: Frontend Engineering`. Without it, the bot falls back to the Slack workspace's default team, which may not be FE. -- `Status: Triage` pins the initial workflow state. -- `Labels:` — comma-separated. If a label doesn't exist yet in Linear, the bot creates it on first use (verified in Linear workspace settings). Keep the taxonomy exactly as SKILL.md § Label Taxonomy. -- `<description body>` — markdown per `reference/schema.md` Description Template. Use real newlines, not literal `\n`. -- End with `Source: <slack-permalink>` so the Linear issue body links back even if the auto-attachment of the parent message fails. - -The Linear bot replies in the same thread with a card that contains: - -- The Linear URL (`https://linear.app/comfy-org/issue/FE-NNNN`) -- Status, assignee (initially unassigned), and applied labels -- A "View in Linear" button - -Parse the URL out of the bot's reply text (or attachments). If no card reply appears within ~10s of polling `slack_read_thread`, treat it as a creation failure — do NOT proceed to the `:white_check_mark:` confirmation reply. - -### 2. Search existing open issues (dedupe) - -```text -mcp__plugin_slack_slack__slack_send_message({ - channel_id: "C0A4XMHANP3", - thread_ts: "<parent-ts>", - text: "@Linear search <keyword-1> <keyword-2>\nTeam: Frontend Engineering\nStatus: open" -}) -``` - -The bot replies with a card listing up to ~5 matching open issues. Parse identifier (`FE-NNNN`) and URL per row. Treat a hit as a duplicate per SKILL.md § Pre-flight Dedupe Gate § Check 1. - -If `@Linear search` is not supported in the installed Slack app version, fall back to Slack-native search across the `#bug-dump` thread replies (previous `@Linear` cards contain title + URL — grep those for the same keywords). Record which path was used in the session log so the human can see dedupe coverage. - -### 3. Link an existing issue (dedupe: `L` response) - -```text -mcp__plugin_slack_slack__slack_send_message({ - channel_id: "C0A4XMHANP3", - thread_ts: "<parent-ts>", - text: "@Linear link FE-4521" -}) -``` - -The bot replies with the linked issue card. The skill then posts its own `:white_check_mark: Linked to Linear: <URL>` confirmation reply (see SKILL.md § Slack Thread Reply). - -### 4. Add labels to an existing issue - -```text -mcp__plugin_slack_slack__slack_send_message({ - channel_id: "C0A4XMHANP3", - thread_ts: "<parent-ts>", - text: "@Linear FE-4521 add-labels pr-open" -}) -``` - -Used when an open PR is discovered after ticket creation and the Linear issue should flip to `pr-open`. - -### 5. Change status - -```text -mcp__plugin_slack_slack__slack_send_message({ - channel_id: "C0A4XMHANP3", - thread_ts: "<parent-ts>", - text: "@Linear FE-4521 status In Progress" -}) -``` - -Rarely used by the skill directly — usually status changes come from the `red-green-fix` PR lifecycle (Linear auto-moves to `In Review` when a PR references `Fixes FE-4521`). - -## Description body template - -The text that follows the command headers is rendered verbatim as the Linear issue description (markdown). Use this template — see `reference/schema.md` for field-by-field extraction notes: - -```markdown -**Reporter:** <slack-display-name> -**Env:** cloud prod / local / electron / ... -**Severity (proposed):** high/medium/low -**Area:** ui / node-system / workflow / cloud / templates - -## Repro - -1. ... -2. ... - -## Expected - -... - -## Actual - -... - -## Attachments (in Slack thread) - -- image.png (png, 315 KB) -- Screen Recording.mov (mov, 37 MB) - -## Source - -Slack: <permalink> -Thread summary: <1-3 bullets if thread adds context> -``` - -The Slack permalink is load-bearing — it's the canonical route to attachments, reporter, and any follow-up discussion. Do NOT embed Slack file IDs (`F0AT...`) directly; they're permissioned. - -## Parsing the bot's reply - -After each `slack_send_message` that mentions `@Linear`, poll `slack_read_thread` (with `channel_id=C0A4XMHANP3`, `thread_ts=<parent-ts>`) up to 3 times, ~3s apart. Scan replies authored by the Linear Slack app user for: - -- Any `https://linear.app/<org>/issue/FE-\d+` URL → capture as the issue URL. -- The `FE-NNNN` identifier pattern → capture as the issue identifier. -- An error phrase (`couldn't`, `failed`, `not found`, `no team matched`) → treat as failure; surface the full bot text to the human. - -Record the bot reply's `ts` alongside the captured URL and identifier in the session log. - -## Failure modes & handling - -| Symptom | Likely cause | Handling | -| ------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| No bot reply within 10s | Linear app not in channel, or bot outage | Halt the batch, surface to human, do NOT fabricate a Linear URL. Remaining approved candidates stay queued for re-run. | -| Bot replies with "no team matched" | Team name typo or Linear workspace drift | Re-send with the exact team name from the Linear workspace (default: `Frontend Engineering`). If it still fails, ask the human to verify. | -| Bot replies with "couldn't parse labels" | One of the labels has syntax the bot rejects | Drop the offending label, re-send; log the partial-label failure so the human can patch after. | -| Bot creates the issue but reply lacks the URL | Rare bot format change | Re-fetch the thread after ~5s; if URL still absent, open Linear search via `@Linear search <title>` and recover the identifier + URL. | -| Multiple `@Linear` replies match (duplicate card) | The skill retried without polling first | Keep the earliest card's URL; log the extras. Never re-issue `@Linear create` for the same candidate without confirming the first card failed. | - -Never retry `@Linear create` without first running `@Linear search` for the same title keywords — a duplicate card is worse than an initial failure because the human has to close one of them manually. - -## Why no direct API path - -- The Linear MCP (official or community) would require either OAuth setup or `LINEAR_API_KEY` in env — both are per-machine hurdles the skill should not depend on. -- Direct GraphQL against `api.linear.app` has the same key-management cost and bypasses the Slack thread as the audit trail. -- Routing every action through `@Linear` in the thread gives humans full visibility in the channel (the bot's card is the receipt) and Processed Detection becomes a simple Slack thread read. - -If a future need requires capabilities the `@Linear` Slack app doesn't expose (bulk operations, private field edits, webhooks), stop and surface the limitation to the human rather than quietly adding an API-key path — the "Slack-only" constraint is intentional. diff --git a/.claude/skills/bug-dump-ingest/reference/schema.md b/.claude/skills/bug-dump-ingest/reference/schema.md deleted file mode 100644 index 84db1a5818..0000000000 --- a/.claude/skills/bug-dump-ingest/reference/schema.md +++ /dev/null @@ -1,94 +0,0 @@ -# Ticket Schema — Extraction Notes - -Field-by-field guidance for normalizing a Slack #bug-dump message into a ticket. - -## `slack_ts` - -The top-level message timestamp from `slack_read_channel` response (`Message TS:` field). Always store the dotted form (`1776510375.473579`). This is the ingestion identity used in `processed.json`. - -## `slack_permalink` - -Construct: - -```text -https://comfy-organization.slack.com/archives/C0A4XMHANP3/p<ts-without-dot> -``` - -Example: `1776510375.473579` → `.../p1776510375473579`. - -## `reporter` - -The display name + parenthetical nickname if present. Examples from the channel: - -- `Ali Ranjah (wavey)` -- `Denys Puziak` -- `Christian Byrne` - -Do NOT use the Slack user ID (`U087MJCDHHC`) in Linear — names are more readable. - -## `title` - -Rules: - -- Start with a verb or noun phrase describing the observed defect, not the reporter. -- ≤ 80 chars. -- Include env qualifier ("cloud prod", "local dev", "electron") only if ambiguous. -- Strip emoji and reactions from the original message when extracting. - -Transformations: - -| Slack message (excerpt) | Title | -| ----------------------------------------------------------------------- | --------------------------------------------------- | -| "unet model dropdown doesnt display all available models..." | Unet dropdown missing selected model | -| "Dates are broken on Settings -> Secrets. Cloud Prod" | Settings → Secrets dates broken on cloud prod | -| "LTX-2: Audio to VIdeo template results in the "RuntimeError..." error" | LTX-2 Audio-to-Video template RuntimeError on cloud | - -## `description` - -Structure — see `linear-api.md` § "Description body template". Key rules: - -- Lead with **Repro** numbered list. Extract from the message body; if no steps are given, write "Repro: [Slack message body quoted verbatim]" and flag for human in approval. -- Preserve the reporter's own words in the Repro section when they include "step 1 / step 2" markers. -- Collapse multi-paragraph asides into "Notes" at the end. - -## `env` - -Detect from message text using these terms: - -| Text in message | Tag | -| -------------------------- | ---------------------- | -| `cloud prod`, `prod cloud` | `cloud prod` | -| `cloud dev` | `cloud dev` | -| `cloud` | `cloud` (unqual.) | -| `local`, `localhost` | `local` | -| `electron`, `desktop` | `electron` | -| `nodes 2.0`, `LG` | (feature tag, not env) | - -A message can have multiple env tags. If none are detectable, set `env: []` and flag "env unclear" in the approval row. - -## `severity` - -Heuristics in SKILL.md. When uncertain, mark `medium` and note in approval table: `Sev: medium (flag)`. - -## `area` - -Single tag. Use the one that best fits; tiebreak toward the more actionable team: - -- `cloud` > `workflow` when the reported behavior is specific to cloud-hosted features (billing, queue, jobs) -- `node-system` > `ui` when the defect is canvas interaction, not just visual -- `templates` only when a named template is the subject - -## `attachments` - -From `slack_read_channel` message `Files:` field. Parse name, ID, type. Never include the Slack file ID in the Linear description — those are permissioned — just the filename and type. - -## `thread_resolution` - -Fetch via `slack_read_thread`. Scan replies for: - -- `solved`, `resolved`, `fixed`, `no action needed` → `solved` -- A `:done:` reaction from the reporter → `solved` -- A `https://github.com/Comfy-Org/ComfyUI_frontend/pull/` URL in a reply → `pr-open` (keep but note in description) -- Otherwise → `open` - -If `solved` and no PR merged, flag in approval table: reporter marked solved — confirm before filing. diff --git a/.claude/skills/bug-dump-ingest/reference/verify-commands.md b/.claude/skills/bug-dump-ingest/reference/verify-commands.md deleted file mode 100644 index a2c99a43a0..0000000000 --- a/.claude/skills/bug-dump-ingest/reference/verify-commands.md +++ /dev/null @@ -1,99 +0,0 @@ -# Verify Commands Cookbook - -One-shot commands for each False-Defect Verification class. Keep each under ~30s. - -## 1. Check for existing fix PR - -```bash -# By keyword in title -gh search prs --repo Comfy-Org/ComfyUI_frontend "<keyword>" --state merged --limit 5 - -# By keyword in body -gh pr list --repo Comfy-Org/ComfyUI_frontend --search "<keyword>" --state all --limit 5 - -# Recent closing PRs near the reported date -gh pr list --repo Comfy-Org/ComfyUI_frontend --state merged \ - --search "merged:>=<YYYY-MM-DD> <keyword>" --limit 10 -``` - -Verify tag: `fixed` if a merged PR explicitly matches; `pr-open` if an open PR matches. - -## 2. Check for existing open Linear issue - -```text -# Primary: @Linear search in the candidate's bug-dump thread -# mcp__plugin_slack_slack__slack_send_message({ -# channel_id: "C0A4XMHANP3", -# thread_ts: "<parent-ts>", -# text: "@Linear search <keyword-1> <keyword-2>\nTeam: Frontend Engineering\nStatus: open" -# }) -# → poll slack_read_thread, parse the Linear app's reply card for FE-NNNN matches. -# -# Fallback: grep past @Linear bot replies in the channel for prior ingested titles -# mcp__plugin_slack_slack__slack_search_public({ -# query: "in:<#C0A4XMHANP3> from:@Linear <keyword-1> <keyword-2>" -# }) -``` - -Verify tag: `dedupe` with the `FE-NNNN` identifier in the approval row. See `reference/linear-api.md` § "Search existing open issues (dedupe)" for full handling. - -## 3. Feature actually exists in codebase - -```bash -# Find the component / feature mentioned -rg -l "<ComponentOrFeatureName>" src/ apps/ --type vue --type ts - -# Find a setting key -rg "<setting-key>" src/locales/en/ src/stores/settingStore.ts - -# Find a store action -rg "<actionName>" src/stores/ --type ts -``` - -Verify tag: `stale` if 0 hits AND the feature name is specific (not a generic word). - -## 4. Intended behavior check - -```bash -# Check docs and release notes -rg -l "<feature keyword>" docs/ CHANGELOG.md - -# Check if behavior is asserted in an existing test (green today) -rg "<observed behavior>" src/**/*.test.ts browser_tests/ -``` - -Verify tag: `expected` if docs describe this as the intended behavior, or a test asserts it. - -## 5. Reporter self-resolution - -Already gathered via `slack_read_thread`. Look for reporter's own replies containing: - -- "solved", "resolved", "fixed", "no action needed", "nvm", "my bad" -- A `:done:` reaction from the reporter -- A `:white_check_mark:` reaction - -Verify tag: `resolved`. - -## 6. Env-specific / local setup - -If the message mentions "my machine", "my proxy", "my docker", "my cache" AND no other reporter has confirmed in-thread: - -```bash -# Check thread for cross-user confirmations -# slack_read_thread → count distinct users replying with "same", "repro'd", "+1" -``` - -Verify tag: `env` if only the reporter is affected. - -## 7. Cross-post (X posting) - -If the top-level message is just a link + "X posting": - -```bash -# Follow the link — use slack_search_public to find the original thread -# slack_search_public({ query: "<in:channel from:@reporter> <before:date>" }) -``` - -If the original is already ingestable, ingest from the original's permalink. If it's a GitHub issue, prefer linking that GitHub issue to the Linear ticket instead of creating two entries. - -Verify tag: `cross-post` with the resolved source permalink. diff --git a/.claude/skills/writing-playwright-tests/SKILL.md b/.claude/skills/writing-playwright-tests/SKILL.md index 84a39210a0..efc20dff85 100644 --- a/.claude/skills/writing-playwright-tests/SKILL.md +++ b/.claude/skills/writing-playwright-tests/SKILL.md @@ -114,7 +114,7 @@ await expect(async () => { ## CI Debugging 1. Download artifacts from failed CI run -2. Extract and view trace: `npx playwright show-trace trace.zip` +2. Extract and view trace: `pnpm dlx 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` diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 20af82bec6..35c5d31183 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -19,15 +19,26 @@ reviews: - name: End-to-end regression coverage for fixes mode: error instructions: | - Use only PR metadata already available in the review context: the PR title, commit subjects in this PR, the files changed in this PR relative to the PR base (equivalent to `base...head`), and the PR description. - Do not rely on shell commands. Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing. + Use only PR metadata already available in the review context: + - the PR title + - commit subjects in this PR + - The files changed in this PR relative to the PR base (equivalent to `base...head`) + - the PR description. + Do not rely on shell commands. + Do not inspect reverse diffs, files changed only on the base branch, or files outside this PR. + If the changed-file list or commit subjects are unavailable, mark the check inconclusive instead of guessing. - Pass if at least one of the following is true: - 1. Neither the PR title nor any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`. - 2. The PR changes at least one file under `browser_tests/`. - 3. The PR description includes a concrete, non-placeholder explanation of why an end-to-end regression test was not added. + Fail if all of the following are true: + 1. The PR title and/or any commit subject in the PR uses bug-fix language such as `fix`, `fixed`, `fixes`, `fixing`, `bugfix`, or `hotfix`. + 2. The PR changes files under `src/` or `packages/` related to the main frontend application but the PR does not change at least one file under `browser_tests/`. + 3. The PR description lacks a concrete explanation of why an end-to-end regression test was not added. + + Do not fail if the changes are exclusively in `apps/website`, just documentation changes, or changes related to CI processes. + The goal is to make sure that fixes include End-to-End regression tests. Do not insist on tests when the PR is not fixing a bug. + + Pass otherwise. + When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical. - Fail otherwise. When failing, mention which bug-fix signal you found and ask the author to either add or update a Playwright regression test under `browser_tests/` or add a concrete explanation in the PR description of why an end-to-end regression test is not practical. - name: ADR compliance for entity/litegraph changes mode: warning instructions: | diff --git a/.env_example b/.env_example index 81caf27585..e2834bbf03 100644 --- a/.env_example +++ b/.env_example @@ -41,6 +41,10 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579 # Enable PostHog debug logging in the browser console. # VITE_POSTHOG_DEBUG=true +# Override staging comfy-api / comfy-platform base URLs. +# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org +# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org + # Sentry ENV vars replace with real ones for debugging # SENTRY_AUTH_TOKEN=private-token # get from sentry # SENTRY_ORG=comfy-org diff --git a/.github/actions/ashby-pull/action.yaml b/.github/actions/ashby-pull/action.yaml new file mode 100644 index 0000000000..d1b27f6c67 --- /dev/null +++ b/.github/actions/ashby-pull/action.yaml @@ -0,0 +1,23 @@ +name: Ashby Pull +description: 'Refresh the apps/website Ashby roles snapshot from the Ashby job board API' +inputs: + api_key: + description: 'Ashby API key (WEBSITE_ASHBY_API_KEY).' + required: true + job_board_name: + description: 'Ashby job board name (WEBSITE_ASHBY_JOB_BOARD_NAME).' + 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 Ashby snapshot + shell: bash + env: + WEBSITE_ASHBY_API_KEY: ${{ inputs.api_key }} + WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ inputs.job_board_name }} + run: pnpm --filter @comfyorg/website ashby:refresh-snapshot diff --git a/.github/actions/changes-filter/action.yaml b/.github/actions/changes-filter/action.yaml new file mode 100644 index 0000000000..3a37ca4a32 --- /dev/null +++ b/.github/actions/changes-filter/action.yaml @@ -0,0 +1,87 @@ +# Outputs default to 'true' for non-pull_request events (push, merge_group): +# granular path filtering is a PR-only optimization. This avoids the silent +# skip footgun where a job gated on e.g. `app-website-changes == 'true'` +# would never run on push. +# +# Shared dependency files (root package.json, pnpm-lock.yaml, +# pnpm-workspace.yaml) are folded into every app-* and packages-changes +# output so a lockfile bump correctly invalidates each granular gate. They +# are NOT folded into docs-changes. +# +# Two paths-filter steps are needed because predicate-quantifier=every is +# required for the negated globs in `should-run` but breaks multi-pattern +# OR filters like `docs:` and `deps:`. +# +# Requires the caller to have checked out the repository. + +name: 'Detect Path Changes' +description: > + Computes typed *-changes outputs and a back-compat should-run for + path-gated CI jobs. + +outputs: + should-run: + description: 'Any file outside `apps/`, `docs/`, `.storybook/`, or `**/*.md` changed.' + value: ${{ github.event_name != 'pull_request' || steps.relevant.outputs.relevant == 'true' }} + app-website-changes: + description: 'Shared deps or `apps/website/**` changed.' + value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_website == 'true' }} + app-desktop-changes: + description: 'Shared deps or `apps/desktop-ui/**` changed.' + value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_desktop == 'true' }} + app-frontend-changes: + description: 'Shared deps or `src/**` changed.' + value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.app_frontend == 'true' }} + packages-changes: + description: 'Shared deps or `packages/**` changed.' + value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.packages == 'true' }} + storybook-changes: + description: 'Shared deps or `.storybook/**` changed.' + value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' || steps.filter.outputs.storybook == 'true' }} + docs-changes: + description: '`docs/**` or any `**/*.md` changed (deps NOT folded in).' + value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.docs == 'true' }} + dependency-changes: + description: 'Root `package.json`, `pnpm-lock.yaml`, or `pnpm-workspace.yaml` changed.' + value: ${{ github.event_name != 'pull_request' || steps.filter.outputs.deps == 'true' }} + +runs: + using: composite + steps: + - name: Filter typed changes + if: ${{ github.event_name == 'pull_request' }} + id: filter + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + with: + filters: | + app_website: + - 'apps/website/**' + app_desktop: + - 'apps/desktop-ui/**' + app_frontend: + - 'src/**' + packages: + - 'packages/**' + storybook: + - '.storybook/**' + docs: + - 'docs/**' + - '**/*.md' + deps: + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + + - name: Filter relevant changes + if: ${{ github.event_name == 'pull_request' }} + id: relevant + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + with: + predicate-quantifier: 'every' + filters: | + relevant: + - '**' + - '!apps/**' + - '!docs/**' + - '!.storybook/**' + - '!**/*.md' diff --git a/.github/actions/cloud-nodes-pull/action.yaml b/.github/actions/cloud-nodes-pull/action.yaml new file mode 100644 index 0000000000..8c4dfd2702 --- /dev/null +++ b/.github/actions/cloud-nodes-pull/action.yaml @@ -0,0 +1,19 @@ +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 diff --git a/.github/pr-images/fe-237-before-after.png b/.github/pr-images/fe-237-before-after.png new file mode 100644 index 0000000000..a104802cdc Binary files /dev/null and b/.github/pr-images/fe-237-before-after.png differ diff --git a/.github/workflows/ci-dist-telemetry-scan.yaml b/.github/workflows/ci-dist-telemetry-scan.yaml index 1821efd95d..c5e53d13fd 100644 --- a/.github/workflows/ci-dist-telemetry-scan.yaml +++ b/.github/workflows/ci-dist-telemetry-scan.yaml @@ -12,17 +12,30 @@ permissions: contents: read jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + should-run: ${{ steps.changes.outputs.should-run }} + steps: + - uses: actions/checkout@v6 + - id: changes + uses: ./.github/actions/changes-filter + scan: + needs: changes + if: ${{ needs.changes.outputs.should-run == 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@v6 - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - name: Use Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/ci-oss-assets-validation.yaml b/.github/workflows/ci-oss-assets-validation.yaml index a145ca04f1..c05399d4dc 100644 --- a/.github/workflows/ci-oss-assets-validation.yaml +++ b/.github/workflows/ci-oss-assets-validation.yaml @@ -14,16 +14,29 @@ permissions: contents: read jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + should-run: ${{ steps.changes.outputs.should-run }} + steps: + - uses: actions/checkout@v6 + - id: changes + uses: ./.github/actions/changes-filter + validate-fonts: + needs: changes + if: ${{ needs.changes.outputs.should-run == 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@v6 - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - name: Use Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: 'pnpm' @@ -68,15 +81,17 @@ jobs: echo '✅ No proprietary fonts found in dist' validate-licenses: + needs: changes + if: ${{ needs.changes.outputs.should-run == 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - uses: actions/checkout@v6 - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - name: Use Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + uses: actions/setup-node@v6 with: node-version-file: '.nvmrc' cache: 'pnpm' diff --git a/.github/workflows/ci-perf-report.yaml b/.github/workflows/ci-perf-report.yaml index b3c7707bd7..94dad8b916 100644 --- a/.github/workflows/ci-perf-report.yaml +++ b/.github/workflows/ci-perf-report.yaml @@ -3,10 +3,8 @@ name: 'CI: Performance Report' on: push: branches: [main, core/*] - paths-ignore: ['**/*.md'] pull_request: branches-ignore: [wip/*, draft/*, temp/*] - paths-ignore: ['**/*.md'] concurrency: group: perf-${{ github.ref }} @@ -16,8 +14,20 @@ permissions: contents: read jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + should-run: ${{ steps.changes.outputs.should-run }} + steps: + - uses: actions/checkout@v6 + - id: changes + uses: ./.github/actions/changes-filter + perf-tests: - if: github.repository == 'Comfy-Org/ComfyUI_frontend' + needs: changes + if: ${{ needs.changes.outputs.should-run == 'true' && github.repository == 'Comfy-Org/ComfyUI_frontend' }} runs-on: ubuntu-latest timeout-minutes: 30 container: @@ -44,10 +54,14 @@ 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 - run: pnpm exec playwright test --project=performance --workers=1 --repeat-each=3 + 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 - name: Upload perf metrics if: always() diff --git a/.github/workflows/ci-size-data.yaml b/.github/workflows/ci-size-data.yaml index b3e4598fce..ad8e7266c7 100644 --- a/.github/workflows/ci-size-data.yaml +++ b/.github/workflows/ci-size-data.yaml @@ -16,9 +16,22 @@ permissions: contents: read jobs: - collect: + changes: if: github.repository == 'Comfy-Org/ComfyUI_frontend' runs-on: ubuntu-latest + permissions: + contents: read + outputs: + should-run: ${{ steps.changes.outputs.should-run }} + steps: + - uses: actions/checkout@v6 + - id: changes + uses: ./.github/actions/changes-filter + + collect: + needs: changes + if: ${{ needs.changes.outputs.should-run == 'true' }} + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/ci-tests-e2e-coverage.yaml b/.github/workflows/ci-tests-e2e-coverage.yaml index 2944bf0c82..246b551917 100644 --- a/.github/workflows/ci-tests-e2e-coverage.yaml +++ b/.github/workflows/ci-tests-e2e-coverage.yaml @@ -20,6 +20,8 @@ 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 @@ -37,31 +39,33 @@ 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) @@ -82,7 +86,7 @@ jobs: done - name: Upload merged coverage data - if: always() + if: steps.coverage-shards.outputs.has-coverage == 'true' uses: actions/upload-artifact@v6 with: name: e2e-coverage @@ -91,7 +95,7 @@ jobs: if-no-files-found: warn - name: Upload E2E coverage to Codecov - if: always() + if: steps.coverage-shards.outputs.has-coverage == 'true' uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 with: files: coverage/playwright/coverage.lcov @@ -100,20 +104,17 @@ 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 + --precision 1 \ + --ignore-errors source,unmapped - name: Upload HTML report artifact + if: steps.coverage-shards.outputs.has-coverage == 'true' uses: actions/upload-artifact@v6 with: name: e2e-coverage-html @@ -122,7 +123,10 @@ jobs: deploy: needs: merge - if: github.event.workflow_run.head_branch == 'main' + if: > + github.event.workflow_run.head_branch == 'main' && + needs.merge.outputs.has-coverage == 'true' && + github.event.workflow_run.event == 'push' runs-on: ubuntu-latest permissions: pages: write diff --git a/.github/workflows/ci-tests-e2e.yaml b/.github/workflows/ci-tests-e2e.yaml index 47101ef2d0..76ff79d873 100644 --- a/.github/workflows/ci-tests-e2e.yaml +++ b/.github/workflows/ci-tests-e2e.yaml @@ -4,7 +4,6 @@ name: 'CI: Tests E2E' on: push: branches: [main, master, core/*, desktop/*] - paths-ignore: ['**/*.md'] pull_request: branches-ignore: [wip/*, draft/*, temp/*] merge_group: @@ -15,36 +14,20 @@ concurrency: cancel-in-progress: true jobs: - # Detect whether e2e-relevant files changed. Required checks see "skipped" - # (which counts as passing) when only docs/apps/storybook files are touched, - # avoiding the stall that paths-ignore would cause. changes: runs-on: ubuntu-latest permissions: contents: read outputs: - should_run: ${{ github.event_name != 'pull_request' || steps.filter.outputs.e2e }} + should-run: ${{ steps.changes.outputs.should-run }} steps: - - name: Checkout repository - if: ${{ github.event_name == 'pull_request' }} - uses: actions/checkout@v6 - - name: Check for e2e-relevant changes - if: ${{ github.event_name == 'pull_request' }} - id: filter - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 - with: - predicate-quantifier: 'every' - filters: | - e2e: - - '**' - - '!apps/**' - - '!docs/**' - - '!.storybook/**' - - '!**/*.md' + - uses: actions/checkout@v6 + - id: changes + uses: ./.github/actions/changes-filter setup: needs: changes - if: ${{ needs.changes.outputs.should_run == 'true' }} + if: ${{ needs.changes.outputs.should-run == 'true' }} runs-on: ubuntu-latest steps: - name: Checkout repository @@ -194,7 +177,7 @@ jobs: merge-reports: needs: [changes, playwright-tests-chromium-sharded] runs-on: ubuntu-latest - if: ${{ !cancelled() && needs.changes.outputs.should_run == 'true' }} + if: ${{ !cancelled() && needs.changes.outputs.should-run == 'true' }} steps: - name: Install pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 @@ -233,7 +216,7 @@ jobs: steps: - name: Check E2E results env: - SHOULD_RUN: ${{ needs.changes.outputs.should_run }} + SHOULD_RUN: ${{ needs.changes.outputs.should-run }} SHARDED: ${{ needs.playwright-tests-chromium-sharded.result }} BROWSERS: ${{ needs.playwright-tests.result }} run: | @@ -251,7 +234,7 @@ jobs: runs-on: ubuntu-latest if: >- ${{ - needs.changes.outputs.should_run == 'true' && + needs.changes.outputs.should-run == 'true' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }} @@ -278,7 +261,7 @@ jobs: if: >- ${{ always() && - needs.changes.outputs.should_run == 'true' && + needs.changes.outputs.should-run == 'true' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false }} diff --git a/.github/workflows/ci-tests-storybook.yaml b/.github/workflows/ci-tests-storybook.yaml index d2fa26826c..f53a254cf9 100644 --- a/.github/workflows/ci-tests-storybook.yaml +++ b/.github/workflows/ci-tests-storybook.yaml @@ -8,10 +8,29 @@ on: branches: [main] jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + storybook-changes: ${{ steps.changes.outputs.storybook-changes }} + app-frontend-changes: ${{ steps.changes.outputs.app-frontend-changes }} + packages-changes: ${{ steps.changes.outputs.packages-changes }} + steps: + - uses: actions/checkout@v6 + - id: changes + uses: ./.github/actions/changes-filter + # Post starting comment for non-forked PRs comment-on-pr-start: + needs: changes runs-on: ubuntu-latest - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false + if: | + github.event_name == 'pull_request' + && github.event.pull_request.head.repo.fork == false + && (needs.changes.outputs.storybook-changes == 'true' + || needs.changes.outputs.app-frontend-changes == 'true' + || needs.changes.outputs.packages-changes == 'true') permissions: pull-requests: write steps: @@ -30,8 +49,13 @@ jobs: # Build Storybook for all PRs (free Cloudflare deployment) storybook-build: + needs: changes runs-on: ubuntu-latest - if: github.event_name == 'pull_request' + if: | + github.event_name == 'pull_request' + && (needs.changes.outputs.storybook-changes == 'true' + || needs.changes.outputs.app-frontend-changes == 'true' + || needs.changes.outputs.packages-changes == 'true') outputs: conclusion: ${{ steps.job-status.outputs.conclusion }} workflow-url: ${{ steps.workflow-url.outputs.url }} @@ -67,8 +91,15 @@ jobs: # Chromatic deployment only for version-bump-* branches or manual triggers chromatic-deployment: + needs: changes runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && startsWith(github.head_ref, 'version-bump-')) + if: | + github.event_name == 'workflow_dispatch' + || (github.event_name == 'pull_request' + && startsWith(github.head_ref, 'version-bump-') + && (needs.changes.outputs.storybook-changes == 'true' + || needs.changes.outputs.app-frontend-changes == 'true' + || needs.changes.outputs.packages-changes == 'true')) outputs: conclusion: ${{ steps.job-status.outputs.conclusion }} workflow-url: ${{ steps.workflow-url.outputs.url }} @@ -107,9 +138,15 @@ jobs: # Deploy and comment for non-forked PRs only deploy-and-comment: - needs: [storybook-build] + needs: [changes, storybook-build] runs-on: ubuntu-latest - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false && always() + if: | + always() + && github.event_name == 'pull_request' + && github.event.pull_request.head.repo.fork == false + && (needs.changes.outputs.storybook-changes == 'true' + || needs.changes.outputs.app-frontend-changes == 'true' + || needs.changes.outputs.packages-changes == 'true') permissions: pull-requests: write contents: read diff --git a/.github/workflows/ci-tests-unit.yaml b/.github/workflows/ci-tests-unit.yaml index 3fc3095bd6..352eb8a49d 100644 --- a/.github/workflows/ci-tests-unit.yaml +++ b/.github/workflows/ci-tests-unit.yaml @@ -4,10 +4,8 @@ name: 'CI: Tests Unit' on: push: branches: [main, master, dev*, core/*, desktop/*] - paths-ignore: ['**/*.md'] pull_request: branches-ignore: [wip/*, draft/*, temp/*] - paths-ignore: ['**/*.md'] merge_group: concurrency: @@ -15,7 +13,20 @@ concurrency: cancel-in-progress: true jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + should-run: ${{ steps.changes.outputs.should-run }} + steps: + - uses: actions/checkout@v6 + - id: changes + uses: ./.github/actions/changes-filter + test: + needs: changes + if: ${{ needs.changes.outputs.should-run == 'true' }} runs-on: ubuntu-latest steps: diff --git a/.github/workflows/ci-vercel-website-preview.yaml b/.github/workflows/ci-vercel-website-preview.yaml index 7a26fd178d..7e298abb81 100644 --- a/.github/workflows/ci-vercel-website-preview.yaml +++ b/.github/workflows/ci-vercel-website-preview.yaml @@ -4,6 +4,9 @@ name: 'CI: Vercel Website Preview' on: pull_request: types: [opened, synchronize, reopened] + branches-ignore: + - 'core/**' + - 'cloud/**' paths: - 'apps/website/**' - 'packages/design-system/**' @@ -52,6 +55,10 @@ jobs: run: vercel pull --yes --environment=preview - 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 - name: Fetch head commit metadata @@ -145,7 +152,20 @@ 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 diff --git a/.github/workflows/ci-website-build.yaml b/.github/workflows/ci-website-build.yaml index 832854c2eb..211ee86960 100644 --- a/.github/workflows/ci-website-build.yaml +++ b/.github/workflows/ci-website-build.yaml @@ -4,23 +4,29 @@ name: 'CI: Website Build' on: push: branches: [main, master, website/*] - paths: - - 'apps/website/**' - - 'packages/design-system/**' - - 'pnpm-lock.yaml' pull_request: branches-ignore: [wip/*, draft/*, temp/*] - paths: - - 'apps/website/**' - - 'packages/design-system/**' - - 'pnpm-lock.yaml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + app-website-changes: ${{ steps.changes.outputs.app-website-changes }} + packages-changes: ${{ steps.changes.outputs.packages-changes }} + steps: + - uses: actions/checkout@v6 + - id: changes + uses: ./.github/actions/changes-filter + build: + needs: changes + if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }} runs-on: ubuntu-latest steps: @@ -30,4 +36,7 @@ jobs: uses: ./.github/actions/setup-frontend - name: Build website + env: + WEBSITE_ASHBY_API_KEY: ${{ secrets.WEBSITE_ASHBY_API_KEY }} + WEBSITE_ASHBY_JOB_BOARD_NAME: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }} run: pnpm --filter @comfyorg/website build diff --git a/.github/workflows/ci-website-e2e.yaml b/.github/workflows/ci-website-e2e.yaml index 51587112b7..ea8e7f0592 100644 --- a/.github/workflows/ci-website-e2e.yaml +++ b/.github/workflows/ci-website-e2e.yaml @@ -3,25 +3,29 @@ name: 'CI: Website E2E' on: push: branches: [main] - paths: - - 'apps/website/**' - - 'packages/design-system/**' - - 'packages/tailwind-utils/**' - - 'pnpm-lock.yaml' pull_request: branches-ignore: [wip/*, draft/*, temp/*] - paths: - - 'apps/website/**' - - 'packages/design-system/**' - - 'packages/tailwind-utils/**' - - 'pnpm-lock.yaml' concurrency: group: ${{ github.workflow }}-${{ github.repository }}-${{ github.head_ref || github.ref }} cancel-in-progress: true jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + app-website-changes: ${{ steps.changes.outputs.app-website-changes }} + packages-changes: ${{ steps.changes.outputs.packages-changes }} + steps: + - uses: actions/checkout@v6 + - id: changes + uses: ./.github/actions/changes-filter + website-e2e: + needs: changes + if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }} runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright:v1.58.1-noble @@ -163,7 +167,11 @@ jobs: post-starting-comment: # Safe to comment from pull_request trigger: fork PRs are excluded by the guard below. # This avoids a ci-*/pr-* workflow_run split for a comment that must appear immediately. - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false + needs: changes + if: | + github.event_name == 'pull_request' + && github.event.pull_request.head.repo.fork == false + && (needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true') runs-on: ubuntu-latest permissions: pull-requests: write diff --git a/.github/workflows/model-page-discovery.yaml b/.github/workflows/model-page-discovery.yaml new file mode 100644 index 0000000000..457c7546aa --- /dev/null +++ b/.github/workflows/model-page-discovery.yaml @@ -0,0 +1,123 @@ +name: Model Page Discovery + +on: + schedule: + - cron: '0 9 * * 1' + workflow_dispatch: + +jobs: + discover: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + + steps: + - name: Fetch model labels from hub API + id: hub + shell: bash + run: | + set -euo pipefail + curl -fsSL 'https://comfy.org/api/hub/labels?type=model' -o hub-labels.json + echo "Fetched $(jq '.labels | length' hub-labels.json) model labels from hub" + + - name: Checkout ComfyUI_frontend + uses: actions/checkout@v6 + with: + sparse-checkout: apps/website/src/config/generated-models.json + + - name: Compare against existing models + id: compare + shell: bash + run: | + set -euo pipefail + + HUB_SLUGS=$(jq -r '[.labels[].name]' hub-labels.json) + + EXISTING_SLUGS=$(node -e " + const fs = require('fs'); + const models = JSON.parse( + fs.readFileSync( + 'apps/website/src/config/generated-models.json', + 'utf8' + ) + ); + console.log(JSON.stringify(models.map(m => m.slug))); + " 2>/dev/null || echo '[]') + + ADDED_SLUGS=$(node -e " + const hub = $HUB_SLUGS; + const existing = new Set($EXISTING_SLUGS); + console.log(JSON.stringify(hub.filter(s => !existing.has(s)))); + ") + + COUNT=$(node -e "console.log($ADDED_SLUGS.length)") + echo "new_count=$COUNT" >> \$GITHUB_OUTPUT + echo "new_slugs=$ADDED_SLUGS" >> \$GITHUB_OUTPUT + + if [ "\$COUNT" -eq 0 ]; then + echo "No new models found." + else + echo "Found \$COUNT new model(s)" + fi + + - name: Check for existing open discovery issue + id: existing_issue + if: steps.compare.outputs.new_count != '0' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + COUNT=$(gh issue list \ + --repo "$GITHUB_REPOSITORY" \ + --state open \ + --search 'in:title "New models detected"' \ + --json number \ + --jq 'length') + echo "open_count=$COUNT" >> $GITHUB_OUTPUT + + - name: Open GitHub issue for new models + if: | + steps.compare.outputs.new_count != '0' && + steps.existing_issue.outputs.open_count == '0' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEW_SLUGS: ${{ steps.compare.outputs.new_slugs }} + NEW_COUNT: ${{ steps.compare.outputs.new_count }} + shell: bash + run: | + SLUG_LIST=$(node -e " + const slugs = $NEW_SLUGS; + console.log(slugs.map(s => '- \`' + s + '\`').join('\n')); + ") + + gh issue create \ + --repo "$GITHUB_REPOSITORY" \ + --title "New models detected — add to model pages" \ + --body "## $NEW_COUNT new model(s) found in hub + + The weekly model discovery scan found model labels on the hub not yet in + \`apps/website/src/config/generated-models.json\`. + + ### New slugs ($NEW_COUNT) + + $SLUG_LIST + + ### Next steps + + 1. Review which of these warrant an SEO model page + 2. For local models: run \`SKIP_THUMBNAILS=1 pnpm generate:models\` and commit the result + 3. For partner/API models: add to \`API_PROVIDER_MAP\` in \`generate-models.ts\`, regenerate, commit + + --- + *Generated by the [model-page-discovery workflow](https://github.com/$GITHUB_REPOSITORY/actions/workflows/model-page-discovery.yaml)*" + + - name: Skip — open issue already exists + if: | + steps.compare.outputs.new_count != '0' && + steps.existing_issue.outputs.open_count != '0' + run: echo "An open discovery issue already exists — skipping creation." + + - name: No new models found + if: steps.compare.outputs.new_count == '0' + run: echo "No new models found — nothing to do." diff --git a/.github/workflows/pr-claude-review.yaml b/.github/workflows/pr-claude-review.yaml index df819b9226..af07fb99cc 100644 --- a/.github/workflows/pr-claude-review.yaml +++ b/.github/workflows/pr-claude-review.yaml @@ -39,7 +39,7 @@ jobs: - name: Install dependencies for analysis tools run: | - pnpm install -g typescript @vue/compiler-sfc + pnpm add -g typescript @vue/compiler-sfc - name: Run Claude PR Review uses: anthropics/claude-code-action@ff34ce0ff04a470bd3fa56c1ef391c8f1c19f8e9 # v1.0.38 diff --git a/.github/workflows/release-website.yaml b/.github/workflows/release-website.yaml new file mode 100644 index 0000000000..2814d75315 --- /dev/null +++ b/.github/workflows/release-website.yaml @@ -0,0 +1,70 @@ +# 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. +name: 'Release: Website' + +on: + workflow_dispatch: + +concurrency: + group: release-website + cancel-in-progress: true + +jobs: + refresh-snapshots: + if: github.repository == 'Comfy-Org/ComfyUI_frontend' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: main + persist-credentials: false + + - name: Refresh Ashby snapshot + uses: ./.github/actions/ashby-pull + with: + 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' + 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` + + **Flow:** + 1. `Release: Website` workflow ran (manual trigger). + 2. This PR opens with the regenerated snapshots. + 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). + + Triggered by workflow run `${{ github.run_id }}`. + branch: chore/refresh-website-snapshots-${{ github.run_id }} + base: main + labels: | + Release:Website + delete-branch: true diff --git a/.github/workflows/weekly-docs-check.yaml b/.github/workflows/weekly-docs-check.yaml index 8e1b4e72ad..23f4e90dc9 100644 --- a/.github/workflows/weekly-docs-check.yaml +++ b/.github/workflows/weekly-docs-check.yaml @@ -40,11 +40,11 @@ jobs: - name: Install dependencies for analysis tools run: | # Check if packages are already available locally - if ! pnpm list typescript @vue/compiler-sfc >/dev/null 2>&1; then + if ! pnpm list -g typescript @vue/compiler-sfc >/dev/null 2>&1; then echo "Installing TypeScript and Vue compiler globally..." - pnpm install -g typescript @vue/compiler-sfc + pnpm add -g typescript @vue/compiler-sfc else - echo "TypeScript and Vue compiler already available locally" + echo "TypeScript and Vue compiler already available globally" fi - name: Run Claude Documentation Review diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 960f693f06..0000000000 --- a/.npmrc +++ /dev/null @@ -1,3 +0,0 @@ -ignore-workspace-root-check=true -catalog-mode=prefer -public-hoist-pattern[]=@parcel/watcher diff --git a/.oxlintrc.json b/.oxlintrc.json index 19700f80c2..66142ab5ca 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -85,6 +85,15 @@ "typescript/no-unused-vars": "off", "unicorn/no-empty-file": "off", "vitest/require-mock-type-parameters": "off", + "vitest/consistent-each-for": [ + "error", + { + "test": "for", + "it": "for", + "describe": "for", + "suite": "for" + } + ], "unicorn/no-new-array": "off", "unicorn/no-single-promise-in-promise-methods": "off", "unicorn/no-useless-fallback-in-spread": "off", diff --git a/apps/desktop-ui/src/components/maintenance/TaskListItem.vue b/apps/desktop-ui/src/components/maintenance/TaskListItem.vue index 0762bbd057..6f893d0496 100644 --- a/apps/desktop-ui/src/components/maintenance/TaskListItem.vue +++ b/apps/desktop-ui/src/components/maintenance/TaskListItem.vue @@ -1,12 +1,12 @@ <template> <tr - class="border-neutral-700 border-solid border-y" + class="border-y border-solid border-neutral-700" :class="{ 'opacity-50': runner.resolved, 'opacity-75': isLoading && runner.resolved }" > - <td class="text-center w-16"> + <td class="w-16 text-center"> <TaskListStatusIcon :state="runner.state" :loading="isLoading" /> </td> <td> @@ -14,7 +14,7 @@ {{ task.name }} </p> <Button - class="inline-block mx-2" + class="mx-2 inline-block" type="button" :icon="PrimeIcons.INFO_CIRCLE" severity="secondary" @@ -22,11 +22,11 @@ @click="toggle" /> - <Popover ref="infoPopover" class="block m-1 max-w-64 min-w-32"> + <Popover ref="infoPopover" class="m-1 block max-w-64 min-w-32"> <span class="whitespace-pre-line">{{ task.description }}</span> </Popover> </td> - <td class="text-right px-4"> + <td class="px-4 text-right"> <Button :icon="task.button?.icon" :label="task.button?.text" diff --git a/apps/desktop-ui/src/components/maintenance/TaskListPanel.vue b/apps/desktop-ui/src/components/maintenance/TaskListPanel.vue index 5021aa0a15..f667cacb11 100644 --- a/apps/desktop-ui/src/components/maintenance/TaskListPanel.vue +++ b/apps/desktop-ui/src/components/maintenance/TaskListPanel.vue @@ -76,7 +76,7 @@ const executeTask = async (task: MaintenanceTask) => { message = t('maintenance.error.taskFailed') } catch (error) { - message = (error as Error)?.message + message = error instanceof Error ? error.message : undefined } toast.add({ diff --git a/apps/desktop-ui/src/i18n.ts b/apps/desktop-ui/src/i18n.ts index 52db23e091..fa3367286d 100644 --- a/apps/desktop-ui/src/i18n.ts +++ b/apps/desktop-ui/src/i18n.ts @@ -9,6 +9,7 @@ import en from '@frontend-locales/en/main.json' with { type: 'json' } import enNodes from '@frontend-locales/en/nodeDefs.json' with { type: 'json' } import enSettings from '@frontend-locales/en/settings.json' with { type: 'json' } +import { getDefaultLocale } from '@frontend-locales/localeConfig' import { createI18n } from 'vue-i18n' function buildLocale< @@ -167,7 +168,7 @@ const messages: Record<string, LocaleMessages> = { export const i18n = createI18n({ // Must set `false`, as Vue I18n Legacy API is for Vue 2 legacy: false, - locale: navigator.language.split('-')[0] || 'en', + locale: getDefaultLocale(), fallbackLocale: 'en', messages, // Ignore warnings for locale options as each option is in its own language. diff --git a/apps/desktop-ui/src/stores/maintenanceTaskStore.ts b/apps/desktop-ui/src/stores/maintenanceTaskStore.ts index a371327ed5..235d370ccf 100644 --- a/apps/desktop-ui/src/stores/maintenanceTaskStore.ts +++ b/apps/desktop-ui/src/stores/maintenanceTaskStore.ts @@ -66,7 +66,7 @@ class MaintenanceTaskRunner { this.error = undefined return true } catch (error) { - this.error = (error as Error)?.message + this.error = error instanceof Error ? error.message : String(error) throw error } finally { this.executing = false diff --git a/apps/website/README.md b/apps/website/README.md index 047940ed02..1b2cc9033d 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -113,6 +113,50 @@ 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: @@ -146,3 +190,4 @@ renders the documented embed container. - `pnpm test:unit` — Vitest unit tests - `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first) - `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot +- `pnpm cloud-nodes:refresh-snapshot` — refresh the committed cloud nodes snapshot diff --git a/apps/website/astro.config.ts b/apps/website/astro.config.ts index e57aef5cd6..4a10cf5bdd 100644 --- a/apps/website/astro.config.ts +++ b/apps/website/astro.config.ts @@ -3,6 +3,23 @@ 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', @@ -11,13 +28,19 @@ export default defineConfig({ '/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping': '/customers/moment-factory/', '/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui': - '/customers/series-entertainment/' + '/customers/series-entertainment/', + '/zh-CN/terms-of-service': '/terms-of-service' }, build: { assets: '_website' }, devToolbar: { enabled: !process.env.NO_TOOLBAR }, - integrations: [vue(), sitemap()], + integrations: [ + vue(), + sitemap({ + filter: (page) => !isExcludedFromSitemap(page) + }) + ], vite: { plugins: [tailwindcss()], server: { @@ -27,8 +50,8 @@ export default defineConfig({ } }, i18n: { - locales: ['en', 'zh-CN'], - defaultLocale: 'en', + locales: [...LOCALES], + defaultLocale: DEFAULT_LOCALE, routing: { prefixDefaultLocale: false } diff --git a/apps/website/e2e/careers.spec.ts b/apps/website/e2e/careers.spec.ts index 6f97c99f86..22263c1bfc 100644 --- a/apps/website/e2e/careers.spec.ts +++ b/apps/website/e2e/careers.spec.ts @@ -23,26 +23,50 @@ test.describe('Careers page @smoke', () => { expect(await roles.count()).toBeGreaterThan(0) }) - test('each role links to jobs.ashbyhq.com', async ({ page }) => { + test('clicking a department button scrolls to and activates that section', async ({ + page + }) => { + const rolesSection = page.getByTestId('careers-roles') + await rolesSection.scrollIntoViewIfNeeded() + await expect(rolesSection).toBeVisible() + + const allCount = await page.getByTestId('careers-role-link').count() + + const engineeringButton = page.getByRole('button', { + name: 'ENGINEERING', + exact: true + }) + + // RolesSection is hydrated via `client:visible`. Once the button responds + // to a click by flipping aria-pressed, Vue is hydrated and the rest of + // the locator logic is in effect. + await expect(async () => { + await engineeringButton.click() + await expect(engineeringButton).toHaveAttribute('aria-pressed', 'true', { + timeout: 1_000 + }) + }).toPass({ timeout: 10_000 }) + + const engineeringSection = page.locator('#careers-dept-engineering') + await expect(engineeringSection).toBeInViewport() + + expect(await page.getByTestId('careers-role-link').count()).toBe(allCount) + }) +}) + +test.describe('Careers page role links', () => { + test('each role links to the Ashby job description page, not the application form', async ({ + page + }) => { + await page.goto('/careers') const roles = page.getByTestId('careers-role-link') const count = await roles.count() for (let i = 0; i < count; i++) { const href = await roles.nth(i).getAttribute('href') expect(href).toMatch(/^https:\/\/jobs\.ashbyhq\.com\//) + expect(href).not.toMatch(/\/application\/?$/) } }) - - test('ENGINEERING category filter narrows the role list', async ({ - page - }) => { - const allCount = await page.getByTestId('careers-role-link').count() - await page.getByRole('button', { name: 'ENGINEERING', exact: true }).click() - const engineeringLocator = page.getByTestId('careers-role-link') - await expect(engineeringLocator.first()).toBeVisible() - const engineeringCount = await engineeringLocator.count() - expect(engineeringCount).toBeLessThanOrEqual(allCount) - expect(engineeringCount).toBeGreaterThan(0) - }) }) test.describe('Careers page (zh-CN) @smoke', () => { diff --git a/apps/website/e2e/cloud-nodes.spec.ts b/apps/website/e2e/cloud-nodes.spec.ts new file mode 100644 index 0000000000..9f99bf453b --- /dev/null +++ b/apps/website/e2e/cloud-nodes.spec.ts @@ -0,0 +1,169 @@ +import { expect } from '@playwright/test' + +import { test } from './fixtures/blockExternalMedia' + +test.describe('Cloud nodes page @smoke', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/cloud/supported-nodes') + }) + + test('has correct title', async ({ page }) => { + await expect(page).toHaveTitle( + 'Custom-node packs on Comfy Cloud — supported by default' + ) + }) + + test('renders at least one pack card', async ({ page }) => { + const cards = page.getByTestId('cloud-node-pack-card') + await expect(cards.first()).toBeVisible() + expect(await cards.count()).toBeGreaterThan(0) + }) + + test('search input filters cards down', async ({ page }) => { + const cards = page.getByTestId('cloud-node-pack-card') + const initialCount = await cards.count() + + await page.getByTestId('cloud-nodes-search').fill('impact') + const filteredCards = page.getByTestId('cloud-node-pack-card') + await expect(filteredCards.first()).toBeVisible() + const filteredCount = await filteredCards.count() + + expect(filteredCount).toBeGreaterThan(0) + expect(filteredCount).toBeLessThanOrEqual(initialCount) + }) + + test('renders banner image or fallback element', async ({ page }) => { + const banners = page.getByTestId('cloud-node-pack-banner') + await expect(banners.first()).toBeVisible() + }) + + test('opens pack detail page from first card', async ({ page }) => { + const firstCard = page.getByTestId('cloud-node-pack-card').first() + await expect(firstCard).toBeVisible() + + await firstCard.locator('a').first().click() + + await expect(page).toHaveURL(/\/cloud\/supported-nodes\/[a-z0-9-]+$/) + await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible() + }) + + test('direct pack detail route renders node entries', async ({ page }) => { + await page.goto('/cloud/supported-nodes/comfyui-impact-pack') + await expect(page.getByTestId('cloud-node-pack-detail')).toBeVisible() + await expect( + page.getByTestId('cloud-node-pack-detail-node').first() + ).toBeVisible() + }) + + test('search with no matches shows empty state', async ({ page }) => { + await page + .getByTestId('cloud-nodes-search') + .fill('zzzz-this-pack-does-not-exist') + await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(0) + await expect(page.getByText('No matching packs')).toBeVisible() + }) + + test('clearing search restores the full list', async ({ page }) => { + const cards = page.getByTestId('cloud-node-pack-card') + const initialCount = await cards.count() + + await page.getByTestId('cloud-nodes-search').fill('impact') + await expect(cards.first()).toBeVisible() + + await page.getByTestId('cloud-nodes-search').fill('') + await expect(cards).toHaveCount(initialCount) + }) + + test('search matches against node display names, not just pack names', async ({ + page + }) => { + await page.getByTestId('cloud-nodes-search').fill('FaceDetailer') + await expect(page.getByTestId('cloud-node-pack-card')).toHaveCount(1) + await expect( + page.getByTestId('cloud-node-pack-card-link').first() + ).toContainText('Impact Pack') + }) + + test('switching sort to A → Z reorders cards alphabetically', async ({ + page + }) => { + await page.locator('#cloud-nodes-sort').selectOption('az') + const firstName = await page + .getByTestId('cloud-node-pack-card-link') + .first() + .textContent() + expect(firstName?.trim().toLowerCase().charAt(0)).toMatch(/^[a-c]/) + }) + + test('list grid carries a localized aria-label', async ({ page }) => { + await expect( + page.getByRole('list', { + name: 'Custom-node packs supported on Comfy Cloud' + }) + ).toBeVisible() + }) + + test('clicking the back link returns to the index from a detail page', async ({ + page + }) => { + await page.goto('/cloud/supported-nodes/comfyui-impact-pack') + await page.getByRole('link', { name: 'Back to all packs' }).click() + await expect(page).toHaveURL(/\/cloud\/supported-nodes\/?$/) + await expect(page.getByTestId('cloud-node-pack-card').first()).toBeVisible() + }) + + test('detail page renders publisher and external repo link', async ({ + page + }) => { + await page.goto('/cloud/supported-nodes/comfyui-impact-pack') + const repoLink = page.getByRole('link', { + name: /github\.com\/ltdrdata\/ComfyUI-Impact-Pack/ + }) + await expect(repoLink).toBeVisible() + await expect(repoLink).toHaveAttribute('rel', /noopener/) + }) + + test('unknown pack slug 404s', async ({ page }) => { + const response = await page.goto( + '/cloud/supported-nodes/this-pack-does-not-exist' + ) + expect(response?.status()).toBe(404) + }) + + test('JSON-LD ItemList is emitted on the index page', async ({ page }) => { + const jsonLd = page.locator('script[type="application/ld+json"]') + const ldBlocks = await jsonLd.allTextContents() + expect(ldBlocks.some((b) => b.includes('"@type":"ItemList"'))).toBeTruthy() + }) + + test('JSON-LD payload escapes <-sequences', async ({ page }) => { + const ldBlocks = await page + .locator('script[type="application/ld+json"]') + .allTextContents() + for (const block of ldBlocks) { + expect(block).not.toContain('</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() + }) +}) diff --git a/apps/website/e2e/content-section.spec.ts b/apps/website/e2e/content-section.spec.ts new file mode 100644 index 0000000000..6d1a6d9265 --- /dev/null +++ b/apps/website/e2e/content-section.spec.ts @@ -0,0 +1,61 @@ +import { expect } from '@playwright/test' + +import { test } from './fixtures/blockExternalMedia' + +const M4_PRO_14_INCH_VIEWPORT = { width: 2016, height: 1310 } +const LAST_SECTION_HASH = '#contact' + +test.describe( + 'ContentSection scroll-spy @smoke', + { + annotation: [ + { + type: 'issue', + description: + 'https://linear.app/comfyorg/issue/FE-604/bug-bottom-badge-not-activating-on-scroll-at-high-resolution-3024x1964' + }, + { + type: 'environment', + description: + '14" MacBook M4 Pro logical viewport reported in FE-604; /privacy-policy reproduces because of its short trailing sections' + } + ] + }, + () => { + test.use({ viewport: M4_PRO_14_INCH_VIEWPORT }) + + test('activates the last badge when user scrolls to the bottom', async ({ + page + }) => { + await page.goto('/privacy-policy') + + const sidebarNav = page.getByRole('navigation', { + name: 'Category filter' + }) + const badges = sidebarNav.getByRole('button') + const lastBadge = badges.last() + + await expect(badges.first()).toHaveAttribute('aria-pressed', 'true') + await expect(lastBadge).toHaveAttribute('aria-pressed', 'false') + + await page.evaluate(() => + window.scrollTo(0, document.documentElement.scrollHeight) + ) + + await expect(lastBadge).toHaveAttribute('aria-pressed', 'true') + }) + + test('activates the last badge when page mounts already at the bottom via trailing hash', async ({ + page + }) => { + await page.goto(`/privacy-policy${LAST_SECTION_HASH}`) + + const sidebarNav = page.getByRole('navigation', { + name: 'Category filter' + }) + const lastBadge = sidebarNav.getByRole('button').last() + + await expect(lastBadge).toHaveAttribute('aria-pressed', 'true') + }) + } +) diff --git a/apps/website/e2e/customers.spec.ts b/apps/website/e2e/customers.spec.ts new file mode 100644 index 0000000000..5d073473da --- /dev/null +++ b/apps/website/e2e/customers.spec.ts @@ -0,0 +1,33 @@ +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) + }) +}) diff --git a/apps/website/e2e/demos.spec.ts b/apps/website/e2e/demos.spec.ts new file mode 100644 index 0000000000..bbf333e3e6 --- /dev/null +++ b/apps/website/e2e/demos.spec.ts @@ -0,0 +1,79 @@ +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) + }) +}) diff --git a/apps/website/e2e/download.spec.ts b/apps/website/e2e/download.spec.ts index 1afa483d1f..1e837ac950 100644 --- a/apps/website/e2e/download.spec.ts +++ b/apps/website/e2e/download.spec.ts @@ -46,7 +46,7 @@ test.describe('Download page @smoke', () => { await expect(githubBtn).toBeVisible() await expect(githubBtn).toHaveAttribute( 'href', - 'https://github.com/Comfy-Org/ComfyUI' + 'https://github.com/Comfy-Org/ComfyUI#installing' ) await context.close() diff --git a/apps/website/e2e/homepage.spec.ts b/apps/website/e2e/homepage.spec.ts index 3797cd20ea..111c8ca5ee 100644 --- a/apps/website/e2e/homepage.spec.ts +++ b/apps/website/e2e/homepage.spec.ts @@ -69,6 +69,50 @@ test.describe('Homepage @smoke', () => { ).toBeVisible() }) + test('CaseStudySpotlight CTA sizes to its content, not the column', async ({ + page + }) => { + const contentColumn = page.getByTestId('case-study-content') + const cta = contentColumn.getByRole('link', { + name: /see all case studies/i + }) + + await cta.scrollIntoViewIfNeeded() + await expect(cta).toBeVisible() + + const [columnBox, ctaBox] = await Promise.all([ + contentColumn.boundingBox(), + cta.boundingBox() + ]) + + expect(columnBox).not.toBeNull() + expect(ctaBox).not.toBeNull() + expect(ctaBox!.width).toBeLessThan(columnBox!.width * 0.7) + }) + + test('CaseStudySpotlight CTA has breathing room above it on mobile @mobile', async ({ + page + }) => { + const contentColumn = page.getByTestId('case-study-content') + const subheading = contentColumn.getByText( + /Videos & case studies from teams/i + ) + const cta = contentColumn.getByRole('link', { + name: /see all case studies/i + }) + + await cta.scrollIntoViewIfNeeded() + + const [subBox, ctaBox] = await Promise.all([ + subheading.boundingBox(), + cta.boundingBox() + ]) + + expect(subBox).not.toBeNull() + expect(ctaBox).not.toBeNull() + expect(ctaBox!.y - (subBox!.y + subBox!.height)).toBeGreaterThanOrEqual(24) + }) + test('BuildWhatSection is visible', async ({ page }) => { // "DOESN'T EXIST" is the actual badge text rendered in the Build What section await expect(page.getByText("DOESN'T EXIST")).toBeVisible() diff --git a/apps/website/e2e/payment.spec.ts b/apps/website/e2e/payment.spec.ts new file mode 100644 index 0000000000..2a2e8f535c --- /dev/null +++ b/apps/website/e2e/payment.spec.ts @@ -0,0 +1,115 @@ +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) + }) +}) diff --git a/apps/website/e2e/pricing.spec.ts b/apps/website/e2e/pricing.spec.ts new file mode 100644 index 0000000000..05520f0e5b --- /dev/null +++ b/apps/website/e2e/pricing.spec.ts @@ -0,0 +1,56 @@ +import { expect } from '@playwright/test' + +import { test } from './fixtures/blockExternalMedia' + +test.describe('Pricing page @smoke', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/cloud/pricing') + }) + + test('shows the three paid tiers and Enterprise', async ({ page }) => { + const pricingGrid = page + .locator('section', { + has: page.getByRole('heading', { name: /Pricing/i }) + }) + .locator('.lg\\:grid') + + for (const label of ['STANDARD', 'CREATOR', 'PRO']) { + await expect( + pricingGrid.locator('span', { hasText: new RegExp(`^${label}$`) }) + ).toBeVisible() + } + + await expect( + page.getByRole('heading', { name: /Looking for Enterprise Solutions/i }) + ).toBeVisible() + }) + + test('does not show the Free tier when SHOW_FREE_TIER is disabled', async ({ + page + }) => { + const pricingGrid = page + .locator('section', { + has: page.getByRole('heading', { name: /Pricing/i }) + }) + .locator('.lg\\:grid') + + await expect( + pricingGrid.locator('span', { hasText: /^FREE$/ }) + ).toHaveCount(0) + await expect(page.getByRole('link', { name: /^START FREE$/ })).toHaveCount( + 0 + ) + await expect(page.getByText(/Everything in Free, plus:/i)).toHaveCount(0) + }) +}) + +test.describe('Cloud pricing teaser @smoke', () => { + test('does not show the "Start free" tagline when SHOW_FREE_TIER is disabled', async ({ + page + }) => { + await page.goto('/cloud') + await expect( + page.getByText(/Start free\.\s*Upgrade when you're ready\./i) + ).toHaveCount(0) + }) +}) diff --git a/apps/website/e2e/responsive.spec.ts b/apps/website/e2e/responsive.spec.ts index c77e8e4ec7..6488170457 100644 --- a/apps/website/e2e/responsive.spec.ts +++ b/apps/website/e2e/responsive.spec.ts @@ -1,3 +1,4 @@ +import type { Page } from '@playwright/test' import { expect } from '@playwright/test' import { test } from './fixtures/blockExternalMedia' @@ -47,4 +48,105 @@ test.describe('Mobile layout @mobile', () => { const mobileContainer = page.getByTestId('social-proof-mobile') await expect(mobileContainer).toBeVisible() }) + + test.describe('SocialProofBar seamless marquee', () => { + test.use({ contextOptions: { reducedMotion: 'no-preference' } }) + + test('mobile forward marquee loops seamlessly', async ({ page }) => { + const geometry = await measureMarqueeLoopGeometry( + page, + '[data-testid="social-proof-mobile"] .animate-marquee' + ) + expectSeamlessForwardLoop(geometry) + }) + + test('mobile reverse marquee loops seamlessly', async ({ page }) => { + const geometry = await measureMarqueeLoopGeometry( + page, + '[data-testid="social-proof-mobile"] .animate-marquee-reverse' + ) + expectSeamlessReverseLoop(geometry) + }) + }) }) + +test.describe('Desktop SocialProofBar @smoke', () => { + test.use({ contextOptions: { reducedMotion: 'no-preference' } }) + + test.beforeEach(async ({ page }) => { + await page.goto('/') + }) + + test('desktop marquee loops seamlessly', async ({ page }) => { + const geometry = await measureMarqueeLoopGeometry( + page, + '[data-testid="social-proof-desktop"] .animate-marquee' + ) + expectSeamlessForwardLoop(geometry) + }) +}) + +type MarqueeGeometry = { + copyWidths: number[] + startPositions: number[] + endPositions: number[] +} + +async function measureMarqueeLoopGeometry( + page: Page, + selector: string +): Promise<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) +} diff --git a/apps/website/e2e/visual-responsive.spec.ts b/apps/website/e2e/visual-responsive.spec.ts index 1954901d85..060e75cfd8 100644 --- a/apps/website/e2e/visual-responsive.spec.ts +++ b/apps/website/e2e/visual-responsive.spec.ts @@ -26,8 +26,8 @@ async function assertNoOverflow(page: Page) { } async function navigateAndSettle(page: Page, url: string) { - await page.goto(url) - await page.waitForLoadState('networkidle') + await page.goto(url, { waitUntil: 'domcontentloaded' }) + await page.waitForLoadState('load') } test.describe('Home', { tag: '@visual' }, () => { @@ -126,6 +126,7 @@ test.describe('Overflow guards', { tag: '@visual' }, () => { const pages = [ '/', '/cloud', + '/cloud/enterprise', '/cloud/pricing', '/contact', '/download', diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-1-sm-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-1-sm-visual-linux.png index bda1554132..bdfa934d5f 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-1-sm-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-1-sm-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-2-md-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-2-md-visual-linux.png index 9f1ffb3b50..33bbea1737 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-2-md-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-2-md-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-3-lg-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-3-lg-visual-linux.png index 636ff5954f..5ce1c80434 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-3-lg-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-3-lg-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-4-xl-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-4-xl-visual-linux.png index 68e1cf8bcd..695a4684aa 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-4-xl-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-get-started-4-xl-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-3-lg-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-3-lg-visual-linux.png index 1a0b83ef45..780cc7b555 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-3-lg-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-3-lg-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-4-xl-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-4-xl-visual-linux.png index 35f66b2859..0290e4e062 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-4-xl-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/home-product-cards-4-xl-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-1-sm-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-1-sm-visual-linux.png index 20d8be7838..13f924097d 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-1-sm-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-1-sm-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-2-md-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-2-md-visual-linux.png index 718fb32ef7..70b26c7ca3 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-2-md-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-2-md-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-3-lg-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-3-lg-visual-linux.png index 4017ab1aba..d5129a7fb1 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-3-lg-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-3-lg-visual-linux.png differ diff --git a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-4-xl-visual-linux.png b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-4-xl-visual-linux.png index fff724b671..d6f83d0fa2 100644 Binary files a/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-4-xl-visual-linux.png and b/apps/website/e2e/visual-responsive.spec.ts-snapshots/pricing-tiers-4-xl-visual-linux.png differ diff --git a/apps/website/package.json b/apps/website/package.json index 5a4aa57c3b..8a34eca2cc 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -15,11 +15,15 @@ "test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test", "test:visual": "playwright test --project visual", "test:visual:update": "playwright test --project visual --update-snapshots", - "ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts" + "ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts", + "cloud-nodes:refresh-snapshot": "tsx ./scripts/refresh-cloud-nodes-snapshot.ts", + "generate:models": "tsx ./scripts/generate-models.ts" }, "dependencies": { "@astrojs/sitemap": "catalog:", "@comfyorg/design-system": "workspace:*", + "@comfyorg/object-info-parser": "workspace:*", + "@comfyorg/shared-frontend-utils": "workspace:*", "@comfyorg/tailwind-utils": "workspace:*", "@vercel/analytics": "catalog:", "@vueuse/core": "catalog:", @@ -27,6 +31,7 @@ "gsap": "catalog:", "lenis": "catalog:", "posthog-js": "catalog:", + "three": "catalog:", "vue": "catalog:", "zod": "catalog:" }, diff --git a/apps/website/playwright.config.ts b/apps/website/playwright.config.ts index af51992ee8..b6100286bc 100644 --- a/apps/website/playwright.config.ts +++ b/apps/website/playwright.config.ts @@ -28,7 +28,7 @@ export default defineConfig({ ? [['html'], ['json', { outputFile: 'results.json' }]] : 'html', expect: { - toHaveScreenshot: { maxDiffPixels: 50 } + toHaveScreenshot: { maxDiffPixels: 100 } }, ...maybeLocalOptions, webServer: { diff --git a/apps/website/public/assets/images/fallback-gradient-avatar.svg b/apps/website/public/assets/images/fallback-gradient-avatar.svg new file mode 100644 index 0000000000..90b860f619 --- /dev/null +++ b/apps/website/public/assets/images/fallback-gradient-avatar.svg @@ -0,0 +1,13 @@ +<svg width="87" height="87" viewBox="0 0 87 87" fill="none" xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink"> + <rect x="0.271484" y="0.547852" width="86" height="86" rx="16" fill="#1C64F2" /> + <rect x="0.271484" y="0.547852" width="86" height="86" rx="16" fill="url(#pattern0_457_15608)" /> + <defs> + <pattern id="pattern0_457_15608" patternContentUnits="objectBoundingBox" width="1" height="1"> + <use xlink:href="#image0_457_15608" + transform="matrix(0.000983706 0 0 0.000755211 -0.472672 0)" /> + </pattern> + <image id="image0_457_15608" width="2000" height="2000" + xlink:href="data:image/jpeg;base64,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AWACQJAkEiRIykSZZkkyzJmTMzJmZzMzMznMzM5zM5zMTMznOZnMxMzOZmZmcyZyzJmSRlkSALS00taNLWlrS21battW2ratq1aqr7MANW71re9b3vW9a3daoAAAEgkCQSSEmTMTLMkmWZM5ZmZmTOczMzmZzM5zM5zM5zmZzmZzmYmZnOWczEkzJmZZZZSGQC0taNLVrS1bbataW2rbVtq1atWrTXsgEW6ut63re973retauqAACwBIJAkJGSSRmJlMySTMzJMzOZM5mczOZnOZmYmZjMzmZznOZmYmZnOZMTMyzmTMkmWSRkBaNGjS1battaW2rbVtrS1batWrVVb7IBKt1rW971ve963rWrqgAAABIJBIJJEkkSSTMkmZMzMkzmZmZmZzM5zM5zM5mJnOZnOc5mc5zJiZzJnMzMyZkyzJlLlCFUWrVrS1ba0ttW2rbWltq1atqrWhb7IALda1vW973vet61rVoAAAAEhIGRJIkkSSZZkkzJmZkzM5kznMzM5zmZzM5znOZnMxM5zM5zM5zMzOZmZmZMzLLMkuREsqmjRbattW2tLbVtrS21bVtWrVq00eyABrWta3ve973retbtoAAAABkSCQkZMspJMsyTMmZmTMzM5kznMzmYmZnOczEznMxmZzMTOZnOZmZzmZZzJmSTLKQRYoaNLWlq21pbattaWtLbVq21atVVV7IAGrret73vet73rd1bYAAAAAkJAySJGWUkmZJmSZkzMzMzmZmczOczOZnOc5znMznOZiZmJnOZnOZM5mZmZmZJJJEgJQtWrWltq21pa00ttW2rbVq1atWlp7IAGrretb6b3ve9b1dqAAAAACQkGRlkyymUyzmSTMzMyZzmTOc5kxM5zMTOZjMxM5zMTOZiZmc5mczMzMyZmZGWRkANGjS1pbatttW22rbVtq21atWrS0vsQAXV3re973vfTW9a1QAAAAFgSEgkMsmWYmWZMyTMzMzMzM5mZnOZnOczOc5zMTEznOczOc5zM5zMzOczLOZMyZSMmQCqtWtLWltrTS1pbataW2rVtWqtVVexABbret73ve973reroAAAAAAZJBIZZMyJlmTMkzMzMmczOZmZzmZznOZnOczExmZznOZnEzmZznMmc5kzmTMmUiRkA0NGlrS21bbatttW2tLVtq1atWlpp7AADWtb10103ve971q6AAAAAACQZJDLKSRmSTMkzMzMzJnOZmZznMznOZiZznOc5mc5znMznEzM5zM5mcyZmcsyMpBkKU0taWtLbWltrS21pbatW2rVqqtKvsABK1rWt73vpve971rVAAAAAABISEhllJJIzlmZkmczMzMzOczOczOc5zMTEznOc5mJiZznOZnOZmYmZmTMzJMxJBmpVFq21pa00ttaW2tLbVtq1atWrVVVewAA1db3rpvfTe971toAAAAABYGSQkMspMpJMyZkmczOZM5mZnOc5mc5zMTEznOc5znOczOc5mc5zmTOcyZzJlmTJIGdSi000taaWtLbWltrS21batWrVpoaHsAALd63ve+nTe971u2wAAAAAADJISGWUmUmWZmTMzM5YmZnMznOZnOc5mJjMznOc5znOZiYmZnOZnOcyTEmWWWSQAVVrS221pbbattaWtNLVtq1aqqqqewAAt1vXTe+m99N71u2wAAAAAABkkJDMiTLLMzJmSZzMzOczMznOczEznOc5zMZzmZznOc5znMznMzmZzMzMzJJJIyAFq1pa00ttaW22rbWltq1bVWtFVVL68ABrWt73030303rpdUAAAAAAAJEhIkkkkZkyzmTMzM5mczOZnOczOcTOc5zmYznMziYmc5zmZznMznMyxJM5MshkC0ttW22rbbWmltrS21batWrVpoaFvrgAGrre99Om+m99N3YAAAAAAAEiQkSSMySSZmZMzOZmZmJmZznOZiZznOc5znOc5znOZnExM5zmZmc5kzlnMjLIhCqWtLWlttrS21ppbattW2rVqqtKpp64ABda103vp030301rYAAAAAABYMkhkkykymZMyZkzmZmc5mc5mc5mM5zM5xM5znOc5znOc5zmZznMzmYkmZmTLKIM2VSratttaW22tLbbVtrS1otWtFVVKvrgADWt730306b6b3vQAAAAAAAsGSQyZZSZkkzJmZmczMzMTOZnOc5znOZjOc5znMxMZzM5znOc5mZnOZmZmZJJDJLKFVba0ttttaW22rbbWlq1atWlpaVXrgADV3vfTpvp06b3rYAAAAAAAWDIyZMySSSTMmczMzM5mczOczGZnOc5znOc5znOc5znOc5zmZznOZnOZlnOWWUkIAWrVttrS22221bbattaWrVq0tLRVeuAALrW+m+nTp0303rVAAAAAAACwZMkiTKTKTMzMyZmc5mc5mczExM5znOc5znOcTOc5xM5znOZjMmJnOWcySSSEgC1VttaW22tNLbWlttq1atqrVVVKp64AAa1vpvp06dOm+mtUAAAAAAAAMpDJJlJlmTOZmTOZnMmJiZznOZiYzMZznMxnMxnOczExM5zmZznMzMsyMyEIUpa00ttaaW22tLbbWlq1atWlqqVR64AAa1ve+nTrvp03vVAAAAAAAACRIZMyMySZmZmTOZnMznOczOc5mM5zMZznOc5znOc5znOc5mMzOczMzmTMkhkQFLVtrTS221ppbbattW2rVq00NC0HrgABdb3vp06denTe7oAAAAAAAAEhkykykyzJmZzJnMzmZznMznOc5znOc5znOc5xmYznMxMTOc5zM5mc5ZkyZkABaaW2222tNLbWltrS1bVq1VpVKovrQAA1ve+nTp16dN9NUAAAAAAAADJkkSZZkkmZnMmc5mczOc5znOZnOcZmM5mM5znOc5znOc5znOZmZzmZkmZGSQBVLbbWmltttrS221batWrWiqqlUNesAAC73vp06denTpvdAAAAAAAAAZSGTMkmWZMzOWJmZzmZznOc5zMZzmYznOc5xmZxnOZjOZnOczOc5kzJJlIhAqlrS22221pbbbattW2rVq0tVSqGp60AALre+nTp16dd73oAAAAAAAAWEiRIkymZJM5mZmZzMSYmJnOc5znOc5zjMxnOc5znOc5znOc5zM5mZnMkmTJBKUtW22tNNLbbWlttq2ratWlpoVQqeuAABre99enXp16b3oAAAAAAAAAykMmZJMsyZmcyZznMznOczOcTOc4znOc5ziZznGc5mM5mc5zmYmZmTMiSIAFq1pbpbbba0tttW2rVtWqtLRVFTXrAABLddN9OvTr16dN2gAAAAAAAAGUhlJlMyTMzMzMznOZnOc5mM5zMZznOcTOJjOc5xM5ziZznOZnMzJmSTJkAVVq2221ppbbba0taWrVrRVVSqGstesAAA3rp069OvXp03qgAAAAAAAAEjJIzIzlmTMzmZmJnMxMTOc5znOcTGc5znOMzOM5zMZzmYzMzOczLMkSIgKq1a000tttttaW2rbVq1aqqpVDUmnrAAALvfTr169enXeqAAAAAAAAAMpDKTKZkmZmZzM5mJmYzmZzjMxnOc5ziZxnOc4mJnExM5zmZzmZkzGUghKpVtW22222tNLbbVq21atLS0qhUtesAAAb306devXr06boAAAAAAAAAkSJGZJMszMzmZmc5mc5znOc5znOc5zjOc5ziYmM4mc5xM5zmZzMzMkkiQAKrS21ppppbbbattW1bVpaaFUKlp6wAABre+vXr169d9KAAAAAAAABYZSGUmZJMs5mZmZxM5mJiZznOc5xmYziYznOc5ziYzMZmMzOcyZkmYkSAKpba00ttttttW2tLVq1atLRoFTQesAAAXXTp169evXrvQAAAAAAAAAMpEjMkmZMzMzM5zMTOc5zmYznOc4mM5zMYznOc5znOcTOc5zmZmZMpJBEKotW222222tLbbVtq1atVVU0C3LQesAAANb6devXt169NAAAAAAAAAAkkMpMsyZZzmZmc5zmZznOc5znOcZznOJnGc5znGc5znOczGZnMzJlmRIQFFq221q1ppbba0tq2rVqqqlUNZWh6wAAAu99evbr269NgAAAAAAAAAZSJGZJmSZzMzMziZzMTEzjOZjOJnGc5znGc5xMTGc5mMzOczLOWUiQAWltrTTS22221battWrS0tKoak0B6wAABLrfXr17duvXdAAAAAAAAABJIZTMZzJM5mZnOZiZznMxnEznOM5zjMxnOMzGc5znGZnOc5mZmSMxEQFotaW222222tLbVq21VWlpVCpaB6wAAAXe+vbt27delAAAAAAAAAAyZZSZZkmZmZzM5zmYmM5mMzGc5znGc5zjOc5xMTGc4mc5mcyZkmYiIBSqtttttaaW22tLVq2qtLSqFS0B6wAAANdOnXt37dumgAAAAAAAAAMpDLMkmZMzOZnMxMTOc5znOc5xnOc5xmYzjOc5xM4mMzEzmTMmZJEQAWltttrTS2221batWrVpaNAqWgHrAAAAuunbt279uuwAAAAAAAAAJGTKTMmZJnMzM5znMzjOZjOc5znGc5ziYznExnOc5ziYmc5mZlllISApVq1pbbbbba0ttWrVqqqmgVLQB6wAAAGt9e3ft369NQAAAAAAAAAGUiRmSZmZM5zMzExM5znOc5xMZznOcZznGc5zjOc5ziZxJmZmZJIkICi1attttttaW2rbVq1VVSqKmgAesAAACXfTt379u/TQAAAAAAAAAEjJlmSTMmczOZnOc5mcZzmYznExnOcZznOMzGc4mM5znMzmTMyykSACqtW2222tLbbVtq1aqqpVFuWgAesAAABNdOvbv9HbtsAAAAAAAAAWGTLKTLOWZnMzmZznEznOc5ziYzjMxnExnOM5znOcZmJnOZmTMkkREBVLVttrTS221pattWlqqVQ1loAD1gAAADXXt3+jv26UAAAAAAAAACSRIzlmTMzM5zM5zmZxnOc5znOM5ziYznExnOM4mc5znMzmTOWUkQgKVVtrTS2221batq1VpaVQ1laAB6wAAAA6du/f6O/XQAAAAAAAAAEjJlmSZmWczOczExM5ziZxnOcTGc5xmYxnOc4zMZznMznMmWZJEgAqrVq222221batW1VpaVQ1JoAA9YAAAAXr37/T377AAAAAAAAAAZMspMyZkzmZmc5zmYznOc5xmYznExjMxnGc5zjOc5mc5zJnLLKQkClLVq22221pbatWrVpoVQ1JaAAesAAAAG+3f6fo+jpQAAAAAAAAAJJEjOWcyTOZiZmJiZxnOc5znGc5zjOc4znExnExM5znOZMsySJBBRVWrbbbbVttq1atVVpVCpaAAPWAAAAC9O30/T9HfQAAAAAAAAAEjJlmSZmZmZzmZznOc5znOM5znGc5zjOc4znExnOc5znMzMyZZSGQCqaLbbWmltq21atWlpVCpaAAHrAAAAA11+j6fq79QAAAAAAAAAGTLKZkzMs5zmZznOZjOc5znExnGc5xnOc4znOM5znOczOZJmSRIQBS1attaaW2rbVq1VWlUKloAAPWAAAAA32+n6/p70AAAAAAAAADKRlJmTMmc5mczExM4mJjOcTGMzGcTGc4znOcZmc5zM5kkkkMgBVWrbWlttq1patVVpVCpaAAB6wAAAADr9H1fX9HQAAAAAAAAACRlIzlnLOZnMznOc5znOJjOc5zjOcTGcZxnOc5znGZmZzJmSRIhApVVbattttW2rVqqqrQVLQAAHrAAAAAL2+n7Pq70AAAAAAAAADLJlmTMmZnMxM5zmYznOc5xnOM5znGc5xnGc5znOczOczMkkkSCChatWrbbWltq1aqqq0FS0AAA9YAAAABN9/s+z6ukAAAAAAAAABkyyzJMzMzmZzmYmJjOZjOMzGcZznGc4mM5zjOczOcyZkyykRAFUtWrbbWlrS1aqqq0FS0AAAesAAAAAN/V9n2/QAAAAAAAAABlIymYxJM5znMznOc5xMZzjMxnExnG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Z1nGMYxjGMYxjGMYxjGM4xnGMZznlnGcYxjOo5xjGMYxjGMYxjGMYxjGMYxjPDGM61jGJGMYxjGMYxjGMYxjGMYxjGMYzxzjOqTnGJGJGMYxjGMYxjGMYxjGMZxjPbnToicyJGJGJGMYkYxjGMYxjGMYzjGeedVnEREiRIkSJGJEjGMYxjGMYxjGMYxnGM9WdRiIkRIkSJGJEiRIkYxjGMYxjGMYzjGMYzrE4iIkRIkSJEiRIkSJGMYxjGMYxjGMYzjPDGdWiIiJESJEiRIkSJGMYxjGMYxjGMYxjOc6zERERIkSJEiRIkSJEjGMYxjGMYxjGc4zol+tEREREiRIkSJEiRIkYxjGMYxjGMYzjOc6zEREREiRIkSJEiRIkSJEjGMYxjGMYxjOh3m/ViIiIiIiJEiRIkSJEiRIkYxjGMYzjOM8M/fvF+vERERERIiRIkSJEiRIkSMYxjGMYxjGcZ0SrxfpxERERERIkRIkSJEiRIkYxjGMYzjOc6FfCrxV+jERERERERESJEiREiRiRjGcZxnSrxV+jOIiIiIiIiIkSJEiJEiRIzjGdMvF5v0YiIiIiIiIiRESIkRE6bfC81VVffERERERERERERESJE6hfCqvF98RERERERERERERETjOm3xqrxfbEREREREREREREiIiInTrzVXmr7oiIiIiIiIiIiIiIiJ1G+F90RERERERERERERERE6dV8LxeKvtiIiIiIiIiIiIiIiInTr43i8Xi+uIiIiIiIiIiIiIiIiJ0++FXwq+uIiIiIiIiIiIiIiIiJ0+8VVXmqvriIiIiIiIiIiIiIiInS74VeKq+FVfVERERERERERERERERE6feKqr4VV9URERERERERERERERE6heKqqvN4vqiIiIiIiIiIiIiIiJ0y+FXiqqrzVX0xERERERERERERERE6bfG81VVeLxfTERERERERERERERETpt8avNVV4qr6IiIiIicIiIiIiIiInTL51eLzV5q+iIiIieIRERERERE6ffC8Xi8VVVeL6IiIieYERERERETqF5qrzVVVXi+cRERPSARERERE6VfO81VVeKqqqr5xERPUAIiIiInVLzVVeKqqq+cRE9QAiIiIidPq81V4qqqqqqqr5xET1ACIiIiJ1Oqqqqqqqqqq+URE9gAIiIidRvjVVVVVVVVV8oiJ6wAiIiInUb4VVVVVVVVVV8oiewAEREROqVV4qqqqvFVVXyiJ7AARERE6heavFVVVVVVXiqvlE9oAERETqVVVVVVVVVVVVVXyie0ACIidSq8VVVVVVVVVVVV8YiewAERETqdVVVVVVVVVVVVXxiJ7A4RETqtVVVVVVVVVVVV8YieweERE6lVXiqqqqqqqqqqqvjE9oeEROoXwqrxVVVVVVVVVVV8YntHwiJ1GqqqqrxVVVVVVVVVXxiewfEROp3iqqqqqqqqqqqqqvjE9h+IidTq8VVVVVVVVVVVVVfGe0/iJ1GrzVVVVVVVVVVVVV4vjPafxE6neKqqqqqqqqqqqqvF8Z7D/ROm3wvNVVVVVVVVVVVVVVVfGew/kTqN4qqqqqqqqqqqqqqqr5T2H+idRqrxVVVVVVVVVVVVVVV8Z7DmROn1VVVVVVVVVVVVVVVVfRPYYydPqqqqqqqqqqqqqqqqvFVfKexh06jVVVVVVVVVVVVVVVX0z2MEnTqqqqqqqqqqqqqqqqqqr6J62CidOqqqqqqqqqqqqqqqqqr9Rgp068VVVVVVVVVVVVVVVVX1z1YKdOqqqqqqqqqqqqqqqqq+yepJTp1VVVVVVVVVVVVVVVVX2T1LKdOqqqqqqqqqqqqqqqqqr656l1Om1VVVVVVVVVVVVVVVVfoNefE6bVVVVVVVVVVVVVVVVVX3tL8uTptVVVVVVVVVVVVVVVV9840tXUUqqqqqqqqqqqqqqqqv0JxpalfHT5eKqqqqqqqqqqqqqqq/RpS1K6g6qqqqqqqqqqqqqqq/Si1q+FfCundVVVVVVVVVVVVVVVV+na1fC/hXwvjOl1VVVVVVVVVVVVVVV+pSnwL+F/CuE6VVVVVVVVVVVVVVqqq/ViuD4uKuE6VVVVVVVVWqqqqqqqq/Wvi+J8S/hfS1VVVVVaq1VVVqqqqq/Xl5r4HwK6SqqqqtVatVVq1VWqqqqqv2L4PhfC+LpOqqqtVatWrVq1aq1VVVXi/ZrmfjfAvpNWrVq1atWrVq1atWrVq1VVV+4fG+J8aukVVq1atWrVq1atWrVq1Vqqq837MucPxPgfA+HR7VWrVq1atWrVq1atWrVq1VX72D4HxvgV/Oia1Vq1atWrVq1atWrVq1atWqtVfv4PjfC2k/Tn0Na1atWrVq1atWrVq1atWrVq1V/BrK+N8TaRPj9CfQ1rWrVq1atWrVq1atWrVq1atWrVVfwc6+P+tcaT9eenWtWrVq1atWrVq1atWrVq1atWrVq/h5WPh/rFP15561rWrVq1atWrVq1atWrVq1atWrV/Flys7P60z+sD9zWta1atWrVq1atWrVq1atWrVq1at/HrbOz+pQ/v7eta1q1atWrVq1atWrVq1atWrVq1b+TaD4mozv7Ota1rVq1atWrVq1atWrVq1atWrVv5c3/UnN/X1rWta1atWrVq1atWrVq1atWrVq1q1v5cCt/iEL+rrWtata1atWrVq1atWrVq1atWrVq1v51ar+kxTZf0ta1rWtatWrVq1atWrVq1atWrVq1atWt/OlsuxpJ/5ql39DWta1rWtWrVq1atWrVq1atWrVq1ata3jfzqNZYsB/Uu/m61rWta1rVq1a1atWrVq1atWrVq1ata1v6M/RAqQjZ+XrWta1rWtWta1atWrVq1atWrVq1rWt/Ukf6nJaify/ka1rWta1rWtWtWtatatWrVrWtWrWta39WK/rST+ovyl38XWta1rWtbw1q1rWta1rWta1rWta39aXFLn9fF/UMf1P8FrWta1rfYADWta1rWta1rWt/YrSmP6+L+tM/qFQv3da1rW+8AA1rWta1rWta1rf2tCs/wCvi/r4v6/2bp9g1rWtb9MAADWta1rWta1v7suMXxf1/u/2f6cjOLffrWtb9cAAANa1rWta1rWt/elxk/0f6v8Ad/pzkYTxxrWta1rfugAAABrWta1rWtb0HcZcf+z/AEf6P9uUhGfjD8//AMABrWta1rWtb0THnf8AXx9YQjPw8H//AIAGta1rWtb0iY9B3pCEJ+D4D/8AADWta1rWtb0qecQCEIz7/wAD/wAANa1rWta3psqeMFjGMZ9zwH/ANa1rWta3qMqMYQhGEYz7MB/wDWta1rW9V1CMYQhOCfWD/gDWta1reta3knHPp/8AwDWta1rWt/T/AP/EACIQAAIDAQACAgMBAQAAAAAAAAERABAgMEBQMUEhUWCBcP/aAAgBAQABPxD+EXH5nx7Z7/2nT6/7PxPxHTjjjjjjjjjjpxz8V/s/23xcc+fWfH8ivUfHjPg+Lv8AEcccfluOOfi3wcf9yo9uLT8748dx8fxHH4Djy44+7jjn+0+D834/mFH2+dfPpPjwnHHxefxPxbjjjjjjjjjjjpz8T8ZfJz8cXPn0Px/I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RWqVKl/w904444DAYDAY4DHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH1fhHx1FS4u/8AP+GuOAxwGAwGAxxxwGOOOOOOOOOOOOOOOOOOnHHTjjjjj0cGOHBhwcGz5iii4q3H/wAKdOOOOOOOAxxxxxxxxxxxxxxxxxxxxxxxxxxxx044+B0cGGz4J89f8MduOAwGOOAxxxwGOOOOOOOOOOOOOOOOOOOOOOOOOOOOnyNnB9GtL/i7w4DHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHTjjhMej4x9Aqej/AMOdunHHTjjjjjjjjjjjjjjjjjjjjjjjjpx06dP0Z8g7Ox/wl26ccccccccccccccccccccccccccccccdO3RyT1NHB5La8c7H/C3h06cccccccccccccccccccccccccdunT2aPsTgUf+KPDtxxxxxx04446ccccccccdvL9i+n7sY/eD/xB5duOOOOOOOOOOO3HHHHTw7ftRDzH/GHlx28OOOO3Tj4O37EYGPvJsf8aenHhxx6cceHHbt+wGBk6MH8R//EACIRAQEBAAICAgMBAQEAAAAAAAEAERAgMEBQYSExQWBRcf/aAAgBAgEBPxD4Xemy2y222222222222yy22y2y2222222222222222222222222222222222222222www22222wwww2wwwxDyfC7yvC28LLLLLLLLLbLLbLLbLbbbbLbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbDbbDbbbDDDDDDDDDDDEMMPA8D7z4F4WWWWWW2WWWWWWWWW2WWW22W22222222222222222222222222222222222G22222GGGG22222G3gYYeSHgY+G2WW22WWWWWWWWW2WWWWWWW2222W222222222222222222222222222222222222GG22GGGGGGGGGGGGGGGHk4PhWWWWWWW2WWWWWWWWWWWWWWWW222222222222222222222222222222222222222G2GG2G2G2222G2GGGGGHg4GGH4PbZZZZZZZZZZZZZZZZZZZZZZZZbZbbbZbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbYYYYYYYYYYYYYYYeBhhiGGH3XqsvCyyyyyyyyyyyyyyyyyyyyyyy222222y2222222222222222222222222222222222w22wwwwwwwwwwwwww8DEQwx7i9VllllllllllllllllllllllttltlttttlttttttttttttttttttttttttttttttttthtthhhhhhhhhhhhhhhhhhhhiGIfZeN6rLLLLLLLLLLLLLLLLLLLLLLbbLLLLLbLbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbDbbbbbbbbbDDDDDDDDDDDDDDDHsPDy8LLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLbLbbLbbbbbbbbbbbbbbbbbbbbbbbbbbbDbbbbDDDDDDDDDDDDDDDDDDDEMMQxD7jwyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy22222222222222222222222222222222222w22w22222w2wwwwwwwwwwwwwwwxDDHtMszLLLLLLLLLLLLLLLLLLLLLLLLLLbLbLLbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbDbbbbbbDDDDDDDDDDDDDDDDDDDDEMMRD7TMyyyyyyyyyyyyyyyyyyyyyyyyyyyyy22y22222222222222222222222222222222222w2ww22ww2wwwwwwwwwwwwwwwxDEMR6rw8rMyyyyyyyyyyyyyyyyyyyyyyyyyyyyy2y2y2yy22222222222222222222222222222222ww22wwwwwwwwwwwwwwwwwwwxDEMQ+s8rwssssssssssssssssssssssssssssssssttttstttttttttttttttttttttttttttttttsMNsMMNtsMMMMMMMMMMMMMMMMQxDEPsMyyyyyyyyyyyyyyyyyyyyyyyyyyy2yyy22y2222222222222222222222222222222222222www222wxDDDDDDDDDDDEMQxDEMR6byzLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLbLbbbbbbbLbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbDbbbDDDDDDDDDDDDEMMMMMQxDEMR6jwzLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbDDDDDDDDDDDDDDEMQwwxDEMMRHrLMssssssssssssssssssssssssssssssssstttttttttttttttttttttttttttttttvG2228bbbbbbDbbbDDEMMMMQwwwwxDEREMei8szLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLbbLbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbDbbbxttsMMMMMMQ8EMMQwxDDERERHpMzMyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy2y2y222222222222222228bxttttvG2228bxtsNttsMMMMMMMQwwwwwxDEQxERERHoPLMyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy22222y22222222222222222229Nt43jbbeN4223geB4GGGGGGGIhiIYhiIiIiPO8MzMsssssssssssssssssssssssssssssstttstttttttttttttttttttttttttttvO2228bDbbxtvIww8DDDEMMRDERERDERERHnZ4Zllllllllllllllllllllllllllllllltlttttttttttttttttttttttttttt4223jbeN43jedt6jwMMMRDEQxEQxERERER5nhmZlllllllllllllllllllllllllllllltltltttltttttttttttttttt52223nbeNt43jeN53qPAxDDEQxDDEREREREREW28b5GZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZbbbbbbZbbbbbbbbbbbbbbbbbeNttttt53neN43jed6jEMMMREMQxEQxERERERHTLLLLLLLLOjMyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy22222222222222228bbbxvfbbedt43jeN43qdCOCIhiIiIiIhiIiIiLen54yyyyzoyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy22y22222222222222229Ntt7bbztvG8bxvA9SIeDgiIhiIhiIiIiIiIiPI8rMsssssssssssssssssssssssssssssssssttttttttttttttttvG+Pbedt43jeN7HUiIiGIiIiIiIiIiIiPMzMzLLLLLLLLLLLLLLLLLLLLLLLLLLLLbbbbbbbbbbbbbbbxtvTbbbbbbbed67zvG8b4CIiIjgiGIiIiIiIiIiI5yyzrvRmZlllllllllllLLLLLLLLLLLLLLLLLLLbbbLbbbbbbbbbbbb122223ttvO875TgjgiIiIiGIiIiIiIiI6ZZZZZZyzMzLLLLLLLLLKWWWWWWWWWWWWWWWWWWW2W2WW22W223jedtt6bbbbxvXbem8bHG+AjqcERERERDERERERERHieGZmWWWWUspZSlLLLLLLLLLLLLLLLLLLLLLLLbbbbxvG28bbb13ne229N53yERHBERERERERERERERER1/F+L8X46MyyyyyyyylLKWWUssssssssssssssssssttttsttttvG87b6e87ycHY7EcEREREREREREREREdcs6szLLLLLKUpSlKWWUpZZZZZZZZZZZZZZZbbbbbeN43jbedt8u29N53yjHUiIiIiIiIiIiIiIiI8LwzMssspSlKUpSlLLLLLLLLLLLLLLLLLLLbLbxvbeu2+hvbfCckRERERERERERERERERERb4WZmWUspSlKUpSlKWWWWWWWWWWWWWWWWW23jbeVt6b223xb23nfGR1OCIiIiIiIiIiIiIiI8DyzMsspSlKUpSlKWWWWWWWWWWWWWWWW22XjZZbem+fbfJviOhEREcEcEREREREREREREdt6MzLLKUpSlKUpSlKWWWWWWWWWWWWWWWW2223pvj2223n835676RwR0IiI4IiIiIiIiIiIiIiyyyyyzlmZllKUpSlKUpSlKWWWWWWWWWWWWWW2W23jeN8G+Pbbbe2+U5OhHQiIiIiIiIiIiIiIiOdtttt4ZmZZSlKUpSlKUssssssssssssststtsvfem+pvnODoREREcEREREREREREREREWWWWdGZlllKUpSlKUpSlKWWWWWWWWWWWWWXjfFviYfBtvTem+I5I7EREREREREREREREREc71ZmWUpSlKUpSlKUpSyyyyyyyyyyy2y9N7vkbed775Tg5ODkjkiOCIiIiIiIiIiIiI8DMy8FKUpSlKUpSlKUsssssssssvwz03yEdCIiIiIiIiIiIiIiIiIiI7szLLKUpSlKUpSlKUssssssssss2/CnoHQjgiIiIiIiIiIiIiIiIiIiyzj8X44ZmWUpSlKUpSlKUpSllllllllltt+JTk5OpyRwdSIiIiIiIiIiIiIiIiO7M8ilKUpSlKUpSlLLLLLLLLL8akcHhI6nJERERERERERERERERHdmXgpSlKUpSlKUpSllllllllnh+K3h8R1OCOSOCIiIiIiIiIiIiIjszPIpSlKUpylKUpSlllllll+POHscnQiIiIiOCIiIiIiIiIiIiIjjbbeGZ5FKU5zlOUpSlKWWWWWWWZ+TPAREcEREREREREREREREREREc5ZwzMuCnOU5znKUpSlLLLLLPR+QeXuRERERERwREREREREREREREdmeC4Kc5znOcpSlKUssssvyh0OCOSI6kRERERERERERERERERFnVngpSnOc5znKUpSllllmeHtnxzwdCI6nBERERERERERERERERER1ZnkU5znOc5ylKUpZZZmfjn1iIiIiIiIiIiIiIiIiIiLOjMy4Kc5znOc5SlKWWWWX5F8DwcfzwkREREREREREREREREREdWegc5znOc5ylKUsss8PXPk2I4I7HBERERERERERERERERERHVmeRznOc5znKUpSyyz4s+ROh2OCIiIiIiIiIiIiIiIiIiOrPBcXOc5znOcpSllmfLnxD3On95OhEREREREREREREREREREdWehc5znOc5ylKZZn/AnYiIiIiIiIiIiIiIiIiIiOrPI5znOc5znKUssz8+eEiIiIiIiIiIiIiIiCIiIi23l6m5znOc5ylKWWfmc7nH/OxERERERERERERBEREREREdGepuc5znOcpSyzPOfAvtHBF/zsREREREREREREREQRERBERbyzPX3Oc5znKUsz3yz5N7l/zqRwRERERERERERERBEREREdGez1Oc5znKUzPiz5gv8AnQiOCIiIiIiIiIiIgiIIiIiI6M9TcpSnOc5SmfLlntPqvYv+RyREREREREREQREERBERBBEREdHucpSlOc5Sz6OfGnJ3IiIiIiIiIiIgiIiIIIiIIgiI4zh7mKUpTnKUs+nnxjFnQ4IiIiIiIiIiIiIiCIIIIIIgggggjo+AwpSnOUp8OeTPizsREREREREREREEREEQRBBEQQQQQRHRnwMFKc+Cn18+KbeM4IiIiIiIiIiIgiIiIgiCCIggggggjnZnuwpSnPgvs56T7GRHJERERERERERERBEEEEEEEEEEEEQRHV7oKUpz4M/4Q6kRERERERBBEQREEEQQQQQRBBBBEdXshcFKcuD0z2M8b7xwckREREREREEEQQQQQQQQQQQQQQQREdXxZx8GbePxZ0z58iIiIiIiIiCCIIgggggiCCIIIIIIIIg6vizj7GWX5tvx8W+mREREREREREREEEEQQQQQQQQQQQQQQREd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lKUpSlKUpSlKUpSlLLLLLLL5Ep+17UvaftP2vrfT5UeZwiHwHhDEMRERERERERERERER4szLLKUpSlKUpSlKUpSlKUpSlKUpZZZZZfNT9r2r2J+0/aXtfT5kcPAfAYiGIiIiIiIiIiIiIiIiIiLfBlllllKUpSlKUpSlKUpSlKUpSlLLLLLLLL5uV7V7V7EvaXtfXk9vlnkPgREREREMRERERERERERHmzLLLKUpSlKUpSlKUpSlKUpSlKWWWWWWWX0SvYvavY4H7X15PmHSIiIhiIiIiIiIiIiIiIjhEcI8GWWWWUspSlKUpSlKUpSlKUpSlKUsssssssy+iexe3eze1L2l7Xsj5x0jwOEREREREREREREREREd3iyyyyyylKUpSlKUpSlKUpSlKUpSyyyyyyyzPoO9q9nqntS9uD2+eeBEREREREREMRERERERERER4LLLLLKUpSlKUpSlKUpSlKUpSlKWWWWWWWWfRL2L2fBLe1P2vZ80jpEdI4RERERERERERERERw8FllllllKUpSlKUpSlKUpSlKUpSlllllllmWZ9K9vwCXsS0L2f0B4EREMRERERERERERERER4bxZZZZZSlKUpSlKUpSlKUpcFKUpZZZZZZZln03jezexwe1e3P+C9vyzzIjwIiIiIiIiIiIiIiIiO7bMsssspSlKUpSlLgpSlKUpSlKUsssssssy9fSxS9rwW38Rez55EeBEQxERERERERERERERHhsyyyyyylKUpSlwXBSlLgpSlKUpZZZZZZZZfWybBL2eoahP8Aj+jIjhERERERERERERERERHdlllllKUpS4LoKXQUuClKUpSlLLLLLLLL8DNL2+yaEf8AB806RHgRwiIiIiIiIiIiIiI8NllllllKUpcF6AAAuClKUpSyllllllllmeLPqYNml7P88280D+kIjhEREREREREREREREd2WWWWWWXwQAAAABSlKUssssssyyzxfXwSzf5cmxNwj2+adIiIjpEREREREREREREeGyyyyyyyl64AAAALgpSlLLKWWWZZZ4vwNlml/P/K/9lqRaP6E8SIiIiIiIiIiIiIiO7bbLLLLLwXwQAAAABSlLLLLLLLLL8XUWb/K/wDTaH8rYf0ZERERERERERERERERFttsssssxi+EAAAAAC4KUssssssss/FHG2F/6rT3/wBFgiIiOEREREREcERERHdttlllmMfhgAAIAAFKWWWWWWWWWfjuVm/yv/Xe6J6P6E8CI4REREcERERERHNtllmMY/EAAAAAAClKWWWWWWX5TlYtEP5wn848iIiIiIiIiIiIjgi2222WWYx+MAAAAAAAUsssssssvyhRJP53CP8Alf0JERERERERERERERwc222WYxjH4gAAAAAAUssssssssvzHJJjwjQIP6AiIiOEREREREREREW22yzGMY/FAAgAAAAssssssssvzhKNhWkf/AI/oTwOERERERERERDbbbLMYxj8UAAgAAACyyyyyyyy/0D9sKpSW+5806eJEREREREREQ2222zGMYx+IUQEAAAFllllllll/ovqNsxyufNOnThHCIiIiIiIbbbeGMYxr8MKqqgAA8DFllllll/pH9/yIH64b884REREREREREQ222zGMY8LX4dVVVAABiyyyyyyy83+k/mruCxHA6PziIiIiIiIiIiItt6MY1rX4tVVVVAAGLLLLLLLx/pQCIjoxflrQAR0fmkRERERERERHNt6MY1rWvw6qqqiADGLMWWWXq/07Ul6Jfxv4EAEdH5hEREREREREQ2+AY1rWtfCq+vVVVABjGLLLLLLxf6lFob/MLdchyaPyTpERERERERbb4BrWta15WtfVqqqgMYxiyyyyy8f6z+W4bzAjEPkHSIiIiIiIbfANa1rWta8ryteV8aq1r0DGMYsssssv9cKMQLLGtNQBo/GOERERERFttvRrWta1rWNeVrWta1rWtaxjGMYxZZZZf7FGJFF2N0CAj+YR+IcIiIiIht8Ra1rWMYxjGMYxjWta1rWsYxjGMWWWW3+zUEZTgIiDT+b7GEfhHSIiLbfI7WMYxjGMYxjGMa1rWta1rGMYxiyyyy/2qAjIkQjaScgA/mJ+4DaeucI5tvoHmMYxjGMYxjGMYxjWta1rWMYxiyyyy83+2dk7P5nI9ibZBchv3A5/ML9wGxaenttvoHGMYxrWta1rWtYxjGMa1rWtaxjGMYssts83+3dyI/mUyezPZmuT9bfP5v3v1v1gQYmPX/wmta1rWta1rWtaxjGMYxrWta1jGMZtttvX+6FHR/Mhn83sz257M9qfvez/ADftfpfrH6iH7v8AfH+vUMAxjGMYxrWta1jWMYxjWta1rHwDbbbbf740njmfzIZ/N7M9v+b9b979L979r9b9I/Ufu/3f6v8AXH+7/d/vn/d/u/34g/qYxjGta1rWsYxjGta18cbbbbbbzf8AgGjH3frfrfpfpftfrfvftfpfrH7j9R+o/d/u/wBdP9X+uf8AfL++/wDro/qeDzfDnwB73zwbbzf+FFJic+7979r9r9L9L9r9r9b9Y/cfqP1H6v8Ad/u/3f6v9X+r/d/vn/fysPw+G22/8UpMX6X636363737373636R+o/UfqP3f7j9x+r/V/rj/AHz/AL7f6+KPD4bbbb0j/iVO3+17d+9+1+t+l+l+kfuP3H7j9R+o/UfqP3f7v98f6+KHB4bbb0iIjp/w+5HKl+t+l+l+l+kT9I/UfqP1H7j9xSn7j9cHwj4eNhhhhiOERERw/wCKIUpyHCQpSkIfqIQhSlORwIU5nMhCEGGGGIiIiI6R/R//2Q==" /> + </defs> +</svg> \ No newline at end of file diff --git a/apps/website/public/favicon.svg b/apps/website/public/favicon.svg new file mode 100644 index 0000000000..f41c8445d7 --- /dev/null +++ b/apps/website/public/favicon.svg @@ -0,0 +1,14 @@ +<svg width="48" height="48" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"> + <style> + .bg { fill: #000000; } + .fg { fill: #F2FF59; } + @media (prefers-color-scheme: dark) { + .bg { fill: #F2FF59; } + .fg { fill: #000000; } + } + </style> + <circle class="bg" cx="24" cy="24" r="24"/> + <g transform="translate(7.8 6.72) scale(0.72)"> + <path class="fg" d="M35.6487 36.021C35.733 35.7387 35.7791 35.4411 35.7791 35.1283C35.7791 33.3963 34.3675 31.9924 32.6262 31.9924H18.4956C17.7361 32 17.1147 31.3896 17.1147 30.6342C17.1147 30.4969 17.1377 30.3672 17.1684 30.2451L20.9734 17.0606C21.1345 16.4807 21.6715 16.0534 22.3005 16.0534L36.4848 16.0382C39.4766 16.0382 42.0005 14.0315 42.76 11.2923L44.8926 3.94468C44.9616 3.68526 45 3.40296 45 3.12065C45 1.39628 43.5961 0 41.8624 0L24.7017 0C21.7252 0 19.209 1.99142 18.4342 4.70005L16.992 9.71292C16.8232 10.2852 16.2939 10.7048 15.6648 10.7048H11.5453C8.59189 10.7048 6.0987 12.6581 5.30089 15.3362L0.11507 33.3505C0.0383566 33.6175 0 33.9075 0 34.1974C0 35.9294 1.41152 37.3333 3.15292 37.3333H7.20338C7.96284 37.3333 8.58421 37.9437 8.58421 38.7067C8.58421 38.8364 8.56887 38.9661 8.53051 39.0882L7.09598 44.0553C7.02694 44.3224 6.98091 44.597 6.98091 44.8794C6.98091 46.6037 8.38476 48 10.1185 48L27.2869 47.9847C30.2711 47.9847 32.7873 45.9857 33.5544 43.2618L35.641 36.0286L35.6487 36.021Z"/> + </g> +</svg> diff --git a/apps/website/public/images/demos/community-workflows-og.png b/apps/website/public/images/demos/community-workflows-og.png new file mode 100644 index 0000000000..7e46105c88 Binary files /dev/null and b/apps/website/public/images/demos/community-workflows-og.png differ diff --git a/apps/website/public/images/demos/community-workflows-thumb.webp b/apps/website/public/images/demos/community-workflows-thumb.webp new file mode 100644 index 0000000000..0e2427c14c Binary files /dev/null and b/apps/website/public/images/demos/community-workflows-thumb.webp differ diff --git a/apps/website/public/images/demos/image-to-video-og.png b/apps/website/public/images/demos/image-to-video-og.png new file mode 100644 index 0000000000..144ca94c41 Binary files /dev/null and b/apps/website/public/images/demos/image-to-video-og.png differ diff --git a/apps/website/public/images/demos/image-to-video-thumb.webp b/apps/website/public/images/demos/image-to-video-thumb.webp new file mode 100644 index 0000000000..24119e7b64 Binary files /dev/null and b/apps/website/public/images/demos/image-to-video-thumb.webp differ diff --git a/apps/website/public/images/demos/workflow-templates-og.png b/apps/website/public/images/demos/workflow-templates-og.png new file mode 100644 index 0000000000..f13a49c448 Binary files /dev/null and b/apps/website/public/images/demos/workflow-templates-og.png differ diff --git a/apps/website/public/images/demos/workflow-templates-thumb.webp b/apps/website/public/images/demos/workflow-templates-thumb.webp new file mode 100644 index 0000000000..7c2e78ab4a Binary files /dev/null and b/apps/website/public/images/demos/workflow-templates-thumb.webp differ diff --git a/apps/website/public/llms.txt b/apps/website/public/llms.txt new file mode 100644 index 0000000000..8f95cf6645 --- /dev/null +++ b/apps/website/public/llms.txt @@ -0,0 +1,58 @@ +# Comfy + +> Comfy is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output. Built around ComfyUI — the open-source node-graph runtime with 60,000+ community nodes and thousands of shared workflows — Comfy ships as a free local app, a managed cloud, an API, and an enterprise platform. + +The Comfy ecosystem spans four surfaces: + +- **ComfyUI (local)** — the open-source node-graph runtime that runs models on your own hardware. +- **Comfy Cloud** — managed ComfyUI in the browser, with hosted models and storage. +- **Comfy API** — a REST API for triggering workflows from your own apps and pipelines. +- **Comfy Enterprise** — single-tenant deployments, BYO keys, data ownership, and orchestration for teams. + +Studios building with Comfy include Series Entertainment, Moment Factory, Open Story Movement, and Ubisoft (La Forge). Use cases concentrate in VFX & animation, advertising & creative studios, gaming, and eCommerce/fashion. + +## Product + +- [Homepage](https://comfy.org/): Overview of Comfy and the four product surfaces (Local, Cloud, API, Enterprise). +- [Download Comfy (Local)](https://comfy.org/download/): Free desktop app for macOS, Windows, and Linux — runs ComfyUI on your own GPU. +- [Comfy Cloud](https://comfy.org/cloud/): Managed ComfyUI in the browser with hosted models and storage; no local install required. +- [Comfy Cloud Pricing](https://comfy.org/cloud/pricing/): Plans and per-credit pricing for individuals and teams using Comfy Cloud. +- [Comfy API](https://comfy.org/api/): REST API for triggering ComfyUI workflows programmatically from external apps. +- [Comfy Enterprise](https://comfy.org/cloud/enterprise/): Single-tenant ComfyUI deployments with BYO keys, orchestration, and data-ownership guarantees. + +## Workflows and Gallery + +- [Workflow Gallery](https://comfy.org/gallery/): Curated showcase of ComfyUI outputs — images, video, and 3D — produced by the community. +- [Community Workflows](https://www.comfy.org/workflows/): Browseable library of community-shared ComfyUI workflows you can load and remix. + +## Customers and Case Studies + +- [Customer Stories](https://comfy.org/customers/): Index of named customers and how they use ComfyUI in production. +- [Series Entertainment](https://comfy.org/customers/series-entertainment/): How Series Entertainment rebuilt game and video production around ComfyUI. +- [Moment Factory](https://comfy.org/customers/moment-factory/): Architectural-scale 3D projection mapping reimagined with ComfyUI at Moment Factory. +- [Ubisoft — Chord](https://comfy.org/customers/ubisoft-chord/): Ubisoft La Forge open-sourcing the Chord model and its ComfyUI integration. +- [Open Story Movement](https://comfy.org/customers/open-story-movement/): How an open-source movement around AI storytelling builds on ComfyUI. + +## Developers and Documentation + +- [ComfyUI Docs](https://docs.comfy.org/): Official documentation for installing, configuring, and extending ComfyUI. +- [ComfyUI on GitHub](https://github.com/comfyanonymous/ComfyUI): Source repository for the open-source ComfyUI runtime. +- [Comfy-Org on GitHub](https://github.com/Comfy-Org): Organization-wide repositories — frontend, registry, manager, docs, and tooling. +- [Comfy Registry](https://registry.comfy.org/): Public registry of ComfyUI custom nodes and extensions, with versioning and search. + +## Company + +- [About Comfy](https://comfy.org/about/): Company background, mission, and the team behind ComfyUI. +- [Careers](https://comfy.org/careers/): Open roles across engineering, design, product, and go-to-market. +- [Contact](https://comfy.org/contact/): Sales, partnership, and general contact form. +- [Blog](https://blog.comfy.org/): Product announcements, technical deep-dives, and customer stories. +- [Privacy Policy](https://comfy.org/privacy-policy/): How Comfy collects, uses, and protects personal information. +- [Terms of Service](https://comfy.org/terms-of-service/): Terms governing use of ComfyUI and related Comfy services. + +## Optional + +- [简体中文 / Chinese homepage](https://comfy.org/zh-CN/): Simplified Chinese localization of the main site. +- [Series Entertainment — long-form case study](https://comfy.org/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui): Extended write-up of the Series Entertainment deployment. +- [Moment Factory — long-form case study](https://comfy.org/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping): Extended write-up of Moment Factory's projection-mapping pipeline. +- [Ubisoft Chord announcement (blog)](https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model): Original blog post announcing Ubisoft's open-source Chord model. +- [Open-source storytelling (blog)](https://blog.comfy.org/p/how-open-source-is-fueling-the-open): Blog post on how open source is fueling the Open Story Movement. diff --git a/apps/website/public/robots.txt b/apps/website/public/robots.txt index b1931edbd8..da06a725ac 100644 --- a/apps/website/public/robots.txt +++ b/apps/website/public/robots.txt @@ -1,4 +1,58 @@ +# robots.txt for comfy.org +# Open to all crawlers — including AI/LLM bots — for maximum visibility +# in AI-powered search, chat-based answer engines, and traditional search. +# Granular UAs are listed explicitly to signal intent; rules are shared +# via stacked user-agent records (RFC 9309 §2.2). + User-agent: * +User-agent: Googlebot +User-agent: Bingbot +User-agent: DuckDuckBot +User-agent: GPTBot +User-agent: ChatGPT-User +User-agent: OAI-SearchBot +User-agent: Google-Extended +User-agent: ClaudeBot +User-agent: Claude-Web +User-agent: anthropic-ai +User-agent: PerplexityBot +User-agent: Perplexity-User +User-agent: Applebot +User-agent: Applebot-Extended +User-agent: Bytespider +User-agent: Amazonbot +User-agent: CCBot +User-agent: Meta-ExternalAgent +User-agent: Meta-ExternalFetcher +User-agent: Diffbot +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 diff --git a/apps/website/scripts/README.md b/apps/website/scripts/README.md new file mode 100644 index 0000000000..3420245b11 --- /dev/null +++ b/apps/website/scripts/README.md @@ -0,0 +1,83 @@ +# Website Scripts + +## `refresh-ashby-snapshot.ts` + +Pulls the latest job postings from Ashby and writes +`src/data/ashby-roles.snapshot.json`. Invoked by the `Release: Website` +GitHub Actions workflow; also runnable locally via +`pnpm --filter @comfyorg/website ashby:refresh-snapshot`. + +## `process-videos.sh` + +Generates multi-resolution VP9/WebM + H.264/MP4 variants and a poster +frame for marketing videos using `ffmpeg`. Run **locally** before +uploading the outputs to `media.comfy.org`; this is not wired into CI. + +```sh +apps/website/scripts/process-videos.sh \ + ./video-sources \ + ./dist/videos \ + "640 960 1280 1920" +``` + +### Output + +For each source video at `./video-sources/foo.mp4`, you get: + +```text +foo-640.webm foo-640.mp4 +foo-960.webm foo-960.mp4 +foo-1280.webm foo-1280.mp4 +foo-1920.webm foo-1920.mp4 +foo-poster.jpg +``` + +The naming convention is enforced by `buildVideoSources()` in +`src/utils/video.ts`, which the `<SiteVideo>` Vue component uses to +emit `<source>` URLs. + +### Pairing with `<SiteVideo>` + +Once the assets are uploaded, render them with: + +```vue +<SiteVideo + name="foo" + base-url="https://media.comfy.org/website/marketing" + :width="1280" + :formats="['webm', 'mp4']" + poster="https://media.comfy.org/website/marketing/foo-poster.jpg" + autoplay + loop +/> +``` + +### `<SiteVideo>` vs `<VideoPlayer>` + +- **`SiteVideo`** — lightweight multi-source `<video>` for decorative or + autoplay marketing clips. No custom controls, no captions UI. +- **`VideoPlayer`** — full-featured player with custom scrubber, mute, + fullscreen, and caption toggles. Use this for content with subtitles or + user-driven playback. + +If you need both responsive sources and the rich `VideoPlayer` chrome, the +two are not yet combined; either pick one or extend `VideoPlayer` to accept +a source list. + +### Encoder choices + +- **VP9/WebM** at CRF 32 — preferred by Chrome and Firefox; smaller files. +- **H.264/MP4** at CRF 23, High profile, `+faststart` — universal fallback, + required for Safari iOS. +- **Poster JPG** at q4 — extracted from t=1s when the clip is long enough, + otherwise t=0; scaled to 1280w. Use this as the `poster` attribute so + the video shows something while loading. + +### Why a single resolution per video + +`<source media="...">` inside `<video>` is unreliable across browsers +(Safari ignores it). The simplest correct strategy is to ship one +well-sized resolution and let CSS scale it down on smaller viewports. +The script generates multiple widths so you can pick a different one +per page (e.g. 1280w for a hero, 640w for a thumbnail), or wire up +JavaScript-based selection later if metrics demand it. diff --git a/apps/website/scripts/generate-models.ts b/apps/website/scripts/generate-models.ts new file mode 100644 index 0000000000..a546f608be --- /dev/null +++ b/apps/website/scripts/generate-models.ts @@ -0,0 +1,396 @@ +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const WORKFLOW_TEMPLATES_BASE = + 'https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates' + +const TEMPLATES_DIR = fileURLToPath( + new URL('../../../../workflow_templates/templates', import.meta.url) +) + +const QUANT_SUFFIXES = [ + '_fp8_e4m3fn_scaled', + '_fp8_e4m3fn', + '_fp8_scaled', + '_fp4_mixed', + '_fp8mixed', + '_fp8', + '_fp16', + '_fp4', + '_bf16', + '_int8' +] + +interface RawModel { + name: string + url: string + directory: string +} + +interface ModelData { + url: string + directory: string + templates: Set<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) +} diff --git a/apps/website/scripts/process-videos.sh b/apps/website/scripts/process-videos.sh new file mode 100755 index 0000000000..ae2ef4d305 --- /dev/null +++ b/apps/website/scripts/process-videos.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# +# Generate multi-resolution VP9/WebM + H.264/MP4 variants and a poster frame +# for every source video in a given directory. Intended to be run locally +# before uploading the outputs to media.comfy.org. +# +# Usage: +# apps/website/scripts/process-videos.sh <input-dir> <output-dir> [widths] +# +# Example: +# apps/website/scripts/process-videos.sh \ +# ./video-sources \ +# ./dist/videos \ +# "640 960 1280 1920" +# +# Defaults to widths "1280" if omitted. +# +# Output naming matches buildVideoSources() in src/utils/video.ts: +# <name>-<width>.webm +# <name>-<width>.mp4 +# <name>-poster.jpg (single 1280w poster, suitable for SiteVideo) +# +# Requires ffmpeg and ffprobe on PATH. Tested with ffmpeg 6.x and 7.x. + +set -euo pipefail + +if [[ $# -lt 2 ]]; then + cat <<USAGE >&2 +Usage: $0 <input-dir> <output-dir> [widths] + widths: space-separated list, e.g. "640 1280 1920" (default: "1280") +USAGE + exit 64 +fi + +input_dir=$1 +output_dir=$2 +widths=${3:-1280} + +for tool in ffmpeg ffprobe; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "error: $tool not found on PATH" >&2 + exit 127 + fi +done + +if [[ ! -d $input_dir ]]; then + echo "error: input dir not found: $input_dir" >&2 + exit 66 +fi + +mkdir -p "$output_dir" + +shopt -s nullglob nocaseglob +sources=("$input_dir"/*.{mp4,mov,webm,mkv}) +shopt -u nullglob nocaseglob + +if [[ ${#sources[@]} -eq 0 ]]; then + echo "error: no source videos in $input_dir (looked for .mp4 .mov .webm .mkv)" >&2 + exit 66 +fi + +for src in "${sources[@]}"; do + name=$(basename "$src") + name=${name%.*} + echo "==> $name" + + for w in $widths; do + webm_out="$output_dir/${name}-${w}.webm" + mp4_out="$output_dir/${name}-${w}.mp4" + + echo " encoding ${w}w VP9/WebM -> $webm_out" + ffmpeg -y -hide_banner -loglevel error \ + -i "$src" \ + -vf "scale=${w}:-2:flags=lanczos" \ + -c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1 -tile-columns 2 \ + -c:a libopus -b:a 96k \ + -f webm "$webm_out" + + echo " encoding ${w}w H.264/MP4 -> $mp4_out" + ffmpeg -y -hide_banner -loglevel error \ + -i "$src" \ + -vf "scale=${w}:-2:flags=lanczos" \ + -c:v libx264 -crf 23 -preset slow -profile:v high -pix_fmt yuv420p \ + -c:a aac -b:a 128k \ + -movflags +faststart \ + "$mp4_out" + done + + poster_out="$output_dir/${name}-poster.jpg" + duration_raw=$(ffprobe -v error -show_entries format=duration \ + -of default=noprint_wrappers=1:nokey=1 "$src" 2>/dev/null || true) + if [[ $duration_raw =~ ^[0-9]+([.][0-9]+)?$ ]]; then + duration="$duration_raw" + else + duration=0 + fi + if awk -v d="$duration" 'BEGIN { exit !(d >= 1.0) }'; then + poster_seek=1 + else + poster_seek=0 + fi + echo " extracting poster (t=${poster_seek}s) -> $poster_out" + ffmpeg -y -hide_banner -loglevel error \ + -ss "$poster_seek" -i "$src" \ + -vframes 1 -vf "scale=1280:-2:flags=lanczos" \ + -q:v 4 \ + "$poster_out" +done + +echo "done. upload contents of $output_dir to media.comfy.org." diff --git a/apps/website/scripts/refresh-cloud-nodes-snapshot.ts b/apps/website/scripts/refresh-cloud-nodes-snapshot.ts new file mode 100644 index 0000000000..4817bb2168 --- /dev/null +++ b/apps/website/scripts/refresh-cloud-nodes-snapshot.ts @@ -0,0 +1,32 @@ +import { renameSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +import { fetchCloudNodesForBuild } from '../src/utils/cloudNodes' + +const snapshotPath = fileURLToPath( + new URL('../src/data/cloud-nodes.snapshot.json', import.meta.url) +) +const tempPath = `${snapshotPath}.tmp` + +const outcome = await fetchCloudNodesForBuild() + +if (outcome.status !== 'fresh') { + const reason = 'reason' in outcome ? outcome.reason : '(none)' + console.error( + `Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}` + ) + process.exit(1) +} + +const serialized = JSON.stringify(outcome.snapshot, null, 2) + '\n' + +writeFileSync(tempPath, serialized, 'utf8') +renameSync(tempPath, snapshotPath) + +const totalNodes = outcome.snapshot.packs.reduce( + (n, pack) => n + pack.nodes.length, + 0 +) +process.stdout.write( + `Wrote snapshot with ${outcome.snapshot.packs.length} pack(s) and ${totalNodes} node(s) to ${snapshotPath}\n` +) diff --git a/apps/website/src/assets/marketing/README.md b/apps/website/src/assets/marketing/README.md new file mode 100644 index 0000000000..5638b82e83 --- /dev/null +++ b/apps/website/src/assets/marketing/README.md @@ -0,0 +1,51 @@ +# Marketing Assets + +Source images committed here are processed by Astro at build time and emitted +as multiple formats (AVIF, WebP) at multiple widths (640w, 960w, 1280w, 1920w). + +## Usage + +Drop a high-resolution source image (PNG or JPG) here, then render it with +Astro's built-in `<Picture>` component plus the shared defaults: + +```astro +--- +import { Picture } from 'astro:assets' +import { + MARKETING_FORMATS, + MARKETING_WIDTHS +} from '../utils/marketingImage' +import hero from '../assets/marketing/hero.png' +--- +<Picture + src={hero} + alt="ComfyUI workflow preview" + formats={[...MARKETING_FORMATS]} + widths={[...MARKETING_WIDTHS]} + sizes="(max-width: 768px) 100vw, 50vw" +/> +``` + +The component generates a `<picture>` element with `<source>` tags for AVIF +and WebP, plus an `<img>` fallback. Output files are hashed and emitted under +`dist/_website/` for long-term caching. + +A custom Astro wrapper component is intentionally not provided: Astro's +discriminated union `LocalImageProps | RemoteImageProps` for `<Picture>` makes +a thin wrapper that mutates `widths` / `formats` impractical to type safely +without `as` casts. The shared constants give us the same consistency benefit +without that cost. + +## When to use this vs. `media.comfy.org` + +- **Use `src/assets/marketing/`** for static marketing images that are part of + page content (hero shots, product imagery, illustrations). Build-time + processing gives you AVIF/WebP variants automatically. +- **Use `media.comfy.org`** for video content, large/changing image libraries + (gallery), and anything shared across properties. + +## Source image guidelines + +- Provide the largest size you'll ever need (≥1920px wide). +- PNG for screenshots/illustrations with sharp edges; JPG for photographs. +- Astro will downscale; it will not upscale. Always supply at least 1920w. diff --git a/apps/website/src/components/careers/RolesSection.vue b/apps/website/src/components/careers/RolesSection.vue index db6053a3e1..f57a400f02 100644 --- a/apps/website/src/components/careers/RolesSection.vue +++ b/apps/website/src/components/careers/RolesSection.vue @@ -1,10 +1,13 @@ <script setup lang="ts"> -import { computed, ref } from 'vue' +import { useEventListener, useTemplateRefsList } from '@vueuse/core' +import { computed, onMounted, 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' @@ -13,24 +16,72 @@ 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(() => [ - { 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 categories = computed(() => + visibleDepartments.value.map((d) => ({ label: d.name, value: d.key })) ) 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> @@ -48,9 +99,10 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0) </h2> <CategoryNav v-if="hasRoles" - v-model="activeCategory" :categories="categories" + :model-value="activeCategory" class="mt-4" + @update:model-value="scrollToDepartment" /> </div> </div> @@ -65,9 +117,11 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0) </p> <div - v-for="dept in filteredDepartments" + v-for="dept in visibleDepartments" + :id="deptElementId(dept.key)" + :ref="sectionRefs.set" :key="dept.key" - class="mb-12 last:mb-0" + class="mb-12 scroll-mt-24 last:mb-0 md:scroll-mt-36" > <SectionLabel> {{ dept.name }} @@ -76,7 +130,7 @@ const hasRoles = computed(() => visibleDepartments.value.length > 0) <a v-for="role in dept.roles" :key="role.id" - :href="role.applyUrl" + :href="role.jobUrl" target="_blank" rel="noopener noreferrer" class="border-primary-warm-gray/20 group flex items-center justify-between border-b py-5" diff --git a/apps/website/src/components/careers/TeamPhotosSection.vue b/apps/website/src/components/careers/TeamPhotosSection.vue index a2bd8fa782..afbbf94909 100644 --- a/apps/website/src/components/careers/TeamPhotosSection.vue +++ b/apps/website/src/components/careers/TeamPhotosSection.vue @@ -1,6 +1,4 @@ <script setup lang="ts"> -import { onMounted, ref } from 'vue' - const photos = [ { src: 'https://media.comfy.org/website/careers/team0.webp', @@ -17,45 +15,34 @@ 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 loopedPhotos" + v-for="(photo, i) in photos" :key="i" class="aspect-3/4 h-64 shrink-0 md:h-96" > diff --git a/apps/website/src/components/cloud-nodes/HeroSection.vue b/apps/website/src/components/cloud-nodes/HeroSection.vue new file mode 100644 index 0000000000..84717017c8 --- /dev/null +++ b/apps/website/src/components/cloud-nodes/HeroSection.vue @@ -0,0 +1,30 @@ +<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> diff --git a/apps/website/src/components/cloud-nodes/NodeList.vue b/apps/website/src/components/cloud-nodes/NodeList.vue new file mode 100644 index 0000000000..2b4653dc60 --- /dev/null +++ b/apps/website/src/components/cloud-nodes/NodeList.vue @@ -0,0 +1,49 @@ +<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> diff --git a/apps/website/src/components/cloud-nodes/PackBanner.vue b/apps/website/src/components/cloud-nodes/PackBanner.vue new file mode 100644 index 0000000000..77c983001a --- /dev/null +++ b/apps/website/src/components/cloud-nodes/PackBanner.vue @@ -0,0 +1,52 @@ +<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> diff --git a/apps/website/src/components/cloud-nodes/PackCard.vue b/apps/website/src/components/cloud-nodes/PackCard.vue new file mode 100644 index 0000000000..ecb22168b9 --- /dev/null +++ b/apps/website/src/components/cloud-nodes/PackCard.vue @@ -0,0 +1,80 @@ +<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> diff --git a/apps/website/src/components/cloud-nodes/PackDetail.vue b/apps/website/src/components/cloud-nodes/PackDetail.vue new file mode 100644 index 0000000000..b5cb777963 --- /dev/null +++ b/apps/website/src/components/cloud-nodes/PackDetail.vue @@ -0,0 +1,203 @@ +<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> diff --git a/apps/website/src/components/cloud-nodes/PackGridSection.vue b/apps/website/src/components/cloud-nodes/PackGridSection.vue new file mode 100644 index 0000000000..c8eea1a7db --- /dev/null +++ b/apps/website/src/components/cloud-nodes/PackGridSection.vue @@ -0,0 +1,113 @@ +<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> diff --git a/apps/website/src/components/common/ContentSection.vue b/apps/website/src/components/common/ContentSection.vue index d79485cac6..a45794b915 100644 --- a/apps/website/src/components/common/ContentSection.vue +++ b/apps/website/src/components/common/ContentSection.vue @@ -1,7 +1,11 @@ <script setup lang="ts"> import { cn } from '@comfyorg/tailwind-utils' -import { useIntersectionObserver, useTemplateRefsList } from '@vueuse/core' -import { computed, ref } from 'vue' +import { + useEventListener, + useIntersectionObserver, + useTemplateRefsList +} from '@vueuse/core' +import { computed, onMounted, ref } from 'vue' import type { Locale, TranslationKey } from '../../i18n/translations' @@ -40,13 +44,25 @@ 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 @@ -58,22 +74,39 @@ 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: () => { - isScrolling = false - } + onComplete: clearScrollLock }) return } - isScrolling = false + clearScrollLock() } </script> diff --git a/apps/website/src/components/common/GitHubStarBadge.vue b/apps/website/src/components/common/GitHubStarBadge.vue index ec13808484..03ae84cd03 100644 --- a/apps/website/src/components/common/GitHubStarBadge.vue +++ b/apps/website/src/components/common/GitHubStarBadge.vue @@ -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-2 lg:flex" + class="hidden shrink-0 items-center gap-1 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-7" + class="bg-primary-comfy-yellow block size-6 shrink-0" aria-hidden="true" style="mask: url('/icons/social/github.svg') center / contain no-repeat" /> diff --git a/apps/website/src/components/common/NodeBadge.vue b/apps/website/src/components/common/NodeBadge.vue index b5c395602c..8a6e9e2857 100644 --- a/apps/website/src/components/common/NodeBadge.vue +++ b/apps/website/src/components/common/NodeBadge.vue @@ -26,7 +26,7 @@ const { <img src="/icons/node-left.svg" alt="" - class="-mx-px self-stretch" + class="-mx-px h-full w-auto self-stretch" aria-hidden="true" /> @@ -38,7 +38,7 @@ const { v-if="i > 0" src="/icons/node-union.svg" alt="" - class="-mx-px self-stretch" + class="-mx-px h-full w-auto self-stretch" aria-hidden="true" /> <span @@ -72,7 +72,7 @@ const { <img src="/icons/node-right.svg" alt="" - class="-mx-px self-stretch" + class="-mx-px h-full w-auto self-stretch" aria-hidden="true" /> </div> diff --git a/apps/website/src/components/common/SiteFooter.vue b/apps/website/src/components/common/SiteFooter.vue index 6377fbaef7..1fa7d8cb6b 100644 --- a/apps/website/src/components/common/SiteFooter.vue +++ b/apps/website/src/components/common/SiteFooter.vue @@ -82,13 +82,18 @@ const companyColumn: { title: string; links: FooterLink[] } = { ] } -const contactColumn = { +const contactColumn: { title: string; links: FooterLink[] } = { title: t('footer.contact', locale), links: [ { label: t('footer.sales', locale), href: routes.contact }, { label: t('footer.support', locale), - href: externalLinks.discord, + 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' } diff --git a/apps/website/src/components/common/SiteVideo.vue b/apps/website/src/components/common/SiteVideo.vue new file mode 100644 index 0000000000..ea149d0cfc --- /dev/null +++ b/apps/website/src/components/common/SiteVideo.vue @@ -0,0 +1,68 @@ +<script setup lang="ts"> +import { cn } from '@comfyorg/tailwind-utils' +import { computed } from 'vue' + +import { buildVideoSources, videoKey } from '../../utils/video' +import type { VideoFormat } from '../../utils/video' + +const { + name, + baseUrl, + width = 1280, + formats = ['webm', 'mp4'], + poster, + alt, + autoplay = false, + loop = false, + muted = autoplay, + controls = false, + preload = autoplay ? 'auto' : 'metadata', + containerClass, + videoClass +} = defineProps<{ + name: string + baseUrl: string + width?: number + formats?: VideoFormat[] + poster?: string + alt?: string + autoplay?: boolean + loop?: boolean + muted?: boolean + controls?: boolean + preload?: 'auto' | 'metadata' | 'none' + containerClass?: string + videoClass?: string +}>() + +const sources = computed(() => + buildVideoSources({ name, baseUrl, width, formats }) +) +const remountKey = computed(() => videoKey(sources.value)) +const decorative = computed(() => !alt && !controls) +</script> + +<template> + <div :class="cn('relative', containerClass)"> + <video + :key="remountKey" + :class="cn('size-full', videoClass)" + :poster + :preload + :autoplay + :loop + :muted + :controls + :aria-label="alt" + :aria-hidden="decorative ? true : undefined" + playsinline + > + <source + v-for="source in sources" + :key="source.src" + :src="source.src" + :type="source.type" + /> + </video> + </div> +</template> diff --git a/apps/website/src/components/common/SocialProofBarSection.vue b/apps/website/src/components/common/SocialProofBarSection.vue index 4cea331a62..e261a18591 100644 --- a/apps/website/src/components/common/SocialProofBarSection.vue +++ b/apps/website/src/components/common/SocialProofBarSection.vue @@ -14,23 +14,28 @@ const logos = [ 'Ubisoft' ] -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] +const mobileRow1Logos = logos.slice(0, 6) +const mobileRow2Logos = logos.slice(6) </script> <template> <section class="overflow-hidden py-12"> <!-- Single row on desktop --> - <div class="animate-marquee hidden items-center gap-2 md:flex"> + <div data-testid="social-proof-desktop" class="hidden w-max gap-2 md:flex"> <div - v-for="(logo, i) in desktopLogos" - :key="`${logo}-${i}`" - class="flex h-20 w-50 shrink-0 items-center justify-center" + 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" > - <img :src="`/icons/clients/${logo}.svg`" :alt="logo" /> + <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> </div> </div> @@ -39,22 +44,38 @@ const mobileRow2 = [...row2, ...row2] data-testid="social-proof-mobile" class="flex flex-col gap-8 md:hidden" > - <div class="animate-marquee flex items-center gap-8"> + <div class="flex w-max gap-8"> <div - v-for="(logo, i) in mobileRow1" - :key="`${logo}-${i}`" - class="flex h-14 w-40 shrink-0 items-center justify-center" + 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" > - <img :src="`/icons/clients/${logo}.svg`" :alt="logo" /> + <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> </div> </div> - <div class="animate-marquee-reverse flex items-center gap-8"> + <div class="flex w-max gap-8"> <div - v-for="(logo, i) in mobileRow2" - :key="`${logo}-${i}`" - class="flex h-14 w-40 shrink-0 items-center justify-center" + 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" > - <img :src="`/icons/clients/${logo}.svg`" :alt="logo" /> + <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> </div> </div> </div> diff --git a/apps/website/src/components/customers/FeedbackSection.vue b/apps/website/src/components/customers/FeedbackSection.vue index 452aacd528..0039847150 100644 --- a/apps/website/src/components/customers/FeedbackSection.vue +++ b/apps/website/src/components/customers/FeedbackSection.vue @@ -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 transition-all duration-200" + class="bg-primary-comfy-yellow h-full rounded-full" :style="{ width: progressPercent }" /> </div> diff --git a/apps/website/src/components/customers/HeroSection.vue b/apps/website/src/components/customers/HeroSection.vue index c50acdd696..bdd5296a4e 100644 --- a/apps/website/src/components/customers/HeroSection.vue +++ b/apps/website/src/components/customers/HeroSection.vue @@ -5,6 +5,7 @@ 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 }>() @@ -22,6 +23,10 @@ useHeroAnimation({ logo: logoRef, video: videoRef }) + +function handleLogoLoad() { + ScrollTrigger.refresh(true) +} </script> <template> @@ -37,7 +42,10 @@ useHeroAnimation({ <img src="https://media.comfy.org/website/customers/c-projection.webp" alt="Comfy 3D logo" - class="mx-auto w-full max-w-md lg:max-w-none" + width="1568" + height="1763" + class="mx-auto h-auto w-full max-w-md lg:max-w-none" + @load="handleLogoLoad" /> </div> diff --git a/apps/website/src/components/demos/ArcadeEmbed.vue b/apps/website/src/components/demos/ArcadeEmbed.vue new file mode 100644 index 0000000000..bd8e572af8 --- /dev/null +++ b/apps/website/src/components/demos/ArcadeEmbed.vue @@ -0,0 +1,70 @@ +<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> diff --git a/apps/website/src/components/demos/DemoHeroSection.vue b/apps/website/src/components/demos/DemoHeroSection.vue new file mode 100644 index 0000000000..85309fe429 --- /dev/null +++ b/apps/website/src/components/demos/DemoHeroSection.vue @@ -0,0 +1,60 @@ +<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> diff --git a/apps/website/src/components/demos/DemoNavSection.vue b/apps/website/src/components/demos/DemoNavSection.vue new file mode 100644 index 0000000000..14ff5c8e22 --- /dev/null +++ b/apps/website/src/components/demos/DemoNavSection.vue @@ -0,0 +1,59 @@ +<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> diff --git a/apps/website/src/components/demos/DemoTranscript.vue b/apps/website/src/components/demos/DemoTranscript.vue new file mode 100644 index 0000000000..99f0cbdc4a --- /dev/null +++ b/apps/website/src/components/demos/DemoTranscript.vue @@ -0,0 +1,50 @@ +<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> diff --git a/apps/website/src/components/home/CaseStudySpotlightSection.vue b/apps/website/src/components/home/CaseStudySpotlightSection.vue index 7dd46c9a4a..9b033a1906 100644 --- a/apps/website/src/components/home/CaseStudySpotlightSection.vue +++ b/apps/website/src/components/home/CaseStudySpotlightSection.vue @@ -35,7 +35,10 @@ const routes = getRoutes(locale) </div> <!-- Right: content --> - <div class="flex flex-col justify-between p-6 lg:flex-1"> + <div + data-testid="case-study-content" + class="flex flex-col justify-between p-6 lg:flex-1" + > <div class="flex flex-col gap-8"> <p class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase" @@ -52,12 +55,8 @@ const routes = getRoutes(locale) </p> </div> - <div class="flex flex-col gap-3 sm:flex-row"> - <BrandButton - :href="routes.customers" - variant="outline" - class="flex-1 text-center" - > + <div class="mt-8 flex flex-col items-start gap-3 sm:flex-row lg:mt-0"> + <BrandButton :href="routes.customers" variant="outline"> {{ t('caseStudy.seeAll', locale) }} </BrandButton> </div> diff --git a/apps/website/src/components/home/HeroSection.vue b/apps/website/src/components/home/HeroSection.vue index 2a520c0b90..7618385e33 100644 --- a/apps/website/src/components/home/HeroSection.vue +++ b/apps/website/src/components/home/HeroSection.vue @@ -1,22 +1,31 @@ <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 class="relative flex-1"> - <video - src="https://media.comfy.org/website/homepage/hero-logo-seq.webm" - autoplay - loop - muted - playsinline - class="w-full" + <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> @@ -32,6 +41,15 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>() > {{ t('hero.subtitle', locale) }} </p> + + <BrandButton + :href="externalLinks.workflows" + variant="outline" + size="lg" + class="mt-8 w-full p-4 uppercase lg:w-auto lg:min-w-60" + > + {{ t('hero.runFirstWorkflow', locale) }} + </BrandButton> </div> </section> </template> diff --git a/apps/website/src/components/home/UseCaseSection.vue b/apps/website/src/components/home/UseCaseSection.vue index ea1f921587..8c8c101104 100644 --- a/apps/website/src/components/home/UseCaseSection.vue +++ b/apps/website/src/components/home/UseCaseSection.vue @@ -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.webp' + rightSrc: 'https://media.comfy.org/website/homepage/use-case/right1.webm' }, { label: t('useCase.advertising', locale), @@ -106,6 +106,11 @@ function onNavKeydown(event: KeyboardEvent) { navButtons()?.[next]?.focus({ preventScroll: true }) } +function onCategoryHover(index: number) { + if (isEnabled.value) return + activeCategory.value = index +} + function travelRange(el: HTMLElement) { if (window.matchMedia('(min-width: 1024px)').matches) return 150 @@ -116,31 +121,29 @@ function travelRange(el: HTMLElement) { } const pinScrubEnd = `+=${categories.length * VH_PER_ITEM}%` +const parallaxMediaQuery = '(max-width: 1023px)' useParallax([rightImgRef], { trigger: sectionRef, fromY: (el) => -travelRange(el), y: (el) => travelRange(el), start: 'top top', - end: pinScrubEnd + end: pinScrubEnd, + mediaQuery: parallaxMediaQuery }) useParallax([leftImgRef], { trigger: sectionRef, fromY: (el) => travelRange(el), y: (el) => -travelRange(el), start: 'top top', - end: pinScrubEnd + end: pinScrubEnd, + mediaQuery: parallaxMediaQuery }) </script> <template> <section ref="sectionRef" - :class=" - cn( - 'bg-primary-comfy-ink relative isolate overflow-x-clip pt-20 lg:py-24', - isEnabled && 'lg:h-[calc(100vh+60px)]' - ) - " + class="bg-primary-comfy-ink relative isolate overflow-x-clip pt-20 lg:h-[calc(100vh+60px)] lg:py-24" > <svg class="absolute size-0" width="0" height="0" aria-hidden="true"> <defs> @@ -202,6 +205,8 @@ useParallax([leftImgRef], { " :aria-current="index === activeCategory ? 'true' : undefined" @click="scrollToIndex(index)" + @mouseenter="onCategoryHover(index)" + @focus="onCategoryHover(index)" > {{ category.label }} </button> diff --git a/apps/website/src/components/models/ModelHeroSection.vue b/apps/website/src/components/models/ModelHeroSection.vue new file mode 100644 index 0000000000..4ef460d190 --- /dev/null +++ b/apps/website/src/components/models/ModelHeroSection.vue @@ -0,0 +1,137 @@ +<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> diff --git a/apps/website/src/components/payment/PaymentStatusSection.vue b/apps/website/src/components/payment/PaymentStatusSection.vue new file mode 100644 index 0000000000..0d8aec3f1e --- /dev/null +++ b/apps/website/src/components/payment/PaymentStatusSection.vue @@ -0,0 +1,101 @@ +<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> diff --git a/apps/website/src/components/pricing/PriceSection.vue b/apps/website/src/components/pricing/PriceSection.vue index 7682656ec7..ccd8192a3c 100644 --- a/apps/website/src/components/pricing/PriceSection.vue +++ b/apps/website/src/components/pricing/PriceSection.vue @@ -7,6 +7,7 @@ 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' @@ -37,21 +38,23 @@ 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[] = [ - { - 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' } - ] - }, + ...(SHOW_FREE_TIER ? [freePlan] : []), { id: 'standard', labelKey: 'pricing.plan.standard.label', @@ -61,7 +64,9 @@ const plans: PricingPlan[] = [ estimateKey: 'pricing.plan.standard.estimate', ctaKey: 'pricing.plan.standard.cta', ctaHref: subscribeUrl('standard'), - featureIntroKey: 'pricing.plan.standard.featureIntro', + featureIntroKey: SHOW_FREE_TIER + ? 'pricing.plan.standard.featureIntro' + : undefined, features: [ { text: 'pricing.plan.standard.feature1' }, { text: 'pricing.plan.standard.feature2' } @@ -77,7 +82,10 @@ const plans: PricingPlan[] = [ ctaKey: 'pricing.plan.creator.cta', ctaHref: subscribeUrl('creator'), featureIntroKey: 'pricing.plan.creator.featureIntro', - features: [{ text: 'pricing.plan.creator.feature1' }], + features: [ + { text: 'pricing.plan.creator.feature1' }, + { text: 'pricing.plan.creator.feature2' } + ], isPopular: true }, { @@ -90,7 +98,10 @@ const plans: PricingPlan[] = [ ctaKey: 'pricing.plan.pro.cta', ctaHref: subscribeUrl('pro'), featureIntroKey: 'pricing.plan.pro.featureIntro', - features: [{ text: 'pricing.plan.pro.feature1' }] + features: [ + { text: 'pricing.plan.pro.feature1' }, + { text: 'pricing.plan.pro.feature2' } + ] }, { id: 'enterprise', @@ -144,9 +155,14 @@ const activePlanIndex = ref(0) </button> </div> - <!-- Desktop: 4-column grid / Mobile: single card --> + <!-- Desktop: dynamic grid (3 or 4 columns) / Mobile: single card --> <div - class="rounded-5xl bg-transparency-white-t4 hidden p-2 lg:grid lg:grid-cols-4 lg:gap-2" + :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' + ) + " > <PricingTierCard v-for="plan in standardPlans" :key="plan.id"> <!-- Label + badge --> @@ -217,10 +233,18 @@ const activePlanIndex = ref(0) <!-- Features --> <div v-if="plan.features.length" class="px-6 py-3"> - <p class="text-primary-comfy-canvas mb-2 text-sm font-semibold"> - {{ - plan.featureIntroKey ? t(plan.featureIntroKey, locale) : ' ' - }} + <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> <ul class="space-y-2"> <li diff --git a/apps/website/src/components/pricing/WhatsIncludedSection.vue b/apps/website/src/components/pricing/WhatsIncludedSection.vue index ed809b84ee..f67a9b94e2 100644 --- a/apps/website/src/components/pricing/WhatsIncludedSection.vue +++ b/apps/website/src/components/pricing/WhatsIncludedSection.vue @@ -101,17 +101,9 @@ const features: IncludedFeature[] = [ class="mt-0.5 size-4 shrink-0" aria-hidden="true" /> - <div> - <p class="text-primary-comfy-canvas text-sm font-medium"> - {{ t(feature.titleKey, locale) }} - </p> - <span - v-if="feature.isComingSoon" - class="text-primary-comfy-yellow mt-1 inline-block text-xs" - > - {{ t('pricing.included.comingSoon', locale) }} - </span> - </div> + <p class="text-primary-comfy-canvas text-sm font-medium"> + {{ t(feature.titleKey, locale) }} + </p> </div> <!-- Description --> diff --git a/apps/website/src/components/product/cloud/PricingSection.vue b/apps/website/src/components/product/cloud/PricingSection.vue index 0b3fe7c1af..347bb8ee27 100644 --- a/apps/website/src/components/product/cloud/PricingSection.vue +++ b/apps/website/src/components/product/cloud/PricingSection.vue @@ -1,6 +1,7 @@ <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' @@ -25,7 +26,10 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>() {{ t('cloud.pricing.description', locale) }} </p> - <p class="text-primary-comfy-ink mt-4 text-base font-bold"> + <p + v-if="SHOW_FREE_TIER" + class="text-primary-comfy-ink mt-4 text-base font-bold" + > {{ t('cloud.pricing.tagline', locale) }} </p> </div> diff --git a/apps/website/src/components/product/enterprise/HeroSection.vue b/apps/website/src/components/product/enterprise/HeroSection.vue index 66513d79fa..5f7f8f71ce 100644 --- a/apps/website/src/components/product/enterprise/HeroSection.vue +++ b/apps/website/src/components/product/enterprise/HeroSection.vue @@ -35,20 +35,20 @@ onMounted(() => { <template> <section - 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)]" + 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)]" > <!-- 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: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)]" + 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)]" > <svg ref="svgRef" - class="block size-full" + class="block size-full overflow-visible" viewBox="0 0 1600 1046" fill="none" aria-hidden="true" > - <g clip-path="url(#enterpriseHeroClip)"> + <g> <rect width="1600" height="1046" fill="#211927" /> <rect width="800" @@ -84,7 +84,7 @@ onMounted(() => { /> <!-- Exploding block cluster --> - <g class="block-cluster"> + <g class="block-cluster" clip-path="url(#enterpriseHeroBlockClip)"> <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="enterpriseHeroClip"> + <clipPath id="enterpriseHeroBlockClip"> <rect width="1600" height="1046" fill="white" /> </clipPath> </defs> diff --git a/apps/website/src/components/product/local/EcoSystemSection.vue b/apps/website/src/components/product/local/EcoSystemSection.vue index f9fac310bf..3778729c8f 100644 --- a/apps/website/src/components/product/local/EcoSystemSection.vue +++ b/apps/website/src/components/product/local/EcoSystemSection.vue @@ -28,7 +28,11 @@ 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.github" variant="outline" size="lg"> + <BrandButton + :href="externalLinks.githubInstall" + 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')]" diff --git a/apps/website/src/components/product/local/HeroSection.vue b/apps/website/src/components/product/local/HeroSection.vue index 59db6a2993..8a2d5b7019 100644 --- a/apps/website/src/components/product/local/HeroSection.vue +++ b/apps/website/src/components/product/local/HeroSection.vue @@ -276,29 +276,6 @@ 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> @@ -323,7 +300,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.github" + :href="externalLinks.githubInstall" variant="outline" size="lg" class="lg:min-w-60 lg:p-4" diff --git a/apps/website/src/components/product/shared/CloudBannerSection.vue b/apps/website/src/components/product/shared/CloudBannerSection.vue index 761786e8f3..61aba594e2 100644 --- a/apps/website/src/components/product/shared/CloudBannerSection.vue +++ b/apps/website/src/components/product/shared/CloudBannerSection.vue @@ -8,7 +8,9 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>() </script> <template> - <section class="bg-transparency-white-t4 p-4 text-center lg:px-20 lg:py-8"> + <section + class="bg-transparency-white-t4 relative z-20 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" > diff --git a/apps/website/src/composables/useBannerImage.test.ts b/apps/website/src/composables/useBannerImage.test.ts new file mode 100644 index 0000000000..bf23aca09e --- /dev/null +++ b/apps/website/src/composables/useBannerImage.test.ts @@ -0,0 +1,52 @@ +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') + }) +}) diff --git a/apps/website/src/composables/useBannerImage.ts b/apps/website/src/composables/useBannerImage.ts new file mode 100644 index 0000000000..0ceaf96aff --- /dev/null +++ b/apps/website/src/composables/useBannerImage.ts @@ -0,0 +1,30 @@ +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 + } +} diff --git a/apps/website/src/composables/useFilteredPacks.test.ts b/apps/website/src/composables/useFilteredPacks.test.ts new file mode 100644 index 0000000000..f3073f561f --- /dev/null +++ b/apps/website/src/composables/useFilteredPacks.test.ts @@ -0,0 +1,144 @@ +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) + }) +}) diff --git a/apps/website/src/composables/useFilteredPacks.ts b/apps/website/src/composables/useFilteredPacks.ts new file mode 100644 index 0000000000..0c26400123 --- /dev/null +++ b/apps/website/src/composables/useFilteredPacks.ts @@ -0,0 +1,53 @@ +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 } +} diff --git a/apps/website/src/composables/useHeroLogo.ts b/apps/website/src/composables/useHeroLogo.ts new file mode 100644 index 0000000000..3a1e0b847a --- /dev/null +++ b/apps/website/src/composables/useHeroLogo.ts @@ -0,0 +1,328 @@ +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 } +} diff --git a/apps/website/src/composables/useNodesByCategory.test.ts b/apps/website/src/composables/useNodesByCategory.test.ts new file mode 100644 index 0000000000..642c598fb5 --- /dev/null +++ b/apps/website/src/composables/useNodesByCategory.test.ts @@ -0,0 +1,68 @@ +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) + }) +}) diff --git a/apps/website/src/composables/useNodesByCategory.ts b/apps/website/src/composables/useNodesByCategory.ts new file mode 100644 index 0000000000..f2bf407582 --- /dev/null +++ b/apps/website/src/composables/useNodesByCategory.ts @@ -0,0 +1,40 @@ +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 } +} diff --git a/apps/website/src/composables/usePinScrub.ts b/apps/website/src/composables/usePinScrub.ts index 14091fe60d..0eb68904be 100644 --- a/apps/website/src/composables/usePinScrub.ts +++ b/apps/website/src/composables/usePinScrub.ts @@ -20,6 +20,9 @@ interface PinScrubOptions { /** Viewport-height percentage each category occupies in the scroll distance. */ export const VH_PER_ITEM = 20 +/** Pin/scrub is mobile-only — desktop uses hover-based category switching. */ +const PIN_SCRUB_MEDIA_QUERY = '(max-width: 1023px)' + function interpolateY( index: number, buttonCenters: number[], @@ -66,7 +69,8 @@ export function usePinScrub(refs: PinScrubRefs, options: PinScrubOptions) { !refs.section.value || !refs.content.value || !refs.nav.value || - prefersReducedMotion() + prefersReducedMotion() || + !window.matchMedia(PIN_SCRUB_MEDIA_QUERY).matches ) return const section: HTMLElement = refs.section.value diff --git a/apps/website/src/config/customerStories.ts b/apps/website/src/config/customerStories.ts index 5042cc2269..0f5449967e 100644 --- a/apps/website/src/config/customerStories.ts +++ b/apps/website/src/config/customerStories.ts @@ -52,6 +52,15 @@ export const customerStories: CustomerStory[] = [ detailPrefix: 'customers.detail.ubisoft-chord', readMoreHref: 'https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model' + }, + { + slug: 'groove-jones', + image: + 'https://media.comfy.org/website/customers/groove-jones/crocs-nfl-dicks-sporting-goods-fooh.webp', + category: 'customers.story.groove-jones.category', + title: 'customers.story.groove-jones.title', + body: 'customers.story.groove-jones.body', + detailPrefix: 'customers.detail.groove-jones' } ] diff --git a/apps/website/src/config/demos.ts b/apps/website/src/config/demos.ts new file mode 100644 index 0000000000..980a7f1dee --- /dev/null +++ b/apps/website/src/config/demos.ts @@ -0,0 +1,95 @@ +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] +} diff --git a/apps/website/src/config/features.ts b/apps/website/src/config/features.ts new file mode 100644 index 0000000000..2247d75941 --- /dev/null +++ b/apps/website/src/config/features.ts @@ -0,0 +1 @@ +export const SHOW_FREE_TIER = false diff --git a/apps/website/src/config/generated-models.json b/apps/website/src/config/generated-models.json new file mode 100644 index 0000000000..7bcf654a75 --- /dev/null +++ b/apps/website/src/config/generated-models.json @@ -0,0 +1,1945 @@ +[ + { + "slug": "kling-ai", + "name": "Kling AI", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 11, + "displayName": "Kling AI" + }, + { + "slug": "openai-dall-e", + "name": "OpenAI DALL-E", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 10, + "displayName": "OpenAI DALL-E" + }, + { + "slug": "vidu", + "name": "Vidu", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 8, + "displayName": "Vidu" + }, + { + "slug": "seedance-bytedance", + "name": "Seedance (ByteDance)", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 7, + "displayName": "Seedance (ByteDance)" + }, + { + "slug": "stability-ai", + "name": "Stability AI", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 7, + "displayName": "Stability AI" + }, + { + "slug": "wan-api", + "name": "Wan (API)", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 6, + "displayName": "Wan (API)" + }, + { + "slug": "flux-api", + "name": "Flux (API)", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 5, + "displayName": "Flux (API)" + }, + { + "slug": "runway", + "name": "Runway", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 5, + "displayName": "Runway" + }, + { + "slug": "tripo-3d", + "name": "Tripo 3D", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 5, + "displayName": "Tripo 3D" + }, + { + "slug": "grok-image", + "name": "Grok Image", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 4, + "displayName": "Grok Image" + }, + { + "slug": "luma-dream-machine", + "name": "Luma Dream Machine", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 4, + "displayName": "Luma Dream Machine" + }, + { + "slug": "moonvalley", + "name": "Moonvalley", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 4, + "displayName": "Moonvalley" + }, + { + "slug": "hailuo-minimax", + "name": "Hailuo MiniMax", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 3, + "displayName": "Hailuo MiniMax" + }, + { + "slug": "magnific-ai", + "name": "Magnific AI", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 3, + "displayName": "Magnific AI" + }, + { + "slug": "meshy-ai", + "name": "Meshy AI", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 3, + "displayName": "Meshy AI" + }, + { + "slug": "pixverse", + "name": "Pixverse", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 3, + "displayName": "Pixverse" + }, + { + "slug": "recraft", + "name": "Recraft", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 3, + "displayName": "Recraft" + }, + { + "slug": "rodin-3d", + "name": "Rodin 3D", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 3, + "displayName": "Rodin 3D" + }, + { + "slug": "bria-ai", + "name": "Bria AI", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 2, + "displayName": "Bria AI" + }, + { + "slug": "gemini-image", + "name": "Gemini Image", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 2, + "displayName": "Gemini Image" + }, + { + "slug": "hunyuan-3d", + "name": "Hunyuan 3D", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 2, + "displayName": "Hunyuan 3D" + }, + { + "slug": "ltxv-api", + "name": "LTX Video (API)", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 2, + "displayName": "LTX Video (API)" + }, + { + "slug": "topaz-labs", + "name": "Topaz Labs", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 2, + "displayName": "Topaz Labs" + }, + { + "slug": "wavespeed", + "name": "Wavespeed", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 2, + "displayName": "Wavespeed" + }, + { + "slug": "ideogram", + "name": "Ideogram", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 1, + "displayName": "Ideogram" + }, + { + "slug": "nano-banana", + "name": "Nano Banana", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 1, + "displayName": "Nano Banana" + }, + { + "slug": "veo-2", + "name": "Veo 2", + "huggingFaceUrl": "", + "directory": "partner_nodes", + "workflowCount": 1, + "displayName": "Veo 2" + }, + { + "slug": "umt5-xxl-fp8-e4m3fn-scaled", + "name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "directory": "text_encoders", + "workflowCount": 34, + "displayName": "Umt5 Xxl FP8 e4m3fn scaled", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/03_video_wan2_2_14B_i2v_subgraphed-1.webp" + }, + { + "slug": "wan-2-1-vae", + "name": "wan_2.1_vae.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/vae/wan_2.1_vae.safetensors", + "directory": "vae", + "workflowCount": 29, + "displayName": "Wan 2.1 Vae", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/03_video_wan2_2_14B_i2v_subgraphed-1.webp" + }, + { + "slug": "ae", + "name": "ae.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors", + "directory": "vae", + "workflowCount": 26, + "displayName": "Ae", + "docsUrl": "https://docs.comfy.org/tutorials/image/z-image/z-image", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/01_get_started_text_to_image-1.webp" + }, + { + "slug": "qwen-2-5-vl-7b-fp8-scaled", + "name": "qwen_2.5_vl_7b_fp8_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors", + "directory": "text_encoders", + "workflowCount": 25, + "displayName": "Qwen 2.5 Vl 7b FP8 scaled", + "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/02_qwen_Image_edit_subgraphed-1.webp" + }, + { + "slug": "qwen-image-vae", + "name": "qwen_image_vae.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors", + "directory": "vae", + "workflowCount": 19, + "displayName": "Qwen Image Vae", + "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/02_qwen_Image_edit_subgraphed-1.webp" + }, + { + "slug": "clip-l", + "name": "clip_l.safetensors", + "huggingFaceUrl": "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/clip_l.safetensors", + "directory": "text_encoders", + "workflowCount": 14, + "displayName": "Clip L", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-fill-dev", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_krea_dev-1.webp" + }, + { + "slug": "t5xxl-fp16", + "name": "t5xxl_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/t5xxl_fp16.safetensors", + "directory": "text_encoders", + "workflowCount": 12, + "displayName": "T5xxl FP16", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-fill-dev", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_krea_dev-1.webp" + }, + { + "slug": "clip-vision-h", + "name": "clip_vision_h.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/clip_vision/clip_vision_h.safetensors", + "directory": "clip_vision", + "workflowCount": 12, + "displayName": "Clip Vision H", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-animate", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_chrono_edit_14B-1.webp" + }, + { + "slug": "flux2-vae", + "name": "flux2-vae.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/vae/flux2-vae.safetensors", + "directory": "vae", + "workflowCount": 9, + "displayName": "Flux2 Vae", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-2-klein", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2-1.webp" + }, + { + "slug": "qwen-3-4b", + "name": "qwen_3_4b.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors", + "directory": "text_encoders", + "workflowCount": 8, + "displayName": "Qwen 3 4b", + "docsUrl": "https://docs.comfy.org/tutorials/image/z-image/z-image", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/01_get_started_text_to_image-1.webp" + }, + { + "slug": "qwen-image-edit-2509-fp8-e4m3fn", + "name": "qwen_image_edit_2509_fp8_e4m3fn.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_2509_fp8_e4m3fn.safetensors", + "directory": "diffusion_models", + "workflowCount": 8, + "displayName": "Qwen Image Edit 2509 FP8 e4m3fn", + "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/02_qwen_Image_edit_subgraphed-1.webp" + }, + { + "slug": "t5xxl-fp8-e4m3fn-scaled", + "name": "t5xxl_fp8_e4m3fn_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/t5xxl_fp8_e4m3fn_scaled.safetensors", + "directory": "text_encoders", + "workflowCount": 8, + "displayName": "T5xxl FP8 e4m3fn scaled", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_kontext_dev_basic-1.webp", + "canonicalSlug": "t5xxl-fp16" + }, + { + "slug": "wan2-2-i2v-lightx2v-4steps-lora-v1-high-noise", + "name": "wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/loras/wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors", + "directory": "loras", + "workflowCount": 7, + "displayName": "Wan2.2 I2v Lightx2v 4steps Lora V1 High Noise", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/03_video_wan2_2_14B_i2v_subgraphed-1.webp" + }, + { + "slug": "wan2-2-i2v-lightx2v-4steps-lora-v1-low-noise", + "name": "wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/loras/wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors", + "directory": "loras", + "workflowCount": 7, + "displayName": "Wan2.2 I2v Lightx2v 4steps Lora V1 Low Noise", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/03_video_wan2_2_14B_i2v_subgraphed-1.webp" + }, + { + "slug": "gemma-3-12b-it-fp4-mixed", + "name": "gemma_3_12B_it_fp4_mixed.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/ltx-2/resolve/main/split_files/text_encoders/gemma_3_12B_it_fp4_mixed.safetensors", + "directory": "text_encoders", + "workflowCount": 7, + "displayName": "Gemma 3 12B It FP4 mixed", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_canny_to_video-1.webp" + }, + { + "slug": "ltx-2-spatial-upscaler-x2-1-0", + "name": "ltx-2-spatial-upscaler-x2-1.0.safetensors", + "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-spatial-upscaler-x2-1.0.safetensors", + "directory": "latent_upscale_models", + "workflowCount": 7, + "displayName": "Ltx 2 Spatial Upscaler X2 1.0", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_canny_to_video-1.webp" + }, + { + "slug": "qwen-image-edit-2509-lightning-4steps-v1-0-bf16", + "name": "Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-2509/Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16.safetensors", + "directory": "loras", + "workflowCount": 6, + "displayName": "Qwen Image Edit 2509 Lightning 4steps V1.0 BF16", + "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/02_qwen_Image_edit_subgraphed-1.webp" + }, + { + "slug": "wan2-1-vace-1-3b-fp16", + "name": "wan2.1_vace_1.3B_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_vace_1.3B_fp16.safetensors", + "directory": "diffusion_models", + "workflowCount": 6, + "displayName": "Wan2.1 Vace 1.3B FP16", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/vace", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan_vace_14B_ref2v-1.webp" + }, + { + "slug": "wan21-causvid-bidirect2-t2v-1-3b-lora-rank32", + "name": "Wan21_CausVid_bidirect2_T2V_1_3B_lora_rank32.safetensors", + "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Wan21_CausVid_bidirect2_T2V_1_3B_lora_rank32.safetensors", + "directory": "loras", + "workflowCount": 6, + "displayName": "Wan21 CausVid Bidirect2 T2V 1 3B Lora Rank32", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/vace", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan_vace_14B_ref2v-1.webp" + }, + { + "slug": "umt5-xxl-fp16", + "name": "umt5_xxl_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/text_encoders/umt5_xxl_fp16.safetensors", + "directory": "text_encoders", + "workflowCount": 6, + "displayName": "Umt5 Xxl FP16", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/vace", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan_vace_14B_ref2v-1.webp", + "canonicalSlug": "umt5-xxl-fp8-e4m3fn-scaled" + }, + { + "slug": "wan2-1-vace-14b-fp16", + "name": "wan2.1_vace_14B_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_vace_14B_fp16.safetensors", + "directory": "diffusion_models", + "workflowCount": 6, + "displayName": "Wan2.1 Vace 14B FP16", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/vace", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan_vace_14B_ref2v-1.webp" + }, + { + "slug": "wan21-causvid-14b-t2v-lora-rank32", + "name": "Wan21_CausVid_14B_T2V_lora_rank32.safetensors", + "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Wan21_CausVid_14B_T2V_lora_rank32.safetensors", + "directory": "loras", + "workflowCount": 6, + "displayName": "Wan21 CausVid 14B T2V Lora Rank32", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/vace", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan_vace_14B_ref2v-1.webp" + }, + { + "slug": "wan2-2-i2v-high-noise-14b-fp8-scaled", + "name": "wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors", + "directory": "diffusion_models", + "workflowCount": 5, + "displayName": "Wan2.2 I2v High Noise 14B FP8 scaled", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/03_video_wan2_2_14B_i2v_subgraphed-1.webp" + }, + { + "slug": "wan2-2-i2v-low-noise-14b-fp8-scaled", + "name": "wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors", + "directory": "diffusion_models", + "workflowCount": 5, + "displayName": "Wan2.2 I2v Low Noise 14B FP8 scaled", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/03_video_wan2_2_14B_i2v_subgraphed-1.webp" + }, + { + "slug": "vae-ft-mse-840000-ema-pruned", + "name": "vae-ft-mse-840000-ema-pruned.safetensors", + "huggingFaceUrl": "https://huggingface.co/stabilityai/sd-vae-ft-mse-original/resolve/main/vae-ft-mse-840000-ema-pruned.safetensors", + "directory": "vae", + "workflowCount": 5, + "displayName": "Vae Ft Mse 840000 Ema Pruned", + "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_depth_lora_example-1.webp" + }, + { + "slug": "lotus-depth-d-v1-1", + "name": "lotus-depth-d-v1-1.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/lotus/resolve/main/lotus-depth-d-v1-1.safetensors", + "directory": "diffusion_models", + "workflowCount": 5, + "displayName": "Lotus Depth D V1 1", + "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_depth_lora_example-1.webp" + }, + { + "slug": "clip-g-hidream", + "name": "clip_g_hidream.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/text_encoders/clip_g_hidream.safetensors", + "directory": "text_encoders", + "workflowCount": 5, + "displayName": "Clip G Hidream", + "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-i1", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_e1_1-1.webp" + }, + { + "slug": "clip-l-hidream", + "name": "clip_l_hidream.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/text_encoders/clip_l_hidream.safetensors", + "directory": "text_encoders", + "workflowCount": 5, + "displayName": "Clip L Hidream", + "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-i1", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_e1_1-1.webp" + }, + { + "slug": "llama-3-1-8b-instruct-fp8-scaled", + "name": "llama_3.1_8b_instruct_fp8_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/text_encoders/llama_3.1_8b_instruct_fp8_scaled.safetensors", + "directory": "text_encoders", + "workflowCount": 5, + "displayName": "Llama 3.1 8b Instruct FP8 scaled", + "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-i1", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_e1_1-1.webp" + }, + { + "slug": "qwen-image-lightning-4steps-v1-0", + "name": "Qwen-Image-Lightning-4steps-V1.0.safetensors", + "huggingFaceUrl": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-4steps-V1.0.safetensors", + "directory": "loras", + "workflowCount": 5, + "displayName": "Qwen Image Lightning 4steps V1.0", + "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_Image_2512-1.webp" + }, + { + "slug": "qwen-image-fp8-e4m3fn", + "name": "qwen_image_fp8_e4m3fn.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_fp8_e4m3fn.safetensors", + "directory": "diffusion_models", + "workflowCount": 5, + "displayName": "Qwen Image FP8 e4m3fn", + "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image-1.webp" + }, + { + "slug": "lightx2v-i2v-14b-480p-cfg-step-distill-rank64-bf16", + "name": "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Lightx2v/lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "directory": "loras", + "workflowCount": 5, + "displayName": "Lightx2v I2V 14B 480p Cfg Step Distill Rank64 BF16", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-animate", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_humo-1.webp" + }, + { + "slug": "ltx-2-19b-dev-fp8", + "name": "ltx-2-19b-dev-fp8.safetensors", + "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-dev-fp8.safetensors", + "directory": "checkpoints", + "workflowCount": 5, + "displayName": "Ltx 2 19b Dev FP8", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_canny_to_video-1.webp" + }, + { + "slug": "ltx-2-19b-distilled-lora-384", + "name": "ltx-2-19b-distilled-lora-384.safetensors", + "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-distilled-lora-384.safetensors", + "directory": "loras", + "workflowCount": 5, + "displayName": "Ltx 2 19b Distilled Lora 384", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_canny_to_video-1.webp" + }, + { + "slug": "ltx-2-19b-distilled", + "name": "ltx-2-19b-distilled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-distilled.safetensors", + "directory": "checkpoints", + "workflowCount": 5, + "displayName": "Ltx 2 19b Distilled", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_canny_to_video-1.webp" + }, + { + "slug": "z-image-turbo-bf16", + "name": "z_image_turbo_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors", + "directory": "diffusion_models", + "workflowCount": 4, + "displayName": "Z Image Turbo BF16", + "docsUrl": "https://docs.comfy.org/tutorials/image/z-image/z-image-turbo", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/01_get_started_text_to_image-1.webp" + }, + { + "slug": "ace-step-v1-3-5b", + "name": "ace_step_v1_3.5b.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/ACE-Step_ComfyUI_repackaged/resolve/main/all_in_one/ace_step_v1_3.5b.safetensors?download=true", + "directory": "checkpoints", + "workflowCount": 4, + "displayName": "Ace Step V1 3.5b", + "docsUrl": "https://docs.comfy.org/tutorials/audio/ace-step/ace-step-v1", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/05_audio_ace_step_1_t2a_song_subgraphed-1.webp" + }, + { + "slug": "sd3-5-large-fp8-scaled", + "name": "sd3.5_large_fp8_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/stable-diffusion-3.5-fp8/resolve/main/sd3.5_large_fp8_scaled.safetensors?download=true", + "directory": "checkpoints", + "workflowCount": 4, + "displayName": "Sd3.5 Large FP8 scaled", + "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sd3/#sd35-controlnets", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sd3.5_large_blur-1.webp" + }, + { + "slug": "sd-xl-base-1-0", + "name": "sd_xl_base_1.0.safetensors", + "huggingFaceUrl": "https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors?download=true", + "directory": "checkpoints", + "workflowCount": 4, + "displayName": "Sd Xl Base 1.0", + "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sdxl/", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sdxl_refiner_prompt_example-1.webp" + }, + { + "slug": "ltx-2-19b-lora-camera-control-dolly-left", + "name": "ltx-2-19b-lora-camera-control-dolly-left.safetensors", + "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Left/resolve/main/ltx-2-19b-lora-camera-control-dolly-left.safetensors", + "directory": "loras", + "workflowCount": 4, + "displayName": "Ltx 2 19b Lora Camera Control Dolly Left", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_i2v-1.webp" + }, + { + "slug": "sigclip-vision-patch14-384", + "name": "sigclip_vision_patch14_384.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/sigclip_vision_384/resolve/main/sigclip_vision_patch14_384.safetensors", + "directory": "clip_vision", + "workflowCount": 3, + "displayName": "Sigclip Vision Patch14 384", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-uso", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_dev_uso_reference_image_gen-1.webp" + }, + { + "slug": "flux1-dev", + "name": "flux1-dev.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/flux1-dev.safetensors", + "directory": "diffusion_models", + "workflowCount": 3, + "displayName": "Flux1 Dev", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-text-to-image", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_dev_checkpoint_example-1.webp" + }, + { + "slug": "hunyuan-video-vae-bf16", + "name": "hunyuan_video_vae_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/vae/hunyuan_video_vae_bf16.safetensors?download=true", + "directory": "vae", + "workflowCount": 3, + "displayName": "Hunyuan Video Vae BF16", + "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_video/", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hunyuan_video_text_to_video-1.webp" + }, + { + "slug": "qwen-image-edit-2511-bf16", + "name": "qwen_image_edit_2511_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_2511_bf16.safetensors", + "directory": "diffusion_models", + "workflowCount": 3, + "displayName": "Qwen Image Edit 2511 BF16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image-qwen_image_edit_2511-lora-inflation-1.webp" + }, + { + "slug": "flux2-dev-fp8mixed", + "name": "flux2_dev_fp8mixed.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/diffusion_models/flux2_dev_fp8mixed.safetensors", + "directory": "diffusion_models", + "workflowCount": 3, + "displayName": "Flux2 Dev fp8mixed", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2-1.webp" + }, + { + "slug": "qwen-3-8b-fp8mixed", + "name": "qwen_3_8b_fp8mixed.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-klein-9B/resolve/main/split_files/text_encoders/qwen_3_8b_fp8mixed.safetensors", + "directory": "text_encoders", + "workflowCount": 3, + "displayName": "Qwen 3 8b fp8mixed", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-2-klein", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_image_edit_9b_base-1.webp" + }, + { + "slug": "wan2-2-vae", + "name": "wan2.2_vae.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/vae/wan2.2_vae.safetensors", + "directory": "vae", + "workflowCount": 3, + "displayName": "Wan2.2 Vae", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_5B_fun_control-1.webp" + }, + { + "slug": "hunyuan-3d-v2-1", + "name": "hunyuan_3d_v2.1.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/hunyuan3D_2.1_repackaged/resolve/main/hunyuan_3d_v2.1.safetensors", + "directory": "checkpoints", + "workflowCount": 2, + "displayName": "Hunyuan 3d V2.1", + "docsUrl": "https://docs.comfy.org/tutorials/3d/hunyuan3D-2", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/04_hunyuan_3d_2.1_subgraphed-1.webp" + }, + { + "slug": "flux1-dev-fp8", + "name": "flux1-dev-fp8.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/flux1-dev-fp8.safetensors", + "directory": "checkpoints", + "workflowCount": 2, + "displayName": "Flux1 Dev FP8", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-uso", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_dev_uso_reference_image_gen-1.webp" + }, + { + "slug": "flux1-fill-dev", + "name": "flux1-fill-dev.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/split_files/diffusion_models/flux1-fill-dev.safetensors", + "directory": "diffusion_models", + "workflowCount": 2, + "displayName": "Flux1 Fill Dev", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-fill-dev", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_fill_inpaint_example-1.webp" + }, + { + "slug": "mistral-3-small-flux2-bf16", + "name": "mistral_3_small_flux2_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/text_encoders/mistral_3_small_flux2_bf16.safetensors", + "directory": "text_encoders", + "workflowCount": 2, + "displayName": "Mistral 3 Small Flux2 BF16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2-1.webp" + }, + { + "slug": "flux-2-klein-base-9b-fp8", + "name": "flux-2-klein-base-9b-fp8.safetensors", + "huggingFaceUrl": "https://huggingface.co/black-forest-labs/FLUX.2-klein-base-9b-fp8/resolve/main/flux-2-klein-base-9b-fp8.safetensors", + "directory": "diffusion_models", + "workflowCount": 2, + "displayName": "Flux 2 Klein Base 9b FP8", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-2-klein", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_image_edit_9b_base-1.webp" + }, + { + "slug": "flux-2-klein-9b-fp8", + "name": "flux-2-klein-9b-fp8.safetensors", + "huggingFaceUrl": "https://huggingface.co/black-forest-labs/FLUX.2-klein-9b-fp8/resolve/main/flux-2-klein-9b-fp8.safetensors", + "directory": "diffusion_models", + "workflowCount": 2, + "displayName": "Flux 2 Klein 9b FP8", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_image_edit_9b_distilled-1.webp" + }, + { + "slug": "qwen-2-5-vl-fp16", + "name": "qwen_2.5_vl_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Omnigen2_ComfyUI_repackaged/resolve/main/split_files/text_encoders/qwen_2.5_vl_fp16.safetensors", + "directory": "text_encoders", + "workflowCount": 2, + "displayName": "Qwen 2.5 Vl FP16", + "docsUrl": "https://docs.comfy.org/tutorials/image/omnigen/omnigen2", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_omnigen2_image_edit-1.webp" + }, + { + "slug": "omnigen2-fp16", + "name": "omnigen2_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Omnigen2_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/omnigen2_fp16.safetensors", + "directory": "diffusion_models", + "workflowCount": 2, + "displayName": "Omnigen2 FP16", + "docsUrl": "https://docs.comfy.org/tutorials/image/omnigen/omnigen2", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_omnigen2_image_edit-1.webp" + }, + { + "slug": "qwen-image-2512-fp8-e4m3fn", + "name": "qwen_image_2512_fp8_e4m3fn.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_2512_fp8_e4m3fn.safetensors", + "directory": "diffusion_models", + "workflowCount": 2, + "displayName": "Qwen Image 2512 FP8 e4m3fn", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_Image_2512-1.webp" + }, + { + "slug": "qwen-image-edit-2511-lightning-4steps-v1-0-bf16", + "name": "Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/lightx2v/Qwen-Image-Edit-2511-Lightning/resolve/main/Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors", + "directory": "loras", + "workflowCount": 2, + "displayName": "Qwen Image Edit 2511 Lightning 4steps V1.0 BF16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_edit_2511-1.webp" + }, + { + "slug": "qwen-image-layered-vae", + "name": "qwen_image_layered_vae.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Layered_ComfyUI/resolve/main/split_files/vae/qwen_image_layered_vae.safetensors", + "directory": "vae", + "workflowCount": 2, + "displayName": "Qwen Image Layered Vae", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_layered-1.webp" + }, + { + "slug": "sd-xl-refiner-1-0", + "name": "sd_xl_refiner_1.0.safetensors", + "huggingFaceUrl": "https://huggingface.co/stabilityai/stable-diffusion-xl-refiner-1.0/resolve/main/sd_xl_refiner_1.0.safetensors?download=true", + "directory": "checkpoints", + "workflowCount": 2, + "displayName": "Sd Xl Refiner 1.0", + "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sdxl/", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sdxl_refiner_prompt_example-1.webp" + }, + { + "slug": "wan2-1-vae-bf16", + "name": "Wan2_1_VAE_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Wan2_1_VAE_bf16.safetensors", + "directory": "vae", + "workflowCount": 2, + "displayName": "Wan2 1 VAE BF16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/template-Animation_Trajectory_Control_Wan_ATI-1.webp" + }, + { + "slug": "wan2-1-i2v-ati-14b-fp8-e4m3fn", + "name": "Wan2_1-I2V-ATI-14B_fp8_e4m3fn.safetensors", + "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Wan2_1-I2V-ATI-14B_fp8_e4m3fn.safetensors", + "directory": "diffusion_models", + "workflowCount": 2, + "displayName": "Wan2 1 I2V ATI 14B FP8 e4m3fn", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan-ati", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/template-Animation_Trajectory_Control_Wan_ATI-1.webp" + }, + { + "slug": "qwen-edit-2509-multiple-angles", + "name": "Qwen-Edit-2509-Multiple-angles.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/loras/Qwen-Edit-2509-Multiple-angles.safetensors", + "directory": "loras", + "workflowCount": 2, + "displayName": "Qwen Edit 2509 Multiple Angles", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/templates-1_click_multiple_character_angles-v1.0-1.webp" + }, + { + "slug": "qwen-image-edit-2509-lightning-8steps-v1-0-bf16", + "name": "Qwen-Image-Edit-2509-Lightning-8steps-V1.0-bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-2509/Qwen-Image-Edit-2509-Lightning-8steps-V1.0-bf16.safetensors", + "directory": "loras", + "workflowCount": 2, + "displayName": "Qwen Image Edit 2509 Lightning 8steps V1.0 BF16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/templates-image_to_real-1.webp" + }, + { + "slug": "byt5-small-glyphxl-fp16", + "name": "byt5_small_glyphxl_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/text_encoders/byt5_small_glyphxl_fp16.safetensors", + "directory": "text_encoders", + "workflowCount": 2, + "displayName": "Byt5 Small Glyphxl FP16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_hunyuan_video_1.5_720p_i2v-1.webp" + }, + { + "slug": "hunyuanvideo15-latent-upsampler-1080p", + "name": "hunyuanvideo15_latent_upsampler_1080p.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/latent_upscale_models/hunyuanvideo15_latent_upsampler_1080p.safetensors", + "directory": "latent_upscale_models", + "workflowCount": 2, + "displayName": "Hunyuanvideo15 Latent Upsampler 1080p", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_hunyuan_video_1.5_720p_i2v-1.webp" + }, + { + "slug": "hunyuanvideo1-5-1080p-sr-distilled-fp16", + "name": "hunyuanvideo1.5_1080p_sr_distilled_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/diffusion_models/hunyuanvideo1.5_1080p_sr_distilled_fp16.safetensors", + "directory": "diffusion_models", + "workflowCount": 2, + "displayName": "Hunyuanvideo1.5 1080p Sr Distilled FP16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_hunyuan_video_1.5_720p_i2v-1.webp" + }, + { + "slug": "hunyuanvideo15-vae-fp16", + "name": "hunyuanvideo15_vae_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/vae/hunyuanvideo15_vae_fp16.safetensors", + "directory": "vae", + "workflowCount": 2, + "displayName": "Hunyuanvideo15 Vae FP16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_hunyuan_video_1.5_720p_i2v-1.webp" + }, + { + "slug": "wan2-2-t2v-lightx2v-4steps-lora-v1-1-high-noise", + "name": "wan2.2_t2v_lightx2v_4steps_lora_v1.1_high_noise.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/loras/wan2.2_t2v_lightx2v_4steps_lora_v1.1_high_noise.safetensors", + "directory": "loras", + "workflowCount": 2, + "displayName": "Wan2.2 T2v Lightx2v 4steps Lora V1.1 High Noise", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_s2v-1.webp" + }, + { + "slug": "wan21-wanmove-fp8-scaled-e4m3fn-kj", + "name": "Wan21-WanMove_fp8_scaled_e4m3fn_KJ.safetensors", + "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy_fp8_scaled/resolve/main/WanMove/Wan21-WanMove_fp8_scaled_e4m3fn_KJ.safetensors", + "directory": "diffusion_models", + "workflowCount": 2, + "displayName": "Wan21 WanMove FP8 scaled e4m3fn KJ", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wanmove_480p-1.webp" + }, + { + "slug": "hunyuan3d-dit-v2-fp16", + "name": "hunyuan3d-dit-v2_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/hunyuan3D_2.0_repackaged/resolve/main/split_files/hunyuan3d-dit-v2_fp16.safetensors", + "directory": "checkpoints", + "workflowCount": 1, + "displayName": "Hunyuan3d Dit V2 FP16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/3d_hunyuan3d_image_to_model-1.webp" + }, + { + "slug": "hunyuan3d-dit-v2-mv-fp16", + "name": "hunyuan3d-dit-v2-mv_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/hunyuan3D_2.0_repackaged/resolve/main/split_files/hunyuan3d-dit-v2-mv_fp16.safetensors", + "directory": "checkpoints", + "workflowCount": 1, + "displayName": "Hunyuan3d Dit V2 Mv FP16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/3d_hunyuan3d_multiview_to_model-1.webp" + }, + { + "slug": "hunyuan3d-dit-v2-mv-turbo-fp16", + "name": "hunyuan3d-dit-v2-mv-turbo_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/hunyuan3D_2.0_repackaged/resolve/main/split_files/hunyuan3d-dit-v2-mv-turbo_fp16.safetensors", + "directory": "checkpoints", + "workflowCount": 1, + "displayName": "Hunyuan3d Dit V2 Mv Turbo FP16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/3d_hunyuan3d_multiview_to_model_turbo-1.webp" + }, + { + "slug": "stable-audio-open-1-0", + "name": "stable-audio-open-1.0.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/stable-audio-open-1.0_repackaged/resolve/main/stable-audio-open-1.0.safetensors", + "directory": "checkpoints", + "workflowCount": 1, + "displayName": "Stable Audio Open 1.0", + "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/audio/" + }, + { + "slug": "t5-base", + "name": "t5-base.safetensors", + "huggingFaceUrl": "https://huggingface.co/ComfyUI-Wiki/t5-base/resolve/main/t5-base.safetensors", + "directory": "text_encoders", + "workflowCount": 1, + "displayName": "T5 Base", + "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/audio/" + }, + { + "slug": "v1-5-pruned-emaonly-fp16", + "name": "v1-5-pruned-emaonly-fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true", + "directory": "checkpoints", + "workflowCount": 1, + "displayName": "V1 5 Pruned Emaonly FP16", + "docsUrl": "https://docs.comfy.org/tutorials/basic/text-to-image", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/default-1.webp" + }, + { + "slug": "uso-flux1-projector-v1", + "name": "uso-flux1-projector-v1.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/USO_1.0_Repackaged/resolve/main/split_files/model_patches/uso-flux1-projector-v1.safetensors", + "directory": "model_patches", + "workflowCount": 1, + "displayName": "Uso Flux1 Projector V1", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-uso", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_dev_uso_reference_image_gen-1.webp" + }, + { + "slug": "uso-flux1-dit-lora-v1", + "name": "uso-flux1-dit-lora-v1.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/USO_1.0_Repackaged/resolve/main/split_files/loras/uso-flux1-dit-lora-v1.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Uso Flux1 Dit Lora V1", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-uso", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_dev_uso_reference_image_gen-1.webp" + }, + { + "slug": "flux1-krea-dev-fp8-scaled", + "name": "flux1-krea-dev_fp8_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/FLUX.1-Krea-dev_ComfyUI/resolve/main/split_files/diffusion_models/flux1-krea-dev_fp8_scaled.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Flux1 Krea Dev FP8 scaled", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux1-krea-dev", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_krea_dev-1.webp" + }, + { + "slug": "flux1-canny-dev", + "name": "flux1-canny-dev.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/split_files/diffusion_models/flux1-canny-dev.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Flux1 Canny Dev", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-controlnet", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_canny_model_example-1.webp" + }, + { + "slug": "flux1-depth-dev-lora", + "name": "flux1-depth-dev-lora.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/split_files/loras/flux1-depth-dev-lora.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Flux1 Depth Dev Lora", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-controlnet", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_depth_lora_example-1.webp" + }, + { + "slug": "flux1-dev-kontext-fp8-scaled", + "name": "flux1-dev-kontext_fp8_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-kontext-dev_ComfyUI/resolve/main/split_files/diffusion_models/flux1-dev-kontext_fp8_scaled.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Flux1 Dev Kontext FP8 scaled", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_kontext_dev_basic-1.webp" + }, + { + "slug": "flux1-redux-dev", + "name": "flux1-redux-dev.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Flux1-Redux-Dev/resolve/main/flux1-redux-dev.safetensors", + "directory": "style_models", + "workflowCount": 1, + "displayName": "Flux1 Redux Dev", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-controlnet", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_redux_model_example-1.webp" + }, + { + "slug": "flux1-schnell-fp8", + "name": "flux1-schnell-fp8.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-schnell/resolve/main/flux1-schnell-fp8.safetensors?download=true", + "directory": "checkpoints", + "workflowCount": 1, + "displayName": "Flux1 Schnell FP8", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-text-to-image", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_schnell-1.webp" + }, + { + "slug": "flux1-schnell", + "name": "flux1-schnell.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-schnell/resolve/main/flux1-schnell.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Flux1 Schnell", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-text-to-image", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_schnell_full_text_to_image-1.webp" + }, + { + "slug": "hidream-e1-1-bf16", + "name": "hidream_e1_1_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/diffusion_models/hidream_e1_1_bf16.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Hidream E1 1 BF16", + "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-e1", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_e1_1-1.webp" + }, + { + "slug": "hidream-e1-full-bf16", + "name": "hidream_e1_full_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/diffusion_models/hidream_e1_full_bf16.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Hidream E1 Full BF16", + "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-e1", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_e1_full-1.webp" + }, + { + "slug": "hidream-i1-dev-fp8", + "name": "hidream_i1_dev_fp8.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/diffusion_models/hidream_i1_dev_fp8.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Hidream I1 Dev FP8", + "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-i1", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_i1_dev-1.webp" + }, + { + "slug": "hidream-i1-fast-fp8", + "name": "hidream_i1_fast_fp8.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/diffusion_models/hidream_i1_fast_fp8.safetensors?download=true", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Hidream I1 Fast FP8", + "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-i1", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_i1_fast-1.webp" + }, + { + "slug": "hidream-i1-full-fp8", + "name": "hidream_i1_full_fp8.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/diffusion_models/hidream_i1_full_fp8.safetensors?download=true", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Hidream I1 Full FP8", + "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-i1", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_i1_full-1.webp" + }, + { + "slug": "llava-llama3-fp8-scaled", + "name": "llava_llama3_fp8_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/text_encoders/llava_llama3_fp8_scaled.safetensors?download=true", + "directory": "text_encoders", + "workflowCount": 1, + "displayName": "Llava Llama3 FP8 scaled", + "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_video/", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hunyuan_video_text_to_video-1.webp" + }, + { + "slug": "hunyuan-video-t2v-720p-bf16", + "name": "hunyuan_video_t2v_720p_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/diffusion_models/hunyuan_video_t2v_720p_bf16.safetensors?download=true", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Hunyuan Video T2v 720p BF16", + "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_video/", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hunyuan_video_text_to_video-1.webp" + }, + { + "slug": "qwen-image-edit-2511-systms-infl8", + "name": "Qwen_Image_Edit_2511-SYSTMS_INFL8.safetensors", + "huggingFaceUrl": "https://huggingface.co/systms/SYSTMS-INFL8-LoRA-Qwen-Image-Edit-2511/resolve/main/SYSTMS_INFL8_LoRA_Qwen_Image_Edit_2511.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Qwen Image Edit 2511 SYSTMS INFL8", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image-qwen_image_edit_2511-lora-inflation-1.webp" + }, + { + "slug": "chroma-radiance-x0", + "name": "chroma-radiance-x0.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Chroma1-Radiance_Repackaged/resolve/main/split_files/diffusion_models/chroma-radiance-x0.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Chroma Radiance X0", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_chroma1_radiance_text_to_image-1.webp" + }, + { + "slug": "chroma1-hd-fp8mixed", + "name": "Chroma1-HD-fp8mixed.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Chroma1-HD_repackaged/resolve/main/split_files/diffusion_models/Chroma1-HD-fp8mixed.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Chroma1 HD fp8mixed", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_chroma_text_to_image-1.webp" + }, + { + "slug": "chronoedit-distill-lora", + "name": "chronoedit_distill_lora.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/loras/chronoedit_distill_lora.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Chronoedit Distill Lora", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_chrono_edit_14B-1.webp" + }, + { + "slug": "chrono-edit-14b-fp16", + "name": "chrono_edit_14B_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/chrono_edit_14B_fp16.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Chrono Edit 14B FP16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_chrono_edit_14B-1.webp" + }, + { + "slug": "flux-1-fill-dev-onereward-transformer-fp8", + "name": "flux.1-fill-dev-OneReward-transformer_fp8.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/OneReward_repackaged/resolve/main/split_files/diffusion_models/flux.1-fill-dev-OneReward-transformer_fp8.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Flux.1 Fill Dev OneReward Transformer FP8", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux.1_fill_dev_OneReward-1.webp" + }, + { + "slug": "removal-timestep-alpha-2-1740", + "name": "removal_timestep_alpha-2-1740.safetensors", + "huggingFaceUrl": "https://huggingface.co/lrzjason/ObjectRemovalFluxFill/resolve/main/removal_timestep_alpha-2-1740.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Removal Timestep Alpha 2 1740", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux.1_fill_dev_OneReward-1.webp" + }, + { + "slug": "flux-2-turbo-lora-comfyui", + "name": "Flux_2-Turbo-LoRA_comfyui.safetensors", + "huggingFaceUrl": "https://huggingface.co/ByteZSzn/Flux.2-Turbo-ComfyUI/resolve/main/Flux_2-Turbo-LoRA_comfyui.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Flux 2 Turbo LoRA Comfyui", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2-1.webp" + }, + { + "slug": "mistral-3-small-flux2-fp8", + "name": "mistral_3_small_flux2_fp8.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/text_encoders/mistral_3_small_flux2_fp8.safetensors", + "directory": "text_encoders", + "workflowCount": 1, + "displayName": "Mistral 3 Small Flux2 FP8", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_fp8-1.webp", + "canonicalSlug": "mistral-3-small-flux2-bf16" + }, + { + "slug": "flux2turbocomfyv2", + "name": "Flux2TurboComfyv2.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/loras/Flux2TurboComfyv2.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Flux2TurboComfyv2", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_fp8-1.webp" + }, + { + "slug": "flux-2-klein-base-4b-fp8", + "name": "flux-2-klein-base-4b-fp8.safetensors", + "huggingFaceUrl": "https://huggingface.co/black-forest-labs/FLUX.2-klein-base-4b-fp8/resolve/main/flux-2-klein-base-4b-fp8.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Flux 2 Klein Base 4b FP8", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-2-klein", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_image_edit_4b_base-1.webp" + }, + { + "slug": "flux-2-klein-4b-fp8", + "name": "flux-2-klein-4b-fp8.safetensors", + "huggingFaceUrl": "https://huggingface.co/black-forest-labs/FLUX.2-klein-4b-fp8/resolve/main/flux-2-klein-4b-fp8.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Flux 2 Klein 4b FP8", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_image_edit_4b_distilled-1.webp" + }, + { + "slug": "flux-2-klein-base-4b", + "name": "flux-2-klein-base-4b.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-klein/resolve/main/split_files/diffusion_models/flux-2-klein-base-4b.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Flux 2 Klein Base 4b", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-2-klein", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_text_to_image-1.webp" + }, + { + "slug": "flux-2-klein-4b", + "name": "flux-2-klein-4b.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-klein/resolve/main/split_files/diffusion_models/flux-2-klein-4b.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Flux 2 Klein 4b", + "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-2-klein", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_text_to_image-1.webp" + }, + { + "slug": "netayumev35-pretrained-all-in-one", + "name": "NetaYumev35_pretrained_all_in_one.safetensors", + "huggingFaceUrl": "https://huggingface.co/duongve/NetaYume-Lumina-Image-2.0/resolve/main/NetaYumev35_pretrained_all_in_one.safetensors", + "directory": "checkpoints", + "workflowCount": 1, + "displayName": "NetaYumev35 Pretrained All In One", + "docsUrl": "https://docs.comfy.org/tutorials/image/newbie-image/newbie-image-exp-0-1", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_netayume_lumina_t2i-1.webp" + }, + { + "slug": "newbie-image-exp0-1-bf16", + "name": "NewBie-Image-Exp0.1-bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/NewBie-image-Exp0.1_repackaged/resolve/main/split_files/diffusion_models/NewBie-Image-Exp0.1-bf16.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "NewBie Image Exp0.1 BF16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_newbieimage_exp0_1-t2i-1.webp" + }, + { + "slug": "gemma-3-4b-it-bf16", + "name": "gemma_3_4b_it_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/NewBie-image-Exp0.1_repackaged/resolve/main/split_files/text_encoders/gemma_3_4b_it_bf16.safetensors", + "directory": "text_encoders", + "workflowCount": 1, + "displayName": "Gemma 3 4b It BF16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_newbieimage_exp0_1-t2i-1.webp" + }, + { + "slug": "jina-clip-v2-bf16", + "name": "jina_clip_v2_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/NewBie-image-Exp0.1_repackaged/resolve/main/split_files/text_encoders/jina_clip_v2_bf16.safetensors", + "directory": "text_encoders", + "workflowCount": 1, + "displayName": "Jina Clip V2 BF16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_newbieimage_exp0_1-t2i-1.webp" + }, + { + "slug": "ovis-image-bf16", + "name": "ovis_image_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Ovis-Image/resolve/main/split_files/diffusion_models/ovis_image_bf16.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Ovis Image BF16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_ovis_text_to_image-1.webp" + }, + { + "slug": "ovis-2-5", + "name": "ovis_2.5.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Ovis-Image/resolve/main/split_files/text_encoders/ovis_2.5.safetensors", + "directory": "text_encoders", + "workflowCount": 1, + "displayName": "Ovis 2.5", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_ovis_text_to_image-1.webp" + }, + { + "slug": "qwen-image-lightning-8steps-v1-0", + "name": "Qwen-Image-Lightning-8steps-V1.0.safetensors", + "huggingFaceUrl": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V1.0.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Qwen Image Lightning 8steps V1.0", + "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image-1.webp" + }, + { + "slug": "wuli-qwen-image-2512-turbo-lora-2steps-v1-0-bf16", + "name": "Wuli-Qwen-Image-2512-Turbo-LoRA-2steps-V1.0-bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Wuli-art/Qwen-Image-2512-Turbo-LoRA-2-Steps/resolve/main/Wuli-Qwen-Image-2512-Turbo-LoRA-2steps-V1.0-bf16.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Wuli Qwen Image 2512 Turbo LoRA 2steps V1.0 BF16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_2512_with_2stpes_lora-1.webp" + }, + { + "slug": "qwen-image-canny-diffsynth-controlnet", + "name": "qwen_image_canny_diffsynth_controlnet.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-DiffSynth-ControlNets/resolve/main/split_files/model_patches/qwen_image_canny_diffsynth_controlnet.safetensors", + "directory": "model_patches", + "workflowCount": 1, + "displayName": "Qwen Image Canny Diffsynth Controlnet", + "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_controlnet_patch-1.webp" + }, + { + "slug": "qwen-image-edit-fp8-e4m3fn", + "name": "qwen_image_edit_fp8_e4m3fn.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_fp8_e4m3fn.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Qwen Image Edit FP8 e4m3fn", + "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_edit-1.webp" + }, + { + "slug": "qwen-image-edit-lightning-4steps-v1-0-bf16", + "name": "Qwen-Image-Edit-Lightning-4steps-V1.0-bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-Lightning-4steps-V1.0-bf16.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Qwen Image Edit Lightning 4steps V1.0 BF16", + "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_edit-1.webp" + }, + { + "slug": "qwen-image-edit-2509-relight", + "name": "Qwen-Image-Edit-2509-Relight.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/loras/Qwen-Image-Edit-2509-Relight.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Qwen Image Edit 2509 Relight", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_edit_2509_relight-1.webp" + }, + { + "slug": "qwen-image-instantx-controlnet-union", + "name": "Qwen-Image-InstantX-ControlNet-Union.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-InstantX-ControlNets/resolve/main/split_files/controlnet/Qwen-Image-InstantX-ControlNet-Union.safetensors", + "directory": "controlnet", + "workflowCount": 1, + "displayName": "Qwen Image InstantX ControlNet Union", + "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_instantx_controlnet-1.webp" + }, + { + "slug": "qwen-image-instantx-controlnet-inpainting", + "name": "Qwen-Image-InstantX-ControlNet-Inpainting.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-InstantX-ControlNets/resolve/main/split_files/controlnet/Qwen-Image-InstantX-ControlNet-Inpainting.safetensors", + "directory": "controlnet", + "workflowCount": 1, + "displayName": "Qwen Image InstantX ControlNet Inpainting", + "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_instantx_inpainting_controlnet-1.webp" + }, + { + "slug": "qwen-image-layered-bf16", + "name": "qwen_image_layered_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Layered_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_layered_bf16.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Qwen Image Layered BF16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_layered-1.webp" + }, + { + "slug": "qwen-image-layered-control-bf16", + "name": "qwen_image_layered_control_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/DiffSynth-Studio/Qwen-Image-Layered-Control/resolve/main/qwen_image_layered_control_bf16.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Qwen Image Layered Control BF16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_layered_control-1.webp" + }, + { + "slug": "qwen-image-union-diffsynth-lora", + "name": "qwen_image_union_diffsynth_lora.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-DiffSynth-ControlNets/resolve/main/split_files/loras/qwen_image_union_diffsynth_lora.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Qwen Image Union Diffsynth Lora", + "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_union_control_lora-1.webp" + }, + { + "slug": "wan2-1-i2v-480p-14b-fp16", + "name": "wan2.1_i2v_480p_14B_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_i2v_480p_14B_fp16.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.1 I2v 480p 14B FP16", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan-video", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_to_video_wan-1.webp" + }, + { + "slug": "z-image-bf16", + "name": "z_image_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/z_image/resolve/main/split_files/diffusion_models/z_image_bf16.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Z Image BF16", + "docsUrl": "https://docs.comfy.org/tutorials/image/z-image/z-image", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_z_image-1.webp" + }, + { + "slug": "pixel-art-style-z-image-turbo", + "name": "pixel_art_style_z_image_turbo.safetensors", + "huggingFaceUrl": "https://huggingface.co/tarn59/pixel_art_style_lora_z_image_turbo/resolve/main/pixel_art_style_z_image_turbo.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Pixel Art Style Z Image Turbo", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_z_image_turbo-1.webp" + }, + { + "slug": "z-image-turbo-fun-controlnet-union", + "name": "Z-Image-Turbo-Fun-Controlnet-Union.safetensors", + "huggingFaceUrl": "https://huggingface.co/alibaba-pai/Z-Image-Turbo-Fun-Controlnet-Union/resolve/main/Z-Image-Turbo-Fun-Controlnet-Union.safetensors", + "directory": "model_patches", + "workflowCount": 1, + "displayName": "Z Image Turbo Fun Controlnet Union", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_z_image_turbo_fun_union_controlnet-1.webp" + }, + { + "slug": "ltx-video-2b-v0-9-5", + "name": "ltx-video-2b-v0.9.5.safetensors", + "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-Video/resolve/main/ltx-video-2b-v0.9.5.safetensors", + "directory": "checkpoints", + "workflowCount": 1, + "displayName": "Ltx Video 2b V0.9.5", + "docsUrl": "https://docs.comfy.org/tutorials/video/ltxv", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/ltxv_image_to_video-1.webp" + }, + { + "slug": "ltx-video-2b-v0-9", + "name": "ltx-video-2b-v0.9.safetensors", + "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-Video/resolve/main/ltx-video-2b-v0.9.safetensors?download=true", + "directory": "checkpoints", + "workflowCount": 1, + "displayName": "Ltx Video 2b V0.9", + "docsUrl": "https://docs.comfy.org/tutorials/video/ltxv", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/ltxv_text_to_video-1.webp" + }, + { + "slug": "sd3-5-large-controlnet-blur", + "name": "sd3.5_large_controlnet_blur.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/stable-diffusion-3.5-controlnets_ComfyUI_repackaged/resolve/main/split_files/controlnet/sd3.5_large_controlnet_blur.safetensors", + "directory": "controlnet", + "workflowCount": 1, + "displayName": "Sd3.5 Large Controlnet Blur", + "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sd3/#sd35-controlnets", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sd3.5_large_blur-1.webp" + }, + { + "slug": "sd3-5-large-controlnet-canny", + "name": "sd3.5_large_controlnet_canny.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/stable-diffusion-3.5-controlnets_ComfyUI_repackaged/resolve/main/split_files/controlnet/sd3.5_large_controlnet_canny.safetensors", + "directory": "controlnet", + "workflowCount": 1, + "displayName": "Sd3.5 Large Controlnet Canny", + "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sd3/#sd35-controlnets", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sd3.5_large_canny_controlnet_example-1.webp" + }, + { + "slug": "sd3-5-large-controlnet-depth", + "name": "sd3.5_large_controlnet_depth.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/stable-diffusion-3.5-controlnets_ComfyUI_repackaged/resolve/main/split_files/controlnet/sd3.5_large_controlnet_depth.safetensors", + "directory": "controlnet", + "workflowCount": 1, + "displayName": "Sd3.5 Large Controlnet Depth", + "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sd3/#sd35-controlnets", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sd3.5_large_depth-1.webp" + }, + { + "slug": "clip-vision-g", + "name": "clip_vision_g.safetensors", + "huggingFaceUrl": "https://huggingface.co/comfyanonymous/clip_vision_g/resolve/main/clip_vision_g.safetensors?download=true", + "directory": "clip_vision", + "workflowCount": 1, + "displayName": "Clip Vision G", + "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sdxl/#revision", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sdxl_revision_text_prompts-1.webp" + }, + { + "slug": "sd-xl-turbo-1-0-fp16", + "name": "sd_xl_turbo_1.0_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/stabilityai/sdxl-turbo/resolve/main/sd_xl_turbo_1.0_fp16.safetensors", + "directory": "checkpoints", + "workflowCount": 1, + "displayName": "Sd Xl Turbo 1.0 FP16", + "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sdturbo/", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sdxlturbo_example-1.webp" + }, + { + "slug": "wan21-t2v-14b-lightx2v-cfg-step-distill-lora-rank32", + "name": "Wan21_T2V_14B_lightx2v_cfg_step_distill_lora_rank32.safetensors", + "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Wan21_T2V_14B_lightx2v_cfg_step_distill_lora_rank32.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Wan21 T2V 14B Lightx2v Cfg Step Distill Lora Rank32", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/template-Animation_Trajectory_Control_Wan_ATI-1.webp" + }, + { + "slug": "umt5-xxl-enc-bf16", + "name": "umt5-xxl-enc-bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/umt5-xxl-enc-bf16.safetensors", + "directory": "text_encoders", + "workflowCount": 1, + "displayName": "Umt5 Xxl Enc BF16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/template-Animation_Trajectory_Control_Wan_ATI-1.webp" + }, + { + "slug": "clip-vit-h-14-laion2b-s32b-b79k", + "name": "CLIP-ViT-H-14-laion2B-s32B-b79K.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/CLIP-ViT-H-14-laion2B-s32B-b79K_repackaged/resolve/main/split_files/clip_vision/CLIP-ViT-H-14-laion2B-s32B-b79K.safetensors", + "directory": "clip_vision", + "workflowCount": 1, + "displayName": "CLIP ViT H 14 Laion2B S32B B79K", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/template-Animation_Trajectory_Control_Wan_ATI-1.webp" + }, + { + "slug": "qwen-image-edit-2509-anything2realalpha", + "name": "Qwen-Image-Edit-2509-Anything2RealAlpha.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/loras/Qwen-Image-Edit-2509-Anything2RealAlpha.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Qwen Image Edit 2509 Anything2RealAlpha", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/templates-image_to_real-1.webp" + }, + { + "slug": "qwen-image-edit-2509-light-migration", + "name": "Qwen-Image-Edit-2509-Light-Migration.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/loras/Qwen-Image-Edit-2509-Light-Migration.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Qwen Image Edit 2509 Light Migration", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/templates-portrait_light_migration-1.webp" + }, + { + "slug": "qwen-image-edit-2509-fusion", + "name": "Qwen-Image-Edit-2509-Fusion.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/loras/Qwen-Image-Edit-2509-Fusion.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Qwen Image Edit 2509 Fusion", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/templates-qwen_image_edit-crop_and_stitch-fusion-1.webp" + }, + { + "slug": "wan2-1-t2v-1-3b-fp16", + "name": "wan2.1_t2v_1.3B_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_t2v_1.3B_fp16.safetensors?download=true", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.1 T2v 1.3B FP16", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan-video", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/text_to_video_wan-1.webp" + }, + { + "slug": "svd-xt", + "name": "svd_xt.safetensors", + "huggingFaceUrl": "https://huggingface.co/stabilityai/stable-video-diffusion-img2vid-xt/resolve/main/svd_xt.safetensors?download=true", + "directory": "checkpoints", + "workflowCount": 1, + "displayName": "Svd Xt", + "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/video/#image-to-video", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/txt_to_image_to_video-1.webp" + }, + { + "slug": "realesrgan-x4plus", + "name": "RealESRGAN_x4plus.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Real-ESRGAN_repackaged/resolve/main/RealESRGAN_x4plus.safetensors", + "directory": "upscale_models", + "workflowCount": 1, + "displayName": "RealESRGAN X4plus", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/ultility-gan_upscaler-1.webp" + }, + { + "slug": "humo-17b-fp8-e4m3fn", + "name": "humo_17B_fp8_e4m3fn.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HuMo_ComfyUI/resolve/main/split_files/diffusion_models/humo_17B_fp8_e4m3fn.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Humo 17B FP8 e4m3fn", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_humo-1.webp" + }, + { + "slug": "whisper-large-v3-fp16", + "name": "whisper_large_v3_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HuMo_ComfyUI/resolve/main/split_files/audio_encoders/whisper_large_v3_fp16.safetensors", + "directory": "audio_encoders", + "workflowCount": 1, + "displayName": "Whisper Large V3 FP16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_humo-1.webp" + }, + { + "slug": "hunyuanvideo1-5-720p-i2v-fp16", + "name": "hunyuanvideo1.5_720p_i2v_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/diffusion_models/hunyuanvideo1.5_720p_i2v_fp16.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Hunyuanvideo1.5 720p I2v FP16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_hunyuan_video_1.5_720p_i2v-1.webp" + }, + { + "slug": "hunyuanvideo1-5-720p-t2v-fp16", + "name": "hunyuanvideo1.5_720p_t2v_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/diffusion_models/hunyuanvideo1.5_720p_t2v_fp16.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Hunyuanvideo1.5 720p T2v FP16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_hunyuan_video_1.5_720p_t2v-1.webp" + }, + { + "slug": "kandinsky5lite-i2v-5s", + "name": "kandinsky5lite_i2v_5s.safetensors", + "huggingFaceUrl": "https://huggingface.co/kandinskylab/Kandinsky-5.0-I2V-Lite-5s/resolve/main/model/kandinsky5lite_i2v_5s.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Kandinsky5lite I2v 5s", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_kandinsky5_i2v-1.webp" + }, + { + "slug": "kandinsky5lite-t2v-sft-5s", + "name": "kandinsky5lite_t2v_sft_5s.safetensors", + "huggingFaceUrl": "https://huggingface.co/kandinskylab/Kandinsky-5.0-T2V-Lite-sft-5s/resolve/main/model/kandinsky5lite_t2v_sft_5s.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Kandinsky5lite T2v Sft 5s", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_kandinsky5_t2v-1.webp" + }, + { + "slug": "ltx-2-19b-ic-lora-canny-control", + "name": "ltx-2-19b-ic-lora-canny-control.safetensors", + "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Canny-Control/resolve/main/ltx-2-19b-ic-lora-canny-control.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Ltx 2 19b Ic Lora Canny Control", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_canny_to_video-1.webp" + }, + { + "slug": "ltx-2-19b-ic-lora-depth-control", + "name": "ltx-2-19b-ic-lora-depth-control.safetensors", + "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Depth-Control/resolve/main/ltx-2-19b-ic-lora-depth-control.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Ltx 2 19b Ic Lora Depth Control", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_depth_to_video-1.webp" + }, + { + "slug": "ltx-2-19b-ic-lora-pose-control", + "name": "ltx-2-19b-ic-lora-pose-control.safetensors", + "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Pose-Control/resolve/main/ltx-2-19b-ic-lora-pose-control.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Ltx 2 19b Ic Lora Pose Control", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_pose_to_video-1.webp" + }, + { + "slug": "wan2-1-t2v-14b-fp8-scaled", + "name": "wan2.1_t2v_14B_fp8_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_t2v_14B_fp8_scaled.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.1 T2v 14B FP8 scaled", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_alpha_t2v_14B-1.webp" + }, + { + "slug": "wan-alpha-2-1-rgba-lora", + "name": "wan_alpha_2.1_rgba_lora.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/loras/wan_alpha_2.1_rgba_lora.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Wan Alpha 2.1 Rgba Lora", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_alpha_t2v_14B-1.webp" + }, + { + "slug": "wan-alpha-2-1-vae-rgb-channel", + "name": "wan_alpha_2.1_vae_rgb_channel.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/vae/wan_alpha_2.1_vae_rgb_channel.safetensors", + "directory": "vae", + "workflowCount": 1, + "displayName": "Wan Alpha 2.1 Vae Rgb Channel", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_alpha_t2v_14B-1.webp" + }, + { + "slug": "wan-alpha-2-1-vae-alpha-channel", + "name": "wan_alpha_2.1_vae_alpha_channel.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/vae/wan_alpha_2.1_vae_alpha_channel.safetensors", + "directory": "vae", + "workflowCount": 1, + "displayName": "Wan Alpha 2.1 Vae Alpha Channel", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_alpha_t2v_14B-1.webp" + }, + { + "slug": "lightx2v-t2v-14b-cfg-step-distill-v2-lora-rank64-bf16", + "name": "lightx2v_T2V_14B_cfg_step_distill_v2_lora_rank64_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Lightx2v/lightx2v_T2V_14B_cfg_step_distill_v2_lora_rank64_bf16.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Lightx2v T2V 14B Cfg Step Distill V2 Lora Rank64 BF16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_alpha_t2v_14B-1.webp" + }, + { + "slug": "wan2-1-fun-camera-v1-1-1-3b-bf16", + "name": "wan2.1_fun_camera_v1.1_1.3B_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_fun_camera_v1.1_1.3B_bf16.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.1 Fun Camera V1.1 1.3B BF16", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/fun-control", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_fun_camera_v1.1_1.3B-1.webp" + }, + { + "slug": "wan2-1-fun-camera-v1-1-14b-bf16", + "name": "wan2.1_fun_camera_v1.1_14B_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_fun_camera_v1.1_14B_bf16.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.1 Fun Camera V1.1 14B BF16", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/fun-control", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_fun_camera_v1.1_14B-1.webp" + }, + { + "slug": "wan2-1-i2v-14b-480p-fp8-e4m3fn-scaled-kj", + "name": "Wan2_1-I2V-14B-480p_fp8_e4m3fn_scaled_KJ.safetensors", + "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy_fp8_scaled/resolve/main/I2V/Wan2_1-I2V-14B-480p_fp8_e4m3fn_scaled_KJ.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2 1 I2V 14B 480p FP8 e4m3fn scaled KJ", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_1_infinitetalk-1.webp" + }, + { + "slug": "wan2-1-infinitetalk-multi-fp16", + "name": "wan2.1_infiniteTalk_multi_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/model_patches/wan2.1_infiniteTalk_multi_fp16.safetensors", + "directory": "model_patches", + "workflowCount": 1, + "displayName": "Wan2.1 InfiniteTalk Multi FP16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_1_infinitetalk-1.webp" + }, + { + "slug": "wav2vec2-chinese-base-fp16", + "name": "wav2vec2-chinese-base_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Kijai/wav2vec2_safetensors/resolve/main/wav2vec2-chinese-base_fp16.safetensors", + "directory": "audio_encoders", + "workflowCount": 1, + "displayName": "Wav2vec2 Chinese Base FP16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_1_infinitetalk-1.webp" + }, + { + "slug": "wananimate-relight-lora-fp16", + "name": "WanAnimate_relight_lora_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/LoRAs/Wan22_relight/WanAnimate_relight_lora_fp16.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "WanAnimate Relight Lora FP16", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-animate", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_animate-1.webp" + }, + { + "slug": "wan2-2-animate-14b-fp8-e4m3fn-scaled-kj", + "name": "Wan2_2-Animate-14B_fp8_e4m3fn_scaled_KJ.safetensors", + "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy_fp8_scaled/resolve/main/Wan22Animate/Wan2_2-Animate-14B_fp8_e4m3fn_scaled_KJ.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2 2 Animate 14B FP8 e4m3fn scaled KJ", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-animate", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_animate-1.webp" + }, + { + "slug": "wan2-2-fun-camera-high-noise-14b-fp8-scaled", + "name": "wan2.2_fun_camera_high_noise_14B_fp8_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_camera_high_noise_14B_fp8_scaled.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.2 Fun Camera High Noise 14B FP8 scaled", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-camera", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_fun_camera-1.webp" + }, + { + "slug": "wan2-2-fun-camera-low-noise-14b-fp8-scaled", + "name": "wan2.2_fun_camera_low_noise_14B_fp8_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_camera_low_noise_14B_fp8_scaled.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.2 Fun Camera Low Noise 14B FP8 scaled", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-camera", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_fun_camera-1.webp" + }, + { + "slug": "wan2-2-fun-control-high-noise-14b-fp8-scaled", + "name": "wan2.2_fun_control_high_noise_14B_fp8_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_control_high_noise_14B_fp8_scaled.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.2 Fun Control High Noise 14B FP8 scaled", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-control", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_fun_control-1.webp" + }, + { + "slug": "wan2-2-fun-control-low-noise-14b-fp8-scaled", + "name": "wan2.2_fun_control_low_noise_14B_fp8_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_control_low_noise_14B_fp8_scaled.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.2 Fun Control Low Noise 14B FP8 scaled", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-control", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_fun_control-1.webp" + }, + { + "slug": "wan2-2-fun-inpaint-high-noise-14b-fp8-scaled", + "name": "wan2.2_fun_inpaint_high_noise_14B_fp8_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_inpaint_high_noise_14B_fp8_scaled.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.2 Fun Inpaint High Noise 14B FP8 scaled", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-inp", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_fun_inpaint-1.webp" + }, + { + "slug": "wan2-2-fun-inpaint-low-noise-14b-fp8-scaled", + "name": "wan2.2_fun_inpaint_low_noise_14B_fp8_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_inpaint_low_noise_14B_fp8_scaled.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.2 Fun Inpaint Low Noise 14B FP8 scaled", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-inp", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_fun_inpaint-1.webp" + }, + { + "slug": "wav2vec2-large-english-fp16", + "name": "wav2vec2_large_english_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/audio_encoders/wav2vec2_large_english_fp16.safetensors", + "directory": "audio_encoders", + "workflowCount": 1, + "displayName": "Wav2vec2 Large English FP16", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-s2v", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_s2v-1.webp" + }, + { + "slug": "wan2-2-s2v-14b-fp8-scaled", + "name": "wan2.2_s2v_14B_fp8_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_s2v_14B_fp8_scaled.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.2 S2v 14B FP8 scaled", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-s2v", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_s2v-1.webp" + }, + { + "slug": "wan2-2-t2v-low-noise-14b-fp8-scaled", + "name": "wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.2 T2v Low Noise 14B FP8 scaled", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_t2v-1.webp" + }, + { + "slug": "wan2-2-t2v-high-noise-14b-fp8-scaled", + "name": "wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.2 T2v High Noise 14B FP8 scaled", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_t2v-1.webp" + }, + { + "slug": "wan2-2-t2v-lightx2v-4steps-lora-v1-1-low-noise", + "name": "wan2.2_t2v_lightx2v_4steps_lora_v1.1_low_noise.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/loras/wan2.2_t2v_lightx2v_4steps_lora_v1.1_low_noise.safetensors", + "directory": "loras", + "workflowCount": 1, + "displayName": "Wan2.2 T2v Lightx2v 4steps Lora V1.1 Low Noise", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_t2v-1.webp" + }, + { + "slug": "wan2-2-fun-control-5b-bf16", + "name": "wan2.2_fun_control_5B_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_control_5B_bf16.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.2 Fun Control 5B BF16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_5B_fun_control-1.webp" + }, + { + "slug": "wan2-2-fun-inpaint-5b-bf16", + "name": "wan2.2_fun_inpaint_5B_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_inpaint_5B_bf16.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.2 Fun Inpaint 5B BF16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_5B_fun_inpaint-1.webp" + }, + { + "slug": "wan2-2-ti2v-5b-fp16", + "name": "wan2.2_ti2v_5B_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_ti2v_5B_fp16.safetensors", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.2 Ti2v 5B FP16", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_5B_ti2v-1.webp" + }, + { + "slug": "wan2-1-flf2v-720p-14b-fp16", + "name": "wan2.1_flf2v_720p_14B_fp16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_flf2v_720p_14B_fp16.safetensors?download=true", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.1 Flf2v 720p 14B FP16", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan-flf", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/wan2.1_flf2v_720_f16-1.webp" + }, + { + "slug": "wan2-1-fun-control-1-3b-bf16", + "name": "wan2.1_fun_control_1.3B_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_fun_control_1.3B_bf16.safetensors?download=true", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.1 Fun Control 1.3B BF16", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/fun-control", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/wan2.1_fun_control-1.webp" + }, + { + "slug": "wan2-1-fun-inp-1-3b-bf16", + "name": "wan2.1_fun_inp_1.3B_bf16.safetensors", + "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_fun_inp_1.3B_bf16.safetensors?download=true", + "directory": "diffusion_models", + "workflowCount": 1, + "displayName": "Wan2.1 Fun Inp 1.3B BF16", + "docsUrl": "https://docs.comfy.org/tutorials/video/wan/fun-inp", + "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/wan2.1_fun_inp-1.webp" + } +] diff --git a/apps/website/src/config/model-metadata.ts b/apps/website/src/config/model-metadata.ts new file mode 100644 index 0000000000..07f8e1ec15 --- /dev/null +++ b/apps/website/src/config/model-metadata.ts @@ -0,0 +1,208 @@ +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 + } +} diff --git a/apps/website/src/config/models.ts b/apps/website/src/config/models.ts new file mode 100644 index 0000000000..c779ac1430 --- /dev/null +++ b/apps/website/src/config/models.ts @@ -0,0 +1,81 @@ +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) +} diff --git a/apps/website/src/config/routes.ts b/apps/website/src/config/routes.ts index 97d3455731..8350bfb0e5 100644 --- a/apps/website/src/config/routes.ts +++ b/apps/website/src/config/routes.ts @@ -11,18 +11,25 @@ const baseRoutes = { about: '/about', careers: '/careers', customers: '/customers', + demos: '/demos', termsOfService: '/terms-of-service', privacyPolicy: '/privacy-policy', - contact: '/contact' + contact: '/contact', + models: '/p/supported-models' } as const type Routes = typeof baseRoutes +const localeInvariantRouteKeys = new Set<keyof Routes>(['termsOfService']) + export function getRoutes(locale: Locale = 'en'): Routes { if (locale === 'en') return baseRoutes const prefix = `/${locale}` return Object.fromEntries( - Object.entries(baseRoutes).map(([k, v]) => [k, `${prefix}${v}`]) + Object.entries(baseRoutes).map(([k, v]) => [ + k, + localeInvariantRouteKeys.has(k as keyof Routes) ? v : `${prefix}${v}` + ]) ) as unknown as Routes } @@ -30,11 +37,16 @@ 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' } as const diff --git a/apps/website/src/data/ashby-roles.snapshot.json b/apps/website/src/data/ashby-roles.snapshot.json index 86220f97fc..455930cd62 100644 --- a/apps/website/src/data/ashby-roles.snapshot.json +++ b/apps/website/src/data/ashby-roles.snapshot.json @@ -1,58 +1,30 @@ { - "fetchedAt": "2026-04-24T18:59:03.989Z", + "fetchedAt": "2026-05-12T16:10:34.114Z", "departments": [ { "name": "DESIGN", "key": "design", "roles": [ { - "id": "4c5d6afb78652df7", - "title": "Freelance Motion Designer", - "department": "Design", - "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application" - }, - { - "id": "0f5256cf302e552b", - "title": "Creative Artist", - "department": "Design", - "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application" - }, - { - "id": "e915f2c78b17f93b", + "id": "18743530eb448c99", "title": "Senior Product Designer", "department": "Design", "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3/application" + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3" }, { - "id": "b9f9a23219be7cd4", - "title": "Design Engineer", - "department": "Design", - "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application" - }, - { - "id": "5746486d87874937", - "title": "Graphic Designer", - "department": "Design", - "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application" - }, - { - "id": "547b6ba622c800a5", + "id": "8718d17012f26fa2", "title": "Senior Product Designer - Craft", "department": "Design", "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f/application" + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f" }, { - "id": "7bb02634a24763bc", + "id": "1e181b9ed8fb2e86", "title": "Staff Product Designer - Systems", "department": "Design", "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34/application" + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34" } ] }, @@ -61,60 +33,67 @@ "key": "engineering", "roles": [ { - "id": "102d58e35a8a9817", + "id": "6a6d865eeb3c10a8", "title": "Senior Software Engineer, Frontend", "department": "Engineering", "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2/application" + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2" }, { - "id": "d01d69fba7743905", + "id": "1b4f7f1da9616e14", "title": "Senior Software Engineer, Backend Generalist", "department": "Engineering", "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e/application" + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e" }, { - "id": "f36f60cfd5bb5910", + "id": "a6d8269c66e37c5c", "title": "Senior/Staff Applied Machine Learning Engineer", "department": "Engineering", "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0/application" + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0" }, { - "id": "9d8ec4c65e20b19e", + "id": "841da783e6e41928", "title": "Software Engineer, Frontend", "department": "Engineering", "location": "Remote", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40/application" + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40" }, { - "id": "be94b193d1f4d482", + "id": "5d01d58b03870d7a", "title": "Tech Lead Manager, Frontend", "department": "Engineering", "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261/application" + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261" }, { - "id": "ab48f5db6bd1783c", + "id": "91604c4182a1bc3c", "title": "Software Engineer, Core ComfyUI Contributor", "department": "Engineering", "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f/application" + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f" }, { - "id": "c5dff4ee628bdcd1", + "id": "a1dbc0576ab14034", "title": "Software Engineer, ComfyUI Desktop", "department": "Engineering", "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0/application" + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0" }, { - "id": "4302a7aaa87e16e3", + "id": "0b8f4fecd89c3b11", "title": "Product Manager, ComfyUI", "department": "Engineering", "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application" + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e" + }, + { + "id": "2f6bac39d723dfef", + "title": "Growth Engineer", + "department": "Engineering", + "location": "San Francisco", + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5" } ] }, @@ -123,18 +102,39 @@ "key": "marketing", "roles": [ { - "id": "b5803a0d4785d406", + "id": "23dd98cab77ff459", + "title": "Freelance Motion Designer", + "department": "Marketing", + "location": "San Francisco", + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b" + }, + { + "id": "a998b9fc973ff3c0", + "title": "Creative Artist", + "department": "Marketing", + "location": "San Francisco", + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d" + }, + { + "id": "3e730938026d6e70", + "title": "Graphic Designer", + "department": "Marketing", + "location": "San Francisco", + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f" + }, + { + "id": "6f771af6858283aa", "title": "Lifecycle Growth Marketer", "department": "Marketing", "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64/application" + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64" }, { - "id": "130d7218d7895bdb", + "id": "527a47e82970afc1", "title": "Partnership & Events Marketing Manager", "department": "Marketing", "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c/application" + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c" } ] }, @@ -143,25 +143,18 @@ "key": "operations", "roles": [ { - "id": "ec68ae44dd5943c9", - "title": "Senior Technical Recruiter", + "id": "0c6cc3685194ab7a", + "title": "Head of Talent", "department": "Operations", "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application" + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362" }, { - "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", + "id": "82bd6ed26adab1c3", "title": "Founding Customer Success Manager", "department": "Operations", "location": "San Francisco", - "applyUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4/application" + "jobUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4" } ] } diff --git a/apps/website/src/data/cloud-nodes.snapshot.json b/apps/website/src/data/cloud-nodes.snapshot.json new file mode 100644 index 0000000000..a5a85565db --- /dev/null +++ b/apps/website/src/data/cloud-nodes.snapshot.json @@ -0,0 +1,394 @@ +{ + "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." + } + ] + } + ] +} diff --git a/apps/website/src/data/cloudNodes.ts b/apps/website/src/data/cloudNodes.ts new file mode 100644 index 0000000000..fb25603564 --- /dev/null +++ b/apps/website/src/data/cloudNodes.ts @@ -0,0 +1,52 @@ +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) + ) + }) +} diff --git a/apps/website/src/data/roles.ts b/apps/website/src/data/roles.ts index d1e4cf075d..dd2896d28f 100644 --- a/apps/website/src/data/roles.ts +++ b/apps/website/src/data/roles.ts @@ -3,7 +3,7 @@ export interface Role { title: string department: string location: string - applyUrl: string + jobUrl: string } export interface Department { diff --git a/apps/website/src/i18n/translations.ts b/apps/website/src/i18n/translations.ts index be142212fd..5361082543 100644 --- a/apps/website/src/i18n/translations.ts +++ b/apps/website/src/i18n/translations.ts @@ -11,6 +11,10 @@ const translations = { 'zh-CN': 'Comfy 是面向专业视觉人士的 AI 创作引擎。您可以精确掌控每个模型、每个参数和每个输出。' }, + 'hero.runFirstWorkflow': { + en: 'Run your first workflow', + 'zh-CN': '运行你的第一个工作流' + }, // ProductShowcaseSection 'showcase.subtitle1': { @@ -731,6 +735,142 @@ 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 ', @@ -910,9 +1050,9 @@ const translations = { 'zh-CN': '我应该选择 Comfy Cloud 还是本地 ComfyUI(自托管)?' }, 'cloud.faq.3.a': { - en: "Comfy Cloud (beta) has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.", + en: "Comfy Cloud has zero setup, is easy to share with your team, and is faster than most GPUs you can run on a desktop workstation. You can immediately run the best models and workflows from the community on Comfy Cloud.\nLocal ComfyUI is infinitely customizable, works offline, and you don't need to worry about queue times. However, depending on what you want to create, you might need to have a good GPU and some amount of technical knowledge to install community-created custom nodes.", 'zh-CN': - 'Comfy Cloud(测试版)无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。' + 'Comfy Cloud 无需任何设置,方便与团队共享,比大多数桌面工作站 GPU 更快。您可以立即在 Comfy Cloud 上运行社区中最好的模型和工作流。\n本地 ComfyUI 可以无限定制,支持离线工作,无需担心排队时间。但根据您的创作需求,可能需要一块好的 GPU 以及一定的技术知识来安装社区创建的自定义节点。' }, 'cloud.faq.4.q': { en: 'Do I need a GPU or a strong computer to use Comfy Cloud?', @@ -1115,6 +1255,10 @@ 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': { @@ -1139,6 +1283,10 @@ 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': { @@ -1276,10 +1424,6 @@ const translations = { en: 'Run multiple workflows in parallel to speed up your pipeline.', 'zh-CN': '并行运行多个工作流,加速你的流程。' }, - 'pricing.included.comingSoon': { - en: 'coming soon', - 'zh-CN': '即将推出' - }, // VideoPlayer 'player.play': { en: 'Play', 'zh-CN': '播放' }, @@ -1599,7 +1743,7 @@ const translations = { }, 'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' }, 'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' }, - 'nav.blogs': { en: 'Blogs', 'zh-CN': '博客' }, + 'nav.blogs': { en: 'Blog', 'zh-CN': '博客' }, 'nav.github': { en: 'GitHub', 'zh-CN': 'GitHub' }, 'nav.discord': { en: 'Discord', 'zh-CN': 'Discord' }, 'nav.docs': { en: 'Docs', 'zh-CN': '文档' }, @@ -1629,6 +1773,7 @@ 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', @@ -1905,269 +2050,594 @@ const translations = { }, // ── Terms of Service ────────────────────────────────────────────── - 'tos.intro.label': { en: 'INTRO', 'zh-CN': '简介' }, + 'tos.effectiveDateLabel': { + en: 'Effective Date', + 'zh-CN': 'Effective Date' + }, + 'tos.effectiveDate': { + en: 'May 13, 2026', + 'zh-CN': 'May 13, 2026' + }, + + 'tos.intro.label': { en: 'INTRO', 'zh-CN': 'INTRO' }, 'tos.intro.block.0': { - en: 'Welcome to the ComfyUI offering, provided by Comfy Organization, Inc.', - 'zh-CN': '欢迎使用由 Comfy Organization, Inc. 提供的 ComfyUI 产品。' + en: 'These Comfy Terms of Service (the “Agreement”) are made available by Comfy Organization, Inc., a Delaware corporation (“Comfy”) and set forth your rights and obligations when accessing the Comfy Products (as defined below).', + 'zh-CN': + 'These Comfy Terms of Service (the “Agreement”) are made available by Comfy Organization, Inc., a Delaware corporation (“Comfy”) and set forth your rights and obligations when accessing the Comfy Products (as defined below).' }, 'tos.intro.block.1': { - en: 'Please read these Terms of Service (these "Terms") carefully, as they constitute a legally binding agreement between Comfy Organization, Inc., a Delaware corporation ("Comfy Org," "We," "Us," or "Our"), and an end-user ("You" and "Your") and apply to Your use of the Services (as defined below). In case You are subscribing to the Services as a representative of or on behalf of an entity (e.g., Your employer, the "Client" or "Entity"), Your acceptance of these Terms also binds the Client or Entity, and any reference in these Terms to "You" shall also mean the "Client" or "Entity" and its affiliates.', + en: 'The Agreement is entered into by and between Comfy and the entity or person accessing the Comfy Products (“Customer” or “you”). If you are accessing or using the Comfy Products on behalf of your company, you represent that you are authorized to enter into the Agreement on behalf of your company.', 'zh-CN': - '请仔细阅读本服务条款(以下简称"条款"),因为它们构成 Comfy Organization, Inc.(一家特拉华州公司,以下简称"Comfy Org"、"我们")与最终用户("您")之间具有法律约束力的协议,并适用于您对服务(定义见下文)的使用。如果您以实体(例如您的雇主,即"客户"或"实体")的代表身份或代表其订阅服务,您对本条款的接受也约束该客户或实体,本条款中对"您"的任何引用也应指"客户"或"实体"及其关联方。' + 'The Agreement is entered into by and between Comfy and the entity or person accessing the Comfy Products (“Customer” or “you”). If you are accessing or using the Comfy Products on behalf of your company, you represent that you are authorized to enter into the Agreement on behalf of your company.' }, 'tos.intro.block.2': { - en: 'You hereby agree to accept these Terms by (a) either using the Services, or (b) by opening an account under a username. BEFORE YOU DO EITHER OF THOSE, PLEASE READ THESE TERMS CAREFULLY. IF YOU DO NOT WANT TO AGREE TO THESE TERMS, YOU MUST NOT USE THE SERVICES OR SET UP AN ACCOUNT.', + en: 'PLEASE REVIEW THESE TERMS OF SERVICE CAREFULLY. ONCE ACCEPTED, THE TERMS AND CONDITIONS OF THE AGREEMENT WILL BECOME A BINDING LEGAL COMMITMENT BETWEEN YOU AND COMFY. IF YOU DO NOT AGREE TO BE BOUND BY THESE TERMS OF SERVICE, YOU SHOULD NOT ACCEPT THESE TERMS OF SERVICE AND MAY NOT USE THE PLATFORM.', 'zh-CN': - '您特此同意通过以下方式接受本条款:(a) 使用服务,或 (b) 以用户名开设账户。在您执行上述任何操作之前,请仔细阅读本条款。如果您不同意本条款,则不得使用服务或设置账户。' + 'PLEASE REVIEW THESE TERMS OF SERVICE CAREFULLY. ONCE ACCEPTED, THE TERMS AND CONDITIONS OF THE AGREEMENT WILL BECOME A BINDING LEGAL COMMITMENT BETWEEN YOU AND COMFY. IF YOU DO NOT AGREE TO BE BOUND BY THESE TERMS OF SERVICE, YOU SHOULD NOT ACCEPT THESE TERMS OF SERVICE AND MAY NOT USE THE PLATFORM.' }, - 'tos.intro.block.3': { - en: 'You also agree to abide by other Comfy Org rules and policies, including our Privacy Policy https://www.comfy.org/privacy-policy (which explains what information we collect from You and how we protect it) that are expressly incorporated into and are a part of these Terms. Please read them carefully.', - 'zh-CN': - '您还同意遵守 Comfy Org 的其他规则和政策,包括我们的隐私政策 https://www.comfy.org/privacy-policy(该政策说明了我们从您处收集的信息以及如何保护这些信息),这些规则和政策明确纳入本条款并构成其组成部分。请仔细阅读。' - }, - 'tos.intro.block.4': { - en: 'Once you accept these Terms You are bound by them until they are terminated. See Section 10 (Term and Termination).', - 'zh-CN': - '一旦您接受本条款,您将受其约束,直至条款终止。请参阅第 10 条(期限和终止)。' - }, - 'tos.intro.block.5': { - en: 'By accessing or using the Software or Services in any way, You represent that (1) You have read, understand, and hereby agree to be bound by these Terms, (2) You are of legal age to form a binding contract with Comfy Org, and (3) You have the authority to enter into these Terms personally or on behalf of the Client Entity. If You do not agree to be bound by, or cannot conform with, these Terms, You may not use the Services. You will be legally and financially responsible for all actions using or accessing the Services, including the actions of anyone You allow to access Your Account.', - 'zh-CN': - '通过以任何方式访问或使用软件或服务,您声明:(1) 您已阅读、理解并特此同意受本条款的约束,(2) 您已达到与 Comfy Org 签订具有约束力的合同的法定年龄,(3) 您有权以个人身份或代表客户实体签订本条款。如果您不同意受本条款约束或无法遵守本条款,则不得使用服务。您将对使用或访问服务的所有行为承担法律和财务责任,包括您允许访问您账户的任何人的行为。' - }, - 'tos.intro.block.6': { - en: 'IF YOU ACCEPT THESE TERMS, YOU AND COMFY ORG AGREE TO RESOLVE DISPUTES IN BINDING, INDIVIDUAL ARBITRATION AND GIVE UP THE RIGHT TO GO TO COURT INDIVIDUALLY OR AS PART OF A CLASS ACTION.', - 'zh-CN': - '如果您接受本条款,您和 COMFY ORG 同意通过具有约束力的个人仲裁解决争议,并放弃以个人身份或作为集体诉讼一部分提起诉讼的权利。' - }, - 'tos.definitions.label': { en: 'DEFINITIONS', 'zh-CN': '定义' }, - 'tos.definitions.title': { en: '1. Definitions', 'zh-CN': '1. 定义' }, + + 'tos.definitions.label': { en: 'DEFINITIONS', 'zh-CN': 'DEFINITIONS' }, + 'tos.definitions.title': { en: '1. Definitions', 'zh-CN': '1. Definitions' }, 'tos.definitions.block.0': { - en: '"Business User" mean an entity or individual using the Software or Services primarily for business, commercial, or professional purposes.', + en: '“Affiliates” means any entity that directly or indirectly controls, is controlled by, or is under common control with a party, where “control” means the ownership of more than fifty percent (50%) of the voting securities or other voting interests of such entity.', 'zh-CN': - '"商业用户"指主要出于商业、贸易或专业目的使用软件或服务的实体或个人。' + '“Affiliates” means any entity that directly or indirectly controls, is controlled by, or is under common control with a party, where “control” means the ownership of more than fifty percent (50%) of the voting securities or other voting interests of such entity.' }, 'tos.definitions.block.1': { - en: '"ComfyUI Branding" means the names, logos, and associated trademarks owned or in progress of being owned by Comfy Org, Inc.', + en: '“Applicable Laws” means all federal and state laws, treaties, rules, regulations, regulatory and supervisory guidance, directives, policies, orders or determinations of a regulatory authority applicable to the activities and obligations contemplated under this Agreement.', 'zh-CN': - '"ComfyUI 品牌"指 Comfy Org, Inc. 拥有或正在申请拥有的名称、标志和相关商标。' + '“Applicable Laws” means all federal and state laws, treaties, rules, regulations, regulatory and supervisory guidance, directives, policies, orders or determinations of a regulatory authority applicable to the activities and obligations contemplated under this Agreement.' }, 'tos.definitions.block.2': { - en: '"ComfyUI Software" or "Software" means the open-source software product named "ComfyUI," including its desktop applications, source code, and user interface elements.', + en: '“Comfy API” means the application programming interface and related developer tools made available by Comfy that allow you to access and execute visual AI workflows programmatically as production endpoints from within your own applications or systems.', 'zh-CN': - '"ComfyUI 软件"或"软件"指名为"ComfyUI"的开源软件产品,包括其桌面应用程序、源代码和用户界面元素。' + '“Comfy API” means the application programming interface and related developer tools made available by Comfy that allow you to access and execute visual AI workflows programmatically as production endpoints from within your own applications or systems.' }, 'tos.definitions.block.3': { - en: '"Customer Data" means any data, content, information, prompts, or workflows that You submit, upload, transmit, or process through the Software or Services.', + en: '“Comfy Branding” means the names, logos, and associated trademarks owned or in progress of being owned by Comfy.', 'zh-CN': - '"客户数据"指您通过软件或服务提交、上传、传输或处理的任何数据、内容、信息、提示词或工作流。' + '“Comfy Branding” means the names, logos, and associated trademarks owned or in progress of being owned by Comfy.' }, 'tos.definitions.block.4': { - en: '"Consumer User" means an individual using the Software or Services primarily for personal, family, or household purposes.', - 'zh-CN': '"消费者用户"指主要出于个人、家庭或家用目的使用软件或服务的个人。' + en: '“Comfy Cloud” means the cloud-based hosting environment made available by Comfy that allows you to access and run visual AI workflows remotely through Comfy’s infrastructure, without requiring local installation or hardware.', + 'zh-CN': + '“Comfy Cloud” means the cloud-based hosting environment made available by Comfy that allows you to access and run visual AI workflows remotely through Comfy’s infrastructure, without requiring local installation or hardware.' }, 'tos.definitions.block.5': { - en: '"Intellectual Property Rights" means all (i) patents, patent disclosures, and inventions (whether patentable or not), (ii) trademarks, (iii) copyrights and copyrightable works (including computer programs), and rights in data and databases, and (iv) all other intellectual property rights, in each case whether registered or unregistered and including all applications for, and renewals or extensions of, such rights, and all similar or equivalent rights or forms of protection in any part of the world.', + en: '“Comfy Enterprise” means the enterprise-grade product tier made available by Comfy that provides organizations with dedicated infrastructure, enhanced security, administrative controls, and related support services for deploying and managing visual AI workflows at scale.', 'zh-CN': - '"知识产权"指所有 (i) 专利、专利披露和发明(无论是否可获得专利),(ii) 商标,(iii) 版权和可受版权保护的作品(包括计算机程序)以及数据和数据库权利,(iv) 所有其他知识产权,在每种情况下无论已注册或未注册,包括所有此类权利的申请、续展或延期,以及世界任何地区的所有类似或等同的权利或保护形式。' + '“Comfy Enterprise” means the enterprise-grade product tier made available by Comfy that provides organizations with dedicated infrastructure, enhanced security, administrative controls, and related support services for deploying and managing visual AI workflows at scale.' }, 'tos.definitions.block.6': { - en: '"Open Source License" means the specific open-source license(s) governing the ComfyUI Software, primarily the GNU General Public License v3 (GPLv3) for its UI elements and potentially other components.', + en: '“Comfy OSS” means the open-source software, source code, libraries, tools, and related components made available by Comfy under one or more open source licenses, including the software repositories published by Comfy at <a href="https://github.com/Comfy-Org" class="text-white underline">https://github.com/Comfy-Org</a>, as updated, modified, or supplemented from time to time. For the avoidance of doubt, Comfy OSS does not include any proprietary software, infrastructure, or functionality made available by Comfy under these Terms of Service or in connection with any commercial product or offering.', 'zh-CN': - '"开源许可证"指管辖 ComfyUI 软件的特定开源许可证,主要是用于其 UI 元素的 GNU 通用公共许可证第 3 版 (GPLv3) 以及可能适用于其他组件的许可证。' + '“Comfy OSS” means the open-source software, source code, libraries, tools, and related components made available by Comfy under one or more open source licenses, including the software repositories published by Comfy at <a href="https://github.com/Comfy-Org" class="text-white underline">https://github.com/Comfy-Org</a>, as updated, modified, or supplemented from time to time. For the avoidance of doubt, Comfy OSS does not include any proprietary software, infrastructure, or functionality made available by Comfy under these Terms of Service or in connection with any commercial product or offering.' }, 'tos.definitions.block.7': { - en: '"Providers" means certain third-party service providers utilized by Comfy Org for certain functionality, including hosting and payment processing.', + en: '“Comfy Products” means Comfy Cloud, Comfy API, Comfy Enterprise and other products, software, features, tools, and functionality made available by Comfy to you under these Terms of Service, excluding any Comfy OSS.', 'zh-CN': - '"提供商"指 Comfy Org 用于某些功能的特定第三方服务提供商,包括托管和支付处理。' + '“Comfy Products” means Comfy Cloud, Comfy API, Comfy Enterprise and other products, software, features, tools, and functionality made available by Comfy to you under these Terms of Service, excluding any Comfy OSS.' }, 'tos.definitions.block.8': { - en: '"Services" means all current and future commercial and auxiliary services provided by Comfy Org in connection with the ComfyUI Software, including but not limited to:', + en: '“Customer Data” means electronic data and information submitted or generated by Customer in connection with its use of the Comfy Products, including all Inputs and Outputs.', 'zh-CN': - '"服务"指 Comfy Org 与 ComfyUI 软件相关的所有当前和未来的商业及辅助服务,包括但不限于:' + '“Customer Data” means electronic data and information submitted or generated by Customer in connection with its use of the Comfy Products, including all Inputs and Outputs.' }, 'tos.definitions.block.9': { - en: 'Commercial services:', - 'zh-CN': '商业服务:' + en: '“Open Source License” means the open source license(s) under which Comfy makes Comfy OSS available, as identified in the applicable source code repository.', + 'zh-CN': + '“Open Source License” means the open source license(s) under which Comfy makes Comfy OSS available, as identified in the applicable source code repository.' }, 'tos.definitions.block.10': { - en: 'Comfy Cloud — paid and fully managed cloud based ComfyUI hosted in our data centers\nAPI Nodes — paid integrations with third-party API services available within ComfyUI\nSupport, Training, Consulting — paid services related to ComfyUI', + en: '“Operational Metadata” means usage and diagnostic information generated by the Comfy Products and collected by Comfy to support, maintain, and optimize the performance and security of the Comfy Products, including information regarding software versions, system configuration, uptime, error logs, health metrics, and feature usage. Operational Metadata does not include Customer Data or Confidential Information.', 'zh-CN': - 'Comfy Cloud——付费的、完全托管的、基于云的 ComfyUI,托管在我们的数据中心\nAPI 节点——ComfyUI 中可用的与第三方 API 服务的付费集成\n支持、培训、咨询——与 ComfyUI 相关的付费服务' + '“Operational Metadata” means usage and diagnostic information generated by the Comfy Products and collected by Comfy to support, maintain, and optimize the performance and security of the Comfy Products, including information regarding software versions, system configuration, uptime, error logs, health metrics, and feature usage. Operational Metadata does not include Customer Data or Confidential Information.' }, 'tos.definitions.block.11': { - en: 'Open source services:', - 'zh-CN': '开源服务:' + en: '“Order Form” means the online sign-up flow, order form or other ordering document entered into or otherwise agreed by Customer that references this Agreement.', + 'zh-CN': + '“Order Form” means the online sign-up flow, order form or other ordering document entered into or otherwise agreed by Customer that references this Agreement.' }, 'tos.definitions.block.12': { - en: 'Custom Node Registry — marketplace of custom nodes freely available to ComfyUI users\nAny other hosted experiences or tools offered by Comfy Org.', + en: '“User” means Customer’s or Customer’s Affiliates’ employees and contractors who are authorized by Customer to access and use the Comfy Products on Customer’s or Customer’s Affiliates’ behalf according to the terms of this Agreement.', 'zh-CN': - '自定义节点 Registry——ComfyUI 用户免费使用的自定义节点市场\nComfy Org 提供的任何其他托管体验或工具。' + '“User” means Customer’s or Customer’s Affiliates’ employees and contractors who are authorized by Customer to access and use the Comfy Products on Customer’s or Customer’s Affiliates’ behalf according to the terms of this Agreement.' }, - 'tos.license.label': { en: 'LICENSE', 'zh-CN': '许可' }, - 'tos.license.title': { - en: '2. ComfyUI Software License', - 'zh-CN': '2. ComfyUI 软件许可' + + 'tos.comfy-products.label': { + en: 'COMFY PRODUCTS', + 'zh-CN': 'COMFY PRODUCTS' }, - 'tos.license.block.0': { - en: 'Open Source Nature. The ComfyUI Software itself is open-source and distributed under the terms of the GNU General Public License v3 (GPLv3), or other specific open-source licenses for particular components, as applicable. Your rights to use, modify, and distribute the ComfyUI Software are governed by the respective Open Source Licenses.', + 'tos.comfy-products.title': { + en: '2. Comfy Products', + 'zh-CN': '2. Comfy Products' + }, + 'tos.comfy-products.block.0.heading': { + en: 'Right to Access and Use Comfy Products.', + 'zh-CN': 'Right to Access and Use Comfy Products.' + }, + 'tos.comfy-products.block.1': { + en: 'Subject to your compliance with all of the terms and conditions of this Agreement, Comfy grants you and your Users a non-exclusive, non-sublicensable, non-transferable right during the term of this Agreement to access and use the Comfy Products as set forth in the applicable Order Form for your internal business purposes.', 'zh-CN': - '开源性质。ComfyUI 软件本身是开源的,根据 GNU 通用公共许可证第 3 版 (GPLv3) 或其他适用于特定组件的开源许可证的条款进行分发。您使用、修改和分发 ComfyUI 软件的权利受相应开源许可证的约束。' + 'Subject to your compliance with all of the terms and conditions of this Agreement, Comfy grants you and your Users a non-exclusive, non-sublicensable, non-transferable right during the term of this Agreement to access and use the Comfy Products as set forth in the applicable Order Form for your internal business purposes.' }, - 'tos.license.block.1': { - en: 'No Charge for Software. Comfy Org explicitly acknowledges that we do not charge for the ComfyUI Software itself. The fees outlined in these Terms are solely for the Services we provide around the Software, such as hosting, compute, and additional functionalities.', + 'tos.comfy-products.block.2.heading': { + en: 'Customer Data.', + 'zh-CN': 'Customer Data.' + }, + 'tos.comfy-products.block.3': { + en: 'As between Comfy and Customer, Customer retains all right, title, and interest in and to any data, images, videos, prompts, models, workflows, nodes, parameters, or other materials submitted or uploaded by Customer to the Comfy Products (“Input”), as well as any images, videos, designs, or other visual content generated through Customer’s use of the Comfy Products as a result of processing Customer’s Input (“Output”). Customer acknowledges that due to the nature of artificial intelligence, Comfy may generate the same or similar Output for other customers, and Customer shall have no right, title, or interest in or to Output generated for any other customer.', 'zh-CN': - '软件免费。Comfy Org 明确承认我们不对 ComfyUI 软件本身收费。本条款中列出的费用仅用于我们围绕软件提供的服务,例如托管、计算和附加功能。' + 'As between Comfy and Customer, Customer retains all right, title, and interest in and to any data, images, videos, prompts, models, workflows, nodes, parameters, or other materials submitted or uploaded by Customer to the Comfy Products (“Input”), as well as any images, videos, designs, or other visual content generated through Customer’s use of the Comfy Products as a result of processing Customer’s Input (“Output”). Customer acknowledges that due to the nature of artificial intelligence, Comfy may generate the same or similar Output for other customers, and Customer shall have no right, title, or interest in or to Output generated for any other customer.' }, - 'tos.license.block.2': { - en: 'Service Updates. You understand that the Software is evolving, and features and benefits You receive upon Your initial use may change. You acknowledge and agree that Comfy Org may update the Software with or without notifying You, including adding or removing features, products, or functionalities.', + 'tos.comfy-products.block.4.heading': { + en: 'No AI Training.', + 'zh-CN': 'No AI Training.' + }, + 'tos.comfy-products.block.5': { + en: 'Comfy will not use Input or Output to train generative AI or diffusion models. Comfy may, however, collect and use limited metadata derived from Customer’s use of the Comfy Products, such as prompt classifications, workflow structures, and node configurations, to improve the performance, functionality, and user experience of the Comfy Products.', 'zh-CN': - '服务更新。您理解软件在不断发展,您初次使用时获得的功能和优势可能会发生变化。您承认并同意 Comfy Org 可能会在通知或不通知您的情况下更新软件,包括添加或删除功能、产品或特性。' + 'Comfy will not use Input or Output to train generative AI or diffusion models. Comfy may, however, collect and use limited metadata derived from Customer’s use of the Comfy Products, such as prompt classifications, workflow structures, and node configurations, to improve the performance, functionality, and user experience of the Comfy Products.' }, - 'tos.using-services.label': { en: 'USAGE', 'zh-CN': '使用服务' }, - 'tos.using-services.title': { - en: '3. Using the Services', - 'zh-CN': '3. 使用服务' + 'tos.comfy-products.block.6.heading': { + en: 'Comfy OSS.', + 'zh-CN': 'Comfy OSS.' }, - 'tos.using-services.block.0': { - en: 'Open Source Nature. The ComfyUI Software itself is open-source and distributed under the terms of the GNU General Public License v3 (GPLv3), or other specific open-source licenses for particular components, as applicable. Your rights to use, modify, and distribute the ComfyUI Software are governed by the respective Open Source Licenses.', + 'tos.comfy-products.block.7': { + en: 'You may use Comfy OSS under the terms of the applicable Open Source License(s) governing each respective component, as identified in the corresponding source code repository, rather than under these Terms. Nothing in these Terms shall be construed to limit, supersede, or modify any rights or obligations arising under an applicable Open Source License. If you choose to use the Comfy Products in conjunction with Comfy OSS, these Terms apply solely to your use of the Comfy Products and not to the Comfy OSS itself.', 'zh-CN': - '开源性质。ComfyUI 软件本身是开源的,根据 GNU 通用公共许可证第 3 版 (GPLv3) 或其他适用于特定组件的开源许可证的条款进行分发。您使用、修改和分发 ComfyUI 软件的权利受相应开源许可证的约束。' + 'You may use Comfy OSS under the terms of the applicable Open Source License(s) governing each respective component, as identified in the corresponding source code repository, rather than under these Terms. Nothing in these Terms shall be construed to limit, supersede, or modify any rights or obligations arising under an applicable Open Source License. If you choose to use the Comfy Products in conjunction with Comfy OSS, these Terms apply solely to your use of the Comfy Products and not to the Comfy OSS itself.' }, - 'tos.using-services.block.1': { - en: 'No Charge for Software. Comfy Org explicitly acknowledges that we do not charge for the ComfyUI Software itself. The fees outlined in these Terms are solely for the Services we provide around the Software, such as hosting, compute, and additional functionalities.', + 'tos.comfy-products.block.8.heading': { + en: 'Partner Nodes.', + 'zh-CN': 'Partner Nodes.' + }, + 'tos.comfy-products.block.9': { + en: 'Certain features of the Comfy Products allow you to access third-party AI model providers (“Partner Nodes”) through Comfy. When you use a Partner Node, Comfy proxies your request to the applicable third-party provider, transmitting the information necessary to fulfill your request, including prompts, images, models, and parameters. Comfy does not transmit your identity or account information to third-party providers in connection with Partner Node requests. Your use of Partner Nodes is subject to the terms and policies of the applicable third-party provider, and Comfy is not responsible for the data practices of such providers. Usage of Partner Nodes is metered and billed through Comfy.', 'zh-CN': - '软件免费。Comfy Org 明确承认我们不对 ComfyUI 软件本身收费。本条款中列出的费用仅用于我们围绕软件提供的服务,例如托管、计算和附加功能。' + 'Certain features of the Comfy Products allow you to access third-party AI model providers (“Partner Nodes”) through Comfy. When you use a Partner Node, Comfy proxies your request to the applicable third-party provider, transmitting the information necessary to fulfill your request, including prompts, images, models, and parameters. Comfy does not transmit your identity or account information to third-party providers in connection with Partner Node requests. Your use of Partner Nodes is subject to the terms and policies of the applicable third-party provider, and Comfy is not responsible for the data practices of such providers. Usage of Partner Nodes is metered and billed through Comfy.' }, - 'tos.using-services.block.2': { - en: 'Service Updates. You understand that the Software is evolving, and features and benefits You receive upon Your initial use may change. You acknowledge and agree that Comfy Org may update the Software with or without notifying You, including adding or removing features, products, or functionalities.', + 'tos.comfy-products.block.10.heading': { + en: 'Modification of Comfy Products.', + 'zh-CN': 'Modification of Comfy Products.' + }, + 'tos.comfy-products.block.11': { + en: 'Comfy may, at any time and in its sole discretion, modify, update, enhance, restrict, suspend, or discontinue the Comfy Products, in whole or in part, including by changing or removing features, functionality, endpoints, specifications, documentation, access methods, usage limits, or availability. Comfy has no obligation to maintain or support any particular version of the Comfy Products or to ensure backward compatibility. Any such modifications may be made with or without notice and may result in interruptions to or degradation of the Comfy Products. Comfy shall have no liability arising out of or related to any modification, suspension, or discontinuation of the Comfy Products, and Customer acknowledges that its use of the Comfy Products is at its own risk and that it should not rely on the continued availability of any aspect of the Comfy Products.', 'zh-CN': - '服务更新。您理解软件在不断发展,您初次使用时获得的功能和优势可能会发生变化。您承认并同意 Comfy Org 可能会在通知或不通知您的情况下更新软件,包括添加或删除功能、产品或特性。' + 'Comfy may, at any time and in its sole discretion, modify, update, enhance, restrict, suspend, or discontinue the Comfy Products, in whole or in part, including by changing or removing features, functionality, endpoints, specifications, documentation, access methods, usage limits, or availability. Comfy has no obligation to maintain or support any particular version of the Comfy Products or to ensure backward compatibility. Any such modifications may be made with or without notice and may result in interruptions to or degradation of the Comfy Products. Comfy shall have no liability arising out of or related to any modification, suspension, or discontinuation of the Comfy Products, and Customer acknowledges that its use of the Comfy Products is at its own risk and that it should not rely on the continued availability of any aspect of the Comfy Products.' }, - 'tos.responsibilities.label': { en: 'RESPONSIBILITIES', 'zh-CN': '您的责任' }, - 'tos.responsibilities.title': { - en: '4. Your Responsibilities', - 'zh-CN': '4. 您的责任' + 'tos.comfy-products.block.12.heading': { + en: 'Data Retention and Deletion.', + 'zh-CN': 'Data Retention and Deletion.' }, - 'tos.responsibilities.block.0': { - en: 'You are responsible for your use of the Services and any content you create, share, or distribute through them. You agree to use the Services in a manner that is lawful, respectful, and consistent with these Terms. You are solely responsible for maintaining the security of your account credentials.', + 'tos.comfy-products.block.13': { + en: 'Comfy retains Customer Data for as long as your account remains active or as otherwise necessary to provide the Comfy Products, comply with applicable legal obligations, resolve disputes, and enforce this Agreement. Specific retention periods for different categories of Customer Data are set forth in Comfy’s retention documentation, available at <a href="https://docs.comfy.org/support/data-retention" class="text-white underline">docs.comfy.org/support/data-retention</a>, as updated from time to time. You may request deletion of your account and associated Customer Data by contacting Comfy at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>. Upon receipt of a verified deletion request, Comfy will use commercially reasonable efforts to delete or de-identify your personal information from its primary systems within a reasonable time. You acknowledge that: (i) deletion may not propagate immediately to all backup systems, third-party analytics providers, or observability systems, which retain data subject to their own retention policies; (ii) certain Customer Data may be retained as required by applicable law or for legitimate business purposes such as billing records; and (iii) aggregated or de-identified data derived from your use of the Comfy Products may be retained indefinitely.', 'zh-CN': - '您应对使用服务以及通过服务创建、共享或分发的任何内容负责。您同意以合法、尊重他人且符合本条款的方式使用服务。您全权负责维护账户凭据的安全。' + 'Comfy retains Customer Data for as long as your account remains active or as otherwise necessary to provide the Comfy Products, comply with applicable legal obligations, resolve disputes, and enforce this Agreement. Specific retention periods for different categories of Customer Data are set forth in Comfy’s retention documentation, available at <a href="https://docs.comfy.org/support/data-retention" class="text-white underline">docs.comfy.org/support/data-retention</a>, as updated from time to time. You may request deletion of your account and associated Customer Data by contacting Comfy at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>. Upon receipt of a verified deletion request, Comfy will use commercially reasonable efforts to delete or de-identify your personal information from its primary systems within a reasonable time. You acknowledge that: (i) deletion may not propagate immediately to all backup systems, third-party analytics providers, or observability systems, which retain data subject to their own retention policies; (ii) certain Customer Data may be retained as required by applicable law or for legitimate business purposes such as billing records; and (iii) aggregated or de-identified data derived from your use of the Comfy Products may be retained indefinitely.' }, - 'tos.restrictions.label': { en: 'RESTRICTIONS', 'zh-CN': '限制' }, - 'tos.restrictions.title': { - en: '5. Use Restrictions', - 'zh-CN': '5. 使用限制' + + 'tos.customer-responsibilities.label': { + en: 'RESPONSIBILITIES', + 'zh-CN': 'RESPONSIBILITIES' }, - 'tos.restrictions.block.0': { - en: 'You agree not to misuse the Services. This includes, but is not limited to:', - 'zh-CN': '您同意不滥用服务,包括但不限于:' + 'tos.customer-responsibilities.title': { + en: '3. Customer Responsibilities', + 'zh-CN': '3. Customer Responsibilities' }, - 'tos.restrictions.block.1': { - en: 'Attempting to gain unauthorized access to any part of the Services\nUsing the Services to distribute malware, viruses, or harmful code\nInterfering with or disrupting the integrity or performance of the Services\nScraping, crawling, or using automated means to access the Services without permission\nPublishing custom nodes or workflows that contain malicious code or violate third-party rights', + 'tos.customer-responsibilities.block.0.heading': { + en: 'Registration.', + 'zh-CN': 'Registration.' + }, + 'tos.customer-responsibilities.block.1': { + en: 'In order to access and use the Comfy Products, you may be required to register an account by providing us with your email and other information requested in our registration form. You agree to provide us with complete and accurate registration information. You may not attempt to impersonate another person in registration. If you are registering on behalf of an organization, you warrant that you are authorized to agree to this Agreement on their behalf. You agree to be responsible for the security of your account. You accept that you are solely responsible for all activities that take place through your account, and that failure to limit access to your devices or systems may permit unauthorized use by third parties.', 'zh-CN': - '试图未经授权访问服务的任何部分\n利用服务传播恶意软件、病毒或有害代码\n干扰或破坏服务的完整性或性能\n未经许可使用自动化手段抓取或爬取服务\n发布包含恶意代码或侵犯第三方权利的自定义节点或工作流' + 'In order to access and use the Comfy Products, you may be required to register an account by providing us with your email and other information requested in our registration form. You agree to provide us with complete and accurate registration information. You may not attempt to impersonate another person in registration. If you are registering on behalf of an organization, you warrant that you are authorized to agree to this Agreement on their behalf. You agree to be responsible for the security of your account. You accept that you are solely responsible for all activities that take place through your account, and that failure to limit access to your devices or systems may permit unauthorized use by third parties.' }, - 'tos.accounts.label': { en: 'ACCOUNTS', 'zh-CN': '账户' }, - 'tos.accounts.title': { - en: '6. Accounts and User Information', - 'zh-CN': '6. 账户和用户信息' + 'tos.customer-responsibilities.block.2.heading': { + en: 'General Technology Restrictions.', + 'zh-CN': 'General Technology Restrictions.' }, - 'tos.accounts.block.0': { - en: 'Certain features of the Services may require you to create an account. You agree to provide accurate and complete information when creating your account and to keep this information up to date. You are responsible for all activity that occurs under your account. We reserve the right to suspend or terminate accounts that violate these Terms.', + 'tos.customer-responsibilities.block.3': { + en: 'You agree that you will not, directly or indirectly: (i) sublicense the Comfy Products for use by a third party; (ii) reverse engineer or attempt to extract the source code or underlying methodology from the Comfy Products or any related software, except to the extent that this restriction is expressly prohibited by Applicable Laws; (iii) use or facilitate the use of the Comfy Products for any activities that are prohibited by Applicable Laws or otherwise; (iv) bypass or circumvent measures employed to prevent or limit access to the Comfy Products; (v) use the Comfy Products to create a product or service competitive with Comfy’s products or services; (vi) create derivative works of or otherwise create, attempt to create or derive, or knowingly assist any third party to create or derive, the source code underlying the Comfy Products; or (vii) otherwise use or interact with the Comfy Products for any purpose not expressly permitted under this Agreement.', 'zh-CN': - '服务的某些功能可能要求您创建账户。您同意在创建账户时提供准确、完整的信息,并及时更新。您对账户下发生的所有活动负责。我们保留暂停或终止违反本条款的账户的权利。' + 'You agree that you will not, directly or indirectly: (i) sublicense the Comfy Products for use by a third party; (ii) reverse engineer or attempt to extract the source code or underlying methodology from the Comfy Products or any related software, except to the extent that this restriction is expressly prohibited by Applicable Laws; (iii) use or facilitate the use of the Comfy Products for any activities that are prohibited by Applicable Laws or otherwise; (iv) bypass or circumvent measures employed to prevent or limit access to the Comfy Products; (v) use the Comfy Products to create a product or service competitive with Comfy’s products or services; (vi) create derivative works of or otherwise create, attempt to create or derive, or knowingly assist any third party to create or derive, the source code underlying the Comfy Products; or (vii) otherwise use or interact with the Comfy Products for any purpose not expressly permitted under this Agreement.' }, - 'tos.ip.label': { en: 'IP RIGHTS', 'zh-CN': '知识产权' }, - 'tos.ip.title': { - en: '7. Intellectual Property Rights', - 'zh-CN': '7. 知识产权' + 'tos.customer-responsibilities.block.4.heading': { + en: 'Acceptable Use; Prohibited Customer Data.', + 'zh-CN': 'Acceptable Use; Prohibited Customer Data.' }, - 'tos.ip.block.0': { - en: 'The Services, excluding open-source components, are owned by Comfy and are protected by intellectual property laws. The Comfy name, logo, and branding are trademarks of Comfy Org, Inc. You retain ownership of any User Content you create. By submitting User Content to the Services, you grant Comfy a non-exclusive, worldwide, royalty-free license to host, display, and distribute such content as necessary to operate the Services.', + 'tos.customer-responsibilities.block.5': { + en: 'Customer is solely responsible for ensuring that all Input submitted to the Comfy Products complies with all Applicable Laws, and Customer agrees that it will not, and will not permit any third party to submit to Comfy or the Comfy Products or otherwise use the Comfy Products to create: (i) any data, designs, or other materials subject to U.S. export control laws and regulations; (ii) any viruses, malware, ransomware, Trojan horses, worms, spyware, or other malicious or harmful code or content that could damage, disrupt, interfere with, or compromise the Comfy Products, Comfy’s systems or infrastructure, or the data or systems of any other user or third party; (iii) any Customer Data that depicts, promotes, or facilitates illegal activity, including without limitation child sexual abuse material, non-consensual intimate imagery, or content that incites violence or hatred against any individual or group; (iv) any Customer Data that infringes or misappropriates the intellectual property rights, privacy rights, or publicity rights of any third party, including without limitation by submitting models, images, or other materials without the right to do so; (v) any content or information that is intentionally deceptive or misleading, including without limitation synthetic media designed to impersonate a real individual without their consent; or (vi) any Customer Data that could reasonably be expected to cause harm to any individual or group.', 'zh-CN': - '除开源组件外,服务归 Comfy 所有并受知识产权法保护。Comfy 名称、标志和品牌是 Comfy Org, Inc. 的商标。您保留您创建的任何用户内容的所有权。向服务提交用户内容即表示您授予 Comfy 一项非排他性、全球性、免版税的许可,以在运营服务所需的范围内托管、展示和分发此类内容。' + 'Customer is solely responsible for ensuring that all Input submitted to the Comfy Products complies with all Applicable Laws, and Customer agrees that it will not, and will not permit any third party to submit to Comfy or the Comfy Products or otherwise use the Comfy Products to create: (i) any data, designs, or other materials subject to U.S. export control laws and regulations; (ii) any viruses, malware, ransomware, Trojan horses, worms, spyware, or other malicious or harmful code or content that could damage, disrupt, interfere with, or compromise the Comfy Products, Comfy’s systems or infrastructure, or the data or systems of any other user or third party; (iii) any Customer Data that depicts, promotes, or facilitates illegal activity, including without limitation child sexual abuse material, non-consensual intimate imagery, or content that incites violence or hatred against any individual or group; (iv) any Customer Data that infringes or misappropriates the intellectual property rights, privacy rights, or publicity rights of any third party, including without limitation by submitting models, images, or other materials without the right to do so; (v) any content or information that is intentionally deceptive or misleading, including without limitation synthetic media designed to impersonate a real individual without their consent; or (vi) any Customer Data that could reasonably be expected to cause harm to any individual or group.' }, - 'tos.distribution.label': { en: 'DISTRIBUTION', 'zh-CN': '分发' }, - 'tos.distribution.title': { - en: '8. Model and Workflow Distribution', - 'zh-CN': '8. 模型和工作流分发' + + 'tos.payment.label': { en: 'PAYMENT', 'zh-CN': 'PAYMENT' }, + 'tos.payment.title': { en: '4. Payment', 'zh-CN': '4. Payment' }, + 'tos.payment.block.0.heading': { + en: 'Plans; Fees; Free Tier.', + 'zh-CN': 'Plans; Fees; Free Tier.' }, - 'tos.distribution.block.0': { - en: 'When you distribute models, workflows, or custom nodes through the Registry or Services, you represent that you have the right to distribute such content and that it does not infringe any third-party rights. You are responsible for specifying an appropriate license for any content you distribute. Comfy does not claim ownership of content distributed through the Registry.', + 'tos.payment.block.1': { + en: 'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.', 'zh-CN': - '当您通过 Registry 或服务分发模型、工作流或自定义节点时,您声明您有权分发此类内容且其不侵犯任何第三方权利。您有责任为分发的内容指定适当的许可证。Comfy 不主张对通过 Registry 分发的内容的所有权。' + 'Your use of the Comfy Products is subject to the plan selected via the applicable ordering page, online sign-up flow, or order form (“Plan”). Comfy may offer a free or freemium tier (“Free Tier”) and one or more paid tiers; the applicable Plan may include usage caps, feature restrictions, throttling, overage charges, or upgrade requirements, each as described in the pricing page or applicable Order Form. You are responsible for all usage under your account, including usage by your Users and under your credentials and API keys. Comfy may modify, suspend, or discontinue any Plan (including the Free Tier) consistent with this Agreement and the Order Forms.' }, - 'tos.fees.label': { en: 'FEES', 'zh-CN': '费用' }, - 'tos.fees.title': { en: '9. Fees and Payment', 'zh-CN': '9. 费用和付款' }, - 'tos.fees.block.0': { - en: 'Certain Services may be offered for a fee. If you choose to use paid features, you agree to pay all applicable fees as described at the time of purchase. Fees are non-refundable except as required by law or as expressly stated in these Terms. Comfy reserves the right to change pricing with reasonable notice.', + 'tos.payment.block.2.heading': { + en: 'Self-Serve Credit Card Billing.', + 'zh-CN': 'Self-Serve Credit Card Billing.' + }, + 'tos.payment.block.3': { + en: 'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method.', 'zh-CN': - '某些服务可能需要付费。如果您选择使用付费功能,则同意支付购买时所述的所有适用费用。除法律要求或本条款明确规定外,费用不予退还。Comfy 保留在合理通知后变更定价的权利。' + 'For self-serve Plans, Customer will provide a valid payment method (e.g., credit card) and authorizes Comfy (and its payment processor) to charge all fees and taxes when due. Unless the Order Forms state otherwise, subscription components (if any) will be billed in advance on a recurring basis and usage-based components (including any overages) will be billed in arrears for the applicable billing period (and may be charged as usage accrues). Paid self-serve Plans automatically renew for successive billing periods until cancelled through the console or as otherwise described in the Order Forms; if a charge fails, Comfy may retry the charge and Customer must promptly update its payment method.' }, - 'tos.termination.label': { en: 'TERMINATION', 'zh-CN': '终止' }, - 'tos.termination.title': { - en: '10. Term and Termination', - 'zh-CN': '10. 期限和终止' + 'tos.payment.block.4.heading': { + en: 'Invoiced Billing.', + 'zh-CN': 'Invoiced Billing.' }, - 'tos.termination.block.0': { - en: 'These Terms remain in effect while you use the Services. You may stop using the Services at any time. Comfy may suspend or terminate your access to the Services at any time, with or without cause and with or without notice. Upon termination, your right to use the Services will immediately cease. Sections that by their nature should survive termination will continue to apply.', + 'tos.payment.block.5': { + en: 'If Comfy approves invoiced billing for Customer, Comfy will invoice Customer in accordance with the applicable Order Form, and Customer will pay all undisputed amounts within thirty (30) days of the invoice date. Any purchase Order Forms are for administrative convenience only and will not modify this Agreement. Customer will notify Comfy in writing of any good-faith dispute regarding an invoice within thirty (30) days of the invoice date and will timely pay all undisputed amounts while the parties work to resolve the dispute.', 'zh-CN': - '在您使用服务期间,本条款持续有效。您可随时停止使用服务。Comfy 可随时暂停或终止您对服务的访问,无论是否有原因,也无论是否事先通知。终止后,您使用服务的权利将立即终止。按其性质应在终止后继续有效的条款将继续适用。' + 'If Comfy approves invoiced billing for Customer, Comfy will invoice Customer in accordance with the applicable Order Form, and Customer will pay all undisputed amounts within thirty (30) days of the invoice date. Any purchase Order Forms are for administrative convenience only and will not modify this Agreement. Customer will notify Comfy in writing of any good-faith dispute regarding an invoice within thirty (30) days of the invoice date and will timely pay all undisputed amounts while the parties work to resolve the dispute.' }, - 'tos.warranties.label': { en: 'WARRANTIES', 'zh-CN': '免责' }, - 'tos.warranties.title': { - en: '11. Disclaimer of Warranties', - 'zh-CN': '11. 免责声明' + 'tos.payment.block.6.heading': { + en: 'Prepaid Credits.', + 'zh-CN': 'Prepaid Credits.' }, - 'tos.warranties.block.0': { - en: 'THE SERVICES ARE PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. COMFY DOES NOT WARRANT THAT THE SERVICES WILL BE UNINTERRUPTED, ERROR-FREE, OR SECURE.', + 'tos.payment.block.7': { + en: 'Customer may prepay for usage credits (“Credits”) which may be applied toward usage of the Comfy Products at the rates set forth on Comfy’s pricing page. Except for documented billing errors or similar service issues attributed to Comfy, all purchases of Credits are final and non-refundable, and Comfy will not issue refunds or credits for any unused, partially used, or remaining Credits under any circumstances, including upon termination or expiration of Customer’s account. Comfy reserves the right to modify the pricing or Credit redemption rates applicable to future Credit purchases upon reasonable notice, but any Credits purchased prior to such modification will be honored at the rates in effect at the time of purchase.', 'zh-CN': - '服务按"现状"和"可用"基础提供,不附带任何形式的明示或暗示保证,包括但不限于对适销性、特定用途适用性和非侵权性的暗示保证。Comfy 不保证服务将不间断、无错误或安全。' + 'Customer may prepay for usage credits (“Credits”) which may be applied toward usage of the Comfy Products at the rates set forth on Comfy’s pricing page. Except for documented billing errors or similar service issues attributed to Comfy, all purchases of Credits are final and non-refundable, and Comfy will not issue refunds or credits for any unused, partially used, or remaining Credits under any circumstances, including upon termination or expiration of Customer’s account. Comfy reserves the right to modify the pricing or Credit redemption rates applicable to future Credit purchases upon reasonable notice, but any Credits purchased prior to such modification will be honored at the rates in effect at the time of purchase.' }, - 'tos.liability.label': { en: 'LIABILITY', 'zh-CN': '责任限制' }, + 'tos.payment.block.8.heading': { + en: 'Taxes; Price Changes; No Refunds.', + 'zh-CN': 'Taxes; Price Changes; No Refunds.' + }, + 'tos.payment.block.9': { + en: 'Fees are exclusive of all taxes, duties, levies, and similar governmental assessments (including sales, use, VAT/GST, and withholding taxes), and Customer is responsible for all such amounts other than taxes based on Comfy’s net income; if withholding is required by law, Customer will gross up payments so Comfy receives the invoiced amount, unless prohibited by law. Comfy may change fees or introduce new fees upon prior notice (including by posting to the pricing page or in-product notice), effective as of the next billing period or as otherwise stated in the notice. Except as required by law or expressly stated in the Order Forms, all fees are non-cancellable and non-refundable.', + 'zh-CN': + 'Fees are exclusive of all taxes, duties, levies, and similar governmental assessments (including sales, use, VAT/GST, and withholding taxes), and Customer is responsible for all such amounts other than taxes based on Comfy’s net income; if withholding is required by law, Customer will gross up payments so Comfy receives the invoiced amount, unless prohibited by law. Comfy may change fees or introduce new fees upon prior notice (including by posting to the pricing page or in-product notice), effective as of the next billing period or as otherwise stated in the notice. Except as required by law or expressly stated in the Order Forms, all fees are non-cancellable and non-refundable.' + }, + 'tos.payment.block.10.heading': { + en: 'Late Payments; Suspension.', + 'zh-CN': 'Late Payments; Suspension.' + }, + 'tos.payment.block.11': { + en: 'Overdue undisputed amounts may accrue interest at the lesser of 1.5% per month or the maximum rate permitted by law, plus reasonable collection costs. Comfy may suspend or limit access to the Comfy Products (including throttling, disabling API keys, or downgrading to the Free Tier) for non-payment of undisputed amounts after providing commercially reasonable notice and an opportunity to cure, unless Comfy reasonably determines immediate suspension is necessary to protect the Comfy Products or comply with Applicable Laws.', + 'zh-CN': + 'Overdue undisputed amounts may accrue interest at the lesser of 1.5% per month or the maximum rate permitted by law, plus reasonable collection costs. Comfy may suspend or limit access to the Comfy Products (including throttling, disabling API keys, or downgrading to the Free Tier) for non-payment of undisputed amounts after providing commercially reasonable notice and an opportunity to cure, unless Comfy reasonably determines immediate suspension is necessary to protect the Comfy Products or comply with Applicable Laws.' + }, + + 'tos.term-termination.label': { + en: 'TERM; TERMINATION', + 'zh-CN': 'TERM; TERMINATION' + }, + 'tos.term-termination.title': { + en: '5. Term; Termination', + 'zh-CN': '5. Term; Termination' + }, + 'tos.term-termination.block.0.heading': { + en: 'Termination of Agreement.', + 'zh-CN': 'Termination of Agreement.' + }, + 'tos.term-termination.block.1': { + en: 'You may stop using the Comfy Products at any time with or without notice. This Agreement will remain in effect until terminated in accordance with this Section. Either party may terminate this Agreement for convenience upon written notice to the other; provided, however, that to the extent the parties have entered into one or more executed Order Forms with a stated term, such Order Form will remain in effect for its stated term unless earlier terminated in accordance with its terms or this Agreement, and termination of this Agreement will not, by itself, terminate any then-effective Order Form.', + 'zh-CN': + 'You may stop using the Comfy Products at any time with or without notice. This Agreement will remain in effect until terminated in accordance with this Section. Either party may terminate this Agreement for convenience upon written notice to the other; provided, however, that to the extent the parties have entered into one or more executed Order Forms with a stated term, such Order Form will remain in effect for its stated term unless earlier terminated in accordance with its terms or this Agreement, and termination of this Agreement will not, by itself, terminate any then-effective Order Form.' + }, + 'tos.term-termination.block.2.heading': { + en: 'Effect of Termination.', + 'zh-CN': 'Effect of Termination.' + }, + 'tos.term-termination.block.3': { + en: 'Upon any termination or expiration of an Order Form (or this Agreement, if no Order Form is then in effect), Customer will promptly cease all use of the Comfy Products under the terminated arrangement and, if applicable, any continued use must be pursuant to a then-effective Order Form or other written authorization from Comfy. Comfy may suspend or terminate Customer’s access to the Comfy Products, or discontinue the Comfy Products or any portion or feature thereof, at any time; provided that Comfy will not terminate an unexpired Order Form for convenience unless the applicable Order Form expressly permits it, and any suspension or termination may be implemented immediately if Comfy reasonably determines that Customer’s use poses a security risk, violates this Agreement, or materially degrades the Comfy Products. Except as expressly set forth in an Order Form, Comfy will have no liability or other obligation to Customer arising out of or relating to any termination, suspension, or discontinuance under this Section.', + 'zh-CN': + 'Upon any termination or expiration of an Order Form (or this Agreement, if no Order Form is then in effect), Customer will promptly cease all use of the Comfy Products under the terminated arrangement and, if applicable, any continued use must be pursuant to a then-effective Order Form or other written authorization from Comfy. Comfy may suspend or terminate Customer’s access to the Comfy Products, or discontinue the Comfy Products or any portion or feature thereof, at any time; provided that Comfy will not terminate an unexpired Order Form for convenience unless the applicable Order Form expressly permits it, and any suspension or termination may be implemented immediately if Comfy reasonably determines that Customer’s use poses a security risk, violates this Agreement, or materially degrades the Comfy Products. Except as expressly set forth in an Order Form, Comfy will have no liability or other obligation to Customer arising out of or relating to any termination, suspension, or discontinuance under this Section.' + }, + 'tos.term-termination.block.4.heading': { + en: 'Survival.', + 'zh-CN': 'Survival.' + }, + 'tos.term-termination.block.5': { + en: 'Termination or expiration will not affect any rights or obligations, including the payment of amounts due, which have accrued under this Agreement up to the date of termination or expiration. Upon termination or expiration of this Agreement, the provisions that are intended by their nature to survive termination will survive and continue in full force and effect in accordance with their terms, including confidentiality obligations, proprietary rights, indemnification, limitations of liability, and disclaimers.', + 'zh-CN': + 'Termination or expiration will not affect any rights or obligations, including the payment of amounts due, which have accrued under this Agreement up to the date of termination or expiration. Upon termination or expiration of this Agreement, the provisions that are intended by their nature to survive termination will survive and continue in full force and effect in accordance with their terms, including confidentiality obligations, proprietary rights, indemnification, limitations of liability, and disclaimers.' + }, + + 'tos.confidentiality.label': { + en: 'CONFIDENTIALITY', + 'zh-CN': 'CONFIDENTIALITY' + }, + 'tos.confidentiality.title': { + en: '6. Confidentiality', + 'zh-CN': '6. Confidentiality' + }, + 'tos.confidentiality.block.0.heading': { + en: 'Definition of Confidential Information.', + 'zh-CN': 'Definition of Confidential Information.' + }, + 'tos.confidentiality.block.1': { + en: '“Confidential Information” means all non-public information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether oral or written, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure. Confidential Information of Customer includes Customer Data; Confidential Information of Comfy includes the Comfy Products; and each party’s Confidential Information includes the terms of this Agreement and any Order Forms (including pricing), as well as business, financial, marketing, technical, and product information. Confidential Information excludes information that the Receiving Party can demonstrate: (i) is or becomes publicly available without breach; (ii) was known prior to disclosure without breach; (iii) is received from a third party without breach; or (iv) was independently developed without use of or reference to the Disclosing Party’s Confidential Information.', + 'zh-CN': + '“Confidential Information” means all non-public information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether oral or written, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure. Confidential Information of Customer includes Customer Data; Confidential Information of Comfy includes the Comfy Products; and each party’s Confidential Information includes the terms of this Agreement and any Order Forms (including pricing), as well as business, financial, marketing, technical, and product information. Confidential Information excludes information that the Receiving Party can demonstrate: (i) is or becomes publicly available without breach; (ii) was known prior to disclosure without breach; (iii) is received from a third party without breach; or (iv) was independently developed without use of or reference to the Disclosing Party’s Confidential Information.' + }, + 'tos.confidentiality.block.2.heading': { + en: 'Protection of Confidential Information.', + 'zh-CN': 'Protection of Confidential Information.' + }, + 'tos.confidentiality.block.3': { + en: 'The Receiving Party will: (a) protect Confidential Information using at least reasonable care; (b) use it solely to perform under this Agreement; and (c) limit access to its and its Affiliates’ employees and contractors with a need to know and confidentiality obligations at least as protective as those herein. Neither party may disclose the terms of this Agreement or any Order Form except to its Affiliates, legal counsel, or accountants, and remains responsible for their compliance. Upon written request, the Receiving Party will promptly return or destroy Confidential Information, except for information retained in routine backups or as required by law or internal retention policies.', + 'zh-CN': + 'The Receiving Party will: (a) protect Confidential Information using at least reasonable care; (b) use it solely to perform under this Agreement; and (c) limit access to its and its Affiliates’ employees and contractors with a need to know and confidentiality obligations at least as protective as those herein. Neither party may disclose the terms of this Agreement or any Order Form except to its Affiliates, legal counsel, or accountants, and remains responsible for their compliance. Upon written request, the Receiving Party will promptly return or destroy Confidential Information, except for information retained in routine backups or as required by law or internal retention policies.' + }, + 'tos.confidentiality.block.4.heading': { + en: 'Compelled Disclosure.', + 'zh-CN': 'Compelled Disclosure.' + }, + 'tos.confidentiality.block.5': { + en: 'The Receiving Party may disclose Confidential Information if legally required, provided it gives prior notice (where permitted) and reasonable assistance, at the Disclosing Party’s expense, to seek protective treatment. Any disclosure will be limited to what is legally required, and the Receiving Party will request confidential treatment. These obligations survive while Confidential Information remains in the Receiving Party’s possession.', + 'zh-CN': + 'The Receiving Party may disclose Confidential Information if legally required, provided it gives prior notice (where permitted) and reasonable assistance, at the Disclosing Party’s expense, to seek protective treatment. Any disclosure will be limited to what is legally required, and the Receiving Party will request confidential treatment. These obligations survive while Confidential Information remains in the Receiving Party’s possession.' + }, + 'tos.confidentiality.block.6.heading': { + en: 'Data Security.', + 'zh-CN': 'Data Security.' + }, + 'tos.confidentiality.block.7': { + en: 'Comfy will implement and maintain commercially reasonable administrative, technical, and physical safeguards designed to protect Customer Data against unauthorized access, disclosure, alteration, or destruction. These measures will be no less protective than those Comfy uses to protect its own confidential information of a similar nature. In the event Comfy becomes aware of a confirmed security breach that results in unauthorized access to or disclosure of Customer Data, Comfy will notify Customer without undue delay and will provide reasonable cooperation to assist Customer in investigating and mitigating the effects of such breach. Customer acknowledges that no security measures are perfect or impenetrable, and Comfy does not guarantee that Customer Data will be free from unauthorized access or disclosure.', + 'zh-CN': + 'Comfy will implement and maintain commercially reasonable administrative, technical, and physical safeguards designed to protect Customer Data against unauthorized access, disclosure, alteration, or destruction. These measures will be no less protective than those Comfy uses to protect its own confidential information of a similar nature. In the event Comfy becomes aware of a confirmed security breach that results in unauthorized access to or disclosure of Customer Data, Comfy will notify Customer without undue delay and will provide reasonable cooperation to assist Customer in investigating and mitigating the effects of such breach. Customer acknowledges that no security measures are perfect or impenetrable, and Comfy does not guarantee that Customer Data will be free from unauthorized access or disclosure.' + }, + + 'tos.proprietary-rights.label': { + en: 'PROPRIETARY RIGHTS', + 'zh-CN': 'PROPRIETARY RIGHTS' + }, + 'tos.proprietary-rights.title': { + en: '7. Proprietary Rights', + 'zh-CN': '7. Proprietary Rights' + }, + 'tos.proprietary-rights.block.0.heading': { + en: 'Reservation of Rights.', + 'zh-CN': 'Reservation of Rights.' + }, + 'tos.proprietary-rights.block.1': { + en: 'Comfy and its licensors retain all right, title, and interest, including all intellectual property and proprietary rights, in and to the Comfy Products, Comfy Branding, and all software, code, algorithms, protocols, interfaces, tools, documentation, data structures, and other technology underlying or embodied in, or used to provide, the Comfy Products (collectively, “Comfy Materials”). Except for the limited rights expressly granted to Customer under this Agreement, no rights or licenses are granted, whether by implication, estoppel, or otherwise. Comfy expressly reserves all rights in and to the Comfy Materials not expressly granted hereunder.', + 'zh-CN': + 'Comfy and its licensors retain all right, title, and interest, including all intellectual property and proprietary rights, in and to the Comfy Products, Comfy Branding, and all software, code, algorithms, protocols, interfaces, tools, documentation, data structures, and other technology underlying or embodied in, or used to provide, the Comfy Products (collectively, “Comfy Materials”). Except for the limited rights expressly granted to Customer under this Agreement, no rights or licenses are granted, whether by implication, estoppel, or otherwise. Comfy expressly reserves all rights in and to the Comfy Materials not expressly granted hereunder.' + }, + 'tos.proprietary-rights.block.2.heading': { + en: 'Feedback.', + 'zh-CN': 'Feedback.' + }, + 'tos.proprietary-rights.block.3': { + en: 'You may from time to time provide feedback (including suggestions, comments for enhancements, functionality or usability, etc.) (“Feedback”) to Comfy regarding your experience using, and needs and integration requirements for, the Comfy Products. Comfy shall have full discretion to determine whether or not to proceed with the development of any requested enhancements, new features or functionality, and you hereby grant Comfy the full, unencumbered, royalty-free right to incorporate and otherwise fully exploit Feedback in connection with Comfy’s products and services.', + 'zh-CN': + 'You may from time to time provide feedback (including suggestions, comments for enhancements, functionality or usability, etc.) (“Feedback”) to Comfy regarding your experience using, and needs and integration requirements for, the Comfy Products. Comfy shall have full discretion to determine whether or not to proceed with the development of any requested enhancements, new features or functionality, and you hereby grant Comfy the full, unencumbered, royalty-free right to incorporate and otherwise fully exploit Feedback in connection with Comfy’s products and services.' + }, + 'tos.proprietary-rights.block.4.heading': { + en: 'Operational Metadata.', + 'zh-CN': 'Operational Metadata.' + }, + 'tos.proprietary-rights.block.5': { + en: 'Customer agrees that Comfy may collect and use Operational Metadata to operate, maintain, improve, and support the Comfy Products, including for diagnostics, analytics, system performance, and reporting purposes. Comfy will only disclose Operational Metadata externally if such data is (a) aggregated or anonymized with data across other customers, and (b) does not disclose the identity of Customer or any Customer Confidential Information.', + 'zh-CN': + 'Customer agrees that Comfy may collect and use Operational Metadata to operate, maintain, improve, and support the Comfy Products, including for diagnostics, analytics, system performance, and reporting purposes. Comfy will only disclose Operational Metadata externally if such data is (a) aggregated or anonymized with data across other customers, and (b) does not disclose the identity of Customer or any Customer Confidential Information.' + }, + + 'tos.disclaimer.label': { en: 'DISCLAIMER', 'zh-CN': 'DISCLAIMER' }, + 'tos.disclaimer.title': { en: '8. Disclaimer', 'zh-CN': '8. Disclaimer' }, + 'tos.disclaimer.block.0': { + en: 'THE Comfy Products AND OUTPUT ARE PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND. Comfy DISCLAIMS ANY AND ALL WARRANTIES, REPRESENTATIONS, AND CONDITIONS RELATING TO THE Comfy Products (INCLUDING ANY OUTPUT), WHETHER EXPRESS, IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY REPRESENTATION, WARRANTY, OR CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT. YOU AGREE AND ACKNOWLEDGE THAT YOUR USE OF ANY OUTPUT PROVIDED BY THE Comfy Products IS AT YOUR OWN RISK.', + 'zh-CN': + 'THE Comfy Products AND OUTPUT ARE PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND. Comfy DISCLAIMS ANY AND ALL WARRANTIES, REPRESENTATIONS, AND CONDITIONS RELATING TO THE Comfy Products (INCLUDING ANY OUTPUT), WHETHER EXPRESS, IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY REPRESENTATION, WARRANTY, OR CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT. YOU AGREE AND ACKNOWLEDGE THAT YOUR USE OF ANY OUTPUT PROVIDED BY THE Comfy Products IS AT YOUR OWN RISK.' + }, + 'tos.disclaimer.block.1': { + en: 'Customer is solely responsible for (a) verifying the Output is appropriate for Customer’s use case, and (b) any decisions, actions, or omissions taken in reliance on the OUTPUT. in no event will Comfy be liable for any damages or losses arising from or related to Customer’s use of or reliance on the OUTPUT, including any decisions made or actions taken based on the OUTPUT.', + 'zh-CN': + 'Customer is solely responsible for (a) verifying the Output is appropriate for Customer’s use case, and (b) any decisions, actions, or omissions taken in reliance on the OUTPUT. in no event will Comfy be liable for any damages or losses arising from or related to Customer’s use of or reliance on the OUTPUT, including any decisions made or actions taken based on the OUTPUT.' + }, + + 'tos.liability.label': { en: 'LIABILITY', 'zh-CN': 'LIABILITY' }, 'tos.liability.title': { - en: '12. Limitation of Liability', - 'zh-CN': '12. 责任限制' + en: '9. Limitation of Liability', + 'zh-CN': '9. Limitation of Liability' }, 'tos.liability.block.0': { - en: "TO THE MAXIMUM EXTENT PERMITTED BY LAW, COMFY SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS OR REVENUES, WHETHER INCURRED DIRECTLY OR INDIRECTLY, OR ANY LOSS OF DATA, USE, GOODWILL, OR OTHER INTANGIBLE LOSSES RESULTING FROM YOUR USE OF THE SERVICES. COMFY'S TOTAL LIABILITY SHALL NOT EXCEED THE AMOUNTS PAID BY YOU TO COMFY IN THE TWELVE MONTHS PRECEDING THE CLAIM.", + en: 'WHEN PERMITTED BY LAW, COMFY, AND COMFY’S SUPPLIERS AND DISTRIBUTORS, WILL NOT BE RESPONSIBLE FOR LOST PROFITS, REVENUES, OR DATA; FINANCIAL LOSSES; OR INDIRECT, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES. TO THE EXTENT PERMITTED BY LAW, THE TOTAL LIABILITY OF Comfy, AND ITS SUPPLIERS AND DISTRIBUTORS, FOR ANY CLAIM UNDER THIS AGREEMENT, INCLUDING FOR ANY IMPLIED WARRANTIES, IS LIMITED TO THE GREATER OF (A) ONE THOUSAND DOLLARS ($1,000); AND (B) THE AMOUNTS PAID OR PAYABLE BY CUSTOMER IN THE SIX (6) MONTHS PRECEDING THE DATE OF THE CLAIM. IN ALL CASES, Comfy, AND ITS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE LIABLE FOR ANY EXPENSE, LOSS, OR DAMAGE THAT IS NOT REASONABLY FORESEEABLE.', 'zh-CN': - '在法律允许的最大范围内,Comfy 不对任何间接、附带、特殊、后果性或惩罚性损害,或任何利润或收入损失(无论是直接还是间接产生的),或任何数据、使用、商誉或其他无形损失承担责任。Comfy 的总责任不超过您在索赔前十二个月内向 Comfy 支付的金额。' + 'WHEN PERMITTED BY LAW, COMFY, AND COMFY’S SUPPLIERS AND DISTRIBUTORS, WILL NOT BE RESPONSIBLE FOR LOST PROFITS, REVENUES, OR DATA; FINANCIAL LOSSES; OR INDIRECT, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES. TO THE EXTENT PERMITTED BY LAW, THE TOTAL LIABILITY OF Comfy, AND ITS SUPPLIERS AND DISTRIBUTORS, FOR ANY CLAIM UNDER THIS AGREEMENT, INCLUDING FOR ANY IMPLIED WARRANTIES, IS LIMITED TO THE GREATER OF (A) ONE THOUSAND DOLLARS ($1,000); AND (B) THE AMOUNTS PAID OR PAYABLE BY CUSTOMER IN THE SIX (6) MONTHS PRECEDING THE DATE OF THE CLAIM. IN ALL CASES, Comfy, AND ITS SUPPLIERS AND DISTRIBUTORS, WILL NOT BE LIABLE FOR ANY EXPENSE, LOSS, OR DAMAGE THAT IS NOT REASONABLY FORESEEABLE.' + }, + + 'tos.indemnification.label': { + en: 'INDEMNIFICATION', + 'zh-CN': 'INDEMNIFICATION' }, - 'tos.indemnification.label': { en: 'INDEMNIFICATION', 'zh-CN': '赔偿' }, 'tos.indemnification.title': { - en: '13. Indemnification', - 'zh-CN': '13. 赔偿' + en: '10. Indemnification', + 'zh-CN': '10. Indemnification' }, 'tos.indemnification.block.0': { - en: 'You agree to indemnify, defend, and hold harmless Comfy, its officers, directors, employees, and agents from and against any claims, liabilities, damages, losses, and expenses arising out of or in any way connected with your access to or use of the Services, your User Content, or your violation of these Terms.', + en: 'You agree to defend, indemnify, and hold harmless Comfy Organization, Inc. and its officers, directors, employees, contractors, and agents from and against any and all third-party claims, demands, actions, suits, or proceedings, and any resulting losses, damages, liabilities, costs, and expenses (including reasonable attorneys’ fees) to the extent resulting from your Customer Data or your breach of this Agreement. You must not settle any claim without Comfy’s prior written consent if the settlement would require Comfy to (a) admit fault, (b) pay any damages or other amounts, or (c) take or refrain from taking any action. Comfy may participate in a claim through counsel of its own choosing at its own expense, and you and Comfy will reasonably cooperate on the defense of any such claim.', 'zh-CN': - '您同意赔偿、辩护并使 Comfy 及其管理人员、董事、员工和代理人免受因您访问或使用服务、您的用户内容或您违反本条款而产生的或与之相关的任何索赔、责任、损害、损失和费用。' + 'You agree to defend, indemnify, and hold harmless Comfy Organization, Inc. and its officers, directors, employees, contractors, and agents from and against any and all third-party claims, demands, actions, suits, or proceedings, and any resulting losses, damages, liabilities, costs, and expenses (including reasonable attorneys’ fees) to the extent resulting from your Customer Data or your breach of this Agreement. You must not settle any claim without Comfy’s prior written consent if the settlement would require Comfy to (a) admit fault, (b) pay any damages or other amounts, or (c) take or refrain from taking any action. Comfy may participate in a claim through counsel of its own choosing at its own expense, and you and Comfy will reasonably cooperate on the defense of any such claim.' }, - 'tos.governing-law.label': { en: 'GOVERNING LAW', 'zh-CN': '适用法律' }, - 'tos.governing-law.title': { - en: '14. Governing Law and Dispute Resolution', - 'zh-CN': '14. 适用法律和争议解决' + + 'tos.dispute-resolution.label': { + en: 'DISPUTE RESOLUTION', + 'zh-CN': 'DISPUTE RESOLUTION' }, - 'tos.governing-law.block.0': { - en: 'These Terms shall be governed by and construed in accordance with the laws of the State of Delaware, without regard to its conflict of laws principles. Any disputes arising under these Terms shall be resolved through binding arbitration in accordance with the rules of the American Arbitration Association, except that either party may seek injunctive relief in any court of competent jurisdiction.', + 'tos.dispute-resolution.title': { + en: '11. Governing Law and Dispute Resolution', + 'zh-CN': '11. Governing Law and Dispute Resolution' + }, + 'tos.dispute-resolution.block.0.heading': { + en: 'Governing Law.', + 'zh-CN': 'Governing Law.' + }, + 'tos.dispute-resolution.block.1': { + en: 'This Agreement and any dispute, claim, or controversy arising out of or relating to this Agreement, the Comfy Products, or the parties’ relationship (each, a “Dispute”), shall be governed by and construed in accordance with the laws of the State of California, without regard to conflict of laws principles that would result in the application of the laws of any other jurisdiction.', 'zh-CN': - '本条款受特拉华州法律管辖并据其解释,不适用其冲突法原则。因本条款引起的任何争议应根据美国仲裁协会的规则通过有约束力的仲裁解决,但任何一方均可在有管辖权的法院寻求禁令救济。' + 'This Agreement and any dispute, claim, or controversy arising out of or relating to this Agreement, the Comfy Products, or the parties’ relationship (each, a “Dispute”), shall be governed by and construed in accordance with the laws of the State of California, without regard to conflict of laws principles that would result in the application of the laws of any other jurisdiction.' }, - 'tos.miscellaneous.label': { en: 'MISCELLANEOUS', 'zh-CN': '其他' }, - 'tos.miscellaneous.title': { en: '15. Miscellaneous', 'zh-CN': '15. 其他' }, - 'tos.miscellaneous.block.0': { - en: 'These Terms constitute the entire agreement between you and Comfy regarding the Services. If any provision of these Terms is found to be unenforceable, the remaining provisions will continue in effect. Our failure to enforce any right or provision of these Terms will not be considered a waiver. We may assign our rights under these Terms. You may not assign your rights without our prior written consent.', + 'tos.dispute-resolution.block.2.heading': { + en: 'Binding Arbitration; JAMS.', + 'zh-CN': 'Binding Arbitration; JAMS.' + }, + 'tos.dispute-resolution.block.3': { + en: 'Except as expressly set forth in Section 11(c) (Exceptions; Injunctive Relief), any Dispute shall be finally resolved by binding arbitration administered by JAMS in accordance with the JAMS Comprehensive Arbitration Rules and Procedures (or, if applicable, the JAMS Streamlined Arbitration Rules and Procedures), as in effect at the time the arbitration is commenced. The arbitration shall be seated in San Francisco, California, and conducted in English before one (1) arbitrator. Judgment on the award rendered by the arbitrator may be entered in any court of competent jurisdiction.', 'zh-CN': - '本条款构成您与 Comfy 之间关于服务的完整协议。如果本条款的任何条款被认定为不可执行,其余条款将继续有效。我们未能执行本条款的任何权利或条款不构成放弃。我们可以转让本条款下的权利。未经我们事先书面同意,您不得转让您的权利。' + 'Except as expressly set forth in Section 11(c) (Exceptions; Injunctive Relief), any Dispute shall be finally resolved by binding arbitration administered by JAMS in accordance with the JAMS Comprehensive Arbitration Rules and Procedures (or, if applicable, the JAMS Streamlined Arbitration Rules and Procedures), as in effect at the time the arbitration is commenced. The arbitration shall be seated in San Francisco, California, and conducted in English before one (1) arbitrator. Judgment on the award rendered by the arbitrator may be entered in any court of competent jurisdiction.' }, - 'tos.contact.label': { en: 'CONTACT', 'zh-CN': '联系' }, - 'tos.contact.title': { en: 'Contact Us', 'zh-CN': '联系我们' }, + 'tos.dispute-resolution.block.4.heading': { + en: 'Exceptions; Injunctive Relief.', + 'zh-CN': 'Exceptions; Injunctive Relief.' + }, + 'tos.dispute-resolution.block.5': { + en: 'Notwithstanding anything to the contrary, either party may seek temporary, preliminary, or permanent injunctive relief (or other equitable relief) in any court of competent jurisdiction located in San Francisco, CA to prevent or enjoin actual or threatened misuse, infringement, or misappropriation of its intellectual property rights, confidential information, or proprietary rights, without the necessity of posting bond or proving actual damages to the extent permitted by Applicable Law. In addition, either party may bring an individual claim in small claims court in San Francisco, CA, if the claim qualifies.', + 'zh-CN': + 'Notwithstanding anything to the contrary, either party may seek temporary, preliminary, or permanent injunctive relief (or other equitable relief) in any court of competent jurisdiction located in San Francisco, CA to prevent or enjoin actual or threatened misuse, infringement, or misappropriation of its intellectual property rights, confidential information, or proprietary rights, without the necessity of posting bond or proving actual damages to the extent permitted by Applicable Law. In addition, either party may bring an individual claim in small claims court in San Francisco, CA, if the claim qualifies.' + }, + 'tos.dispute-resolution.block.6.heading': { + en: 'Class Action Waiver.', + 'zh-CN': 'Class Action Waiver.' + }, + 'tos.dispute-resolution.block.7': { + en: 'To the fullest extent permitted by Applicable Law, the parties agree that any Dispute will be brought and resolved on an individual basis only, and not as a plaintiff or class member in any purported class, collective, consolidated, coordinated, or representative action or proceeding. The arbitrator may not consolidate claims or preside over any form of representative or class proceeding.', + 'zh-CN': + 'To the fullest extent permitted by Applicable Law, the parties agree that any Dispute will be brought and resolved on an individual basis only, and not as a plaintiff or class member in any purported class, collective, consolidated, coordinated, or representative action or proceeding. The arbitrator may not consolidate claims or preside over any form of representative or class proceeding.' + }, + 'tos.dispute-resolution.block.8.heading': { + en: 'Waiver of Jury Trial.', + 'zh-CN': 'Waiver of Jury Trial.' + }, + 'tos.dispute-resolution.block.9': { + en: 'To the fullest extent permitted by Applicable Law, each party hereby knowingly and irrevocably waives any right to a trial by jury in any action, proceeding, or counterclaim arising out of or relating to this Agreement or the Comfy Products.', + 'zh-CN': + 'To the fullest extent permitted by Applicable Law, each party hereby knowingly and irrevocably waives any right to a trial by jury in any action, proceeding, or counterclaim arising out of or relating to this Agreement or the Comfy Products.' + }, + 'tos.dispute-resolution.block.10.heading': { + en: 'Exclusive Forum for Court Proceedings.', + 'zh-CN': 'Exclusive Forum for Court Proceedings.' + }, + 'tos.dispute-resolution.block.11': { + en: 'To the extent any Dispute is not subject to arbitration under this Agreement, the parties agree to the exclusive jurisdiction and venue of the state and federal courts located in San Francisco, CA and each party irrevocably submits to such jurisdiction and venue and waives any objection based on inconvenient forum.', + 'zh-CN': + 'To the extent any Dispute is not subject to arbitration under this Agreement, the parties agree to the exclusive jurisdiction and venue of the state and federal courts located in San Francisco, CA and each party irrevocably submits to such jurisdiction and venue and waives any objection based on inconvenient forum.' + }, + 'tos.dispute-resolution.block.12.heading': { + en: 'Confidentiality.', + 'zh-CN': 'Confidentiality.' + }, + 'tos.dispute-resolution.block.13': { + en: 'The arbitration, including the existence of the arbitration, all materials submitted, and all testimony and awards, shall be confidential and may not be disclosed except as necessary to conduct the arbitration, to enforce an award, or as required by Applicable Law.', + 'zh-CN': + 'The arbitration, including the existence of the arbitration, all materials submitted, and all testimony and awards, shall be confidential and may not be disclosed except as necessary to conduct the arbitration, to enforce an award, or as required by Applicable Law.' + }, + 'tos.dispute-resolution.block.14.heading': { + en: 'Time Limit.', + 'zh-CN': 'Time Limit.' + }, + 'tos.dispute-resolution.block.15': { + en: 'To the fullest extent permitted by Applicable Law, any Dispute must be brought by you within one (1) year after the claim or cause of action first arose, or it is permanently barred.', + 'zh-CN': + 'To the fullest extent permitted by Applicable Law, any Dispute must be brought by you within one (1) year after the claim or cause of action first arose, or it is permanently barred.' + }, + + 'tos.miscellaneous.label': { en: 'MISCELLANEOUS', 'zh-CN': 'MISCELLANEOUS' }, + 'tos.miscellaneous.title': { + en: '12. Miscellaneous', + 'zh-CN': '12. Miscellaneous' + }, + 'tos.miscellaneous.block.0.heading': { + en: 'Export Compliance.', + 'zh-CN': 'Export Compliance.' + }, + 'tos.miscellaneous.block.1': { + en: 'You will comply with the export laws and regulations of the United States, the European Union and other applicable jurisdictions in providing and using the Comfy Products.', + 'zh-CN': + 'You will comply with the export laws and regulations of the United States, the European Union and other applicable jurisdictions in providing and using the Comfy Products.' + }, + 'tos.miscellaneous.block.2.heading': { + en: 'Publicity.', + 'zh-CN': 'Publicity.' + }, + 'tos.miscellaneous.block.3': { + en: 'You agree that Comfy may refer to your name, logo, and trademarks in Comfy’s marketing materials and website; however, Comfy will not use your name or trademarks in any other publicity (e.g., press releases, customer references and case studies) without your prior written consent (which may be by email) not to be unreasonably withheld, conditioned, or delayed.', + 'zh-CN': + 'You agree that Comfy may refer to your name, logo, and trademarks in Comfy’s marketing materials and website; however, Comfy will not use your name or trademarks in any other publicity (e.g., press releases, customer references and case studies) without your prior written consent (which may be by email) not to be unreasonably withheld, conditioned, or delayed.' + }, + 'tos.miscellaneous.block.4.heading': { + en: 'Third-Party Infrastructure.', + 'zh-CN': 'Third-Party Infrastructure.' + }, + 'tos.miscellaneous.block.5': { + en: 'Customer acknowledges that the Comfy Products relies on third-party infrastructure, hardware, and services, including cloud computing providers and GPU infrastructure providers (collectively, “Third-Party Infrastructure”), and that the availability, performance, and security of the Comfy Products may be affected by the operation, maintenance, or failure of such Third-Party Infrastructure. Comfy will use commercially reasonable efforts to maintain Comfy Products availability but makes no representation or warranty regarding the performance or availability of any Third-Party Infrastructure, and Comfy shall have no liability to Customer for any interruption, degradation, loss of data, or other harm arising out of or related to any failure, outage, or limitation of Third-Party Infrastructure, whether or not within Comfy’s control.', + 'zh-CN': + 'Customer acknowledges that the Comfy Products relies on third-party infrastructure, hardware, and services, including cloud computing providers and GPU infrastructure providers (collectively, “Third-Party Infrastructure”), and that the availability, performance, and security of the Comfy Products may be affected by the operation, maintenance, or failure of such Third-Party Infrastructure. Comfy will use commercially reasonable efforts to maintain Comfy Products availability but makes no representation or warranty regarding the performance or availability of any Third-Party Infrastructure, and Comfy shall have no liability to Customer for any interruption, degradation, loss of data, or other harm arising out of or related to any failure, outage, or limitation of Third-Party Infrastructure, whether or not within Comfy’s control.' + }, + 'tos.miscellaneous.block.6.heading': { + en: 'Assignment; Delegation.', + 'zh-CN': 'Assignment; Delegation.' + }, + 'tos.miscellaneous.block.7': { + en: 'Neither party hereto may assign or otherwise transfer this Agreement, in whole or in part, without the other party’s prior written consent, except that Comfy may assign this Agreement without consent to a successor to all or substantially all of its assets or business related to this Agreement. Any attempted assignment, delegation, or transfer by either party in violation hereof will be null and void. Subject to the foregoing, this Agreement will be binding on the parties and their successors and assigns.', + 'zh-CN': + 'Neither party hereto may assign or otherwise transfer this Agreement, in whole or in part, without the other party’s prior written consent, except that Comfy may assign this Agreement without consent to a successor to all or substantially all of its assets or business related to this Agreement. Any attempted assignment, delegation, or transfer by either party in violation hereof will be null and void. Subject to the foregoing, this Agreement will be binding on the parties and their successors and assigns.' + }, + 'tos.miscellaneous.block.8.heading': { + en: 'Amendment; Waiver.', + 'zh-CN': 'Amendment; Waiver.' + }, + 'tos.miscellaneous.block.9': { + en: 'Comfy reserves the right in its sole discretion and at any time and for any reason to modify this Agreement. Any modifications to this Agreement shall become effective upon the date of posting. Your continued use of, or access to, the Comfy Products after an update goes into effect will constitute acceptance of the update. If you do not agree with an update, you may stop using the Comfy Products or terminate this Agreement. No waiver by either party of any breach or default hereunder shall be deemed to be a waiver of any preceding or subsequent breach or default. Any such waiver will apply only to the specific provision and under the specific circumstances for which it was given, and will not apply with respect to any repeated or continued violation of the same provision or any other provision. Failure or delay by either party to enforce any provision of this Agreement will not be deemed a waiver of future enforcement of that or any other provision.', + 'zh-CN': + 'Comfy reserves the right in its sole discretion and at any time and for any reason to modify this Agreement. Any modifications to this Agreement shall become effective upon the date of posting. Your continued use of, or access to, the Comfy Products after an update goes into effect will constitute acceptance of the update. If you do not agree with an update, you may stop using the Comfy Products or terminate this Agreement. No waiver by either party of any breach or default hereunder shall be deemed to be a waiver of any preceding or subsequent breach or default. Any such waiver will apply only to the specific provision and under the specific circumstances for which it was given, and will not apply with respect to any repeated or continued violation of the same provision or any other provision. Failure or delay by either party to enforce any provision of this Agreement will not be deemed a waiver of future enforcement of that or any other provision.' + }, + 'tos.miscellaneous.block.10.heading': { + en: 'Relationship.', + 'zh-CN': 'Relationship.' + }, + 'tos.miscellaneous.block.11': { + en: 'Nothing contained herein will in any way constitute any association, partnership, agency, employment or joint venture between the parties hereto, or be construed to evidence the intention of the parties to establish any such relationship. Neither party will have the authority to obligate or bind the other in any manner, and nothing herein contained will give rise to, or is intended to give rise to any rights of any kind in favor of any third parties.', + 'zh-CN': + 'Nothing contained herein will in any way constitute any association, partnership, agency, employment or joint venture between the parties hereto, or be construed to evidence the intention of the parties to establish any such relationship. Neither party will have the authority to obligate or bind the other in any manner, and nothing herein contained will give rise to, or is intended to give rise to any rights of any kind in favor of any third parties.' + }, + 'tos.miscellaneous.block.12.heading': { + en: 'Unenforceability.', + 'zh-CN': 'Unenforceability.' + }, + 'tos.miscellaneous.block.13': { + en: 'If a court of competent jurisdiction determines that any provision of this Agreement is invalid, illegal, or otherwise unenforceable, such provision will be enforced as nearly as possible in accordance with the stated intention of the parties, while the remainder of this Agreement will remain in full force and effect and bind the parties according to its terms.', + 'zh-CN': + 'If a court of competent jurisdiction determines that any provision of this Agreement is invalid, illegal, or otherwise unenforceable, such provision will be enforced as nearly as possible in accordance with the stated intention of the parties, while the remainder of this Agreement will remain in full force and effect and bind the parties according to its terms.' + }, + 'tos.miscellaneous.block.14.heading': { + en: 'Notices.', + 'zh-CN': 'Notices.' + }, + 'tos.miscellaneous.block.15': { + en: 'Any notice required or permitted to be given hereunder will be given in writing by personal delivery, certified mail, return receipt requested, or by overnight delivery. Notices to you may be sent to the email address provided by you when you created your account with Comfy. Notices to Comfy must be sent to the following: 201 Spear Street, Ste 17, San Francisco, CA 94105.', + 'zh-CN': + 'Any notice required or permitted to be given hereunder will be given in writing by personal delivery, certified mail, return receipt requested, or by overnight delivery. Notices to you may be sent to the email address provided by you when you created your account with Comfy. Notices to Comfy must be sent to the following: 201 Spear Street, Ste 17, San Francisco, CA 94105.' + }, + 'tos.miscellaneous.block.16.heading': { + en: 'Entire Agreement.', + 'zh-CN': 'Entire Agreement.' + }, + 'tos.miscellaneous.block.17': { + en: 'This Agreement comprises the entire agreement between you and Comfy with respect to its subject matter, and supersedes all prior and contemporaneous proposals, statements, sales materials or presentations and agreements (oral and written). No oral or written information or advice given by Comfy, its agents or employees will create a warranty or in any way increase the scope of the warranties in this Agreement.', + 'zh-CN': + 'This Agreement comprises the entire agreement between you and Comfy with respect to its subject matter, and supersedes all prior and contemporaneous proposals, statements, sales materials or presentations and agreements (oral and written). No oral or written information or advice given by Comfy, its agents or employees will create a warranty or in any way increase the scope of the warranties in this Agreement.' + }, + + 'tos.contact.label': { en: 'CONTACT', 'zh-CN': 'CONTACT' }, + 'tos.contact.title': { en: '13. Contact Us', 'zh-CN': '13. Contact Us' }, 'tos.contact.block.0': { - en: 'If you have questions about these Terms, please contact us at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>.', + en: 'If you have any questions regarding this Agreement or the Comfy Products, please contact us at: <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>.', 'zh-CN': - '如果您对本条款有任何疑问,请通过 <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a> 与我们联系。' + 'If you have any questions regarding this Agreement or the Comfy Products, please contact us at: <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>.' }, // Customers page @@ -2243,6 +2713,20 @@ const translations = { 'zh-CN': '育碧 La Forge 开源了 CHORD PBR 材质估算模型及 ComfyUI 自定义节点,为 AAA 游戏制作实现了端到端的纹理生成工作流。' }, + 'customers.story.groove-jones.category': { + en: 'CASE STUDY', + 'zh-CN': '案例研究' + }, + 'customers.story.groove-jones.title': { + en: "How Groove Jones Delivered a Holiday FOOH Campaign for Dick's Sporting Goods with Comfy", + 'zh-CN': + "Groove Jones 如何借助 Comfy 为 Dick's Sporting Goods 打造节日 FOOH 营销" + }, + 'customers.story.groove-jones.body': { + en: 'Groove Jones, a Dallas-based creative studio, used Comfy to deliver a hyper-realistic FOOH holiday campaign for the Crocs x NFL collection on a fast-approaching deadline.', + 'zh-CN': + '达拉斯创意工作室 Groove Jones 借助 Comfy,在紧迫的节日档期内为 Crocs x NFL 联名系列交付了超写实的 FOOH 营销内容。' + }, 'customers.story.readMore': { en: 'READ MORE ON THIS TOPIC', 'zh-CN': '阅读更多相关内容' @@ -3276,6 +3760,215 @@ const translations = { 'zh-CN': 'ComfyUI 博客' }, + // Customer Detail: Groove Jones + // Topic 1: Intro + 'customers.detail.groove-jones.topic-1.label': { + en: 'INTRO', + 'zh-CN': '简介' + }, + 'customers.detail.groove-jones.topic-1.block.0': { + en: 'Groove Jones, a Dallas-based creative studio, builds AI-driven campaigns and immersive experiences for major brands where photoreal polish, creative ambition, and social-ready speed all have to land together. As their work expanded across AI Video, AR, VR, and WebGL for clients like Crocs, the NFL, and Dick\u2019s Sporting Goods, they faced a recurring challenge: delivering feature-film-quality VFX on commercial timelines and budgets.', + 'zh-CN': + '位于达拉斯的创意工作室 Groove Jones,为众多大牌客户打造由 AI 驱动的营销活动和沉浸式体验,需要同时兼顾照片级的精细度、创意野心,以及适配社交媒体的交付速度。随着他们为 Crocs、NFL 和 Dick\u2019s Sporting Goods 等客户的工作扩展到 AI 视频、AR、VR 和 WebGL,他们反复遇到同一个挑战:用商业项目的工期和预算,交付电影级的 VFX 质量。' + }, + 'customers.detail.groove-jones.topic-1.block.1': { + en: 'For the Crocs x NFL collection holiday launch, that challenge came to a head. The brief called for hyper-realistic video of giant NFL-licensed Crocs parachuting into real Dick\u2019s Sporting Goods parking lots, across multiple locations, delivered on a fast-approaching holiday deadline. A live-action shoot plus a traditional CG pipeline was off the table.', + 'zh-CN': + '在 Crocs x NFL 联名系列的节日上市项目中,这个挑战被推到了极致。Brief 要求制作超写实视频:巨型 NFL 授权 Crocs 鞋款跳伞落入多个真实的 Dick\u2019s Sporting Goods 停车场,并要在紧迫的节日档期前交付。实地拍摄加传统 CG 流水线的方案,已经完全行不通。' + }, + // Topic 2: The Output + 'customers.detail.groove-jones.topic-2.label': { + en: 'THE OUTPUT', + 'zh-CN': '交付成果' + }, + 'customers.detail.groove-jones.topic-2.title': { + en: 'The Output Groove Jones Achieved Using Comfy', + 'zh-CN': 'Groove Jones 借助 Comfy 实现的交付成果' + }, + 'customers.detail.groove-jones.topic-2.block.0': { + en: 'A full FOOH (faux out-of-home) social campaign delivered on a tight holiday deadline\nHyper-realistic videos of giant NFL-licensed Crocs parachuting onto Dick\u2019s Sporting Goods parking lots\nVertical 9:16 deliverables at 2K for Instagram Reels, TikTok, and YouTube Shorts\nSame-day iteration on client notes instead of week-long asset updates\nWinner, Aaron Awards 2024: Best AI Workflow for Production', + 'zh-CN': + '在紧迫的节日档期内交付完整的 FOOH(虚构户外广告)社媒营销活动\n超写实视频:巨型 NFL 授权 Crocs 鞋款跳伞落入 Dick\u2019s Sporting Goods 停车场\n面向 Instagram Reels、TikTok、YouTube Shorts 的 9:16 竖屏 2K 交付物\n客户反馈当天迭代,不再需要数周的资产更新周期\n荣获 2024 年 Aaron Awards:最佳 AI 制作工作流奖' + }, + // Topic 3: The Problem + 'customers.detail.groove-jones.topic-3.label': { + en: 'THE PROBLEM', + 'zh-CN': '挑战' + }, + 'customers.detail.groove-jones.topic-3.title': { + en: 'The Problem Groove Jones Was Trying to Solve', + 'zh-CN': 'Groove Jones 试图解决的问题' + }, + 'customers.detail.groove-jones.topic-3.block.0': { + en: 'A traditional pipeline for this creative meant a live-action shoot at multiple store locations plus a full CG build: high-res modeling of every team\u2019s clog, look development, lighting, rendering, compositing, and a new render every time the client wanted a variation. It also meant a large crew (modelers, texture artists, lighting artists, compositors) and a schedule measured in months. Neither the budget nor the holiday window supported that path.', + 'zh-CN': + '按照传统流水线做这个创意,意味着要在多家门店实地拍摄,加上完整的 CG 制作:每支球队鞋款的高精建模、look development、灯光、渲染、合成,客户每次想要新变体都要重新渲染。这也意味着庞大的团队(建模师、纹理师、灯光师、合成师),以及以"月"为单位的工期。无论是预算还是节日档期,都无法支撑这条路径。' + }, + // Topic 4: How Comfy Solved the Problem + 'customers.detail.groove-jones.topic-4.label': { + en: 'HOW COMFY SOLVED THE PROBLEM', + 'zh-CN': 'Comfy 如何解决问题' + }, + 'customers.detail.groove-jones.topic-4.title': { + en: 'How Groove Jones Used Comfy to Solve the Problem', + 'zh-CN': 'Groove Jones 如何用 Comfy 解决问题' + }, + 'customers.detail.groove-jones.topic-4.block.0': { + en: 'Groove Jones\u2019s Senior Creative Technologist, Doug Hogan, rebuilt the production process around Comfy\u2019s node-based workflow system, using their proprietary GrooveTech GenVFX pipeline. Custom LoRAs handled brand accuracy, a single Comfy graph orchestrated multiple generative models, and Nuke handled final polish. For a team with feature-film and commercial roots, the environment was immediately familiar.', + 'zh-CN': + 'Groove Jones 的高级创意技术总监 Doug Hogan 围绕 Comfy 的节点式工作流系统重新搭建了制作流程,并基于他们自研的 GrooveTech GenVFX 流水线展开。自定义 LoRA 负责保证品牌一致性,一张 Comfy 图编排多个生成模型,Nuke 负责最终精修。对于有电影和广告制作背景的团队,这套环境上手没有任何门槛。' + }, + 'customers.detail.groove-jones.topic-4.block.1.text': { + en: 'Comfy felt very similar to working inside a traditional CG and compositing pipeline. Node-based logic, clear data flow, modular builds. It felt natural to our artists already.', + 'zh-CN': + 'Comfy 用起来非常像传统 CG 和合成流水线:节点逻辑、清晰的数据流、模块化构建。我们的艺术家用起来毫无违和感。' + }, + 'customers.detail.groove-jones.topic-4.block.1.name': { + en: 'Doug Hogan | Senior Creative Technologist @ Groove Jones', + 'zh-CN': 'Doug Hogan | Groove Jones 高级创意技术总监' + }, + // Topic 5: Brand-Trained LoRAs + 'customers.detail.groove-jones.topic-5.label': { + en: 'BRAND-TRAINED LORAS', + 'zh-CN': '品牌定制 LORA' + }, + 'customers.detail.groove-jones.topic-5.title': { + en: 'Brand-Trained LoRAs for Hero Assets', + 'zh-CN': '为主视觉资产定制的品牌 LoRA' + }, + 'customers.detail.groove-jones.topic-5.block.0': { + en: 'Groove Jones trained custom LoRAs on the Crocs NFL Team Clogs and on Dick\u2019s Sporting Goods storefronts, so every generation came out anchored in brand-accurate references. Real team colorways, real product silhouettes, and real store exteriors stayed consistent across shots without per-frame correction, replacing what would normally take weeks of manual look development.', + 'zh-CN': + 'Groove Jones 基于 Crocs NFL 球队联名鞋款和 Dick\u2019s Sporting Goods 门店外景训练了定制 LoRA,让每一次生成都能锚定品牌精准的参考素材。真实的球队配色、产品轮廓和门店外观在不同镜头之间保持一致,不需要逐帧修正——而这通常意味着数周的 look development 工作量。' + }, + 'customers.detail.groove-jones.topic-5.block.1.src': { + en: 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-team-lineup.webp', + 'zh-CN': + 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-team-lineup.webp' + }, + 'customers.detail.groove-jones.topic-5.block.1.alt': { + en: 'Grid of brand-accurate NFL team Crocs generated via custom LoRAs', + 'zh-CN': '通过定制 LoRA 生成的多支 NFL 球队联名 Crocs 网格' + }, + 'customers.detail.groove-jones.topic-5.block.1.caption': { + en: 'Brand-accurate NFL team colorways generated through custom LoRAs.', + 'zh-CN': '通过定制 LoRA 生成的、与品牌精准一致的 NFL 球队配色。' + }, + // Topic 6: Multi-Model Orchestration + 'customers.detail.groove-jones.topic-6.label': { + en: 'MULTI-MODEL ORCHESTRATION', + 'zh-CN': '多模型编排' + }, + 'customers.detail.groove-jones.topic-6.title': { + en: 'Multi-Model Orchestration in a Single Graph', + 'zh-CN': '单张图内的多模型编排' + }, + 'customers.detail.groove-jones.topic-6.block.0': { + en: 'The creative required different generative models at different stages: Flux for key-frame still development, Gemini Flash 2.5 (Nano Banana) for fast ideation and variants, and Veo 3.1 plus Moonvalley\u2019s Marey for final video generation. Comfy routed between all four inside one graph, so outputs from one model fed directly into the next without ever leaving the environment.', + 'zh-CN': + '这个创意在不同阶段需要不同的生成模型:Flux 用于关键帧静帧开发,Gemini Flash 2.5(Nano Banana)用于快速构思和变体生成,Veo 3.1 加上 Moonvalley 的 Marey 用于最终的视频生成。Comfy 在一张图里就把这四个模型串起来,前一个模型的输出直接喂给下一个模型,全程无需切换环境。' + }, + 'customers.detail.groove-jones.topic-6.block.1.text': { + en: 'The Comfy community develops at an almost exponential curve, and we were able to leverage their existing nodes and tools to solve very specific production challenges instead of reinventing the wheel ourselves.', + 'zh-CN': + 'Comfy 社区几乎是指数级增长的,我们可以直接利用社区已有的节点和工具去解决非常具体的制作问题,而不必自己重新造轮子。' + }, + 'customers.detail.groove-jones.topic-6.block.1.name': { + en: 'Dale Carman | Co-founder @ Groove Jones', + 'zh-CN': 'Dale Carman | Groove Jones 联合创始人' + }, + // Topic 7: The Pipeline + 'customers.detail.groove-jones.topic-7.label': { + en: 'THE PIPELINE', + 'zh-CN': '流水线' + }, + 'customers.detail.groove-jones.topic-7.title': { + en: 'Storyboards to Previz to Final Shot in One Pipeline', + 'zh-CN': '从故事板到 Previz 再到成片,全部在一条流水线内' + }, + 'customers.detail.groove-jones.topic-7.block.0': { + en: 'The workflow opened with traditional storyboards for narrative approval, then moved into CGI blocking to lock composition, camera framing, and story beats. Comfy drove generation from there: the shoe drop, the parking lot reactions, the crowd coverage, and the environmental conversions that turned static summer storefronts into snow-covered holiday scenes, all inside the same graph.', + 'zh-CN': + '工作流从传统故事板开始用于叙事确认,再进入 CGI blocking,锁定构图、镜头取景和叙事节奏。从这里开始 Comfy 接管生成:鞋款空投、停车场反应镜头、人群覆盖、把夏季静态门店外景转换成被雪覆盖的节日场景——全部在同一张图里完成。' + }, + 'customers.detail.groove-jones.topic-7.block.1.src': { + en: 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-dicks-storyboards.webp', + 'zh-CN': + 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-dicks-storyboards.webp' + }, + 'customers.detail.groove-jones.topic-7.block.1.alt': { + en: 'Storyboard grid for the Crocs x NFL holiday campaign', + 'zh-CN': 'Crocs x NFL 节日营销的故事板网格' + }, + 'customers.detail.groove-jones.topic-7.block.1.caption': { + en: 'Grayscale storyboards used to lock narrative beats before generation.', + 'zh-CN': '在生成之前用于锁定叙事节奏的灰度故事板。' + }, + 'customers.detail.groove-jones.topic-7.block.2.src': { + en: 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-fooh-sequence.webp', + 'zh-CN': + 'https://media.comfy.org/website/customers/groove-jones/nfl-crocs-fooh-sequence.webp' + }, + 'customers.detail.groove-jones.topic-7.block.2.alt': { + en: 'Composition progression from blocking to mid-render to final shot', + 'zh-CN': '从 blocking 到中间渲染再到最终镜头的构图演进' + }, + 'customers.detail.groove-jones.topic-7.block.2.caption': { + en: 'Composition progression: wireframe blocking, mid-render, and final shot.', + 'zh-CN': '构图演进:线框 blocking、中间渲染、最终成片。' + }, + // Topic 8: Version Control + 'customers.detail.groove-jones.topic-8.label': { + en: 'VERSION CONTROL', + 'zh-CN': '版本管理' + }, + 'customers.detail.groove-jones.topic-8.title': { + en: 'Workflow Files as Version Control', + 'zh-CN': '把工作流文件当作版本管理' + }, + 'customers.detail.groove-jones.topic-8.block.0': { + en: 'Every variant of every shot lived as a Comfy workflow file, which doubled as version control. When notes came in requesting a different team colorway, store exterior, or time of day, the team duplicated a branch instead of rebuilding, which made same-day iteration possible. GPU usage and API credit burn were trackable inside the same environment as the work itself, giving Production real-time visibility into compute cost per iteration.', + 'zh-CN': + '每个镜头的每个变体都以 Comfy 工作流文件的形式存在,文件本身就是版本管理。当客户反馈要求换一支球队配色、换一个门店外景或者换一个时间段时,团队只需复制一个分支,而不是重建——这才让"当天迭代"成为可能。GPU 使用量和 API 额度消耗也都能在同一个环境里追踪到,让制作部门实时看到每次迭代的算力成本。' + }, + // Topic 9: Finishing in Nuke + 'customers.detail.groove-jones.topic-9.label': { + en: 'FINISHING IN NUKE', + 'zh-CN': 'Nuke 终修' + }, + 'customers.detail.groove-jones.topic-9.title': { + en: 'Finishing in Nuke', + 'zh-CN': '在 Nuke 中完成终修' + }, + 'customers.detail.groove-jones.topic-9.block.0': { + en: 'Generated shots moved into Nuke for final compositing: falling snow, camera shake, crowd ambience, holiday audio, and 2K mastering in 9:16 for Instagram Reels, TikTok, and YouTube Shorts. Because Comfy handled generation cleanly, Nuke focused on polish and motion enhancement rather than patching generative artifacts.', + 'zh-CN': + '生成的镜头进入 Nuke 完成最终合成:飘雪、镜头抖动、人群环境音、节日氛围音效,以及面向 Instagram Reels、TikTok、YouTube Shorts 的 9:16 2K 母带。由于 Comfy 把生成环节处理得很干净,Nuke 可以专注于精修和动态增强,而不是去修补生成模型留下的瑕疵。' + }, + // Topic 10: The Takeaway + 'customers.detail.groove-jones.topic-10.label': { + en: 'THE TAKEAWAY', + 'zh-CN': '总结' + }, + 'customers.detail.groove-jones.topic-10.title': { + en: 'Conclusion', + 'zh-CN': '结语' + }, + 'customers.detail.groove-jones.topic-10.block.0': { + en: 'By building the FOOH pipeline inside Comfy, Groove Jones turned a brief that would have required an expensive live-action shoot plus months of CG into a fast, iterative, single-environment workflow the client could direct in real time. The project recently won the Aaron Award for Best AI Workflow for Production.', + 'zh-CN': + '通过在 Comfy 中搭建整套 FOOH 流水线,Groove Jones 把一个原本需要昂贵实地拍摄加数月 CG 制作的项目,变成了一套高速迭代、单一环境、客户可以实时指挥的工作流。该项目近期还荣获 Aaron Award 的"最佳 AI 制作工作流"奖。' + }, + 'customers.detail.groove-jones.topic-10.block.1.text': { + en: 'At Groove Jones, we care deeply about delivering work that makes people say WOW! But we also care about delivering on time and on budget. VFX projects used to operate at razor thin margins. Comfy solved that for us.', + 'zh-CN': + '在 Groove Jones,我们非常在意交付让人说"WOW!"的作品,但我们同样在意按时按预算交付。VFX 项目以前的利润率薄得像刀刃,Comfy 帮我们彻底解决了这个问题。' + }, + 'customers.detail.groove-jones.topic-10.block.1.name': { + en: 'Dale Carman | Co-founder @ Groove Jones', + 'zh-CN': 'Dale Carman | Groove Jones 联合创始人' + }, + // Contact – FormSection 'contact.form.badge': { en: 'CONTACT SALES', @@ -3307,6 +4000,94 @@ 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': '接下来看什么?' @@ -3357,6 +4138,103 @@ 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>> diff --git a/apps/website/src/layouts/BaseLayout.astro b/apps/website/src/layouts/BaseLayout.astro index 77cf76131c..5397d96820 100644 --- a/apps/website/src/layouts/BaseLayout.astro +++ b/apps/website/src/layouts/BaseLayout.astro @@ -5,11 +5,13 @@ 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 } @@ -17,10 +19,13 @@ 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) @@ -62,10 +67,11 @@ 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} - + @@ -89,8 +95,11 @@ const websiteJsonLd = { - ` or ` proxyWidgets[properties.proxyWidgets] + workflow --> hostValues[host widgets_values] + proxyWidgets --> promotionStore[PromotionStore / promotion runtime] + promotionStore --> sourceWidget[Interior source widget] + linkedInput[Linked SubgraphInput] --> hostWidget[Host promoted widget] + sourceWidget --> hostWidget + hostValues --> hostWidget + hostWidget --> prompt[Prompt serialization] + hostWidget -. may copy value back .-> sourceWidget + sourceWidget -. shared by host instances .-> otherHost[Another host instance] + + classDef legacy fill:#fff3cd,stroke:#a66f00,color:#332200 + classDef ambiguous fill:#f8d7da,stroke:#842029,color:#330000 + classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16 + + class proxyWidgets,promotionStore legacy + class sourceWidget,hostValues ambiguous + class linkedInput,hostWidget canonical +``` + +Key problems in the old flow: + +- `properties.proxyWidgets` and linked `SubgraphInput` widgets could describe + the same promotion. +- Interior source widgets supplied both schema metadata and, in some flows, + persisted host values. +- Multiple host instances of the same subgraph could stomp one another through + the shared interior widget value. +- Display-only previews were mixed into widget-promotion language even though + they do not own values or feed prompt serialization. + +## After: linked inputs are the promoted-widget boundary + +Promoted value widgets are now represented only as standard linked +`SubgraphInput` widgets. The source widget remains the schema/default provider, +but the host `SubgraphNode` owns the promoted value. + +```mermaid +flowchart TD + workflow[Workflow JSON] --> subgraphInterface[Subgraph interface / inputs] + workflow --> hostValues[host widgets_values] + subgraphInterface --> subgraphInput[SubgraphInput.name] + subgraphInput --> hostWidget[Host-scoped widget entity] + hostValues --> hostWidget + sourceWidget[Interior source widget] --> schema[Schema, type, options, tooltip, default] + schema --> hostWidget + hostWidget --> prompt[Prompt serialization] + + hostIdentity[Host node locator + SubgraphInput.name] --> hostWidget + sourceWidget -. metadata only .-> diagnostics[Diagnostics / lookup / migration] + sourceWidget -. no host value ownership .-> schema + + classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16 + classDef metadata fill:#cff4fc,stroke:#055160,color:#032830 + classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529 + + class subgraphInterface,subgraphInput,hostWidget,hostIdentity owner + class sourceWidget,schema,diagnostics metadata + class workflow,hostValues persisted +``` + +Canonical ownership after the migration: + +- UI/value identity is host-scoped: host node locator plus + `SubgraphInput.name`. +- `SubgraphInput.name` is stable identity; labels and localized names are + display-only. +- Host values win during repair, persistence, and prompt serialization. +- Source widgets provide metadata and defaults only. +- Canonical saves omit repaired `properties.proxyWidgets` entries. + +## Legacy load migration + +Loading a workflow with legacy `proxyWidgets` performs an idempotent repair. The +repair builds a plan before mutating graph state, then re-resolves against the +current graph when node IDs and links are stable. + +```mermaid +flowchart TD + start[Load workflow] --> parse{Parse properties.proxyWidgets} + parse -->|invalid raw data| invalid[console.error and ignore] + parse -->|valid tuples| plan[Build repair plan] + plan --> classify{Classify entry} + + classify -->|value widget| valueRepair{Already linked SubgraphInput?} + valueRepair -->|yes| consume[Consume legacy proxy entry] + valueRepair -->|no| repair[Repair through subgraph input/link systems] + repair --> repairResult{Repair succeeded?} + repairResult -->|yes| consume + repairResult -->|no| quarantine[Persist proxyWidgetErrorQuarantine] + + classify -->|primitive fanout| primitive[Validate all primitive targets] + primitive --> primitiveResult{All targets reconnectable?} + primitiveResult -->|yes| primitiveRepair[Create one SubgraphInput and reconnect fanout] + primitiveRepair --> consume + primitiveResult -->|no| quarantine + + classify -->|display-only preview| preview[Create / keep previewExposures entry] + preview --> consume + + consume --> save[Canonical save] + quarantine --> save + save --> omit[Omit repaired entries from proxyWidgets] + save --> keepQuarantine[Persist unrepaired value intent in quarantine] + save --> keepPreview[Persist previews in previewExposures] + + classDef ok fill:#d1e7dd,stroke:#0f5132,color:#052e16 + classDef warn fill:#fff3cd,stroke:#a66f00,color:#332200 + classDef error fill:#f8d7da,stroke:#842029,color:#330000 + classDef neutral fill:#e2e3e5,stroke:#41464b,color:#212529 + + class consume,repair,primitiveRepair,preview,save,omit,keepPreview ok + class plan,classify,valueRepair,primitive,primitiveResult,repairResult neutral + class quarantine,keepQuarantine warn + class invalid error +``` + +## Preview exposures are separate from value widgets + +Display-only previews, such as `$$canvas-image-preview`, are not promoted +widgets. They have host-scoped serialized identity, but they do not create +prompt inputs, do not create `widgets_values`, and do not own user values. + +```mermaid +flowchart TD + hostNode[Host SubgraphNode] --> previewExposures[properties.previewExposures] + previewExposures --> exposure[PreviewExposure.name] + exposure --> sourceLocator[sourceNodeId + sourcePreviewName] + sourceLocator --> runtimePreview[Runtime preview/output state] + runtimePreview --> hostCanvas[Host canvas / app-mode preview] + + exposure --> uiIdentity[hostNodeLocator + previewName] + runtimePreview -. UI projection only .-> hostCanvas + previewExposures -. no prompt input .-> noPrompt[No prompt serialization] + previewExposures -. no value widget .-> noValue[No widgets_values entry] + previewExposures -. no graph edge .-> noEdge[No executable graph edge] + + classDef preview fill:#cff4fc,stroke:#055160,color:#032830 + classDef noValue fill:#f8d7da,stroke:#842029,color:#330000 + classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529 + + class previewExposures,exposure,sourceLocator,runtimePreview,hostCanvas,uiIdentity preview + class noPrompt,noValue,noEdge noValue + class hostNode persisted +``` + +For nested subgraphs, preview exposures chain across immediate host boundaries +instead of persisting flattened deep paths. + +```mermaid +flowchart LR + outerHost[Outer SubgraphNode] --> outerExposure[Outer previewExposures entry] + outerExposure --> innerHost[Immediate inner SubgraphNode] + innerHost --> innerExposure[Inner previewExposures entry] + innerExposure --> deepestPreview[Interior preview source] + deepestPreview --> media[Resolved media] + + outerExposure -. sourcePreviewName names inner preview identity .-> innerExposure + outerExposure -. does not persist deep private path .-> opaque[Subgraph internals remain opaque] + + classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16 + classDef preview fill:#cff4fc,stroke:#055160,color:#032830 + classDef note fill:#fff3cd,stroke:#a66f00,color:#332200 + + class outerHost,innerHost boundary + class outerExposure,innerExposure,deepestPreview,media preview + class opaque note +``` + +## Serialization summary + +```mermaid +flowchart TD + canonical[Canonical serialized SubgraphNode] --> inputs[Subgraph interface / inputs] + canonical --> values[widgets_values for host-owned values] + canonical --> previews[properties.previewExposures] + canonical --> quarantine[properties.proxyWidgetErrorQuarantine] + canonical -. omits repaired entries .-> noProxy[No canonical proxyWidgets] + + inputs --> valueWidgets[Promoted value widgets] + values --> valueWidgets + previews --> previewUi[Display-only preview UI] + quarantine --> futureTooling[Future recovery tooling] + + valueWidgets --> prompt[Prompt serialization] + previewUi -. not serialized into prompt .-> prompt + quarantine -. inert .-> prompt + + classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16 + classDef inert fill:#fff3cd,stroke:#a66f00,color:#332200 + classDef removed fill:#f8d7da,stroke:#842029,color:#330000 + + class inputs,values,valueWidgets,prompt,canonical canonical + class previews,previewUi,quarantine,futureTooling inert + class noProxy removed +``` diff --git a/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/disambiguating-source-node-id.md b/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/disambiguating-source-node-id.md new file mode 100644 index 0000000000..f9cb51b1f4 --- /dev/null +++ b/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/disambiguating-source-node-id.md @@ -0,0 +1,147 @@ +# Appendix: Removing `disambiguatingSourceNodeId` + +This appendix explains where the existing promotion system needs +`disambiguatingSourceNodeId`, why that need appears, and how the canonical form +chosen by [ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md) +removes the pattern from promoted-widget identity. + +## Why the disambiguator exists + +The legacy promotion model identifies a promoted widget by source location: + +```ts +type PromotedWidgetSource = { + sourceNodeId: string + sourceWidgetName: string + disambiguatingSourceNodeId?: string +} +``` + +`sourceNodeId` is the immediate interior node visible from the host subgraph. +That is not always the original widget owner. When promotions pass through +nested subgraphs, two promoted widgets can have the same immediate +`sourceNodeId` and `sourceWidgetName` while pointing at different leaf widgets. +`disambiguatingSourceNodeId` carries the deepest source node ID so the runtime +can choose the right promoted view. + +```mermaid +flowchart TD + outerHost[Outer host SubgraphNode] --> middleNode[Interior middle SubgraphNode] + middleNode --> middleWidgetA[Promoted widget view: text] + middleNode --> middleWidgetB[Promoted widget view: text] + middleWidgetA --> leafA[Leaf source node 17 / widget text] + middleWidgetB --> leafB[Leaf source node 42 / widget text] + + oldKeyA[Old key: middleNodeId + text + disambiguatingSourceNodeId 17] + oldKeyB[Old key: middleNodeId + text + disambiguatingSourceNodeId 42] + middleWidgetA -. requires .-> oldKeyA + middleWidgetB -. requires .-> oldKeyB + + classDef host fill:#d1e7dd,stroke:#0f5132,color:#052e16 + classDef ambiguous fill:#fff3cd,stroke:#a66f00,color:#332200 + classDef leaf fill:#cff4fc,stroke:#055160,color:#032830 + + class outerHost host + class middleNode,middleWidgetA,middleWidgetB,oldKeyA,oldKeyB ambiguous + class leafA,leafB leaf +``` + +The disambiguator is therefore not a domain concept. It is compensating for an +identity model that asks host UI state to identify private nested internals. + +## Existing places that need it + +| Area | Current use of `disambiguatingSourceNodeId` | Ambiguity being patched | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Promotion source types | `PromotedWidgetSource` and `PromotedWidgetView` carry the optional field. | Source identity needs more than immediate node ID plus widget name for nested promoted views. | +| Concrete widget resolution | `findWidgetByIdentity(...)` matches promoted views by `(disambiguatingSourceNodeId ?? sourceNodeId)` when a source node ID is supplied. | Multiple promoted views under the same intermediate node can share a widget name. | +| Legacy proxy normalization | Prefixed legacy names such as `123:widget_name` are converted into structured source identity and tested with candidate disambiguators. | Old serialized names encode leaf identity inside the widget name string. | +| Promotion store keys | `makePromotionEntryKey(...)`, `isPromoted(...)`, and `demote(...)` include the field in equality. | Store-level uniqueness would collapse distinct nested promotions without the leaf ID. | +| Linked promotion propagation | `SubgraphNode._resolveLinkedPromotionBySubgraphInput(...)` preserves the leaf ID when a linked input targets an inner subgraph promoted view. | The outer host otherwise sees only the immediate inner `SubgraphNode` and the promoted widget name. | +| Subgraph editor UI | The editor uses the field when resolving active widgets and when writing reordered/toggled promotions back to the store. | UI list operations must not merge same-name promoted views from different leaves. | + +## New promoted-widget identity + +ADR 0009 moves promoted value identity to the host boundary: + +```ts +type PromotedWidgetUiIdentity = { + hostNodeLocator: string + subgraphInputName: string +} +``` + +The canonical widget is owned by a `SubgraphInput` on the host +`SubgraphNode`. The host widget no longer needs to identify the deepest source +node to preserve value identity. The source widget is consulted for schema, +defaults, diagnostics, and migration, but it is not the value owner. + +```mermaid +flowchart TD + host[Host SubgraphNode] --> inputA[SubgraphInput.name: prompt] + host --> inputB[SubgraphInput.name: negative_prompt] + inputA --> hostWidgetA[Host-owned widget entity] + inputB --> hostWidgetB[Host-owned widget entity] + + hostWidgetA -. schema/default metadata .-> sourceA[Interior source widget text] + hostWidgetB -. schema/default metadata .-> sourceB[Interior source widget text] + + identityA[Identity: hostNodeLocator + prompt] --> hostWidgetA + identityB[Identity: hostNodeLocator + negative_prompt] --> hostWidgetB + sourceA -. not part of host value key .-> identityA + sourceB -. not part of host value key .-> identityB + + classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16 + classDef metadata fill:#cff4fc,stroke:#055160,color:#032830 + classDef removed fill:#f8d7da,stroke:#842029,color:#330000 + + class host,inputA,inputB,hostWidgetA,hostWidgetB,identityA,identityB owner + class sourceA,sourceB metadata +``` + +This is the same rule the subgraph interface already uses: `name` is stable +identity, and `label` / `localized_name` are display-only. + +## How the new form removes each need + +| Previous disambiguation site | New canonical replacement | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `PromotedWidgetSource.disambiguatingSourceNodeId` | Host value identity is `hostNodeLocator + SubgraphInput.name`; source locator fields become migration/diagnostic metadata only. | +| `PromotedWidgetView.disambiguatingSourceNodeId` | Host-scoped widget entities are derived from subgraph inputs, not from promoted views chained through nested source widgets. | +| `findWidgetByIdentity(...)` leaf matching | Runtime value lookup starts from the host input identity; source traversal is metadata resolution, not value identity resolution. | +| Legacy prefixed widget-name normalization | Load migration consumes legacy source-shaped entries and writes standard subgraph input state or quarantine metadata. | +| PromotionStore source-key equality | `PromotionStore` becomes a temporary derived index; canonical consumers query subgraph inputs directly. | +| Linked promotion propagation across nested hosts | Nested value composition is represented boundary-by-boundary by linked subgraph inputs with stable names. | +| Subgraph editor active widget matching | Editor state can operate on host boundary entries instead of matching leaf source widgets through same-name promoted views. | + +## Boundary-by-boundary nested flow + +The new form avoids flattened deep source paths. Each host boundary exposes its +own named input, and the next outer host links to that immediate boundary +contract. + +```mermaid +flowchart LR + leaf[Leaf node widget] --> innerInput[Inner SubgraphInput.name: text] + innerInput --> innerHostWidget[Inner host-owned widget] + innerHostWidget --> outerInput[Outer SubgraphInput.name: prompt] + outerInput --> outerHostWidget[Outer host-owned widget] + + innerIdentity[Inner value key: innerHost + text] --> innerHostWidget + outerIdentity[Outer value key: outerHost + prompt] --> outerHostWidget + leaf -. schema/default source .-> innerHostWidget + leaf -. not persisted as outer value key .-> outerIdentity + + classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16 + classDef source fill:#cff4fc,stroke:#055160,color:#032830 + classDef note fill:#fff3cd,stroke:#a66f00,color:#332200 + + class innerInput,innerHostWidget,outerInput,outerHostWidget,innerIdentity,outerIdentity boundary + class leaf source +``` + +Because each layer has its own stable `SubgraphInput.name`, two same-name leaf +widgets no longer require a persisted leaf-node disambiguator at the outer host. +If the user exposes both, the collision is resolved when the host inputs are +created by assigning distinct input names with the existing unique-name +behavior. diff --git a/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/system-comparison.md b/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/system-comparison.md new file mode 100644 index 0000000000..82265820e5 --- /dev/null +++ b/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/system-comparison.md @@ -0,0 +1,37 @@ +# Appendix: System comparison + +This appendix compares the legacy promoted-widget systems with the canonical +linked-input model chosen by +[ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md). + +| Concern | Legacy `properties.proxyWidgets` promotions | Linked `SubgraphInput` promotions before migration | New canonical linked-input system | +| -------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | +| Serialized authority | `properties.proxyWidgets` stores source node/widget tuples as promotion topology. | Subgraph interface/input links can also represent the same exposed widget. | Subgraph interface/input links are the only canonical topology for promoted value widgets. | +| Load-time role | Hydrates promoted widgets directly from legacy tuples. | May already describe the promoted widget, creating overlap with `proxyWidgets`. | Existing linked inputs are accepted as resolved; legacy tuples are consumed by repair or quarantined. | +| Save-time role | Could be re-emitted as promotion state. | Serialized as normal subgraph interface data. | Repaired `proxyWidgets` entries are omitted; standard subgraph inputs plus host `widgets_values` are saved. | +| Value owner | Ambiguous: host `widgets_values` and the interior source widget could both carry the value. | Closer to the desired boundary model, but still coexisted with source/proxy ownership paths. | Host `SubgraphNode` owns value state through host-scoped widget identity. | +| Schema/default provider | Interior source widget provides schema and may also become persistence carrier. | Interior source widget provides source metadata through the link. | Interior source widget provides schema, type, options, tooltip, and defaults only. | +| UI identity | Often persisted by source node/widget identity. | Can use subgraph input identity, but mixed states still exist while proxy identity remains. | Host node locator plus `SubgraphInput.name`. | +| Display label handling | Source widget identity and display concerns can blur. | Uses existing subgraph input naming conventions. | `SubgraphInput.name` is stable identity; `label` / `localized_name` are display-only. | +| Multiple host instances | Risk of host instances stomping one another through shared interior values. | Better host boundary shape, but overlap with proxy/source value paths can reintroduce ambiguity. | Host-instance-owned sparse overlay prevents shared interior widget value stomping. | +| Prompt serialization | May read values through promoted runtime state that can collapse to source widgets. | Can serialize through standard subgraph input widgets when used consistently. | Promoted values serialize only through standard host-owned subgraph-input widgets. | +| Interior mutation on save | Existing `SubgraphNode.serialize()` behavior could copy exterior values into connected interior widgets. | Could still be affected by legacy copy-back behavior. | Copy-back is removed; source widgets are not persistence carriers. | +| Primitive-node promotions | Legacy tuples may point at `PrimitiveNode` outputs. | Not the canonical primitive fanout representation by itself. | Repaired all-or-nothing into one `SubgraphInput` that reconnects validated fanout targets. | +| Invalid or unresolved data | Invalid data could sit in legacy promotion state or fail repair paths. | Missing linked inputs can be ambiguous when proxy data exists. | Invalid raw data logs and is ignored; unrepaired valid value entries go to `proxyWidgetErrorQuarantine`. | +| Display-only previews | Often mixed into `proxyWidgets` despite not being value widgets. | Linked inputs are inappropriate because previews do not own values or prompt inputs. | Separate host-scoped `properties.previewExposures` entries model preview UI only. | +| Preview persistence | Preview selections can depend on source preview/widget-like identity. | No clean distinction from promoted widget inputs. | Preview identity is host node locator plus `previewName`; unresolved previews stay inert and persisted. | +| Nested preview behavior | Deep source identity can leak through host UI state. | Linked value inputs do not model display-only preview composition. | Preview exposures chain across immediate subgraph host boundaries; deep private paths are not persisted. | +| ECS compatibility | Weak: value identity can depend on source widget tuples and mutable interior widgets. | Partial: linked inputs fit boundary modeling, but duplicate authority remains. | Strong: host-scoped widget entity identity maps cleanly to ECS component state. | +| Long-term status | Legacy load-time input only. | Becomes the standard representation once overlap is removed. | Canonical system; `PromotionStore` becomes a temporary derived compatibility/index layer. | + +## Practical migration summary + +| Legacy shape | New result | +| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| Valid `proxyWidgets` entry already represented by a linked `SubgraphInput` | Entry is consumed; the existing linked input remains canonical. | +| Valid value-widget `proxyWidgets` entry without a linked input | Repair creates or reconnects standard subgraph input/link state. | +| Valid primitive fanout entry | Repair creates one `SubgraphInput`, reconnects all validated targets, and leaves the primitive node inert. | +| Valid value-widget entry that cannot be repaired | Entry is persisted in `properties.proxyWidgetErrorQuarantine` with the host value when available. | +| Preview-shaped legacy entry | Entry is migrated into `properties.previewExposures`, not a linked input. | +| Unresolved preview exposure | Entry remains inert in `previewExposures`; it is not quarantined because it owns no user value. | +| Invalid raw `proxyWidgets` data | Logs `console.error`, does not throw, and is not quarantined. | diff --git a/docs/architecture/ecs-migration-plan.md b/docs/architecture/ecs-migration-plan.md index a3ba8fbe53..e6a1d1d61a 100644 --- a/docs/architecture/ecs-migration-plan.md +++ b/docs/architecture/ecs-migration-plan.md @@ -231,6 +231,11 @@ assigning synthetic widget IDs (via `lastWidgetId` counter on LGraphState). the ID mapping — widgets currently lack independent IDs, so the bridge must maintain a `(nodeId, widgetName) -> WidgetEntityId` lookup. +**Promoted-widget caveat:** ADR 0009 assigns promoted value widgets a +host-boundary identity (`host node locator + SubgraphInput.name`). Interior +source node/widget identity is preserved only as migration and diagnostic +metadata. + ### 2c. Read-only bridge for Node metadata Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by @@ -663,6 +668,10 @@ The 6 proto-ECS stores use 6 different keying strategies: | NodeOutputStore | `"${subgraphId}:${nodeId}"` | | SubgraphNavigationStore | subgraphId or `'root'` | +ADR 0009 refines the promoted-widget target: promoted value widgets should use +host boundary identity (`host node locator + SubgraphInput.name`), not interior +source node/widget identity. + The World unifies these under branded entity IDs. But stores that use composite keys (e.g., `nodeId:widgetName`) reflect a genuine structural reality — a widget is identified by its relationship to a node. Synthetic diff --git a/docs/architecture/proto-ecs-stores.md b/docs/architecture/proto-ecs-stores.md index 0831f0535d..f5f09ccb50 100644 --- a/docs/architecture/proto-ecs-stores.md +++ b/docs/architecture/proto-ecs-stores.md @@ -17,6 +17,10 @@ Six stores extract entity state out of class instances into centralized, queryab | NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs | | SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache | +ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by +the host boundary (`host node locator + SubgraphInput.name`), while interior +source node/widget identity is migration and diagnostic metadata only. + ## 2. WidgetValueStore **File:** `src/stores/widgetValueStore.ts` @@ -254,6 +258,9 @@ Each store invents its own identity scheme: | NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No | In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection. +For promoted value widgets, ADR 0009 narrows the target key to host boundary +identity (`host node locator + SubgraphInput.name`) instead of interior source +identity. ## 6. Extraction Map diff --git a/docs/architecture/subgraph-boundaries-and-promotion.md b/docs/architecture/subgraph-boundaries-and-promotion.md index 51d7b8b941..0426a8afe1 100644 --- a/docs/architecture/subgraph-boundaries-and-promotion.md +++ b/docs/architecture/subgraph-boundaries-and-promotion.md @@ -404,26 +404,21 @@ Whichever candidate is chosen: instance-specific state beyond inputs — must remain reachable. This is a constraint, not a current requirement. -### Recommendation and decision criteria +### Decision -**Lean toward A.** It eliminates an entire subsystem by recognizing a structural -truth: promotion is adding a typed input to a function signature. The type -system already handles widget creation for typed inputs. Building a parallel -mechanism for "promoted widgets" is building a second, narrower version of -something the system already does. +[ADR 0009](../adr/0009-subgraph-promoted-widgets-use-linked-inputs.md) +chooses Candidate A for promoted value widgets. It eliminates an entire +subsystem by recognizing a structural truth: promotion is adding a typed input +to a function signature. The type system already handles widget creation for +typed inputs. Building a parallel mechanism for "promoted widgets" is building +a second, narrower version of something the system already does. The cost of A is a migration path for existing `proxyWidgets` serialization. On -load, the `SerializationSystem` converts `proxyWidgets` entries into interface -inputs and boundary links. This is a one-time ratchet conversion — once -loaded and re-saved, the workflow uses the new format. - -**Choose B if** the team determines that promoted widgets must remain -visually or behaviorally distinct from normal input widgets in ways the type → -widget mapping cannot express, or if the `proxyWidgets` migration burden exceeds -the current release cycle's capacity. - -**Decision needed before** Phase 3 of the ECS migration, when systems are -introduced and the widget/connectivity architecture solidifies. +load, the `SerializationSystem` converts value-widget `proxyWidgets` entries +into interface inputs and boundary links. Once loaded and re-saved, the workflow +uses the new format. ADR 0009 separates display-only preview exposures from +promoted value widgets; those previews use their own host-scoped serialized +representation instead of linked `SubgraphInput` widgets. --- @@ -471,14 +466,14 @@ and produces the recursive `ExportedSubgraph` structure, matching the current format exactly. Existing workflows, the ComfyUI backend, and third-party tools see no change. -| Direction | Format | Notes | -| --------------- | ------------------------------- | ---------------------------------------- | -| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree | -| **Load/import** | Nested (current) or future flat | Ratchet: normalize to flat World on load | +| Direction | Format | Notes | +| --------------- | ------------------------------- | ------------------------------------------ | +| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree | +| **Load/import** | Nested (current) or future flat | Migration: normalize to flat World on load | -The "ratchet conversion" pattern: load any supported format, normalize to the -internal model. The system accepts old formats indefinitely but produces the -current format on save. +The migration pattern: load any supported format and normalize to the internal +model. The system accepts old formats indefinitely but produces the current +format on save. ### Widget identity at the boundary @@ -511,13 +506,12 @@ SubgraphIO { } ``` -If Candidate A (connections-only promotion) is chosen: promoted widgets become -interface inputs, serialized as additional `SubgraphIO` entries. On load, legacy -`proxyWidgets` data is converted to interface inputs and boundary links (ratchet -migration). On save, `proxyWidgets` is no longer written. - -If Candidate B (simplified promotion) is chosen: `proxyWidgets` continues to be -serialized in its current format. +ADR 0009 chooses Candidate A (connections-only promotion) for promoted value +widgets: they become interface inputs, serialized as additional `SubgraphIO` +entries. On load, legacy value-widget `proxyWidgets` data is converted to +interface inputs and boundary links. On save, repaired `proxyWidgets` entries +are no longer written. Display-only preview exposures use separate +host-scoped `previewExposures` serialization. ### Backward-compatible loading contract @@ -555,7 +549,7 @@ This document proposes or surfaces the following changes to | World structure | Implied per-graph containment | Flat World with `graphScope` tags; one World per workflow | | Acyclicity | Not addressed | DAG invariant on `SubgraphStructure.graphId` references, enforced on mutation | | Boundary model | Deferred | Typed interface contracts on `SubgraphStructure`; no virtual nodes or magic IDs | -| Widget promotion | Treated as a given feature to migrate | Open decision: Candidate A (connections-only) vs B (simplified component) | +| Widget promotion | Treated as a given feature to migrate | ADR 0009 chooses Candidate A: promoted value widgets are linked inputs | | Serialization | Not explicitly separated from internal model | Internal model ≠ wire format; `SerializationSystem` is the membrane | | Backward compat | Implicit | Explicit contract: load any prior format, indefinitely | diff --git a/docs/testing/unit-testing.md b/docs/testing/unit-testing.md index a47012ffa2..181627fd65 100644 --- a/docs/testing/unit-testing.md +++ b/docs/testing/unit-testing.md @@ -147,7 +147,7 @@ it('should subscribe to logs API', () => { }) ``` -## Mocking Lodash Functions +## Mocking Utility Functions Mocking utility functions like debounce: diff --git a/eslint.config.ts b/eslint.config.ts index 108a68cff5..b5f36864b7 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -36,6 +36,7 @@ const settings = { alwaysTryTypes: true, project: [ './tsconfig.json', + './browser_tests/tsconfig.json', './apps/*/tsconfig.json', './packages/*/tsconfig.json' ], @@ -230,6 +231,37 @@ export default defineConfig([ ] } }, + { + name: 'comfy/no-unsafe-error-assertion', + files: [ + 'src/**/*.ts', + 'src/**/*.tsx', + 'src/**/*.vue', + 'apps/*/src/**/*.ts', + 'apps/*/src/**/*.tsx', + 'apps/*/src/**/*.vue' + ], + ignores: ['**/*.test.ts', '**/*.spec.ts'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + // Bans `value as Error` and `value as Error & { ... }`. + // Use `error instanceof Error` narrowing or `toError()` from + // @/utils/errorUtil instead — see issue #11429. + selector: "TSAsExpression TSTypeReference[typeName.name='Error']", + message: + 'Do not use Error type assertions. Use `instanceof Error` narrowing or `toError()` from @/utils/errorUtil instead. See issue #11429.' + }, + { + // Bans `value` and `value`. + selector: "TSTypeAssertion TSTypeReference[typeName.name='Error']", + message: + 'Do not use Error type assertions. Use `instanceof Error` narrowing or `toError()` from @/utils/errorUtil instead. See issue #11429.' + } + ] + } + }, { files: ['**/*.spec.ts'], ignores: ['browser_tests/tests/**/*.spec.ts', 'apps/*/e2e/**/*.spec.ts'], diff --git a/index.html b/index.html index 5d72443e53..f737e6c766 100644 --- a/index.html +++ b/index.html @@ -26,6 +26,10 @@ width: 100%; height: 100%; margin: 0; + /* Disable trackpad two-finger horizontal swipe back/forward navigation + and other overscroll gestures. ComfyUI is a full-screen editor; the + browser's overscroll behaviors only ever leave or break the workflow. */ + overscroll-behavior: none; } body { display: grid; diff --git a/knip.config.ts b/knip.config.ts index 5eb302a809..b204587958 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -54,6 +54,9 @@ const config: KnipConfig = { '.github/workflows/ci-oss-assets-validation.yaml', // Pending integration in stacked PR 'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue', + // Marketing media tooling — adopted by pages in a follow-up PR + 'apps/website/src/components/common/SiteVideo.vue', + 'apps/website/src/utils/marketingImage.ts', // Agent review check config, not part of the build '.agents/checks/eslint.strict.config.js', // Devtools extensions, included dynamically diff --git a/lint-staged.config.ts b/lint-staged.config.ts index 97a95ddd2f..d5f69648f1 100644 --- a/lint-staged.config.ts +++ b/lint-staged.config.ts @@ -34,7 +34,7 @@ function formatAndEslint(fileNames: string[]) { const joinedPaths = toJoinedRelativePaths(fileNames) return [ `pnpm exec oxfmt --write ${joinedPaths}`, - `pnpm exec oxlint --fix ${joinedPaths}`, + `pnpm exec oxlint --type-aware --fix ${joinedPaths}`, `pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}` ] } diff --git a/package.json b/package.json index 5d0acfc510..6a52d1d793 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@comfyorg/comfyui-frontend", - "version": "1.44.15", + "version": "1.45.12", "private": true, "description": "Official front-end implementation of ComfyUI", "homepage": "https://comfy.org", @@ -11,7 +11,7 @@ "build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build", "build:desktop": "nx build @comfyorg/desktop-ui", "build-storybook": "storybook build", - "build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js", + "build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' nx build --config vite.types.config.mts && node scripts/prepare-types.js", "build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build", "build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build", "size:collect": "node scripts/size-collect.js", @@ -37,7 +37,7 @@ "lint:desktop": "nx run @comfyorg/desktop-ui:lint", "locale": "lobe-i18n locale", "oxlint": "oxlint src browser_tests --type-aware", - "prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true", + "prepare": "pnpm exec husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true", "preview": "nx preview", "storybook": "nx storybook", "storybook:desktop": "nx run @comfyorg/desktop-ui:storybook", @@ -60,6 +60,8 @@ "@atlaskit/pragmatic-drag-and-drop": "^1.3.1", "@comfyorg/comfyui-electron-types": "catalog:", "@comfyorg/design-system": "workspace:*", + "@comfyorg/fbx-exporter-three": "^1.0.1", + "@comfyorg/object-info-parser": "workspace:*", "@comfyorg/registry-types": "workspace:*", "@comfyorg/shared-frontend-utils": "workspace:*", "@comfyorg/tailwind-utils": "workspace:*", @@ -83,6 +85,7 @@ "@tiptap/extension-table-row": "catalog:", "@tiptap/pm": "catalog:", "@tiptap/starter-kit": "catalog:", + "@vee-validate/zod": "catalog:", "@vueuse/core": "catalog:", "@vueuse/integrations": "catalog:", "@vueuse/router": "^14.2.0", @@ -110,9 +113,10 @@ "primevue": "catalog:", "reka-ui": "catalog:", "semver": "^7.7.2", - "three": "^0.170.0", + "three": "catalog:", "tiptap-markdown": "^0.8.10", "typegpu": "catalog:", + "vee-validate": "catalog:", "vue": "catalog:", "vue-i18n": "catalog:", "vue-router": "catalog:", @@ -207,20 +211,7 @@ }, "engines": { "node": "24.x", - "pnpm": ">=10" + "pnpm": ">=11" }, - "packageManager": "pnpm@10.33.0", - "pnpm": { - "overrides": { - "vite": "catalog:" - }, - "ignoredBuiltDependencies": [ - "@firebase/util", - "core-js", - "protobufjs", - "sharp", - "unrs-resolver", - "vue-demi" - ] - } + "packageManager": "pnpm@11.1.1" } diff --git a/packages/design-system/package.json b/packages/design-system/package.json index ab40b3a5fc..89239759f2 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -20,7 +20,6 @@ "tailwindcss": "catalog:", "typescript": "catalog:" }, - "packageManager": "pnpm@10.17.1", "nx": { "tags": [ "scope:shared", diff --git a/packages/design-system/src/css/style.css b/packages/design-system/src/css/style.css index 5106ed90e9..767e8c25bc 100644 --- a/packages/design-system/src/css/style.css +++ b/packages/design-system/src/css/style.css @@ -16,7 +16,7 @@ @plugin "./lucideStrokePlugin.js"; /* Safelist dynamic comfy icons for node library folders */ -@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]"); +@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]"); /* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */ @source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]"); diff --git a/packages/design-system/src/icons/anthropic.svg b/packages/design-system/src/icons/anthropic.svg new file mode 100644 index 0000000000..f9f90c67c9 --- /dev/null +++ b/packages/design-system/src/icons/anthropic.svg @@ -0,0 +1 @@ +Anthropic diff --git a/packages/ingest-types/package.json b/packages/ingest-types/package.json index 6125c4bbbb..a92cb8cd90 100644 --- a/packages/ingest-types/package.json +++ b/packages/ingest-types/package.json @@ -16,7 +16,6 @@ "devDependencies": { "@hey-api/openapi-ts": "0.93.0" }, - "packageManager": "pnpm@10.17.1", "nx": { "tags": [ "scope:shared", diff --git a/packages/ingest-types/src/index.ts b/packages/ingest-types/src/index.ts index 94fb077aa3..03a4f5ca9c 100644 --- a/packages/ingest-types/src/index.ts +++ b/packages/ingest-types/src/index.ts @@ -29,6 +29,17 @@ export type { BillingStatus, BillingStatusResponse, BindingErrorResponse, + BulkRevokeApiKeysResponse, + BulkRevokeWorkspaceMemberApiKeysData, + BulkRevokeWorkspaceMemberApiKeysError, + BulkRevokeWorkspaceMemberApiKeysErrors, + BulkRevokeWorkspaceMemberApiKeysResponse, + BulkRevokeWorkspaceMemberApiKeysResponses, + CancelJobData, + CancelJobError, + CancelJobErrors, + CancelJobResponse, + CancelJobResponses, CancelSubscriptionData, CancelSubscriptionError, CancelSubscriptionErrors, @@ -307,6 +318,28 @@ export type { GetJwksData, GetJwksResponse, GetJwksResponses, + GetLegacyAssetContentData, + GetLegacyAssetContentErrors, + GetLegacyHistoryByIdData, + GetLegacyHistoryByIdErrors, + GetLegacyHistoryData, + GetLegacyHistoryErrors, + GetLegacyJobByIdData, + GetLegacyJobByIdErrors, + GetLegacyJobOutputsData, + GetLegacyJobOutputsErrors, + GetLegacyModelsByFolderData, + GetLegacyModelsByFolderErrors, + GetLegacyModelsData, + GetLegacyModelsErrors, + GetLegacyObjectInfoByNodeClassData, + GetLegacyObjectInfoByNodeClassErrors, + GetLegacyPromptByIdData, + GetLegacyPromptByIdErrors, + GetLegacyUserdataV2Data, + GetLegacyUserdataV2Errors, + GetLegacyViewMetadataData, + GetLegacyViewMetadataErrors, GetLogsData, GetLogsError, GetLogsErrors, @@ -505,6 +538,7 @@ export type { InterruptJobError, InterruptJobErrors, InterruptJobResponses, + JobCancelResponse, JobDetailResponse, JobEntry, JobsListResponse, @@ -719,6 +753,13 @@ export type { SubscribeResponses, SubscriptionDuration, SubscriptionTier, + SyncApiKeyData, + SyncApiKeyError, + SyncApiKeyErrors, + SyncApiKeyRequest, + SyncApiKeyResponse, + SyncApiKeyResponse2, + SyncApiKeyResponses, SystemStatsResponse, TagInfo, TagsModificationResponse, diff --git a/packages/ingest-types/src/types.gen.ts b/packages/ingest-types/src/types.gen.ts index 5af8c4b07d..c0cf784658 100644 --- a/packages/ingest-types/src/types.gen.ts +++ b/packages/ingest-types/src/types.gen.ts @@ -4,6 +4,9 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } +/** + * Response indicating whether a Hub username is available. + */ export type HubUsernameCheckResponse = { /** * The username that was checked. @@ -23,6 +26,9 @@ export type HubUsernameCheckResponse = { validation_error?: string } +/** + * Response containing a signed upload URL and the target asset path. + */ export type HubAssetUploadUrlResponse = { /** * Presigned R2 URL for uploading the file via PUT. @@ -38,6 +44,9 @@ export type HubAssetUploadUrlResponse = { token: string } +/** + * Request body for requesting a signed upload URL for a Hub asset. + */ export type HubAssetUploadUrlRequest = { /** * Original filename for display purposes. Not used in the storage key. @@ -116,6 +125,9 @@ export type UpdateHubWorkflowRequest = { } | null } +/** + * Request body for publishing or updating a workflow on the Hub. + */ export type PublishHubWorkflowRequest = { /** * Username of the hub profile to publish under. The authenticated user must belong to the workspace that owns this profile. @@ -177,6 +189,9 @@ export type PublishHubWorkflowRequest = { sample_image_tokens_or_urls?: Array } +/** + * Paginated list of Hub workflows matching search criteria. + */ export type HubWorkflowListResponse = { /** * Array of HubWorkflowSummary (default) or HubWorkflowDetail (when detail=true). @@ -188,6 +203,9 @@ export type HubWorkflowListResponse = { next_cursor?: string } +/** + * Lightweight asset reference used in workflow publishing payloads. + */ export type AssetInfo = { /** * Asset identifier. @@ -213,6 +231,9 @@ export type AssetInfo = { in_library: boolean } +/** + * Full Hub workflow detail including versions, assets, and statistics. + */ export type HubWorkflowDetail = { share_id: string workflow_id: string @@ -241,6 +262,9 @@ export type HubWorkflowDetail = { profile: HubProfileSummary } +/** + * Abbreviated Hub profile used in workflow listings. + */ export type HubProfileSummary = { username: string display_name?: string @@ -250,6 +274,9 @@ export type HubProfileSummary = { avatar_url?: string } +/** + * Reference to a Hub label by ID. + */ export type LabelRef = { /** * Slug identifier (e.g. "video-generation", "flux"). @@ -270,6 +297,9 @@ export type HubWorkflowStatus = | 'rejected' | 'deprecated' +/** + * Abbreviated Hub workflow metadata used in search and listing results. + */ export type HubWorkflowSummary = { share_id: string name: string @@ -290,6 +320,9 @@ export type HubWorkflowSummary = { sample_image_urls?: Array } +/** + * Metadata for a single Hub label. + */ export type HubLabelInfo = { /** * Slug identifier. @@ -309,6 +342,9 @@ export type HubLabelInfo = { type: 'tag' | 'model' | 'custom_node' } +/** + * List of available Hub labels for categorizing workflows. + */ export type HubLabelListResponse = { /** * Available labels filtered by type (or all if no type specified). @@ -316,6 +352,9 @@ export type HubLabelListResponse = { labels: Array } +/** + * Entry in the curated workflow template gallery shown on the home page. + */ export type HubWorkflowTemplateEntry = { /** * Slug identifier for the template @@ -410,6 +449,9 @@ export type HubWorkflowTemplateEntry = { contentTemplate?: string } +/** + * Request body for updating an existing Hub profile. + */ export type UpdateHubProfileRequest = { display_name?: string description?: string @@ -424,6 +466,9 @@ export type UpdateHubProfileRequest = { website_urls?: Array } +/** + * Request body for creating a new Hub profile. + */ export type CreateHubProfileRequest = { /** * ID of the workspace to create the hub profile for. The authenticated user must belong to this workspace. @@ -446,6 +491,9 @@ export type CreateHubProfileRequest = { website_urls?: Array } +/** + * Full public profile for a Hub creator. + */ export type HubProfile = { username: string display_name?: string @@ -460,17 +508,39 @@ export type HubProfile = { website_urls?: Array } +/** + * Response after importing published workflow assets. + */ export type ImportPublishedAssetsResponse = { assets: Array } +/** + * Request body for importing assets from a published workflow. + */ export type ImportPublishedAssetsRequest = { /** * IDs of published assets (inputs and models) to import. */ published_asset_ids: Array + /** + * Optional. Share ID of the published workflow these assets belong to. + * When provided (non-null, non-empty): all published_asset_ids must + * belong to this share's workflow version; returns + * 400/CodeInvalidAssets if the share is not found or any asset does + * not belong to it. + * When omitted, null, or empty string: no share-scoped validation is + * performed and the assets are validated only against global rules + * (legacy behaviour, preserved for clients that have not yet adopted + * share_id). + * + */ + share_id?: string | null } +/** + * Full detail of a publicly published workflow on the Hub. + */ export type PublishedWorkflowDetail = { share_id: string workflow_id: string @@ -492,16 +562,25 @@ export type PublishedWorkflowDetail = { assets: Array } +/** + * Response containing assets associated with a workflow. + */ export type WorkflowApiAssetsResponse = { assets: Array } +/** + * Request body for querying assets associated with a workflow. + */ export type WorkflowApiAssetsRequest = { workflow_api_json: { [key: string]: unknown } } +/** + * Request body for publishing workflow assets to the Hub. + */ export type PublishWorkflowAssetsRequest = { /** * IDs of assets (inputs and models) to snapshot. @@ -509,6 +588,9 @@ export type PublishWorkflowAssetsRequest = { asset_ids: Array } +/** + * Publishing metadata for a workflow shared to the Hub. + */ export type WorkflowPublishInfo = { workflow_id: string share_id: string @@ -520,6 +602,9 @@ export type WorkflowPublishInfo = { assets: Array } +/** + * Request body for forking an existing workflow into the user's account. + */ export type ForkWorkflowRequest = { /** * Version number to fork from @@ -531,6 +616,9 @@ export type ForkWorkflowRequest = { name?: string } +/** + * Full workflow version including the serialized workflow JSON. + */ export type WorkflowVersionContentResponse = { id: string version: number @@ -542,6 +630,9 @@ export type WorkflowVersionContentResponse = { dependency_asset_ids?: Array } +/** + * Metadata for a single workflow version. + */ export type WorkflowVersionResponse = { id: string version: number @@ -550,6 +641,9 @@ export type WorkflowVersionResponse = { created_at: string } +/** + * Request body for creating a new version of a saved workflow. + */ export type CreateWorkflowVersionRequest = { /** * The version number this change is based on (for optimistic concurrency) @@ -563,11 +657,17 @@ export type CreateWorkflowVersionRequest = { } } +/** + * Paginated list of saved workflows. + */ export type WorkflowListResponse = { data: Array pagination: PaginationInfo } +/** + * Offset/limit-based pagination metadata included in list responses. + */ export type PaginationInfo = { /** * Current offset (0-based) @@ -587,11 +687,17 @@ export type PaginationInfo = { has_more: boolean } +/** + * Reference to the parent workflow from which this workflow was forked. + */ export type WorkflowForkedFrom = { workflow_id?: string workflow_version_id?: string } +/** + * Full workflow entity including metadata and version history. + */ export type WorkflowResponse = { id: string name?: string @@ -604,6 +710,9 @@ export type WorkflowResponse = { updated_at: string } +/** + * Request body for updating an existing saved workflow. + */ export type UpdateWorkflowRequest = { /** * New display name @@ -619,6 +728,9 @@ export type UpdateWorkflowRequest = { default_view?: 'workflow' | 'app' } +/** + * Request body for creating a new saved workflow. + */ export type CreateWorkflowRequest = { /** * Display name for the workflow @@ -648,6 +760,9 @@ export type CreateWorkflowRequest = { forked_from_workflow_version_id?: string } +/** + * Response after recording partner usage data. + */ export type PartnerUsageResponse = { /** * Result status (e.g., "ok") @@ -655,6 +770,9 @@ export type PartnerUsageResponse = { status: string } +/** + * Request body for reporting partner resource usage (admin endpoint). + */ export type PartnerUsageRequest = { /** * The workspace ID to bill usage against @@ -684,6 +802,9 @@ export type PartnerUsageRequest = { } } +/** + * Status of an asynchronous billing operation. + */ export type BillingOpStatusResponse = { /** * Unique identifier for the billing operation @@ -707,6 +828,9 @@ export type BillingOpStatusResponse = { completed_at?: string } +/** + * Response after successfully purchasing a credit top-up. + */ export type CreateTopupResponse = { /** * Billing operation ID to poll for status via GET /api/billing/ops/{id} @@ -726,6 +850,9 @@ export type CreateTopupResponse = { amount_cents: number } +/** + * Request body for purchasing a one-time credit top-up. + */ export type CreateTopupRequest = { /** * Amount to charge and grant as credits (in cents). Minimum $5.00. @@ -739,6 +866,9 @@ export type CreateTopupRequest = { idempotency_key?: string } +/** + * Response containing a redirect URL to the payment portal. + */ export type PaymentPortalResponse = { /** * Stripe Billing Portal URL @@ -746,6 +876,9 @@ export type PaymentPortalResponse = { url: string } +/** + * Request body for generating a payment portal session URL. + */ export type PaymentPortalRequest = { /** * URL to redirect after the user exits the portal @@ -753,6 +886,9 @@ export type PaymentPortalRequest = { return_url?: string } +/** + * Response after successfully resubscribing to a billing plan. + */ export type ResubscribeResponse = { /** * Billing operation ID to poll for status via GET /api/billing/ops/{id} @@ -768,6 +904,9 @@ export type ResubscribeResponse = { message?: string } +/** + * Request body for reactivating a previously cancelled subscription. + */ export type ResubscribeRequest = { /** * Client-provided key to prevent duplicate operations. @@ -777,6 +916,9 @@ export type ResubscribeRequest = { idempotency_key?: string } +/** + * Response after successfully cancelling a subscription. + */ export type CancelSubscriptionResponse = { /** * Billing operation ID to poll for status via GET /api/billing/ops/{id} @@ -788,6 +930,9 @@ export type CancelSubscriptionResponse = { cancel_at: string } +/** + * Request body for cancelling the current subscription. + */ export type CancelSubscriptionRequest = { /** * Client-provided key to prevent duplicate operations. @@ -797,6 +942,9 @@ export type CancelSubscriptionRequest = { idempotency_key?: string } +/** + * Response after successfully subscribing to a billing plan. + */ export type SubscribeResponse = { /** * Billing operation ID to poll for status via GET /api/billing/ops/{id} @@ -820,6 +968,9 @@ export type SubscribeResponse = { payment_method_url?: string } +/** + * Request body for subscribing a workspace to a billing plan. + */ export type SubscribeRequest = { /** * Target plan slug to subscribe to @@ -907,6 +1058,9 @@ export type SubscriptionTier = | 'PRO' | 'FOUNDERS_EDITION' +/** + * Itemized cost preview for a pending subscription change. + */ export type PreviewSubscribeResponse = { /** * Whether this subscription change is allowed @@ -952,6 +1106,9 @@ export type PreviewSubscribeResponse = { new_plan: PreviewPlanInfo } +/** + * Request body for previewing the cost of a plan subscription change. + */ export type PreviewSubscribeRequest = { /** * Target plan slug to preview subscribing to @@ -959,6 +1116,9 @@ export type PreviewSubscribeRequest = { plan_slug: string } +/** + * List of available billing plans for subscription. + */ export type BillingPlansResponse = { /** * Current plan slug if subscribed @@ -977,6 +1137,9 @@ export type PlanAvailabilityReason = | 'requires_personal' | 'exceeds_max_seats' +/** + * Availability and eligibility information for a billing plan. + */ export type PlanAvailability = { /** * Whether the workspace can subscribe to this plan @@ -985,6 +1148,9 @@ export type PlanAvailability = { reason?: PlanAvailabilityReason } +/** + * Billing plan details including pricing, limits, and features. + */ export type Plan = { /** * Plan identifier (e.g., "pro-monthly", "team-standard-annual") @@ -1008,10 +1174,16 @@ export type Plan = { seat_summary: PlanSeatSummary } +/** + * List of user secrets with metadata only. + */ export type SecretListResponse = { data: Array } +/** + * User secret metadata (the secret value itself is never returned after creation). + */ export type SecretResponse = { /** * Unique identifier for the secret @@ -1039,6 +1211,9 @@ export type SecretResponse = { updated_at: string } +/** + * Request body for updating an existing user secret. + */ export type UpdateSecretRequest = { /** * New name for the secret @@ -1050,6 +1225,9 @@ export type UpdateSecretRequest = { secret_value?: string } +/** + * Request body for creating a new user secret. + */ export type CreateSecretRequest = { /** * User-provided label for the secret @@ -1065,6 +1243,9 @@ export type CreateSecretRequest = { secret_value: string } +/** + * Paginated list of billing events for a workspace. + */ export type BillingEventsResponse = { /** * Total number of events @@ -1085,6 +1266,9 @@ export type BillingEventsResponse = { totalPages: number } +/** + * A single billing event such as a charge, credit, or adjustment. + */ export type BillingEvent = { /** * Type of billing event (e.g., subscription.created, payment.succeeded) @@ -1106,6 +1290,9 @@ export type BillingEvent = { createdAt: string } +/** + * Current credit balance and usage details for a workspace. + */ export type BillingBalanceResponse = { /** * The total remaining balance in microamount (1/1,000,000 of the currency unit) @@ -1133,6 +1320,9 @@ export type BillingBalanceResponse = { currency: string } +/** + * Current billing and subscription status for a workspace. + */ export type BillingStatusResponse = { /** * Whether the workspace has an active subscription @@ -1173,6 +1363,9 @@ export type BillingStatus = | 'payment_failed' | 'inactive' +/** + * A single JSON Web Key entry within a JWKS response. + */ export type JwkKey = { kty: string crv: string @@ -1189,10 +1382,59 @@ export type JwkKey = { y: string } +/** + * JSON Web Key Set containing the public keys used to verify Cloud JWTs. + */ export type JwksResponse = { keys: Array } +/** + * Response after synchronizing an API key into the local database. + */ +export type SyncApiKeyResponse = { + /** + * `revoked` — matching row found, was active, now revoked. + * `already_revoked` — matching row found, already revoked. + * `no_op` — no row matches the supplied hash. + * + */ + result: 'revoked' | 'already_revoked' | 'no_op' +} + +/** + * Request body for synchronizing an API key from the external registry. + */ +export type SyncApiKeyRequest = { + /** + * Lifecycle event type. Only `delete` is supported in Phase 1. + */ + event: 'delete' + /** + * SHA-256 hex digest of the plaintext API key (64 hex characters). + * Case-insensitive: the server lowercases the value before lookup, so + * producers may emit lowercase or uppercase hex. The lowercase form + * is recommended for consistency with the rest of the codebase, which + * computes hashes via `hex.EncodeToString`. + * + */ + key_hash: string + /** + * Firebase UID of the key's owner according to comfy-api. Required on + * the request so cloud can detect drift between the two systems, but + * **advisory only**: `key_hash` is the sole authoritative identifier + * for the revocation. A mismatch against cloud's stored `user_id` is + * logged and emits `admin.api_key_sync.delete.customer_mismatch`, but + * does not change the outcome — the matching row is still revoked so + * a subsequent sync call can repair drift. + * + */ + customer_id: string +} + +/** + * Response confirming the validity and scope of a workspace API key. + */ export type VerifyApiKeyResponse = { /** * Firebase UID of the key creator @@ -1242,6 +1484,9 @@ export type VerifyApiKeyResponse = { permissions: Array } +/** + * Request body for verifying a workspace API key (admin endpoint). + */ export type VerifyApiKeyRequest = { /** * The full plaintext API key to verify @@ -1249,10 +1494,26 @@ export type VerifyApiKeyRequest = { api_key: string } +/** + * Response after bulk-revoking API keys for a workspace member. + */ +export type BulkRevokeApiKeysResponse = { + /** + * Number of API keys that were revoked + */ + revoked_count: number +} + +/** + * List of API keys associated with the current workspace. + */ export type ListWorkspaceApiKeysResponse = { api_keys: Array } +/** + * Metadata for a workspace-scoped API key (secret is never returned). + */ export type WorkspaceApiKeyInfo = { /** * API key ID @@ -1292,6 +1553,9 @@ export type WorkspaceApiKeyInfo = { created_at: string } +/** + * Response containing the newly created workspace API key. + */ export type CreateWorkspaceApiKeyResponse = { /** * API key ID @@ -1319,6 +1583,9 @@ export type CreateWorkspaceApiKeyResponse = { created_at: string } +/** + * Request body for creating a new workspace-scoped API key. + */ export type CreateWorkspaceApiKeyRequest = { /** * User-provided label for the key @@ -1330,6 +1597,9 @@ export type CreateWorkspaceApiKeyRequest = { expires_at?: string } +/** + * Response returned after successfully accepting a workspace invitation. + */ export type AcceptInviteResponse = { /** * ID of the workspace joined @@ -1341,6 +1611,9 @@ export type AcceptInviteResponse = { workspace_name: string } +/** + * Request body for inviting a user to a workspace. + */ export type CreateInviteRequest = { /** * Email address to invite @@ -1348,10 +1621,16 @@ export type CreateInviteRequest = { email: string } +/** + * List of pending invitations for the current workspace. + */ export type ListInvitesResponse = { invites: Array } +/** + * An outstanding workspace invitation that has not yet been accepted. + */ export type PendingInvite = { /** * Invite ID @@ -1375,11 +1654,17 @@ export type PendingInvite = { expires_at: string } +/** + * List of members in the current workspace. + */ export type ListMembersResponse = { members: Array pagination: PaginationInfo } +/** + * Workspace member with profile and role information. + */ export type Member = { /** * User ID @@ -1403,6 +1688,9 @@ export type Member = { joined_at: string } +/** + * Request body for updating an existing workspace's settings. + */ export type UpdateWorkspaceRequest = { /** * New display name for the workspace @@ -1410,6 +1698,9 @@ export type UpdateWorkspaceRequest = { name?: string } +/** + * Request body for creating a new workspace. + */ export type CreateWorkspaceRequest = { /** * Display name for the workspace @@ -1417,6 +1708,9 @@ export type CreateWorkspaceRequest = { name: string } +/** + * Workspace entity annotated with the requesting user's role. + */ export type WorkspaceWithRole = { id: string name: string @@ -1433,10 +1727,16 @@ export type WorkspaceWithRole = { subscription_tier?: SubscriptionTier } +/** + * Paginated list of workspaces the authenticated user belongs to. + */ export type ListWorkspacesResponse = { workspaces: Array } +/** + * Full workspace entity with configuration and ownership details. + */ export type Workspace = { id: string name: string @@ -1444,12 +1744,18 @@ export type Workspace = { created_at: string } +/** + * Abbreviated workspace metadata used in list responses. + */ export type WorkspaceSummary = { id: string name: string type: 'personal' | 'team' } +/** + * Response containing the issued Cloud JWT and its expiry. + */ export type ExchangeTokenResponse = { /** * Cloud JWT token @@ -1470,6 +1776,12 @@ export type ExchangeTokenResponse = { permissions: Array } +/** + * Optional request body for the token exchange endpoint. The Firebase JWT + * being exchanged is supplied via the `Authorization: Bearer` header; this + * body only carries workspace-selection input. + * + */ export type ExchangeTokenRequest = { /** * Workspace ID to get token for. Defaults to personal workspace if omitted. @@ -1561,6 +1873,9 @@ export type TaskEntry = { completed_at?: string } +/** + * Paginated list of background tasks for the authenticated user. + */ export type TasksListResponse = { /** * Array of tasks ordered by create_time @@ -1569,6 +1884,9 @@ export type TasksListResponse = { pagination: PaginationInfo } +/** + * Details of a pending or completed user data deletion request. + */ export type DeletionRequest = { /** * Unique identifier for the deletion request @@ -1588,6 +1906,9 @@ export type DeletionRequest = { deletion_status: Array } +/** + * Current status of a user data deletion request. + */ export type DeletionStatus = { /** * The name of the deletion status @@ -1650,7 +1971,13 @@ export type JobDetailResponse = { */ status: 'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled' /** - * Full ComfyUI workflow (10-100KB, omitted if not available) + * Full ComfyUI workflow (10-100KB, omitted if not available). + * + * Sensitive credentials are redacted before the response is returned: + * `extra_data.api_key_comfy_org`, when present, is replaced with the + * literal string `"[REDACTED]"`. The field is preserved (not removed) + * so existence checks still pass, but the value is not usable. + * */ workflow?: { [key: string]: unknown @@ -1701,6 +2028,19 @@ export type JobDetailResponse = { } } +/** + * Response for POST /api/jobs/{job_id}/cancel. Returned on both fresh cancels and idempotent no-ops. + */ +export type JobCancelResponse = { + /** + * True when a cancel event was successfully dispatched by this call. + * False when the job was already in a terminal or cancelling state, + * in which case the call is a no-op (still 200 — idempotent). + * + */ + cancelled: boolean +} + /** * Lightweight job data for list views (workflow and full outputs excluded) */ @@ -1745,6 +2085,9 @@ export type JobEntry = { execution_end_time?: number } +/** + * Paginated list of jobs for the authenticated user. + */ export type JobsListResponse = { /** * Array of jobs ordered by specified sort field @@ -1753,6 +2096,9 @@ export type JobsListResponse = { pagination: PaginationInfo } +/** + * Response after adding, updating, or removing tags on an asset. + */ export type TagsModificationResponse = { /** * Tags that were successfully added (for add operation) @@ -1776,6 +2122,9 @@ export type TagsModificationResponse = { total_tags: Array } +/** + * Details of a single validation error encountered during asset operations. + */ export type ValidationError = { /** * Machine-readable error code @@ -1791,6 +2140,9 @@ export type ValidationError = { field: string } +/** + * Result of validating a set of asset operations. + */ export type ValidationResult = { /** * Overall validation status (true if all checks passed) @@ -1806,6 +2158,9 @@ export type ValidationResult = { warnings?: Array } +/** + * Acknowledgement of an async asset download task; clients poll GET /api/tasks/{task_id} for status. + */ export type AssetDownloadResponse = { /** * Task ID for tracking download progress via GET /api/tasks/{task_id} @@ -1821,6 +2176,9 @@ export type AssetDownloadResponse = { message?: string } +/** + * Metadata for a remotely hosted asset resolved by URL. + */ export type AssetMetadataResponse = { /** * Size of the asset in bytes (-1 if unknown) @@ -1852,6 +2210,9 @@ export type AssetMetadataResponse = { validation?: ValidationResult } +/** + * Histogram of tag counts used for refining asset search results. + */ export type AssetTagHistogramResponse = { /** * Map of tag names to their occurrence counts on matching assets @@ -1861,6 +2222,9 @@ export type AssetTagHistogramResponse = { } } +/** + * Paginated list of available asset tags. + */ export type ListTagsResponse = { /** * List of tags @@ -1876,6 +2240,9 @@ export type ListTagsResponse = { has_more: boolean } +/** + * Metadata for a single tag that can be applied to assets. + */ export type TagInfo = { /** * Tag name @@ -1887,6 +2254,9 @@ export type TagInfo = { count: number } +/** + * Paginated list of assets belonging to the authenticated user. + */ export type ListAssetsResponse = { /** * List of assets matching the query @@ -1902,6 +2272,9 @@ export type ListAssetsResponse = { has_more: boolean } +/** + * Represents a user-owned asset (image, video, or other generated output). + */ export type Asset = { /** * Unique identifier for the asset @@ -1948,9 +2321,15 @@ export type Asset = { */ preview_id?: string | null /** - * ID of the job/prompt that created this asset, if available + * Deprecated: use job_id instead. ID of the prompt that created this asset, if available + * + * @deprecated */ prompt_id?: string | null + /** + * ID of the job that created this asset, if available + */ + job_id?: string | null /** * Timestamp when the asset was created */ @@ -1969,6 +2348,9 @@ export type Asset = { is_immutable?: boolean } +/** + * Response returned when an existing asset is successfully updated. + */ export type AssetUpdated = { /** * Asset ID @@ -2002,6 +2384,9 @@ export type AssetUpdated = { updated_at: string } +/** + * Response returned when a new asset is successfully created. + */ export type AssetCreated = Asset & { /** * Whether this was a new asset creation (true) or returned existing (false) @@ -2009,6 +2394,9 @@ export type AssetCreated = Asset & { created_new: boolean } +/** + * Response after updating the review status of a Hub workflow. + */ export type SetReviewStatusResponse = { /** * The share IDs that were submitted for review @@ -2020,6 +2408,9 @@ export type SetReviewStatusResponse = { status: 'approved' | 'rejected' } +/** + * Request body for setting the review status of a Hub workflow. + */ export type SetReviewStatusRequest = { /** * The share IDs of the hub workflows to review @@ -2233,6 +2624,9 @@ export type PromptErrorResponse = { [key: string]: unknown } +/** + * Individual file entry within a full user data response. + */ export type GetUserDataResponseFullFile = { /** * File name or path relative to the user directory. @@ -2248,8 +2642,14 @@ export type GetUserDataResponseFullFile = { modified?: number } +/** + * List of user data file entries (each with path, size, and modification time) returned when full_info=true. + */ export type GetUserDataResponseFull = Array +/** + * User data listing entry with file metadata (path, size, modification time). + */ export type UserDataResponseFull = { path?: string size?: number @@ -2313,6 +2713,9 @@ export type JobStatusResponse = { error_message?: string | null } +/** + * Response after a queue management action (delete or clear). + */ export type QueueManageResponse = { /** * Array of job IDs that were successfully cancelled @@ -2536,6 +2939,9 @@ export type GlobalSubgraphInfo = { data?: string } +/** + * Metadata describing a single ComfyUI node type and its inputs/outputs. + */ export type NodeInfo = { /** * Input specifications for the node @@ -2603,6 +3009,9 @@ export type NodeInfo = { api_node?: boolean } +/** + * Metadata about the currently running and queued prompts. + */ export type PromptInfo = { exec_info?: { /** @@ -2612,6 +3021,9 @@ export type PromptInfo = { } } +/** + * Response containing a signed download URL for an exported asset archive. + */ export type ExportDownloadUrlResponse = { /** * Signed URL for downloading the export ZIP file @@ -2623,15 +3035,24 @@ export type ExportDownloadUrlResponse = { expires_at?: string } +/** + * Error shape returned when request binding or validation fails before the handler runs. + */ export type BindingErrorResponse = { message: string } +/** + * Standard error response with a machine-readable code and human-readable message. + */ export type ErrorResponse = { code: string message: string } +/** + * Response returned after successfully queuing a workflow prompt. + */ export type PromptResponse = { /** * Unique identifier for the prompt execution @@ -2649,6 +3070,9 @@ export type PromptResponse = { } } +/** + * Request body for submitting a ComfyUI workflow prompt for execution. + */ export type PromptRequest = { /** * The workflow graph to execute @@ -2684,6 +3108,9 @@ export type PromptRequest = { workflow_version_id?: string } +/** + * Paginated list of assets belonging to the authenticated user. + */ export type ListAssetsResponseWritable = { /** * List of assets matching the query @@ -2699,6 +3126,9 @@ export type ListAssetsResponseWritable = { has_more: boolean } +/** + * Represents a user-owned asset (image, video, or other generated output). + */ export type AssetWritable = { /** * Unique identifier for the asset @@ -2739,9 +3169,15 @@ export type AssetWritable = { */ preview_id?: string | null /** - * ID of the job/prompt that created this asset, if available + * Deprecated: use job_id instead. ID of the prompt that created this asset, if available + * + * @deprecated */ prompt_id?: string | null + /** + * ID of the job that created this asset, if available + */ + job_id?: string | null /** * Timestamp when the asset was created */ @@ -2760,6 +3196,9 @@ export type AssetWritable = { is_immutable?: boolean } +/** + * Response returned when a new asset is successfully created. + */ export type AssetCreatedWritable = AssetWritable & { /** * Whether this was a new asset creation (true) or returned existing (false) @@ -3112,6 +3551,20 @@ export type GetModelPreviewResponses = { export type GetModelPreviewResponse = GetModelPreviewResponses[keyof GetModelPreviewResponses] +export type GetLegacyHistoryData = { + body?: never + path?: never + query?: never + url: '/api/history' +} + +export type GetLegacyHistoryErrors = { + /** + * Not Found — use /api/history_v2 instead + */ + 404: unknown +} + export type ManageHistoryData = { body: HistoryManageRequest path?: never @@ -3322,6 +3775,48 @@ export type GetJobDetailResponses = { export type GetJobDetailResponse = GetJobDetailResponses[keyof GetJobDetailResponses] +export type CancelJobData = { + body?: never + path: { + /** + * Job identifier (UUID) + */ + job_id: string + } + query?: never + url: '/api/jobs/{job_id}/cancel' +} + +export type CancelJobErrors = { + /** + * Bad Request - job_id is not a valid UUID (emitted by request validation before the handler runs) + */ + 400: BindingErrorResponse + /** + * Unauthorized - Authentication required + */ + 401: ErrorResponse + /** + * Job not found for this user + */ + 404: ErrorResponse + /** + * Internal server error - cancellation failed + */ + 500: ErrorResponse +} + +export type CancelJobError = CancelJobErrors[keyof CancelJobErrors] + +export type CancelJobResponses = { + /** + * Success - Cancel request accepted (or job was already terminal) + */ + 200: JobCancelResponse +} + +export type CancelJobResponse = CancelJobResponses[keyof CancelJobResponses] + export type ViewFileData = { body?: never path?: never @@ -3753,7 +4248,7 @@ export type GetRemoteAssetMetadataResponse = export type CreateAssetDownloadData = { body: { /** - * URL of the file to download (must be from huggingface.co or civitai.com) + * URL of the file to download (must be from huggingface.co, civitai.com, or civitai.red) */ source_url: string /** @@ -3825,16 +4320,16 @@ export type CreateAssetExportData = { /** * Strategy for naming files in the ZIP: * - group_by_job_id: Group assets by job ID as a parent directory (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4/ComfyUI_00001_.png") - * - group_by_job_time: Group assets by job execution time as parent directories * - preserve: Use original asset names, skip duplicates (first one wins) * - asset_id: Use the asset ID as the filename (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4.png") + * - group_by_job_time: Group by job creation timestamp (e.g., "2026-03-26T16-13-00/ComfyUI_00001_.png") * */ naming_strategy?: | 'group_by_job_id' - | 'group_by_job_time' | 'preserve' | 'asset_id' + | 'group_by_job_time' /** * Optional per-job asset name filters. When provided for a job ID, * only assets whose name matches one of the specified names are included. @@ -3943,6 +4438,10 @@ export type DeleteAssetErrors = { * Asset not found */ 404: ErrorResponse + /** + * Asset cannot be deleted because it is referenced by another resource (e.g., workflow version) + */ + 409: ErrorResponse /** * Internal server error */ @@ -4441,10 +4940,6 @@ export type ImportPublishedAssetsErrors = { * Unauthorized */ 401: ErrorResponse - /** - * Not found - */ - 404: ErrorResponse /** * Internal server error */ @@ -5028,6 +5523,9 @@ export type GetUserdataResponse = export type GetUserdataFilePublishData = { body?: never path: { + /** + * The workflow file path within the user's data directory (URL encoded if necessary). + */ file: string } query?: never @@ -5065,6 +5563,9 @@ export type GetUserdataFilePublishResponse = export type PostUserdataFilePublishData = { body: PublishWorkflowAssetsRequest path: { + /** + * The workflow file path within the user's data directory (URL encoded if necessary). + */ file: string } query?: never @@ -6256,6 +6757,50 @@ export type RevokeWorkspaceApiKeyResponses = { export type RevokeWorkspaceApiKeyResponse = RevokeWorkspaceApiKeyResponses[keyof RevokeWorkspaceApiKeyResponses] +export type BulkRevokeWorkspaceMemberApiKeysData = { + body?: never + path: { + /** + * Firebase UID of the member whose keys to revoke (must be non-empty) + */ + user_id: string + } + query?: never + url: '/api/workspace/members/{user_id}/api-keys' +} + +export type BulkRevokeWorkspaceMemberApiKeysErrors = { + /** + * Unauthorized + */ + 401: ErrorResponse + /** + * Not authorized (must be workspace owner) + */ + 403: ErrorResponse + /** + * Validation error (e.g. empty user_id) + */ + 422: ErrorResponse + /** + * Internal server error + */ + 500: ErrorResponse +} + +export type BulkRevokeWorkspaceMemberApiKeysError = + BulkRevokeWorkspaceMemberApiKeysErrors[keyof BulkRevokeWorkspaceMemberApiKeysErrors] + +export type BulkRevokeWorkspaceMemberApiKeysResponses = { + /** + * Keys revoked successfully + */ + 200: BulkRevokeApiKeysResponse +} + +export type BulkRevokeWorkspaceMemberApiKeysResponse = + BulkRevokeWorkspaceMemberApiKeysResponses[keyof BulkRevokeWorkspaceMemberApiKeysResponses] + export type VerifyWorkspaceApiKeyData = { body: VerifyApiKeyRequest path?: never @@ -6359,6 +6904,9 @@ export type SetReviewStatusResponse2 = export type UpdateHubWorkflowData = { body: UpdateHubWorkflowRequest path: { + /** + * The share ID of the hub workflow to update. + */ share_id: string } query?: never @@ -6592,6 +7140,39 @@ export type UpdateSubscriptionCacheResponses = { export type UpdateSubscriptionCacheResponse = UpdateSubscriptionCacheResponses[keyof UpdateSubscriptionCacheResponses] +export type SyncApiKeyData = { + body: SyncApiKeyRequest + path?: never + query?: never + url: '/admin/api/keys/sync' +} + +export type SyncApiKeyErrors = { + /** + * Malformed request or unsupported event + */ + 400: ErrorResponse + /** + * Missing or invalid admin secret + */ + 401: ErrorResponse + /** + * Internal error + */ + 500: ErrorResponse +} + +export type SyncApiKeyError = SyncApiKeyErrors[keyof SyncApiKeyErrors] + +export type SyncApiKeyResponses = { + /** + * Sync processed — see `result` field + */ + 200: SyncApiKeyResponse +} + +export type SyncApiKeyResponse2 = SyncApiKeyResponses[keyof SyncApiKeyResponses] + export type GetJobStatusData = { body?: never path: { @@ -7209,6 +7790,9 @@ export type CreateWorkflowResponse = export type DeleteWorkflowData = { body?: never path: { + /** + * The UUID of the workflow to delete. + */ workflow_id: string } query?: never @@ -7246,6 +7830,9 @@ export type DeleteWorkflowResponse = export type GetWorkflowData = { body?: never path: { + /** + * The UUID of the workflow. + */ workflow_id: string } query?: never @@ -7286,6 +7873,9 @@ export type GetWorkflowResponse = export type UpdateWorkflowData = { body: UpdateWorkflowRequest path: { + /** + * The UUID of the workflow to update. + */ workflow_id: string } query?: never @@ -7327,6 +7917,9 @@ export type UpdateWorkflowResponse = export type CreateWorkflowVersionData = { body: CreateWorkflowVersionRequest path: { + /** + * The UUID of the workflow to create a new version for. + */ workflow_id: string } query?: never @@ -7376,6 +7969,9 @@ export type CreateWorkflowVersionResponse = export type GetWorkflowContentData = { body?: never path: { + /** + * The UUID of the workflow whose content should be retrieved. + */ workflow_id: string } query?: never @@ -7417,6 +8013,9 @@ export type GetWorkflowContentResponse = export type ForkWorkflowData = { body: ForkWorkflowRequest path: { + /** + * The UUID of the source workflow to fork from. + */ workflow_id: string } query?: never @@ -7576,6 +8175,9 @@ export type CheckHubUsernameResponse = export type GetHubProfileByUsernameData = { body?: never path: { + /** + * The hub profile username. + */ username: string } query?: never @@ -7609,6 +8211,9 @@ export type GetHubProfileByUsernameResponse = export type UpdateHubProfileData = { body: UpdateHubProfileRequest path: { + /** + * The hub profile username to update. + */ username: string } query?: never @@ -7853,6 +8458,9 @@ export type ListHubWorkflowIndexResponse = export type DeleteHubWorkflowData = { body?: never path: { + /** + * The share ID of the hub workflow to unpublish. + */ share_id: string } query?: never @@ -7890,6 +8498,9 @@ export type DeleteHubWorkflowResponse = export type GetHubWorkflowData = { body?: never path: { + /** + * The share ID of the hub workflow. + */ share_id: string } query?: never @@ -7927,6 +8538,9 @@ export type GetHubWorkflowResponse = export type GetPublishedWorkflowData = { body?: never path: { + /** + * The share ID of the published workflow. + */ share_id: string } query?: never @@ -8263,6 +8877,9 @@ export type GetWebsocketErrors = { export type GetTemplateProxyData = { body?: never path: { + /** + * Template subpath within the versioned GCS bucket. + */ path: string } query?: never @@ -8343,6 +8960,9 @@ export type GetMonitoringTasksResponses = { export type DeleteMonitoringTasksSubpathData = { body?: never path: { + /** + * Asynqmon deletion subpath (e.g. delete task). + */ path: string } query?: never @@ -8370,6 +8990,9 @@ export type DeleteMonitoringTasksSubpathResponses = { export type GetMonitoringTasksSubpathData = { body?: never path: { + /** + * Asynqmon UI subpath (HTML page, SPA XHR, or static asset). + */ path: string } query?: never @@ -8397,6 +9020,9 @@ export type GetMonitoringTasksSubpathResponses = { export type PostMonitoringTasksSubpathData = { body?: never path: { + /** + * Asynqmon action subpath (e.g. retry, archive). + */ path: string } query?: never @@ -8424,6 +9050,9 @@ export type PostMonitoringTasksSubpathResponses = { export type GetPprofData = { body?: never path: { + /** + * pprof endpoint name (e.g. heap, goroutine, allocs, block, mutex, threadcreate). + */ path: string } query?: never @@ -8482,6 +9111,9 @@ export type PostPprofSymbolResponses = { export type GetStaticExtensionsData = { body?: never path: { + /** + * Extension file path relative to /static/extensions on disk. + */ path: string } query?: never @@ -8505,6 +9137,9 @@ export type GetStaticExtensionsResponses = { export type GetCustomNodeProxyData = { body?: never path: { + /** + * Custom node HTTP endpoint path being proxied to the CPU-backed worker. + */ path: string } query?: never @@ -8532,6 +9167,9 @@ export type GetCustomNodeProxyResponses = { export type PostCustomNodeProxyData = { body?: never path: { + /** + * Custom node HTTP endpoint path being proxied to the CPU-backed worker. + */ path: string } query?: never @@ -8555,3 +9193,159 @@ export type PostCustomNodeProxyResponses = { */ 200: unknown } + +export type GetLegacyPromptByIdData = { + body?: never + path: { + prompt_id: string + } + query?: never + url: '/api/prompt/{prompt_id}' +} + +export type GetLegacyPromptByIdErrors = { + /** + * Not Found — use /api/jobs/{prompt_id} instead + */ + 404: unknown +} + +export type GetLegacyHistoryByIdData = { + body?: never + path: { + prompt_id: string + } + query?: never + url: '/api/history/{prompt_id}' +} + +export type GetLegacyHistoryByIdErrors = { + /** + * Not Found — use /api/jobs/{prompt_id} instead + */ + 404: unknown +} + +export type GetLegacyJobByIdData = { + body?: never + path: { + job_id: string + } + query?: never + url: '/api/job/{job_id}' +} + +export type GetLegacyJobByIdErrors = { + /** + * Not Found — use /api/jobs/{job_id} instead + */ + 404: unknown +} + +export type GetLegacyJobOutputsData = { + body?: never + path: { + job_id: string + } + query?: never + url: '/api/job/{job_id}/outputs' +} + +export type GetLegacyJobOutputsErrors = { + /** + * Not Found — use /api/jobs/{job_id} instead + */ + 404: unknown +} + +export type GetLegacyModelsData = { + body?: never + path?: never + query?: never + url: '/api/models' +} + +export type GetLegacyModelsErrors = { + /** + * Not Found — use /api/experiment/models instead + */ + 404: unknown +} + +export type GetLegacyModelsByFolderData = { + body?: never + path: { + folder: string + } + query?: never + url: '/api/models/{folder}' +} + +export type GetLegacyModelsByFolderErrors = { + /** + * Not Found — use /api/experiment/models/{folder} instead + */ + 404: unknown +} + +export type GetLegacyObjectInfoByNodeClassData = { + body?: never + path: { + node_class: string + } + query?: never + url: '/api/object_info/{node_class}' +} + +export type GetLegacyObjectInfoByNodeClassErrors = { + /** + * Not Found — use /api/object_info instead + */ + 404: unknown +} + +export type GetLegacyUserdataV2Data = { + body?: never + path?: never + query?: never + url: '/api/v2/userdata' +} + +export type GetLegacyUserdataV2Errors = { + /** + * Not Found — use /api/userdata instead + */ + 404: unknown +} + +export type GetLegacyAssetContentData = { + body?: never + path: { + id: string + } + query?: never + url: '/api/assets/{id}/content' +} + +export type GetLegacyAssetContentErrors = { + /** + * Not Found — use /api/assets/download instead + */ + 404: unknown +} + +export type GetLegacyViewMetadataData = { + body?: never + path: { + folder_name: string + } + query?: never + url: '/api/view_metadata/{folder_name}' +} + +export type GetLegacyViewMetadataErrors = { + /** + * Not Found — use /api/experiment/models instead + */ + 404: unknown +} diff --git a/packages/ingest-types/src/zod.gen.ts b/packages/ingest-types/src/zod.gen.ts index 1ad5981dd8..117a53448d 100644 --- a/packages/ingest-types/src/zod.gen.ts +++ b/packages/ingest-types/src/zod.gen.ts @@ -2,6 +2,9 @@ import { z } from 'zod' +/** + * Response indicating whether a Hub username is available. + */ export const zHubUsernameCheckResponse = z.object({ username: z.string(), available: z.boolean(), @@ -9,12 +12,18 @@ export const zHubUsernameCheckResponse = z.object({ validation_error: z.string().optional() }) +/** + * Response containing a signed upload URL and the target asset path. + */ export const zHubAssetUploadUrlResponse = z.object({ upload_url: z.string(), public_url: z.string(), token: z.string() }) +/** + * Request body for requesting a signed upload URL for a Hub asset. + */ export const zHubAssetUploadUrlRequest = z.object({ filename: z.string(), content_type: z.string() @@ -46,6 +55,9 @@ export const zUpdateHubWorkflowRequest = z.object({ metadata: z.record(z.unknown()).nullish() }) +/** + * Request body for publishing or updating a workflow on the Hub. + */ export const zPublishHubWorkflowRequest = z.object({ username: z.string(), name: z.string(), @@ -63,6 +75,9 @@ export const zPublishHubWorkflowRequest = z.object({ sample_image_tokens_or_urls: z.array(z.string()).optional() }) +/** + * Lightweight asset reference used in workflow publishing payloads. + */ export const zAssetInfo = z.object({ id: z.string(), name: z.string(), @@ -73,12 +88,18 @@ export const zAssetInfo = z.object({ in_library: z.boolean() }) +/** + * Abbreviated Hub profile used in workflow listings. + */ export const zHubProfileSummary = z.object({ username: z.string(), display_name: z.string().optional(), avatar_url: z.string().optional() }) +/** + * Reference to a Hub label by ID. + */ export const zLabelRef = z.object({ name: z.string(), display_name: z.string() @@ -94,6 +115,9 @@ export const zHubWorkflowStatus = z.enum([ 'deprecated' ]) +/** + * Full Hub workflow detail including versions, assets, and statistics. + */ export const zHubWorkflowDetail = z.object({ share_id: z.string(), workflow_id: z.string(), @@ -115,6 +139,9 @@ export const zHubWorkflowDetail = z.object({ profile: zHubProfileSummary }) +/** + * Abbreviated Hub workflow metadata used in search and listing results. + */ export const zHubWorkflowSummary = z.object({ share_id: z.string(), name: z.string(), @@ -133,11 +160,17 @@ export const zHubWorkflowSummary = z.object({ sample_image_urls: z.array(z.string()).optional() }) +/** + * Paginated list of Hub workflows matching search criteria. + */ export const zHubWorkflowListResponse = z.object({ workflows: z.array(z.union([zHubWorkflowSummary, zHubWorkflowDetail])), next_cursor: z.string().optional() }) +/** + * Metadata for a single Hub label. + */ export const zHubLabelInfo = z.object({ name: z.string(), display_name: z.string(), @@ -145,10 +178,16 @@ export const zHubLabelInfo = z.object({ type: z.enum(['tag', 'model', 'custom_node']) }) +/** + * List of available Hub labels for categorizing workflows. + */ export const zHubLabelListResponse = z.object({ labels: z.array(zHubLabelInfo) }) +/** + * Entry in the curated workflow template gallery shown on the home page. + */ export const zHubWorkflowTemplateEntry = z.object({ name: z.string(), title: z.string(), @@ -227,6 +266,9 @@ export const zHubWorkflowTemplateEntry = z.object({ contentTemplate: z.string().optional() }) +/** + * Request body for updating an existing Hub profile. + */ export const zUpdateHubProfileRequest = z.object({ display_name: z.string().optional(), description: z.string().optional(), @@ -234,6 +276,9 @@ export const zUpdateHubProfileRequest = z.object({ website_urls: z.array(z.string()).optional() }) +/** + * Request body for creating a new Hub profile. + */ export const zCreateHubProfileRequest = z.object({ workspace_id: z.string(), username: z.string(), @@ -243,6 +288,9 @@ export const zCreateHubProfileRequest = z.object({ website_urls: z.array(z.string()).optional() }) +/** + * Full public profile for a Hub creator. + */ export const zHubProfile = z.object({ username: z.string(), display_name: z.string().optional(), @@ -251,14 +299,24 @@ export const zHubProfile = z.object({ website_urls: z.array(z.string()).optional() }) +/** + * Response after importing published workflow assets. + */ export const zImportPublishedAssetsResponse = z.object({ assets: z.array(zAssetInfo) }) +/** + * Request body for importing assets from a published workflow. + */ export const zImportPublishedAssetsRequest = z.object({ - published_asset_ids: z.array(z.string()) + published_asset_ids: z.array(z.string()), + share_id: z.string().nullish() }) +/** + * Full detail of a publicly published workflow on the Hub. + */ export const zPublishedWorkflowDetail = z.object({ share_id: z.string(), workflow_id: z.string(), @@ -269,18 +327,30 @@ export const zPublishedWorkflowDetail = z.object({ assets: z.array(zAssetInfo) }) +/** + * Response containing assets associated with a workflow. + */ export const zWorkflowApiAssetsResponse = z.object({ assets: z.array(zAssetInfo) }) +/** + * Request body for querying assets associated with a workflow. + */ export const zWorkflowApiAssetsRequest = z.object({ workflow_api_json: z.record(z.unknown()) }) +/** + * Request body for publishing workflow assets to the Hub. + */ export const zPublishWorkflowAssetsRequest = z.object({ asset_ids: z.array(z.string()) }) +/** + * Publishing metadata for a workflow shared to the Hub. + */ export const zWorkflowPublishInfo = z.object({ workflow_id: z.string(), share_id: z.string(), @@ -289,11 +359,17 @@ export const zWorkflowPublishInfo = z.object({ assets: z.array(zAssetInfo) }) +/** + * Request body for forking an existing workflow into the user's account. + */ export const zForkWorkflowRequest = z.object({ source_version: z.number().int(), name: z.string().optional() }) +/** + * Full workflow version including the serialized workflow JSON. + */ export const zWorkflowVersionContentResponse = z.object({ id: z.string(), version: z.number().int(), @@ -303,6 +379,9 @@ export const zWorkflowVersionContentResponse = z.object({ dependency_asset_ids: z.array(z.string()).optional() }) +/** + * Metadata for a single workflow version. + */ export const zWorkflowVersionResponse = z.object({ id: z.string(), version: z.number().int(), @@ -311,11 +390,17 @@ export const zWorkflowVersionResponse = z.object({ created_at: z.string().datetime() }) +/** + * Request body for creating a new version of a saved workflow. + */ export const zCreateWorkflowVersionRequest = z.object({ base_version: z.number().int(), workflow_json: z.record(z.unknown()) }) +/** + * Offset/limit-based pagination metadata included in list responses. + */ export const zPaginationInfo = z.object({ offset: z.number().int().gte(0), limit: z.number().int().gte(1), @@ -323,11 +408,17 @@ export const zPaginationInfo = z.object({ has_more: z.boolean() }) +/** + * Reference to the parent workflow from which this workflow was forked. + */ export const zWorkflowForkedFrom = z.object({ workflow_id: z.string().optional(), workflow_version_id: z.string().optional() }) +/** + * Full workflow entity including metadata and version history. + */ export const zWorkflowResponse = z.object({ id: z.string(), name: z.string().optional(), @@ -340,17 +431,26 @@ export const zWorkflowResponse = z.object({ updated_at: z.string().datetime() }) +/** + * Paginated list of saved workflows. + */ export const zWorkflowListResponse = z.object({ data: z.array(zWorkflowResponse), pagination: zPaginationInfo }) +/** + * Request body for updating an existing saved workflow. + */ export const zUpdateWorkflowRequest = z.object({ name: z.string().optional(), description: z.string().optional(), default_view: z.enum(['workflow', 'app']).optional() }) +/** + * Request body for creating a new saved workflow. + */ export const zCreateWorkflowRequest = z.object({ name: z.string().optional(), description: z.string().optional(), @@ -360,10 +460,16 @@ export const zCreateWorkflowRequest = z.object({ forked_from_workflow_version_id: z.string().optional() }) +/** + * Response after recording partner usage data. + */ export const zPartnerUsageResponse = z.object({ status: z.string() }) +/** + * Request body for reporting partner resource usage (admin endpoint). + */ export const zPartnerUsageRequest = z.object({ workspace_id: z.string().min(1), user_id: z.string().optional(), @@ -373,6 +479,9 @@ export const zPartnerUsageRequest = z.object({ properties: z.record(z.unknown()).optional() }) +/** + * Status of an asynchronous billing operation. + */ export const zBillingOpStatusResponse = z.object({ id: z.string(), status: z.enum(['pending', 'succeeded', 'failed']), @@ -381,6 +490,9 @@ export const zBillingOpStatusResponse = z.object({ completed_at: z.string().datetime().optional() }) +/** + * Response after successfully purchasing a credit top-up. + */ export const zCreateTopupResponse = z.object({ billing_op_id: z.string(), topup_id: z.string(), @@ -395,6 +507,9 @@ export const zCreateTopupResponse = z.object({ }) }) +/** + * Request body for purchasing a one-time credit top-up. + */ export const zCreateTopupRequest = z.object({ amount_cents: z.coerce .bigint() @@ -405,33 +520,54 @@ export const zCreateTopupRequest = z.object({ idempotency_key: z.string().optional() }) +/** + * Response containing a redirect URL to the payment portal. + */ export const zPaymentPortalResponse = z.object({ url: z.string() }) +/** + * Request body for generating a payment portal session URL. + */ export const zPaymentPortalRequest = z.object({ return_url: z.string().optional() }) +/** + * Response after successfully resubscribing to a billing plan. + */ export const zResubscribeResponse = z.object({ billing_op_id: z.string(), status: z.enum(['active']), message: z.string().optional() }) +/** + * Request body for reactivating a previously cancelled subscription. + */ export const zResubscribeRequest = z.object({ idempotency_key: z.string().optional() }) +/** + * Response after successfully cancelling a subscription. + */ export const zCancelSubscriptionResponse = z.object({ billing_op_id: z.string(), cancel_at: z.string().datetime() }) +/** + * Request body for cancelling the current subscription. + */ export const zCancelSubscriptionRequest = z.object({ idempotency_key: z.string().optional() }) +/** + * Response after successfully subscribing to a billing plan. + */ export const zSubscribeResponse = z.object({ billing_op_id: z.string(), status: z.enum(['subscribed', 'needs_payment_method', 'pending_payment']), @@ -439,6 +575,9 @@ export const zSubscribeResponse = z.object({ payment_method_url: z.string().optional() }) +/** + * Request body for subscribing a workspace to a billing plan. + */ export const zSubscribeRequest = z.object({ plan_slug: z.string(), idempotency_key: z.string().optional(), @@ -513,6 +652,9 @@ export const zPreviewPlanInfo = z.object({ period_end: z.string().datetime().optional() }) +/** + * Itemized cost preview for a pending subscription change. + */ export const zPreviewSubscribeResponse = z.object({ allowed: z.boolean(), reason: z.string().optional(), @@ -560,6 +702,9 @@ export const zPreviewSubscribeResponse = z.object({ new_plan: zPreviewPlanInfo }) +/** + * Request body for previewing the cost of a plan subscription change. + */ export const zPreviewSubscribeRequest = z.object({ plan_slug: z.string() }) @@ -575,11 +720,17 @@ export const zPlanAvailabilityReason = z.enum([ 'exceeds_max_seats' ]) +/** + * Availability and eligibility information for a billing plan. + */ export const zPlanAvailability = z.object({ available: z.boolean(), reason: zPlanAvailabilityReason.optional() }) +/** + * Billing plan details including pricing, limits, and features. + */ export const zPlan = z.object({ slug: z.string(), tier: zSubscriptionTier, @@ -612,11 +763,17 @@ export const zPlan = z.object({ seat_summary: zPlanSeatSummary }) +/** + * List of available billing plans for subscription. + */ export const zBillingPlansResponse = z.object({ current_plan_slug: z.string().optional(), plans: z.array(zPlan) }) +/** + * User secret metadata (the secret value itself is never returned after creation). + */ export const zSecretResponse = z.object({ id: z.string().uuid(), name: z.string(), @@ -626,21 +783,33 @@ export const zSecretResponse = z.object({ updated_at: z.string().datetime() }) +/** + * List of user secrets with metadata only. + */ export const zSecretListResponse = z.object({ data: z.array(zSecretResponse) }) +/** + * Request body for updating an existing user secret. + */ export const zUpdateSecretRequest = z.object({ name: z.string().min(1).max(255).optional(), secret_value: z.string().min(1).optional() }) +/** + * Request body for creating a new user secret. + */ export const zCreateSecretRequest = z.object({ name: z.string().min(1).max(255), provider: z.string().max(64).optional(), secret_value: z.string().min(1) }) +/** + * A single billing event such as a charge, credit, or adjustment. + */ export const zBillingEvent = z.object({ event_type: z.string(), event_id: z.string(), @@ -648,6 +817,9 @@ export const zBillingEvent = z.object({ createdAt: z.string().datetime() }) +/** + * Paginated list of billing events for a workspace. + */ export const zBillingEventsResponse = z.object({ total: z.number().int(), events: z.array(zBillingEvent), @@ -656,6 +828,9 @@ export const zBillingEventsResponse = z.object({ totalPages: z.number().int() }) +/** + * Current credit balance and usage details for a workspace. + */ export const zBillingBalanceResponse = z.object({ amount_micros: z.number(), prepaid_balance_micros: z.number().optional(), @@ -676,6 +851,9 @@ export const zBillingStatus = z.enum([ 'inactive' ]) +/** + * Current billing and subscription status for a workspace. + */ export const zBillingStatusResponse = z.object({ is_active: z.boolean(), subscription_status: z.enum(['active', 'ended', 'canceled']).optional(), @@ -688,6 +866,9 @@ export const zBillingStatusResponse = z.object({ renewal_date: z.string().datetime().optional() }) +/** + * A single JSON Web Key entry within a JWKS response. + */ export const zJwkKey = z.object({ kty: z.string(), crv: z.string(), @@ -698,10 +879,32 @@ export const zJwkKey = z.object({ y: z.string() }) +/** + * JSON Web Key Set containing the public keys used to verify Cloud JWTs. + */ export const zJwksResponse = z.object({ keys: z.array(zJwkKey) }) +/** + * Response after synchronizing an API key into the local database. + */ +export const zSyncApiKeyResponse = z.object({ + result: z.enum(['revoked', 'already_revoked', 'no_op']) +}) + +/** + * Request body for synchronizing an API key from the external registry. + */ +export const zSyncApiKeyRequest = z.object({ + event: z.enum(['delete']), + key_hash: z.string().regex(/^[0-9a-fA-F]{64}$/), + customer_id: z.string().min(1) +}) + +/** + * Response confirming the validity and scope of a workspace API key. + */ export const zVerifyApiKeyResponse = z.object({ user_id: z.string(), email: z.string(), @@ -715,10 +918,23 @@ export const zVerifyApiKeyResponse = z.object({ permissions: z.array(z.string()) }) +/** + * Request body for verifying a workspace API key (admin endpoint). + */ export const zVerifyApiKeyRequest = z.object({ api_key: z.string() }) +/** + * Response after bulk-revoking API keys for a workspace member. + */ +export const zBulkRevokeApiKeysResponse = z.object({ + revoked_count: z.number().int().gte(0) +}) + +/** + * Metadata for a workspace-scoped API key (secret is never returned). + */ export const zWorkspaceApiKeyInfo = z.object({ id: z.string().uuid(), workspace_id: z.string(), @@ -731,10 +947,16 @@ export const zWorkspaceApiKeyInfo = z.object({ created_at: z.string().datetime() }) +/** + * List of API keys associated with the current workspace. + */ export const zListWorkspaceApiKeysResponse = z.object({ api_keys: z.array(zWorkspaceApiKeyInfo) }) +/** + * Response containing the newly created workspace API key. + */ export const zCreateWorkspaceApiKeyResponse = z.object({ id: z.string().uuid(), name: z.string(), @@ -744,20 +966,32 @@ export const zCreateWorkspaceApiKeyResponse = z.object({ created_at: z.string().datetime() }) +/** + * Request body for creating a new workspace-scoped API key. + */ export const zCreateWorkspaceApiKeyRequest = z.object({ name: z.string(), expires_at: z.string().datetime().optional() }) +/** + * Response returned after successfully accepting a workspace invitation. + */ export const zAcceptInviteResponse = z.object({ workspace_id: z.string(), workspace_name: z.string() }) +/** + * Request body for inviting a user to a workspace. + */ export const zCreateInviteRequest = z.object({ email: z.string().email() }) +/** + * An outstanding workspace invitation that has not yet been accepted. + */ export const zPendingInvite = z.object({ id: z.string(), email: z.string().email(), @@ -766,10 +1000,16 @@ export const zPendingInvite = z.object({ expires_at: z.string().datetime() }) +/** + * List of pending invitations for the current workspace. + */ export const zListInvitesResponse = z.object({ invites: z.array(zPendingInvite) }) +/** + * Workspace member with profile and role information. + */ export const zMember = z.object({ id: z.string(), name: z.string(), @@ -778,19 +1018,31 @@ export const zMember = z.object({ joined_at: z.string().datetime() }) +/** + * List of members in the current workspace. + */ export const zListMembersResponse = z.object({ members: z.array(zMember), pagination: zPaginationInfo }) +/** + * Request body for updating an existing workspace's settings. + */ export const zUpdateWorkspaceRequest = z.object({ name: z.string().min(1).max(100).optional() }) +/** + * Request body for creating a new workspace. + */ export const zCreateWorkspaceRequest = z.object({ name: z.string().min(1).max(100) }) +/** + * Workspace entity annotated with the requesting user's role. + */ export const zWorkspaceWithRole = z.object({ id: z.string(), name: z.string(), @@ -801,10 +1053,16 @@ export const zWorkspaceWithRole = z.object({ subscription_tier: zSubscriptionTier.optional() }) +/** + * Paginated list of workspaces the authenticated user belongs to. + */ export const zListWorkspacesResponse = z.object({ workspaces: z.array(zWorkspaceWithRole) }) +/** + * Full workspace entity with configuration and ownership details. + */ export const zWorkspace = z.object({ id: z.string(), name: z.string(), @@ -812,12 +1070,18 @@ export const zWorkspace = z.object({ created_at: z.string().datetime() }) +/** + * Abbreviated workspace metadata used in list responses. + */ export const zWorkspaceSummary = z.object({ id: z.string(), name: z.string(), type: z.enum(['personal', 'team']) }) +/** + * Response containing the issued Cloud JWT and its expiry. + */ export const zExchangeTokenResponse = z.object({ token: z.string(), expires_at: z.string().datetime(), @@ -826,6 +1090,12 @@ export const zExchangeTokenResponse = z.object({ permissions: z.array(z.string()) }) +/** + * Optional request body for the token exchange endpoint. The Firebase JWT + * being exchanged is supplied via the `Authorization: Bearer` header; this + * body only carries workspace-selection input. + * + */ export const zExchangeTokenRequest = z.object({ workspace_id: z.string().optional() }) @@ -859,16 +1129,25 @@ export const zTaskEntry = z.object({ completed_at: z.string().datetime().optional() }) +/** + * Paginated list of background tasks for the authenticated user. + */ export const zTasksListResponse = z.object({ tasks: z.array(zTaskEntry), pagination: zPaginationInfo }) +/** + * Current status of a user data deletion request. + */ export const zDeletionStatus = z.object({ status_name: z.string(), status_details: z.string() }) +/** + * Details of a pending or completed user data deletion request. + */ export const zDeletionRequest = z.object({ id: z.string(), firebase_id: z.string(), @@ -927,6 +1206,13 @@ export const zJobDetailResponse = z.object({ execution_meta: z.record(z.unknown()).optional() }) +/** + * Response for POST /api/jobs/{job_id}/cancel. Returned on both fresh cancels and idempotent no-ops. + */ +export const zJobCancelResponse = z.object({ + cancelled: z.boolean() +}) + /** * Lightweight job data for list views (workflow and full outputs excluded) */ @@ -971,11 +1257,17 @@ export const zJobEntry = z.object({ .optional() }) +/** + * Paginated list of jobs for the authenticated user. + */ export const zJobsListResponse = z.object({ jobs: z.array(zJobEntry), pagination: zPaginationInfo }) +/** + * Response after adding, updating, or removing tags on an asset. + */ export const zTagsModificationResponse = z.object({ added: z.array(z.string()).optional(), removed: z.array(z.string()).optional(), @@ -984,24 +1276,36 @@ export const zTagsModificationResponse = z.object({ total_tags: z.array(z.string()) }) +/** + * Details of a single validation error encountered during asset operations. + */ export const zValidationError = z.object({ code: z.string(), message: z.string(), field: z.string() }) +/** + * Result of validating a set of asset operations. + */ export const zValidationResult = z.object({ is_valid: z.boolean(), errors: z.array(zValidationError).optional(), warnings: z.array(zValidationError).optional() }) +/** + * Acknowledgement of an async asset download task; clients poll GET /api/tasks/{task_id} for status. + */ export const zAssetDownloadResponse = z.object({ task_id: z.string().uuid(), status: z.enum(['created', 'running', 'completed', 'failed']), message: z.string().optional() }) +/** + * Metadata for a remotely hosted asset resolved by URL. + */ export const zAssetMetadataResponse = z.object({ content_length: z.coerce .bigint() @@ -1019,21 +1323,33 @@ export const zAssetMetadataResponse = z.object({ validation: zValidationResult.optional() }) +/** + * Histogram of tag counts used for refining asset search results. + */ export const zAssetTagHistogramResponse = z.object({ tag_counts: z.record(z.number().int()) }) +/** + * Metadata for a single tag that can be applied to assets. + */ export const zTagInfo = z.object({ name: z.string(), count: z.number().int() }) +/** + * Paginated list of available asset tags. + */ export const zListTagsResponse = z.object({ tags: z.array(zTagInfo), total: z.number().int(), has_more: z.boolean() }) +/** + * Represents a user-owned asset (image, video, or other generated output). + */ export const zAsset = z.object({ id: z.string().uuid(), name: z.string(), @@ -1056,18 +1372,25 @@ export const zAsset = z.object({ preview_url: z.string().url().optional(), preview_id: z.string().uuid().nullish(), prompt_id: z.string().uuid().nullish(), + job_id: z.string().uuid().nullish(), created_at: z.string().datetime(), updated_at: z.string().datetime(), last_access_time: z.string().datetime().optional(), is_immutable: z.boolean().optional() }) +/** + * Paginated list of assets belonging to the authenticated user. + */ export const zListAssetsResponse = z.object({ assets: z.array(zAsset), total: z.number().int(), has_more: z.boolean() }) +/** + * Response returned when an existing asset is successfully updated. + */ export const zAssetUpdated = z.object({ id: z.string().uuid(), name: z.string().optional(), @@ -1081,17 +1404,26 @@ export const zAssetUpdated = z.object({ updated_at: z.string().datetime() }) +/** + * Response returned when a new asset is successfully created. + */ export const zAssetCreated = zAsset.and( z.object({ created_new: z.boolean() }) ) +/** + * Response after updating the review status of a Hub workflow. + */ export const zSetReviewStatusResponse = z.object({ share_ids: z.array(z.string()), status: z.enum(['approved', 'rejected']) }) +/** + * Request body for setting the review status of a Hub workflow. + */ export const zSetReviewStatusRequest = z.object({ share_ids: z.array(z.string()).min(1), status: z.enum(['approved', 'rejected']) @@ -1196,6 +1528,9 @@ export const zModelFolder = z.object({ */ export const zPromptErrorResponse = z.record(z.unknown()) +/** + * Individual file entry within a full user data response. + */ export const zGetUserDataResponseFullFile = z.object({ path: z.string().optional(), size: z.number().int().optional(), @@ -1210,8 +1545,14 @@ export const zGetUserDataResponseFullFile = z.object({ .optional() }) +/** + * List of user data file entries (each with path, size, and modification time) returned when full_info=true. + */ export const zGetUserDataResponseFull = z.array(zGetUserDataResponseFullFile) +/** + * User data listing entry with file metadata (path, size, modification time). + */ export const zUserDataResponseFull = z.object({ path: z.string().optional(), size: z.number().int().optional(), @@ -1254,6 +1595,9 @@ export const zJobStatusResponse = z.object({ error_message: z.string().nullish() }) +/** + * Response after a queue management action (delete or clear). + */ export const zQueueManageResponse = z.object({ deleted: z.array(z.string()).optional(), cleared: z.boolean().optional() @@ -1369,6 +1713,9 @@ export const zGlobalSubgraphInfo = z.object({ data: z.string().optional() }) +/** + * Metadata describing a single ComfyUI node type and its inputs/outputs. + */ export const zNodeInfo = z.object({ input: z.record(z.unknown()).optional(), input_order: z.record(z.array(z.string())).optional(), @@ -1387,6 +1734,9 @@ export const zNodeInfo = z.object({ api_node: z.boolean().optional() }) +/** + * Metadata about the currently running and queued prompts. + */ export const zPromptInfo = z.object({ exec_info: z .object({ @@ -1395,26 +1745,41 @@ export const zPromptInfo = z.object({ .optional() }) +/** + * Response containing a signed download URL for an exported asset archive. + */ export const zExportDownloadUrlResponse = z.object({ url: z.string(), expires_at: z.string().datetime().optional() }) +/** + * Error shape returned when request binding or validation fails before the handler runs. + */ export const zBindingErrorResponse = z.object({ message: z.string() }) +/** + * Standard error response with a machine-readable code and human-readable message. + */ export const zErrorResponse = z.object({ code: z.string(), message: z.string() }) +/** + * Response returned after successfully queuing a workflow prompt. + */ export const zPromptResponse = z.object({ prompt_id: z.string().uuid().optional(), number: z.number().optional(), node_errors: z.record(z.unknown()).optional() }) +/** + * Request body for submitting a ComfyUI workflow prompt for execution. + */ export const zPromptRequest = z.object({ prompt: z.record(z.unknown()), number: z.number().optional(), @@ -1425,6 +1790,9 @@ export const zPromptRequest = z.object({ workflow_version_id: z.string().optional() }) +/** + * Represents a user-owned asset (image, video, or other generated output). + */ export const zAssetWritable = z.object({ id: z.string().uuid(), name: z.string(), @@ -1446,18 +1814,25 @@ export const zAssetWritable = z.object({ preview_url: z.string().url().optional(), preview_id: z.string().uuid().nullish(), prompt_id: z.string().uuid().nullish(), + job_id: z.string().uuid().nullish(), created_at: z.string().datetime(), updated_at: z.string().datetime(), last_access_time: z.string().datetime().optional(), is_immutable: z.boolean().optional() }) +/** + * Paginated list of assets belonging to the authenticated user. + */ export const zListAssetsResponseWritable = z.object({ assets: z.array(zAssetWritable), total: z.number().int(), has_more: z.boolean() }) +/** + * Response returned when a new asset is successfully created. + */ export const zAssetCreatedWritable = zAssetWritable.and( z.object({ created_new: z.boolean() @@ -1601,6 +1976,12 @@ export const zGetModelPreviewData = z.object({ */ export const zGetModelPreviewResponse = z.string() +export const zGetLegacyHistoryData = z.object({ + body: z.never().optional(), + path: z.never().optional(), + query: z.never().optional() +}) + export const zManageHistoryData = z.object({ body: zHistoryManageRequest, path: z.never().optional(), @@ -1670,6 +2051,19 @@ export const zGetJobDetailData = z.object({ */ export const zGetJobDetailResponse = zJobDetailResponse +export const zCancelJobData = z.object({ + body: z.never().optional(), + path: z.object({ + job_id: z.string().uuid() + }), + query: z.never().optional() +}) + +/** + * Success - Cancel request accepted (or job was already terminal) + */ +export const zCancelJobResponse = zJobCancelResponse + export const zViewFileData = z.object({ body: z.never().optional(), path: z.never().optional(), @@ -1818,7 +2212,7 @@ export const zCreateAssetExportData = z.object({ job_ids: z.array(z.string()).optional(), asset_ids: z.array(z.string()).optional(), naming_strategy: z - .enum(['group_by_job_id', 'group_by_job_time', 'preserve', 'asset_id']) + .enum(['group_by_job_id', 'preserve', 'asset_id', 'group_by_job_time']) .optional(), job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional() }), @@ -2564,6 +2958,20 @@ export const zRevokeWorkspaceApiKeyData = z.object({ */ export const zRevokeWorkspaceApiKeyResponse = z.void() +export const zBulkRevokeWorkspaceMemberApiKeysData = z.object({ + body: z.never().optional(), + path: z.object({ + user_id: z.string().min(1) + }), + query: z.never().optional() +}) + +/** + * Keys revoked successfully + */ +export const zBulkRevokeWorkspaceMemberApiKeysResponse = + zBulkRevokeApiKeysResponse + export const zVerifyWorkspaceApiKeyData = z.object({ body: zVerifyApiKeyRequest, path: z.never().optional(), @@ -2670,6 +3078,17 @@ export const zUpdateSubscriptionCacheResponse = z.object({ status: z.string().optional() }) +export const zSyncApiKeyData = z.object({ + body: zSyncApiKeyRequest, + path: z.never().optional(), + query: z.never().optional() +}) + +/** + * Sync processed — see `result` field + */ +export const zSyncApiKeyResponse2 = zSyncApiKeyResponse + export const zGetJobStatusData = z.object({ body: z.never().optional(), path: z.object({ @@ -3337,3 +3756,79 @@ export const zPostCustomNodeProxyData = z.object({ }), query: z.never().optional() }) + +export const zGetLegacyPromptByIdData = z.object({ + body: z.never().optional(), + path: z.object({ + prompt_id: z.string() + }), + query: z.never().optional() +}) + +export const zGetLegacyHistoryByIdData = z.object({ + body: z.never().optional(), + path: z.object({ + prompt_id: z.string() + }), + query: z.never().optional() +}) + +export const zGetLegacyJobByIdData = z.object({ + body: z.never().optional(), + path: z.object({ + job_id: z.string() + }), + query: z.never().optional() +}) + +export const zGetLegacyJobOutputsData = z.object({ + body: z.never().optional(), + path: z.object({ + job_id: z.string() + }), + query: z.never().optional() +}) + +export const zGetLegacyModelsData = z.object({ + body: z.never().optional(), + path: z.never().optional(), + query: z.never().optional() +}) + +export const zGetLegacyModelsByFolderData = z.object({ + body: z.never().optional(), + path: z.object({ + folder: z.string() + }), + query: z.never().optional() +}) + +export const zGetLegacyObjectInfoByNodeClassData = z.object({ + body: z.never().optional(), + path: z.object({ + node_class: z.string() + }), + query: z.never().optional() +}) + +export const zGetLegacyUserdataV2Data = z.object({ + body: z.never().optional(), + path: z.never().optional(), + query: z.never().optional() +}) + +export const zGetLegacyAssetContentData = z.object({ + body: z.never().optional(), + path: z.object({ + id: z.string() + }), + query: z.never().optional() +}) + +export const zGetLegacyViewMetadataData = z.object({ + body: z.never().optional(), + path: z.object({ + folder_name: z.string() + }), + query: z.never().optional() +}) diff --git a/packages/object-info-parser/package.json b/packages/object-info-parser/package.json new file mode 100644 index 0000000000..f6ea11577e --- /dev/null +++ b/packages/object-info-parser/package.json @@ -0,0 +1,30 @@ +{ + "name": "@comfyorg/object-info-parser", + "version": "1.0.0", + "description": "Shared object_info schemas and helpers", + "license": "GPL-3.0-only", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run --config ./vitest.config.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "zod": "catalog:", + "zod-validation-error": "catalog:" + }, + "devDependencies": { + "typescript": "catalog:", + "vitest": "catalog:" + }, + "nx": { + "tags": [ + "scope:shared", + "type:util" + ] + } +} diff --git a/packages/object-info-parser/src/__tests__/groupNodesByPack.test.ts b/packages/object-info-parser/src/__tests__/groupNodesByPack.test.ts new file mode 100644 index 0000000000..6c490b1d1b --- /dev/null +++ b/packages/object-info-parser/src/__tests__/groupNodesByPack.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' + +import { groupNodesByPack } from '../helpers/groupNodesByPack' +import type { ComfyNodeDef } from '../schemas/nodeDefSchema' + +function makeNodeDef( + name: string, + pythonModule: string, + displayName = name +): ComfyNodeDef { + return { + name, + display_name: displayName, + description: '', + category: 'test', + output_node: false, + python_module: pythonModule + } +} + +describe('groupNodesByPack', () => { + it('excludes core nodes and groups custom nodes by pack id', () => { + const grouped = groupNodesByPack({ + CoreNode: makeNodeDef('CoreNode', 'nodes'), + ImpactA: makeNodeDef( + 'ImpactA', + 'custom_nodes.comfyui-impact-pack.nodes', + 'Impact A' + ), + ImpactB: makeNodeDef( + 'ImpactB', + 'custom_nodes.comfyui-impact-pack.nodes', + 'Impact B' + ), + AuxNode: makeNodeDef( + 'AuxNode', + 'custom_nodes.comfyui-controlnet-aux.nodes', + 'Aux Node' + ) + }) + + expect(grouped).toHaveLength(2) + expect(grouped.map((pack) => pack.id)).toEqual([ + 'comfyui-controlnet-aux', + 'comfyui-impact-pack' + ]) + expect( + grouped.find((pack) => pack.id === 'comfyui-impact-pack')?.nodes + ).toHaveLength(2) + expect( + grouped.find((pack) => pack.id === 'comfyui-controlnet-aux')?.nodes + ).toHaveLength(1) + }) +}) diff --git a/src/schemas/nodeDefSchema.validation.test.ts b/packages/object-info-parser/src/__tests__/nodeDefSchema.test.ts similarity index 51% rename from src/schemas/nodeDefSchema.validation.test.ts rename to packages/object-info-parser/src/__tests__/nodeDefSchema.test.ts index 707eabd3e4..41f02e7a71 100644 --- a/src/schemas/nodeDefSchema.validation.test.ts +++ b/packages/object-info-parser/src/__tests__/nodeDefSchema.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -import { validateComfyNodeDef } from '@/schemas/nodeDefSchema' -import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' +import { validateComfyNodeDef } from '../schemas/nodeDefSchema' +import type { ComfyNodeDef } from '../schemas/nodeDefSchema' const EXAMPLE_NODE_DEF: ComfyNodeDef = { input: { @@ -23,52 +23,45 @@ const EXAMPLE_NODE_DEF: ComfyNodeDef = { } describe('validateNodeDef', () => { - it('Should accept a valid node definition', async () => { + it('accepts a valid node definition', () => { expect(validateComfyNodeDef(EXAMPLE_NODE_DEF)).not.toBeNull() }) - describe.each([ + describe.for([ [{ ckpt_name: ['foo', { default: 1 }] }, ['foo', { default: 1 }]], - // Extra input spec should be preserved [{ ckpt_name: ['foo', { bar: 1 }] }, ['foo', { bar: 1 }]], [{ ckpt_name: ['INT', { bar: 1 }] }, ['INT', { bar: 1 }]], [{ ckpt_name: [[1, 2, 3], { bar: 1 }] }, [[1, 2, 3], { bar: 1 }]] ])( 'validateComfyNodeDef with various input spec formats', - (inputSpec, expected) => { - it(`should accept input spec format: ${JSON.stringify(inputSpec)}`, async () => { - expect( - // @ts-expect-error fixme ts strict error - validateComfyNodeDef({ - ...EXAMPLE_NODE_DEF, - input: { - required: inputSpec - } - }).input.required.ckpt_name - ).toEqual(expected) + ([inputSpec, expected]) => { + it(`accepts input spec format: ${JSON.stringify(inputSpec)}`, () => { + const parsed = validateComfyNodeDef({ + ...EXAMPLE_NODE_DEF, + input: { + required: inputSpec + } + }) + expect(parsed?.input?.required?.ckpt_name).toEqual(expected) }) } ) - describe.each([ + describe.for([ [{ ckpt_name: { 'model1.safetensors': 'foo' } }], [{ ckpt_name: ['*', ''] }], [{ ckpt_name: ['foo', { default: 1 }, { default: 2 }] }], - // Should reject incorrect default value type. [{ ckpt_name: ['INT', { default: '124' }] }] - ])( - 'validateComfyNodeDef rejects with various input spec formats', - (inputSpec) => { - it(`should accept input spec format: ${JSON.stringify(inputSpec)}`, async () => { - expect( - validateComfyNodeDef({ - ...EXAMPLE_NODE_DEF, - input: { - required: inputSpec - } - }) - ).toBeNull() - }) - } - ) + ])('validateComfyNodeDef rejects invalid input specs', (inputSpec) => { + it(`rejects input spec format: ${JSON.stringify(inputSpec)}`, () => { + expect( + validateComfyNodeDef({ + ...EXAMPLE_NODE_DEF, + input: { + required: inputSpec + } + }) + ).toBeNull() + }) + }) }) diff --git a/src/types/nodeSource.test.ts b/packages/object-info-parser/src/__tests__/nodeSource.test.ts similarity index 97% rename from src/types/nodeSource.test.ts rename to packages/object-info-parser/src/__tests__/nodeSource.test.ts index 215b4106be..5e32966faf 100644 --- a/src/types/nodeSource.test.ts +++ b/packages/object-info-parser/src/__tests__/nodeSource.test.ts @@ -5,8 +5,8 @@ import { getNodeSource, isCustomNode, isEssentialNode -} from '@/types/nodeSource' -import type { NodeSource } from '@/types/nodeSource' +} from '../classifiers/nodeSource' +import type { NodeSource } from '../classifiers/nodeSource' describe('getNodeSource', () => { it('should return UNKNOWN_NODE_SOURCE when python_module is undefined', () => { diff --git a/packages/object-info-parser/src/__tests__/sanitizeUserContent.test.ts b/packages/object-info-parser/src/__tests__/sanitizeUserContent.test.ts new file mode 100644 index 0000000000..6d0b91b942 --- /dev/null +++ b/packages/object-info-parser/src/__tests__/sanitizeUserContent.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' + +import { sanitizeUserContent } from '../helpers/sanitizeUserContent' +import type { ComfyNodeDef } from '../schemas/nodeDefSchema' + +function makeNodeDef( + name: string, + pythonModule: string, + input: ComfyNodeDef['input'] +): ComfyNodeDef { + return { + name, + display_name: name, + description: '', + category: 'test', + output_node: false, + python_module: pythonModule, + input + } +} + +describe('sanitizeUserContent', () => { + it('strips known user filenames from combo inputs across all nodes', () => { + const defs = { + CustomCombo: makeNodeDef('CustomCombo', 'custom_nodes.some-pack', { + required: { + choice: [['my-secret.png', 'safe-option', 'video.mp4', 42], {}], + choiceV2: ['COMBO', { options: ['a.jpg', 'keep-me', 'b'] }] + } + }) + } + + const sanitized = sanitizeUserContent(defs) + const required = sanitized.CustomCombo.input?.required + expect(required?.choice).toEqual([['safe-option', 42], {}]) + expect(required?.choiceV2).toEqual(['COMBO', { options: ['keep-me', 'b'] }]) + }) + + it('zeros combo lists for known upload nodes in required/optional/hidden sections', () => { + const defs = { + LoadImage: makeNodeDef('LoadImage', 'nodes', { + required: { + image: [['personal.png', 'public.png'], { image_upload: true }] + }, + optional: { + mask: ['COMBO', { options: ['another.jpg', 'value'] }] + }, + hidden: { + cache: [['movie.mov', 'keep'], {}], + hiddenV2: ['COMBO', { options: ['private.wav', 'other'] }] + } + }), + LoadVideo: makeNodeDef('LoadVideo', 'nodes', { + required: { + video: [['clip.mp4', 'clip2.webm'], {}] + } + }), + LoadAudio: makeNodeDef('LoadAudio', 'nodes', { + required: { + audio: [['song.mp3', 'song.flac'], {}] + } + }) + } + + const sanitized = sanitizeUserContent(defs) + + expect(sanitized.LoadImage.input?.required?.image).toEqual([ + [], + { image_upload: true } + ]) + expect(sanitized.LoadImage.input?.optional?.mask).toEqual([ + 'COMBO', + { options: [] } + ]) + expect(sanitized.LoadImage.input?.hidden?.cache).toEqual([[], {}]) + expect(sanitized.LoadImage.input?.hidden?.hiddenV2).toEqual([ + 'COMBO', + { options: [] } + ]) + expect(sanitized.LoadVideo.input?.required?.video).toEqual([[], {}]) + expect(sanitized.LoadAudio.input?.required?.audio).toEqual([[], {}]) + }) +}) diff --git a/packages/object-info-parser/src/classifiers/nodeSource.ts b/packages/object-info-parser/src/classifiers/nodeSource.ts new file mode 100644 index 0000000000..b2089d7625 --- /dev/null +++ b/packages/object-info-parser/src/classifiers/nodeSource.ts @@ -0,0 +1,97 @@ +export const BLUEPRINT_CATEGORY = 'Subgraph Blueprints' + +export enum NodeSourceType { + Core = 'core', + CustomNodes = 'custom_nodes', + Blueprint = 'blueprint', + Essentials = 'essentials', + Unknown = 'unknown' +} +export const CORE_NODE_MODULES = ['nodes', 'comfy_extras', 'comfy_api_nodes'] + +export type NodeSource = { + type: NodeSourceType + className: string + displayText: string + badgeText: string +} + +const UNKNOWN_NODE_SOURCE: NodeSource = { + type: NodeSourceType.Unknown, + className: 'comfy-unknown', + displayText: 'Unknown', + badgeText: '?' +} + +function shortenNodeName(name: string) { + return name + .replace(/^(ComfyUI-|ComfyUI_|Comfy-|Comfy_)/, '') + .replace(/(-ComfyUI|_ComfyUI|-Comfy|_Comfy)$/, '') +} + +export function getNodeSource( + python_module?: string, + essentials_category?: string +): NodeSource { + if (!python_module) { + return UNKNOWN_NODE_SOURCE + } + const modules = python_module.split('.') + if (essentials_category) { + const moduleName = modules[1] ?? modules[0] ?? 'essentials' + const displayName = shortenNodeName(moduleName.split('@')[0]) + return { + type: NodeSourceType.Essentials, + className: 'comfy-essentials', + displayText: displayName, + badgeText: displayName + } + } else if (CORE_NODE_MODULES.includes(modules[0])) { + return { + type: NodeSourceType.Core, + className: 'comfy-core', + displayText: 'Comfy Core', + badgeText: '🦊' + } + } else if (modules[0] === 'blueprint') { + return { + type: NodeSourceType.Blueprint, + className: 'blueprint', + displayText: 'Blueprint', + badgeText: 'bp' + } + } else if (modules[0] === 'custom_nodes') { + const moduleName = modules[1] + if (!moduleName) { + return UNKNOWN_NODE_SOURCE + } + const customNodeName = moduleName.split('@')[0] + const displayName = shortenNodeName(customNodeName) + return { + type: NodeSourceType.CustomNodes, + className: 'comfy-custom-nodes', + displayText: displayName, + badgeText: displayName + } + } else { + return UNKNOWN_NODE_SOURCE + } +} + +interface NodeDefLike { + nodeSource: NodeSource +} + +export function isEssentialNode(node: NodeDefLike): boolean { + return node.nodeSource.type === NodeSourceType.Essentials +} + +export function isCustomNode(node: NodeDefLike): boolean { + return node.nodeSource.type === NodeSourceType.CustomNodes +} + +export enum NodeBadgeMode { + None = 'None', + ShowAll = 'Show all', + HideBuiltIn = 'Hide built-in' +} diff --git a/packages/object-info-parser/src/helpers/groupNodesByPack.ts b/packages/object-info-parser/src/helpers/groupNodesByPack.ts new file mode 100644 index 0000000000..509205a800 --- /dev/null +++ b/packages/object-info-parser/src/helpers/groupNodesByPack.ts @@ -0,0 +1,47 @@ +import { getNodeSource, NodeSourceType } from '../classifiers/nodeSource' +import type { ComfyNodeDef } from '../schemas/nodeDefSchema' + +export interface PackedNode { + className: string + def: ComfyNodeDef +} + +export interface NodePack { + id: string + displayName: string + nodes: PackedNode[] +} + +export function groupNodesByPack( + defs: Record +): NodePack[] { + const byPackId = new Map() + + for (const [className, def] of Object.entries(defs)) { + const source = getNodeSource(def.python_module, def.essentials_category) + if (source.type !== NodeSourceType.CustomNodes) { + continue + } + + const packId = def.python_module.split('.')[1]?.split('@')[0] + if (!packId) { + continue + } + + const existing = byPackId.get(packId) + const node = { className, def } + + if (existing) { + existing.nodes.push(node) + continue + } + + byPackId.set(packId, { + id: packId, + displayName: source.displayText, + nodes: [node] + }) + } + + return [...byPackId.values()].sort((a, b) => a.id.localeCompare(b.id)) +} diff --git a/packages/object-info-parser/src/helpers/sanitizeUserContent.ts b/packages/object-info-parser/src/helpers/sanitizeUserContent.ts new file mode 100644 index 0000000000..c7d18255fd --- /dev/null +++ b/packages/object-info-parser/src/helpers/sanitizeUserContent.ts @@ -0,0 +1,134 @@ +import type { + ComfyNodeDef, + ComfyInputsSpec, + InputSpec, + ComboInputSpec, + ComboInputSpecV2 +} from '../schemas/nodeDefSchema' +import { + isComboInputSpecV1, + isComboInputSpecV2 +} from '../schemas/nodeDefSchema' + +const USER_CONTENT_REGEX = + /\.(png|jpe?g|webp|gif|mp4|mov|webm|wav|mp3|flac|ogg|safetensors|ckpt|pt)$/i + +const KNOWN_USER_UPLOAD_NODES = new Set([ + 'LoadImage', + 'LoadImageMask', + 'LoadImageOutput', + 'LoadVideo', + 'LoadAudio' +]) + +export function sanitizeUserContent( + defs: Record +): Record { + const nextEntries = Object.entries(defs).map(([className, def]) => [ + className, + sanitizeNode(def) + ]) + return Object.fromEntries(nextEntries) as Record +} + +function sanitizeNode(def: ComfyNodeDef): ComfyNodeDef { + if (!def.input) return def + + const shouldClearAllComboOptions = + def.python_module === 'nodes' && KNOWN_USER_UPLOAD_NODES.has(def.name) + + return { + ...def, + input: { + ...def.input, + required: sanitizeInputSpecSection( + def.input.required, + shouldClearAllComboOptions + ), + optional: sanitizeInputSpecSection( + def.input.optional, + shouldClearAllComboOptions + ), + hidden: sanitizeHiddenSection( + def.input.hidden, + shouldClearAllComboOptions + ) + } + } +} + +function sanitizeInputSpecSection( + section: ComfyInputsSpec['required'] | ComfyInputsSpec['optional'], + forceEmpty: boolean +): ComfyInputsSpec['required'] | ComfyInputsSpec['optional'] { + if (!section) return section + + const nextEntries = Object.entries(section).map(([key, value]) => { + const sanitized = sanitizeInputSpec(value, forceEmpty) as InputSpec + return [key, sanitized] as const + }) + + return Object.fromEntries(nextEntries) +} + +function sanitizeHiddenSection( + section: ComfyInputsSpec['hidden'], + forceEmpty: boolean +): ComfyInputsSpec['hidden'] { + if (!section) return section + + const nextEntries = Object.entries(section).map(([key, value]) => [ + key, + sanitizeInputSpec(value, forceEmpty) + ]) + + return Object.fromEntries(nextEntries) +} + +function sanitizeInputSpec(inputSpec: unknown, forceEmpty: boolean): unknown { + if (!Array.isArray(inputSpec)) { + return inputSpec + } + + if (isComboInputSpecV1(inputSpec as InputSpec)) { + return sanitizeComboInputSpecV1(inputSpec as ComboInputSpec, forceEmpty) + } + + if (isComboInputSpecV2(inputSpec as InputSpec)) { + return sanitizeComboInputSpecV2(inputSpec as ComboInputSpecV2, forceEmpty) + } + + return inputSpec +} + +function sanitizeComboInputSpecV1( + inputSpec: ComboInputSpec, + forceEmpty: boolean +): ComboInputSpec { + const [comboValues, options] = inputSpec + const sanitizedValues = forceEmpty ? [] : filterComboValues(comboValues) + return [sanitizedValues, options] +} + +function sanitizeComboInputSpecV2( + inputSpec: ComboInputSpecV2, + forceEmpty: boolean +): ComboInputSpecV2 { + const [comboTag, options] = inputSpec + if (!options?.options) { + return inputSpec + } + + const nextOptions = { + ...options, + options: forceEmpty ? [] : filterComboValues(options.options) + } + + return [comboTag, nextOptions] +} + +function filterComboValues(values: (number | string)[]): (number | string)[] { + return values.filter((value) => + typeof value === 'string' ? !USER_CONTENT_REGEX.test(value) : true + ) +} diff --git a/packages/object-info-parser/src/index.ts b/packages/object-info-parser/src/index.ts new file mode 100644 index 0000000000..f512634cc9 --- /dev/null +++ b/packages/object-info-parser/src/index.ts @@ -0,0 +1,4 @@ +export * from './schemas/nodeDefSchema' +export * from './classifiers/nodeSource' +export * from './helpers/groupNodesByPack' +export * from './helpers/sanitizeUserContent' diff --git a/packages/object-info-parser/src/schemas/nodeDefSchema.ts b/packages/object-info-parser/src/schemas/nodeDefSchema.ts new file mode 100644 index 0000000000..86963dfee0 --- /dev/null +++ b/packages/object-info-parser/src/schemas/nodeDefSchema.ts @@ -0,0 +1,384 @@ +import { z } from 'zod' +import { fromZodError } from 'zod-validation-error' + +const CONTROL_OPTIONS = [ + 'fixed', + 'increment', + 'decrement', + 'randomize' +] as const +const RESULT_ITEM_TYPE = z.enum(['input', 'output', 'temp']) + +const zComboOption = z.union([z.string(), z.number()]) +const zRemoteWidgetConfig = z.object({ + route: z.string().url().or(z.string().startsWith('/')), + refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(), + response_key: z.string().optional(), + query_params: z.record(z.string(), z.string()).optional(), + refresh_button: z.boolean().optional(), + control_after_refresh: z.enum(['first', 'last']).optional(), + timeout: z.number().gte(0).optional(), + max_retries: z.number().gte(0).optional() +}) +const zMultiSelectOption = z.object({ + placeholder: z.string().optional(), + chip: z.boolean().optional() +}) + +export const zBaseInputOptions = z + .object({ + default: z.any().optional(), + defaultInput: z.boolean().optional(), + display_name: z.string().optional(), + forceInput: z.boolean().optional(), + tooltip: z.string().optional(), + socketless: z.boolean().optional(), + hidden: z.boolean().optional(), + advanced: z.boolean().optional(), + widgetType: z.string().optional(), + /** Backend-only properties. */ + rawLink: z.boolean().optional(), + lazy: z.boolean().optional() + }) + .passthrough() + +const zNumericInputOptions = zBaseInputOptions.extend({ + min: z.number().optional(), + max: z.number().optional(), + step: z.number().optional(), + /** Note: Many node authors are using INT/FLOAT to pass list of INT/FLOAT. */ + default: z.union([z.number(), z.array(z.number())]).optional(), + display: z.enum(['slider', 'number', 'knob', 'gradientslider']).optional() +}) + +export const zIntInputOptions = zNumericInputOptions.extend({ + /** + * If true, a linked widget will be added to the node to select the mode + * of `control_after_generate`. + */ + control_after_generate: z + .union([z.boolean(), z.enum(CONTROL_OPTIONS)]) + .optional() +}) + +export const zColorStop = z.object({ + offset: z.number(), + color: z.tuple([z.number(), z.number(), z.number()]) +}) + +export const zFloatInputOptions = zNumericInputOptions.extend({ + round: z.union([z.number(), z.literal(false)]).optional(), + gradient_stops: z.array(zColorStop).optional() +}) + +export const zBooleanInputOptions = zBaseInputOptions.extend({ + label_on: z.string().optional(), + label_off: z.string().optional(), + default: z.boolean().optional() +}) + +export const zStringInputOptions = zBaseInputOptions.extend({ + default: z.string().optional(), + multiline: z.boolean().optional(), + dynamicPrompts: z.boolean().optional(), + + // Multiline-only fields + defaultVal: z.string().optional(), + placeholder: z.string().optional() +}) + +export const zComboInputOptions = zBaseInputOptions.extend({ + control_after_generate: z + .union([z.boolean(), z.enum(CONTROL_OPTIONS)]) + .optional(), + image_upload: z.boolean().optional(), + image_folder: RESULT_ITEM_TYPE.optional(), + allow_batch: z.boolean().optional(), + video_upload: z.boolean().optional(), + audio_upload: z.boolean().optional(), + mesh_upload: z.boolean().optional(), + upload_subfolder: z.string().optional(), + animated_image_upload: z.boolean().optional(), + options: z.array(zComboOption).optional(), + remote: zRemoteWidgetConfig.optional(), + /** Whether the widget is a multi-select widget. */ + multi_select: zMultiSelectOption.optional() +}) + +const zIntInputSpec = z.tuple([z.literal('INT'), zIntInputOptions.optional()]) +const zFloatInputSpec = z.tuple([ + z.literal('FLOAT'), + zFloatInputOptions.optional() +]) +const zBooleanInputSpec = z.tuple([ + z.literal('BOOLEAN'), + zBooleanInputOptions.optional() +]) +const zStringInputSpec = z.tuple([ + z.literal('STRING'), + zStringInputOptions.optional() +]) +/** + * Legacy combo syntax. + * @deprecated Use `zComboInputSpecV2` instead. + */ +const zComboInputSpec = z.tuple([ + z.array(zComboOption), + zComboInputOptions.optional() +]) +const zComboInputSpecV2 = z.tuple([ + z.literal('COMBO'), + zComboInputOptions.optional() +]) + +export function isComboInputSpecV1( + inputSpec: InputSpec +): inputSpec is ComboInputSpec { + return Array.isArray(inputSpec[0]) +} + +export function isIntInputSpec( + inputSpec: InputSpec +): inputSpec is IntInputSpec { + return inputSpec[0] === 'INT' +} + +export function isFloatInputSpec( + inputSpec: InputSpec +): inputSpec is FloatInputSpec { + return inputSpec[0] === 'FLOAT' +} + +export function isComboInputSpecV2( + inputSpec: InputSpec +): inputSpec is ComboInputSpecV2 { + return inputSpec[0] === 'COMBO' +} + +export function isComboInputSpec( + inputSpec: InputSpec +): inputSpec is ComboInputSpec | ComboInputSpecV2 { + return isComboInputSpecV1(inputSpec) || isComboInputSpecV2(inputSpec) +} + +export function isMediaUploadComboInput(inputSpec: InputSpec): boolean { + const [inputName, inputOptions] = inputSpec + if (!inputOptions) return false + + const isUploadInput = + inputOptions['image_upload'] === true || + inputOptions['video_upload'] === true || + inputOptions['animated_image_upload'] === true + + return ( + isUploadInput && (isComboInputSpecV1(inputSpec) || inputName === 'COMBO') + ) +} + +/** + * Get the type of an input spec. + * + * @param inputSpec - The input spec to get the type of. + * @returns The type of the input spec. + */ +export function getInputSpecType(inputSpec: InputSpec): string { + return isComboInputSpec(inputSpec) ? 'COMBO' : inputSpec[0] +} + +/** + * Get the combo options from a combo input spec. + * + * @param inputSpec - The input spec to get the combo options from. + * @returns The combo options. + */ +export function getComboSpecComboOptions( + inputSpec: ComboInputSpec | ComboInputSpecV2 +): (number | string)[] { + return ( + (isComboInputSpecV2(inputSpec) ? inputSpec[1]?.options : inputSpec[0]) ?? [] + ) +} + +const excludedLiterals = new Set(['INT', 'FLOAT', 'BOOLEAN', 'STRING', 'COMBO']) +const zCustomInputSpec = z.tuple([ + z.string().refine((value) => !excludedLiterals.has(value)), + zBaseInputOptions.optional() +]) + +const zInputSpec = z.union([ + zIntInputSpec, + zFloatInputSpec, + zBooleanInputSpec, + zStringInputSpec, + zComboInputSpec, + zComboInputSpecV2, + zCustomInputSpec +]) + +export const zComfyInputsSpec = z.object({ + required: z.record(zInputSpec).optional(), + optional: z.record(zInputSpec).optional(), + // Frontend repo is not using it, but some custom nodes are using the + // hidden field to pass various values. + hidden: z.record(z.any()).optional() +}) + +const zComfyNodeDataType = z.string() +const zComfyComboOutput = z.array(zComboOption) +export const zComfyOutputTypesSpec = z.array( + z.union([zComfyNodeDataType, zComfyComboOutput]) +) + +/** + * Widget dependency with type information. + * Provides strong type enforcement for JSONata evaluation context. + */ +const zWidgetDependency = z.object({ + name: z.string(), + type: z.string() +}) + +export type WidgetDependency = z.infer + +/** + * Schema for price badge depends_on field. + * Specifies which widgets and inputs the pricing expression depends on. + * Widgets must be specified as objects with name and type. + */ +const zPriceBadgeDepends = z.object({ + widgets: z.array(zWidgetDependency).optional().default([]), + inputs: z.array(z.string()).optional().default([]), + /** + * Autogrow input group names to track. + * For each group, the count of connected inputs will be available in the + * JSONata context as `g.`. + * Example: `input_groups: ["reference_videos"]` makes `g.reference_videos` + * available with the count of connected inputs like `reference_videos.character1`, etc. + */ + input_groups: z.array(z.string()).optional().default([]) +}) + +/** + * Schema for price badge definition. + * Used to calculate and display pricing information for API nodes. + * The `expr` field contains a JSONata expression that returns a PricingResult. + */ +const zPriceBadge = z.object({ + engine: z.literal('jsonata').optional().default('jsonata'), + depends_on: zPriceBadgeDepends + .optional() + .default({ widgets: [], inputs: [], input_groups: [] }), + expr: z.string() +}) + +export type PriceBadge = z.infer + +export const zComfyNodeDef = z.object({ + input: zComfyInputsSpec.optional(), + output: zComfyOutputTypesSpec.optional(), + output_is_list: z.array(z.boolean()).optional(), + output_name: z.array(z.string()).optional(), + output_tooltips: z.array(z.string()).optional(), + output_matchtypes: z.array(z.string().optional()).optional(), + name: z.string(), + display_name: z.string(), + description: z.string(), + help: z.string().optional(), + category: z.string(), + main_category: z.string().optional(), + output_node: z.boolean(), + python_module: z.string(), + deprecated: z.boolean().optional(), + experimental: z.boolean().optional(), + dev_only: z.boolean().optional(), + /** + * Whether the node is an API node. Running API nodes requires login to + * Comfy Org account. + * https://docs.comfy.org/tutorials/api-nodes/overview + */ + api_node: z.boolean().optional(), + /** + * Specifies the order of inputs for each input category. + * Used to ensure consistent widget ordering regardless of JSON serialization. + * Keys are 'required', 'optional', etc., values are arrays of input names. + */ + input_order: z.record(z.array(z.string())).optional(), + /** + * Alternative names for search. Useful for synonyms, abbreviations, + * or old names after renaming a node. + */ + search_aliases: z.array(z.string()).optional(), + /** + * Price badge definition for API nodes. + * Contains a JSONata expression to calculate pricing based on widget values + * and input connectivity. + */ + price_badge: zPriceBadge.optional(), + /** Category for the Essentials tab. If set, the node appears in Essentials. */ + essentials_category: z.string().optional(), + /** Whether the blueprint is a global/installed blueprint (not user-created). */ + isGlobal: z.boolean().optional() +}) + +export const zAutogrowOptions = z.object({ + ...zBaseInputOptions.shape, + template: z.object({ + input: zComfyInputsSpec, + names: z.array(z.string()).optional(), + max: z.number().optional(), + //Backend defines as mandatory with min 1, Frontend is more forgiving + min: z.number().optional(), + prefix: z.string().optional() + }) +}) + +export const zDynamicComboInputSpec = z.tuple([ + z.literal('COMFY_DYNAMICCOMBO_V3'), + zBaseInputOptions.extend({ + options: z.array( + z.object({ + inputs: zComfyInputsSpec, + key: z.string() + }) + ) + }) +]) + +export const zMatchTypeOptions = z.object({ + ...zBaseInputOptions.shape, + type: z.literal('COMFY_MATCHTYPE_V3'), + template: z.object({ + allowed_types: z.string(), + template_id: z.string() + }) +}) + +// `/object_info` +export type ComfyInputsSpec = z.infer +export type ComfyOutputTypesSpec = z.infer +export type ComfyNodeDef = z.infer +export type RemoteWidgetConfig = z.infer + +export type ComboInputOptions = z.infer +export type NumericInputOptions = z.infer + +export type IntInputSpec = z.infer +export type FloatInputSpec = z.infer +export type ComboInputSpec = z.infer +export type ComboInputSpecV2 = z.infer +export type InputSpec = z.infer + +export function validateComfyNodeDef( + data: unknown, + onError: (error: string) => void = console.warn +): ComfyNodeDef | null { + const result = zComfyNodeDef.safeParse(data) + if (!result.success) { + const zodError = fromZodError(result.error) + onError( + `Invalid ComfyNodeDef: ${JSON.stringify(data)}\n${zodError.message}` + ) + return null + } + return result.data +} diff --git a/packages/object-info-parser/tsconfig.json b/packages/object-info-parser/tsconfig.json new file mode 100644 index 0000000000..ea72a25788 --- /dev/null +++ b/packages/object-info-parser/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*", "vitest.config.ts"] +} diff --git a/packages/object-info-parser/vitest.config.ts b/packages/object-info-parser/vitest.config.ts new file mode 100644 index 0000000000..911cf80d50 --- /dev/null +++ b/packages/object-info-parser/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/__tests__/**/*.test.ts'], + globals: false + } +}) diff --git a/packages/registry-types/package.json b/packages/registry-types/package.json index 096c8eede3..9cf838810e 100644 --- a/packages/registry-types/package.json +++ b/packages/registry-types/package.json @@ -6,7 +6,6 @@ "exports": { ".": "./src/comfyRegistryTypes.ts" }, - "packageManager": "pnpm@10.17.1", "nx": { "tags": [ "scope:shared", diff --git a/packages/registry-types/src/comfyRegistryTypes.ts b/packages/registry-types/src/comfyRegistryTypes.ts index 397e93815d..182adf6eea 100644 --- a/packages/registry-types/src/comfyRegistryTypes.ts +++ b/packages/registry-types/src/comfyRegistryTypes.ts @@ -2524,6 +2524,46 @@ export interface paths { patch?: never; trace?: never; }; + "/proxy/luma_2/generations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create a Luma Agents generation + * @description Submit an image generation or edit job. Returns immediately with an opaque job ID to poll via GET /proxy/luma_2/generations/{id}. + */ + post: operations["lumaAgentsCreateGeneration"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/proxy/luma_2/generations/{generation_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a Luma Agents generation + * @description Poll for generation status and output. On completion, the response includes presigned URLs to download the generated images. + */ + get: operations["lumaAgentsGetGeneration"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/proxy/pixverse/video/text/generate": { parameters: { query?: never; @@ -4014,6 +4054,26 @@ export interface paths { patch?: never; trace?: never; }; + "/proxy/seedance/visual-validate/groups": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List the caller's completed visual-validation groups + * @description Returns the caller's completed visual-validation groups (real-person H5 verification). Used to power the group selector in client UIs. Excludes virtual-library (AIGC) groups, which are not part of the public API surface. + */ + get: operations["seedanceListVisualValidationGroups"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/proxy/seedance/visual-validate/sessions/{session_id}": { parameters: { query?: never; @@ -4037,7 +4097,11 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + /** + * List the caller's assets across all owned groups + * @description Fans out to BytePlus ListAssets across the caller's completed verification groups, denormalizes the group label into each row, and returns a single flat list. Result is post-filtered by asset_type. Optional group_id narrows to one group. Hard caps: 5 pages × 100 assets per group, 1000 total assets. + */ + get: operations["seedanceListUserAssets"]; put?: never; post: operations["seedanceCreateAsset"]; delete?: never; @@ -9993,6 +10057,8 @@ export interface components { }; progress: number; create_time: number; + /** @description Actual credits consumed by the task. Present once status is finalized; 0 for failed tasks. */ + consumed_credit?: number; }; TripoSuccessTask: { /** @enum {integer} */ @@ -10357,6 +10423,88 @@ export interface components { /** @description The request of the generation */ request?: components["schemas"]["LumaGenerationRequest"] | components["schemas"]["LumaImageGenerationRequest"] | components["schemas"]["LumaUpscaleVideoGenerationRequest"] | components["schemas"]["LumaAudioGenerationRequest"]; }; + /** + * @description Output aspect ratio + * @enum {string} + */ + LumaAgentsAspectRatio: "3:1" | "2:1" | "16:9" | "3:2" | "1:1" | "2:3" | "9:16" | "1:2" | "1:3"; + /** + * @description Style preset + * @enum {string} + */ + LumaAgentsStyle: "auto" | "manga"; + /** + * @description Output image format + * @enum {string} + */ + LumaAgentsOutputFormat: "png" | "jpeg"; + /** + * @description The kind of generation to perform + * @enum {string} + */ + LumaAgentsGenerationType: "image" | "image_edit"; + /** + * @description Current state of the generation + * @enum {string} + */ + LumaAgentsState: "queued" | "processing" | "completed" | "failed"; + /** + * @description Machine-readable failure code for programmatic handling + * @enum {string} + */ + LumaAgentsFailureCode: "content_moderated" | "generation_failed" | "budget_exhausted" | "output_not_found"; + /** @description Reference image for style/content guidance or guided generation */ + LumaAgentsImageRef: { + /** @description Base64-encoded image data */ + data?: string; + /** @description MIME type (e.g. image/jpeg). Required with data. */ + media_type?: string; + /** @description Publicly accessible image URL */ + url?: string; + }; + /** @description The Luma Agents generation request object */ + LumaAgentsGenerationRequest: { + /** @description Text prompt */ + prompt: string; + aspect_ratio?: components["schemas"]["LumaAgentsAspectRatio"]; + /** @description Reference images for style/content guidance. Up to 9 for type 'image', up to 8 for type 'image_edit'. */ + image_ref?: components["schemas"]["LumaAgentsImageRef"][]; + /** @description Model to use */ + model?: string; + output_format?: components["schemas"]["LumaAgentsOutputFormat"]; + source?: components["schemas"]["LumaAgentsImageRef"]; + style?: components["schemas"]["LumaAgentsStyle"]; + type?: components["schemas"]["LumaAgentsGenerationType"]; + /** @description Enable web search grounding */ + web_search?: boolean; + }; + /** @description A generated output entry */ + LumaAgentsGenerationOutput: { + /** @description Media type (e.g. image) */ + type?: string; + /** @description Presigned URL (1hr expiry) */ + url?: string; + }; + /** @description Generation status and output */ + LumaAgentsGeneration: { + /** @description Generation identifier */ + id?: string; + /** @description Creation timestamp */ + created_at?: string; + /** @description Model used */ + model?: string; + state?: components["schemas"]["LumaAgentsState"]; + type?: components["schemas"]["LumaAgentsGenerationType"]; + failure_code?: components["schemas"]["LumaAgentsFailureCode"]; + /** @description Human-readable failure description */ + failure_reason?: string; + output?: components["schemas"]["LumaAgentsGenerationOutput"][]; + }; + /** @description The error object */ + LumaAgentsError: { + /** @description The error message */ + detail?: string; + }; PixverseTextVideoRequest: { /** @enum {string} */ aspect_ratio: "16:9" | "4:3" | "1:1" | "3:4" | "9:16"; @@ -12429,12 +12577,16 @@ export interface components { Rodin3DGenerateRequest: { /** @description The reference images to generate 3D Assets. */ images: string; + /** @description Text prompt used by the upstream Rodin API. Required by upstream for text-to-3D requests (no images uploaded); optional for image-to-3D requests where it acts as additional guidance. */ + prompt?: string; /** @description Seed. */ seed?: number; tier?: components["schemas"]["RodinTierType"]; material?: components["schemas"]["RodinMaterialType"]; quality?: components["schemas"]["RodinQualityType"]; mesh_mode?: components["schemas"]["RodinMeshModeType"]; + /** @description Optional list of upstream addon flags (e.g. "HighPack"). */ + addons?: string[]; }; /** * @description Rodin Tier para options @@ -13569,7 +13721,7 @@ export interface components { stream: boolean | null; }; /** @enum {string} */ - OpenAIModels: "gpt-4" | "gpt-4-0314" | "gpt-4-0613" | "gpt-4-32k" | "gpt-4-32k-0314" | "gpt-4-32k-0613" | "gpt-4-0125-preview" | "gpt-4-turbo" | "gpt-4-turbo-2024-04-09" | "gpt-4-turbo-preview" | "gpt-4-1106-preview" | "gpt-4-vision-preview" | "gpt-3.5-turbo" | "gpt-3.5-turbo-16k" | "gpt-3.5-turbo-0301" | "gpt-3.5-turbo-0613" | "gpt-3.5-turbo-1106" | "gpt-3.5-turbo-0125" | "gpt-3.5-turbo-16k-0613" | "gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" | "gpt-4.1-2025-04-14" | "gpt-4.1-mini-2025-04-14" | "gpt-4.1-nano-2025-04-14" | "o1" | "o1-mini" | "o1-preview" | "o1-pro" | "o1-2024-12-17" | "o1-preview-2024-09-12" | "o1-mini-2024-09-12" | "o1-pro-2025-03-19" | "o3" | "o3-mini" | "o3-2025-04-16" | "o3-mini-2025-01-31" | "o4-mini" | "o4-mini-2025-04-16" | "gpt-4o" | "gpt-4o-mini" | "gpt-4o-2024-11-20" | "gpt-4o-2024-08-06" | "gpt-4o-2024-05-13" | "gpt-4o-mini-2024-07-18" | "gpt-4o-audio-preview" | "gpt-4o-audio-preview-2024-10-01" | "gpt-4o-audio-preview-2024-12-17" | "gpt-4o-mini-audio-preview" | "gpt-4o-mini-audio-preview-2024-12-17" | "gpt-4o-search-preview" | "gpt-4o-mini-search-preview" | "gpt-4o-search-preview-2025-03-11" | "gpt-4o-mini-search-preview-2025-03-11" | "computer-use-preview" | "computer-use-preview-2025-03-11" | "gpt-5" | "gpt-5-mini" | "gpt-5-nano" | "chatgpt-4o-latest"; + OpenAIModels: "gpt-4" | "gpt-4-0314" | "gpt-4-0613" | "gpt-4-32k" | "gpt-4-32k-0314" | "gpt-4-32k-0613" | "gpt-4-0125-preview" | "gpt-4-turbo" | "gpt-4-turbo-2024-04-09" | "gpt-4-turbo-preview" | "gpt-4-1106-preview" | "gpt-4-vision-preview" | "gpt-3.5-turbo" | "gpt-3.5-turbo-16k" | "gpt-3.5-turbo-0301" | "gpt-3.5-turbo-0613" | "gpt-3.5-turbo-1106" | "gpt-3.5-turbo-0125" | "gpt-3.5-turbo-16k-0613" | "gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" | "gpt-4.1-2025-04-14" | "gpt-4.1-mini-2025-04-14" | "gpt-4.1-nano-2025-04-14" | "o1" | "o1-mini" | "o1-preview" | "o1-pro" | "o1-2024-12-17" | "o1-preview-2024-09-12" | "o1-mini-2024-09-12" | "o1-pro-2025-03-19" | "o3" | "o3-mini" | "o3-2025-04-16" | "o3-mini-2025-01-31" | "o4-mini" | "o4-mini-2025-04-16" | "gpt-4o" | "gpt-4o-mini" | "gpt-4o-2024-11-20" | "gpt-4o-2024-08-06" | "gpt-4o-2024-05-13" | "gpt-4o-mini-2024-07-18" | "gpt-4o-audio-preview" | "gpt-4o-audio-preview-2024-10-01" | "gpt-4o-audio-preview-2024-12-17" | "gpt-4o-mini-audio-preview" | "gpt-4o-mini-audio-preview-2024-12-17" | "gpt-4o-search-preview" | "gpt-4o-mini-search-preview" | "gpt-4o-search-preview-2025-03-11" | "gpt-4o-mini-search-preview-2025-03-11" | "computer-use-preview" | "computer-use-preview-2025-03-11" | "gpt-5" | "gpt-5-mini" | "gpt-5-nano" | "gpt-5.5" | "gpt-5.5-pro" | "chatgpt-4o-latest"; MoonvalleyTextToVideoInferenceParams: { /** * @description Height of the generated video in pixels @@ -14442,6 +14594,10 @@ export interface components { total_tokens?: number; }; }; + SeedanceCreateVisualValidateSessionRequest: { + /** @description Optional human-readable label for the asset group that will be created by this verification. Stored locally and returned by seedanceListVisualValidationGroups so users can identify their groups in selectors. */ + name?: string; + }; SeedanceCreateVisualValidateSessionResponse: { /** * Format: uuid @@ -14451,6 +14607,37 @@ export interface components { /** @description BytePlus-issued H5 liveness link. Open in a browser with camera access. Valid for ~120 seconds. */ h5_link: string; }; + SeedanceListVisualValidationGroupsResponse: { + groups: components["schemas"]["SeedanceVisualValidationGroup"][]; + }; + SeedanceListUserAssetsResponse: { + assets: components["schemas"]["SeedanceUserAsset"][]; + /** @description True if the global per-request asset cap was hit and older results were dropped. */ + truncated: boolean; + }; + SeedanceUserAsset: { + asset_id: string; + name?: string | null; + /** @description BytePlus access URL (~12h validity). Refreshed on each list call. */ + url?: string | null; + group_id: string; + /** @description Display label of the source group, denormalized for client-side search. */ + group_name: string; + /** @enum {string} */ + asset_type: "Image" | "Video" | "Audio"; + /** @enum {string} */ + status: "Active" | "Processing" | "Failed"; + /** Format: date-time */ + create_time: string; + }; + SeedanceVisualValidationGroup: { + /** @description BytePlus-issued asset group id. */ + group_id: string; + /** @description Display label. Caller-supplied at creation time when available; otherwise a server-generated fallback derived from the creation date. */ + name: string; + /** Format: date-time */ + created_at: string; + }; SeedanceGetVisualValidateSessionResponse: { /** Format: uuid */ session_id: string; @@ -14458,6 +14645,8 @@ export interface components { status: "pending" | "completed" | "failed"; /** @description Populated only when status == completed. This is the BytePlus Asset Group ID the user will upload assets into. */ group_id?: string | null; + /** @description Optional human-readable label provided when the session was created. */ + name?: string | null; error_code?: string | null; error_message?: string | null; }; @@ -26122,6 +26311,72 @@ export interface operations { }; }; }; + lumaAgentsCreateGeneration: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** @description The generation request object */ + requestBody: { + content: { + "application/json": components["schemas"]["LumaAgentsGenerationRequest"]; + }; + }; + responses: { + /** @description Generation accepted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LumaAgentsGeneration"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LumaAgentsError"]; + }; + }; + }; + }; + lumaAgentsGetGeneration: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The ID of the generation */ + generation_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Generation found */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LumaAgentsGeneration"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LumaAgentsError"]; + }; + }; + }; + }; PixverseGenerateTextVideo: { parameters: { query?: never; @@ -30275,7 +30530,11 @@ export interface operations { path?: never; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["SeedanceCreateVisualValidateSessionRequest"]; + }; + }; responses: { /** @description Verification session created */ 201: { @@ -30297,6 +30556,35 @@ export interface operations { }; }; }; + seedanceListVisualValidationGroups: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Visual-validation groups owned by the caller */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SeedanceListVisualValidationGroupsResponse"]; + }; + }; + /** @description Error 4xx/5xx */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; seedanceGetVisualValidateSession: { parameters: { query?: never; @@ -30329,6 +30617,40 @@ export interface operations { }; }; }; + seedanceListUserAssets: { + parameters: { + query: { + /** @description Asset type to return. */ + asset_type: "Image" | "Video"; + /** @description Narrow the listing to one group. Caller must own it. */ + group_id?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Assets owned by the caller */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SeedanceListUserAssetsResponse"]; + }; + }; + /** @description Error 4xx/5xx */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; seedanceCreateAsset: { parameters: { query?: never; diff --git a/packages/shared-frontend-utils/package.json b/packages/shared-frontend-utils/package.json index aa18c7a940..2f289a271d 100644 --- a/packages/shared-frontend-utils/package.json +++ b/packages/shared-frontend-utils/package.json @@ -18,6 +18,5 @@ }, "devDependencies": { "typescript": "catalog:" - }, - "packageManager": "pnpm@10.17.1" + } } diff --git a/packages/shared-frontend-utils/src/formatUtil.test.ts b/packages/shared-frontend-utils/src/formatUtil.test.ts index 3a1e6c877d..2c38d767d5 100644 --- a/packages/shared-frontend-utils/src/formatUtil.test.ts +++ b/packages/shared-frontend-utils/src/formatUtil.test.ts @@ -3,12 +3,17 @@ import { describe, expect, it } from 'vitest' import { appendWorkflowJsonExt, ensureWorkflowSuffix, + formatLocalizedMediumDate, + formatLocalizedNumber, + getFilePathSeparatorVariants, getFilenameDetails, getMediaTypeFromFilename, getPathDetails, highlightQuery, isCivitaiModelUrl, + isCivitaiUrl, isPreviewableMediaType, + joinFilePath, truncateFilename } from './formatUtil' @@ -83,9 +88,11 @@ describe('formatUtil', () => { describe('video files', () => { it('should identify video extensions correctly', () => { expect(getMediaTypeFromFilename('video.mp4')).toBe('video') + expect(getMediaTypeFromFilename('apple.m4v')).toBe('video') expect(getMediaTypeFromFilename('clip.webm')).toBe('video') expect(getMediaTypeFromFilename('movie.mov')).toBe('video') expect(getMediaTypeFromFilename('film.avi')).toBe('video') + expect(getMediaTypeFromFilename('episode.mkv')).toBe('video') }) }) @@ -105,6 +112,7 @@ describe('formatUtil', () => { expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D') expect(getMediaTypeFromFilename('binary.glb')).toBe('3D') expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D') + expect(getMediaTypeFromFilename('scan.ply')).toBe('3D') }) }) @@ -299,6 +307,42 @@ describe('formatUtil', () => { }) }) + describe('joinFilePath', () => { + it('joins subfolder and filename with normalized slash separators', () => { + expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe( + 'nested/folder/child/file.png' + ) + }) + + it('trims boundary separators without changing the filename body', () => { + expect(joinFilePath('/nested/folder/', '/file.png')).toBe( + 'nested/folder/file.png' + ) + }) + + it('returns the normalized filename when no subfolder is provided', () => { + expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png') + }) + + it('returns the normalized subfolder without a trailing slash when no filename is provided', () => { + expect(joinFilePath('nested\\folder', '')).toBe('nested/folder') + expect(joinFilePath('nested\\folder', null)).toBe('nested/folder') + }) + }) + + describe('getFilePathSeparatorVariants', () => { + it('returns slash and backslash variants for nested paths', () => { + expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([ + 'nested/folder/file.png', + 'nested\\folder\\file.png' + ]) + }) + + it('returns a single value when no separator is present', () => { + expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png']) + }) + }) + describe('appendWorkflowJsonExt', () => { it('appends .app.json when isApp is true', () => { expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json') @@ -381,6 +425,19 @@ describe('formatUtil', () => { }) }) + describe('isCivitaiUrl', () => { + it.for([ + { url: 'https://civitai.com/models/123', expected: true }, + { url: 'https://civitai.red/models/123', expected: true }, + { url: 'https://sub.civitai.com/models/123', expected: true }, + { url: 'https://sub.civitai.red/models/123', expected: true }, + { url: 'https://example.com/model', expected: false }, + { url: 'not-a-url', expected: false } + ])('$url → $expected', ({ url, expected }) => { + expect(isCivitaiUrl(url)).toBe(expected) + }) + }) + describe('isCivitaiModelUrl', () => { it('recognizes civitai.red model URLs', () => { expect( @@ -388,4 +445,34 @@ describe('formatUtil', () => { ).toBe(true) }) }) + + describe('formatLocalizedNumber', () => { + it('formats numbers using the given locale', () => { + expect(formatLocalizedNumber(2618646, 'en')).toBe('2,618,646') + expect(formatLocalizedNumber(2618646, 'de')).toBe('2.618.646') + }) + + it('returns an em-dash for undefined / NaN / Infinity', () => { + expect(formatLocalizedNumber(undefined, 'en')).toBe('—') + expect(formatLocalizedNumber(Number.NaN, 'en')).toBe('—') + expect(formatLocalizedNumber(Number.POSITIVE_INFINITY, 'en')).toBe('—') + }) + + it('formats zero as "0"', () => { + expect(formatLocalizedNumber(0, 'en')).toBe('0') + }) + }) + + describe('formatLocalizedMediumDate', () => { + it('formats an ISO date with the medium style', () => { + expect(formatLocalizedMediumDate('2026-04-19T00:00:00Z', 'en')).toMatch( + /Apr \d{1,2}, 2026/ + ) + }) + + it('returns an em-dash for undefined or unparseable input', () => { + expect(formatLocalizedMediumDate(undefined, 'en')).toBe('—') + expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—') + }) + }) }) diff --git a/packages/shared-frontend-utils/src/formatUtil.ts b/packages/shared-frontend-utils/src/formatUtil.ts index 3e52190092..01e52cdd4a 100644 --- a/packages/shared-frontend-utils/src/formatUtil.ts +++ b/packages/shared-frontend-utils/src/formatUtil.ts @@ -256,6 +256,31 @@ export function isValidUrl(url: string): boolean { } } +export function joinFilePath( + subfolder: string | null | undefined, + filename: string | null | undefined +): string { + const normalizedSubfolder = normalizeFilePathSeparators( + subfolder ?? '' + ).replace(/^\/+|\/+$/g, '') + const normalizedFilename = normalizeFilePathSeparators( + filename ?? '' + ).replace(/^\/+/g, '') + if (!normalizedSubfolder) return normalizedFilename + if (!normalizedFilename) return normalizedSubfolder + return `${normalizedSubfolder}/${normalizedFilename}` +} + +export function getFilePathSeparatorVariants(filepath: string): string[] { + const slashPath = normalizeFilePathSeparators(filepath) + const backslashPath = slashPath.replace(/\//g, '\\') + return slashPath === backslashPath ? [slashPath] : [slashPath, backslashPath] +} + +function normalizeFilePathSeparators(filepath: string): string { + return filepath.replace(/[\\/]+/g, '/') +} + /** * Parses a filepath into its filename and subfolder components. * @@ -274,8 +299,7 @@ export function parseFilePath(filepath: string): { } { if (!filepath?.trim()) return { filename: '', subfolder: '' } - const normalizedPath = filepath - .replace(/[\\/]+/g, '/') // Normalize path separators + const normalizedPath = normalizeFilePathSeparators(filepath) .replace(/^\//, '') // Remove leading slash .replace(/\/$/, '') // Remove trailing slash @@ -355,6 +379,22 @@ export const generateUUID = (): string => { }) } +const isCivitaiHost = (hostname: string): boolean => + hostname === 'civitai.com' || + hostname.endsWith('.civitai.com') || + hostname === 'civitai.red' || + hostname.endsWith('.civitai.red') + +/** + * Checks if a URL belongs to any Civitai domain (civitai.com or civitai.red). + * Use this for source-name detection; use `isCivitaiModelUrl` when the URL + * must also match a specific model API path format. + */ +export const isCivitaiUrl = (url: string): boolean => { + if (!isValidUrl(url)) return false + return isCivitaiHost(new URL(url).hostname.toLowerCase()) +} + /** * Checks if a URL is a Civitai model URL * @example @@ -367,17 +407,9 @@ export const isCivitaiModelUrl = (url: string): boolean => { if (!isValidUrl(url)) return false const urlObj = new URL(url) - const hostname = urlObj.hostname.toLowerCase() - const isCivitaiHost = - hostname === 'civitai.com' || - hostname.endsWith('.civitai.com') || - hostname === 'civitai.red' || - hostname.endsWith('.civitai.red') - if (!isCivitaiHost) { - return false - } - const pathname = urlObj.pathname + if (!isCivitaiHost(urlObj.hostname.toLowerCase())) return false + const pathname = urlObj.pathname return ( /^\/api\/download\/models\/(\d+)$/.test(pathname) || /^\/api\/v1\/models\/(\d+)$/.test(pathname) || @@ -557,9 +589,9 @@ const IMAGE_EXTENSIONS = [ 'tiff', 'svg' ] as const -const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const +const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const -const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz'] as const +const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz', 'ply'] as const const TEXT_EXTENSIONS = [ 'txt', 'md', @@ -651,3 +683,32 @@ export function formatTime(seconds: number): string { const secs = Math.floor(seconds % 60) return `${mins}:${secs.toString().padStart(2, '0')}` } + +/** + * Format a number with the given BCP-47 locale. + * Returns an em-dash for non-numeric, NaN, or infinite inputs. + */ +export function formatLocalizedNumber( + value: number | undefined, + locale: string +): string { + if (typeof value !== 'number' || !Number.isFinite(value)) return '—' + return new Intl.NumberFormat(locale).format(value) +} + +/** + * Format an ISO 8601 date string with the given BCP-47 locale using the + * `medium` date style (e.g. "Apr 19, 2026"). Returns an em-dash for missing + * or unparseable inputs. + */ +export function formatLocalizedMediumDate( + value: string | undefined, + locale: string +): string { + if (!value) return '—' + const timestamp = Date.parse(value) + if (Number.isNaN(timestamp)) return '—' + return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format( + timestamp + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bc5d8593b..76da91581d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,8 +91,8 @@ catalogs: specifier: ^10.32.1 version: 10.32.1 '@sparkjsdev/spark': - specifier: ^0.1.10 - version: 0.1.10 + specifier: ^2.1.0 + version: 2.1.0 '@storybook/addon-docs': specifier: ^10.2.10 version: 10.2.10 @@ -106,8 +106,8 @@ catalogs: specifier: ^10.2.10 version: 10.2.10 '@tailwindcss/vite': - specifier: ^4.2.0 - version: 4.2.0 + specifier: ^4.3.0 + version: 4.3.0 '@tanstack/vue-virtual': specifier: ^3.13.12 version: 3.13.12 @@ -138,9 +138,6 @@ catalogs: '@tiptap/extension-table-row': specifier: ^2.27.2 version: 2.27.2 - '@tiptap/pm': - specifier: 2.27.2 - version: 2.27.2 '@tiptap/starter-kit': specifier: ^2.27.2 version: 2.27.2 @@ -160,8 +157,11 @@ catalogs: specifier: ^7.7.0 version: 7.7.0 '@types/three': - specifier: ^0.169.0 - version: 0.169.0 + specifier: ^0.184.1 + version: 0.184.1 + '@vee-validate/zod': + specifier: ^4.15.1 + version: 4.15.1 '@vercel/analytics': specifier: ^2.0.1 version: 2.0.1 @@ -331,11 +331,14 @@ catalogs: specifier: ^16.26.1 version: 16.26.1 tailwindcss: - specifier: ^4.2.0 - version: 4.2.0 + specifier: ^4.3.0 + version: 4.3.0 tailwindcss-primeui: specifier: ^0.6.1 version: 0.6.1 + three: + specifier: ^0.184.0 + version: 0.184.0 tsx: specifier: ^4.15.6 version: 4.19.4 @@ -360,6 +363,9 @@ catalogs: unplugin-vue-components: specifier: ^30.0.0 version: 30.0.0 + vee-validate: + specifier: ^4.15.1 + version: 4.15.1 vite-plugin-dts: specifier: ^4.5.4 version: 4.5.4 @@ -411,6 +417,8 @@ catalogs: overrides: vite: ^8.0.0 + '@tiptap/pm': 2.27.2 + '@types/eslint': '-' importers: @@ -428,6 +436,12 @@ importers: '@comfyorg/design-system': specifier: workspace:* version: link:packages/design-system + '@comfyorg/fbx-exporter-three': + specifier: ^1.0.1 + version: 1.0.1(@types/three@0.184.1)(three@0.184.0) + '@comfyorg/object-info-parser': + specifier: workspace:* + version: link:packages/object-info-parser '@comfyorg/registry-types': specifier: workspace:* version: link:packages/registry-types @@ -469,7 +483,7 @@ importers: version: 10.32.1(pinia@3.0.4(typescript@5.9.3)(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3)) '@sparkjsdev/spark': specifier: 'catalog:' - version: 0.1.10 + version: 2.1.0(three@0.184.0) '@tanstack/vue-virtual': specifier: 'catalog:' version: 3.13.12(vue@3.5.13(typescript@5.9.3)) @@ -492,11 +506,14 @@ importers: specifier: 'catalog:' version: 2.27.2(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) '@tiptap/pm': - specifier: 'catalog:' + specifier: 2.27.2 version: 2.27.2 '@tiptap/starter-kit': specifier: 'catalog:' version: 2.27.2 + '@vee-validate/zod': + specifier: 'catalog:' + version: 4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76) '@vueuse/core': specifier: 'catalog:' version: 14.2.0(vue@3.5.13(typescript@5.9.3)) @@ -579,14 +596,17 @@ importers: specifier: ^7.7.2 version: 7.7.4 three: - specifier: ^0.170.0 - version: 0.170.0 + specifier: 'catalog:' + version: 0.184.0 tiptap-markdown: specifier: ^0.8.10 version: 0.8.10(@tiptap/core@2.27.2(@tiptap/pm@2.27.2)) typegpu: specifier: 'catalog:' version: 0.8.2 + vee-validate: + specifier: 'catalog:' + version: 4.15.1(vue@3.5.13(typescript@5.9.3)) vue: specifier: 'catalog:' version: 3.5.13(typescript@5.9.3) @@ -601,7 +621,7 @@ importers: version: 3.2.1(consola@3.4.2)(firebase@11.6.0)(vue@3.5.13(typescript@5.9.3)) wwobjloader2: specifier: 'catalog:' - version: 6.2.1(three@0.170.0) + version: 6.2.1(three@0.184.0) yjs: specifier: 'catalog:' version: 13.6.27 @@ -635,7 +655,7 @@ importers: version: 22.6.1(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.6.1)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) '@nx/vite': specifier: 'catalog:' - version: 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16) + version: 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vitest@4.0.16) '@pinia/testing': specifier: 'catalog:' version: 1.0.3(pinia@3.0.4(typescript@5.9.3)(vue@3.5.13(typescript@5.9.3))) @@ -647,7 +667,7 @@ importers: version: 4.6.0 '@storybook/addon-docs': specifier: 'catalog:' - version: 10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + version: 10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) '@storybook/addon-mcp': specifier: 'catalog:' version: 0.1.6(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) @@ -656,10 +676,10 @@ importers: version: 10.2.10(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vue@3.5.13(typescript@5.9.3)) '@storybook/vue3-vite': specifier: 'catalog:' - version: 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)) + version: 10.2.10(esbuild@0.27.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)) '@tailwindcss/vite': specifier: 'catalog:' - version: 4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + version: 4.3.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) '@testing-library/jest-dom': specifier: 'catalog:' version: 6.9.1 @@ -686,10 +706,10 @@ importers: version: 7.7.0 '@types/three': specifier: 'catalog:' - version: 0.169.0 + version: 0.184.1 '@vitejs/plugin-vue': specifier: 'catalog:' - version: 6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)) + version: 6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.0.16(vitest@4.0.16) @@ -710,13 +730,13 @@ importers: version: 10.1.8(eslint@9.39.1(jiti@2.6.1)) eslint-import-resolver-typescript: specifier: 'catalog:' - version: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) + version: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-better-tailwindcss: specifier: 'catalog:' - version: 4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.59.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.2.0)(typescript@5.9.3) + version: 4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.59.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.3.0)(typescript@5.9.3) eslint-plugin-import-x: specifier: 'catalog:' - version: 4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)) + version: 4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-oxlint: specifier: 'catalog:' version: 1.59.0(oxlint@1.59.0(oxlint-tsgolint@0.20.0)) @@ -794,7 +814,7 @@ importers: version: 7.1.0 rollup-plugin-visualizer: specifier: 'catalog:' - version: 6.0.4(rolldown@1.0.0-rc.9)(rollup@4.53.5) + version: 6.0.4(rolldown@1.0.0-rc.9) storybook: specifier: 'catalog:' version: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -803,7 +823,7 @@ importers: version: 16.26.1(typescript@5.9.3) tailwindcss: specifier: 'catalog:' - version: 4.2.0 + version: 4.3.0 tsx: specifier: 'catalog:' version: 4.19.4 @@ -827,19 +847,19 @@ importers: version: 11.1.0 vite: specifier: ^8.0.0 - version: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + version: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) vite-plugin-dts: specifier: 'catalog:' - version: 4.5.4(@types/node@24.10.4)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + version: 4.5.4(@types/node@24.10.4)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) vite-plugin-html: specifier: 'catalog:' - version: 3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + version: 3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) vite-plugin-vue-devtools: specifier: 'catalog:' - version: 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)) + version: 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)) vitest: specifier: 'catalog:' - version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) vue-component-type-helpers: specifier: 'catalog:' version: 3.2.6 @@ -897,10 +917,10 @@ importers: devDependencies: '@tailwindcss/vite': specifier: 'catalog:' - version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + version: 4.3.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) '@vitejs/plugin-vue': specifier: 'catalog:' - version: 6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)) + version: 6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)) dotenv: specifier: 'catalog:' version: 16.6.1 @@ -912,13 +932,13 @@ importers: version: 30.0.0(@babel/parser@7.29.0)(vue@3.5.13(typescript@5.9.3)) vite: specifier: ^8.0.0 - version: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + version: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) vite-plugin-html: specifier: 'catalog:' - version: 3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + version: 3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) vite-plugin-vue-devtools: specifier: 'catalog:' - version: 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)) + version: 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)) vue-tsc: specifier: 'catalog:' version: 3.2.5(typescript@5.9.3) @@ -931,6 +951,12 @@ importers: '@comfyorg/design-system': specifier: workspace:* version: link:../../packages/design-system + '@comfyorg/object-info-parser': + specifier: workspace:* + version: link:../../packages/object-info-parser + '@comfyorg/shared-frontend-utils': + specifier: workspace:* + version: link:../../packages/shared-frontend-utils '@comfyorg/tailwind-utils': specifier: workspace:* version: link:../../packages/tailwind-utils @@ -952,6 +978,9 @@ importers: posthog-js: specifier: 'catalog:' version: 1.358.1 + three: + specifier: 'catalog:' + version: 0.184.0 vue: specifier: 'catalog:' version: 3.5.13(typescript@5.9.3) @@ -964,19 +993,19 @@ importers: version: 0.9.8(prettier@3.7.4)(typescript@5.9.3) '@astrojs/vue': specifier: 'catalog:' - version: 5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2) + version: 5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.9.0) '@playwright/test': specifier: 'catalog:' version: 1.58.1 '@tailwindcss/vite': specifier: 'catalog:' - version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + version: 4.3.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) astro: specifier: 'catalog:' - version: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2) + version: 5.18.1(@types/node@25.0.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.9.0) tailwindcss: specifier: 'catalog:' - version: 4.2.0 + version: 4.3.0 tsx: specifier: 'catalog:' version: 4.19.4 @@ -985,7 +1014,7 @@ importers: version: 5.9.3 vitest: specifier: 'catalog:' - version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.7.0)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) packages/design-system: dependencies: @@ -994,20 +1023,20 @@ importers: version: 1.2.79 '@iconify/tailwind4': specifier: 'catalog:' - version: 1.2.1(tailwindcss@4.2.0) + version: 1.2.1(tailwindcss@4.3.0) '@iconify/utils': specifier: 'catalog:' version: 3.1.0 tailwindcss-primeui: specifier: 'catalog:' - version: 0.6.1(tailwindcss@4.2.0) + version: 0.6.1(tailwindcss@4.3.0) tw-animate-css: specifier: 'catalog:' version: 1.3.8 devDependencies: tailwindcss: specifier: 'catalog:' - version: 4.2.0 + version: 4.3.0 typescript: specifier: 'catalog:' version: 5.9.3 @@ -1022,6 +1051,22 @@ importers: specifier: 0.93.0 version: 0.93.0(magicast@0.5.1)(typescript@5.9.3) + packages/object-info-parser: + dependencies: + zod: + specifier: 'catalog:' + version: 3.25.76 + zod-validation-error: + specifier: 'catalog:' + version: 3.3.0(zod@3.25.76) + devDependencies: + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.7.0)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) + packages/registry-types: {} packages/shared-frontend-utils: @@ -1747,6 +1792,16 @@ packages: '@comfyorg/comfyui-electron-types@0.6.2': resolution: {integrity: sha512-r3By5Wbizq8jagUrhtcym79HYUTinsvoBnYkFFWbUmrURBWIaC0HduFVkRkI1PNdI76piW+JSOJJnw00YCVXeg==} + '@comfyorg/fbx-exporter-three@1.0.1': + resolution: {integrity: sha512-fQ1zBsgmmwfio6iEi91hRiFCr946yEgqR2DGh/UMismaLyUohiKGOJL/OnJQnW3+yne/PXxVoYgcortyumsO5w==} + engines: {node: '>=18'} + peerDependencies: + '@types/three': '>=0.160.0' + three: '>=0.160.0' + peerDependenciesMeta: + '@types/three': + optional: true + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -1795,6 +1850,9 @@ packages: '@cyberalien/svg-utils@1.1.1': resolution: {integrity: sha512-i05Cnpzeezf3eJAXLx7aFirTYYoq5D1XUItp1XsjqkerNJh//6BG9sOYHbiO7v0KYMvJAx3kosrZaRcNlQPdsA==} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@dual-bundle/import-meta-resolve@4.2.1': resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==} @@ -3731,130 +3789,6 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.53.5': - resolution: {integrity: sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.53.5': - resolution: {integrity: sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.53.5': - resolution: {integrity: sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.53.5': - resolution: {integrity: sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.53.5': - resolution: {integrity: sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.53.5': - resolution: {integrity: sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.53.5': - resolution: {integrity: sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.53.5': - resolution: {integrity: sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.53.5': - resolution: {integrity: sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.53.5': - resolution: {integrity: sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.53.5': - resolution: {integrity: sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-gnu@4.53.5': - resolution: {integrity: sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-gnu@4.53.5': - resolution: {integrity: sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.53.5': - resolution: {integrity: sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.53.5': - resolution: {integrity: sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.53.5': - resolution: {integrity: sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.53.5': - resolution: {integrity: sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openharmony-arm64@4.53.5': - resolution: {integrity: sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.53.5': - resolution: {integrity: sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.53.5': - resolution: {integrity: sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.53.5': - resolution: {integrity: sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.53.5': - resolution: {integrity: sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==} - cpu: [x64] - os: [win32] - - '@rtsao/scc@1.1.0': - resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - '@rushstack/node-core-library@5.20.1': resolution: {integrity: sha512-QvxZyh+RsTJ77JpQkS9K9lJujh6lj5WyMxieT0bdACtwqxEkGB9zCuSMX5UlXRweaIgSpu1ztdHmhV07fKUpMg==} peerDependencies: @@ -4017,8 +3951,10 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@sparkjsdev/spark@0.1.10': - resolution: {integrity: sha512-CiijdZQuj7KPDUqIZPiEqyUkJCYo1JqR05vq/V+ElxMwqR7L70ZuZDyIKcasjZHSiPB8pGRMH8HZGqUKO9aRPQ==} + '@sparkjsdev/spark@2.1.0': + resolution: {integrity: sha512-BRw+MuMzx0B3K8fDLQygt2OHEhYUV+41RX7btq9pZ3rCVrq42o57jW34VAIvC7JO/84DJh/1AutACV9ym6BfVg==} + peerDependencies: + three: '>=0.180.0' '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -4091,69 +4027,69 @@ packages: '@swc/helpers@0.5.17': resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} - '@tailwindcss/node@4.2.0': - resolution: {integrity: sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==} + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} - '@tailwindcss/oxide-android-arm64@4.2.0': - resolution: {integrity: sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw==} + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.2.0': - resolution: {integrity: sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA==} + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.2.0': - resolution: {integrity: sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg==} + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.2.0': - resolution: {integrity: sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw==} + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': - resolution: {integrity: sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg==} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': - resolution: {integrity: sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA==} + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.2.0': - resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==} + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.2.0': - resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==} + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.2.0': - resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==} + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.2.0': - resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==} + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -4164,24 +4100,24 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': - resolution: {integrity: sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA==} + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.2.0': - resolution: {integrity: sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ==} + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.2.0': - resolution: {integrity: sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ==} + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} engines: {node: '>= 20'} - '@tailwindcss/vite@4.2.0': - resolution: {integrity: sha512-da9mFCaHpoOgtQiWtDGIikTrSpUFBtIZCG3jy/u2BGV+l/X1/pbxzmIUxNt6JWm19N3WtGi4KlJdSH/Si83WOA==} + '@tailwindcss/vite@4.3.0': + resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} peerDependencies: vite: ^8.0.0 @@ -4224,7 +4160,7 @@ packages: '@tiptap/core@2.27.2': resolution: {integrity: sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==} peerDependencies: - '@tiptap/pm': ^2.7.0 + '@tiptap/pm': 2.27.2 '@tiptap/extension-blockquote@2.27.2': resolution: {integrity: sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==} @@ -4245,7 +4181,7 @@ packages: resolution: {integrity: sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==} peerDependencies: '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/pm': 2.27.2 '@tiptap/extension-code@2.27.2': resolution: {integrity: sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==} @@ -4261,13 +4197,13 @@ packages: resolution: {integrity: sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==} peerDependencies: '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/pm': 2.27.2 '@tiptap/extension-gapcursor@2.27.2': resolution: {integrity: sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==} peerDependencies: '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/pm': 2.27.2 '@tiptap/extension-hard-break@2.27.2': resolution: {integrity: sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==} @@ -4283,13 +4219,13 @@ packages: resolution: {integrity: sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==} peerDependencies: '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/pm': 2.27.2 '@tiptap/extension-horizontal-rule@2.27.2': resolution: {integrity: sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==} peerDependencies: '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/pm': 2.27.2 '@tiptap/extension-italic@2.27.2': resolution: {integrity: sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==} @@ -4300,7 +4236,7 @@ packages: resolution: {integrity: sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==} peerDependencies: '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/pm': 2.27.2 '@tiptap/extension-list-item@2.27.2': resolution: {integrity: sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==} @@ -4341,7 +4277,7 @@ packages: resolution: {integrity: sha512-pDbhOpT5phZkcsyPjGBQlXv0+0hmdrvqHJ+dJjkGcCtlfy2pHiEIhmIItOFagc7wXy8G9iUFZ9Jie4zvDf+brg==} peerDependencies: '@tiptap/core': ^2.7.0 - '@tiptap/pm': ^2.7.0 + '@tiptap/pm': 2.27.2 '@tiptap/extension-text-style@2.27.2': resolution: {integrity: sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==} @@ -4430,9 +4366,6 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/json5@0.0.29': - resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/jsonfile@6.1.4': resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} @@ -4496,8 +4429,8 @@ packages: '@types/stats.js@0.17.3': resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==} - '@types/three@0.169.0': - resolution: {integrity: sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==} + '@types/three@0.184.1': + resolution: {integrity: sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==} '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} @@ -4615,6 +4548,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -4724,6 +4658,11 @@ packages: peerDependencies: valibot: ^1.2.0 + '@vee-validate/zod@4.15.1': + resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==} + peerDependencies: + zod: ^3.24.0 + '@vercel/analytics@2.0.1': resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==} peerDependencies: @@ -5243,10 +5182,6 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} - array-includes@3.1.9: - resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} - engines: {node: '>= 0.4'} - array-iterate@2.0.1: resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} @@ -5254,22 +5189,6 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} - array.prototype.findlastindex@1.2.6: - resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} - engines: {node: '>= 0.4'} - - array.prototype.flat@1.3.3: - resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} - engines: {node: '>= 0.4'} - - array.prototype.flatmap@1.3.3: - resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} - engines: {node: '>= 0.4'} - - arraybuffer.prototype.slice@1.0.4: - resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} - engines: {node: '>= 0.4'} - asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -5296,10 +5215,6 @@ packages: engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} hasBin: true - async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} - async@3.2.5: resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} @@ -5804,18 +5719,6 @@ packages: resolution: {integrity: sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==} engines: {node: '>=20'} - data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} - - data-view-byte-length@1.0.2: - resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} - engines: {node: '>= 0.4'} - - data-view-byte-offset@1.0.1: - resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} - engines: {node: '>= 0.4'} - de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} @@ -5823,14 +5726,6 @@ packages: resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==} engines: {node: '>=18'} - debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -5940,10 +5835,6 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - doctrine@2.1.0: - resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} - engines: {node: '>=0.10.0'} - doctypes@1.1.0: resolution: {integrity: sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==} @@ -6052,6 +5943,10 @@ packages: resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} + enhanced-resolve@5.21.3: + resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==} + engines: {node: '>=10.13.0'} + enquirer@2.3.6: resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==} engines: {node: '>=8.6'} @@ -6092,10 +5987,6 @@ packages: error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} - es-abstract@1.24.1: - resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} - engines: {node: '>= 0.4'} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -6118,14 +6009,6 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es-shim-unscopables@1.1.0: - resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} - engines: {node: '>= 0.4'} - - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} - engines: {node: '>= 0.4'} - es-toolkit@1.39.10: resolution: {integrity: sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==} @@ -6184,9 +6067,6 @@ packages: unrs-resolver: optional: true - eslint-import-resolver-node@0.3.9: - resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} - eslint-import-resolver-typescript@4.4.4: resolution: {integrity: sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==} engines: {node: ^16.17.0 || >=18.6.0} @@ -6200,27 +6080,6 @@ packages: eslint-plugin-import-x: optional: true - eslint-module-utils@2.12.1: - resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true - eslint-plugin-better-tailwindcss@4.3.1: resolution: {integrity: sha512-b6xM31GukKz0WlgMD0tQdY/rLjf/9mWIk8EcA45ngOKJPPQf1C482xZtBlT357jyunQE2mOk4NlPcL4i9Pr85A==} engines: {node: ^20.19.0 || ^22.12.0 || >=23.0.0} @@ -6247,16 +6106,6 @@ packages: eslint-import-resolver-node: optional: true - eslint-plugin-import@2.32.0: - resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint-plugin-oxlint@1.59.0: resolution: {integrity: sha512-g0DR+xSsnUdyaMc2KAXvBVGWz5V4GwlAE1PM+ocKxl2Eg7YgOjkRLLbxgJ3bhYOhRLhD8F0X4DjJu2FSDvrvAg==} peerDependencies: @@ -6592,10 +6441,6 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} - engines: {node: '>= 0.4'} - functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} @@ -6603,10 +6448,6 @@ packages: resolution: {integrity: sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==} engines: {node: '>=10'} - generator-function@2.0.1: - resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} - engines: {node: '>= 0.4'} - gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -6631,10 +6472,6 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} - get-symbol-description@1.1.0: - resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} - engines: {node: '>= 0.4'} - get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} @@ -6691,10 +6528,6 @@ packages: resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==} engines: {node: '>=18'} - globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} - globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} @@ -6740,10 +6573,6 @@ packages: has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - has-proto@1.2.0: - resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} - engines: {node: '>= 0.4'} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -6939,10 +6768,6 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - is-async-function@2.1.1: - resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} - engines: {node: '>= 0.4'} - is-bigint@1.1.0: resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} engines: {node: '>= 0.4'} @@ -6966,10 +6791,6 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} - is-data-view@1.0.2: - resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} - engines: {node: '>= 0.4'} - is-date-object@1.1.0: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} @@ -6995,10 +6816,6 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-finalizationregistry@1.1.1: - resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} - engines: {node: '>= 0.4'} - is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -7007,10 +6824,6 @@ packages: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} - is-generator-function@1.1.2: - resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} - engines: {node: '>= 0.4'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -7049,10 +6862,6 @@ packages: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} - is-negative-zero@2.0.3: - resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} - engines: {node: '>= 0.4'} - is-npm@6.1.0: resolution: {integrity: sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -7107,10 +6916,6 @@ packages: resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} engines: {node: '>= 0.4'} - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} - is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} @@ -7123,10 +6928,6 @@ packages: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} - is-weakref@1.1.1: - resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} - engines: {node: '>= 0.4'} - is-weakset@2.0.4: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} @@ -7187,6 +6988,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} @@ -7255,10 +7060,6 @@ packages: resolution: {integrity: sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==} engines: {node: '>= 0.4'} - json5@1.0.2: - resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} - hasBin: true - json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -7363,73 +7164,36 @@ packages: lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} - lightningcss-android-arm64@1.31.1: - resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.31.1: - resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - lightningcss-darwin-arm64@1.32.0: resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.31.1: - resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - lightningcss-darwin-x64@1.32.0: resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.31.1: - resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - lightningcss-freebsd-x64@1.32.0: resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.31.1: - resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - lightningcss-linux-arm-gnueabihf@1.32.0: resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.31.1: - resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} @@ -7437,13 +7201,6 @@ packages: os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.31.1: - resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} @@ -7451,13 +7208,6 @@ packages: os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.31.1: - resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} @@ -7465,13 +7215,6 @@ packages: os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.31.1: - resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} @@ -7479,34 +7222,18 @@ packages: os: [linux] libc: [musl] - lightningcss-win32-arm64-msvc@1.31.1: - resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.31.1: - resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - lightningcss-win32-x64-msvc@1.32.0: resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.31.1: - resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} - engines: {node: '>= 12.0.0'} - lightningcss@1.32.0: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} @@ -7719,8 +7446,8 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - meshoptimizer@0.18.1: - resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==} + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -8016,18 +7743,6 @@ packages: resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} engines: {node: '>= 0.4'} - object.fromentries@2.0.8: - resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} - engines: {node: '>= 0.4'} - - object.groupby@1.0.3: - resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} - engines: {node: '>= 0.4'} - - object.values@1.2.1: - resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} - engines: {node: '>= 0.4'} - obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -8089,10 +7804,6 @@ packages: orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} - engines: {node: '>= 0.4'} - oxc-parser@0.121.0: resolution: {integrity: sha512-ek9o58+SCv6AV7nchiAcUJy1DNE2CC5WRdBcO0mF+W4oRjNQfPO7b3pLjTHSFECpHkKGOZSQxx3hk8viIL5YCg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8579,10 +8290,6 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} - reflect.getprototypeof@1.0.10: - resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} - engines: {node: '>= 0.4'} - regenerate-unicode-properties@10.2.2: resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} engines: {node: '>=4'} @@ -8745,11 +8452,6 @@ packages: rollup: optional: true - rollup@4.53.5: - resolution: {integrity: sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} @@ -8763,20 +8465,12 @@ packages: rxjs-interop@2.0.0: resolution: {integrity: sha512-ASEq9atUw7lualXB+knvgtvwkCEvGWV2gDD/8qnASzBkzEARZck9JAyxmY8OS6Nc1pCPEgDTKNcx+YqqYfzArw==} - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} - engines: {node: '>=0.4'} - safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safe-push-apply@1.0.0: - resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} - engines: {node: '>= 0.4'} - safe-regex-test@1.1.0: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} @@ -8815,6 +8509,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -8823,10 +8522,6 @@ packages: resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} engines: {node: '>= 0.4'} - set-proto@1.0.0: - resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} - engines: {node: '>= 0.4'} - setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} @@ -8990,18 +8685,6 @@ packages: string.fromcodepoint@0.2.1: resolution: {integrity: sha512-n69H31OnxSGSZyZbgBlvYIXlrMhJQ0dQAX1js1QDhpaUH6zmU3QYlj07bCwCNlPOu3oRXIubGPl2gDGnHsiCqg==} - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} - engines: {node: '>= 0.4'} - - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} - engines: {node: '>= 0.4'} - - string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} - string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -9125,11 +8808,11 @@ packages: peerDependencies: tailwindcss: '>=3.1.0' - tailwindcss@4.2.0: - resolution: {integrity: sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==} + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} tar-stream@2.2.0: @@ -9145,8 +8828,8 @@ packages: engines: {node: '>=10'} hasBin: true - three@0.170.0: - resolution: {integrity: sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==} + three@0.184.0: + resolution: {integrity: sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==} tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} @@ -9267,9 +8950,6 @@ packages: resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} engines: {node: '>=10.13.0'} - tsconfig-paths@3.15.0: - resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} - tsconfig-paths@4.2.0: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} @@ -9301,22 +8981,6 @@ packages: resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==} engines: {node: '>=20'} - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} - - typed-array-byte-length@1.0.3: - resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} - engines: {node: '>= 0.4'} - - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} - engines: {node: '>= 0.4'} - - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} - engines: {node: '>= 0.4'} - typed-binary@4.3.2: resolution: {integrity: sha512-HT3pIBM2njCZUmeczDaQUUErGiM6GXFCqMsHegE12HCoBtvHCkfR10JJni0TeGOTnLilTd6YFyj+YhflqQDrDQ==} @@ -9364,10 +9028,6 @@ packages: resolution: {integrity: sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w==} engines: {node: '>=14'} - unbox-primitive@1.1.0: - resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} - engines: {node: '>= 0.4'} - uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} @@ -9596,6 +9256,11 @@ packages: typescript: optional: true + vee-validate@4.15.1: + resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==} + peerDependencies: + vue: ^3.4.26 + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -9861,8 +9526,8 @@ packages: vue-component-type-helpers@3.2.6: resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==} - vue-component-type-helpers@3.2.7: - resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==} + vue-component-type-helpers@3.3.1: + resolution: {integrity: sha512-pu58kqxmVyEH6VfNYW1UyEfR3XAnJ27ZXT3yzXxxpjLxVzAbyC35Zk/nm/RMs7ijWnJNSd9fWkeex2OhUsx3MA==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -10007,10 +9672,6 @@ packages: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} - which-builtin-type@1.2.1: - resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} - engines: {node: '>= 0.4'} - which-collection@1.0.2: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} @@ -10158,6 +9819,11 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -10439,14 +10105,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/vue@5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2)': + '@astrojs/vue@5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.9.0)': dependencies: - '@vitejs/plugin-vue': 5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)) - '@vitejs/plugin-vue-jsx': 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)) + '@vitejs/plugin-vue': 5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)) + '@vitejs/plugin-vue-jsx': 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)) '@vue/compiler-sfc': 3.5.28 - astro: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2) - vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) - vite-plugin-vue-devtools: 7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)) + astro: 5.18.1(@types/node@25.0.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.9.0) + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) + vite-plugin-vue-devtools: 7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)) vue: 3.5.13(typescript@5.9.3) transitivePeerDependencies: - '@nuxt/kit' @@ -10467,7 +10133,7 @@ snapshots: '@astrojs/yaml2ts@0.2.3': dependencies: - yaml: 2.8.2 + yaml: 2.9.0 '@atlaskit/pragmatic-drag-and-drop@1.3.1': dependencies: @@ -11214,6 +10880,13 @@ snapshots: '@comfyorg/comfyui-electron-types@0.6.2': {} + '@comfyorg/fbx-exporter-three@1.0.1(@types/three@0.184.1)(three@0.184.0)': + dependencies: + fflate: 0.8.2 + three: 0.184.0 + optionalDependencies: + '@types/three': 0.184.1 + '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -11249,6 +10922,8 @@ snapshots: dependencies: '@iconify/types': 2.0.0 + '@dimforge/rapier3d-compat@0.12.0': {} + '@dual-bundle/import-meta-resolve@4.2.1': {} '@emmetio/abbreviation@2.3.3': @@ -11913,12 +11588,12 @@ snapshots: '@iconify/types': 2.0.0 pathe: 1.1.2 - '@iconify/tailwind4@1.2.1(tailwindcss@4.2.0)': + '@iconify/tailwind4@1.2.1(tailwindcss@4.3.0)': dependencies: '@iconify/tools': 5.0.3 '@iconify/types': 2.0.0 '@iconify/utils': 3.1.0 - tailwindcss: 4.2.0 + tailwindcss: 4.3.0 '@iconify/tools@5.0.3': dependencies: @@ -12467,11 +12142,11 @@ snapshots: - typescript - verdaccio - '@nx/vite@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)': + '@nx/vite@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vitest@4.0.16)': dependencies: '@nx/devkit': 22.6.1(nx@22.6.1) '@nx/js': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1) - '@nx/vitest': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16) + '@nx/vitest': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vitest@4.0.16) '@phenomnomnominal/tsquery': 6.1.4(typescript@5.9.3) ajv: 8.18.0 enquirer: 2.3.6 @@ -12479,8 +12154,8 @@ snapshots: semver: 7.7.4 tsconfig-paths: 4.2.0 tslib: 2.8.1 - vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) - vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) transitivePeerDependencies: - '@babel/traverse' - '@swc-node/register' @@ -12491,7 +12166,7 @@ snapshots: - typescript - verdaccio - '@nx/vitest@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)': + '@nx/vitest@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vitest@4.0.16)': dependencies: '@nx/devkit': 22.6.1(nx@22.6.1) '@nx/js': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1) @@ -12499,8 +12174,8 @@ snapshots: semver: 7.7.4 tslib: 2.8.1 optionalDependencies: - vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) - vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) transitivePeerDependencies: - '@babel/traverse' - '@swc-node/register' @@ -13024,82 +12699,11 @@ snapshots: estree-walker: 2.0.2 picomatch: 2.3.1 - '@rollup/pluginutils@5.3.0(rollup@4.53.5)': + '@rollup/pluginutils@5.3.0': dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 picomatch: 4.0.3 - optionalDependencies: - rollup: 4.53.5 - - '@rollup/rollup-android-arm-eabi@4.53.5': - optional: true - - '@rollup/rollup-android-arm64@4.53.5': - optional: true - - '@rollup/rollup-darwin-arm64@4.53.5': - optional: true - - '@rollup/rollup-darwin-x64@4.53.5': - optional: true - - '@rollup/rollup-freebsd-arm64@4.53.5': - optional: true - - '@rollup/rollup-freebsd-x64@4.53.5': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.53.5': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.53.5': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.53.5': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.53.5': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.53.5': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.53.5': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.53.5': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.53.5': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.53.5': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.53.5': - optional: true - - '@rollup/rollup-linux-x64-musl@4.53.5': - optional: true - - '@rollup/rollup-openharmony-arm64@4.53.5': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.53.5': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.53.5': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.53.5': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.53.5': - optional: true - - '@rtsao/scc@1.1.0': - optional: true '@rushstack/node-core-library@5.20.1(@types/node@24.10.4)': dependencies: @@ -13283,16 +12887,17 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@sparkjsdev/spark@0.1.10': + '@sparkjsdev/spark@2.1.0(three@0.184.0)': dependencies: fflate: 0.8.2 + three: 0.184.0 '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))': + '@storybook/addon-docs@10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.1.9)(react@19.2.4) - '@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + '@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) '@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@storybook/react-dom-shim': 10.2.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) react: 19.2.4 @@ -13318,25 +12923,24 @@ snapshots: - '@tmcp/auth' - typescript - '@storybook/builder-vite@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))': + '@storybook/builder-vite@10.2.10(esbuild@0.27.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))': dependencies: - '@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + '@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ts-dedent: 2.2.0 - vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))': + '@storybook/csf-plugin@10.2.10(esbuild@0.27.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))': dependencies: storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) unplugin: 2.3.11 optionalDependencies: esbuild: 0.27.3 - rollup: 4.53.5 - vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) '@storybook/global@5.0.0': {} @@ -13361,14 +12965,14 @@ snapshots: react-dom: 19.2.4(react@19.2.4) storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@storybook/vue3-vite@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))': + '@storybook/vue3-vite@10.2.10(esbuild@0.27.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))': dependencies: - '@storybook/builder-vite': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + '@storybook/builder-vite': 10.2.10(esbuild@0.27.3)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) '@storybook/vue3': 10.2.10(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vue@3.5.13(typescript@5.9.3)) magic-string: 0.30.21 storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) typescript: 5.9.3 - vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) vue-component-meta: 2.2.12(typescript@5.9.3) vue-docgen-api: 4.79.2(vue@3.5.13(typescript@5.9.3)) transitivePeerDependencies: @@ -13383,86 +12987,86 @@ snapshots: storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) type-fest: 2.19.0 vue: 3.5.13(typescript@5.9.3) - vue-component-type-helpers: 3.2.7 + vue-component-type-helpers: 3.3.1 '@swc/helpers@0.5.17': dependencies: tslib: 2.8.1 - '@tailwindcss/node@4.2.0': + '@tailwindcss/node@4.3.0': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.19.0 - jiti: 2.6.1 - lightningcss: 1.31.1 + enhanced-resolve: 5.21.3 + jiti: 2.7.0 + lightningcss: 1.32.0 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.2.0 + tailwindcss: 4.3.0 - '@tailwindcss/oxide-android-arm64@4.2.0': + '@tailwindcss/oxide-android-arm64@4.3.0': optional: true - '@tailwindcss/oxide-darwin-arm64@4.2.0': + '@tailwindcss/oxide-darwin-arm64@4.3.0': optional: true - '@tailwindcss/oxide-darwin-x64@4.2.0': + '@tailwindcss/oxide-darwin-x64@4.3.0': optional: true - '@tailwindcss/oxide-freebsd-x64@4.2.0': + '@tailwindcss/oxide-freebsd-x64@4.3.0': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.2.0': + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.2.0': + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.2.0': + '@tailwindcss/oxide-linux-x64-musl@4.3.0': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.2.0': + '@tailwindcss/oxide-wasm32-wasi@4.3.0': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.2.0': + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': optional: true - '@tailwindcss/oxide@4.2.0': + '@tailwindcss/oxide@4.3.0': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.0 - '@tailwindcss/oxide-darwin-arm64': 4.2.0 - '@tailwindcss/oxide-darwin-x64': 4.2.0 - '@tailwindcss/oxide-freebsd-x64': 4.2.0 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.0 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.0 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.0 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.0 - '@tailwindcss/oxide-linux-x64-musl': 4.2.0 - '@tailwindcss/oxide-wasm32-wasi': 4.2.0 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.0 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.0 + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 - '@tailwindcss/vite@4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))': + '@tailwindcss/vite@4.3.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))': dependencies: - '@tailwindcss/node': 4.2.0 - '@tailwindcss/oxide': 4.2.0 - tailwindcss: 4.2.0 - vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) - '@tailwindcss/vite@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))': + '@tailwindcss/vite@4.3.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))': dependencies: - '@tailwindcss/node': 4.2.0 - '@tailwindcss/oxide': 4.2.0 - tailwindcss: 4.2.0 - vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) '@tanstack/virtual-core@3.13.12': {} @@ -13740,9 +13344,6 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/json5@0.0.29': - optional: true - '@types/jsonfile@6.1.4': dependencies: '@types/node': 25.0.3 @@ -13812,14 +13413,14 @@ snapshots: '@types/stats.js@0.17.3': {} - '@types/three@0.169.0': + '@types/three@0.184.1': dependencies: + '@dimforge/rapier3d-compat': 0.12.0 '@tweenjs/tween.js': 23.1.3 '@types/stats.js': 0.17.3 '@types/webxr': 0.5.20 - '@webgpu/types': 0.1.66 fflate: 0.8.2 - meshoptimizer: 0.18.1 + meshoptimizer: 1.1.1 '@types/tough-cookie@4.0.5': {} @@ -13937,7 +13538,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 minimatch: 9.0.5 - semver: 7.7.4 + semver: 7.8.0 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -14041,38 +13642,46 @@ snapshots: dependencies: valibot: 1.2.0(typescript@5.9.3) + '@vee-validate/zod@4.15.1(vue@3.5.13(typescript@5.9.3))(zod@3.25.76)': + dependencies: + type-fest: 4.41.0 + vee-validate: 4.15.1(vue@3.5.13(typescript@5.9.3)) + zod: 3.25.76 + transitivePeerDependencies: + - vue + '@vercel/analytics@2.0.1(react@19.2.4)(vue-router@4.4.3(vue@3.5.13(typescript@5.9.3)))(vue@3.5.13(typescript@5.9.3))': optionalDependencies: react: 19.2.4 vue: 3.5.13(typescript@5.9.3) vue-router: 4.4.3(vue@3.5.13(typescript@5.9.3)) - '@vitejs/plugin-vue-jsx@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))': + '@vitejs/plugin-vue-jsx@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) '@rolldown/pluginutils': 1.0.0-rc.9 '@vue/babel-plugin-jsx': 1.4.0(@babel/core@7.29.0) - vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) vue: 3.5.13(typescript@5.9.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))': + '@vitejs/plugin-vue@5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))': dependencies: - vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) vue: 3.5.13(typescript@5.9.3) - '@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.53 - vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) vue: 3.5.13(typescript@5.9.3) - '@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.53 - vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) vue: 3.5.13(typescript@5.9.3) '@vitest/coverage-v8@4.0.16(vitest@4.0.16)': @@ -14088,7 +13697,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) transitivePeerDependencies: - supports-color @@ -14109,21 +13718,21 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))': + '@vitest/mocker@4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.0.16 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) - '@vitest/mocker@4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))': + '@vitest/mocker@4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.0.16 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -14159,7 +13768,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) '@vitest/utils@3.2.4': dependencies: @@ -14334,38 +13943,38 @@ snapshots: dependencies: '@vue/devtools-kit': 7.7.9 - '@vue/devtools-core@7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))': + '@vue/devtools-core@7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))': dependencies: '@vue/devtools-kit': 7.7.9 '@vue/devtools-shared': 7.7.9 mitt: 3.0.1 nanoid: 5.1.5 pathe: 2.0.3 - vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) vue: 3.5.13(typescript@5.9.3) transitivePeerDependencies: - vite - '@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))': + '@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))': dependencies: '@vue/devtools-kit': 8.0.5 '@vue/devtools-shared': 8.0.5 mitt: 3.0.1 nanoid: 5.1.5 pathe: 2.0.3 - vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) vue: 3.5.13(typescript@5.9.3) transitivePeerDependencies: - vite - '@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))': + '@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))': dependencies: '@vue/devtools-kit': 8.0.5 '@vue/devtools-shared': 8.0.5 mitt: 3.0.1 nanoid: 5.1.5 pathe: 2.0.3 - vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) vue: 3.5.13(typescript@5.9.3) transitivePeerDependencies: - vite @@ -14701,60 +14310,10 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 - array-includes@3.1.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - is-string: 1.1.1 - math-intrinsics: 1.1.0 - optional: true - array-iterate@2.0.1: {} array-union@2.1.0: {} - array.prototype.findlastindex@1.2.6: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-shim-unscopables: 1.1.0 - optional: true - - array.prototype.flat@1.3.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-shim-unscopables: 1.1.0 - optional: true - - array.prototype.flatmap@1.3.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-shim-unscopables: 1.1.0 - optional: true - - arraybuffer.prototype.slice@1.0.4: - dependencies: - array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - is-array-buffer: 3.0.5 - optional: true - asap@2.0.6: {} assert-never@1.4.0: {} @@ -14773,7 +14332,7 @@ snapshots: astral-regex@2.0.0: {} - astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2): + astro@5.18.1(@types/node@25.0.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.9.0): dependencies: '@astrojs/compiler': 2.13.1 '@astrojs/internal-helpers': 0.7.6 @@ -14781,7 +14340,7 @@ snapshots: '@astrojs/telemetry': 3.3.0 '@capsizecss/unpack': 4.0.0 '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.3.0(rollup@4.53.5) + '@rollup/pluginutils': 5.3.0 acorn: 8.16.0 aria-query: 5.3.2 axobject-query: 4.1.0 @@ -14818,7 +14377,7 @@ snapshots: picomatch: 4.0.3 prompts: 2.4.2 rehype: 13.0.2 - semver: 7.7.4 + semver: 7.8.0 shiki: 3.23.0 smol-toml: 1.6.1 svgo: 4.0.0 @@ -14830,8 +14389,8 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.4 vfile: 6.0.3 - vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) - vitefu: 1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) + vitefu: 1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 21.1.1 yocto-spinner: 0.2.3 @@ -14875,9 +14434,6 @@ snapshots: - uploadthing - yaml - async-function@1.0.0: - optional: true - async@3.2.5: {} asynckit@0.4.0: {} @@ -15062,7 +14618,7 @@ snapshots: dotenv: 17.3.1 exsolve: 1.0.8 giget: 2.0.0 - jiti: 2.6.1 + jiti: 2.7.0 ohash: 2.0.11 pathe: 2.0.3 perfect-debounce: 2.0.0 @@ -15406,38 +14962,12 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 15.1.0 - data-view-buffer@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - optional: true - - data-view-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - optional: true - - data-view-byte-offset@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - optional: true - de-indent@1.0.2: {} debounce-fn@6.0.0: dependencies: mimic-function: 5.0.1 - debug@3.2.7: - dependencies: - ms: 2.1.3 - optional: true - debug@4.4.3: dependencies: ms: 2.1.3 @@ -15545,11 +15075,6 @@ snapshots: dlv@1.1.3: {} - doctrine@2.1.0: - dependencies: - esutils: 2.0.3 - optional: true - doctypes@1.1.0: {} dom-accessibility-api@0.5.16: {} @@ -15630,7 +15155,7 @@ snapshots: '@one-ini/wasm': 0.1.1 commander: 10.0.1 minimatch: 9.0.1 - semver: 7.7.4 + semver: 7.8.0 eight-colors@1.3.3: {} @@ -15658,7 +15183,12 @@ snapshots: enhanced-resolve@5.19.0: dependencies: graceful-fs: 4.2.11 - tapable: 2.3.0 + tapable: 2.3.3 + + enhanced-resolve@5.21.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 enquirer@2.3.6: dependencies: @@ -15686,64 +15216,6 @@ snapshots: error-stack-parser-es@1.0.5: {} - es-abstract@1.24.1: - dependencies: - array-buffer-byte-length: 1.0.2 - arraybuffer.prototype.slice: 1.0.4 - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - data-view-buffer: 1.0.2 - data-view-byte-length: 1.0.2 - data-view-byte-offset: 1.0.1 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - get-symbol-description: 1.1.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - internal-slot: 1.1.0 - is-array-buffer: 3.0.5 - is-callable: 1.2.7 - is-data-view: 1.0.2 - is-negative-zero: 2.0.3 - is-regex: 1.2.1 - is-set: 2.0.3 - is-shared-array-buffer: 1.0.4 - is-string: 1.1.1 - is-typed-array: 1.1.15 - is-weakref: 1.1.1 - math-intrinsics: 1.1.0 - object-inspect: 1.13.4 - object-keys: 1.1.1 - object.assign: 4.1.7 - own-keys: 1.0.1 - regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 - safe-push-apply: 1.0.0 - safe-regex-test: 1.1.0 - set-proto: 1.0.0 - stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.10 - string.prototype.trimend: 1.0.9 - string.prototype.trimstart: 1.0.8 - typed-array-buffer: 1.0.3 - typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 - unbox-primitive: 1.1.0 - which-typed-array: 1.1.20 - optional: true - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -15773,18 +15245,6 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - es-shim-unscopables@1.1.0: - dependencies: - hasown: 2.0.2 - optional: true - - es-to-primitive@1.3.0: - dependencies: - is-callable: 1.2.7 - is-date-object: 1.1.0 - is-symbol: 1.1.1 - optional: true - es-toolkit@1.39.10: {} esbuild@0.25.5: @@ -15872,16 +15332,7 @@ snapshots: optionalDependencies: unrs-resolver: 1.11.1 - eslint-import-resolver-node@0.3.9: - dependencies: - debug: 3.2.7 - is-core-module: 2.16.1 - resolve: 1.22.11 - transitivePeerDependencies: - - supports-color - optional: true - - eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)): + eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 4.4.3 eslint: 9.39.1(jiti@2.6.1) @@ -15892,24 +15343,11 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1(jiti@2.6.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.1(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)))(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - optional: true - - eslint-plugin-better-tailwindcss@4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.59.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.2.0)(typescript@5.9.3): + eslint-plugin-better-tailwindcss@4.3.1(eslint@9.39.1(jiti@2.6.1))(oxlint@1.59.0(oxlint-tsgolint@0.20.0))(tailwindcss@4.3.0)(typescript@5.9.3): dependencies: '@eslint/css-tree': 3.6.9 '@valibot/to-json-schema': 1.5.0(valibot@1.2.0(typescript@5.9.3)) @@ -15917,7 +15355,7 @@ snapshots: jiti: 2.6.1 synckit: 0.11.12 tailwind-csstree: 0.1.4 - tailwindcss: 4.2.0 + tailwindcss: 4.3.0 tsconfig-paths-webpack-plugin: 4.2.0 valibot: 1.2.0(typescript@5.9.3) optionalDependencies: @@ -15926,7 +15364,7 @@ snapshots: transitivePeerDependencies: - typescript - eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)): dependencies: '@typescript-eslint/types': 8.56.0 comment-parser: 1.4.1 @@ -15940,40 +15378,9 @@ snapshots: unrs-resolver: 1.11.1 optionalDependencies: '@typescript-eslint/utils': 8.56.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1(jiti@2.6.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.39.1(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.1(jiti@2.6.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - optional: true - eslint-plugin-oxlint@1.59.0(oxlint@1.59.0(oxlint-tsgolint@0.20.0)): dependencies: jsonc-parser: 3.3.1 @@ -16379,23 +15786,10 @@ snapshots: function-bind@1.1.2: {} - function.prototype.name@1.1.8: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - functions-have-names: 1.2.3 - hasown: 2.0.2 - is-callable: 1.2.7 - optional: true - functions-have-names@1.2.3: {} fuse.js@7.0.0: {} - generator-function@2.0.1: - optional: true - gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -16425,13 +15819,6 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 - get-symbol-description@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - optional: true - get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 @@ -16497,12 +15884,6 @@ snapshots: globals@17.4.0: {} - globalthis@1.0.4: - dependencies: - define-properties: 1.2.1 - gopd: 1.2.0 - optional: true - globby@11.1.0: dependencies: array-union: 2.1.0 @@ -16557,11 +15938,6 @@ snapshots: dependencies: es-define-property: 1.0.1 - has-proto@1.2.0: - dependencies: - dunder-proto: 1.0.1 - optional: true - has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -16821,15 +16197,6 @@ snapshots: is-arrayish@0.2.1: {} - is-async-function@2.1.1: - dependencies: - async-function: 1.0.0 - call-bound: 1.0.4 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - optional: true - is-bigint@1.1.0: dependencies: has-bigints: 1.1.0 @@ -16853,13 +16220,6 @@ snapshots: dependencies: hasown: 2.0.2 - is-data-view@1.0.2: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - is-typed-array: 1.1.15 - optional: true - is-date-object@1.1.0: dependencies: call-bound: 1.0.4 @@ -16878,26 +16238,12 @@ snapshots: is-extglob@2.1.1: {} - is-finalizationregistry@1.1.1: - dependencies: - call-bound: 1.0.4 - optional: true - is-fullwidth-code-point@3.0.0: {} is-fullwidth-code-point@5.1.0: dependencies: get-east-asian-width: 1.5.0 - is-generator-function@1.1.2: - dependencies: - call-bound: 1.0.4 - generator-function: 2.0.1 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - optional: true - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -16925,9 +16271,6 @@ snapshots: is-map@2.0.3: {} - is-negative-zero@2.0.3: - optional: true - is-npm@6.1.0: {} is-number-object@1.1.1: @@ -16973,22 +16316,12 @@ snapshots: has-symbols: 1.1.0 safe-regex-test: 1.1.0 - is-typed-array@1.1.15: - dependencies: - which-typed-array: 1.1.20 - optional: true - is-unicode-supported@0.1.0: {} is-unicode-supported@2.1.0: {} is-weakmap@2.0.2: {} - is-weakref@1.1.1: - dependencies: - call-bound: 1.0.4 - optional: true - is-weakset@2.0.4: dependencies: call-bound: 1.0.4 @@ -17055,6 +16388,8 @@ snapshots: jiti@2.6.1: {} + jiti@2.7.0: {} + jju@1.4.0: {} js-beautify@1.15.1: @@ -17134,11 +16469,6 @@ snapshots: jsonify: 0.0.1 object-keys: 1.1.1 - json5@1.0.2: - dependencies: - minimist: 1.2.8 - optional: true - json5@2.2.3: {} jsonata@2.1.0: {} @@ -17148,7 +16478,7 @@ snapshots: acorn: 8.16.0 eslint-visitor-keys: 3.4.3 espree: 9.6.1 - semver: 7.7.4 + semver: 7.8.0 jsonc-parser@2.3.1: {} @@ -17240,88 +16570,39 @@ snapshots: dependencies: immediate: 3.0.6 - lightningcss-android-arm64@1.31.1: - optional: true - lightningcss-android-arm64@1.32.0: optional: true - lightningcss-darwin-arm64@1.31.1: - optional: true - lightningcss-darwin-arm64@1.32.0: optional: true - lightningcss-darwin-x64@1.31.1: - optional: true - lightningcss-darwin-x64@1.32.0: optional: true - lightningcss-freebsd-x64@1.31.1: - optional: true - lightningcss-freebsd-x64@1.32.0: optional: true - lightningcss-linux-arm-gnueabihf@1.31.1: - optional: true - lightningcss-linux-arm-gnueabihf@1.32.0: optional: true - lightningcss-linux-arm64-gnu@1.31.1: - optional: true - lightningcss-linux-arm64-gnu@1.32.0: optional: true - lightningcss-linux-arm64-musl@1.31.1: - optional: true - lightningcss-linux-arm64-musl@1.32.0: optional: true - lightningcss-linux-x64-gnu@1.31.1: - optional: true - lightningcss-linux-x64-gnu@1.32.0: optional: true - lightningcss-linux-x64-musl@1.31.1: - optional: true - lightningcss-linux-x64-musl@1.32.0: optional: true - lightningcss-win32-arm64-msvc@1.31.1: - optional: true - lightningcss-win32-arm64-msvc@1.32.0: optional: true - lightningcss-win32-x64-msvc@1.31.1: - optional: true - lightningcss-win32-x64-msvc@1.32.0: optional: true - lightningcss@1.31.1: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.31.1 - lightningcss-darwin-arm64: 1.31.1 - lightningcss-darwin-x64: 1.31.1 - lightningcss-freebsd-x64: 1.31.1 - lightningcss-linux-arm-gnueabihf: 1.31.1 - lightningcss-linux-arm64-gnu: 1.31.1 - lightningcss-linux-arm64-musl: 1.31.1 - lightningcss-linux-x64-gnu: 1.31.1 - lightningcss-linux-x64-musl: 1.31.1 - lightningcss-win32-arm64-msvc: 1.31.1 - lightningcss-win32-x64-msvc: 1.31.1 - lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 @@ -17637,7 +16918,7 @@ snapshots: merge2@1.4.1: {} - meshoptimizer@0.18.1: {} + meshoptimizer@1.1.1: {} micromark-core-commonmark@2.0.3: dependencies: @@ -18064,29 +17345,6 @@ snapshots: has-symbols: 1.1.0 object-keys: 1.1.1 - object.fromentries@2.0.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - optional: true - - object.groupby@1.0.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - optional: true - - object.values@1.2.1: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - optional: true - obug@2.1.1: {} ofetch@1.5.1: @@ -18176,13 +17434,6 @@ snapshots: orderedmap@2.1.1: {} - own-keys@1.0.1: - dependencies: - get-intrinsic: 1.3.0 - object-keys: 1.1.1 - safe-push-apply: 1.0.0 - optional: true - oxc-parser@0.121.0: dependencies: '@oxc-project/types': 0.121.0 @@ -18315,7 +17566,7 @@ snapshots: ky: 1.14.3 registry-auth-token: 5.1.1 registry-url: 6.0.1 - semver: 7.7.4 + semver: 7.8.0 package-manager-detector@1.6.0: {} @@ -18832,18 +18083,6 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 - reflect.getprototypeof@1.0.10: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - which-builtin-type: 1.2.1 - optional: true - regenerate-unicode-properties@10.2.2: dependencies: regenerate: 1.4.2 @@ -19073,7 +18312,7 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.9 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.9 - rollup-plugin-visualizer@6.0.4(rolldown@1.0.0-rc.9)(rollup@4.53.5): + rollup-plugin-visualizer@6.0.4(rolldown@1.0.0-rc.9): dependencies: open: 8.4.2 picomatch: 4.0.3 @@ -19081,36 +18320,6 @@ snapshots: yargs: 17.7.2 optionalDependencies: rolldown: 1.0.0-rc.9 - rollup: 4.53.5 - - rollup@4.53.5: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.53.5 - '@rollup/rollup-android-arm64': 4.53.5 - '@rollup/rollup-darwin-arm64': 4.53.5 - '@rollup/rollup-darwin-x64': 4.53.5 - '@rollup/rollup-freebsd-arm64': 4.53.5 - '@rollup/rollup-freebsd-x64': 4.53.5 - '@rollup/rollup-linux-arm-gnueabihf': 4.53.5 - '@rollup/rollup-linux-arm-musleabihf': 4.53.5 - '@rollup/rollup-linux-arm64-gnu': 4.53.5 - '@rollup/rollup-linux-arm64-musl': 4.53.5 - '@rollup/rollup-linux-loong64-gnu': 4.53.5 - '@rollup/rollup-linux-ppc64-gnu': 4.53.5 - '@rollup/rollup-linux-riscv64-gnu': 4.53.5 - '@rollup/rollup-linux-riscv64-musl': 4.53.5 - '@rollup/rollup-linux-s390x-gnu': 4.53.5 - '@rollup/rollup-linux-x64-gnu': 4.53.5 - '@rollup/rollup-linux-x64-musl': 4.53.5 - '@rollup/rollup-openharmony-arm64': 4.53.5 - '@rollup/rollup-win32-arm64-msvc': 4.53.5 - '@rollup/rollup-win32-ia32-msvc': 4.53.5 - '@rollup/rollup-win32-x64-gnu': 4.53.5 - '@rollup/rollup-win32-x64-msvc': 4.53.5 - fsevents: 2.3.3 - optional: true rope-sequence@1.3.4: {} @@ -19122,25 +18331,10 @@ snapshots: rxjs-interop@2.0.0: {} - safe-array-concat@1.1.3: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - has-symbols: 1.1.0 - isarray: 2.0.5 - optional: true - safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} - safe-push-apply@1.0.0: - dependencies: - es-errors: 1.3.0 - isarray: 2.0.5 - optional: true - safe-regex-test@1.1.0: dependencies: call-bound: 1.0.4 @@ -19170,6 +18364,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -19186,20 +18382,13 @@ snapshots: functions-have-names: 1.2.3 has-property-descriptors: 1.0.2 - set-proto@1.0.0: - dependencies: - dunder-proto: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - optional: true - setimmediate@1.0.5: {} sharp@0.34.5: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.7.4 + semver: 7.8.0 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -19410,32 +18599,6 @@ snapshots: string.fromcodepoint@0.2.1: {} - string.prototype.trim@1.2.10: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-data-property: 1.1.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - has-property-descriptors: 1.0.2 - optional: true - - string.prototype.trimend@1.0.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - optional: true - - string.prototype.trimstart@1.0.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - optional: true - string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -19592,13 +18755,13 @@ snapshots: tailwind-merge@2.6.0: {} - tailwindcss-primeui@0.6.1(tailwindcss@4.2.0): + tailwindcss-primeui@0.6.1(tailwindcss@4.3.0): dependencies: - tailwindcss: 4.2.0 + tailwindcss: 4.3.0 - tailwindcss@4.2.0: {} + tailwindcss@4.3.0: {} - tapable@2.3.0: {} + tapable@2.3.3: {} tar-stream@2.2.0: dependencies: @@ -19617,7 +18780,7 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - three@0.170.0: {} + three@0.184.0: {} tiny-inflate@1.0.3: {} @@ -19712,17 +18875,9 @@ snapshots: dependencies: chalk: 4.1.2 enhanced-resolve: 5.19.0 - tapable: 2.3.0 + tapable: 2.3.3 tsconfig-paths: 4.2.0 - tsconfig-paths@3.15.0: - dependencies: - '@types/json5': 0.0.29 - json5: 1.0.2 - minimist: 1.2.8 - strip-bom: 3.0.0 - optional: true - tsconfig-paths@4.2.0: dependencies: json5: 2.2.3 @@ -19752,43 +18907,6 @@ snapshots: dependencies: tagged-tag: 1.0.0 - typed-array-buffer@1.0.3: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 - optional: true - - typed-array-byte-length@1.0.3: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - optional: true - - typed-array-byte-offset@1.0.4: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - reflect.getprototypeof: 1.0.10 - optional: true - - typed-array-length@1.0.7: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - is-typed-array: 1.1.15 - possible-typed-array-names: 1.1.0 - reflect.getprototypeof: 1.0.10 - optional: true - typed-binary@4.3.2: {} typegpu@0.8.2: @@ -19800,7 +18918,7 @@ snapshots: typescript-auto-import-cache@0.3.6: dependencies: - semver: 7.7.4 + semver: 7.8.0 typescript-eslint@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: @@ -19827,14 +18945,6 @@ snapshots: unbash@2.2.0: {} - unbox-primitive@1.1.0: - dependencies: - call-bound: 1.0.4 - has-bigints: 1.1.0 - has-symbols: 1.1.0 - which-boxed-primitive: 1.1.1 - optional: true - uncrypto@0.1.3: {} undici-types@5.26.5: {} @@ -20054,6 +19164,12 @@ snapshots: optionalDependencies: typescript: 5.9.3 + vee-validate@4.15.1(vue@3.5.13(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 7.7.9 + type-fest: 4.41.0 + vue: 3.5.13(typescript@5.9.3) + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -20069,30 +19185,30 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)): + vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)): dependencies: birpc: 2.9.0 - vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) - vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) + vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) - vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)): + vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)): dependencies: birpc: 2.9.0 - vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) - vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) + vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) - vite-hot-client@2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)): + vite-hot-client@2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)): dependencies: - vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) - vite-hot-client@2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)): + vite-hot-client@2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)): dependencies: - vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) - vite-plugin-dts@4.5.4(@types/node@24.10.4)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)): + vite-plugin-dts@4.5.4(@types/node@24.10.4)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)): dependencies: '@microsoft/api-extractor': 7.57.2(@types/node@24.10.4) - '@rollup/pluginutils': 5.3.0(rollup@4.53.5) + '@rollup/pluginutils': 5.3.0 '@volar/typescript': 2.4.28 '@vue/language-core': 2.2.0(typescript@5.9.3) compare-versions: 6.1.1 @@ -20102,13 +19218,13 @@ snapshots: magic-string: 0.30.21 typescript: 5.9.3 optionalDependencies: - vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - rollup - supports-color - vite-plugin-html@3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)): + vite-plugin-html@3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)): dependencies: '@rollup/pluginutils': 4.2.1 colorette: 2.0.20 @@ -20122,9 +19238,9 @@ snapshots: html-minifier-terser: 6.1.0 node-html-parser: 5.4.2 pathe: 0.2.0 - vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) - vite-plugin-html@3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)): + vite-plugin-html@3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)): dependencies: '@rollup/pluginutils': 4.2.1 colorette: 2.0.20 @@ -20138,12 +19254,12 @@ snapshots: html-minifier-terser: 6.1.0 node-html-parser: 5.4.2 pathe: 0.2.0 - vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) - vite-plugin-inspect@0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)): + vite-plugin-inspect@0.8.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)): dependencies: '@antfu/utils': 0.7.10 - '@rollup/pluginutils': 5.3.0(rollup@4.53.5) + '@rollup/pluginutils': 5.3.0 debug: 4.4.3 error-stack-parser-es: 0.1.5 fs-extra: 11.3.2 @@ -20151,12 +19267,12 @@ snapshots: perfect-debounce: 1.0.0 picocolors: 1.1.1 sirv: 3.0.2 - vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) transitivePeerDependencies: - rollup - supports-color - vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)): + vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)): dependencies: ansis: 4.2.0 debug: 4.4.3 @@ -20166,12 +19282,12 @@ snapshots: perfect-debounce: 2.0.0 sirv: 3.0.2 unplugin-utils: 0.3.1 - vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) - vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) + vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) transitivePeerDependencies: - supports-color - vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)): + vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)): dependencies: ansis: 4.2.0 debug: 4.4.3 @@ -20181,56 +19297,56 @@ snapshots: perfect-debounce: 2.0.0 sirv: 3.0.2 unplugin-utils: 0.3.1 - vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) - vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) + vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) transitivePeerDependencies: - supports-color - vite-plugin-vue-devtools@7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)): + vite-plugin-vue-devtools@7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)): dependencies: - '@vue/devtools-core': 7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)) + '@vue/devtools-core': 7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)) '@vue/devtools-kit': 7.7.9 '@vue/devtools-shared': 7.7.9 execa: 9.6.1 sirv: 3.0.2 - vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) - vite-plugin-inspect: 0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) - vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) + vite-plugin-inspect: 0.8.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) + vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) transitivePeerDependencies: - '@nuxt/kit' - rollup - supports-color - vue - vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)): + vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)): dependencies: - '@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)) + '@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)) '@vue/devtools-kit': 8.0.5 '@vue/devtools-shared': 8.0.5 sirv: 3.0.2 - vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) - vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) - vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) + vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) + vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) transitivePeerDependencies: - '@nuxt/kit' - supports-color - vue - vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)): + vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)): dependencies: - '@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)) + '@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)) '@vue/devtools-kit': 8.0.5 '@vue/devtools-shared': 8.0.5 sirv: 3.0.2 - vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) - vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) - vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) + vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) + vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) transitivePeerDependencies: - '@nuxt/kit' - supports-color - vue - vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)): + vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.0 '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) @@ -20241,11 +19357,11 @@ snapshots: '@vue/compiler-dom': 3.5.28 kolorist: 1.8.0 magic-string: 0.30.21 - vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) transitivePeerDependencies: - supports-color - vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)): + vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)): dependencies: '@babel/core': 7.29.0 '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0) @@ -20256,11 +19372,11 @@ snapshots: '@vue/compiler-dom': 3.5.28 kolorist: 1.8.0 magic-string: 0.30.21 - vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) transitivePeerDependencies: - supports-color - vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2): + vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0): dependencies: '@oxc-project/runtime': 0.115.0 lightningcss: 1.32.0 @@ -20275,9 +19391,9 @@ snapshots: jiti: 2.6.1 terser: 5.39.2 tsx: 4.19.4 - yaml: 2.8.2 + yaml: 2.9.0 - vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2): + vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0): dependencies: '@oxc-project/runtime': 0.115.0 lightningcss: 1.32.0 @@ -20289,19 +19405,19 @@ snapshots: '@types/node': 25.0.3 esbuild: 0.27.3 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 terser: 5.39.2 tsx: 4.19.4 - yaml: 2.8.2 + yaml: 2.9.0 - vitefu@1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)): + vitefu@1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)): optionalDependencies: - vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) - vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2): + vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0): dependencies: '@vitest/expect': 4.0.16 - '@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + '@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.16 '@vitest/runner': 4.0.16 '@vitest/snapshot': 4.0.16 @@ -20318,7 +19434,7 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -20340,10 +19456,10 @@ snapshots: - tsx - yaml - vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2): + vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.7.0)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0): dependencies: '@vitest/expect': 4.0.16 - '@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)) + '@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.16 '@vitest/runner': 4.0.16 '@vitest/snapshot': 4.0.16 @@ -20360,7 +19476,7 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2) + vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -20425,7 +19541,7 @@ snapshots: volar-service-typescript@0.0.70(@volar/language-service@2.4.28): dependencies: path-browserify: 1.0.1 - semver: 7.7.4 + semver: 7.8.0 typescript-auto-import-cache: 0.3.6 vscode-languageserver-textdocument: 1.0.12 vscode-nls: 5.2.0 @@ -20494,7 +19610,7 @@ snapshots: vue-component-type-helpers@3.2.6: {} - vue-component-type-helpers@3.2.7: {} + vue-component-type-helpers@3.3.1: {} vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)): dependencies: @@ -20630,23 +19746,6 @@ snapshots: is-string: 1.1.1 is-symbol: 1.1.1 - which-builtin-type@1.2.1: - dependencies: - call-bound: 1.0.4 - function.prototype.name: 1.1.8 - has-tostringtag: 1.0.2 - is-async-function: 2.1.1 - is-date-object: 1.1.0 - is-finalizationregistry: 1.1.1 - is-generator-function: 1.1.2 - is-regex: 1.2.1 - is-weakref: 1.1.1 - isarray: 2.0.5 - which-boxed-primitive: 1.1.1 - which-collection: 1.0.2 - which-typed-array: 1.1.20 - optional: true - which-collection@1.0.2: dependencies: is-map: 2.0.3 @@ -20740,16 +19839,16 @@ snapshots: wtd-core@3.0.0: {} - wtd-three-ext@3.0.0(three@0.170.0): + wtd-three-ext@3.0.0(three@0.184.0): dependencies: - three: 0.170.0 + three: 0.184.0 wtd-core: 3.0.0 - wwobjloader2@6.2.1(three@0.170.0): + wwobjloader2@6.2.1(three@0.184.0): dependencies: - three: 0.170.0 + three: 0.184.0 wtd-core: 3.0.0 - wtd-three-ext: 3.0.0(three@0.170.0) + wtd-three-ext: 3.0.0(three@0.184.0) xdg-basedir@5.1.0: {} @@ -20770,7 +19869,7 @@ snapshots: yaml-eslint-parser@1.3.0: dependencies: eslint-visitor-keys: 3.4.3 - yaml: 2.8.2 + yaml: 2.9.0 yaml-language-server@1.20.0: dependencies: @@ -20792,6 +19891,8 @@ snapshots: yaml@2.8.2: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 29b3a82afa..04e53c6837 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,11 @@ packages: - apps/** - packages/** +ignoreWorkspaceRootCheck: true +catalogMode: prefer +publicHoistPattern: + - '@parcel/watcher' + catalog: '@alloc/quick-lru': ^5.2.0 '@astrojs/check': ^0.9.8 @@ -31,12 +36,12 @@ catalog: '@primevue/themes': ^4.2.5 '@sentry/vite-plugin': ^4.6.0 '@sentry/vue': ^10.32.1 - '@sparkjsdev/spark': ^0.1.10 + '@sparkjsdev/spark': ^2.1.0 '@storybook/addon-docs': ^10.2.10 '@storybook/addon-mcp': 0.1.6 '@storybook/vue3': ^10.2.10 '@storybook/vue3-vite': ^10.2.10 - '@tailwindcss/vite': ^4.2.0 + '@tailwindcss/vite': ^4.3.0 '@tanstack/vue-virtual': ^3.13.12 '@testing-library/jest-dom': ^6.9.1 '@testing-library/user-event': ^14.6.1 @@ -54,7 +59,8 @@ catalog: '@types/jsdom': ^21.1.7 '@types/node': ^24.1.0 '@types/semver': ^7.7.0 - '@types/three': ^0.169.0 + '@types/three': ^0.184.1 + '@vee-validate/zod': ^4.15.1 '@vercel/analytics': ^2.0.1 '@vitejs/plugin-vue': ^6.0.0 '@vitest/coverage-v8': ^4.0.16 @@ -111,7 +117,8 @@ catalog: rollup-plugin-visualizer: ^6.0.4 storybook: ^10.2.10 stylelint: ^16.26.1 - tailwindcss: ^4.2.0 + tailwindcss: ^4.3.0 + three: ^0.184.0 tailwindcss-primeui: ^0.6.1 tsx: ^4.15.6 tw-animate-css: ^1.3.8 @@ -121,6 +128,7 @@ catalog: unplugin-icons: ^22.5.0 unplugin-typegpu: 0.8.0 unplugin-vue-components: ^30.0.0 + vee-validate: ^4.15.1 vite: ^8.0.0 vite-plugin-dts: ^4.5.4 vite-plugin-html: ^3.2.2 @@ -141,22 +149,20 @@ catalog: cleanupUnusedCatalogs: true -ignoredBuiltDependencies: - - '@firebase/util' - - protobufjs - - unrs-resolver - - vue-demi - -onlyBuiltDependencies: - - '@playwright/browser-chromium' - - '@playwright/browser-firefox' - - '@playwright/browser-webkit' - - '@sentry/cli' - - '@tailwindcss/oxide' - - esbuild - - nx - - oxc-resolver +allowBuilds: + '@firebase/util': false + '@sentry/cli': true + '@tailwindcss/oxide': true + core-js: false + esbuild: true + nx: true + oxc-resolver: true + protobufjs: false + sharp: false + unrs-resolver: false + vue-demi: false overrides: + vite: 'catalog:' '@tiptap/pm': 2.27.2 '@types/eslint': '-' diff --git a/public/assets/sorted-custom-node-map.json b/public/assets/sorted-custom-node-map.json index 3bd0889fe1..87cea332c8 100644 --- a/public/assets/sorted-custom-node-map.json +++ b/public/assets/sorted-custom-node-map.json @@ -3,6 +3,7 @@ "LoadImage": 3474, "CLIPTextEncode": 2435, "SaveImage": 1762, + "SaveImageAdvanced": 1762, "VAEDecode": 1754, "KSampler": 1511, "CheckpointLoaderSimple": 1293, diff --git a/scripts/generate-embedded-metadata-test-files.py b/scripts/generate-embedded-metadata-test-files.py new file mode 100644 index 0000000000..f90a54f92f --- /dev/null +++ b/scripts/generate-embedded-metadata-test-files.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +""" +Generate test fixture files for metadata parser tests. + +Each fixture embeds the same workflow and prompt JSON, matching the +format the ComfyUI backend uses to write metadata. + +Prerequisites: + source ~/ComfyUI/.venv/bin/activate + python3 scripts/generate-embedded-metadata-test-files.py + +Output: src/scripts/metadata/__fixtures__/ +""" + +import json +import os +import struct +import subprocess + +import av +from PIL import Image +from PIL.PngImagePlugin import PngInfo + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +FIXTURES_DIR = os.path.join(REPO_ROOT, 'src', 'scripts', 'metadata', '__fixtures__') + +WORKFLOW = { + 'nodes': [ + { + 'id': 1, + 'type': 'KSampler', + 'pos': [100, 100], + 'size': [200, 200], + } + ] +} +PROMPT = {'1': {'class_type': 'KSampler', 'inputs': {}}} + +# API-format prompt with bare NaN/Infinity tokens (as Python's json.dumps emits +# by default). The NaN variant fixtures omit the workflow field so the loader +# must route through prompt-parsing, which trips JSON.parse on bare NaN. +PROMPT_NAN = { + '1': { + 'class_type': 'KSampler', + 'inputs': {'cfg': float('nan'), 'denoise': float('inf')}, + } +} + +WORKFLOW_JSON = json.dumps(WORKFLOW, separators=(',', ':')) +PROMPT_JSON = json.dumps(PROMPT, separators=(',', ':')) +PROMPT_NAN_JSON = json.dumps(PROMPT_NAN, separators=(',', ':')) + + +def out(name: str) -> str: + return os.path.join(FIXTURES_DIR, name) + + +def report(name: str): + size = os.path.getsize(out(name)) + print(f' {name} ({size} bytes)') + + +def make_1x1_image() -> Image.Image: + return Image.new('RGB', (1, 1), (255, 0, 0)) + + +def build_exif_bytes( + workflow_str: str | None = WORKFLOW_JSON, + prompt_str: str | None = PROMPT_JSON, +) -> bytes: + """Build EXIF bytes matching the backend's tag assignments. + + Backend: 0x010F (Make) = "workflow:", 0x0110 (Model) = "prompt:" + Pass ``None`` to omit a tag. + """ + img = make_1x1_image() + exif = img.getexif() + if workflow_str is not None: + exif[0x010F] = f'workflow:{workflow_str}' + if prompt_str is not None: + exif[0x0110] = f'prompt:{prompt_str}' + return exif.tobytes() + + +def inject_exif_prefix_in_webp(path: str): + """Prepend Exif\\0\\0 to the EXIF chunk in a WEBP file. + + PIL always strips this prefix, so we re-inject it to test that code path. + """ + data = bytearray(open(path, 'rb').read()) + off = 12 + while off < len(data): + chunk_type = data[off:off + 4] + chunk_len = struct.unpack_from(' { + let consoleErrorSpy: ReturnType + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + setAssertReporter(null) + }) + + it('does nothing when condition is true', () => { + expect(() => assert(true, 'should not throw')).not.toThrow() + expect(consoleErrorSpy).not.toHaveBeenCalled() + }) + + it('logs console.error when condition is false', () => { + vi.stubEnv('DEV', false) + assert(false, 'test message') + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[Assertion failed]: test message' + ) + }) + + it('throws in DEV mode when condition is false', () => { + vi.stubEnv('DEV', true) + const reporter = vi.fn() + setAssertReporter(reporter) + expect(() => assert(false, 'dev error')).toThrow( + '[Assertion failed]: dev error' + ) + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[Assertion failed]: dev error' + ) + expect(reporter).not.toHaveBeenCalled() + }) + + it('does not throw in non-DEV mode when condition is false', () => { + vi.stubEnv('DEV', false) + expect(() => assert(false, 'non-dev error')).not.toThrow() + }) + + it('calls registered reporter in non-DEV mode with formatted message', () => { + vi.stubEnv('DEV', false) + const reporter = vi.fn() + setAssertReporter(reporter) + assert(false, 'reporter message') + expect(reporter).toHaveBeenCalledWith( + '[Assertion failed]: reporter message' + ) + }) + + it('does not call reporter when condition is true', () => { + vi.stubEnv('DEV', false) + const reporter = vi.fn() + setAssertReporter(reporter) + assert(true, 'no call') + expect(reporter).not.toHaveBeenCalled() + }) + + it('handles null reporter gracefully in non-DEV mode', () => { + vi.stubEnv('DEV', false) + setAssertReporter(null) + expect(() => assert(false, 'null reporter')).not.toThrow() + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[Assertion failed]: null reporter' + ) + }) + + it('swallows reporter exceptions in non-DEV mode', () => { + vi.stubEnv('DEV', false) + const reporter = vi.fn(() => { + throw new Error('reporter blew up') + }) + setAssertReporter(reporter) + expect(() => assert(false, 'safe under reporter failure')).not.toThrow() + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[Assertion failed]: safe under reporter failure' + ) + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[Assertion reporter failed]', + expect.any(Error) + ) + }) +}) diff --git a/src/base/assert.ts b/src/base/assert.ts new file mode 100644 index 0000000000..829285ffde --- /dev/null +++ b/src/base/assert.ts @@ -0,0 +1,36 @@ +type AssertReporter = (message: string) => void + +let reporter: AssertReporter | null = null + +/** + * Register a reporter for assertion failures in non-DEV environments. + * Called once at app startup by platform/ or higher layers to wire in + * Sentry, toast notifications, etc. + */ +export function setAssertReporter(fn: AssertReporter | null): void { + reporter = fn +} + +/** + * Centralized invariant assertion. + * + * - Always: console.error + * - DEV: throws (surfaces bugs immediately) + * - Otherwise: delegates to registered reporter (Sentry, toast, etc.) + */ +export function assert(condition: unknown, message: string): asserts condition { + if (condition) return + + const formatted = `[Assertion failed]: ${message}` + console.error(formatted) + + if (import.meta.env.DEV) { + throw new Error(formatted) + } + + try { + reporter?.(formatted) + } catch (error) { + console.error('[Assertion reporter failed]', error) + } +} diff --git a/src/base/webviewDetection.test.ts b/src/base/webviewDetection.test.ts new file mode 100644 index 0000000000..c7faab6854 --- /dev/null +++ b/src/base/webviewDetection.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { isEmbeddedWebView } from '@/base/webviewDetection' + +describe('isEmbeddedWebView', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('Android WebView', () => { + it('detects Android WebView with wv token', () => { + const ua = + 'Mozilla/5.0 (Linux; Android 13; SM-G991B; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/120.0.0.0 Mobile Safari/537.36' + expect(isEmbeddedWebView(ua)).toBe(true) + }) + + it('does not flag regular Chrome on Android', () => { + const ua = + 'Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36' + expect(isEmbeddedWebView(ua)).toBe(false) + }) + }) + + describe('iOS WKWebView', () => { + it('detects iOS WKWebView (AppleWebKit without Safari/)', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148' + expect(isEmbeddedWebView(ua)).toBe(true) + }) + + it('does not flag regular Safari on iOS', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1' + expect(isEmbeddedWebView(ua)).toBe(false) + }) + + it('does not flag Chrome on iOS (CriOS)', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.0.0 Mobile/15E148' + expect(isEmbeddedWebView(ua)).toBe(false) + }) + + it('does not flag Firefox on iOS (FxiOS)', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/120.0 Mobile/15E148' + expect(isEmbeddedWebView(ua)).toBe(false) + }) + }) + + describe('social app in-app browsers', () => { + it('detects Facebook (FBAN)', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/400.0]' + expect(isEmbeddedWebView(ua)).toBe(true) + }) + + it('detects Instagram', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Instagram 300.0' + expect(isEmbeddedWebView(ua)).toBe(true) + }) + + it('detects TikTok', () => { + const ua = + 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 TikTok/30.0' + expect(isEmbeddedWebView(ua)).toBe(true) + }) + + it('detects Line', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Line/13.0' + expect(isEmbeddedWebView(ua)).toBe(true) + }) + + it('detects Snapchat', () => { + const ua = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Snapchat/12.0' + expect(isEmbeddedWebView(ua)).toBe(true) + }) + }) + + describe('regular desktop browsers', () => { + it('does not flag Chrome desktop', () => { + const ua = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + expect(isEmbeddedWebView(ua)).toBe(false) + }) + + it('does not flag Firefox desktop', () => { + const ua = + 'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0' + expect(isEmbeddedWebView(ua)).toBe(false) + }) + + it('does not flag Safari desktop', () => { + const ua = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15' + expect(isEmbeddedWebView(ua)).toBe(false) + }) + }) + + describe('edge cases', () => { + it('handles empty string', () => { + expect(isEmbeddedWebView('')).toBe(false) + }) + }) + + describe('JS bridge detection', () => { + it('detects webkit.messageHandlers bridge', () => { + vi.stubGlobal('webkit', { messageHandlers: {} }) + expect(isEmbeddedWebView('')).toBe(true) + }) + + it('detects ReactNativeWebView bridge', () => { + vi.stubGlobal('ReactNativeWebView', { postMessage: vi.fn() }) + expect(isEmbeddedWebView('')).toBe(true) + }) + }) +}) diff --git a/src/base/webviewDetection.ts b/src/base/webviewDetection.ts new file mode 100644 index 0000000000..4d56118409 --- /dev/null +++ b/src/base/webviewDetection.ts @@ -0,0 +1,72 @@ +/** + * Detects whether the app is running inside an embedded webview. + * + * Google blocks OAuth via `signInWithPopup` in embedded webviews, + * returning a 403 `disallowed_useragent` error (policy since 2021). + * This utility is used to hide the Google SSO button in those contexts. + * + * Detection covers: + * • Android WebView (`wv` token in UA) + * • iOS WKWebView (has `AppleWebKit` but lacks `Safari/`) + * • Social app in-app browsers (Facebook, Instagram, TikTok, etc.) + * • JS bridge objects (`window.webkit.messageHandlers`, `ReactNativeWebView`) + */ + +const SOCIAL_APP_PATTERNS = + /FBAN|FBAV|Instagram|Line\/|Snapchat|TikTok|musical_ly/i + +function isAndroidWebView(ua: string): boolean { + return /\bwv\b/.test(ua) && /Android/.test(ua) +} + +function isIOSWebView(ua: string): boolean { + if (!/AppleWebKit/i.test(ua)) return false + if (/Safari\//i.test(ua)) return false + if (/CriOS|FxiOS|OPiOS|EdgiOS/i.test(ua)) return false + return true +} + +function isSocialAppBrowser(ua: string): boolean { + return SOCIAL_APP_PATTERNS.test(ua) +} + +function hasWebViewBridge(): boolean { + try { + const win = globalThis as Record + if ( + typeof win.webkit === 'object' && + win.webkit !== null && + typeof (win.webkit as Record).messageHandlers === + 'object' + ) { + return true + } + if (win.ReactNativeWebView != null) return true + } catch { + // Access to bridge objects may throw in sandboxed contexts + } + return false +} + +export function isEmbeddedWebView(ua: string = navigator.userAgent): boolean { + if (isSocialAppBrowser(ua)) return true + if (isAndroidWebView(ua)) return true + if (isIOSWebView(ua)) return true + if (hasWebViewBridge()) return true + return false +} + +/** + * Reason why Google SSO is blocked in the current environment, or `null` if it + * is available. Modeled as a discriminated string so call sites read as + * "if blocked, here's why" rather than an opaque boolean. Extend this union + * (e.g. `'unauthorized-host'`) as new blocking conditions are detected. + */ +type GoogleSsoBlockedReason = 'embedded-webview' | null + +export function getGoogleSsoBlockedReason( + ua: string = navigator.userAgent +): GoogleSsoBlockedReason { + if (isEmbeddedWebView(ua)) return 'embedded-webview' + return null +} diff --git a/src/base/wheelGestures.ts b/src/base/wheelGestures.ts new file mode 100644 index 0000000000..3e4f952759 --- /dev/null +++ b/src/base/wheelGestures.ts @@ -0,0 +1,20 @@ +/** + * Wheel events whose browser default would break the editing experience. + * On macOS trackpads: + * - `ctrl/meta + wheel` (pinch-zoom) triggers page-level zoom, which + * pushes fixed-position UI (e.g. ComfyActionbar) off-screen with no + * recovery short of a page reload. + * - Horizontal-dominant wheel (two-finger horizontal swipe) triggers + * back/forward navigation, which leaves the workflow. + * + * Equal `|deltaX| == |deltaY|` (including idle 0/0 frames between meaningful + * trackpad samples) intentionally falls on the false branch so native + * vertical scroll wins on a tie. + * + * Components that intercept wheel events should suppress the default for + * these gestures even when they otherwise let the browser scroll natively. + */ +export const isCanvasGestureWheel = (event: WheelEvent): boolean => + event.ctrlKey || + event.metaKey || + Math.abs(event.deltaX) > Math.abs(event.deltaY) diff --git a/src/components/bottomPanel/tabs/terminal/LogsTerminal.test.ts b/src/components/bottomPanel/tabs/terminal/LogsTerminal.test.ts new file mode 100644 index 0000000000..ba4707ce14 --- /dev/null +++ b/src/components/bottomPanel/tabs/terminal/LogsTerminal.test.ts @@ -0,0 +1,291 @@ +import { createTestingPinia } from '@pinia/testing' +import { render, screen } from '@testing-library/vue' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import { createI18n } from 'vue-i18n' + +import LogsTerminal from '@/components/bottomPanel/tabs/terminal/LogsTerminal.vue' + +const apiMock = vi.hoisted( + () => + new (class extends EventTarget { + clientId: string | null = 'test-client' + getRawLogs = vi.fn(async () => ({ entries: [{ m: 'log line\n' }] })) + subscribeLogs = vi.fn(async () => {}) + })() +) + +vi.mock('@/scripts/api', () => ({ api: apiMock })) + +const terminalMock = vi.hoisted(() => ({ + open: vi.fn(), + dispose: vi.fn(), + write: vi.fn(), + reset: vi.fn(), + scrollToBottom: vi.fn(), + onSelectionChange: vi.fn(() => ({ dispose: vi.fn() })), + hasSelection: vi.fn(() => false), + getSelection: vi.fn(() => ''), + selectAll: vi.fn(), + clearSelection: vi.fn() +})) + +vi.mock('@/composables/bottomPanelTabs/useTerminal', () => ({ + useTerminal: vi.fn(() => ({ + terminal: terminalMock, + useAutoSize: vi.fn(() => ({ resize: vi.fn() })) + })) +})) + +vi.mock('@/components/bottomPanel/tabs/terminal/BaseTerminal.vue', async () => { + const { defineComponent, ref } = await import('vue') + const { useTerminal } = + await import('@/composables/bottomPanelTabs/useTerminal') + return { + default: defineComponent({ + emits: ['created'], + setup(_, { emit }) { + const root = ref(undefined) + emit('created', useTerminal(root), root) + return () => null + } + }) + } +}) + +const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: { + logsTerminal: { + loadError: + 'Unable to load logs, please ensure you have updated your ComfyUI backend.', + resyncError: + 'Unable to resync logs after the backend reconnected. Reopen the console to retry.' + } + } + } +}) + +const renderLogsTerminal = () => + render(LogsTerminal, { + global: { + plugins: [ + createTestingPinia({ + createSpy: vi.fn, + stubActions: false, + initialState: { execution: { clientId: 'test-client' } } + }), + i18n + ] + } + }) + +// Silence the production console.error calls in error-path tests. Vitest +// isolates this file's module graph so the spy does not affect other files. +vi.spyOn(console, 'error').mockImplementation(() => {}) + +// Resolve a getRawLogs call manually to drive deterministic timing in tests +// that need to observe behavior mid-fetch. +const deferredRawLogs = () => { + let resolve!: (value: { entries: { m: string }[] }) => void + let reject!: (err: unknown) => void + const promise = new Promise<{ entries: { m: string }[] }>((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe('LogsTerminal', () => { + beforeEach(() => { + vi.clearAllMocks() + apiMock.clientId = 'test-client' + }) + + it('loads logs and subscribes to streaming on mount', async () => { + renderLogsTerminal() + + await vi.waitFor(() => { + expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1) + expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true) + expect(terminalMock.write).toHaveBeenCalledWith('log line\n') + }) + }) + + it('resyncs, snaps to tail, and re-subscribes on "reconnected"', async () => { + renderLogsTerminal() + + await vi.waitFor(() => { + expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true) + }) + + apiMock.dispatchEvent(new CustomEvent('reconnected')) + + await vi.waitFor(() => { + expect(apiMock.getRawLogs).toHaveBeenCalledTimes(2) + expect(terminalMock.reset).toHaveBeenCalledTimes(1) + expect(terminalMock.scrollToBottom).toHaveBeenCalledTimes(1) + expect(apiMock.subscribeLogs).toHaveBeenCalledTimes(2) + expect(apiMock.subscribeLogs).toHaveBeenLastCalledWith(true) + }) + + // The full sequence must be: reset -> write -> scroll -> subscribe + const resetOrder = terminalMock.reset.mock.invocationCallOrder[0] + const writeOrder = terminalMock.write.mock.invocationCallOrder.at(-1)! + const scrollOrder = terminalMock.scrollToBottom.mock.invocationCallOrder[0] + const subscribeOrder = + apiMock.subscribeLogs.mock.invocationCallOrder.at(-1)! + expect(resetOrder).toBeLessThan(writeOrder) + expect(writeOrder).toBeLessThan(scrollOrder) + expect(scrollOrder).toBeLessThan(subscribeOrder) + }) + + it('aborts an in-flight resync when a second "reconnected" arrives', async () => { + renderLogsTerminal() + await vi.waitFor(() => { + expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true) + }) + + // First resync hangs on getRawLogs + const first = deferredRawLogs() + apiMock.getRawLogs.mockImplementationOnce(() => first.promise) + apiMock.dispatchEvent(new CustomEvent('reconnected')) + await vi.waitFor(() => { + expect(apiMock.getRawLogs).toHaveBeenCalledTimes(2) + }) + + // Second resync resolves immediately + apiMock.getRawLogs.mockImplementationOnce(async () => ({ + entries: [{ m: 'fresh\n' }] + })) + apiMock.dispatchEvent(new CustomEvent('reconnected')) + await vi.waitFor(() => { + expect(terminalMock.reset).toHaveBeenCalledTimes(1) + }) + + // Now resolve the first (aborted) resync — none of its side effects must apply + first.resolve({ entries: [{ m: 'stale\n' }] }) + await nextTick() + await nextTick() + + expect(terminalMock.reset).toHaveBeenCalledTimes(1) + expect(terminalMock.write).not.toHaveBeenCalledWith('stale\n') + expect(terminalMock.write).toHaveBeenCalledWith('fresh\n') + }) + + it('aborts an in-flight mount fetch when "reconnected" arrives first', async () => { + // Mount's getRawLogs hangs so we can drive the race deterministically. + const mount = deferredRawLogs() + apiMock.getRawLogs.mockImplementationOnce(() => mount.promise) + renderLogsTerminal() + await vi.waitFor(() => { + expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1) + }) + + // Resync wins the race and writes the post-reboot snapshot. + apiMock.getRawLogs.mockImplementationOnce(async () => ({ + entries: [{ m: 'fresh\n' }] + })) + apiMock.dispatchEvent(new CustomEvent('reconnected')) + await vi.waitFor(() => { + expect(terminalMock.reset).toHaveBeenCalledTimes(1) + expect(terminalMock.write).toHaveBeenCalledWith('fresh\n') + }) + + // Mount's late response must not stomp on the freshly-reset terminal. + mount.resolve({ entries: [{ m: 'stale-mount\n' }] }) + await nextTick() + await nextTick() + + expect(terminalMock.write).not.toHaveBeenCalledWith('stale-mount\n') + }) + + it('surfaces an inline error when the resync fetch fails', async () => { + renderLogsTerminal() + await vi.waitFor(() => { + expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true) + }) + + apiMock.getRawLogs.mockRejectedValueOnce(new Error('boom')) + + apiMock.dispatchEvent(new CustomEvent('reconnected')) + + await vi.waitFor(() => { + expect( + screen.getByTestId('terminal-error-message').textContent + ).toContain('Unable to resync logs') + }) + }) + + it('shows a load error when the initial fetch fails', async () => { + apiMock.getRawLogs.mockRejectedValueOnce(new Error('boom')) + + renderLogsTerminal() + + await vi.waitFor(() => { + expect( + screen.getByTestId('terminal-error-message').textContent + ).toContain('Unable to load logs') + }) + }) + + it('recovers from an initial load failure when a reconnect arrives', async () => { + apiMock.getRawLogs + .mockRejectedValueOnce(new Error('initial fail')) + .mockResolvedValueOnce({ entries: [{ m: 'recovered\n' }] }) + + renderLogsTerminal() + + await vi.waitFor(() => { + expect( + screen.getByTestId('terminal-error-message').textContent + ).toContain('Unable to load logs') + }) + + apiMock.dispatchEvent(new CustomEvent('reconnected')) + + await vi.waitFor(() => { + expect(screen.queryByTestId('terminal-error-message')).toBeNull() + expect(screen.queryByTestId('terminal-loading-spinner')).toBeNull() + expect(terminalMock.write).toHaveBeenCalledWith('recovered\n') + }) + }) + + it('cleans up listeners and unsubscribes on unmount', async () => { + const { unmount } = renderLogsTerminal() + await vi.waitFor(() => { + expect(apiMock.subscribeLogs).toHaveBeenCalledWith(true) + }) + + unmount() + await vi.waitFor(() => { + expect(apiMock.subscribeLogs).toHaveBeenCalledWith(false) + }) + + apiMock.dispatchEvent(new CustomEvent('reconnected')) + await nextTick() + + expect(terminalMock.reset).not.toHaveBeenCalled() + // No additional getRawLogs beyond the mount-time call + expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1) + }) + + it('does not write to the terminal when unmount happens mid-fetch', async () => { + const pending = deferredRawLogs() + apiMock.getRawLogs.mockImplementationOnce(() => pending.promise) + + const { unmount } = renderLogsTerminal() + await vi.waitFor(() => { + expect(apiMock.getRawLogs).toHaveBeenCalledTimes(1) + }) + + unmount() + pending.resolve({ entries: [{ m: 'late\n' }] }) + await nextTick() + await nextTick() + + expect(terminalMock.write).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/bottomPanel/tabs/terminal/LogsTerminal.vue b/src/components/bottomPanel/tabs/terminal/LogsTerminal.vue index bfb485217f..4360888c43 100644 --- a/src/components/bottomPanel/tabs/terminal/LogsTerminal.vue +++ b/src/components/bottomPanel/tabs/terminal/LogsTerminal.vue @@ -12,79 +12,36 @@ data-testid="terminal-loading-spinner" class="relative inset-0 z-10 flex h-full items-center justify-center" /> - + diff --git a/src/components/common/Badge.test.ts b/src/components/common/Badge.test.ts index 86507cce53..a3804cc12e 100644 --- a/src/components/common/Badge.test.ts +++ b/src/components/common/Badge.test.ts @@ -42,7 +42,7 @@ describe('Badge', () => { }) describe('twMerge preserves color alongside text-3xs font size', () => { - it.each([ + it.for([ ['default', 'text-white'], ['secondary', 'text-white'], ['warn', 'text-white'], @@ -50,7 +50,7 @@ describe('Badge', () => { ['contrast', 'text-base-background'] ] as const)( '%s severity retains its text color class', - (severity, expectedColor) => { + ([severity, expectedColor]) => { const classes = badgeVariants({ severity, variant: 'label' }) expect(classes).toContain(expectedColor) expect(classes).toContain('text-3xs') diff --git a/src/components/common/CustomizationDialog.test.ts b/src/components/common/CustomizationDialog.test.ts new file mode 100644 index 0000000000..75c2c55c04 --- /dev/null +++ b/src/components/common/CustomizationDialog.test.ts @@ -0,0 +1,117 @@ +import { render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { createI18n } from 'vue-i18n' + +import CustomizationDialog from './CustomizationDialog.vue' + +const DEFAULT_ICON = 'pi-bookmark-fill' +const DEFAULT_COLOR = '#a1a1aa' + +vi.mock('@/stores/nodeBookmarkStore', () => ({ + useNodeBookmarkStore: () => ({ + defaultBookmarkIcon: DEFAULT_ICON, + defaultBookmarkColor: DEFAULT_COLOR, + bookmarksCustomization: {} + }) +})) + +vi.mock('primevue/selectbutton', () => ({ + default: { + name: 'SelectButton', + template: '
', + props: ['modelValue', 'options'] + } +})) + +vi.mock('@/components/ui/dialog/Dialog.vue', () => ({ + default: { name: 'Dialog', template: '
' } +})) +vi.mock('@/components/ui/dialog/DialogPortal.vue', () => ({ + default: { name: 'DialogPortal', template: '
' } +})) +vi.mock('@/components/ui/dialog/DialogOverlay.vue', () => ({ + default: { name: 'DialogOverlay', template: '
' } +})) +vi.mock('@/components/ui/dialog/DialogContent.vue', () => ({ + default: { name: 'DialogContent', template: '
' } +})) +vi.mock('@/components/ui/dialog/DialogHeader.vue', () => ({ + default: { name: 'DialogHeader', template: '
' } +})) +vi.mock('@/components/ui/dialog/DialogFooter.vue', () => ({ + default: { name: 'DialogFooter', template: '
' } +})) +vi.mock('@/components/ui/dialog/DialogTitle.vue', () => ({ + default: { name: 'DialogTitle', template: '
' } +})) +vi.mock('@/components/ui/dialog/DialogClose.vue', () => ({ + default: { name: 'DialogClose', template: '`, + emits: ['click'] + } +})) + +const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } }) + +function renderDialog(extraProps: Record = {}) { + const onConfirm = vi.fn() + render(CustomizationDialog, { + global: { plugins: [i18n] }, + props: { modelValue: true, onConfirm, ...extraProps } + }) + return { onConfirm } +} + +describe('CustomizationDialog', () => { + describe('confirmCustomization', () => { + it('emits confirm with default icon and color when no initial values provided', async () => { + const user = userEvent.setup() + const { onConfirm } = renderDialog() + + await user.click(screen.getByText('g.confirm')) + + expect(onConfirm).toHaveBeenCalledWith(DEFAULT_ICON, DEFAULT_COLOR) + }) + + it('emits confirm with matching initialIcon when provided', async () => { + const user = userEvent.setup() + const { onConfirm } = renderDialog({ initialIcon: 'pi-star' }) + + await user.click(screen.getByText('g.confirm')) + + expect(onConfirm).toHaveBeenCalledWith('pi-star', DEFAULT_COLOR) + }) + + it('falls back to default icon when initialIcon does not match any option', async () => { + const user = userEvent.setup() + const { onConfirm } = renderDialog({ initialIcon: 'pi-nonexistent' }) + + await user.click(screen.getByText('g.confirm')) + + expect(onConfirm).toHaveBeenCalledWith(DEFAULT_ICON, DEFAULT_COLOR) + }) + + it('emits confirm with initialColor when provided', async () => { + const user = userEvent.setup() + const { onConfirm } = renderDialog({ initialColor: '#007bff' }) + + await user.click(screen.getByText('g.confirm')) + + expect(onConfirm).toHaveBeenCalledWith(DEFAULT_ICON, '#007bff') + }) + }) +}) diff --git a/src/components/common/CustomizationDialog.vue b/src/components/common/CustomizationDialog.vue index f5df6d84d7..12eac61a1d 100644 --- a/src/components/common/CustomizationDialog.vue +++ b/src/components/common/CustomizationDialog.vue @@ -1,72 +1,111 @@ diff --git a/src/components/graph/selectionToolbox/ColorPickerMenu.vue b/src/components/graph/selectionToolbox/SubmenuPopover.vue similarity index 96% rename from src/components/graph/selectionToolbox/ColorPickerMenu.vue rename to src/components/graph/selectionToolbox/SubmenuPopover.vue index 188b5ed397..31d76bf3c5 100644 --- a/src/components/graph/selectionToolbox/ColorPickerMenu.vue +++ b/src/components/graph/selectionToolbox/SubmenuPopover.vue @@ -8,7 +8,7 @@ unstyled :pt="{ root: { - class: 'absolute z-60' + class: 'p-popover absolute z-60' }, content: { class: [ @@ -90,8 +90,12 @@ const popoverRef = ref>() const toggle = (event: Event, target?: HTMLElement) => { popoverRef.value?.toggle(event, target) } +const hide = () => { + popoverRef.value?.hide() +} defineExpose({ - toggle + toggle, + hide }) const handleSubmenuClick = (subOption: SubMenuOption) => { diff --git a/src/components/helpcenter/HelpCenterMenuContent.test.ts b/src/components/helpcenter/HelpCenterMenuContent.test.ts new file mode 100644 index 0000000000..55819cf355 --- /dev/null +++ b/src/components/helpcenter/HelpCenterMenuContent.test.ts @@ -0,0 +1,164 @@ +import { cleanup, render, screen } from '@testing-library/vue' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, h } from 'vue' +import { createI18n } from 'vue-i18n' + +import enMessages from '@/locales/en/main.json' with { type: 'json' } + +import HelpCenterMenuContent from './HelpCenterMenuContent.vue' + +const distribution = vi.hoisted(() => ({ + isCloud: false, + isDesktop: false, + isNightly: false +})) + +const commandStoreExecute = vi.hoisted(() => vi.fn()) + +vi.mock('@/platform/distribution/types', () => ({ + get isCloud() { + return distribution.isCloud + }, + get isDesktop() { + return distribution.isDesktop + }, + get isNightly() { + return distribution.isNightly + } +})) + +vi.mock('@/composables/useExternalLink', () => ({ + useExternalLink: () => ({ + staticUrls: { discord: '', github: '' }, + buildDocsUrl: () => 'https://docs.comfy.org' + }) +})) + +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: () => ({ + get: () => false + }) +})) + +vi.mock('@/platform/telemetry', () => ({ + useTelemetry: () => ({ + trackHelpResourceClicked: vi.fn(), + trackHelpCenterOpened: vi.fn(), + trackHelpCenterClosed: vi.fn() + }) +})) + +vi.mock('@/platform/updates/common/releaseStore', () => ({ + useReleaseStore: () => ({ + releases: [], + recentReleases: [], + isLoading: false, + fetchReleases: vi.fn().mockResolvedValue(undefined) + }) +})) + +vi.mock('@/stores/commandStore', () => ({ + useCommandStore: () => ({ execute: commandStoreExecute }) +})) + +vi.mock('@/utils/envUtil', () => ({ + electronAPI: () => null +})) + +vi.mock( + '@/workbench/extensions/manager/composables/useConflictAcknowledgment', + () => ({ + useConflictAcknowledgment: () => ({ shouldShowRedDot: { value: false } }) + }) +) + +vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({ + useManagerState: () => ({ isNewManagerUI: { value: false } }) +})) + +vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({ + useComfyManagerService: () => ({}) +})) + +vi.mock('primevue/usetoast', () => ({ + useToast: () => ({ add: vi.fn() }) +})) + +vi.mock('@/components/icons/PuzzleIcon.vue', () => ({ + default: defineComponent({ + name: 'PuzzleIconStub', + render: () => h('div') + }) +})) + +function renderComponent() { + const user = userEvent.setup() + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: enMessages } + }) + + const result = render(HelpCenterMenuContent, { + global: { + plugins: [i18n] + } + }) + + return { user, ...result } +} + +describe('HelpCenterMenuContent feedback item', () => { + let openSpy: ReturnType + + beforeEach(() => { + distribution.isCloud = false + distribution.isDesktop = false + distribution.isNightly = false + commandStoreExecute.mockReset() + openSpy = vi.spyOn(window, 'open').mockReturnValue(null) + }) + + afterEach(() => { + openSpy.mockRestore() + cleanup() + }) + + it('opens the Typeform survey tagged with help-center source on Cloud', async () => { + distribution.isCloud = true + const { user } = renderComponent() + + await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' })) + + expect(openSpy).toHaveBeenCalledWith( + 'https://form.typeform.com/to/q7azbWPi#distribution=ccloud&source=help-center', + '_blank', + 'noopener,noreferrer' + ) + expect(commandStoreExecute).not.toHaveBeenCalled() + }) + + it('opens the Typeform survey tagged with help-center source on Nightly', async () => { + distribution.isNightly = true + const { user } = renderComponent() + + await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' })) + + expect(openSpy).toHaveBeenCalledWith( + 'https://form.typeform.com/to/q7azbWPi#distribution=oss-nightly&source=help-center', + '_blank', + 'noopener,noreferrer' + ) + expect(commandStoreExecute).not.toHaveBeenCalled() + }) + + it('falls back to Comfy.ContactSupport on OSS builds', async () => { + const { user } = renderComponent() + + await user.click(screen.getByRole('menuitem', { name: 'Give Feedback' })) + + expect(openSpy).not.toHaveBeenCalled() + expect(commandStoreExecute).toHaveBeenCalledWith('Comfy.ContactSupport') + }) +}) diff --git a/src/components/helpcenter/HelpCenterMenuContent.vue b/src/components/helpcenter/HelpCenterMenuContent.vue index 5773083340..7deab72887 100644 --- a/src/components/helpcenter/HelpCenterMenuContent.vue +++ b/src/components/helpcenter/HelpCenterMenuContent.vue @@ -163,6 +163,7 @@ import PuzzleIcon from '@/components/icons/PuzzleIcon.vue' import { useExternalLink } from '@/composables/useExternalLink' import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types' import { useSettingStore } from '@/platform/settings/settingStore' +import { buildFeedbackTypeformUrl } from '@/platform/support/config' import { useTelemetry } from '@/platform/telemetry' import type { ReleaseNote } from '@/platform/updates/common/releaseService' import { useReleaseStore } from '@/platform/updates/common/releaseStore' @@ -306,7 +307,7 @@ const menuItems = computed(() => { trackResourceClick('help_feedback', isCloud || isNightly) if (isCloud || isNightly) { window.open( - 'https://form.typeform.com/to/q7azbWPi', + buildFeedbackTypeformUrl('help-center'), '_blank', 'noopener,noreferrer' ) diff --git a/src/components/load3d/Load3D.vue b/src/components/load3d/Load3D.vue index a0244a4fe8..b145fb8464 100644 --- a/src/components/load3d/Load3D.vue +++ b/src/components/load3d/Load3D.vue @@ -4,8 +4,6 @@ @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave" @pointerdown.stop - @pointermove.stop - @pointerup.stop > { await user.click(screen.getByRole('button', { name: label })) } - it.each([ + it.for([ ['Model', 'model-controls'], ['Camera', 'camera-controls'] - ])('%s category renders only %s', async (label, testId) => { + ])('%s category renders only %s', async ([label, testId]) => { const { user } = renderControls() await selectCategory(user, label) @@ -315,12 +315,12 @@ describe('Load3DControls', () => { ).not.toBeInTheDocument() }) - it.each([ + it.for([ ['Gizmo', 'gizmo-controls', 'canUseGizmo' as const], ['Export', 'export-controls', 'canExport' as const] ])( 'hides the %s panel when its capability flips off at runtime', - async (label, testId, capabilityProp) => { + async ([label, testId, capabilityProp]) => { const { user, rerender } = renderControls() await openMenu(user) diff --git a/src/components/load3d/Load3DScene.vue b/src/components/load3d/Load3DScene.vue index c1334142b0..de96690d3a 100644 --- a/src/components/load3d/Load3DScene.vue +++ b/src/components/load3d/Load3DScene.vue @@ -5,11 +5,7 @@ data-capture-wheel="true" tabindex="-1" @pointerdown.stop="focusContainer" - @pointermove.stop - @pointerup.stop @mousedown.stop - @mousemove.stop - @mouseup.stop @contextmenu.stop.prevent @dragover.prevent.stop="handleDragOver" @dragleave.stop="handleDragLeave" diff --git a/src/components/load3d/controls/AnimationControls.vue b/src/components/load3d/controls/AnimationControls.vue index 5cdfb7b345..0e676292da 100644 --- a/src/components/load3d/controls/AnimationControls.vue +++ b/src/components/load3d/controls/AnimationControls.vue @@ -21,20 +21,42 @@
@@ -54,10 +76,14 @@ diff --git a/src/components/load3d/controls/viewer/ViewerExportControls.test.ts b/src/components/load3d/controls/viewer/ViewerExportControls.test.ts index 800fd3d70b..43fc98a695 100644 --- a/src/components/load3d/controls/viewer/ViewerExportControls.test.ts +++ b/src/components/load3d/controls/viewer/ViewerExportControls.test.ts @@ -5,22 +5,65 @@ import { createI18n } from 'vue-i18n' import ViewerExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue' -vi.mock('primevue/select', () => ({ - default: { - name: 'Select', - props: ['modelValue', 'options', 'optionLabel', 'optionValue'], - emits: ['update:modelValue'], - template: ` - - ` +vi.mock('@/components/ui/select/Select.vue', async () => { + const { provide } = await import('vue') + return { + default: { + name: 'Select', + props: ['modelValue'], + emits: ['update:modelValue'], + setup( + props: { modelValue: string }, + { emit }: { emit: (event: string, value: string) => void } + ) { + provide('selectModelValue', (): string => props.modelValue) + provide('selectUpdate', (v: string): void => + emit('update:modelValue', v) + ) + }, + template: '
' + } } +}) + +vi.mock('@/components/ui/select/SelectContent.vue', async () => { + const { inject, ref, onMounted } = await import('vue') + return { + default: { + name: 'SelectContent', + setup() { + const selectModelValue = inject<() => string>('selectModelValue') + const selectUpdate = inject<(v: string) => void>('selectUpdate') + const el = ref(null) + onMounted(() => { + if (el.value) el.value.value = selectModelValue?.() ?? '' + }) + return { + el, + onChange: (e: Event) => { + selectUpdate?.((e.target as HTMLSelectElement).value) + } + } + }, + template: '' + } + } +}) + +vi.mock('@/components/ui/select/SelectItem.vue', () => ({ + default: { + name: 'SelectItem', + props: ['value'], + template: '' + } +})) + +vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({ + default: { name: 'SelectTrigger', template: '' } +})) + +vi.mock('@/components/ui/select/SelectValue.vue', () => ({ + default: { name: 'SelectValue', template: '' } })) const i18n = createI18n({ @@ -38,12 +81,12 @@ function renderComponent(onExportModel?: (format: string) => void) { } describe('ViewerExportControls', () => { - it('renders all three export format options', () => { + it('renders all four export format options', () => { renderComponent() const select = screen.getByRole('combobox') as HTMLSelectElement const optionValues = Array.from(select.options).map((o) => o.value) - expect(optionValues).toEqual(['glb', 'obj', 'stl']) + expect(optionValues).toEqual(['glb', 'obj', 'stl', 'fbx']) }) it('defaults the export format to obj', () => { diff --git a/src/components/load3d/controls/viewer/ViewerExportControls.vue b/src/components/load3d/controls/viewer/ViewerExportControls.vue index 7c19710374..054a424702 100644 --- a/src/components/load3d/controls/viewer/ViewerExportControls.vue +++ b/src/components/load3d/controls/viewer/ViewerExportControls.vue @@ -1,11 +1,18 @@ diff --git a/src/components/queue/JobHistoryActionsMenu.vue b/src/components/queue/JobHistoryActionsMenu.vue index 3e021fb029..4b2f07764f 100644 --- a/src/components/queue/JobHistoryActionsMenu.vue +++ b/src/components/queue/JobHistoryActionsMenu.vue @@ -98,6 +98,7 @@ import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags' import { buildTooltipConfig } from '@/composables/useTooltipConfig' import { isCloud } from '@/platform/distribution/types' import { useSettingStore } from '@/platform/settings/settingStore' +import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking' import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' const emit = defineEmits<{ @@ -107,6 +108,7 @@ const emit = defineEmits<{ const { t } = useI18n() const settingStore = useSettingStore() const sidebarTabStore = useSidebarTabStore() +const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay') const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more'))) const { isQueuePanelV2Enabled, isRunProgressBarEnabled } = @@ -119,6 +121,7 @@ const onClearHistoryFromMenu = (close: () => void) => { } const onToggleDockedJobHistory = async (close: () => void) => { + trackFeatureUsed() close() try { @@ -138,6 +141,7 @@ const onToggleDockedJobHistory = async (close: () => void) => { } const onToggleRunProgressBar = async () => { + trackFeatureUsed() await settingStore.set( 'Comfy.Queue.ShowRunProgressBar', !isRunProgressBarEnabled.value diff --git a/src/components/queue/QueueNotificationBannerHost.vue b/src/components/queue/QueueNotificationBannerHost.vue index 48fb3e2247..34fccdc832 100644 --- a/src/components/queue/QueueNotificationBannerHost.vue +++ b/src/components/queue/QueueNotificationBannerHost.vue @@ -5,6 +5,7 @@ role="status" aria-live="polite" aria-atomic="true" + data-testid="queue-notification-banner" >
diff --git a/src/components/queue/QueueOverlayExpanded.vue b/src/components/queue/QueueOverlayExpanded.vue index db2cbe846f..e049ffa33b 100644 --- a/src/components/queue/QueueOverlayExpanded.vue +++ b/src/components/queue/QueueOverlayExpanded.vue @@ -13,7 +13,7 @@ :selected-sort-mode="selectedSortMode" :has-failed-jobs="hasFailedJobs" @show-assets="$emit('showAssets')" - @update:selected-job-tab="$emit('update:selectedJobTab', $event)" + @update:selected-job-tab="onUpdateSelectedJobTab" @update:selected-workflow-filter=" $emit('update:selectedWorkflowFilter', $event) " @@ -50,6 +50,7 @@ import type { import type { MenuEntry } from '@/composables/queue/useJobMenu' import { useJobMenu } from '@/composables/queue/useJobMenu' import { useErrorHandling } from '@/composables/useErrorHandling' +import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking' import QueueOverlayHeader from './QueueOverlayHeader.vue' import JobContextMenu from './job/JobContextMenu.vue' @@ -81,6 +82,7 @@ const emit = defineEmits<{ const currentMenuItem = ref(null) const jobContextMenuRef = ref | null>(null) const { wrapWithErrorHandlingAsync } = useErrorHandling() +const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay') const { jobMenuEntries } = useJobMenu( () => currentMenuItem.value, @@ -95,6 +97,11 @@ const onDeleteItemEvent = (item: JobListItem) => { emit('deleteItem', item) } +const onUpdateSelectedJobTab = (value: JobTab) => { + trackFeatureUsed() + emit('update:selectedJobTab', value) +} + const onMenuItem = (item: JobListItem, event: Event) => { currentMenuItem.value = item jobContextMenuRef.value?.open(event) diff --git a/src/components/queue/QueueProgressOverlay.vue b/src/components/queue/QueueProgressOverlay.vue index fb05c0b8d4..38cfae3146 100644 --- a/src/components/queue/QueueProgressOverlay.vue +++ b/src/components/queue/QueueProgressOverlay.vue @@ -4,6 +4,7 @@ :class="['flex', 'justify-end', 'w-full', 'pointer-events-none']" >
groupedJobItems.value) const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => { + trackFeatureUsed() const jobId = item.taskRef?.jobId if (!jobId) return @@ -209,6 +213,7 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => { }) const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => { + trackFeatureUsed() if (!item.taskRef) return await queueStore.delete(item.taskRef) }) @@ -224,10 +229,12 @@ const setExpanded = (expanded: boolean) => { } const viewAllJobs = () => { + trackFeatureUsed() setExpanded(true) } const toggleAssetsSidebar = () => { + trackFeatureUsed() sidebarTabStore.toggleSidebarTab('assets') } @@ -257,12 +264,14 @@ const focusAssetInSidebar = async (item: JobListItem) => { const inspectJobAsset = wrapWithErrorHandlingAsync( async (item: JobListItem) => { + trackFeatureUsed() await openResultGallery(item) await focusAssetInSidebar(item) } ) const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => { + trackFeatureUsed() // Capture pending jobIds before clearing const pendingJobIds = queueStore.pendingTasks .map((task) => task.jobId) @@ -275,6 +284,7 @@ const cancelQueuedWorkflows = wrapWithErrorHandlingAsync(async () => { }) const interruptAll = wrapWithErrorHandlingAsync(async () => { + trackFeatureUsed() const tasks = queueStore.runningTasks const jobIds = tasks .map((task) => task.jobId) @@ -298,6 +308,7 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => { }) const onClearHistoryFromMenu = () => { + trackFeatureUsed() showQueueClearHistoryDialog() } diff --git a/src/components/queue/job/JobAssetsList.test.ts b/src/components/queue/job/JobAssetsList.test.ts index d1f673ffb9..6ddff89c02 100644 --- a/src/components/queue/job/JobAssetsList.test.ts +++ b/src/components/queue/job/JobAssetsList.test.ts @@ -4,6 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/vue' import userEvent from '@testing-library/user-event' import { afterEach, describe, expect, it, vi } from 'vitest' import { defineComponent, nextTick } from 'vue' +import type * as RekaUi from 'reka-ui' import './testUtils/mockTanstackVirtualizer' @@ -27,6 +28,85 @@ vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({ default: hoisted.jobDetailsPopoverStub })) +vi.mock('reka-ui', async (importOriginal) => { + const actual = await importOriginal() + const { computed, defineComponent, h, inject, provide } = await import('vue') + const popoverOpenKey = Symbol('popoverOpen') + + return { + ...actual, + PopoverContent: defineComponent({ + name: 'PopoverContent', + props: { + align: { type: String, default: undefined }, + avoidCollisions: { type: Boolean, default: undefined }, + collisionPadding: { type: Number, default: undefined }, + hideWhenDetached: { type: Boolean, default: undefined }, + positionStrategy: { type: String, default: undefined }, + reference: { type: null, default: undefined }, + side: { type: String, default: undefined }, + sideFlip: { type: Boolean, default: undefined }, + sideOffset: { type: Number, default: undefined }, + sticky: { type: String, default: undefined } + }, + emits: ['mouseenter', 'mouseleave'], + setup(props, { attrs, emit, slots }) { + const isOpen = inject( + popoverOpenKey, + computed(() => false) + ) + return () => + isOpen.value + ? h( + 'div', + { + class: attrs.class, + 'data-align': props.align, + 'data-avoid-collisions': props.avoidCollisions, + 'data-collision-padding': props.collisionPadding, + 'data-hide-when-detached': props.hideWhenDetached, + 'data-position-strategy': props.positionStrategy, + 'data-reference-bound': props.reference ? 'true' : 'false', + 'data-side': props.side, + 'data-side-flip': props.sideFlip, + 'data-side-offset': props.sideOffset, + 'data-sticky': props.sticky, + onMouseenter: () => emit('mouseenter'), + onMouseleave: () => emit('mouseleave') + }, + slots.default?.() + ) + : null + } + }), + PopoverPortal: { + name: 'PopoverPortal', + template: '
' + }, + PopoverRoot: defineComponent({ + name: 'PopoverRoot', + props: { + open: { type: Boolean, default: false } + }, + setup(props, { slots }) { + provide( + popoverOpenKey, + computed(() => props.open) + ) + + return () => + h( + 'div', + { + 'data-open': props.open + }, + slots.default?.() + ) + } + }) + } +}) + const AssetsListItemStub = defineComponent({ name: 'AssetsListItem', props: { @@ -72,6 +152,7 @@ vi.mock('vue-i18n', () => { type TestPreviewOutput = { url: string + previewUrl: string isImage: boolean isVideo: boolean } @@ -96,6 +177,7 @@ const createPreviewOutput = ( const url = `/api/view/${filename}` return { url, + previewUrl: mediaType === 'images' ? `${url}?res=512` : url, isImage: mediaType === 'images', isVideo: mediaType === 'video' } @@ -153,30 +235,6 @@ function renderJobAssetsList({ return { ...result, user } } -function createDomRect({ - top, - left, - width, - height -}: { - top: number - left: number - width: number - height: number -}): DOMRect { - return { - x: left, - y: top, - top, - left, - width, - height, - right: left + width, - bottom: top + height, - toJSON: () => '' - } as DOMRect -} - afterEach(() => { vi.useRealTimers() vi.restoreAllMocks() @@ -240,6 +298,18 @@ describe('JobAssetsList', () => { expect(onViewItem).toHaveBeenCalledWith(job) }) + it('uses thumbnail preview URLs for completed image rows', () => { + const preview = createPreviewOutput('job-1.png') + const job = buildJob({ + taskRef: createTaskRef(preview) + }) + const { container } = renderJobAssetsList({ jobs: [job] }) + + const stubRoot = container.querySelector('.assets-list-item-stub')! + expect(stubRoot.getAttribute('data-preview-url')).toBe(preview.previewUrl) + expect(stubRoot.getAttribute('data-preview-url')).not.toBe(preview.url) + }) + it('emits viewItem on double-click for completed jobs with preview', async () => { const job = buildJob() const onViewItem = vi.fn() @@ -378,54 +448,24 @@ describe('JobAssetsList', () => { expect(container.querySelector('.job-details-popover-stub')).toBeNull() }) - it('positions the popover to the right of rows near the left viewport edge', async () => { + it('anchors the popover to the active row through Reka', async () => { vi.useFakeTimers() const job = buildJob() const { container } = renderJobAssetsList({ jobs: [job] }) const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)! - vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280) - vi.spyOn(jobRow, 'getBoundingClientRect').mockReturnValue( - createDomRect({ - top: 100, - left: 40, - width: 200, - height: 48 - }) - ) - await fireEvent.mouseEnter(jobRow) await vi.advanceTimersByTimeAsync(200) await nextTick() const popover = container.querySelector('.job-details-popover')! - expect(popover.getAttribute('style')).toContain('left: 248px;') - }) - - it('positions the popover to the left of rows near the right viewport edge', async () => { - vi.useFakeTimers() - const job = buildJob() - const { container } = renderJobAssetsList({ jobs: [job] }) - - const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)! - - vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280) - vi.spyOn(jobRow, 'getBoundingClientRect').mockReturnValue( - createDomRect({ - top: 100, - left: 980, - width: 200, - height: 48 - }) - ) - - await fireEvent.mouseEnter(jobRow) - await vi.advanceTimersByTimeAsync(200) - await nextTick() - - const popover = container.querySelector('.job-details-popover')! - expect(popover.getAttribute('style')).toContain('left: 672px;') + expect(popover.getAttribute('data-avoid-collisions')).toBe('true') + expect(popover.getAttribute('data-hide-when-detached')).toBe('true') + expect(popover.getAttribute('data-reference-bound')).toBe('true') + expect(popover.getAttribute('data-side')).toBe('right') + expect(popover.getAttribute('data-side-flip')).toBe('true') + expect(popover.getAttribute('data-position-strategy')).toBe('fixed') }) it('clears the previous popover when hovering a new row briefly and leaving the list', async () => { @@ -458,7 +498,7 @@ describe('JobAssetsList', () => { expect(container.querySelector('.job-details-popover-stub')).toBeNull() }) - it('shows the new popover after the previous row hides while the next row stays hovered', async () => { + it('updates the visible popover without closing when hovering another row', async () => { vi.useFakeTimers() const firstJob = buildJob({ id: 'job-1' }) const secondJob = buildJob({ id: 'job-2', title: 'Job 2' }) @@ -479,17 +519,21 @@ describe('JobAssetsList', () => { await fireEvent.mouseLeave(firstRow) await fireEvent.mouseEnter(secondRow) - - await vi.advanceTimersByTimeAsync(150) - await nextTick() - expect(container.querySelector('.job-details-popover-stub')).toBeNull() - - await vi.advanceTimersByTimeAsync(50) await nextTick() - const popoverStub = container.querySelector('.job-details-popover-stub')! - expect(popoverStub).not.toBeNull() - expect(popoverStub.getAttribute('data-job-id')).toBe('job-2') + expect( + container + .querySelector('.job-details-popover-stub') + ?.getAttribute('data-job-id') + ).toBe('job-2') + + await vi.advanceTimersByTimeAsync(200) + await nextTick() + expect( + container + .querySelector('.job-details-popover-stub') + ?.getAttribute('data-job-id') + ).toBe('job-2') }) it('does not show details if the hovered row disappears before the show delay ends', async () => { diff --git a/src/components/queue/job/JobAssetsList.vue b/src/components/queue/job/JobAssetsList.vue index 5e94fc5ddd..552b38084c 100644 --- a/src/components/queue/job/JobAssetsList.vue +++ b/src/components/queue/job/JobAssetsList.vue @@ -90,23 +90,15 @@
- -
- -
-
+ diff --git a/src/components/queue/job/JobDetailsHoverPopover.vue b/src/components/queue/job/JobDetailsHoverPopover.vue new file mode 100644 index 0000000000..41b2369848 --- /dev/null +++ b/src/components/queue/job/JobDetailsHoverPopover.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/components/queue/job/JobDetailsPopover.stories.ts b/src/components/queue/job/JobDetailsPopover.stories.ts index 3e9d4781b2..6ee7ed1b5e 100644 --- a/src/components/queue/job/JobDetailsPopover.stories.ts +++ b/src/components/queue/job/JobDetailsPopover.stories.ts @@ -423,3 +423,44 @@ export const Failed: Story = { ` }) } + +export const FailedWithLongError: Story = { + render: (args) => ({ + components: { JobDetailsPopover }, + setup() { + resetStores() + const queue = useQueueStore() + + const jobId = 'job-failed-long-1' + const priority = 411 + const longError = + `Failed to send prompt request: request returned error status 400: ` + + `{"error": {"type": "prompt_outputs_failed_validation", "message": ` + + `"Prompt outputs failed validation", "details": "", "extra_info": {}}, ` + + `"node_errors": {"4": {"errors": [{"type": "value_not_in_list", ` + + `"message": "Value not in list", "details": "ckpt_name: ` + + `'__this_checkpoint_does_not_exist__.safetensors' not in ` + + `['JANKUTrainedChenkinNoobai_v777.safetensors', ` + + `'NetaYumevXX_pretrained_all_in_one.safetensors', ` + + `'animagineXL_v31Inpainting.safetensors', ` + + `'animayhemPaleRider_v30PlainsDrifter.safetensors', ` + + `'bananaSplitzXL_bananaSplitzXXL.safetensors', ` + + `'cyberrealistic_final.safetensors', ` + + `'dreamshaper_8.safetensors', ` + + `'intorealism_sdxlV4.safetensors', ` + + `'kleinova_10BF16.safetensors', 'ltx-2-19b-distilled-fp8.safetensors', ` + + `'ltx-2-3-22b-dev-fp8.safetensors', 'ltx-2-3-22b-distilled-fp8.safetensors', ` + + `'moodyDesireMix_v10.safetensors', 'perfectionSuperModelILXL_20.safetensors']"}]}}}` + queue.historyTasks = [ + makeHistoryTask(jobId, priority, 12, false, longError) + ] + + return { args: { ...args, jobId } } + }, + template: ` +
+ +
+ ` + }) +} diff --git a/src/components/queue/job/JobDetailsPopover.vue b/src/components/queue/job/JobDetailsPopover.vue index 55a391c096..0e6ff9556a 100644 --- a/src/components/queue/job/JobDetailsPopover.vue +++ b/src/components/queue/job/JobDetailsPopover.vue @@ -78,7 +78,7 @@
{{ errorMessageValue }}
diff --git a/src/components/queue/job/JobFilterActions.vue b/src/components/queue/job/JobFilterActions.vue index fce83d2edc..f8c16f23df 100644 --- a/src/components/queue/job/JobFilterActions.vue +++ b/src/components/queue/job/JobFilterActions.vue @@ -122,6 +122,7 @@ import Button from '@/components/ui/button/Button.vue' import { jobSortModes } from '@/composables/queue/useJobList' import type { JobSortMode } from '@/composables/queue/useJobList' import { buildTooltipConfig } from '@/composables/useTooltipConfig' +import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking' const { hideShowAssetsAction = false, @@ -147,6 +148,7 @@ const emit = defineEmits<{ }>() const { t } = useI18n() +const { trackFeatureUsed } = useSurveyFeatureTracking('queue-progress-overlay') const filterTooltipConfig = computed(() => buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy')) @@ -170,6 +172,7 @@ const onSelectWorkflowFilter = ( value: 'all' | 'current', close: () => void ) => { + trackFeatureUsed() selectWorkflowFilter(value) close() } @@ -179,6 +182,7 @@ const selectSortMode = (value: JobSortMode) => { } const onSelectSortMode = (value: JobSortMode, close: () => void) => { + trackFeatureUsed() selectSortMode(value) close() } diff --git a/src/components/queue/job/getHoverPopoverPosition.test.ts b/src/components/queue/job/getHoverPopoverPosition.test.ts deleted file mode 100644 index 9cd8595ccf..0000000000 --- a/src/components/queue/job/getHoverPopoverPosition.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { getHoverPopoverPosition } from './getHoverPopoverPosition' - -describe('getHoverPopoverPosition', () => { - it('places the popover to the right when space is available', () => { - const position = getHoverPopoverPosition( - { top: 100, left: 40, right: 240 }, - 1280 - ) - expect(position).toEqual({ top: 100, left: 248 }) - }) - - it('places the popover to the left when right space is insufficient', () => { - const position = getHoverPopoverPosition( - { top: 100, left: 980, right: 1180 }, - 1280 - ) - expect(position).toEqual({ top: 100, left: 672 }) - }) - - it('clamps the top to viewport padding when rect.top is near the top edge', () => { - const position = getHoverPopoverPosition( - { top: 2, left: 40, right: 240 }, - 1280 - ) - expect(position).toEqual({ top: 8, left: 248 }) - }) - - it('clamps left to viewport padding when fallback would go off-screen', () => { - const position = getHoverPopoverPosition( - { top: 100, left: 100, right: 300 }, - 320 - ) - expect(position).toEqual({ top: 100, left: 8 }) - }) - - it('prefers right when both sides have equal space', () => { - const position = getHoverPopoverPosition( - { top: 200, left: 340, right: 640 }, - 1280 - ) - expect(position).toEqual({ top: 200, left: 648 }) - }) - - it('falls back to left when right space is less than popover width', () => { - const position = getHoverPopoverPosition( - { top: 100, left: 600, right: 1000 }, - 1280 - ) - expect(position).toEqual({ top: 100, left: 292 }) - }) - - it('handles narrow viewport where popover barely fits', () => { - const position = getHoverPopoverPosition( - { top: 50, left: 8, right: 100 }, - 316 - ) - expect(position).toEqual({ top: 50, left: 8 }) - }) -}) diff --git a/src/components/queue/job/getHoverPopoverPosition.ts b/src/components/queue/job/getHoverPopoverPosition.ts deleted file mode 100644 index b339b78997..0000000000 --- a/src/components/queue/job/getHoverPopoverPosition.ts +++ /dev/null @@ -1,39 +0,0 @@ -const POPOVER_GAP = 8 -const POPOVER_WIDTH = 300 -const VIEWPORT_PADDING = 8 - -type AnchorRect = Pick - -type HoverPopoverPosition = { - top: number - left: number -} - -export function getHoverPopoverPosition( - rect: AnchorRect, - viewportWidth: number -): HoverPopoverPosition { - const availableLeft = rect.left - POPOVER_GAP - const availableRight = viewportWidth - rect.right - POPOVER_GAP - const preferredLeft = rect.right + POPOVER_GAP - const fallbackLeft = rect.left - POPOVER_WIDTH - POPOVER_GAP - const maxLeft = Math.max( - VIEWPORT_PADDING, - viewportWidth - POPOVER_WIDTH - VIEWPORT_PADDING - ) - - if ( - availableRight >= POPOVER_WIDTH && - (availableRight >= availableLeft || availableLeft < POPOVER_WIDTH) - ) { - return { - top: Math.max(VIEWPORT_PADDING, rect.top), - left: Math.min(maxLeft, preferredLeft) - } - } - - return { - top: Math.max(VIEWPORT_PADDING, rect.top), - left: Math.max(VIEWPORT_PADDING, Math.min(maxLeft, fallbackLeft)) - } -} diff --git a/src/components/rightSidePanel/RightSidePanel.vue b/src/components/rightSidePanel/RightSidePanel.vue index 2b743641f7..76d2ccaf22 100644 --- a/src/components/rightSidePanel/RightSidePanel.vue +++ b/src/components/rightSidePanel/RightSidePanel.vue @@ -370,7 +370,7 @@ function handleTitleCancel() { -
+
diff --git a/src/components/rightSidePanel/parameters/TabNormalInputs.vue b/src/components/rightSidePanel/parameters/TabNormalInputs.vue index e104b98f4c..c6ed795eb9 100644 --- a/src/components/rightSidePanel/parameters/TabNormalInputs.vue +++ b/src/components/rightSidePanel/parameters/TabNormalInputs.vue @@ -3,9 +3,9 @@ import { storeToRefs } from 'pinia' import { computed, reactive, ref, shallowRef, watch } from 'vue' import { useI18n } from 'vue-i18n' -import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph' import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue' -import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue' +import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore' @@ -68,19 +68,19 @@ watch( } ) -function isSectionCollapsed(nodeId: string): boolean { +function isSectionCollapsed(nodeId: NodeId): boolean { // When not explicitly set, sections are collapsed if multiple nodes are selected return collapseMap[nodeId] ?? isMultipleNodesSelected.value } -function setSectionCollapsed(nodeId: string, collapsed: boolean) { +function setSectionCollapsed(nodeId: NodeId, collapsed: boolean) { collapseMap[nodeId] = collapsed } const isAllCollapsed = computed({ get() { const normalAllCollapsed = searchedWidgetsSectionDataList.value.every( - ({ node }) => isSectionCollapsed(String(node.id)) + ({ node }) => isSectionCollapsed(node.id) ) const hasAdvanced = advancedWidgetsSectionDataList.value.length > 0 return hasAdvanced @@ -89,7 +89,7 @@ const isAllCollapsed = computed({ }, set(collapse: boolean) { for (const { node } of widgetsSectionDataList.value) { - setSectionCollapsed(String(node.id), collapse) + setSectionCollapsed(node.id, collapse) } advancedCollapsed.value = collapse } @@ -122,7 +122,7 @@ const advancedLabel = computed(() => {
- { :node :label :widgets - :collapse="isSectionCollapsed(String(node.id)) && !isSearching" + :collapse="isSectionCollapsed(node.id) && !isSearching" :show-locate-button="isMultipleNodesSelected" :tooltip=" isSearching || widgets.length @@ -162,7 +162,7 @@ const advancedLabel = computed(() => { : t('rightSidePanel.inputsNoneTooltip') " class="border-b border-interface-stroke" - @update:collapse="setSectionCollapsed(String(node.id), $event)" + @update:collapse="setSectionCollapsed(node.id, $event)" />