mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-08 16:17:58 +00:00
Compare commits
8 Commits
uy/agent-p
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dd68bec34 | ||
|
|
4626cb80a8 | ||
|
|
2ec2a0e091 | ||
|
|
9cf5c9a93f | ||
|
|
9e5fb67b76 | ||
|
|
690e0e3590 | ||
|
|
01738b7b19 | ||
|
|
be9de941c9 |
@@ -1,173 +0,0 @@
|
||||
---
|
||||
name: add-model-page
|
||||
description: 'add, update, or remove a model page entry on the comfy org website. creates a PR to Comfy-Org/ComfyUI_frontend apps/website folder with the change and posts a Vercel preview link back to Slack.'
|
||||
---
|
||||
|
||||
# add-model-page
|
||||
|
||||
add, update, or remove model pages in the ComfyUI website.
|
||||
|
||||
## Trigger phrases
|
||||
|
||||
- `Add a model page for <model-name>`
|
||||
- `Update the model page for <model-name>`
|
||||
- `Remove <model-name> from model pages`
|
||||
|
||||
## Phase 1 — Parse the request
|
||||
|
||||
Extract:
|
||||
|
||||
- **action**: `add` | `update` | `remove`
|
||||
- **model-name**: raw string (e.g. `flux1-schnell`, `flux1_dev.safetensors`)
|
||||
|
||||
Normalize to a slug: lowercase, replace `_` and `.` with `-`, strip file extensions.
|
||||
Example: `flux1_dev.safetensors` → `flux1-dev`
|
||||
|
||||
## Architecture overview
|
||||
|
||||
Models come from two sources merged at build time:
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `apps/website/src/config/generated-models.json` | Auto-generated from workflow_templates (slug, name, directory, huggingFaceUrl, workflowCount, displayName, thumbnailUrl, docsUrl) |
|
||||
| `apps/website/src/config/model-metadata.ts` | Hand-curated overrides (docsUrl, blogUrl, featured) — only add entries that need overrides |
|
||||
| `apps/website/src/config/models.ts` | Merges the two above; exports typed `Model[]` |
|
||||
|
||||
To regenerate the JSON from workflow_templates:
|
||||
|
||||
```bash
|
||||
pnpm tsx apps/website/scripts/generate-models.ts
|
||||
```
|
||||
|
||||
This writes `apps/website/src/config/generated-models.json` directly.
|
||||
Thumbnails are populated from local `.webp` files in `workflow_templates/templates/` — no network access needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Gather model data (ADD / UPDATE)
|
||||
|
||||
Run the generator to get fresh data, then find the model:
|
||||
|
||||
```bash
|
||||
pnpm tsx apps/website/scripts/generate-models.ts
|
||||
jq '.[] | select(.slug | contains("MODEL_SLUG"))' \
|
||||
apps/website/src/config/generated-models.json
|
||||
```
|
||||
|
||||
The JSON fields are:
|
||||
|
||||
- `slug` — URL slug
|
||||
- `name` — exact filename or display name for partner nodes
|
||||
- `huggingFaceUrl` — download URL (empty for partner nodes)
|
||||
- `directory` — `diffusion_models` | `loras` | … | `partner_nodes`
|
||||
- `workflowCount` — integer
|
||||
- `displayName` — human-readable name
|
||||
|
||||
If no match and it is a known API/partner model, add it to `API_PROVIDER_MAP` in
|
||||
`generate-models.ts` and re-run. Otherwise tell the user.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Check for existing entry
|
||||
|
||||
```bash
|
||||
jq --arg slug "${SLUG}" '.[] | select(.slug == $slug)' \
|
||||
apps/website/src/config/generated-models.json
|
||||
```
|
||||
|
||||
- Match found + action is `add` → switch to UPDATE flow automatically
|
||||
- No match + action is `update` → stop and tell the user
|
||||
|
||||
---
|
||||
|
||||
## Phase 4A — ADD: new partner/API model not in workflow_templates
|
||||
|
||||
For partner nodes (no local file), add an entry to `API_PROVIDER_MAP` in
|
||||
`apps/website/scripts/generate-models.ts`:
|
||||
|
||||
```typescript
|
||||
mymodel: { name: 'My Model', slug: 'my-model' },
|
||||
```
|
||||
|
||||
Then re-run `pnpm tsx apps/website/scripts/generate-models.ts` — it will appear
|
||||
in `generated-models.json` automatically.
|
||||
|
||||
If you also want a `docsUrl`, `blogUrl`, or a link to the hub model page, add an entry to `model-metadata.ts`:
|
||||
|
||||
```typescript
|
||||
'my-model': {
|
||||
docsUrl: 'https://docs.comfy.org/tutorials/...',
|
||||
blogUrl: 'https://blog.comfy.org/...',
|
||||
hubSlug: 'my-model', // slug at comfy.org/workflows/model/{hubSlug} — only set if the page exists (returns 200)
|
||||
featured: true
|
||||
}
|
||||
```
|
||||
|
||||
No changes to `models.ts` or `translations.ts` are needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4B — UPDATE: edit existing entry
|
||||
|
||||
Only `model-metadata.ts` needs editing for most updates (docsUrl, blogUrl,
|
||||
featured). For `displayName` or `directory` changes, edit the entry directly in
|
||||
`generated-models.json` (until the next generator run would overwrite it — then
|
||||
fix the source in `generate-models.ts`).
|
||||
|
||||
---
|
||||
|
||||
## Phase 4C — REMOVE: delete entry
|
||||
|
||||
Remove the entry from `generated-models.json` (or mark it with `canonicalSlug`
|
||||
pointing to the replacement). No translation file changes needed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Verify TypeScript
|
||||
|
||||
```bash
|
||||
pnpm typecheck 2>&1 | grep -E "error|warning" | head -20
|
||||
```
|
||||
|
||||
Fix any type errors before proceeding. Common issues:
|
||||
|
||||
- `ModelDirectory` type not matching a new `directory` value — add it to the union
|
||||
- JSON import shape mismatch — `generated-models.json` must match `OutputModel`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Create PR
|
||||
|
||||
```bash
|
||||
BRANCH="add-model-page-MODEL-SLUG" # or update- / remove-
|
||||
git checkout -b $BRANCH
|
||||
git add apps/website/src/config/generated-models.json \
|
||||
apps/website/scripts/generate-models.ts \
|
||||
apps/website/src/config/model-metadata.ts
|
||||
git commit -m "feat(models): add model page for MODEL-SLUG"
|
||||
git push -u origin $BRANCH
|
||||
gh pr create \
|
||||
--title "Add model page: MODEL-SLUG" \
|
||||
--body "$(cat <<'EOF'
|
||||
Adds a new model page entry for MODEL-SLUG.
|
||||
|
||||
## Changes
|
||||
- `generated-models.json`: regenerated with new entry (workflowCount N, directory DIRECTORY)
|
||||
- `model-metadata.ts`: editorial overrides (docsUrl, featured) if needed
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
For UPDATE use branch `update-model-page-MODEL-SLUG`.
|
||||
For REMOVE use `remove-model-page-MODEL-SLUG`.
|
||||
|
||||
---
|
||||
|
||||
## Error states
|
||||
|
||||
| Situation | Response |
|
||||
| ------------------------------- | ---------------------------------------------------------------- |
|
||||
| Model not in workflow templates | Ask user to verify spelling or add it manually as a partner node |
|
||||
| Slug already exists (add) | Switch to update flow automatically |
|
||||
| Slug not found (update/remove) | Stop and ask user to confirm |
|
||||
| Typecheck fails | Fix the error before pushing |
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
name: adding-deprecation-warnings
|
||||
description: 'Adds deprecation warnings for renamed or removed properties/APIs. Searches custom node ecosystem for usage, applies defineDeprecatedProperty helper, adds JSDoc. Triggers on: deprecate, deprecation warning, rename property, backward compatibility.'
|
||||
---
|
||||
|
||||
# Adding Deprecation Warnings
|
||||
|
||||
Adds backward-compatible deprecation warnings for renamed or removed
|
||||
properties using the `defineDeprecatedProperty` helper in
|
||||
`src/lib/litegraph/src/utils/feedback.ts`.
|
||||
|
||||
## When to Use
|
||||
|
||||
- A property or API has been renamed and custom nodes still use the old name
|
||||
- A property is being removed but needs a grace period
|
||||
- Backward compatibility must be preserved while nudging adoption
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Search the Custom Node Ecosystem
|
||||
|
||||
Before implementing, assess impact by searching for usage of the
|
||||
deprecated property across ComfyUI custom nodes:
|
||||
|
||||
```text
|
||||
Use the comfy_codesearch tool to search for the old property name.
|
||||
Search for both `widget.oldProp` and just `oldProp` to catch all patterns.
|
||||
```
|
||||
|
||||
Document the usage patterns found (property access, truthiness checks,
|
||||
caching to local vars, style mutation, etc.) — these all must continue
|
||||
working.
|
||||
|
||||
### 2. Apply the Deprecation
|
||||
|
||||
Use `defineDeprecatedProperty` from `src/lib/litegraph/src/utils/feedback.ts`:
|
||||
|
||||
```typescript
|
||||
import { defineDeprecatedProperty } from '@/lib/litegraph/src/utils/feedback'
|
||||
|
||||
/** @deprecated Use {@link obj.newProp} instead. */
|
||||
defineDeprecatedProperty(
|
||||
obj,
|
||||
'oldProp',
|
||||
'newProp',
|
||||
'obj.oldProp is deprecated. Use obj.newProp instead.'
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Checklist
|
||||
|
||||
- [ ] Ecosystem search completed — all usage patterns are compatible
|
||||
- [ ] `defineDeprecatedProperty` call added after the new property is assigned
|
||||
- [ ] JSDoc `@deprecated` tag added above the call for IDE support
|
||||
- [ ] Warning message names both old and new property clearly
|
||||
- [ ] `pnpm typecheck` passes
|
||||
- [ ] `pnpm lint` passes
|
||||
|
||||
### 4. PR Comment
|
||||
|
||||
Add a PR comment summarizing the ecosystem search results: which repos
|
||||
use the deprecated property, what access patterns were found, and
|
||||
confirmation that all patterns are compatible with the ODP getter/setter.
|
||||
|
||||
## How `defineDeprecatedProperty` Works
|
||||
|
||||
- Creates an `Object.defineProperty` getter/setter on the target object
|
||||
- Getter returns `this[currentKey]`, setter assigns `this[currentKey]`
|
||||
- Both log via `warnDeprecated`, which deduplicates (once per unique
|
||||
message per session via a `Set`)
|
||||
- `enumerable: false` keeps the alias out of `Object.keys()` / `for...in`
|
||||
/ `JSON.stringify`
|
||||
- `configurable: true` allows further redefinition if needed
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Truthiness checks** (`if (widget.oldProp)`) — works, getter fires
|
||||
- **Caching to local var** (`const el = widget.oldProp`) — works, warns
|
||||
once then the cached ref is used directly
|
||||
- **Style/property mutation** (`widget.oldProp.style.color = 'red'`) —
|
||||
works, getter returns the real object
|
||||
- **Serialization** (`JSON.stringify`) — `enumerable: false` excludes it
|
||||
- **Heavy access in loops** — `warnDeprecated` deduplicates, only warns
|
||||
once per session regardless of call count
|
||||
@@ -1,390 +0,0 @@
|
||||
---
|
||||
name: backport-management
|
||||
description: Manages cherry-pick backports across stable release branches. Discovers candidates from Slack/git, analyzes dependencies, resolves conflicts via worktree, and logs results. Use when asked to backport, cherry-pick to stable, manage release branches, do stable branch maintenance, or run a backport session.
|
||||
---
|
||||
|
||||
# Backport Management
|
||||
|
||||
Cherry-pick backport management for Comfy-Org/ComfyUI_frontend stable release branches.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Discover** — Collect candidates from Slack bot + git log gap, then **reconcile both lists** (`reference/discovery.md`)
|
||||
2. **Pre-filter by path** — Auto-skip PRs whose changed files are entirely under `apps/website/`, `browser_tests/`, `.github/`, `packages/design-system/`, `packages/{cloud,registry}-types/`, `.Codex/`, `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
|
||||
|
||||
| Item | Value |
|
||||
| -------------- | --------------------------------------------------------------------------- |
|
||||
| Repo | `~/ComfyUI_frontend` (Comfy-Org/ComfyUI_frontend) |
|
||||
| Merge strategy | Auto-merge via workflow (`--auto --squash`); `--admin` only after CI passes |
|
||||
| Automation | `pr-backport.yaml` GitHub Action (label-driven, auto-merge enabled) |
|
||||
| Tracking dir | `~/temp/backport-session/` |
|
||||
|
||||
## CI Safety Rules
|
||||
|
||||
**NEVER merge a backport PR without all CI checks passing.** This applies to both automation-created and manual cherry-pick PRs.
|
||||
|
||||
- **Automation PRs:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash`, so clean PRs auto-merge once CI passes. Monitor with polling (`gh pr list --base TARGET_BRANCH --state open`). Do not intervene unless CI fails.
|
||||
- **Manual cherry-pick PRs:** After `gh pr create`, wait for CI before merging. Poll with `gh pr checks $PR --watch` or use a sleep+check loop. Only merge after all checks pass.
|
||||
- **CI failures:** DO NOT use `--admin` to bypass failing CI. Analyze the failure, present it to the user with possible causes (test backported without implementation, missing dependency, flaky test), and let the user decide the next step.
|
||||
|
||||
## Branch Scope Rules
|
||||
|
||||
**Critical: Match PRs to the correct target branches.**
|
||||
|
||||
| Branch prefix | Scope | Example |
|
||||
| ------------- | ------------------------------ | ------------------------------------------------- |
|
||||
| `cloud/*` | Cloud-hosted ComfyUI only | Team workspaces, cloud queue, cloud-only login |
|
||||
| `core/*` | Local/self-hosted ComfyUI only | Core editor, local workflows, node system |
|
||||
| Both | Shared infrastructure | App mode, Firebase auth (API nodes), payment URLs |
|
||||
|
||||
### What Goes Where
|
||||
|
||||
**Both core + cloud:**
|
||||
|
||||
- **App mode** PRs — app mode is NOT cloud-only
|
||||
- **Firebase auth** PRs — Firebase auth is on core for API nodes
|
||||
- **Payment redirect** PRs — payment infrastructure shared
|
||||
- **Bug fixes** touching shared components
|
||||
|
||||
**Cloud-only (skip for core):**
|
||||
|
||||
- Team workspaces
|
||||
- Cloud queue virtualization
|
||||
- Hide API key login
|
||||
- Cloud-specific UI behind cloud feature flags
|
||||
|
||||
**⚠️ NEVER backport cloud-only PRs to `core/*` branches.** But do NOT assume "app mode" or "Firebase" = cloud-only. Check the actual files changed.
|
||||
|
||||
## ⚠️ Gotchas (Learn from Past Sessions)
|
||||
|
||||
### Use `gh api` for Labels — NOT `gh pr edit`
|
||||
|
||||
`gh pr edit --add-label` triggers Projects Classic deprecation errors. Always use:
|
||||
|
||||
```bash
|
||||
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
|
||||
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH"
|
||||
```
|
||||
|
||||
### Automation Over-Reports Conflicts
|
||||
|
||||
The `pr-backport.yaml` action reports more conflicts than reality. `git cherry-pick -m 1` with git auto-merge handles many cases the automation can't. Always attempt manual cherry-pick before skipping.
|
||||
|
||||
### Never Skip Based on Conflict File Count
|
||||
|
||||
12 or 27 conflicting files can be trivial (snapshots, new files). **Categorize conflicts first**, then decide. See Conflict Triage below.
|
||||
|
||||
### Accept-Theirs Can Produce Broken Hybrids
|
||||
|
||||
When a PR **rewrites a component** (e.g., PrimeVue → Reka UI), the accept-theirs regex produces a broken mix of old and new code. The template may reference new APIs while the script still has old imports, or vice versa.
|
||||
|
||||
**Detection:** Content conflicts with 4+ conflict markers in a single `.vue` file, especially when imports change between component libraries.
|
||||
|
||||
**Fix:** Instead of accept-theirs regex, use `git show MERGE_SHA:path/to/file > path/to/file` to get the complete correct version from the merge commit on main. This bypasses the conflict entirely.
|
||||
|
||||
### Cherry-Picks Can Reference Missing Dependencies
|
||||
|
||||
When PR A on main depends on code introduced by PR B (which was merged before A), cherry-picking A brings in code that references B's additions. The cherry-pick succeeds but the branch is broken.
|
||||
|
||||
**Common pattern:** Composables, component files, or type definitions introduced by an earlier PR and used by the cherry-picked PR.
|
||||
|
||||
**Detection:** `pnpm typecheck` fails with "Cannot find module" or "is not defined" errors after cherry-pick.
|
||||
|
||||
**Fix:** Use `git show MERGE_SHA:path/to/missing/file > path/to/missing/file` to bring the missing files from main. Always verify with typecheck.
|
||||
|
||||
### Use `--no-verify` for Worktree Pushes
|
||||
|
||||
Husky hooks fail in worktrees (can't find lint-staged config). Always use `git push --no-verify` and `git commit --no-verify` when working in `/tmp/` worktrees.
|
||||
|
||||
### Automation Success Varies Wildly by Branch
|
||||
|
||||
In the 2026-04-06 session: core/1.42 got 18/26 auto-PRs, cloud/1.42 got only 1/25. The cloud branch has more divergence. **Always plan for manual fallback** — don't assume automation will handle most PRs.
|
||||
|
||||
### Cherry-Picked Tests Can Reference Files Added By Earlier Unbackported PRs
|
||||
|
||||
A common conflict: PR A on main modifies a test file that was _added_ on main by an earlier PR B (not backported to the target). The cherry-pick of A reports "modify/delete" on B's test file because the file doesn't exist on the target. Adding the new file would smuggle in B's test scaffolding without B's runtime changes.
|
||||
|
||||
**Detection:** Conflict says `deleted in HEAD and modified in <PR>`. Verify with:
|
||||
|
||||
```bash
|
||||
git log --diff-filter=A --oneline origin/main -- path/to/test.ts
|
||||
```
|
||||
|
||||
If the introducing commit is **not** on the target branch, the test file isn't a real prerequisite for the runtime fix.
|
||||
|
||||
**Fix:** `git rm` the test file (drop it from the backport). Document in the commit body which PR introduced it on main and why dropping it is safe. The runtime fix itself usually doesn't depend on these tests — coverage exists at the integration layer.
|
||||
|
||||
### Backport-Only Compatibility Shims
|
||||
|
||||
When a PR's _mechanism_ relies on changes upstream that aren't on the older branch, a literal cherry-pick can recreate the original bug for any consumer still using the old contract. This is most dangerous for **public LiteGraph callbacks, extension APIs, and `node.*` methods** that custom-node packages depend on.
|
||||
|
||||
**Real example (#11541, core/1.43 backport):** The PR removed `LGraphNode.vue`'s legacy `handled === true` sync-return check from `handleDrop`, replacing it with `await node.onDragDrop(event, true)`. Safe on `main` because all in-repo `onDragDrop` handlers had migrated to participate in the new `claimEvent` flag. On `core/1.43`, `onDragDrop` is a public callback — custom-node packages with synchronous `onDragDrop` returning `true` would no longer have their event claimed, recreating the duplicate-node-creation bug the PR was fixing.
|
||||
|
||||
**Detection:** The PR's diff modifies a file that is part of a public extension API surface. Look for:
|
||||
|
||||
- `node.onXxx` callback assignments
|
||||
- Methods on `LGraphNode`, `LGraphCanvas`, `LGraph`, `Subgraph`
|
||||
- Public exports from `src/lib/litegraph/`
|
||||
- Type changes affecting `litegraph-augmentation.d.ts`
|
||||
|
||||
**Fix:** Add a backport-only compatibility shim that preserves the old contract while keeping the new fix. Document it explicitly in the commit body under a `## Backport-only compatibility fix` heading. Consult oracle for review before pushing — a bad shim is worse than no fix.
|
||||
|
||||
## Conflict Triage
|
||||
|
||||
**Always categorize before deciding to skip. High conflict count ≠ hard conflicts.**
|
||||
|
||||
| Type | Symptom | Resolution |
|
||||
| ---------------------------- | ------------------------------------ | --------------------------------------------------------------- |
|
||||
| **Binary snapshots (PNGs)** | `.png` files in conflict list | `git checkout --theirs $FILE && git add $FILE` — always trivial |
|
||||
| **Modify/delete (new file)** | PR introduces files not on target | `git add $FILE` — keep the new file |
|
||||
| **Modify/delete (removed)** | Target removed files the PR modifies | `git rm $FILE` — file no longer relevant |
|
||||
| **Content conflicts** | Marker-based (`<<<<<<<`) | Accept theirs via python regex (see below) |
|
||||
| **Component rewrites** | 4+ markers in `.vue`, library change | Use `git show SHA:path > path` — do NOT accept-theirs |
|
||||
| **Import-only conflicts** | Only import lines differ | Keep both imports if both used; remove unused after |
|
||||
| **Add/add** | Both sides added same file | Accept theirs, verify no logic conflict |
|
||||
| **Locale/JSON files** | i18n key additions | Accept theirs, validate JSON after |
|
||||
|
||||
```python
|
||||
# Accept theirs for content conflicts
|
||||
import re
|
||||
pattern = r'<<<<<<< HEAD\n(.*?)=======\n(.*?)>>>>>>> [^\n]+\n?'
|
||||
content = re.sub(pattern, r'\2', content, flags=re.DOTALL)
|
||||
```
|
||||
|
||||
### Escalation Triggers (Flag for Human)
|
||||
|
||||
- **Package.json/lockfile changes** → skip on stable (transitive dep regression risk)
|
||||
- **Core type definition changes** → requires human judgment
|
||||
- **Business logic conflicts** (not just imports/exports) → requires domain knowledge
|
||||
- **Admin-merged conflict resolutions** → get human review of the resolution before continuing the wave
|
||||
|
||||
## Auto-Skip Categories
|
||||
|
||||
Skip these without discussion:
|
||||
|
||||
- **Dep refresh PRs** — Risk of transitive dep regressions on stable. Cherry-pick individual CVE fixes instead.
|
||||
- **CI/tooling changes** — Not user-facing
|
||||
- **Test-only / lint rule changes** — Not user-facing
|
||||
- **Revert pairs** — If PR A reverted by PR B, skip both. If fixed version (PR C) exists, backport only C.
|
||||
- **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 |
|
||||
| `.Codex/`, `.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:
|
||||
|
||||
```bash
|
||||
# Fetch latest state of target branch
|
||||
git fetch origin TARGET_BRANCH
|
||||
|
||||
# Quick smoke check: does the branch build?
|
||||
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/verify-TARGET
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If typecheck or tests fail, stop and investigate before continuing. A broken branch after wave N means all subsequent waves will compound the problem.
|
||||
|
||||
### Fix PRs Are Normal
|
||||
|
||||
Expect to create 1 fix PR per branch after verification. Common issues:
|
||||
|
||||
1. **Component rewrite hybrids** — accept-theirs produced broken `.vue` files. Fix: overwrite with correct version from merge commit via `git show SHA:path > path`
|
||||
2. **Missing dependency files** — cherry-pick brought in code referencing composables/components not on the branch. Fix: add missing files from merge commit
|
||||
3. **Missing type properties** — cherry-picked code uses interface properties not yet on the branch (e.g., `key` on `ConfirmDialogOptions`). Fix: add the property to the interface
|
||||
4. **Unused imports** — conflict resolution kept imports that the branch doesn't use. Fix: remove unused imports
|
||||
5. **Wrong types from conflict resolution** — e.g., `{ top: number; right: number }` vs `{ top: number; left: number }`. Fix: match the return type of the actual function
|
||||
|
||||
Create a fix PR on a branch from the target, verify typecheck passes, then merge with `--squash --admin`.
|
||||
|
||||
### Never Admin-Merge Without CI
|
||||
|
||||
In a previous bulk session, all 69 backport PRs were merged with `gh pr merge --squash --admin`, bypassing required CI checks. This shipped 3 test failures to a release branch. **Lesson: `--admin` skips all branch protection, including required status checks.** Only use `--admin` after confirming CI has passed (e.g., `gh pr checks $PR` shows all green), or rely on auto-merge (`--auto --squash`) which waits for CI by design.
|
||||
|
||||
## Continuous Backporting Recommendation
|
||||
|
||||
Large backport sessions (50+ PRs) are expensive and error-prone. Prefer continuous backporting:
|
||||
|
||||
- Backport bug fixes as they merge to main (same day or next day)
|
||||
- Use the automation labels immediately after merge
|
||||
- Reserve session-style bulk backporting for catching up after gaps
|
||||
- When a release branch is created, immediately start the continuous process
|
||||
|
||||
## Interactive Approval Flow
|
||||
|
||||
After analysis, present ALL candidates (MUST, SHOULD, and borderline) to the human for interactive review before execution. Do not write a static decisions.md — collect approvals in conversation.
|
||||
|
||||
### Batch Presentation
|
||||
|
||||
Present PRs in batches of 5-10, grouped by theme (visual bugs, interaction bugs, cloud/auth, data correctness, etc.). Use this table format:
|
||||
|
||||
```
|
||||
# | PR | Title | Target | Rec | Context
|
||||
----+--------+------------------------------------------+---------------+------+--------
|
||||
1 | #12345 | fix: broken thing | core+cloud/42 | Y | Description here. Why it matters. Agent reasoning.
|
||||
2 | #12346 | fix: another issue | core/42 | N | Only affects removed feature. Not on target branch.
|
||||
```
|
||||
|
||||
Each row includes:
|
||||
|
||||
- PR number and title
|
||||
- Target branches
|
||||
- Agent recommendation: `Rec: Y` or `Rec: N` with brief reasoning
|
||||
- 2-3 sentence context: what the PR does, why it matters (or doesn't)
|
||||
|
||||
### Human Response Format
|
||||
|
||||
- `Y` — approve for backport
|
||||
- `N` — skip
|
||||
- `?` — investigate (agent shows PR description, files changed, detailed take, then re-asks)
|
||||
- Any freeform question or comment triggers discussion before moving on
|
||||
- Bulk responses accepted (e.g. `1 Y, 2 Y, 3 N, 4 ?`)
|
||||
|
||||
### Rules
|
||||
|
||||
- ALL candidates are reviewed, not just MUST items
|
||||
- When human responds `?`, show the PR description, files changed, and agent's detailed analysis, then re-ask for their decision
|
||||
- When human asks a question about a PR, answer with context and recommendation, then wait for their decision
|
||||
- Do not proceed to execution until all batches are reviewed and every candidate has a Y or N
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Label-Driven Automation (default path)
|
||||
|
||||
```bash
|
||||
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$PR/labels \
|
||||
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH"
|
||||
# Wait 3 min, check: gh pr list --base TARGET_BRANCH --state open
|
||||
```
|
||||
|
||||
### Manual Worktree Cherry-Pick (conflict fallback)
|
||||
|
||||
```bash
|
||||
git worktree add /tmp/backport-$BRANCH origin/$BRANCH
|
||||
cd /tmp/backport-$BRANCH
|
||||
|
||||
# For each PR:
|
||||
git fetch origin $BRANCH
|
||||
git checkout -b backport-$PR-to-$BRANCH origin/$BRANCH
|
||||
git cherry-pick -m 1 $MERGE_SHA
|
||||
# Resolve conflicts (see Conflict Triage)
|
||||
git push origin backport-$PR-to-$BRANCH --no-verify
|
||||
gh pr create --base $BRANCH --head backport-$PR-to-$BRANCH \
|
||||
--title "[backport $BRANCH] $TITLE (#$PR)" \
|
||||
--body "Backport of #$PR. [conflict notes]"
|
||||
gh pr merge $NEW_PR --squash --admin
|
||||
sleep 25
|
||||
```
|
||||
|
||||
### Efficient Batch: Test-Then-Resolve Pattern
|
||||
|
||||
When many PRs need manual cherry-pick (e.g., cloud branches), test all first:
|
||||
|
||||
```bash
|
||||
cd /tmp/backport-$BRANCH
|
||||
for pr in "${ORDER[@]}"; do
|
||||
git checkout -b test-$pr origin/$BRANCH
|
||||
if git cherry-pick -m 1 $SHA 2>/dev/null; then
|
||||
echo "CLEAN: $pr"
|
||||
else
|
||||
echo "CONFLICT: $pr"
|
||||
git cherry-pick --abort
|
||||
fi
|
||||
git checkout --detach HEAD
|
||||
git branch -D test-$pr
|
||||
done
|
||||
```
|
||||
|
||||
Then process clean PRs in a batch loop, conflicts individually.
|
||||
|
||||
### PR Title Convention
|
||||
|
||||
```
|
||||
[backport TARGET_BRANCH] Original Title (#ORIGINAL_PR)
|
||||
```
|
||||
|
||||
## Final Deliverables (Slack-Compatible)
|
||||
|
||||
After execution completes, generate two files in `~/temp/backport-session/`. Both must be **Slack-compatible plain text** — no emojis, no markdown tables, no headers (`#`), no bold (`**`), no inline code. Use plain dashes, indentation, and line breaks only.
|
||||
|
||||
### 1. Author Accountability Report
|
||||
|
||||
File: `backport-author-accountability.md`
|
||||
|
||||
Lists all backported PRs grouped by original author (via `gh pr view $PR --json author`). Surfaces who should be self-labeling.
|
||||
|
||||
```
|
||||
Backport Session YYYY-MM-DD -- PRs that should have been labeled by authors
|
||||
|
||||
- author-login
|
||||
- #1234 fix: short title
|
||||
- #5678 fix: another title
|
||||
- other-author
|
||||
- #9012 fix: some other fix
|
||||
```
|
||||
|
||||
Authors sorted alphabetically, 4-space indent for nested items.
|
||||
|
||||
### 2. Slack Status Update
|
||||
|
||||
File: `slack-status-update.md`
|
||||
|
||||
A shareable summary of the session. Structure:
|
||||
|
||||
```
|
||||
Backport session complete -- YYYY-MM-DD
|
||||
|
||||
[1-sentence summary: N PRs backported to which branches. All pass typecheck.]
|
||||
|
||||
Branches updated:
|
||||
- core/X.XX: N PRs + N fix PRs (N auto, N manual)
|
||||
- cloud/X.XX: N PRs + N fix PRs (N auto, N manual)
|
||||
- ...
|
||||
|
||||
N total PRs created and merged (N backports + N fix PRs).
|
||||
|
||||
Notable fixes included:
|
||||
- [category]: [list of fixes]
|
||||
- ...
|
||||
|
||||
Conflict patterns encountered:
|
||||
- [pattern and how it was resolved]
|
||||
- ...
|
||||
|
||||
N authors had PRs backported. See author accountability list for details.
|
||||
```
|
||||
|
||||
No emojis, no tables, no bold, no headers. Plain text that pastes cleanly into Slack.
|
||||
@@ -1,149 +0,0 @@
|
||||
# Analysis & Decision Framework
|
||||
|
||||
## Categorization
|
||||
|
||||
| Category | Criteria | Action |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| **MUST** | User-facing bug, crash, data corruption, security. Clear breakage that users will hit. | Backport (with deps if needed) |
|
||||
| **SHOULD** | UX improvement, minor bug, small dep chain. No user-visible breakage if skipped, but improves experience. | Backport if clean cherry-pick; defer if conflict resolution is non-trivial |
|
||||
| **SKIP** | CI/tooling, test-only, lint rules, cosmetic, dep refresh | Skip with documented reason |
|
||||
| **NEEDS DISCUSSION** | Large dep chain, unclear risk/benefit, touches core types | Flag for human |
|
||||
|
||||
### MUST vs SHOULD Decision Guide
|
||||
|
||||
When unsure, ask: "If a user on this stable branch reports this issue, would we consider it a bug?"
|
||||
|
||||
- **Yes** → MUST. The fix addresses broken behavior.
|
||||
- **No, but it's noticeably better** → SHOULD. The fix is a quality-of-life improvement.
|
||||
- **No, and it's cosmetic or internal** → SKIP.
|
||||
|
||||
For SHOULD items with conflicts: if conflict resolution requires more than trivial accept-theirs patterns (content conflicts in business logic, not just imports), downgrade to SKIP or escalate to NEEDS DISCUSSION.
|
||||
|
||||
## Branch Scope Filtering
|
||||
|
||||
**Before categorizing, filter by branch scope:**
|
||||
|
||||
| Target branch | Skip if PR is... |
|
||||
| ------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `core/*` | Cloud-only (team workspaces, cloud queue, cloud-only login). Note: app mode and Firebase auth are NOT cloud-only. |
|
||||
| `cloud/*` | Local-only features not present on cloud branch |
|
||||
|
||||
Cloud-only PRs backported to `core/*` are wasted effort — `core/*` branches serve local/self-hosted users who never see cloud features. Check PR titles, descriptions, and files changed for cloud-specific indicators.
|
||||
|
||||
## Features Not on Stable Branches
|
||||
|
||||
Check before backporting — these don't exist on older branches:
|
||||
|
||||
- **Painter** (`src/extensions/core/painter.ts`) — not on core/1.40
|
||||
- **GLSLShader** — not on core/1.40
|
||||
- **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.
|
||||
|
||||
## Revert Pairs
|
||||
|
||||
If PR A is reverted by PR B:
|
||||
|
||||
- Skip BOTH A and B
|
||||
- If a fixed version exists (PR C), backport only C
|
||||
|
||||
## Dependency Analysis
|
||||
|
||||
```bash
|
||||
# Find other PRs that touched the same files
|
||||
gh pr view $PR --json files --jq '.files[].path' | while read f; do
|
||||
git log --oneline origin/TARGET..$MERGE_SHA -- "$f"
|
||||
done
|
||||
```
|
||||
|
||||
## Human Review Checkpoint
|
||||
|
||||
Use the Interactive Approval Flow (see SKILL.md) to review all candidates interactively. Do not write a static decisions.md for the human to edit — instead, present batches of 5-10 PRs with context and recommendations, and collect Y/N/? responses in conversation.
|
||||
|
||||
All candidates must be reviewed (MUST, SHOULD, and borderline items), not just a subset.
|
||||
@@ -1,84 +0,0 @@
|
||||
# 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):
|
||||
|
||||
```bash
|
||||
slackdump export -o ~/slack-exports/frontend-releases.zip C09K9TPU2G7
|
||||
```
|
||||
|
||||
Parse bot messages for PRs flagged "Might need backport" per release version.
|
||||
|
||||
## Source 2: Git Log Gap Analysis
|
||||
|
||||
```bash
|
||||
# Count gap
|
||||
git log --oneline origin/TARGET..origin/main | wc -l
|
||||
|
||||
# List gap commits
|
||||
git log --oneline origin/TARGET..origin/main
|
||||
|
||||
# Check if a PR is already on target
|
||||
git log --oneline origin/TARGET --grep="#PR_NUMBER"
|
||||
|
||||
# Check for existing backport PRs
|
||||
gh pr list --base TARGET --state all --search "backport PR_NUMBER"
|
||||
```
|
||||
|
||||
## Source 3: GitHub PR Details
|
||||
|
||||
```bash
|
||||
# Get merge commit SHA
|
||||
gh pr view $PR --json mergeCommit,title --jq '"Title: \(.title)\nMerge: \(.mergeCommit.oid)"'
|
||||
|
||||
# Get files changed
|
||||
gh pr view $PR --json files --jq '.files[].path'
|
||||
```
|
||||
|
||||
## Source 4: Already-Backported PRs (cross-reference)
|
||||
|
||||
When the target branch already has some cherry-picks on it (e.g., partway through a release window), extract the originals to avoid re-backporting:
|
||||
|
||||
```bash
|
||||
# Get all original PR numbers already backported to TARGET since the last release tag
|
||||
git log --format="%H%n%B" $LAST_TAG..origin/$TARGET \
|
||||
| grep -oiE "(backport of|cherry.picked) #?[0-9]+" \
|
||||
| grep -oE "[0-9]+" \
|
||||
| sort -un > /tmp/already-backported.txt
|
||||
```
|
||||
|
||||
Subtract this list from your candidates.
|
||||
|
||||
## Reconciliation Workflow
|
||||
|
||||
```bash
|
||||
# 1. Slack bot list (parse from export)
|
||||
# /tmp/bot-flagged.txt — one PR# per line, sorted
|
||||
|
||||
# 2. Git gap fix/perf only
|
||||
MB=$(git merge-base origin/main origin/$TARGET)
|
||||
git log --format="%h|%s" $MB..origin/main \
|
||||
| grep -iE "^[a-f0-9]+\|(fix|perf)" \
|
||||
| grep -oE "#[0-9]+\)" | grep -oE "[0-9]+" \
|
||||
| sort -un > /tmp/gap-fixes.txt
|
||||
|
||||
# 3. Already backported (Source 4 above)
|
||||
|
||||
# 4. Candidates = (gap-fixes ∪ bot-flagged) − already-backported
|
||||
sort -u /tmp/gap-fixes.txt /tmp/bot-flagged.txt > /tmp/union.txt
|
||||
comm -23 /tmp/union.txt /tmp/already-backported.txt > /tmp/candidates.txt
|
||||
```
|
||||
|
||||
The result is the input to the path pre-filter (`SKILL.md` Quick Start step 2).
|
||||
|
||||
## Output: candidate_list.md
|
||||
|
||||
Table per target branch:
|
||||
| PR# | Title | Source (bot/gap/both) | Path bucket | Tier | Decision |
|
||||
@@ -1,380 +0,0 @@
|
||||
# Execution Workflow
|
||||
|
||||
## Per-Branch Execution Order
|
||||
|
||||
1. Smallest gap first (validation run)
|
||||
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
|
||||
# Add labels to all candidates for a target branch
|
||||
for pr in $PR_LIST; do
|
||||
gh api repos/Comfy-Org/ComfyUI_frontend/issues/$pr/labels \
|
||||
-f "labels[]=needs-backport" -f "labels[]=TARGET_BRANCH" --silent
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Wait 3 minutes for automation
|
||||
sleep 180
|
||||
|
||||
# Check which got auto-PRs (auto-merge is enabled, so clean ones will self-merge after CI)
|
||||
gh pr list --base TARGET_BRANCH --state open --limit 50 --json number,title
|
||||
```
|
||||
|
||||
> **Note:** The `pr-backport.yaml` workflow now enables `gh pr merge --auto --squash` on automation-created PRs. Clean PRs will auto-merge once CI passes — no manual merge needed for those.
|
||||
|
||||
## Step 2: Wait for CI & Merge Clean Auto-PRs
|
||||
|
||||
Most automation PRs will auto-merge once CI passes (via `--auto --squash` in the workflow). Monitor and handle failures:
|
||||
|
||||
```bash
|
||||
# Wait for CI to complete (~45 minutes for full suite)
|
||||
sleep 2700
|
||||
|
||||
# Check which PRs are still open (CI may have failed, or auto-merge succeeded)
|
||||
STILL_OPEN_PRS=$(gh pr list --base TARGET_BRANCH --state open --limit 50 --json number --jq '.[].number')
|
||||
RECENTLY_MERGED=$(gh pr list --base TARGET_BRANCH --state merged --limit 50 --json number,title,mergedAt)
|
||||
|
||||
# For PRs still open, check CI status
|
||||
for pr in $STILL_OPEN_PRS; do
|
||||
CI_FAILED=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "FAILURE")] | length')
|
||||
CI_PENDING=$(gh pr checks $pr --json name,state --jq '[.[] | select(.state == "PENDING" or .state == "QUEUED")] | length')
|
||||
if [ "$CI_FAILED" != "0" ]; then
|
||||
# CI failed — collect details for triage
|
||||
echo "PR #$pr — CI FAILED:"
|
||||
gh pr checks $pr --json name,state,link --jq '.[] | select(.state == "FAILURE") | "\(.name): \(.state)"'
|
||||
elif [ "$CI_PENDING" != "0" ]; then
|
||||
echo "PR #$pr — CI still running ($CI_PENDING checks pending)"
|
||||
else
|
||||
# All checks passed but didn't auto-merge (race condition or label issue)
|
||||
gh pr merge $pr --squash --admin
|
||||
sleep 3
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
**⚠️ If CI fails: DO NOT admin-merge to bypass.** See "CI Failure Triage" below.
|
||||
|
||||
## Step 3: Manual Worktree for Conflicts
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git worktree add /tmp/backport-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/backport-TARGET
|
||||
|
||||
for PR in ${CONFLICT_PRS[@]}; do
|
||||
# Refresh target ref so each branch is based on current HEAD
|
||||
git fetch origin TARGET_BRANCH
|
||||
git checkout origin/TARGET_BRANCH
|
||||
|
||||
git checkout -b backport-$PR-to-TARGET origin/TARGET_BRANCH
|
||||
git cherry-pick -m 1 $MERGE_SHA
|
||||
|
||||
# If conflict — NEVER skip based on file count alone!
|
||||
# Categorize conflicts first: binary PNGs, modify/delete, content, add/add, component rewrites
|
||||
# See SKILL.md Conflict Triage table for resolution per type.
|
||||
|
||||
# For component rewrites (4+ markers in a .vue file, library migration):
|
||||
# DO NOT use accept-theirs regex — it produces broken hybrids.
|
||||
# Instead, use the complete file from the merge commit:
|
||||
# git show $MERGE_SHA:path/to/file > path/to/file
|
||||
|
||||
# For simple content conflicts, accept theirs:
|
||||
# python3 -c "import re; ..."
|
||||
|
||||
# Resolve all conflicts, then:
|
||||
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`
|
||||
# with no path filter 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 "${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)" \
|
||||
--body "Backport of #$PR..." | grep -oP '\d+$')
|
||||
|
||||
# Wait for CI before merging — NEVER admin-merge without CI passing
|
||||
echo "Waiting for CI on PR #$NEW_PR..."
|
||||
gh pr checks $NEW_PR --watch --fail-fast || {
|
||||
echo "⚠️ CI failed on PR #$NEW_PR — skipping merge, needs triage"
|
||||
continue
|
||||
}
|
||||
gh pr merge $NEW_PR --squash --admin
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# Cleanup
|
||||
cd -
|
||||
git worktree remove /tmp/backport-TARGET --force
|
||||
```
|
||||
|
||||
**⚠️ Human review for conflict resolutions:** When admin-merging a PR where you manually resolved conflicts (especially content conflicts beyond trivial accept-theirs), pause and present the resolution diff to the human for review before merging. Trivial resolutions (binary snapshots, modify/delete, locale key additions) can proceed without review.
|
||||
|
||||
## Step 4: Wave Verification
|
||||
|
||||
After completing all PRs in a wave for a target branch:
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git worktree add /tmp/verify-TARGET origin/TARGET_BRANCH
|
||||
cd /tmp/verify-TARGET
|
||||
source ~/.nvm/nvm.sh && nvm use 24 && pnpm install && pnpm typecheck && pnpm test:unit
|
||||
git worktree remove /tmp/verify-TARGET --force
|
||||
```
|
||||
|
||||
If verification fails, **do not skip** — create a fix PR:
|
||||
|
||||
```bash
|
||||
# Stay in the verify worktree
|
||||
git checkout -b fix-backport-TARGET origin/TARGET_BRANCH
|
||||
|
||||
# Common fixes:
|
||||
# 1. Component rewrite hybrids: overwrite with merge commit version
|
||||
git show MERGE_SHA:path/to/Component.vue > path/to/Component.vue
|
||||
|
||||
# 2. Missing dependency files
|
||||
git show MERGE_SHA:path/to/missing.ts > path/to/missing.ts
|
||||
|
||||
# 3. Missing type properties: edit the interface
|
||||
# 4. Unused imports: delete the import lines
|
||||
|
||||
git add -A
|
||||
git commit --no-verify -m "fix: resolve backport typecheck issues on TARGET"
|
||||
git push origin fix-backport-TARGET --no-verify
|
||||
gh pr create --base TARGET --head fix-backport-TARGET --title "fix: resolve backport typecheck issues on TARGET" --body "..."
|
||||
gh pr merge $PR --squash --admin
|
||||
```
|
||||
|
||||
Do not proceed to the next branch until typecheck passes.
|
||||
|
||||
## Conflict Resolution Patterns
|
||||
|
||||
### 1. Content Conflicts (accept theirs)
|
||||
|
||||
```python
|
||||
import re
|
||||
pattern = r'<<<<<<< HEAD\n(.*?)=======\n(.*?)>>>>>>> [^\n]+\n?'
|
||||
content = re.sub(pattern, r'\2', content, flags=re.DOTALL)
|
||||
```
|
||||
|
||||
### 2. Modify/Delete (two cases!)
|
||||
|
||||
```bash
|
||||
# Case A: PR introduces NEW files not on target → keep them
|
||||
git add $FILE
|
||||
|
||||
# Case B: Target REMOVED files the PR modifies → drop them
|
||||
git rm $FILE
|
||||
```
|
||||
|
||||
### 3. Binary Files (snapshots)
|
||||
|
||||
```bash
|
||||
git checkout --theirs $FILE && git add $FILE
|
||||
```
|
||||
|
||||
### 4. Component Rewrites (DO NOT accept-theirs)
|
||||
|
||||
When a PR completely rewrites a component (e.g., PrimeVue → Reka UI), accept-theirs produces
|
||||
a broken hybrid with mismatched template/script sections.
|
||||
|
||||
```bash
|
||||
# Use the complete correct file from the merge commit instead:
|
||||
git show $MERGE_SHA:src/components/input/MultiSelect.vue > src/components/input/MultiSelect.vue
|
||||
git show $MERGE_SHA:src/components/input/SingleSelect.vue > src/components/input/SingleSelect.vue
|
||||
git add src/components/input/MultiSelect.vue src/components/input/SingleSelect.vue
|
||||
```
|
||||
|
||||
**Detection:** 4+ conflict markers in a single `.vue` file, imports changing between component
|
||||
libraries (PrimeVue → Reka UI, etc.), template structure completely different on each side.
|
||||
|
||||
### 5. Missing Dependencies After Cherry-Pick
|
||||
|
||||
Cherry-picks can succeed but leave the branch broken because the PR's code on main
|
||||
references composables/components introduced by an earlier PR.
|
||||
|
||||
```bash
|
||||
# Add the missing file from the merge commit:
|
||||
git show $MERGE_SHA:src/composables/queue/useJobDetailsHover.ts > src/composables/queue/useJobDetailsHover.ts
|
||||
git show $MERGE_SHA:src/components/builder/BuilderSaveDialogContent.vue > src/components/builder/BuilderSaveDialogContent.vue
|
||||
```
|
||||
|
||||
**Detection:** `pnpm typecheck` fails with "Cannot find module" or "X is not defined" after cherry-pick succeeds cleanly.
|
||||
|
||||
### 6. Locale Files
|
||||
|
||||
Usually adding new i18n keys — accept theirs, validate JSON:
|
||||
|
||||
```bash
|
||||
python3 -c "import json; json.load(open('src/locales/en/main.json'))" && echo "Valid"
|
||||
```
|
||||
|
||||
## Merge Conflicts After Other Merges
|
||||
|
||||
When merging multiple PRs to the same branch, later PRs may conflict with earlier merges:
|
||||
|
||||
```bash
|
||||
git fetch origin TARGET_BRANCH
|
||||
git rebase origin/TARGET_BRANCH
|
||||
# Resolve new conflicts
|
||||
git push --force origin backport-$PR-to-TARGET
|
||||
sleep 20 # Wait for GitHub to recompute merge state
|
||||
# Wait for CI after rebase before merging
|
||||
gh pr checks $PR --watch --fail-fast && gh pr merge $PR --squash --admin
|
||||
```
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
1. **Automation reports more conflicts than reality** — `cherry-pick -m 1` with git auto-merge handles many "conflicts" the automation can't
|
||||
2. **Never skip based on conflict file count** — 12 or 27 conflicts can be trivial (snapshots, new files). Categorize first: binary PNGs, modify/delete, content, add/add.
|
||||
3. **Modify/delete goes BOTH ways** — if the PR introduces new files (not on target), `git add` them. If target deleted files the PR modifies, `git rm`.
|
||||
4. **Binary snapshot PNGs** — always `git checkout --theirs && git add`. Never skip a PR just because it has many snapshot conflicts.
|
||||
5. **Batch label additions need 2s delay** between API calls to avoid rate limits
|
||||
6. **Merging 6+ PRs rapidly** can cause later PRs to become unmergeable — wait 20-30s for GitHub to recompute merge state
|
||||
7. **appModeStore.ts, painter files, GLSLShader files** don't exist on core/1.40 — `git rm` these
|
||||
8. **Always validate JSON** after resolving locale file conflicts
|
||||
9. **Dep refresh PRs** — skip on stable branches. Risk of transitive dep regressions outweighs audit cleanup. Cherry-pick individual CVE fixes instead.
|
||||
10. **Verify after each wave** — run `pnpm typecheck && pnpm test:unit` on the target branch after merging a batch. Catching breakage early prevents compounding errors.
|
||||
11. **App mode and Firebase auth are NOT cloud-only** — they go to both core and cloud branches. Only team workspaces, cloud queue, and cloud-specific login are cloud-only.
|
||||
12. **Never admin-merge without CI** — `--admin` bypasses all branch protections including required status checks. A bulk session of 69 admin-merges shipped 3 test failures. Always wait for CI to pass first, or use `--auto --squash` which waits by design.
|
||||
13. **Accept-theirs regex breaks component rewrites** — when a PR migrates between component libraries (PrimeVue → Reka UI), the regex produces a broken hybrid. Use `git show SHA:path > path` to get the complete correct version instead.
|
||||
14. **Cherry-picks can silently bring in missing-dependency code** — if PR A references a composable introduced by PR B, cherry-picking A succeeds but typecheck fails. Always run typecheck after each wave and add missing files from the merge commit.
|
||||
15. **Fix PRs are expected** — plan for 1 fix PR per branch to resolve typecheck issues from conflict resolutions. This is normal, not a failure.
|
||||
16. **Use `--no-verify` in worktrees** — husky hooks fail in `/tmp/` worktrees. Always push/commit with `--no-verify`.
|
||||
17. **Automation success varies by branch** — core/1.42 got 18/26 auto-PRs (69%), cloud/1.42 got 1/25 (4%). Cloud branches diverge more. Plan for manual fallback.
|
||||
18. **Test-then-resolve pattern** — for branches with low automation success, run a dry-run loop to classify clean vs conflict PRs before processing. This is much faster than resolving conflicts serially.
|
||||
19. **Public-API conflict resolutions need oracle review** — when a conflict touches `node.onXxx` callbacks, `LGraphNode`/`LGraphCanvas`/`LGraph`/`Subgraph` methods, or types in `litegraph-augmentation.d.ts`, consult oracle BEFORE pushing. Custom-node packages depend on these contracts. A literal cherry-pick of a refactor-style fix can silently break extensions still using the old contract — sometimes recreating the very bug the PR was fixing. Document any backport-only compatibility shim explicitly in the commit body.
|
||||
20. **Cherry-picked tests can require unbackported test scaffolding** — when a PR modifies a test file that was _added_ on main by an earlier unbackported PR, the cherry-pick reports modify/delete on that file. Drop it from the backport (`git rm`) and document which PR introduced it. Don't smuggle in test infrastructure without its runtime prerequisites.
|
||||
21. **Per-PR validation catches issues earlier than wave verification** — for high-stakes branches, run `pnpm typecheck && pnpm exec eslint <changed files> && pnpm exec oxfmt --check` per PR before pushing. Wave verification still matters (it catches cross-PR interactions), but per-PR makes attribution trivial when something fails.
|
||||
|
||||
## CI Failure Triage
|
||||
|
||||
When CI fails on a backport PR, present failures to the user using this template:
|
||||
|
||||
```markdown
|
||||
### PR #XXXX — CI Failed
|
||||
|
||||
- **Failing check:** test / lint / typecheck
|
||||
- **Error:** (summary of the failure message)
|
||||
- **Likely cause:** test backported without implementation / missing dependency / flaky test / snapshot mismatch
|
||||
- **Recommendation:** backport PR #YYYY first / skip this PR / rerun CI after fixing prerequisites
|
||||
```
|
||||
|
||||
Common failure categories:
|
||||
|
||||
| Category | Example | Resolution |
|
||||
| --------------------------- | ---------------------------------------- | ----------------------------------------- |
|
||||
| Test without implementation | Test references function not on branch | Backport the implementation PR first |
|
||||
| Missing dependency | Import from module not on branch | Backport the dependency PR first, or skip |
|
||||
| Snapshot mismatch | Screenshot test differs | Usually safe — update snapshots on branch |
|
||||
| Flaky test | Passes on retry | Re-run CI, merge if green on retry |
|
||||
| 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 <targeted suites>` ✅ (N/N passing)
|
||||
- `pnpm exec eslint <changed files>` ✅ (0 errors)
|
||||
- `pnpm exec oxfmt --check` ✅ (clean)
|
||||
|
||||
[If manual e2e was skipped, explain why — e.g., requires live backend, headless not feasible. State that source is byte-identical to upstream + how long it's been baking on main.]
|
||||
|
||||
Original PR: #ORIG / Original commit: `FULL_SHA`
|
||||
```
|
||||
|
||||
The conflict-resolution section is non-negotiable — every conflict you resolved by hand needs a one-liner. This makes archaeology trivial six months later when someone asks "why does this look slightly different from main?"
|
||||
@@ -1,103 +0,0 @@
|
||||
# Logging & Session Reports
|
||||
|
||||
## During Execution
|
||||
|
||||
Maintain `execution-log.md` with per-branch tables (this is internal, markdown tables are fine here):
|
||||
|
||||
```markdown
|
||||
| PR# | Title | Status | Backport PR | Notes |
|
||||
| ----- | ----- | ------ | ----------- | ------- |
|
||||
| #XXXX | Title | merged | #YYYY | Details |
|
||||
```
|
||||
|
||||
## Wave Verification Log
|
||||
|
||||
Track verification results per wave within execution-log.md:
|
||||
|
||||
```markdown
|
||||
Wave N Verification -- TARGET_BRANCH
|
||||
|
||||
- PRs merged: #A, #B, #C
|
||||
- Typecheck: pass / fail
|
||||
- Fix PR: #YYYY (if needed)
|
||||
- Issues found: (if any)
|
||||
```
|
||||
|
||||
## Session Report Template
|
||||
|
||||
```markdown
|
||||
# Backport Session Report
|
||||
|
||||
## Summary
|
||||
|
||||
| Branch | Candidates | Merged | Skipped | Deferred | Rate |
|
||||
| ------ | ---------- | ------ | ------- | -------- | ---- |
|
||||
|
||||
## Deferred Items (Needs Human)
|
||||
|
||||
| PR# | Title | Branch | Issue |
|
||||
|
||||
## Conflict Resolutions Requiring Review
|
||||
|
||||
| PR# | Branch | Conflict Type | Resolution Summary |
|
||||
|
||||
## CI Failure Report
|
||||
|
||||
| PR# | Branch | Failing Check | Error Summary | Cause | Resolution |
|
||||
| --- | ------ | ------------- | ------------- | ----- | ---------- |
|
||||
|
||||
## Automation Performance
|
||||
|
||||
| Metric | Value |
|
||||
| --------------------------- | ----- |
|
||||
| Auto success rate | X% |
|
||||
| Manual resolution rate | X% |
|
||||
| Overall clean rate | X% |
|
||||
| Wave verification pass rate | X% |
|
||||
|
||||
## Process Recommendations
|
||||
|
||||
- Were there clusters of related PRs that should have been backported together?
|
||||
- Any PRs that should have been backported sooner (continuous backporting candidates)?
|
||||
- Feature branches that need tracking for future sessions?
|
||||
```
|
||||
|
||||
## Final Deliverables
|
||||
|
||||
After all branches are complete and verified, generate these files in `~/temp/backport-session/`:
|
||||
|
||||
### 1. execution-log.md (internal)
|
||||
|
||||
Per-branch tables with PR#, title, status, backport PR#, notes. Markdown tables are fine — this is for internal tracking, not Slack.
|
||||
|
||||
### 2. backport-author-accountability.md (Slack-compatible)
|
||||
|
||||
See SKILL.md "Final Deliverables" section. Plain text, no emojis/tables/headers/bold. Authors sorted alphabetically with PRs nested under each.
|
||||
|
||||
### 3. slack-status-update.md (Slack-compatible)
|
||||
|
||||
See SKILL.md "Final Deliverables" section. Plain text summary that pastes cleanly into Slack. Includes branch counts, notable fixes, conflict patterns, author count.
|
||||
|
||||
## Slack Formatting Rules
|
||||
|
||||
Both shareable files (author accountability + status update) must follow these rules:
|
||||
|
||||
- No emojis (no checkmarks, no arrows, no icons)
|
||||
- No markdown tables (use plain lists with dashes)
|
||||
- No headers (no # or ##)
|
||||
- No bold (\*_) or italic (_)
|
||||
- No inline code backticks
|
||||
- Use -- instead of em dash
|
||||
- Use plain dashes (-) for lists with 4-space indent for nesting
|
||||
- Line breaks between sections for readability
|
||||
|
||||
These files should paste directly into a Slack message and look clean.
|
||||
|
||||
## Files to Track
|
||||
|
||||
All in `~/temp/backport-session/`:
|
||||
|
||||
- `execution-plan.md` -- approved PRs with merge SHAs (input)
|
||||
- `execution-log.md` -- real-time status with per-branch tables (internal)
|
||||
- `backport-author-accountability.md` -- PRs grouped by author (Slack-compatible)
|
||||
- `slack-status-update.md` -- session summary (Slack-compatible)
|
||||
@@ -1,99 +0,0 @@
|
||||
---
|
||||
name: contain-audit
|
||||
description: 'Detect DOM elements where CSS contain:layout+style would improve rendering performance. Runs a Playwright-based audit on a large workflow, scores candidates by subtree size and sizing constraints, measures performance impact, and generates a ranked report.'
|
||||
---
|
||||
|
||||
# CSS Containment Audit
|
||||
|
||||
Automatically finds DOM elements where adding `contain: layout style` would reduce browser recalculation overhead.
|
||||
|
||||
## What It Does
|
||||
|
||||
1. Loads a large workflow (245 nodes) in a real browser
|
||||
2. Walks the DOM tree and scores every element as a containment candidate
|
||||
3. For each high-scoring candidate, applies `contain: layout style` via JavaScript
|
||||
4. Measures rendering performance (style recalcs, layouts, task duration) before and after
|
||||
5. Takes before/after screenshots to detect visual breakage
|
||||
6. Generates a ranked report with actionable recommendations
|
||||
|
||||
## When to Use
|
||||
|
||||
- After adding new Vue components to the node rendering pipeline
|
||||
- When investigating rendering performance on large workflows
|
||||
- Before and after refactoring node DOM structure
|
||||
- As part of periodic performance audits
|
||||
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# Start the dev server first
|
||||
pnpm dev &
|
||||
|
||||
# Run the audit (uses the @audit tag, not included in normal CI runs)
|
||||
pnpm exec playwright test browser_tests/tests/containAudit.spec.ts --project=audit
|
||||
|
||||
# View the HTML report
|
||||
pnpm exec playwright show-report
|
||||
```
|
||||
|
||||
## How to Read Results
|
||||
|
||||
The audit outputs a table to the console:
|
||||
|
||||
```text
|
||||
CSS Containment Audit Results
|
||||
=======================================================
|
||||
Rank | Selector | Subtree | Score | DRecalcs | DLayouts | Visual
|
||||
1 | [data-testid="node-inner-wrap"] | 18 | 72 | -34% | -12% | OK
|
||||
2 | .node-body | 12 | 48 | -8% | -3% | OK
|
||||
3 | .node-header | 4 | 16 | +1% | 0% | OK
|
||||
```
|
||||
|
||||
- **Subtree**: Number of descendant elements (higher = more to skip)
|
||||
- **Score**: Composite heuristic score (subtree size x sizing constraint bonus)
|
||||
- **DRecalcs / DLayouts**: Change in style recalcs / layout counts vs baseline (negative = improvement)
|
||||
- **Visual**: OK if no pixel change, DIFF if screenshot differs (may include subpixel noise — verify manually)
|
||||
|
||||
## Candidate Scoring
|
||||
|
||||
An element is a good containment candidate when:
|
||||
|
||||
1. **Large subtree** -- many descendants that the browser can skip recalculating
|
||||
2. **Externally constrained size** -- width/height determined by CSS variables, flex, or explicit values (not by content)
|
||||
3. **No existing containment** -- `contain` is not already applied
|
||||
4. **Not a leaf** -- has at least a few child elements
|
||||
|
||||
Elements that should NOT get containment:
|
||||
|
||||
- Elements whose children overflow visually beyond bounds (e.g., absolute-positioned overlays with negative inset)
|
||||
- Elements whose height is determined by content and affects sibling layout
|
||||
- Very small subtrees (overhead of containment context outweighs benefit)
|
||||
|
||||
## Limitations
|
||||
|
||||
- Cannot fully guarantee `contain` safety -- visual review of screenshots is required
|
||||
- Performance measurements have natural variance; run multiple times for confidence
|
||||
- Only tests idle and pan scenarios; widget interactions may differ
|
||||
- The audit modifies styles at runtime via JS, which doesn't account for Tailwind purging or build-time optimizations
|
||||
|
||||
## Example PR
|
||||
|
||||
[#9946 — fix: add CSS contain:layout contain:style to node inner wrapper](https://github.com/Comfy-Org/ComfyUI_frontend/pull/9946)
|
||||
|
||||
This PR added `contain-layout contain-style` to the node inner wrapper div in `LGraphNode.vue`. The audit tool would have flagged this element as a high-scoring candidate because:
|
||||
|
||||
- **Large subtree** (18+ descendants: header, slots, widgets, content, badges)
|
||||
- **Externally constrained size** (`w-(--node-width)`, `flex-1` — dimensions set by CSS variables and flex parent)
|
||||
- **Natural isolation boundary** between frequently-changing content (widgets) and infrequently-changing overlays (selection outlines, borders)
|
||||
|
||||
The actual change was a single line: adding `'contain-layout contain-style'` to the inner wrapper's class list at `src/renderer/extensions/vueNodes/components/LGraphNode.vue:79`.
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path |
|
||||
| ----------------- | ------------------------------------------------------- |
|
||||
| Audit test | `browser_tests/tests/containAudit.spec.ts` |
|
||||
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
|
||||
| Perf tests | `browser_tests/tests/performance.spec.ts` |
|
||||
| Large workflow | `browser_tests/assets/large-graph-workflow.json` |
|
||||
| Example PR | https://github.com/Comfy-Org/ComfyUI_frontend/pull/9946 |
|
||||
@@ -1,246 +0,0 @@
|
||||
---
|
||||
name: hardening-flaky-e2e-tests
|
||||
description: 'Diagnoses and fixes flaky Playwright e2e tests by replacing race-prone patterns with retry-safe alternatives. Use when triaging CI flakes, hardening spec files, fixing timing races, or asked to stabilize browser tests. Triggers on: flaky, flake, harden, stabilize, race condition in e2e, intermittent failure.'
|
||||
---
|
||||
|
||||
# Hardening Flaky E2E Tests
|
||||
|
||||
Fix flaky Playwright specs by identifying race-prone patterns and replacing them with retry-safe alternatives. This skill covers diagnosis, pattern matching, and mechanical transforms — not writing new tests (see `writing-playwright-tests` for that).
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Gather CI Evidence
|
||||
|
||||
```bash
|
||||
gh run list --workflow=ci-test.yaml --limit=5
|
||||
gh run download <run-id> -n playwright-report
|
||||
```
|
||||
|
||||
- Open `report.json` and search for `"status": "flaky"` entries.
|
||||
- Collect file paths, test titles, and error messages.
|
||||
- Do NOT trust green checks alone — flaky tests that passed on retry still need fixing.
|
||||
- Use `error-context.md`, traces, and page snapshots before editing code.
|
||||
- Pull the newest run after each push instead of assuming the flaky set is unchanged.
|
||||
|
||||
### 2. Classify the Flake
|
||||
|
||||
Read the failing assertion and match it against the pattern table. Most flakes fall into one of these categories:
|
||||
|
||||
| # | Pattern | Signature in Code | Fix |
|
||||
| --- | ------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| 1 | **Snapshot-then-assert** | `expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
|
||||
| 2 | **Immediate count** | `const n = await loc.count(); expect(n).toBe(3)` | `await expect(loc).toHaveCount(3)` |
|
||||
| 3 | **nextFrame after menu click** | `clickMenuItem(x); nextFrame()` | `clickMenuItem(x); contextMenu.waitForHidden()` |
|
||||
| 4 | **Tight poll timeout** | `expect.poll(..., { timeout: 250 })` | ≥2000 ms; prefer default 5000 ms |
|
||||
| 5 | **Immediate evaluate after mutation** | `setSetting(k, v); expect(await evaluate()).toBe(x)` | `await expect.poll(() => evaluate()).toBe(x)` |
|
||||
| 6 | **Screenshot without readiness** | `loadWorkflow(); nextFrame(); toHaveScreenshot()` | `waitForNodes()` or poll state first |
|
||||
| 7 | **Non-deterministic node order** | `getNodeRefsByType('X')[0]` with >1 match | `getNodeRefById(id)` or guard `toHaveLength(1)` |
|
||||
| 8 | **Fake readiness helper** | Helper clicks but doesn't assert state | Remove; poll the actual value |
|
||||
| 9 | **Immediate graph state after drop** | `expect(await getLinkCount()).toBe(1)` | `await expect.poll(() => getLinkCount()).toBe(1)` |
|
||||
| 10 | **Immediate boundingBox/layout read** | `const box = await loc.boundingBox(); expect(box!.width)` | `await expect.poll(() => loc.boundingBox().then(b => b?.width))` |
|
||||
|
||||
### 3. Apply the Transform
|
||||
|
||||
#### Rule: Choose the Smallest Correct Assertion
|
||||
|
||||
- **Locator state** → use built-in retrying assertions: `toBeVisible()`, `toHaveText()`, `toHaveCount()`, `toHaveClass()`
|
||||
- **Single async value** → `expect.poll(() => asyncFn()).toBe(expected)`
|
||||
- **Multiple assertions that must settle together** → `expect(async () => { ... }).toPass()`
|
||||
- **Never** use `waitForTimeout()` to hide a race.
|
||||
|
||||
```typescript
|
||||
// ✅ Single value — use expect.poll
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.app!.graph.links.length))
|
||||
.toBe(3)
|
||||
|
||||
// ✅ Locator count — use toHaveCount
|
||||
await expect(comfyPage.page.locator('.dom-widget')).toHaveCount(2)
|
||||
|
||||
// ✅ Multiple conditions — use toPass
|
||||
await expect(async () => {
|
||||
expect(await node1.getValue()).toBe('foo')
|
||||
expect(await node2.getValue()).toBe('bar')
|
||||
}).toPass({ timeout: 5000 })
|
||||
```
|
||||
|
||||
#### Rule: Wait for the Real Readiness Boundary
|
||||
|
||||
Visible is not always ready. Prefer user-facing assertions when possible; poll internal state only when there is no UI surface to assert on.
|
||||
|
||||
Common readiness boundaries:
|
||||
|
||||
| After this action... | Wait for... |
|
||||
| -------------------------------------- | ------------------------------------------------------------ |
|
||||
| Canvas interaction (drag, click node) | `await comfyPage.nextFrame()` |
|
||||
| Menu item click | `await contextMenu.waitForHidden()` |
|
||||
| Workflow load | `await comfyPage.workflow.loadWorkflow(...)` (built-in wait) |
|
||||
| Settings write | Poll the setting value with `expect.poll()` |
|
||||
| Node pin/bypass/collapse toggle | `await expect.poll(() => nodeRef.isPinned()).toBe(true)` |
|
||||
| Graph mutation (add/remove node, link) | Poll link/node count |
|
||||
| Clipboard write | Poll pasted value |
|
||||
| Screenshot | Ensure nodes are rendered: `waitForNodes()` or poll state |
|
||||
|
||||
#### Rule: Expose Locators for Retrying Assertions
|
||||
|
||||
When a helper returns a count via `await loc.count()`, callers can't use `toHaveCount()`. Expose the underlying `Locator` as a getter so callers choose between:
|
||||
|
||||
```typescript
|
||||
// Helper exposes locator
|
||||
get domWidgets(): Locator {
|
||||
return this.page.locator('.dom-widget')
|
||||
}
|
||||
|
||||
// Caller uses retrying assertion
|
||||
await expect(comfyPage.domWidgets).toHaveCount(2)
|
||||
```
|
||||
|
||||
Replace count methods with locator getters so callers can use retrying assertions directly.
|
||||
|
||||
#### Rule: Fix Check-then-Act Races in Helpers
|
||||
|
||||
```typescript
|
||||
// ❌ Race: count can change between check and waitFor
|
||||
const count = await locator.count()
|
||||
if (count > 0) {
|
||||
await locator.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
// ✅ Direct: waitFor handles both cases
|
||||
await locator.waitFor({ state: 'hidden' })
|
||||
```
|
||||
|
||||
#### Rule: Remove force:true from Clicks
|
||||
|
||||
`force: true` bypasses actionability checks, hiding real animation/visibility races. Remove it and fix the underlying timing issue.
|
||||
|
||||
```typescript
|
||||
// ❌ Hides the race
|
||||
await closeButton.click({ force: true })
|
||||
|
||||
// ✅ Surfaces the real issue — fix with proper wait
|
||||
await closeButton.click()
|
||||
await dialog.waitForHidden()
|
||||
```
|
||||
|
||||
#### Rule: Handle Non-deterministic Element Order
|
||||
|
||||
When `getNodeRefsByType` returns multiple nodes, the order is not guaranteed. Don't use index `[0]` blindly.
|
||||
|
||||
```typescript
|
||||
// ❌ Assumes order
|
||||
const node = (await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode'))[0]
|
||||
|
||||
// ✅ Find by ID or proximity
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
let target = nodes[0]
|
||||
for (const n of nodes) {
|
||||
const pos = await n.getPosition()
|
||||
if (Math.abs(pos.y - expectedY) < minDist) target = n
|
||||
}
|
||||
```
|
||||
|
||||
Or guard the assumption:
|
||||
|
||||
```typescript
|
||||
const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')
|
||||
expect(nodes).toHaveLength(1)
|
||||
const node = nodes[0]
|
||||
```
|
||||
|
||||
#### Rule: Use toPass for Timing-sensitive Dismiss Guards
|
||||
|
||||
Some UI elements (e.g. LiteGraph's graphdialog) have built-in dismiss delays. Retry the entire dismiss action:
|
||||
|
||||
```typescript
|
||||
// ✅ Retry click+assert together
|
||||
await expect(async () => {
|
||||
await comfyPage.canvas.click({ position: { x: 10, y: 10 } })
|
||||
await expect(dialog).toBeHidden({ timeout: 500 })
|
||||
}).toPass({ timeout: 5000 })
|
||||
```
|
||||
|
||||
### 4. Keep Changes Narrow
|
||||
|
||||
- Shared helpers should drive setup to a stable boundary.
|
||||
- Do not encode one-spec timing assumptions into generic helpers.
|
||||
- If a race only matters to one spec, prefer a local wait in that spec.
|
||||
- If a helper fails before the real test begins, remove or relax the brittle precondition and let downstream UI interaction prove readiness.
|
||||
|
||||
### 5. Verify Narrowly
|
||||
|
||||
```bash
|
||||
# Targeted rerun with repetition
|
||||
pnpm test:browser:local -- browser_tests/tests/myFile.spec.ts --repeat-each 10
|
||||
|
||||
# Single test by line number (avoids grep quoting issues on Windows)
|
||||
pnpm test:browser:local -- browser_tests/tests/myFile.spec.ts:42
|
||||
```
|
||||
|
||||
- Use `--repeat-each 10` for targeted flake verification (use 20 for single test cases).
|
||||
- Verify with the smallest command that exercises the flaky path.
|
||||
|
||||
### 6. Watch CI E2E Runs
|
||||
|
||||
After pushing, use `gh` to monitor the E2E workflow:
|
||||
|
||||
```bash
|
||||
# Find the run for the current branch
|
||||
gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1
|
||||
|
||||
# Watch it live (blocks until complete, streams logs)
|
||||
gh run watch <run-id>
|
||||
|
||||
# One-liner: find and watch the latest E2E run for the current branch
|
||||
gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 --json databaseId --jq ".[0].databaseId" | xargs gh run watch
|
||||
```
|
||||
|
||||
On Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
# One-liner equivalent
|
||||
gh run watch (gh run list --workflow="CI: Tests E2E" --branch=$(git branch --show-current) --limit=1 --json databaseId --jq ".[0].databaseId")
|
||||
```
|
||||
|
||||
After the run completes:
|
||||
|
||||
```bash
|
||||
# Download the Playwright report artifact
|
||||
gh run download <run-id> -n playwright-report
|
||||
|
||||
# View the run summary in browser
|
||||
gh run view <run-id> --web
|
||||
```
|
||||
|
||||
Also watch the unit test workflow in parallel if you changed helpers:
|
||||
|
||||
```bash
|
||||
gh run list --workflow="CI: Tests Unit" --branch=$(git branch --show-current) --limit=1
|
||||
```
|
||||
|
||||
### 7. Pre-merge Checklist
|
||||
|
||||
Before merging a flaky-test fix, confirm:
|
||||
|
||||
- [ ] The latest CI artifact was inspected directly
|
||||
- [ ] The root cause is stated as a race or readiness mismatch
|
||||
- [ ] The fix waits on the real readiness boundary
|
||||
- [ ] The assertion primitive matches the job (poll vs toHaveCount vs toPass)
|
||||
- [ ] The fix stays local unless a shared helper truly owns the race
|
||||
- [ ] Local verification uses a targeted rerun
|
||||
- [ ] No behavioral changes to the test — only timing/retry strategy updated
|
||||
|
||||
## Local Noise — Do Not Fix
|
||||
|
||||
These are local distractions, not CI root causes:
|
||||
|
||||
- Missing local input fixture files required by the test path
|
||||
- Missing local models directory
|
||||
- Teardown `EPERM` while restoring the local browser-test user data directory
|
||||
- Local screenshot baseline differences on Windows
|
||||
|
||||
Rules:
|
||||
|
||||
- First confirm whether it blocks the exact flaky path under investigation.
|
||||
- Do not commit temporary local assets used only for verification.
|
||||
- Do not commit local screenshot baselines.
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
name: layer-audit
|
||||
description: 'Detect violations of the layered architecture import rules (base -> platform -> workbench -> renderer). Runs ESLint with the import-x/no-restricted-paths rule and generates a grouped report.'
|
||||
---
|
||||
|
||||
# Layer Architecture Audit
|
||||
|
||||
Finds imports that violate the layered architecture boundary rules enforced by `import-x/no-restricted-paths` in `eslint.config.ts`.
|
||||
|
||||
## Layer Hierarchy (bottom to top)
|
||||
|
||||
```
|
||||
renderer (top -- can import from all lower layers)
|
||||
^
|
||||
workbench
|
||||
^
|
||||
platform
|
||||
^
|
||||
base (bottom -- cannot import from any upper layer)
|
||||
```
|
||||
|
||||
Each layer may only import from layers below it.
|
||||
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# Run ESLint filtering for just the layer boundary rule violations
|
||||
pnpm lint 2>&1 | grep 'import-x/no-restricted-paths' -B1 | head -200
|
||||
```
|
||||
|
||||
To get a full structured report, run:
|
||||
|
||||
```bash
|
||||
# Collect all violations from base/, platform/, workbench/ layers
|
||||
pnpm eslint src/base/ src/platform/ src/workbench/ --no-error-on-unmatched-pattern --rule '{"import-x/no-restricted-paths": "warn"}' --format compact 2>&1 | grep 'no-restricted-paths' | sort
|
||||
```
|
||||
|
||||
## How to Read Results
|
||||
|
||||
Each violation line shows:
|
||||
|
||||
- The **file** containing the bad import
|
||||
- The **import path** crossing the boundary
|
||||
- The **message** identifying which layer pair is violated
|
||||
|
||||
### Grouping by Layer Pair
|
||||
|
||||
After collecting violations, group them by the layer pair pattern:
|
||||
|
||||
| Layer pair | Meaning |
|
||||
| --------------------- | ----------------------------------- |
|
||||
| base -> platform | base/ importing from platform/ |
|
||||
| base -> workbench | base/ importing from workbench/ |
|
||||
| base -> renderer | base/ importing from renderer/ |
|
||||
| platform -> workbench | platform/ importing from workbench/ |
|
||||
| platform -> renderer | platform/ importing from renderer/ |
|
||||
| workbench -> renderer | workbench/ importing from renderer/ |
|
||||
|
||||
## When to Use
|
||||
|
||||
- Before creating a PR that adds imports between `src/base/`, `src/platform/`, `src/workbench/`, or `src/renderer/`
|
||||
- When auditing the codebase to find and plan migration of existing violations
|
||||
- After moving files between layers to verify no new violations were introduced
|
||||
|
||||
## Fixing Violations
|
||||
|
||||
Common strategies to resolve a layer violation:
|
||||
|
||||
1. **Move the import target down** -- if the imported module doesn't depend on upper-layer concepts, move it to a lower layer
|
||||
2. **Introduce an interface** -- define an interface/type in the lower layer and implement it in the upper layer via dependency injection or a registration pattern
|
||||
3. **Move the importing file up** -- if the file logically belongs in a higher layer, relocate it
|
||||
4. **Extract shared logic** -- pull the shared functionality into `base/` or a shared utility
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path |
|
||||
| ------------------------------- | ------------------ |
|
||||
| ESLint config (rule definition) | `eslint.config.ts` |
|
||||
| Base layer | `src/base/` |
|
||||
| Platform layer | `src/platform/` |
|
||||
| Workbench layer | `src/workbench/` |
|
||||
| Renderer layer | `src/renderer/` |
|
||||
@@ -1,179 +0,0 @@
|
||||
---
|
||||
name: perf-fix-with-proof
|
||||
description: 'Ships performance fixes with CI-proven improvement using stacked PRs. PR1 adds a @perf test (establishes baseline on main), PR2 adds the fix (CI shows delta). Use when implementing a perf optimization and wanting to prove it in CI.'
|
||||
---
|
||||
|
||||
# Performance Fix with Proof
|
||||
|
||||
Ships perf fixes as two stacked PRs so CI automatically proves the improvement.
|
||||
|
||||
## Why Two PRs
|
||||
|
||||
The `ci-perf-report.yaml` workflow compares PR metrics against the **base branch baseline**. If you add a new `@perf` test in the same PR as the fix, that test doesn't exist on main yet — no baseline, no delta, no proof. Stacking solves this:
|
||||
|
||||
1. **PR1 (test-only)** — adds the `@perf` test that exercises the bottleneck. Merges to main. CI runs it on main → baseline established.
|
||||
2. **PR2 (fix)** — adds the optimization. CI runs the same test → compares against PR1's baseline → delta shows improvement.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Create the test branch
|
||||
|
||||
```bash
|
||||
git worktree add <worktree-path> -b perf/test-<name> origin/main
|
||||
```
|
||||
|
||||
### Step 2: Write the `@perf` test
|
||||
|
||||
Add a test to `browser_tests/tests/performance.spec.ts` (or a new file with `@perf` tag). The test should stress the specific bottleneck.
|
||||
|
||||
**Test structure:**
|
||||
|
||||
```typescript
|
||||
test('<descriptive name>', async ({ comfyPage }) => {
|
||||
// 1. Load a workflow that exercises the bottleneck
|
||||
await comfyPage.workflow.loadWorkflow('<workflow>')
|
||||
|
||||
// 2. Start measuring
|
||||
await comfyPage.perf.startMeasuring()
|
||||
|
||||
// 3. Perform the action that triggers the bottleneck (at scale)
|
||||
for (let i = 0; i < N; i++) {
|
||||
// ... stress the hot path ...
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
// 4. Stop measuring and record
|
||||
const m = await comfyPage.perf.stopMeasuring('<metric-name>')
|
||||
recordMeasurement(m)
|
||||
console.log(`<name>: ${m.styleRecalcs} recalcs, ${m.layouts} layouts`)
|
||||
})
|
||||
```
|
||||
|
||||
**Available metrics** (from `PerformanceHelper`):
|
||||
|
||||
- `m.styleRecalcs` / `m.styleRecalcDurationMs` — style recalculation count and time
|
||||
- `m.layouts` / `m.layoutDurationMs` — forced layout count and time
|
||||
- `m.taskDurationMs` — total main-thread JS execution time
|
||||
- `m.heapDeltaBytes` — memory pressure delta
|
||||
|
||||
**Key helpers** (from `ComfyPage`):
|
||||
|
||||
- `comfyPage.perf.startMeasuring()` / `.stopMeasuring(name)` — CDP metrics capture
|
||||
- `comfyPage.nextFrame()` — wait one animation frame
|
||||
- `comfyPage.workflow.loadWorkflow(name)` — load a test workflow from `browser_tests/assets/`
|
||||
- `comfyPage.canvas` — the canvas locator
|
||||
- `comfyPage.page.mouse.move(x, y)` — mouse interaction
|
||||
|
||||
### Step 3: Add test workflow asset (if needed)
|
||||
|
||||
If the bottleneck needs a specific workflow (e.g., 50+ nodes, many DOM widgets), add it to `browser_tests/assets/`. Keep it minimal — only the structure needed to trigger the bottleneck.
|
||||
|
||||
### Step 4: Verify locally
|
||||
|
||||
```bash
|
||||
pnpm exec playwright test --project=performance --grep "<test name>"
|
||||
```
|
||||
|
||||
Confirm the test runs and produces reasonable metric values.
|
||||
|
||||
### Step 5: Create PR1 (test-only)
|
||||
|
||||
```bash
|
||||
pnpm typecheck:browser
|
||||
pnpm lint
|
||||
git add browser_tests/
|
||||
git commit -m "test: add perf test for <bottleneck description>"
|
||||
git push -u origin perf/test-<name>
|
||||
gh pr create --title "test: add perf test for <bottleneck>" \
|
||||
--body "Adds a @perf test to establish a baseline for <bottleneck>.
|
||||
|
||||
This is PR 1 of 2. The fix will follow in a separate PR once this baseline is established on main.
|
||||
|
||||
## What
|
||||
Adds \`<test-name>\` to the performance test suite measuring <metric> during <action>.
|
||||
|
||||
## Why
|
||||
Needed to prove the improvement from the upcoming fix for backlog item #<N>." \
|
||||
--base main
|
||||
```
|
||||
|
||||
### Step 6: Get PR1 merged
|
||||
|
||||
Once PR1 merges, CI runs the test on main → baseline artifact saved.
|
||||
|
||||
### Step 7: Create PR2 (fix) on top of main
|
||||
|
||||
```bash
|
||||
git worktree add <worktree-path> -b perf/fix-<name> origin/main
|
||||
```
|
||||
|
||||
Implement the fix. The `@perf` test from PR1 is now on main and will run automatically. CI will:
|
||||
|
||||
1. Run the test on the PR branch
|
||||
2. Download the baseline from main (which includes PR1's test results)
|
||||
3. Post a PR comment showing the delta
|
||||
|
||||
### Step 8: Verify the improvement shows in CI
|
||||
|
||||
The `ci-perf-report.yaml` posts a comment like:
|
||||
|
||||
```markdown
|
||||
## ⚡ Performance Report
|
||||
|
||||
| Metric | Baseline | PR (n=3) | Δ | Sig |
|
||||
| --------------------- | -------- | -------- | ---- | --- |
|
||||
| <name>: style recalcs | 450 | 12 | -97% | 🟢 |
|
||||
```
|
||||
|
||||
If Δ is negative for the target metric, the fix is proven.
|
||||
|
||||
## Test Design Guidelines
|
||||
|
||||
1. **Stress the specific bottleneck** — don't measure everything, isolate the hot path
|
||||
2. **Use enough iterations** — the test should run long enough that the metric difference is clear (100+ frames for idle tests, 50+ interactions for event tests)
|
||||
3. **Keep it deterministic** — avoid timing-dependent assertions; measure counts not durations when possible
|
||||
4. **Match the backlog entry** — reference the backlog item number in the test name or PR description
|
||||
|
||||
## Examples
|
||||
|
||||
**Testing DOM widget reactive mutations (backlog #8):**
|
||||
|
||||
```typescript
|
||||
test('DOM widget positioning recalculations', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.perf.startMeasuring()
|
||||
// Idle for 120 frames — DOM widgets update position every frame
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
const m = await comfyPage.perf.stopMeasuring('dom-widget-idle')
|
||||
recordMeasurement(m)
|
||||
})
|
||||
```
|
||||
|
||||
**Testing measureText caching (backlog #4):**
|
||||
|
||||
```typescript
|
||||
test('canvas text rendering with many nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('large-workflow-50-nodes')
|
||||
await comfyPage.perf.startMeasuring()
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
const m = await comfyPage.perf.stopMeasuring('text-rendering-50-nodes')
|
||||
recordMeasurement(m)
|
||||
})
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path |
|
||||
| ----------------- | ----------------------------------------------------- |
|
||||
| Perf test file | `browser_tests/tests/performance.spec.ts` |
|
||||
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
|
||||
| Perf reporter | `browser_tests/fixtures/utils/perfReporter.ts` |
|
||||
| CI workflow | `.github/workflows/ci-perf-report.yaml` |
|
||||
| Report generator | `scripts/perf-report.ts` |
|
||||
| Stats utilities | `scripts/perf-stats.ts` |
|
||||
| Backlog | `docs/perf/BACKLOG.md` (local only, not committed) |
|
||||
| Playbook | `docs/perf/PLAYBOOK.md` (local only, not committed) |
|
||||
@@ -1,221 +0,0 @@
|
||||
---
|
||||
name: red-green-fix
|
||||
description: 'Bug fix workflow that proves test validity with a red-then-green CI sequence. Commits a failing test first (CI red), then the minimal fix (CI green). Use when fixing a bug, writing a regression test, or when asked to prove a fix works.'
|
||||
---
|
||||
|
||||
# Red-Green Fix
|
||||
|
||||
Fixes bugs as two commits so CI automatically proves the test catches the bug.
|
||||
|
||||
## Why Two Commits
|
||||
|
||||
If you commit the test and fix together, the test always passes — reviewers cannot tell whether the test actually detects the bug or is a no-op. Splitting into two commits creates a verifiable CI trail:
|
||||
|
||||
1. **Commit 1 (test-only)** — adds a test that exercises the bug. CI runs it → test fails → red X.
|
||||
2. **Commit 2 (fix)** — adds the minimal fix. CI runs the same test → test passes → green check.
|
||||
|
||||
The red-then-green sequence in the commit history proves the test is valid.
|
||||
|
||||
## Input
|
||||
|
||||
The user provides a bug description as an argument. If no description is given, ask the user to describe the bug before proceeding.
|
||||
|
||||
Bug description: $ARGUMENTS
|
||||
|
||||
## Step 0 — Setup
|
||||
|
||||
Create an isolated branch from main:
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git checkout -b fix/<bug-name> origin/main
|
||||
```
|
||||
|
||||
## Step 1 — Red: Failing Test Only
|
||||
|
||||
Write a test that reproduces the bug. **Do NOT write any fix code.**
|
||||
|
||||
### Choosing the Test Framework
|
||||
|
||||
| Bug type | Framework | File location |
|
||||
| --------------------------------- | ---------- | ------------------------------- |
|
||||
| Logic, utils, stores, composables | Vitest | `src/**/*.test.ts` (colocated) |
|
||||
| UI interaction, canvas, workflows | Playwright | `browser_tests/tests/*.spec.ts` |
|
||||
|
||||
For Playwright tests, follow the `/writing-playwright-tests` skill for patterns, fixtures, and tags.
|
||||
|
||||
### Rules
|
||||
|
||||
- The test MUST fail against the current codebase (this is the whole point)
|
||||
- Do NOT modify any source code outside of test files
|
||||
- Do NOT include any fix, workaround, or behavioral change
|
||||
- Do NOT add unrelated tests or refactor existing tests
|
||||
- Keep the test minimal — only what is needed to reproduce the bug
|
||||
- Avoid common anti-patterns — see `reference/testing-anti-patterns.md`
|
||||
|
||||
### Vitest Example
|
||||
|
||||
```typescript
|
||||
// src/utils/pathUtil.test.ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveModelPath } from './pathUtil'
|
||||
|
||||
describe('resolveModelPath', () => {
|
||||
it('handles absolute paths from folder_paths API', () => {
|
||||
const result = resolveModelPath(
|
||||
'/absolute/models',
|
||||
'/absolute/models/checkpoints'
|
||||
)
|
||||
expect(result).toBe('/absolute/models/checkpoints')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Playwright Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Model Download', { tag: ['@smoke'] }, () => {
|
||||
test('downloads model when path is absolute', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing-model')
|
||||
const downloadBtn = comfyPage.page.getByTestId('download-model-button')
|
||||
await downloadBtn.click()
|
||||
await expect(comfyPage.page.getByText('Download complete')).toBeVisible()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Verify Locally First
|
||||
|
||||
Run the test locally before pushing to confirm it fails for the right reason:
|
||||
|
||||
```bash
|
||||
# Vitest
|
||||
pnpm test:unit <test-file>
|
||||
|
||||
# Playwright
|
||||
pnpm test:browser:local -- --grep "<test name>"
|
||||
```
|
||||
|
||||
If the test passes locally, it does not reproduce the bug — revisit your test before pushing.
|
||||
|
||||
### Quality Checks and Commit
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm format:check
|
||||
|
||||
git add <test-files-only>
|
||||
git commit -m "test: add failing test for <concise bug description>"
|
||||
git push -u origin HEAD
|
||||
```
|
||||
|
||||
### Verify CI Failure
|
||||
|
||||
```bash
|
||||
gh run list --branch $(git branch --show-current) --limit 1
|
||||
```
|
||||
|
||||
**STOP HERE.** Inform the user of the CI status and wait for confirmation before proceeding to Step 2.
|
||||
|
||||
- If CI passes: the test does not catch the bug. Revisit the test.
|
||||
- If CI fails for unrelated reasons: investigate and fix the test setup, not the bug.
|
||||
- If CI fails because the test correctly catches the bug: proceed to Step 2.
|
||||
|
||||
## Step 2 — Green: Minimal Fix
|
||||
|
||||
Write the minimum code change needed to make the failing test pass.
|
||||
|
||||
### Rules
|
||||
|
||||
- Do NOT modify, weaken, or delete the test from Step 1 — it is immutable. If the test needs changes, restart from Step 1 and re-prove the red.
|
||||
- Do NOT add new tests (tests were finalized in Step 1)
|
||||
- Do NOT refactor, clean up, or make "drive-by" improvements
|
||||
- Do NOT modify code unrelated to the bug
|
||||
- The fix should be the smallest correct change
|
||||
|
||||
### Quality Checks and Commit
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm format
|
||||
|
||||
git add <fix-files-only>
|
||||
git commit -m "fix: <concise bug description>"
|
||||
git push
|
||||
```
|
||||
|
||||
### Verify CI Pass
|
||||
|
||||
```bash
|
||||
gh run list --branch $(git branch --show-current) --limit 1
|
||||
```
|
||||
|
||||
- If CI passes: the fix is verified. Proceed to PR creation.
|
||||
- If CI fails: investigate and fix. Do NOT change the test from Step 1.
|
||||
|
||||
## Step 3 — Open Pull Request
|
||||
|
||||
```bash
|
||||
gh pr create --title "fix: <description>" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
|
||||
<Brief explanation of the bug and root cause>
|
||||
|
||||
- Fixes #<issue-number>
|
||||
|
||||
## Red-Green Verification
|
||||
|
||||
| Commit | CI Status | Purpose |
|
||||
|--------|-----------|---------|
|
||||
| `test: ...` | :red_circle: Red | Proves the test catches the bug |
|
||||
| `fix: ...` | :green_circle: Green | Proves the fix resolves the bug |
|
||||
|
||||
## Test Plan
|
||||
|
||||
- [ ] CI red on test-only commit
|
||||
- [ ] CI green on fix commit
|
||||
- [ ] Added/updated E2E regression under `browser_tests/` or explained why not applicable
|
||||
- [ ] Manual verification (if applicable)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
### CI fails on test commit for unrelated reasons
|
||||
|
||||
Lint, typecheck, or other tests may fail — not just your new test. Check the CI logs carefully. If the failure is unrelated, fix it in a separate commit before the `test:` commit so the red X is clearly attributable to your test.
|
||||
|
||||
### Test passes when it should fail
|
||||
|
||||
The bug may only manifest under specific conditions (e.g., Windows paths, external model directories, certain workflow structures). Make sure your test setup matches the actual bug scenario. Check that you're not accidentally testing the happy path.
|
||||
|
||||
### Flaky Playwright tests
|
||||
|
||||
If your e2e test is intermittent, it doesn't prove anything. Use retrying assertions (`toBeVisible`, `toHaveText`) instead of `waitForTimeout`. See the `/writing-playwright-tests` skill for anti-patterns.
|
||||
|
||||
### Pre-existing CI failures on main
|
||||
|
||||
If main itself is red, branch from the last green commit or fix the pre-existing failure first. A red-green proof is meaningless if the baseline is already red.
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path |
|
||||
| --------------------- | ------------------------------------------------- |
|
||||
| Unit test framework | Vitest (`src/**/*.test.ts`) |
|
||||
| E2E test framework | Playwright (`browser_tests/tests/*.spec.ts`) |
|
||||
| E2E fixtures | `browser_tests/fixtures/` |
|
||||
| E2E assets | `browser_tests/assets/` |
|
||||
| Playwright skill | `.Codex/skills/writing-playwright-tests/SKILL.md` |
|
||||
| Unit CI | `.github/workflows/ci-tests-unit.yaml` |
|
||||
| E2E CI | `.github/workflows/ci-tests-e2e.yaml` |
|
||||
| Lint CI | `.github/workflows/ci-lint-format.yaml` |
|
||||
| Testing anti-patterns | `reference/testing-anti-patterns.md` |
|
||||
| Related skill | `.Codex/skills/perf-fix-with-proof/SKILL.md` |
|
||||
@@ -1,214 +0,0 @@
|
||||
# Testing Anti-Patterns for Red-Green Fixes
|
||||
|
||||
Common mistakes that undermine the red-green proof. Avoid these when writing the test commit (Step 1).
|
||||
|
||||
## Testing Implementation Details
|
||||
|
||||
Test observable behavior, not internal state.
|
||||
|
||||
**Bad** — coupling to internals:
|
||||
|
||||
```typescript
|
||||
it('uses cache internally', () => {
|
||||
const service = new UserService()
|
||||
service.getUser(1)
|
||||
expect(service._cache.has(1)).toBe(true) // Implementation detail
|
||||
})
|
||||
```
|
||||
|
||||
**Good** — testing through the public interface:
|
||||
|
||||
```typescript
|
||||
it('returns same user on repeated calls', async () => {
|
||||
const service = new UserService()
|
||||
const user1 = await service.getUser(1)
|
||||
const user2 = await service.getUser(1)
|
||||
expect(user1).toBe(user2) // Behavior, not implementation
|
||||
})
|
||||
```
|
||||
|
||||
Why this matters for red-green: if your test is coupled to internals, a valid fix that changes the implementation may break the test — even though the bug is fixed. The green commit should only require changing source code, not rewriting the test.
|
||||
|
||||
## Assertion-Free Tests
|
||||
|
||||
Every test must assert something meaningful. A test without assertions always passes — it cannot produce the red X needed in Step 1.
|
||||
|
||||
**Bad**:
|
||||
|
||||
```typescript
|
||||
it('processes the download', () => {
|
||||
processDownload('/models/checkpoints', 'model.safetensors')
|
||||
// No expect()!
|
||||
})
|
||||
```
|
||||
|
||||
**Good**:
|
||||
|
||||
```typescript
|
||||
it('processes the download to correct path', () => {
|
||||
const result = processDownload('/models/checkpoints', 'model.safetensors')
|
||||
expect(result.savePath).toBe('/models/checkpoints/model.safetensors')
|
||||
})
|
||||
```
|
||||
|
||||
## Over-Mocking
|
||||
|
||||
Mock only system boundaries (network, filesystem, Electron APIs). If you mock the module under test, you are testing your mocks — the test will not detect the real bug.
|
||||
|
||||
**Bad** — mocking everything:
|
||||
|
||||
```typescript
|
||||
vi.mock('./pathResolver')
|
||||
vi.mock('./validator')
|
||||
vi.mock('./downloader')
|
||||
|
||||
it('downloads model', () => {
|
||||
// This only tests that mocks were called, not that the bug exists
|
||||
})
|
||||
```
|
||||
|
||||
**Good** — mock only the boundary:
|
||||
|
||||
```typescript
|
||||
vi.mock('./electronAPI') // Boundary: Electron IPC
|
||||
|
||||
it('resolves absolute path correctly', () => {
|
||||
const result = resolveModelPath('/root/models', '/root/models/checkpoints')
|
||||
expect(result).toBe('/root/models/checkpoints')
|
||||
})
|
||||
```
|
||||
|
||||
See also: [Don't Mock What You Don't Own](https://hynek.me/articles/what-to-mock-in-5-mins/)
|
||||
|
||||
## Giant Tests
|
||||
|
||||
A test that covers the entire flow makes it hard to pinpoint which part catches the bug. Keep it focused — one concept per test.
|
||||
|
||||
**Bad**:
|
||||
|
||||
```typescript
|
||||
it('full model download flow', async () => {
|
||||
// 80 lines: load workflow, open dialog, select model,
|
||||
// click download, verify path, check progress, confirm completion
|
||||
})
|
||||
```
|
||||
|
||||
**Good**:
|
||||
|
||||
```typescript
|
||||
it('resolves absolute savePath without nesting under modelsDirectory', () => {
|
||||
const result = getLocalSavePath(
|
||||
'/models',
|
||||
'/models/checkpoints',
|
||||
'file.safetensors'
|
||||
)
|
||||
expect(result).toBe('/models/checkpoints/file.safetensors')
|
||||
})
|
||||
```
|
||||
|
||||
If the bug is in path resolution, test path resolution — not the entire download flow.
|
||||
|
||||
## Test Duplication
|
||||
|
||||
Duplicated test code hides what actually differs between cases. Use parameterized tests.
|
||||
|
||||
**Bad**:
|
||||
|
||||
```typescript
|
||||
it('resolves checkpoints path', () => {
|
||||
expect(resolve('/models', '/models/checkpoints', 'a.safetensors')).toBe(
|
||||
'/models/checkpoints/a.safetensors'
|
||||
)
|
||||
})
|
||||
it('resolves loras path', () => {
|
||||
expect(resolve('/models', '/models/loras', 'b.safetensors')).toBe(
|
||||
'/models/loras/b.safetensors'
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
**Good**:
|
||||
|
||||
```typescript
|
||||
it.each([
|
||||
['/models/checkpoints', 'a.safetensors', '/models/checkpoints/a.safetensors'],
|
||||
['/models/loras', 'b.safetensors', '/models/loras/b.safetensors']
|
||||
])('resolves %s/%s to %s', (dir, file, expected) => {
|
||||
expect(resolve('/models', dir, file)).toBe(expected)
|
||||
})
|
||||
```
|
||||
|
||||
## Flaky Tests
|
||||
|
||||
A flaky test cannot prove anything — it may show red for reasons unrelated to the bug, or green despite the bug still existing.
|
||||
|
||||
**Common causes in this codebase:**
|
||||
|
||||
| Cause | Fix |
|
||||
| -------------------------------------- | --------------------------------------- |
|
||||
| Missing `nextFrame()` after canvas ops | Add `await comfyPage.nextFrame()` |
|
||||
| `waitForTimeout` instead of assertions | Use `toBeVisible()`, `toHaveText()` |
|
||||
| Shared state between tests | Isolate with `afterEach` / `beforeEach` |
|
||||
| Timing-dependent logic | Use `expect.poll()` or `toPass()` |
|
||||
|
||||
## Gaming the Red-Green Process
|
||||
|
||||
These are ways the red-green proof gets invalidated during Step 2 (the fix commit). The test from Step 1 is immutable — if any of these happen, restart from Step 1.
|
||||
|
||||
**Weakening the assertion to make it pass:**
|
||||
|
||||
```typescript
|
||||
// Step 1 (red) — strict assertion
|
||||
expect(result).toBe('/external/drive/models/checkpoints/file.safetensors')
|
||||
|
||||
// Step 2 (green) — weakened to pass without a real fix
|
||||
expect(result).toBeDefined() // This proves nothing
|
||||
```
|
||||
|
||||
**Updating snapshots to bless the bug:**
|
||||
|
||||
```bash
|
||||
# Instead of fixing the code, just updating the snapshot to match buggy output
|
||||
pnpm test:unit --update
|
||||
```
|
||||
|
||||
If a snapshot needs updating, the fix should change the code behavior, not the expected output.
|
||||
|
||||
**Adding mocks in Step 2 that hide the failure:**
|
||||
|
||||
```typescript
|
||||
// Step 2 adds a mock that didn't exist in Step 1
|
||||
vi.mock('./pathResolver', () => ({
|
||||
resolve: () => '/expected/path' // Hardcoded to pass
|
||||
}))
|
||||
```
|
||||
|
||||
Step 2 should only change source code — not test infrastructure.
|
||||
|
||||
## Testing the Happy Path Only
|
||||
|
||||
The red-green pattern specifically requires the test to exercise the **broken path**. If you only test the case that already works, the test will pass (green) on Step 1 — defeating the purpose.
|
||||
|
||||
**Bad** — testing the default case that works:
|
||||
|
||||
```typescript
|
||||
it('downloads to default models directory', () => {
|
||||
// This already works — it won't produce a red X
|
||||
const result = resolve('/models', 'checkpoints', 'file.safetensors')
|
||||
expect(result).toBe('/models/checkpoints/file.safetensors')
|
||||
})
|
||||
```
|
||||
|
||||
**Good** — testing the case that is actually broken:
|
||||
|
||||
```typescript
|
||||
it('downloads to external models directory configured via extra_model_paths', () => {
|
||||
// This is the broken case — absolute path from folder_paths API
|
||||
const result = resolve(
|
||||
'/models',
|
||||
'/external/drive/models/checkpoints',
|
||||
'file.safetensors'
|
||||
)
|
||||
expect(result).toBe('/external/drive/models/checkpoints/file.safetensors')
|
||||
})
|
||||
```
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
name: regenerating-screenshots
|
||||
description: 'Creates a PR to regenerate Playwright screenshot expectations. Use when screenshot tests are failing on main or PRs due to stale golden images. Triggers on: regen screenshots, regenerate screenshots, update expectations, fix screenshot tests.'
|
||||
---
|
||||
|
||||
# Regenerating Playwright Screenshot Expectations
|
||||
|
||||
Automates the process of triggering the `PR: Update Playwright Expectations`
|
||||
GitHub Action by creating a labeled PR from `origin/main`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Fetch latest main**
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
```
|
||||
|
||||
2. **Create a timestamped branch** from `origin/main`
|
||||
|
||||
Format: `regen-screenshots/YYYY-MM-DDTHH` (hour resolution, local time)
|
||||
|
||||
```bash
|
||||
git checkout -b regen-screenshots/<datetime> origin/main
|
||||
```
|
||||
|
||||
3. **Create an empty commit**
|
||||
|
||||
```bash
|
||||
git commit --allow-empty -m "test: regenerate screenshot expectations"
|
||||
```
|
||||
|
||||
4. **Push the branch**
|
||||
|
||||
```bash
|
||||
git push origin regen-screenshots/<datetime>
|
||||
```
|
||||
|
||||
5. **Generate a poem** about regenerating screenshots. Be creative — a
|
||||
new, unique poem every time. Short (4–8 lines). Can be funny, wistful,
|
||||
epic, haiku-style, limerick, sonnet fragment — vary the form.
|
||||
|
||||
6. **Create the PR** with the poem as the body (no label yet).
|
||||
|
||||
Write the poem to a temp file and use `--body-file`:
|
||||
|
||||
```bash
|
||||
# Write poem to temp file
|
||||
# Create PR:
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head regen-screenshots/<datetime> \
|
||||
--title "test: regenerate screenshot expectations" \
|
||||
--body-file <temp-file>
|
||||
```
|
||||
|
||||
7. **Add the label** as a separate step to trigger the GitHub Action.
|
||||
|
||||
The `labeled` event only fires when a label is added after PR
|
||||
creation, not when applied during creation via `--label`.
|
||||
|
||||
Use the GitHub API directly (`gh pr edit --add-label` fails due to
|
||||
deprecated Projects Classic GraphQL errors):
|
||||
|
||||
```bash
|
||||
gh api repos/{owner}/{repo}/issues/<pr-number>/labels \
|
||||
-f "labels[]=New Browser Test Expectations" --method POST
|
||||
```
|
||||
|
||||
8. **Report the result** to the user:
|
||||
- PR URL
|
||||
- Branch name
|
||||
- Note that the GitHub Action will run automatically and commit
|
||||
updated screenshots to the branch.
|
||||
|
||||
## Notes
|
||||
|
||||
- The `New Browser Test Expectations` label triggers the
|
||||
`pr-update-playwright-expectations.yaml` workflow.
|
||||
- The workflow runs Playwright with `--update-snapshots`, commits results
|
||||
back to the PR branch, then removes the label.
|
||||
- This is fire-and-forget — no need to wait for or monitor the Action.
|
||||
- Always return to the original branch/worktree state after pushing.
|
||||
@@ -1,156 +0,0 @@
|
||||
---
|
||||
name: reviewing-unit-tests
|
||||
description: Use when reviewing Vitest unit-test diffs in ComfyUI_frontend, especially new mocks, store tests, component tests, or bugfix regression tests.
|
||||
---
|
||||
|
||||
# Reviewing Unit Tests for ComfyUI_frontend
|
||||
|
||||
## Overview
|
||||
|
||||
Review for behavior and current repo rules, not motion. Compare to authoritative rules, not prior diffs or legacy snippets.
|
||||
|
||||
## Review Workflow
|
||||
|
||||
1. Identify the test type: component, store, composable, util, or bugfix regression.
|
||||
2. Name the behavior the test proves. If you cannot say it in one sentence, request changes.
|
||||
3. Open the authoritative doc section before judging structure.
|
||||
4. Scan the red flags below.
|
||||
5. State the verdict first. Name the failure mode. Cite the doc or rule.
|
||||
|
||||
## Source of Truth / Precedence
|
||||
|
||||
When docs and examples conflict, use this order:
|
||||
|
||||
1. Explicit repo rules, lint rules, and note blocks.
|
||||
2. [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md)
|
||||
3. Rule sections in [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md), [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md), and [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md)
|
||||
4. Example snippets
|
||||
5. Prior diffs
|
||||
|
||||
Apply these repo-specific clarifications:
|
||||
|
||||
- [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) starts with the authoritative rule: new component tests use `@testing-library/vue` with `@testing-library/user-event`. The `@vue/test-utils` snippets below it are legacy examples.
|
||||
- [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) still contains `as any` examples. Treat them as legacy snippets, not approval for new or edited test code.
|
||||
- If docs conflict, prefer the stricter newer rule and call out the doc ambiguity. Do not approve through it.
|
||||
- Motion != fix.
|
||||
|
||||
## 30-Second Red Flags
|
||||
|
||||
| If you see... | Failure mode | Default action |
|
||||
| ----------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------- |
|
||||
| New `@vue/test-utils` import in a new component test | legacy test API | Request changes |
|
||||
| `vi.mock('vue-i18n', ...)` | mocked i18n | Request changes |
|
||||
| `as any`, `@ts-expect-error`, `as Mock`, `as ReturnType<typeof vi.fn>`, `as unknown as X` | unnecessary cast or type escape | Request changes unless the author proves no safer type exists |
|
||||
| `getXMock()`, renamed wrapper, or helper that only returns a mocked value | alias-by-renaming | Request changes |
|
||||
| `beforeEach` recreates the return object for a module-mocked composable or service | shared mock setup drift | Request changes |
|
||||
| Assertions only check defaults, mock plumbing, or CSS hooks | non-behavioral test | Request changes |
|
||||
| Bugfix test has no proof it fails on pre-fix code | unproven regression | Request changes |
|
||||
|
||||
## Rationalization Table
|
||||
|
||||
| Excuse | Reality |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| "I restructured the mocks" | If the indirection stayed, nothing improved. Flag `alias-by-renaming`. |
|
||||
| "The docs do it" | Rule, note, and lint beat legacy snippet. Compare to the current rule, not the nearest example. |
|
||||
| "TypeScript required the cast" | `vi.mocked()` usually narrows mock methods. Assertion-only references need no cast. |
|
||||
| "Putting it in `beforeEach` is DRY" | Recreating module mock state in hooks hides singleton behavior and drifts from the documented pattern. |
|
||||
| "It is only a nit" | Explicit repo-rule violations are never nits. |
|
||||
| "No behavior changed, just cleanup" | Motion != fix. Ask what behavior got stronger. |
|
||||
| "Mental revert is enough" | For bugfix tests, establish red on pre-fix code or ask the author to show it. |
|
||||
|
||||
## Mocking Rules
|
||||
|
||||
- Fail helpers that do not remove repeated setup, encode domain meaning, or simplify assertions. Barely earning the abstraction is not enough.
|
||||
- For composables with reactive or singleton state, define stable mock state inside the `vi.mock()` factory. Access it per test via the composable itself. See [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) "Mocking Composables with Reactive State".
|
||||
- This does not ban local test data builders or per-test `vi.spyOn(...)`.
|
||||
- Mock seams, not the project-owned module you are trying to exercise. For store tests, prefer real Pinia plus `createTestingPinia({ stubActions: false })` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md).
|
||||
|
||||
### Alias-by-Renaming
|
||||
|
||||
```ts
|
||||
// Before
|
||||
const mockAdd = vi.hoisted(() => vi.fn())
|
||||
|
||||
// After: same indirection, new name
|
||||
function getToastAddMock() {
|
||||
return useToast().add
|
||||
}
|
||||
```
|
||||
|
||||
If the wrapper only renames or relays a mocked value, fail it. Inline the lookup at the call site or fetch the singleton mock via the documented pattern.
|
||||
|
||||
### `vi.mocked()` Scope
|
||||
|
||||
| Use case | `vi.mocked()` required? |
|
||||
| --------------------------------------------------------------- | ----------------------- |
|
||||
| `.mockReturnValue`, `.mockResolvedValue`, `.mockImplementation` | Yes |
|
||||
| `.mock.calls`, `.mock.results` | Yes |
|
||||
| `expect(fn).toHaveBeenCalled()` | No |
|
||||
| `expect(fn).toHaveBeenCalledWith(...)` | No |
|
||||
|
||||
- Flag casts whenever `vi.mocked()` would narrow correctly.
|
||||
- Do not add `vi.mocked()` around assertion-only references just for style.
|
||||
|
||||
### Reset Hygiene
|
||||
|
||||
- Flag per-mock `mockClear()` or `mockReset()` when `vi.clearAllMocks()` or `vi.resetAllMocks()` already runs in the relevant hook chain.
|
||||
- Review for redundancy or broken state management. Do not bikeshed `clearAllMocks` vs `resetAllMocks` unless behavior depends on it.
|
||||
|
||||
### Third-Party Seams
|
||||
|
||||
- Distinguish trivial hooks from behavior-rich APIs.
|
||||
- Mocking single-method third-party hooks like `primevue/usetoast` is usually acceptable.
|
||||
- That exception does not justify mocking behavior-rich third-party modules.
|
||||
|
||||
### `vue-i18n`
|
||||
|
||||
- Never mock `vue-i18n` in component tests.
|
||||
- Use real `createI18n` per [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) and the shared [`testI18n`](../../../src/components/searchbox/v2/__test__/testUtils.ts) setup.
|
||||
|
||||
## Test-Body Rules
|
||||
|
||||
| Smell | Review bar |
|
||||
| ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Change-detector test | Reject. Default values alone prove nothing. |
|
||||
| Mock-only assertion | Accept collaborator-call assertions only when the call is the meaningful external effect and the test also exercises the triggering behavior. |
|
||||
| Non-behavioral assertion | Reject tests that only check classes, utility hooks, or styling internals. |
|
||||
| New component test using `@vue/test-utils` | Request changes. Use `@testing-library/vue` plus `@testing-library/user-event`. |
|
||||
| `any`, `as any`, or `@ts-expect-error` in new or edited test code | Request changes unless the author proves no safer type exists. Legacy doc snippets do not authorize it. |
|
||||
|
||||
## Bugfix Regression Proof
|
||||
|
||||
For `fix:` PRs or bugfix diffs:
|
||||
|
||||
1. Identify the production change that fixes the bug.
|
||||
2. Verify the new test fails on pre-fix code, or ask the author to show it.
|
||||
3. If the test passes on broken code, request changes.
|
||||
|
||||
A regression test that never proves red does not pin the bug.
|
||||
|
||||
## Review Output Rules
|
||||
|
||||
- State verdict before procedural questions.
|
||||
- Do not lead with approval language like `LGTM, just one nit` or `approve and move on?`.
|
||||
- Name the failure mode directly: `alias-by-renaming`, `unnecessary cast`, `mocked i18n`, `mock-only assertion`, `unproven regression`.
|
||||
- Link the authoritative doc section in the review comment.
|
||||
- If an explicit repo rule, lint rule, or authoritative doc note is violated, do not downgrade it to "minor deviation" or "nit".
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| When you see... | Read this |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| New `vi.mock(...)` for a composable | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) -> "Mocking Composables with Reactive State" |
|
||||
| New store test or store mock | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) setup + [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
|
||||
| New component test | Top note in [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
|
||||
| `vue-i18n` in a component test | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) + [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |
|
||||
| Cast around a mock | [`docs/guidance/typescript.md`](../../../docs/guidance/typescript.md) -> "Type Assertion Hierarchy" |
|
||||
|
||||
## Key Files to Read
|
||||
|
||||
| Purpose | Path |
|
||||
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| Composable mocking patterns | [`docs/testing/unit-testing.md`](../../../docs/testing/unit-testing.md) |
|
||||
| Store testing patterns | [`docs/testing/store-testing.md`](../../../docs/testing/store-testing.md) |
|
||||
| Repo-wide Vitest setup defaults | [`docs/testing/vitest-patterns.md`](../../../docs/testing/vitest-patterns.md) |
|
||||
| Component testing rule for new tests | [`docs/testing/component-testing.md`](../../../docs/testing/component-testing.md) |
|
||||
| Real i18n setup | [`src/components/searchbox/v2/__test__/testUtils.ts`](../../../src/components/searchbox/v2/__test__/testUtils.ts) |
|
||||
@@ -1,203 +0,0 @@
|
||||
---
|
||||
name: writing-playwright-tests
|
||||
description: 'Writes Playwright e2e tests for ComfyUI_frontend. Use when creating, modifying, or debugging browser tests. Triggers on: playwright, e2e test, browser test, spec file.'
|
||||
---
|
||||
|
||||
# Writing Playwright Tests for ComfyUI_frontend
|
||||
|
||||
## Golden Rules
|
||||
|
||||
1. **ALWAYS look at existing tests first.** Search `browser_tests/tests/` for similar patterns before writing new tests.
|
||||
|
||||
2. **ALWAYS read the fixture code.** The APIs are in `browser_tests/fixtures/` - read them directly instead of guessing.
|
||||
|
||||
3. **Use premade JSON workflow assets** instead of building workflows programmatically.
|
||||
- Assets live in `browser_tests/assets/`
|
||||
- Load with `await comfyPage.workflow.loadWorkflow('feature/my_workflow')`
|
||||
- Create new assets by starting with `browser_tests/assets/default.json` and manually editing the JSON to match your desired graph state
|
||||
|
||||
## Vue Nodes vs LiteGraph: Decision Guide
|
||||
|
||||
Choose based on **what you're testing**, not personal preference:
|
||||
|
||||
| Testing... | Use | Why |
|
||||
| ---------------------------------------------- | -------------------------------- | ---------------------------------------- |
|
||||
| Vue-rendered node UI, DOM widgets, CSS states | `comfyPage.vueNodes.*` | Nodes are DOM elements, use locators |
|
||||
| Canvas interactions, connections, legacy nodes | `comfyPage.nodeOps.*` | Canvas-based, use coordinates/references |
|
||||
| Both in same test | Pick primary, minimize switching | Avoid confusion |
|
||||
|
||||
**Vue Nodes requires explicit opt-in:**
|
||||
|
||||
```typescript
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
```
|
||||
|
||||
**Vue Node state uses CSS classes:**
|
||||
|
||||
```typescript
|
||||
const BYPASS_CLASS = /before:bg-bypass\/60/
|
||||
await expect(node).toHaveClass(BYPASS_CLASS)
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
These are frequent causes of flaky tests - check them first, but investigate if they don't apply:
|
||||
|
||||
| Symptom | Common Cause | Typical Fix |
|
||||
| ----------------------------------- | ------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Test passes locally, fails in CI | Missing nextFrame() | Add `await comfyPage.nextFrame()` after canvas ops (not needed after `loadWorkflow()`) |
|
||||
| Keyboard shortcuts don't work | Missing focus | Add `await comfyPage.canvas.click()` first |
|
||||
| Double-click doesn't trigger | Timing too fast | Add `{ delay: 5 }` option |
|
||||
| Elements end up in wrong position | Drag animation incomplete | Use `{ steps: 10 }` not `{ steps: 1 }` |
|
||||
| Widget value wrong after drag-drop | Upload incomplete | Add `{ waitForUpload: true }` |
|
||||
| Test fails when run with others | Test pollution | Add `afterEach` with `resetView()` |
|
||||
| Local screenshots don't match CI | Platform differences | Screenshots are Linux-only, use PR label |
|
||||
| `subtree intercepts pointer events` | Canvas overlay (z-999) | Use `dispatchEvent` on the DOM element to bypass overlay |
|
||||
| Context menu empty / wrong items | Node not selected | Select node first: `vueNodes.selectNode()` or `nodeRef.click('title')` |
|
||||
| `navigateIntoSubgraph` timeout | Node too small in asset | Use node size `[400, 200]` minimum in test asset JSON |
|
||||
|
||||
## Test Tags
|
||||
|
||||
Add appropriate tags to every test:
|
||||
|
||||
| Tag | When to Use |
|
||||
| ------------- | ----------------------------------------- |
|
||||
| `@smoke` | Quick essential tests |
|
||||
| `@slow` | Tests > 10 seconds |
|
||||
| `@screenshot` | Visual regression tests |
|
||||
| `@canvas` | Canvas interactions |
|
||||
| `@node` | Node-related |
|
||||
| `@widget` | Widget-related |
|
||||
| `@mobile` | Mobile viewport (runs on Pixel 5 project) |
|
||||
| `@2x` | HiDPI tests (runs on 2x scale project) |
|
||||
|
||||
```typescript
|
||||
test.describe('Feature', { tag: ['@screenshot', '@canvas'] }, () => {
|
||||
```
|
||||
|
||||
## Retry Patterns
|
||||
|
||||
**Never use `waitForTimeout`** - it's always wrong.
|
||||
|
||||
| Pattern | Use Case |
|
||||
| ------------------------ | ---------------------------------------------------- |
|
||||
| Auto-retrying assertions | `toBeVisible()`, `toHaveText()`, etc. (prefer these) |
|
||||
| `expect.poll()` | Single value polling |
|
||||
| `expect().toPass()` | Multiple assertions that must all pass |
|
||||
|
||||
```typescript
|
||||
// Prefer auto-retrying assertions when possible
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
// Single value polling
|
||||
await expect.poll(() => widget.getValue(), { timeout: 2000 }).toBe(100)
|
||||
|
||||
// Multiple conditions
|
||||
await expect(async () => {
|
||||
expect(await node1.getValue()).toBe('foo')
|
||||
expect(await node2.getValue()).toBe('bar')
|
||||
}).toPass({ timeout: 2000 })
|
||||
```
|
||||
|
||||
## Screenshot Baselines
|
||||
|
||||
- **Screenshots are Linux-only.** Don't commit local screenshots.
|
||||
- **To update baselines:** Add PR label `New Browser Test Expectations`
|
||||
- **Mask dynamic content:**
|
||||
```typescript
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('page.png', {
|
||||
mask: [page.locator('.timestamp')]
|
||||
})
|
||||
```
|
||||
|
||||
## CI Debugging
|
||||
|
||||
1. Download artifacts from failed CI run
|
||||
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`
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
Avoid these common mistakes:
|
||||
|
||||
1. **Arbitrary waits** - Use retrying assertions instead
|
||||
|
||||
```typescript
|
||||
// ❌ await page.waitForTimeout(500)
|
||||
// ✅ await expect(element).toBeVisible()
|
||||
```
|
||||
|
||||
2. **Implementation-tied selectors** - Use test IDs or semantic selectors
|
||||
|
||||
```typescript
|
||||
// ❌ page.locator('div.container > button.btn-primary')
|
||||
// ✅ page.getByTestId('submit-button')
|
||||
```
|
||||
|
||||
3. **Missing nextFrame after canvas ops** - Canvas needs sync time
|
||||
|
||||
```typescript
|
||||
await node.drag({ x: 50, y: 50 })
|
||||
await comfyPage.nextFrame() // Required
|
||||
```
|
||||
|
||||
4. **Shared state between tests** - Tests must be independent
|
||||
```typescript
|
||||
// ❌ let sharedData // Outside test
|
||||
// ✅ Define state inside each test
|
||||
```
|
||||
|
||||
## Quick Start Template
|
||||
|
||||
```typescript
|
||||
// Path depends on test file location - adjust '../' segments accordingly
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('FeatureName', { tag: ['@canvas'] }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test('should do something', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('myWorkflow')
|
||||
|
||||
const node = (await comfyPage.nodeOps.getNodeRefsByTitle('KSampler'))[0]
|
||||
// ... test logic
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('expected.png')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Finding Patterns
|
||||
|
||||
```bash
|
||||
# Find similar tests
|
||||
grep -r "KSampler" browser_tests/tests/
|
||||
|
||||
# Find usage of a fixture method
|
||||
grep -r "loadWorkflow" browser_tests/tests/
|
||||
|
||||
# Find tests with specific tag
|
||||
grep -r '@screenshot' browser_tests/tests/
|
||||
```
|
||||
|
||||
## Key Files to Read
|
||||
|
||||
| Purpose | Path |
|
||||
| ----------------- | ------------------------------------------ |
|
||||
| Main fixture | `browser_tests/fixtures/ComfyPage.ts` |
|
||||
| Helper classes | `browser_tests/fixtures/helpers/` |
|
||||
| Component objects | `browser_tests/fixtures/components/` |
|
||||
| Test selectors | `browser_tests/fixtures/selectors.ts` |
|
||||
| Vue Node helpers | `browser_tests/fixtures/VueNodeHelpers.ts` |
|
||||
| Test assets | `browser_tests/assets/` |
|
||||
| Existing tests | `browser_tests/tests/` |
|
||||
|
||||
**Read the fixture code directly** - it's the source of truth for available methods.
|
||||
@@ -1,143 +0,0 @@
|
||||
---
|
||||
name: writing-storybook-stories
|
||||
description: 'Write or update Storybook stories for Vue components in ComfyUI_frontend. Use when adding, modifying, reviewing, or debugging `.stories.ts` files, Storybook docs, component demos, or visual catalog entries in `src/` or `apps/desktop-ui/`.'
|
||||
---
|
||||
|
||||
# Write Storybook Stories for ComfyUI_frontend
|
||||
|
||||
## Workflow
|
||||
|
||||
1. !!!!IMPORTANT Confirm the worktree is on a `feat/*` or `fix/*` branch. Base PRs on the local `main`, not a fork branch.
|
||||
2. Read the component source first. Understand props, emits, slots, exposed methods, and any supporting types or composables.
|
||||
3. Read nearby stories before writing anything.
|
||||
- Search stories: `rg --files src apps | rg '\.stories\.ts$'`
|
||||
- Inspect title patterns: `rg -n "title:\\s*'" src apps --glob '*.stories.ts'`
|
||||
4. If a Figma link is provided, list the states you need to cover before writing stories.
|
||||
5. Co-locate the story file with the component: `ComponentName.stories.ts`.
|
||||
6. Add each variation on separate stories, except hover state. this should be automatically applied by the implementation and not require a separate story.
|
||||
7. Run Storybook and validation checks before handing off.
|
||||
|
||||
## Match Local Conventions
|
||||
|
||||
- Copy the closest neighboring story instead of forcing one universal template.
|
||||
- Most repo stories use `@storybook/vue3-vite`. Some stories under `apps/desktop-ui` still use `@storybook/vue3`; keep the local convention for that area.
|
||||
- Add `tags: ['autodocs']` unless the surrounding stories in that area intentionally omit it.
|
||||
- Use `ComponentPropsAndSlots<typeof Component>` when it helps with prop and slot typing.
|
||||
- Keep `render` functions stateful when needed. Use `ref()`, `computed()`, and `toRefs(args)` instead of mutating Storybook args directly.
|
||||
- Use `args.default` or other slot-shaped args when the component content is provided through slots.
|
||||
- Use `ComponentExposed` only when a component's exposed API breaks the normal Storybook typing.
|
||||
- Add decorators for realistic width or background context when the component needs it.
|
||||
|
||||
## Title Patterns
|
||||
|
||||
Do not invent titles from scratch when a close sibling story already exists. Match the nearest domain pattern.
|
||||
|
||||
| Component area | Typical title pattern |
|
||||
| ------------------------------------------------------- | ------------------------------------ |
|
||||
| `src/components/ui/button/Button.vue` | `Components/Button/Button` |
|
||||
| `src/components/ui/input/Input.vue` | `Components/Input` |
|
||||
| `src/components/ui/search-input/SearchInput.vue` | `Components/Input/SearchInput` |
|
||||
| `src/components/common/SearchBox.vue` | `Components/Input/SearchBox` |
|
||||
| `src/renderer/extensions/vueNodes/widgets/components/*` | `Widgets/<WidgetName>` |
|
||||
| `src/platform/assets/components/*` | `Platform/Assets/<ComponentName>` |
|
||||
| `apps/desktop-ui/src/components/*` | `Desktop/Components/<ComponentName>` |
|
||||
| `apps/desktop-ui/src/views/*` | `Desktop/Views/<ViewName>` |
|
||||
|
||||
If multiple patterns seem plausible, follow the closest sibling story in the same folder tree.
|
||||
|
||||
## Common Story Shapes
|
||||
|
||||
### Stateful input or `v-model`
|
||||
|
||||
```typescript
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { MyComponent },
|
||||
setup() {
|
||||
const { disabled, size } = toRefs(args)
|
||||
const value = ref('Hello world')
|
||||
return { value, disabled, size }
|
||||
},
|
||||
template:
|
||||
'<MyComponent v-model="value" :disabled="disabled" :size="size" />'
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Slot-driven content
|
||||
|
||||
```typescript
|
||||
const meta: Meta<ComponentPropsAndSlots<typeof Button>> = {
|
||||
argTypes: {
|
||||
default: { control: 'text' }
|
||||
},
|
||||
args: {
|
||||
default: 'Button'
|
||||
}
|
||||
}
|
||||
|
||||
export const SingleButton: Story = {
|
||||
render: (args) => ({
|
||||
components: { Button },
|
||||
setup() {
|
||||
return { args }
|
||||
},
|
||||
template: '<Button v-bind="args">{{ args.default }}</Button>'
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Variants or edge cases grid
|
||||
|
||||
```typescript
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
components: { MyComponent },
|
||||
template: `
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<MyComponent />
|
||||
<MyComponent disabled />
|
||||
<MyComponent loading />
|
||||
<MyComponent invalid />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Figma Mapping
|
||||
|
||||
- Extract the named states from the design first.
|
||||
- Prefer explicit prop-driven stories such as `Disabled`, `Loading`, `Invalid`, `WithPlaceholder`, `AllSizes`, or `EdgeCases`.
|
||||
- Add an aggregate story such as `AllVariants`, `AllSizes`, or `EdgeCases` when side-by-side comparison is useful.
|
||||
- Use pseudo-state parameters only if the addon is already configured in this repo.
|
||||
- If a Figma state cannot be represented exactly, capture the closest prop-driven version and explain the gap in the story docs.
|
||||
|
||||
## Component-Specific Notes
|
||||
|
||||
- Widget components often need a minimal `SimplifiedWidget` object. Build it in `setup()` and use `computed()` when `args` change `widget.options`.
|
||||
- Input and search components often need a width-constrained wrapper so they render at realistic sizes.
|
||||
- Asset and platform cards often need background decorators such as `bg-base-background` and fixed-width containers.
|
||||
- Desktop installer stories may need custom `backgrounds` parameters and may intentionally keep the older Storybook import style used by neighboring files.
|
||||
- Use semantic tokens such as `bg-base-background` and `bg-node-component-surface` instead of `dark:` variants or hardcoded theme assumptions.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Read the component source and any supporting types or composables
|
||||
- [ ] Match the nearest local title pattern and story style
|
||||
- [ ] Include a baseline story; name it `Default` only when that matches nearby conventions
|
||||
- [ ] Add focused stories for meaningful states
|
||||
- [ ] Add `tags: ['autodocs']`
|
||||
- [ ] Keep the story co-located with the component
|
||||
- [ ] Run `pnpm storybook`
|
||||
- [ ] Run `pnpm typecheck`
|
||||
- [ ] Run `pnpm lint`
|
||||
|
||||
## Avoid
|
||||
|
||||
- Do not guess props, emits, slots, or exposed methods.
|
||||
- Do not force one generic title convention across the repo.
|
||||
- Do not mutate Storybook args directly for `v-model` components.
|
||||
- Do not introduce `dark:` Tailwind variants in story wrappers.
|
||||
- Do not create barrel files.
|
||||
- Do not assume every story needs `layout: 'centered'` or a `Default` export; follow the nearest existing pattern.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: 'ComfyUI Storybook Stories'
|
||||
short_description: 'Write Vue Storybook stories for ComfyUI'
|
||||
default_prompt: 'Use $writing-storybook-stories to add or update a Storybook story for this ComfyUI_frontend component.'
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
description: Agent chat panel layout rule — always full viewport height, never nested under the header bar
|
||||
globs:
|
||||
- src/components/LiteGraphCanvasSplitterOverlay.vue
|
||||
- src/platform/agent/**
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Agent Panel Layout
|
||||
|
||||
The Comfy Agent chat panel must always span the **full viewport height** — from the very top of the screen to the bottom, alongside the header bar and canvas, not below them.
|
||||
|
||||
## Correct structure
|
||||
|
||||
`LiteGraphCanvasSplitterOverlay` uses a top-level **`flex-row`** so the agent panel is a sibling of the entire left column (tabs + canvas), not a child inside it:
|
||||
|
||||
```
|
||||
div.flex-row (viewport)
|
||||
├── div.flex-col.flex-1 ← left side: everything else
|
||||
│ ├── slot#workflow-tabs ← header bar
|
||||
│ └── div.flex-1 ← canvas + sidebar panels
|
||||
└── div.shrink-0 (agent panel) ← RIGHT: full viewport height
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never** place the agent panel inside the `div` that sits below `slot#workflow-tabs`. That causes the panel to start below the header bar.
|
||||
- The agent panel div must be a **direct child** of the outermost `div.flex-row` container in `LiteGraphCanvasSplitterOverlay.vue`.
|
||||
- The left side (`flex-1 flex-col`) wraps both `slot#workflow-tabs` AND the canvas/splitter row.
|
||||
- The agent panel has `h-full` and `shrink-0` so it fills the full height and does not flex-shrink.
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
description: Icon buttons must always have a tooltip
|
||||
globs: src/**/*.vue
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Icon Button Tooltip Requirement
|
||||
|
||||
Every icon-only button (`size="icon"` or any button containing only an icon with no visible label) **must** be wrapped in a `Tooltip` so users can discover what it does.
|
||||
|
||||
## Required Pattern
|
||||
|
||||
```vue
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button size="icon" border-interface-stroke" :aria-label="$t('...')">
|
||||
<i class="icon-[lucide--some-icon] size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ $t('...') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
## Imports
|
||||
|
||||
```ts
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
```
|
||||
|
||||
- Always use `side="top"` unless a different direction is needed for layout reasons.
|
||||
- The `aria-label` on the button and the tooltip text should be the same translated string.
|
||||
- Use `vue-i18n` (`$t(...)`) for the label — never hardcode strings.
|
||||
3
.github/workflows/ci-tests-unit.yaml
vendored
3
.github/workflows/ci-tests-unit.yaml
vendored
@@ -55,3 +55,6 @@ jobs:
|
||||
flags: unit
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Enforce critical coverage gate
|
||||
run: pnpm test:coverage:critical
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"packages/registry-types/src/comfyRegistryTypes.ts",
|
||||
"public/materialdesignicons.min.css",
|
||||
"src/types/generatedManagerTypes.ts",
|
||||
"**/__fixtures__/**/*.json"
|
||||
"**/__fixtures__/**/*.json",
|
||||
"apps/website/src/content/**/*.mdx"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from 'astro/config'
|
||||
import mdx from '@astrojs/mdx'
|
||||
import sitemap from '@astrojs/sitemap'
|
||||
import vue from '@astrojs/vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
@@ -24,6 +25,9 @@ export default defineConfig({
|
||||
site: 'https://comfy.org',
|
||||
output: 'static',
|
||||
prefetch: { prefetchAll: true },
|
||||
// Keep MDX punctuation verbatim; SmartyPants would turn the source's straight
|
||||
// quotes into curly ones and drift from the rest of the site's copy.
|
||||
markdown: { smartypants: false },
|
||||
redirects: {
|
||||
'/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping':
|
||||
'/customers/moment-factory/',
|
||||
@@ -37,6 +41,7 @@ export default defineConfig({
|
||||
devToolbar: { enabled: !process.env.NO_TOOLBAR },
|
||||
integrations: [
|
||||
vue(),
|
||||
mdx(),
|
||||
sitemap({
|
||||
filter: (page) => !isExcludedFromSitemap(page)
|
||||
})
|
||||
|
||||
73
apps/website/e2e/customers-detail.spec.ts
Normal file
73
apps/website/e2e/customers-detail.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Customer story detail @smoke', () => {
|
||||
test('renders the migrated article: hero, section nav, and body', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/series-entertainment')
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
level: 1,
|
||||
name: /Series Entertainment Rebuilt Game and Video Production/i
|
||||
})
|
||||
).toBeVisible()
|
||||
|
||||
const nav = page.getByRole('navigation', { name: 'Category filter' })
|
||||
await expect(nav.getByRole('button', { name: 'INTRO' })).toBeVisible()
|
||||
await expect(nav.getByRole('button', { name: 'CONCLUSION' })).toBeVisible()
|
||||
|
||||
// Section title rendered from the MDX <Section title> wrapper.
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: 'The Output Series Achieved Using ComfyUI'
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('section nav highlights the section the reader selects', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/series-entertainment')
|
||||
const nav = page.getByRole('navigation', { name: 'Category filter' })
|
||||
const intro = nav.getByRole('button', { name: 'INTRO' })
|
||||
const problem = nav.getByRole('button', { name: 'THE PROBLEM' })
|
||||
|
||||
await expect(intro).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
await problem.click()
|
||||
await expect(problem).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('shows the read-more link only when an external source exists', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/open-story-movement')
|
||||
await expect(
|
||||
page.getByRole('link', { name: /read more on this topic/i })
|
||||
).toBeVisible()
|
||||
|
||||
// series-entertainment only redirected back to itself, so the link is gone.
|
||||
await page.goto('/customers/series-entertainment')
|
||||
await expect(
|
||||
page.getByRole('link', { name: /read more on this topic/i })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('links to the next story in the what-is-next section', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('/customers/series-entertainment')
|
||||
const nextLink = page.getByRole('link', { name: /view article/i })
|
||||
await expect(nextLink).toBeVisible()
|
||||
// Links to another customer story, without coupling the test to the
|
||||
// specific slug or sort order.
|
||||
await expect(nextLink).toHaveAttribute('href', /^\/customers\/[a-z0-9-]+$/)
|
||||
await expect(nextLink).not.toHaveAttribute(
|
||||
'href',
|
||||
'/customers/series-entertainment'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -40,6 +40,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "catalog:",
|
||||
"@astrojs/mdx": "catalog:",
|
||||
"@astrojs/vue": "catalog:",
|
||||
"@playwright/test": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
@@ -48,6 +49,7 @@
|
||||
"tsx": "catalog:",
|
||||
"tw-animate-css": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
"vitest": "catalog:",
|
||||
"vue-component-type-helpers": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
100
apps/website/src/components/customers/ArticleNav.vue
Normal file
100
apps/website/src/components/customers/ArticleNav.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, useIntersectionObserver } from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
import { scrollTo } from '../../scripts/smoothScroll'
|
||||
import CategoryNav from '../common/CategoryNav.vue'
|
||||
|
||||
type Category = ComponentProps<typeof CategoryNav>['categories'][number]
|
||||
|
||||
const { categories } = defineProps<{
|
||||
categories: Category[]
|
||||
}>()
|
||||
|
||||
const activeSection = ref(categories[0]?.value ?? '')
|
||||
|
||||
const HEADER_OFFSET_PX = -144
|
||||
const BOTTOM_THRESHOLD_PX = 4
|
||||
const SCROLL_SAFETY_MS = 1500
|
||||
|
||||
let isScrolling = false
|
||||
let scrollSafetyTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function clearScrollLock() {
|
||||
isScrolling = false
|
||||
if (scrollSafetyTimer !== undefined) {
|
||||
clearTimeout(scrollSafetyTimer)
|
||||
scrollSafetyTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
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 = categories[categories.length - 1]?.value
|
||||
if (lastId) activeSection.value = lastId
|
||||
}
|
||||
|
||||
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_PX,
|
||||
duration: 0.8,
|
||||
immediate: prefersReducedMotion(),
|
||||
onComplete: clearScrollLock
|
||||
})
|
||||
return
|
||||
}
|
||||
clearScrollLock()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// The section anchors live in the statically rendered article body, so the
|
||||
// observer targets are resolved from the DOM by id rather than template refs.
|
||||
const elements = categories
|
||||
.map((category) => document.getElementById(category.value))
|
||||
.filter((el): el is HTMLElement => el !== null)
|
||||
|
||||
useIntersectionObserver(
|
||||
elements,
|
||||
(entries) => {
|
||||
if (isScrolling) return
|
||||
if (isAtBottom()) return
|
||||
let best: IntersectionObserverEntry | null = null
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top)
|
||||
best = entry
|
||||
}
|
||||
if (best) activeSection.value = best.target.id
|
||||
},
|
||||
{ rootMargin: '-20% 0px -60% 0px' }
|
||||
)
|
||||
|
||||
activateLastIfAtBottom()
|
||||
})
|
||||
|
||||
useEventListener('scroll', activateLastIfAtBottom, { passive: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CategoryNav
|
||||
:categories
|
||||
:model-value="activeSection"
|
||||
@update:model-value="scrollToSection"
|
||||
/>
|
||||
</template>
|
||||
68
apps/website/src/components/customers/CustomerArticle.astro
Normal file
68
apps/website/src/components/customers/CustomerArticle.astro
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
import { render } from 'astro:content'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { CustomerStoryEntry } from '../../utils/customers'
|
||||
import ArticleNav from './ArticleNav.vue'
|
||||
import BulletList from './content/BulletList.astro'
|
||||
import Contributors from './content/Contributors.astro'
|
||||
import Figure from './content/Figure.astro'
|
||||
import Heading from './content/Heading.astro'
|
||||
import ListItem from './content/ListItem.astro'
|
||||
import Paragraph from './content/Paragraph.astro'
|
||||
import Quote from './content/Quote.astro'
|
||||
import ReadMore from './content/ReadMore.vue'
|
||||
import Section from './content/Section.astro'
|
||||
import Steps from './content/Steps.astro'
|
||||
|
||||
interface Props {
|
||||
entry: CustomerStoryEntry
|
||||
locale?: Locale
|
||||
}
|
||||
|
||||
const { entry, locale = 'en' } = Astro.props
|
||||
const { Content } = await render(entry)
|
||||
|
||||
// The sidebar nav mirrors the section outline declared in frontmatter so it is
|
||||
// server-rendered, exactly like the previous ContentSection sidebar.
|
||||
const categories = entry.data.sections.map((section) => ({
|
||||
label: section.label,
|
||||
value: section.id
|
||||
}))
|
||||
|
||||
// Markdown elements are mapped to the ported block styles; the named
|
||||
// components (Section, Figure, ...) are used directly inside the MDX body.
|
||||
const contentComponents = {
|
||||
p: Paragraph,
|
||||
h3: Heading,
|
||||
ul: BulletList,
|
||||
li: ListItem,
|
||||
Section,
|
||||
Figure,
|
||||
Quote,
|
||||
Contributors,
|
||||
Steps
|
||||
}
|
||||
---
|
||||
|
||||
<section class="px-4 pt-8 pb-24 lg:px-20 lg:pt-24 lg:pb-40">
|
||||
<div class="lg:flex lg:gap-16">
|
||||
<aside class="hidden scrollbar-none lg:block lg:w-48 lg:shrink-0">
|
||||
<div class="sticky top-32">
|
||||
<ArticleNav
|
||||
categories={categories}
|
||||
client:media="(min-width: 1024px)"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="flex-1">
|
||||
<Content components={contentComponents} />
|
||||
{
|
||||
entry.data.readMore && (
|
||||
<ReadMore href={entry.data.readMore} locale={locale} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { customerStories } from '../../config/customerStories'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { StoryCard } from '../../utils/customers'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
const { stories, locale = 'en' } = defineProps<{
|
||||
stories: StoryCard[]
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
</script>
|
||||
@@ -13,7 +16,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
class="max-w-9xl mx-auto grid grid-cols-1 gap-6 px-6 py-16 lg:grid-cols-2 lg:px-16 lg:py-24"
|
||||
>
|
||||
<a
|
||||
v-for="story in customerStories"
|
||||
v-for="story in stories"
|
||||
:key="story.slug"
|
||||
:href="`${prefix}/customers/${story.slug}`"
|
||||
class="bg-transparency-white-t4 group flex flex-col overflow-hidden rounded-3xl transition-colors hover:bg-white/8"
|
||||
@@ -22,7 +25,7 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
<div class="m-2 aspect-video overflow-hidden rounded-2xl">
|
||||
<div
|
||||
class="size-full rounded-2xl bg-white/5 bg-cover bg-center"
|
||||
:style="{ backgroundImage: `url(${story.image})` }"
|
||||
:style="{ backgroundImage: `url(${story.cover})` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -32,12 +35,12 @@ const prefix = locale === 'zh-CN' ? '/zh-CN' : ''
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-[10px] font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(story.category, locale) }}
|
||||
{{ story.category }}
|
||||
</span>
|
||||
<h3
|
||||
class="mt-2 text-lg/snug font-light text-primary-comfy-canvas lg:text-xl/snug"
|
||||
>
|
||||
{{ t(story.title, locale) }}
|
||||
{{ story.title }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<ul class="mt-4 space-y-1 pl-5 text-sm"><slot /></ul>
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
interface Person {
|
||||
name: string
|
||||
role: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
people: Person[]
|
||||
}
|
||||
|
||||
const { label, people } = Astro.props
|
||||
---
|
||||
|
||||
<div class="mt-8 rounded-2xl bg-(--site-bg-soft) p-6">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{
|
||||
people.map((person, i) => (
|
||||
<>
|
||||
<p
|
||||
class={cn(
|
||||
'text-sm font-semibold text-primary-comfy-canvas',
|
||||
i === 0 ? 'mt-2' : 'mt-4'
|
||||
)}
|
||||
>
|
||||
{person.name}
|
||||
</p>
|
||||
<p class="text-xs text-primary-comfy-canvas">{person.role}</p>
|
||||
</>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
20
apps/website/src/components/customers/content/Figure.astro
Normal file
20
apps/website/src/components/customers/content/Figure.astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
interface Props {
|
||||
src: string
|
||||
alt: string
|
||||
caption?: string
|
||||
}
|
||||
|
||||
const { src, alt, caption } = Astro.props
|
||||
---
|
||||
|
||||
<figure class="my-8">
|
||||
<img src={src} alt={alt} class="w-full rounded-2xl object-cover" />
|
||||
{
|
||||
caption && (
|
||||
<figcaption class="mt-3 text-xs text-primary-comfy-canvas">
|
||||
{caption}
|
||||
</figcaption>
|
||||
)
|
||||
}
|
||||
</figure>
|
||||
@@ -0,0 +1,3 @@
|
||||
<h3 class="text-primary-comfy-yellow mt-6 mb-2 text-lg font-semibold italic">
|
||||
<slot />
|
||||
</h3>
|
||||
@@ -0,0 +1,5 @@
|
||||
<li
|
||||
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow"
|
||||
>
|
||||
<slot />
|
||||
</li>
|
||||
@@ -0,0 +1 @@
|
||||
<p class="mt-4 text-sm/relaxed text-primary-comfy-canvas"><slot /></p>
|
||||
16
apps/website/src/components/customers/content/Quote.astro
Normal file
16
apps/website/src/components/customers/content/Quote.astro
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
interface Props {
|
||||
name: string
|
||||
}
|
||||
|
||||
const { name } = Astro.props
|
||||
---
|
||||
|
||||
<blockquote
|
||||
class="border-primary-comfy-yellow my-8 rounded-2xl border-l-4 bg-(--site-bg-soft) p-8"
|
||||
>
|
||||
<p class="text-lg/relaxed font-light text-primary-comfy-canvas italic">
|
||||
"<slot />"
|
||||
</p>
|
||||
<p class="text-primary-comfy-yellow mt-4 text-sm font-semibold">{name}</p>
|
||||
</blockquote>
|
||||
21
apps/website/src/components/customers/content/ReadMore.vue
Normal file
21
apps/website/src/components/customers/content/ReadMore.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../../i18n/translations'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import Button from '../../ui/button/Button.vue'
|
||||
|
||||
const { href, locale = 'en' } = defineProps<{
|
||||
href: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-8 flex justify-center">
|
||||
<Button as="a" :href variant="default" size="lg">
|
||||
{{ t('customers.story.readMore', locale) }}
|
||||
<template #append>
|
||||
<span class="text-base" aria-hidden="true">↗</span>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
17
apps/website/src/components/customers/content/Section.astro
Normal file
17
apps/website/src/components/customers/content/Section.astro
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
interface Props {
|
||||
id: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
const { id, title } = Astro.props
|
||||
---
|
||||
|
||||
<div id={id} class="mb-16 scroll-mt-24 lg:scroll-mt-36">
|
||||
{
|
||||
title && (
|
||||
<h2 class="mb-6 text-2xl font-light text-primary-comfy-canvas">{title}</h2>
|
||||
)
|
||||
}
|
||||
<slot />
|
||||
</div>
|
||||
17
apps/website/src/components/customers/content/Steps.astro
Normal file
17
apps/website/src/components/customers/content/Steps.astro
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
interface Props {
|
||||
items: string[]
|
||||
}
|
||||
|
||||
const { items } = Astro.props
|
||||
---
|
||||
|
||||
<ol class="mt-4 space-y-1 pl-1 text-sm [counter-reset:step]">
|
||||
{
|
||||
items.map((item) => (
|
||||
<li class="flex items-start gap-3 text-primary-comfy-canvas [counter-increment:step] before:shrink-0 before:font-semibold before:tabular-nums before:text-primary-comfy-yellow before:content-[counter(step,_decimal-leading-zero)]">
|
||||
{item}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { TranslationKey } from '../i18n/translations'
|
||||
|
||||
interface CustomerStory {
|
||||
slug: string
|
||||
image: string
|
||||
category: TranslationKey
|
||||
title: TranslationKey
|
||||
body: TranslationKey
|
||||
detailPrefix: string
|
||||
readMoreHref?: string
|
||||
}
|
||||
|
||||
export const customerStories: CustomerStory[] = [
|
||||
{
|
||||
slug: 'series-entertainment',
|
||||
image:
|
||||
'https://media.comfy.org/website/customers/series-entertainment/cover.webp',
|
||||
category: 'customers.story.series-entertainment.category',
|
||||
title: 'customers.story.series-entertainment.title',
|
||||
body: 'customers.story.series-entertainment.body',
|
||||
detailPrefix: 'customers.detail.series-entertainment',
|
||||
readMoreHref:
|
||||
'https://comfy.org/cloud/enterprise-case-studies/how-series-entertainment-rebuilt-game-and-video-production-with-comfyui'
|
||||
},
|
||||
{
|
||||
slug: 'open-story-movement',
|
||||
image:
|
||||
'https://media.comfy.org/website/customers/open-story-movement/cover.webp',
|
||||
category: 'customers.story.open-story-movement.category',
|
||||
title: 'customers.story.open-story-movement.title',
|
||||
body: 'customers.story.open-story-movement.body',
|
||||
detailPrefix: 'customers.detail.open-story-movement',
|
||||
readMoreHref: 'https://blog.comfy.org/p/how-open-source-is-fueling-the-open'
|
||||
},
|
||||
{
|
||||
slug: 'moment-factory',
|
||||
image:
|
||||
'https://media.comfy.org/website/customers/moment-factory/cover.webp',
|
||||
category: 'customers.story.moment-factory.category',
|
||||
title: 'customers.story.moment-factory.title',
|
||||
body: 'customers.story.moment-factory.body',
|
||||
detailPrefix: 'customers.detail.moment-factory',
|
||||
readMoreHref:
|
||||
'https://comfy.org/cloud/enterprise-case-studies/comfyui-at-architectural-scale-how-moment-factory-reimagined-3d-projection-mapping'
|
||||
},
|
||||
{
|
||||
slug: 'ubisoft-chord',
|
||||
image: 'https://media.comfy.org/website/customers/ubisoft/cover.webp',
|
||||
category: 'customers.story.ubisoft-chord.category',
|
||||
title: 'customers.story.ubisoft-chord.title',
|
||||
body: 'customers.story.ubisoft-chord.body',
|
||||
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'
|
||||
}
|
||||
]
|
||||
|
||||
export function getStoryBySlug(slug: string): CustomerStory | undefined {
|
||||
return customerStories.find((s) => s.slug === slug)
|
||||
}
|
||||
|
||||
export function getNextStory(slug: string): CustomerStory {
|
||||
const index = customerStories.findIndex((s) => s.slug === slug)
|
||||
return customerStories[(index + 1) % customerStories.length]
|
||||
}
|
||||
17
apps/website/src/content.config.ts
Normal file
17
apps/website/src/content.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineCollection } from 'astro:content'
|
||||
import { glob } from 'astro/loaders'
|
||||
|
||||
import { customerStorySchema } from './content/customers.schema'
|
||||
|
||||
const customers = defineCollection({
|
||||
// Preserve the exact path as the id (default slugification lowercases the
|
||||
// `zh-CN` locale folder, which would break locale filtering).
|
||||
loader: glob({
|
||||
base: './src/content/customers',
|
||||
pattern: '**/*.mdx',
|
||||
generateId: ({ entry }) => entry.replace(/\.mdx$/, '')
|
||||
}),
|
||||
schema: customerStorySchema
|
||||
})
|
||||
|
||||
export const collections = { customers }
|
||||
64
apps/website/src/content/README.md
Normal file
64
apps/website/src/content/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Website content collections
|
||||
|
||||
How we keep editable marketing content in code, using Astro Content Collections.
|
||||
Customer stories (`/customers`) are the first content type moved over, and this is
|
||||
the pattern to follow for the rest of the marketing content.
|
||||
|
||||
## Which kind of collection to use
|
||||
|
||||
- **Article / prose content** (case studies, blog-style pages): use an **MDX**
|
||||
collection. One MDX file per entry, frontmatter for the metadata, prose body with
|
||||
a few small components for images, quotes, etc.
|
||||
- **Structured / list content** (pricing tiers, feature grids, model lists): use a
|
||||
**data** collection (`file()` loader + JSON/YAML + a zod schema). Do not force this
|
||||
kind of content into MDX.
|
||||
|
||||
## How customer stories are set up (the article pattern)
|
||||
|
||||
- The collection is defined in `src/content.config.ts` (a `glob` loader over
|
||||
`src/content/customers`).
|
||||
- One folder per locale: `src/content/customers/en` and `.../zh-CN`. The same
|
||||
filename is the same story in both languages. A custom `generateId` keeps the exact
|
||||
path as the id, so the `zh-CN` folder is not lower-cased (that silently breaks
|
||||
locale filtering otherwise).
|
||||
- The schema lives in `src/content/customers.schema.ts` (title, category,
|
||||
description, cover, order, section list, optional read-more link).
|
||||
- The body components are in `components/customers/content` (`Section`, `Figure`,
|
||||
`Quote`, `Contributors`, `Steps`, plus styled paragraph/heading/list). These are
|
||||
generic article blocks. When a second article type is added, move them to a shared
|
||||
folder so both can use them.
|
||||
- The detail page renders the body with `<Content components={...} />` and a small
|
||||
scroll-spy sidebar island (`ArticleNav.vue`). The article body itself is static
|
||||
HTML; only the sidebar ships JavaScript.
|
||||
|
||||
## Adding a new article type (quick version)
|
||||
|
||||
1. Add a collection to `src/content.config.ts` with a `glob` loader and a zod schema.
|
||||
2. Put the content under `src/content/<type>/<locale>/<slug>.mdx`.
|
||||
3. Build the listing and detail pages that read it with `getCollection`.
|
||||
4. Reuse the block components above.
|
||||
|
||||
## Gotchas worth knowing
|
||||
|
||||
- `src/env.d.ts` must reference `../.astro/types.d.ts`, otherwise `getCollection` is
|
||||
untyped and entry data comes back empty.
|
||||
- `astro.config.ts` sets `markdown.smartypants: false` so punctuation stays exactly
|
||||
as written (otherwise straight quotes become curly and drift from the rest of the
|
||||
site). This option is deprecated in Astro 7 and moves onto the markdown processor;
|
||||
handle that as part of the Astro 7 upgrade.
|
||||
- ESLint: `apps/website` files ignore the `astro:` virtual modules in
|
||||
`import-x/no-unresolved` (they are real at build time but the resolver cannot see
|
||||
them).
|
||||
- `ui/button/Button.vue` cannot take an `href` inside a `.astro` file (its props do
|
||||
not declare it). Wrap it in a small `.vue` when you need a link button, see
|
||||
`components/customers/content/ReadMore.vue`.
|
||||
- Content MDX is excluded from `oxfmt` in `.oxfmtrc.json`. The formatter rewraps
|
||||
component slots and changes the rendered output (it broke blockquotes). Keep one
|
||||
logical block per line when editing.
|
||||
- `components/common/ContentSection.vue` and `config/contentSections.ts` still power
|
||||
the legal and privacy pages. Do not delete them.
|
||||
- The MDX `components` map styles the block elements (paragraphs, `###`, lists) and the
|
||||
named block components (`Figure`, `Quote`, etc.). Inline `a`/`strong`/`em` typed
|
||||
directly in prose render with browser defaults, so route prose through the block
|
||||
components; if styled inline links are ever needed, add them to the map with design
|
||||
sign-off.
|
||||
97
apps/website/src/content/customers.content.test.ts
Normal file
97
apps/website/src/content/customers.content.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const customersDir = join(dirname(fileURLToPath(import.meta.url)), 'customers')
|
||||
const locales = ['en', 'zh-CN'] as const
|
||||
|
||||
interface Story {
|
||||
file: string
|
||||
frontmatter: string
|
||||
body: string
|
||||
}
|
||||
|
||||
function loadStories(): Story[] {
|
||||
const stories: Story[] = []
|
||||
for (const locale of locales) {
|
||||
const dir = join(customersDir, locale)
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (!name.endsWith('.mdx')) continue
|
||||
const raw = readFileSync(join(dir, name), 'utf8')
|
||||
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
||||
if (!match) throw new Error(`No frontmatter block in ${locale}/${name}`)
|
||||
stories.push({
|
||||
file: `${locale}/${name}`,
|
||||
frontmatter: match[1],
|
||||
body: match[2]
|
||||
})
|
||||
}
|
||||
}
|
||||
return stories
|
||||
}
|
||||
|
||||
// The TOC sidebar is built from frontmatter `sections`, but the scroll-spy
|
||||
// anchors come from `<Section id="...">` in the body. Nothing binds the two but
|
||||
// matching strings, so this guards against silent drift (a renamed body id or a
|
||||
// missing frontmatter entry would leave the nav pointing at a dead anchor).
|
||||
function frontmatterSections(
|
||||
frontmatter: string
|
||||
): { id: string; label: string }[] {
|
||||
const sections: { id: string; label: string }[] = []
|
||||
const pattern = /-\s*id:\s*(\S+)\s*\n\s*label:\s*(.+)/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(frontmatter)) !== null) {
|
||||
sections.push({
|
||||
id: match[1].trim(),
|
||||
label: match[2].trim().replace(/^["']|["']$/g, '')
|
||||
})
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
function bodySectionIds(body: string): string[] {
|
||||
const ids: string[] = []
|
||||
const pattern = /<Section\b[^>]*\bid="([^"]*)"/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = pattern.exec(body)) !== null) {
|
||||
ids.push(match[1])
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
const stories = loadStories()
|
||||
|
||||
it('finds all ten customer stories', () => {
|
||||
expect(stories).toHaveLength(10)
|
||||
})
|
||||
|
||||
describe.for(stories)('$file', ({ frontmatter, body }) => {
|
||||
const sections = frontmatterSections(frontmatter)
|
||||
const bodyIds = bodySectionIds(body)
|
||||
|
||||
it('declares at least one section', () => {
|
||||
expect(sections.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('has a non-empty id and label for every section', () => {
|
||||
for (const section of sections) {
|
||||
expect(section.id).not.toBe('')
|
||||
expect(section.label).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('gives every body <Section> an id', () => {
|
||||
expect(bodyIds).not.toContain('')
|
||||
expect(bodyIds.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('matches frontmatter section ids to body <Section> ids', () => {
|
||||
const fromFrontmatter = [
|
||||
...new Set(sections.map((section) => section.id))
|
||||
].sort()
|
||||
const fromBody = [...new Set(bodyIds)].sort()
|
||||
expect(fromBody).toEqual(fromFrontmatter)
|
||||
})
|
||||
})
|
||||
15
apps/website/src/content/customers.schema.ts
Normal file
15
apps/website/src/content/customers.schema.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { z } from 'astro/zod'
|
||||
|
||||
// strictObject so a misspelled frontmatter key (e.g. readMoreHref) fails the
|
||||
// content build instead of being silently dropped.
|
||||
export const customerStorySchema = z.strictObject({
|
||||
title: z.string(),
|
||||
category: z.string(),
|
||||
description: z.string(),
|
||||
cover: z.url(),
|
||||
readMore: z.url().optional(),
|
||||
order: z.number().int().nonnegative(),
|
||||
sections: z.array(z.object({ id: z.string(), label: z.string() }))
|
||||
})
|
||||
|
||||
export type CustomerStoryFrontmatter = z.infer<typeof customerStorySchema>
|
||||
106
apps/website/src/content/customers/en/groove-jones.mdx
Normal file
106
apps/website/src/content/customers/en/groove-jones.mdx
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "How Groove Jones Delivered a Holiday FOOH Campaign for Dick's Sporting Goods with Comfy"
|
||||
category: "CASE STUDY"
|
||||
description: "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."
|
||||
cover: "https://media.comfy.org/website/customers/groove-jones/crocs-nfl-dicks-sporting-goods-fooh.webp"
|
||||
order: 4
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "THE OUTPUT"
|
||||
- id: topic-3
|
||||
label: "THE PROBLEM"
|
||||
- id: topic-4
|
||||
label: "HOW COMFY SOLVED THE PROBLEM"
|
||||
- id: topic-5
|
||||
label: "BRAND-TRAINED LORAS"
|
||||
- id: topic-6
|
||||
label: "MULTI-MODEL ORCHESTRATION"
|
||||
- id: topic-7
|
||||
label: "THE PIPELINE"
|
||||
- id: topic-8
|
||||
label: "VERSION CONTROL"
|
||||
- id: topic-9
|
||||
label: "FINISHING IN NUKE"
|
||||
- id: topic-10
|
||||
label: "THE TAKEAWAY"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
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’s Sporting Goods, they faced a recurring challenge: delivering feature-film-quality VFX on commercial timelines and budgets.
|
||||
|
||||
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’s 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.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="The Output Groove Jones Achieved Using Comfy">
|
||||
|
||||
- A full FOOH (faux out-of-home) social campaign delivered on a tight holiday deadline
|
||||
- Hyper-realistic videos of giant NFL-licensed Crocs parachuting onto Dick’s Sporting Goods parking lots
|
||||
- Vertical 9:16 deliverables at 2K for Instagram Reels, TikTok, and YouTube Shorts
|
||||
- Same-day iteration on client notes instead of week-long asset updates
|
||||
- Winner, Aaron Awards 2024: Best AI Workflow for Production
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="The Problem Groove Jones Was Trying to Solve">
|
||||
|
||||
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’s 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.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="How Groove Jones Used Comfy to Solve the Problem">
|
||||
|
||||
Groove Jones’s Senior Creative Technologist, Doug Hogan, rebuilt the production process around Comfy’s 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.
|
||||
|
||||
<Quote name="Doug Hogan | Senior Creative Technologist @ Groove Jones">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.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="Brand-Trained LoRAs for Hero Assets">
|
||||
|
||||
Groove Jones trained custom LoRAs on the Crocs NFL Team Clogs and on Dick’s 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.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/groove-jones/nfl-crocs-team-lineup.webp" alt="Grid of brand-accurate NFL team Crocs generated via custom LoRAs" caption="Brand-accurate NFL team colorways generated through custom LoRAs." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="Multi-Model Orchestration in a Single Graph">
|
||||
|
||||
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’s 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.
|
||||
|
||||
<Quote name="Dale Carman | Co-founder @ Groove Jones">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.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-7" title="Storyboards to Previz to Final Shot in One Pipeline">
|
||||
|
||||
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.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/groove-jones/nfl-crocs-dicks-storyboards.webp" alt="Storyboard grid for the Crocs x NFL holiday campaign" caption="Grayscale storyboards used to lock narrative beats before generation." />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/groove-jones/nfl-crocs-fooh-sequence.webp" alt="Composition progression from blocking to mid-render to final shot" caption="Composition progression: wireframe blocking, mid-render, and final shot." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-8" title="Workflow Files as Version Control">
|
||||
|
||||
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.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-9" title="Finishing in Nuke">
|
||||
|
||||
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.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-10" title="Conclusion">
|
||||
|
||||
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.
|
||||
|
||||
<Quote name="Dale Carman | Co-founder @ Groove Jones">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.</Quote>
|
||||
|
||||
</Section>
|
||||
156
apps/website/src/content/customers/en/moment-factory.mdx
Normal file
156
apps/website/src/content/customers/en/moment-factory.mdx
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
title: "How Moment Factory Reimagined 3D Projection Mapping at Architectural Scale with ComfyUI"
|
||||
category: "CASE STUDY"
|
||||
description: "Moment Factory used ComfyUI to reimagine their 3D projection mapping pipeline, enabling architectural-scale visual experiences with AI-driven content generation and real-time iteration."
|
||||
cover: "https://media.comfy.org/website/customers/moment-factory/cover.webp"
|
||||
order: 2
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "BEFORE COMFY"
|
||||
- id: topic-3
|
||||
label: "WHAT CHANGED?"
|
||||
- id: topic-4
|
||||
label: "WHY COMFYUI WAS CRITICAL"
|
||||
- id: topic-5
|
||||
label: "THE TAKEAWAY"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
How do you make generative AI work at architectural scale? Moment Factory used ComfyUI to fundamentally transform how they handle early concept, look development, and design exploration for architectural projection mapping.
|
||||
|
||||
Before ComfyUI, this phase was slower, more abstract, and carried greater risk. After ComfyUI, it became faster, more concrete, and spatially grounded from the start.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/hero.webp" alt="Moment Factory architectural projection mapping" caption="Arched interior architectural projection by Moment Factory." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="Before ComfyUI: Slow Iteration, Abstract Decisions, Late Risk">
|
||||
|
||||
Early concept and look development traditionally relied on:
|
||||
|
||||
- Static sketches
|
||||
- Reference decks
|
||||
- Moodboards
|
||||
- Abstract discussions about intent
|
||||
|
||||
For architectural projection mapping, this creates a problem. You do not really know if something works until it is projected at scale. Seams, pixel density, spatial drift, and composition issues usually reveal themselves later in the process, when changes have a massive impact on production.
|
||||
|
||||
Traditionally, this means:
|
||||
|
||||
- Fewer directions explored
|
||||
- Longer back-and-forth cycles
|
||||
- Creative decisions made without spatial proof
|
||||
- Risk pushed downstream into production
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="What Changed with ComfyUI">
|
||||
|
||||
Moment Factory built a custom ComfyUI workflow and used it to enhance and accelerate large parts of early concept sketching, look-dev exploration, and part of the design phase.
|
||||
|
||||
They did not just generate images. They changed how decisions were made.
|
||||
|
||||
### 1. Iteration stopped being the bottleneck
|
||||
|
||||
ComfyUI transformed the iteration process, making it faster, sharper, and more intentional. Grounded in real production parameters, they explored:
|
||||
|
||||
- Over 20 main artistic directions
|
||||
- 20 to 40 iterations per direction
|
||||
- Styles ranging from hyper-realism to illustrative engraving
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/variations.webp" alt="Grid of generated artistic variations" caption="A grid of generated variations exploring different artistic directions." />
|
||||
|
||||
The studio used batching and parameter tweaks to move quickly, while intentionally stress-testing the system to understand its limits.
|
||||
|
||||
<Quote name="Guillaume Borgomano | Senior Multimedia Director & Innovation Creative Lead @ Moment Factory">With any GenAI tool, it's easy to over-iterate, to believe the best result is always one click away. Imposing real production constraints, whether financial or time-based, was essential to ensure these explorations remained meaningful and truly impacted our pipelines.</Quote>
|
||||
|
||||
That volume of exploration would not have been realistic in their previous workflow.
|
||||
|
||||
### 2. Concept work moved from days to hours
|
||||
|
||||
The biggest acceleration happened early. What would normally involve days of back-and-forth between static concepts and reference decks could happen within a few hours.
|
||||
|
||||
They generated intentionally low-resolution outputs around 2K, reviewed them quickly, and even generated new variations live on site. Those outputs could be checked directly in the media server timeline minutes later.
|
||||
|
||||
This low-resolution stage was not about polish. It was about validation and decision-making. That shift alone changed the pace of the entire project.
|
||||
|
||||
### 3. Spatial credibility came first, not last
|
||||
|
||||
A major reason this worked is that every generation was already spatially constrained. Moment Factory built the entire workflow around architectural surface templates, so outputs were pre-mapped from the start. The pipeline supported multiple template types in parallel, including flat UVs, 360 layouts, and camera-projection setups.
|
||||
|
||||
ControlNet injected structural information from those templates directly into the diffusion process, enforcing scale, layout, and spatial logic early.
|
||||
|
||||
Because of this, visuals were already spatially credible during the concept phase. Abstract intent turned into shared reference points. The team could react to something grounded instead of imagining how it might look later.
|
||||
|
||||
### 4. Approval no longer meant starting over
|
||||
|
||||
Once a direction was approved, the workflow did not reset. They could:
|
||||
|
||||
- Inpaint specific regions
|
||||
- Preserve composition
|
||||
- Upscale selected outputs to 18K in ~20 minutes
|
||||
|
||||
This completely changed how fast ideas moved from concept to projection-ready content. Previously, approval often meant rebuilding work. With ComfyUI, approval meant pushing forward.
|
||||
|
||||
### 5. Fewer people, better collaboration
|
||||
|
||||
Once the system was stable, one main artist operated inside ComfyUI. Around that setup, two additional team members were continuously involved in art direction, prompt tuning, selection, and alignment discussions.
|
||||
|
||||
They had to define a new working methodology to keep creative intent at the center, but in practice, ComfyUI functioned as a shared exploration tool, not a solo technical setup.
|
||||
|
||||
### 6. The moment it became undeniable
|
||||
|
||||
Within Moment Factory's innovation team, it felt like a breakthrough early on — the level of malleability and control simply wasn't achievable with more rigid tools. But the real turning point came during an in-situ live demo, held at 25 Broadway. Late in the process, Moment Factory swapped the surface template and reran the entire pipeline without re-authoring a single asset. The composition held and the spatial logic remained intact. The content dropped straight into the media server timeline.
|
||||
|
||||
The room went quiet.
|
||||
|
||||
In that moment, it stopped being a promising experiment and became a shared realization. People weren't asking "what if" anymore — they were asking how to prompt, and in what other context it could apply.
|
||||
|
||||
That's when it became undeniable: this wasn't just a powerful tool for R&D. It was a shift in how teams across Moment Factory could think, iterate, and produce.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/demo.webp" alt="Moment Factory live projection mapping demo" caption="Interior crowd view with projection mapping at architectural scale." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="Why ComfyUI Was Critical at Architectural Scale">
|
||||
|
||||
Moment Factory had been exploring diffusion-based workflows for projection mapping for years. The ambition was clear: use generative systems not just for images, but as structured spatial material within complex, large-scale environments.
|
||||
|
||||
What architectural scale demanded, however, was not just image generation. It required:
|
||||
|
||||
- Precise control over spatial conditioning
|
||||
- The ability to inject UV layouts and depth constraints directly into inference
|
||||
- Rapid template switching without breaking composition
|
||||
- Iterative refinement without rebuilding from scratch
|
||||
- A pipeline that could evolve as constraints changed
|
||||
|
||||
This level of structural malleability was essential.
|
||||
|
||||
ComfyUI's node-based architecture allowed the team to design and reshape the workflow itself, not just the outputs. Conditioning logic, batching strategies, template inputs, and upscaling stages could be reconfigured as the project evolved.
|
||||
|
||||
Rather than adapting the project to fit a tool, the tool could be adapted to fit the architecture.
|
||||
|
||||
At that point, it became clear: achieving reliable architectural-scale generative workflows required a system flexible enough to be re-authored alongside the creative process. ComfyUI provided that flexibility.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/workflow.webp" alt="ComfyUI node-based workflow" caption="Screenshot of the ComfyUI node-based workflow used by Moment Factory." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="The Takeaway">
|
||||
|
||||
ComfyUI did not make the creative decisions. The vision stayed human. The constraints were architectural, and the expectations were production-level from the start.
|
||||
|
||||
What ComfyUI brought to the table was structural flexibility. It allowed the workflow itself to be shaped and reshaped as the project evolved. Spatial inputs could be injected directly into inference. Templates could be swapped without collapsing the composition. Refinements could happen without rebuilding entire directions.
|
||||
|
||||
Generative systems stopped behaving like black boxes and started behaving like controllable material. Spatial logic was embedded early, and scaling to architectural resolution became a managed step rather than a gamble.
|
||||
|
||||
The impact was not just speed. Decisions could be validated earlier, directly against geometry and projection conditions. Spatial alignment became part of concept development instead of a late-stage correction. That shift reduced uncertainty before entering production.
|
||||
|
||||
In that sense, ComfyUI did more than accelerate exploration. It made architectural-scale generative workflows structurally viable within real production constraints.
|
||||
|
||||
<Contributors label="MOMENT FACTORY CONTRIBUTORS" people={[{"name":"Guillaume Borgomano","role":"Senior Multimedia Director & Innovation Creative Lead"},{"name":"Conner Tozier","role":"Lead Motion Designer & Generative AI Lead"}]} />
|
||||
|
||||
</Section>
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: "How Doodles, SYSTMS, and Open-Source Tools Like ComfyUI Are Rewriting the Rules for Artists"
|
||||
category: "OPEN SOURCE × BRAND"
|
||||
description: "Doodles and SYSTMS built Doodles AI — a generative platform powered by PRISM 1.0 — on open-source infrastructure including ComfyUI, proving that open-source workflows can power brand-quality, commercially successful products."
|
||||
cover: "https://media.comfy.org/website/customers/open-story-movement/cover.webp"
|
||||
order: 1
|
||||
readMore: "https://blog.comfy.org/p/how-open-source-is-fueling-the-open"
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "IP WITHOUT WALLS"
|
||||
- id: topic-3
|
||||
label: "THE LAST MILE"
|
||||
- id: topic-4
|
||||
label: "CODED DNA"
|
||||
- id: topic-5
|
||||
label: "TAKEAWAY"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Doodles, the entertainment brand built around the iconic pastel-palette artwork of Canadian illustrator Scott Martin (known as Burnt Toast), is about to launch **Doodles AI** — a generative platform powered by **PRISM 1.0**, a generative image model trained on Doodles' extensive body of work that can reimagine people and objects in the unmistakable Doodles visual language.
|
||||
|
||||
Behind the scenes, the engineering is being handled by **SYSTMS**, an AI studio whose tagline — "Engineering the Impossible" — reflects their approach to building bespoke creative pipelines using open-source infrastructure, including node-based workflow tools like ComfyUI.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/cover.webp" alt="Doodles AI generative platform powered by PRISM 1.0" caption="The Doodles AI platform reimagines people and objects in the Doodles visual language." />
|
||||
|
||||
The story of how these pieces came together offers a compelling blueprint for anyone watching the intersection of open-source, AI, artist-driven brands, and the emerging concept the Doodles team is calling "open story."
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="IP Without Walls">
|
||||
|
||||
Artists have traditionally been protective of their IP, and for good reason. But the Doodles team is exploring a new model where the community doesn't just consume the brand — they co-create it. Every generation a user produces on the Doodles AI platform makes the model stronger.
|
||||
|
||||
Through reinforcement learning, user-generated content becomes part of the training data for future iterations of the PRISM. Users aren't just customers; they're collaborators shaping the brand's visual DNA.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/walls.webp" alt="Doodles community co-creation" caption="Users become collaborators, co-creating the Doodles brand through AI-generated content." />
|
||||
|
||||
As Scott Martin put it when he returned as CEO in early 2025, the goal is to recalibrate — creativity first, community at the center, art driving everything. Martin, who built his career as an illustrator working with Google, Snapchat, Dropbox, and Adobe before co-founding Doodles in 2021 alongside Evan Keast and Jordan Castro, understands both the commercial and artistic sides of this equation.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="The Last Mile Is the Whole Game">
|
||||
|
||||
Doodles AI represents something powerful: proof that open-source tools can power commercially successful, brand-quality products.
|
||||
|
||||
The SYSTMS team uses open-source tools in their rawest form, prioritizing control and innovation at the bleeding edge of the space. The fact that these same tools are now producing output with the kind of brand fidelity that differentiates Doodles from generalized platforms like MidJourney or Sora is significant. It's the "last mile" problem in creative AI — getting from 85% to 100% fidelity — and it's where the real value lies.
|
||||
|
||||
Doodles AI is a showcase of what's possible when open-source workflows meet professional creative direction. ComfyUI's powerful node-based platform allows users to package complex systems of open-source models, APIs, and other tools into consumer-facing applications, making it a natural fit for projects like this.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/workflow.webp" alt="ComfyUI workflow powering Doodles AI" caption="Open-source workflows powering brand-quality generative output." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="Coded DNA">
|
||||
|
||||
Doodles AI launches with PRISM 1.0 as an image-to-image model, but the roadmap is ambitious: 2D and 3D output generation, video with sound, real-time AR, and gaming applications. Original Doodles holders receive 100 free generations on launch day — a deliberate move to seed the community and let them flood every timeline with the platform's output.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/dna.webp" alt="Doodles AI output examples" caption="Doodles AI output demonstrating brand-fidelity generative results." />
|
||||
|
||||
The deeper play is alignment with the speed and scale of the entire AI industry. By building on open-source infrastructure and fostering a community of co-creators, Doodles has positioned itself to plug its "coded DNA" into future technologies that don't yet exist. It's a bet that openness — open source, open story, open creation — isn't just philosophically appealing but strategically sound.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="What It Means for Artists">
|
||||
|
||||
For artists watching from the sidelines, the message is clear: the building blocks are here, the community is building, and the line between creator and consumer is disappearing. The question isn't whether open source will reshape creative industries. It's whether you'll be building with it when it does.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/output.webp" alt="Doodles AI creative output" caption="Open-source tools powering brand-quality creative output at scale." />
|
||||
|
||||
<Contributors label="LINKS" people={[{"name":"Doodles: doodles.app | SYSTMS: systms.ai | ComfyUI: comfy.org","role":"Official websites"}]} />
|
||||
|
||||
</Section>
|
||||
106
apps/website/src/content/customers/en/series-entertainment.mdx
Normal file
106
apps/website/src/content/customers/en/series-entertainment.mdx
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "How Series Entertainment Rebuilt Game and Video Production with ComfyUI"
|
||||
category: "GAME & VIDEO PRODUCTION"
|
||||
description: "Scaling emotional storytelling across 100,000+ assets and multiple Netflix titles, using repeatable ComfyUI production systems."
|
||||
cover: "https://media.comfy.org/website/customers/series-entertainment/cover.webp"
|
||||
order: 0
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "THE OUTPUT"
|
||||
- id: topic-3
|
||||
label: "THE PROBLEM"
|
||||
- id: topic-4
|
||||
label: "THE SOLUTION"
|
||||
- id: topic-5
|
||||
label: "WHY COMFYUI"
|
||||
- id: topic-6
|
||||
label: "CONCLUSION"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Series Entertainment builds story-driven games and short-form video experiences where characters, emotion, and visual consistency matter. As the scope of their work expanded across internal projects, partner collaborations, and Netflix titles, the team faced a growing challenge: they needed to produce more content, across more projects, without slowing down or losing consistency.
|
||||
|
||||
To meet that challenge, Series leveraged ComfyUI to scale their workflows. By building custom, repeatable workflows on top of ComfyUI, Series changed how they create characters, emotions, and video. The result was a scalable production system that supported over 100,000 assets, shipped Netflix games, and continues to power multiple projects in active development.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/series.webp" alt="Series Entertainment game titles including Olympus Rising, Gilded Scales, Evergrove, and The Wandering Teahouse" caption="Series Entertainment produces story-driven games and video experiences across multiple titles and visual styles." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="The Output Series Achieved Using ComfyUI">
|
||||
|
||||
With ComfyUI integrated into its production workflows, Series achieved:
|
||||
|
||||
- 100,000+ assets generated across games and video
|
||||
- 180× faster production speed
|
||||
- Six distinct character emotions generated in seconds
|
||||
- 15 minutes of final video per creator per week
|
||||
- Multiple Netflix titles shipped, with many more experiences in active development
|
||||
|
||||
These outputs span character assets, emotional variations, background consistency, and short-form video — all created through repeatable ComfyUI-powered workflows.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="The Problem Series Was Trying to Solve">
|
||||
|
||||
Series' work depends on expressive characters and consistent visual identity. As projects grew in size and complexity, the team needed a way to scale content creation without breaking timelines.
|
||||
|
||||
Traditional animation workflows rely on manual keyframing, multiple disconnected tools, and long production cycles that can stretch into weeks per video. Producing variations often means redoing work from scratch, and experimentation can be slow and expensive.
|
||||
|
||||
Series needed workflows that could be reused across teams and projects, while still supporting emotional storytelling, character consistency, and fast iteration.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="How Series Used ComfyUI to Solve the Problem">
|
||||
|
||||
Series rebuilt their production process around ComfyUI's node-based workflow system. Instead of treating generation as a one-off step, they treated workflows as long-term production assets. ComfyUI became the place where creative structure lived — from character creation to emotion generation to video output.
|
||||
|
||||
### Emotion Generation at Scale
|
||||
|
||||
Series built a custom avatar system using ComfyUI that generates six distinct emotions in seconds: Happy, Sad, Serious, Snarky, Thinking, and Surprised. This made it possible to create expressive characters with multiple emotional states without manually recreating each variation.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/panel.webp" alt="ComfyUI Expression Editor node for facial expression manipulation" caption="The Expression Editor node in ComfyUI enables fine-grained control over character emotions." />
|
||||
|
||||
### Replicable Pipelines from Test to Production
|
||||
|
||||
Using ComfyUI's modular node system, Series built four streamlined pipelines that support the full production cycle — from early exploration to final output. These workflows deliver results up to **180× faster** than traditional manual processes that can take six hours or more per asset, while maintaining production quality.
|
||||
|
||||
The pipelines range from quick 512×512 single-emotion tests to high-resolution batch generation, allowing teams to experiment quickly and move directly into production using the same workflows.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/workflows.webp" alt="ComfyUI workflow for facial expression manipulation and upscaling pipeline" caption="A ComfyUI workflow showing parallel expression editing, upscaling, and face detailing pipelines." />
|
||||
|
||||
### Consistency Across Games and Branching Stories
|
||||
|
||||
For multiple Netflix titles, Series used ComfyUI to build workflows that keep characters and backgrounds consistent across complex, branching narratives. Styling and consistency pipelines help ensure that characters stay visually aligned across scenes, emotions, and story paths — even as asset counts grow.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/consistency.webp" alt="Consistent character across multiple scenes and emotional states" caption="A single character maintained across six different scenes and emotional states using ComfyUI consistency pipelines." />
|
||||
|
||||
### Production at Scale with ComfyUI
|
||||
|
||||
Series also uses ComfyUI as part of an AI-assisted animation pipeline that connects story development directly to image and video generation. This pipeline includes bot-assisted video generation, allowing creators to repeatedly run the same workflows to produce video efficiently. Using this approach, each creator can generate Lorespark videos at scale, delivering over **15 minutes of final video per week**.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/batch.webp" alt="ComfyUI batch processing workflow using Nano Banana and Google Gemini" caption="A batch processing workflow connecting multiple character images to Nano Banana for style-consistent generation." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="Why ComfyUI Worked for Series">
|
||||
|
||||
ComfyUI worked well because its node-based structure makes workflows explicit and reusable — once a workflow is built, it can be refined and shared across projects. This allowed Series to turn video generation into a repeatable system rather than a one-off process.
|
||||
|
||||
Batch execution and bot integration allow those workflows to run at scale. Because the same workflows support both low-resolution testing and high-resolution final output, teams can move from exploration to delivery without switching tools or rebuilding pipelines.
|
||||
|
||||
Most importantly, ComfyUI let Series focus on building structure instead of relying on trial-and-error prompting. Emotions, consistency, and production logic live inside the workflows themselves.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/scale.webp" alt="Six variations of the same character generated with consistent style" caption="Multiple pose and expression variations of a single character, generated at scale while maintaining visual consistency." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="Conclusion">
|
||||
|
||||
By making ComfyUI a core creative platform, Series Entertainment transformed how it produces games and video. What started as a need for scale and consistency became a workflow-driven production system that supports emotional storytelling, large asset volumes, and ongoing development across multiple teams.
|
||||
|
||||
<Quote name="Series Entertainment">For Series, ComfyUI is not an experiment. It is how entertainment gets made.</Quote>
|
||||
|
||||
</Section>
|
||||
101
apps/website/src/content/customers/en/ubisoft-chord.mdx
Normal file
101
apps/website/src/content/customers/en/ubisoft-chord.mdx
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: "Ubisoft Open-Sources the CHORD Model with ComfyUI for AAA PBR Material Generation"
|
||||
category: "AAA GAME PRODUCTION"
|
||||
description: "Ubisoft La Forge open-sourced its CHORD PBR material estimation model with ComfyUI custom nodes, enabling end-to-end texture generation workflows for AAA game production."
|
||||
cover: "https://media.comfy.org/website/customers/ubisoft/cover.webp"
|
||||
order: 3
|
||||
readMore: "https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model"
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "INTRO"
|
||||
- id: topic-2
|
||||
label: "THE PROBLEM"
|
||||
- id: topic-3
|
||||
label: "WHY COMFYUI"
|
||||
- id: topic-4
|
||||
label: "THE PIPELINE"
|
||||
- id: topic-5
|
||||
label: "TRY IT"
|
||||
- id: topic-6
|
||||
label: "RESULTS"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Ubisoft La Forge has open-sourced its PBR material estimation model, **CHORD (Chain of Rendering Decomposition)**, together with **ComfyUI-Chord** custom node implementation to build an end-to-end material generation workflow with AI.
|
||||
|
||||
The model weights and code are released with a Research-Only license. Beyond research, this is a significant step toward integrating ComfyUI into AAA-scale video game production workflows.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/cover.webp" alt="CHORD PBR material generation in ComfyUI" caption="PBR materials generated using the CHORD model in ComfyUI." />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="PBR Material Production in AAA Games Today">
|
||||
|
||||
In AAA game development, PBR materials are the foundation of visual realism. Large-scale titles require hundreds of reusable materials, each with full Base Color, Normal, Height, Roughness, and Metalness maps that meet strict svBRDF standards.
|
||||
|
||||
Traditionally, these assets are crafted by texture artists using photogrammetry, procedural tools, and extensive manual tuning — making the process time-consuming and highly expertise-dependent.
|
||||
|
||||
Ubisoft's Generative Base Material prototype directly targets this production bottleneck. The ComfyUI workflow outputs PBR texture sets that integrate directly into DCC tools and game engines for prototyping and placeholder assets.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="Why Ubisoft Chose ComfyUI as The Workflow Platform">
|
||||
|
||||
Ubisoft's choice of ComfyUI is rooted in production realities. For large studios, the requirement is not another image generator — it is a controllable and integratable AI workflow platform that can meet the bespoke requirements of game development.
|
||||
|
||||
<Quote name="Ubisoft La Forge Blog">Considering the multi-stage nature of our prototype, ComfyUI provides us with an efficient framework to build integrated workflows doing texture image synthesis, material estimation and material upscaling. This also enables us to leverage state-of-the-art generative models and the powerful features of ComfyUI that provide fine-grain control to creators with ControlNets, image guidance, inpainting, and countless other options.</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="3 Stages of The Generative Base Material Pipeline">
|
||||
|
||||
The CHORD model is integrated into a broader pipeline consisting of 3 core stages.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/pipeline.webp" alt="The 3-stage generative base material pipeline" caption="The 3-stage generative base material pipeline: texture generation, CHORD estimation, and upscaling." />
|
||||
|
||||
### Stage 1 — Texture Image Generation
|
||||
|
||||
The first stage generates seamless, tileable 2D textures from text prompts or reference inputs such as lineart and height maps using a custom diffusion model with full conditional control.
|
||||
|
||||
### Stage 2 — CHORD Image-to-Material Estimation
|
||||
|
||||
A single texture is converted into a full set of PBR maps — including Base Color, Normal, Height, Roughness, and Metalness — using chained decomposition, unified multi-modal prediction, and efficient single-step diffusion inference for controllable and scalable results.
|
||||
|
||||
### Stage 3 — Material Upscaling
|
||||
|
||||
Since CHORD operates optimally at 1024 resolution, the third stage applies industrial-grade PBR upscaling. All channels are upscaled by 2x or 4x to produce 2K and 4K texture assets for real-time game production.
|
||||
|
||||
This complete pipeline enables artists to rapidly iterate on ideas and mix and match AI-generated outputs within their existing workflows, lowering the barrier to industrial-grade PBR material creation.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="How to Try CHORD in ComfyUI">
|
||||
|
||||
Ubisoft has open-sourced the CHORD model weights, ComfyUI custom nodes, and example workflows covering the texture image generation stage and the image-to-material estimation stage of the pipeline.
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/workflow.webp" alt="CHORD example workflow in ComfyUI" caption="The CHORD example workflow in ComfyUI for end-to-end PBR material generation." />
|
||||
|
||||
<Steps items={["Install or update ComfyUI to the latest version","Install the CHORD ComfyUI custom node from Ubisoft","Download the CHORD model and place it in ./ComfyUI/models/checkpoints","Load the CHORD example workflow in ComfyUI"]} />
|
||||
|
||||
You can switch the texture image generation model to any other image model, and use the workflow modules for each stage separately.
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="Example Outputs">
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example1.webp" alt="CHORD PBR material example output 1" caption="Generated PBR material set showing Base Color, Normal, Height, Roughness, and Metalness maps." />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example2.webp" alt="CHORD PBR material example output 2" caption="Another generated PBR material set demonstrating the variety of textures achievable with CHORD." />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example3.webp" alt="CHORD PBR material example output 3" caption="Material generation output with full PBR channel decomposition." />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example4.webp" alt="CHORD PBR material example output 4" caption="High-quality PBR texture set generated from a single input texture." />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example5.webp" alt="CHORD PBR material example output 5" caption="Final rendered PBR material demonstrating production-ready quality." />
|
||||
|
||||
The release of CHORD demonstrates how ComfyUI has grown from a community-driven tool into a platform for real production. Studio users can build end-to-end pipelines from prompt or reference input through texture generation, material estimation, PBR upscaling, and finally export to DCC tools or game engines. Each stage can also operate independently and be embedded into an existing production system.
|
||||
|
||||
<Contributors label="AUTHOR" people={[{"name":"Jo Zhang","role":"ComfyUI Blog"},{"name":"Daxiong (Lin)","role":"ComfyUI Blog"}]} />
|
||||
|
||||
</Section>
|
||||
106
apps/website/src/content/customers/zh-CN/groove-jones.mdx
Normal file
106
apps/website/src/content/customers/zh-CN/groove-jones.mdx
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "Groove Jones 如何借助 Comfy 为 Dick's Sporting Goods 打造节日 FOOH 营销"
|
||||
category: "案例研究"
|
||||
description: "达拉斯创意工作室 Groove Jones 借助 Comfy,在紧迫的节日档期内为 Crocs x NFL 联名系列交付了超写实的 FOOH 营销内容。"
|
||||
cover: "https://media.comfy.org/website/customers/groove-jones/crocs-nfl-dicks-sporting-goods-fooh.webp"
|
||||
order: 4
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "简介"
|
||||
- id: topic-2
|
||||
label: "交付成果"
|
||||
- id: topic-3
|
||||
label: "挑战"
|
||||
- id: topic-4
|
||||
label: "Comfy 如何解决问题"
|
||||
- id: topic-5
|
||||
label: "品牌定制 LORA"
|
||||
- id: topic-6
|
||||
label: "多模型编排"
|
||||
- id: topic-7
|
||||
label: "流水线"
|
||||
- id: topic-8
|
||||
label: "版本管理"
|
||||
- id: topic-9
|
||||
label: "Nuke 终修"
|
||||
- id: topic-10
|
||||
label: "总结"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
位于达拉斯的创意工作室 Groove Jones,为众多大牌客户打造由 AI 驱动的营销活动和沉浸式体验,需要同时兼顾照片级的精细度、创意野心,以及适配社交媒体的交付速度。随着他们为 Crocs、NFL 和 Dick’s Sporting Goods 等客户的工作扩展到 AI 视频、AR、VR 和 WebGL,他们反复遇到同一个挑战:用商业项目的工期和预算,交付电影级的 VFX 质量。
|
||||
|
||||
在 Crocs x NFL 联名系列的节日上市项目中,这个挑战被推到了极致。Brief 要求制作超写实视频:巨型 NFL 授权 Crocs 鞋款跳伞落入多个真实的 Dick’s Sporting Goods 停车场,并要在紧迫的节日档期前交付。实地拍摄加传统 CG 流水线的方案,已经完全行不通。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="Groove Jones 借助 Comfy 实现的交付成果">
|
||||
|
||||
- 在紧迫的节日档期内交付完整的 FOOH(虚构户外广告)社媒营销活动
|
||||
- 超写实视频:巨型 NFL 授权 Crocs 鞋款跳伞落入 Dick’s Sporting Goods 停车场
|
||||
- 面向 Instagram Reels、TikTok、YouTube Shorts 的 9:16 竖屏 2K 交付物
|
||||
- 客户反馈当天迭代,不再需要数周的资产更新周期
|
||||
- 荣获 2024 年 Aaron Awards:最佳 AI 制作工作流奖
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="Groove Jones 试图解决的问题">
|
||||
|
||||
按照传统流水线做这个创意,意味着要在多家门店实地拍摄,加上完整的 CG 制作:每支球队鞋款的高精建模、look development、灯光、渲染、合成,客户每次想要新变体都要重新渲染。这也意味着庞大的团队(建模师、纹理师、灯光师、合成师),以及以"月"为单位的工期。无论是预算还是节日档期,都无法支撑这条路径。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="Groove Jones 如何用 Comfy 解决问题">
|
||||
|
||||
Groove Jones 的高级创意技术总监 Doug Hogan 围绕 Comfy 的节点式工作流系统重新搭建了制作流程,并基于他们自研的 GrooveTech GenVFX 流水线展开。自定义 LoRA 负责保证品牌一致性,一张 Comfy 图编排多个生成模型,Nuke 负责最终精修。对于有电影和广告制作背景的团队,这套环境上手没有任何门槛。
|
||||
|
||||
<Quote name="Doug Hogan | Groove Jones 高级创意技术总监">Comfy 用起来非常像传统 CG 和合成流水线:节点逻辑、清晰的数据流、模块化构建。我们的艺术家用起来毫无违和感。</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="为主视觉资产定制的品牌 LoRA">
|
||||
|
||||
Groove Jones 基于 Crocs NFL 球队联名鞋款和 Dick’s Sporting Goods 门店外景训练了定制 LoRA,让每一次生成都能锚定品牌精准的参考素材。真实的球队配色、产品轮廓和门店外观在不同镜头之间保持一致,不需要逐帧修正——而这通常意味着数周的 look development 工作量。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/groove-jones/nfl-crocs-team-lineup.webp" alt="通过定制 LoRA 生成的多支 NFL 球队联名 Crocs 网格" caption="通过定制 LoRA 生成的、与品牌精准一致的 NFL 球队配色。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="单张图内的多模型编排">
|
||||
|
||||
这个创意在不同阶段需要不同的生成模型:Flux 用于关键帧静帧开发,Gemini Flash 2.5(Nano Banana)用于快速构思和变体生成,Veo 3.1 加上 Moonvalley 的 Marey 用于最终的视频生成。Comfy 在一张图里就把这四个模型串起来,前一个模型的输出直接喂给下一个模型,全程无需切换环境。
|
||||
|
||||
<Quote name="Dale Carman | Groove Jones 联合创始人">Comfy 社区几乎是指数级增长的,我们可以直接利用社区已有的节点和工具去解决非常具体的制作问题,而不必自己重新造轮子。</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-7" title="从故事板到 Previz 再到成片,全部在一条流水线内">
|
||||
|
||||
工作流从传统故事板开始用于叙事确认,再进入 CGI blocking,锁定构图、镜头取景和叙事节奏。从这里开始 Comfy 接管生成:鞋款空投、停车场反应镜头、人群覆盖、把夏季静态门店外景转换成被雪覆盖的节日场景——全部在同一张图里完成。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/groove-jones/nfl-crocs-dicks-storyboards.webp" alt="Crocs x NFL 节日营销的故事板网格" caption="在生成之前用于锁定叙事节奏的灰度故事板。" />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/groove-jones/nfl-crocs-fooh-sequence.webp" alt="从 blocking 到中间渲染再到最终镜头的构图演进" caption="构图演进:线框 blocking、中间渲染、最终成片。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-8" title="把工作流文件当作版本管理">
|
||||
|
||||
每个镜头的每个变体都以 Comfy 工作流文件的形式存在,文件本身就是版本管理。当客户反馈要求换一支球队配色、换一个门店外景或者换一个时间段时,团队只需复制一个分支,而不是重建——这才让"当天迭代"成为可能。GPU 使用量和 API 额度消耗也都能在同一个环境里追踪到,让制作部门实时看到每次迭代的算力成本。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-9" title="在 Nuke 中完成终修">
|
||||
|
||||
生成的镜头进入 Nuke 完成最终合成:飘雪、镜头抖动、人群环境音、节日氛围音效,以及面向 Instagram Reels、TikTok、YouTube Shorts 的 9:16 2K 母带。由于 Comfy 把生成环节处理得很干净,Nuke 可以专注于精修和动态增强,而不是去修补生成模型留下的瑕疵。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-10" title="结语">
|
||||
|
||||
通过在 Comfy 中搭建整套 FOOH 流水线,Groove Jones 把一个原本需要昂贵实地拍摄加数月 CG 制作的项目,变成了一套高速迭代、单一环境、客户可以实时指挥的工作流。该项目近期还荣获 Aaron Award 的"最佳 AI 制作工作流"奖。
|
||||
|
||||
<Quote name="Dale Carman | Groove Jones 联合创始人">在 Groove Jones,我们非常在意交付让人说"WOW!"的作品,但我们同样在意按时按预算交付。VFX 项目以前的利润率薄得像刀刃,Comfy 帮我们彻底解决了这个问题。</Quote>
|
||||
|
||||
</Section>
|
||||
156
apps/website/src/content/customers/zh-CN/moment-factory.mdx
Normal file
156
apps/website/src/content/customers/zh-CN/moment-factory.mdx
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
title: "Moment Factory 如何使用 ComfyUI 在建筑尺度重新定义 3D 投影映射"
|
||||
category: "案例研究"
|
||||
description: "Moment Factory 使用 ComfyUI 重新定义了 3D 投影映射管线,通过 AI 驱动的内容生成和实时迭代,实现建筑尺度的视觉体验。"
|
||||
cover: "https://media.comfy.org/website/customers/moment-factory/cover.webp"
|
||||
order: 2
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "简介"
|
||||
- id: topic-2
|
||||
label: "使用前"
|
||||
- id: topic-3
|
||||
label: "发生了什么变化?"
|
||||
- id: topic-4
|
||||
label: "为什么 ComfyUI 至关重要"
|
||||
- id: topic-5
|
||||
label: "总结"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
如何让生成式 AI 在建筑尺度下发挥作用?Moment Factory 使用 ComfyUI 从根本上改变了他们在建筑投影映射中处理早期概念、外观开发和设计探索的方式。
|
||||
|
||||
在使用 ComfyUI 之前,这一阶段更慢、更抽象,风险也更大。使用 ComfyUI 之后,它变得更快、更具体,从一开始就在空间上有了坚实的基础。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/hero.webp" alt="Moment Factory 建筑投影映射" caption="Moment Factory 的拱形室内建筑投影。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="使用 ComfyUI 之前:迭代缓慢、决策抽象、风险滞后">
|
||||
|
||||
早期概念和外观开发传统上依赖于:
|
||||
|
||||
- 静态草图
|
||||
- 参考资料集
|
||||
- 情绪板
|
||||
- 关于意图的抽象讨论
|
||||
|
||||
对于建筑投影映射来说,这带来了一个问题。在实际投影到建筑上之前,你无法真正知道某个方案是否可行。接缝、像素密度、空间偏移和构图问题通常在流程后期才暴露出来,而此时的修改对制作的影响是巨大的。
|
||||
|
||||
传统上,这意味着:
|
||||
|
||||
- 探索的方向更少
|
||||
- 反复沟通的周期更长
|
||||
- 创意决策缺乏空间验证
|
||||
- 风险被推迟到制作阶段
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="使用 ComfyUI 后发生了什么变化">
|
||||
|
||||
Moment Factory 构建了自定义的 ComfyUI 工作流,并将其用于增强和加速早期概念草图、外观开发探索以及部分设计阶段。
|
||||
|
||||
他们不仅仅是生成图像,而是改变了决策方式。
|
||||
|
||||
### 1. 迭代不再是瓶颈
|
||||
|
||||
ComfyUI 改变了迭代过程,使其更快、更精准、更有目的性。基于真实的制作参数,他们探索了:
|
||||
|
||||
- 20 多个主要艺术方向
|
||||
- 每个方向 20 到 40 次迭代
|
||||
- 风格从超写实到插画版画不等
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/variations.webp" alt="生成的艺术变体网格" caption="探索不同艺术方向的生成变体网格。" />
|
||||
|
||||
工作室通过批处理和参数调整快速推进,同时有意地对系统进行压力测试以了解其极限。
|
||||
|
||||
<Quote name="Guillaume Borgomano | Moment Factory 高级多媒体总监 & 创新创意负责人">使用任何生成式 AI 工具,都很容易过度迭代,认为最佳结果总是只差一次点击。施加真实的制作约束,无论是财务上还是时间上的,对于确保这些探索保持有意义并真正影响我们的管线至关重要。</Quote>
|
||||
|
||||
在他们之前的工作流中,如此大量的探索是不现实的。
|
||||
|
||||
### 2. 概念工作从数天缩短到数小时
|
||||
|
||||
最大的加速发生在早期阶段。通常需要在静态概念和参考资料集之间来回数天的工作,现在可以在几个小时内完成。
|
||||
|
||||
他们有意生成约 2K 的低分辨率输出,快速审查,甚至在现场实时生成新的变体。这些输出可以在几分钟后直接在媒体服务器时间线中查看。
|
||||
|
||||
这个低分辨率阶段不是关于打磨,而是关于验证和决策。仅这一转变就改变了整个项目的节奏。
|
||||
|
||||
### 3. 空间可信度优先,而非滞后
|
||||
|
||||
这之所以有效的一个主要原因是,每次生成已经在空间上受到约束。Moment Factory 围绕建筑表面模板构建了整个工作流,因此输出从一开始就是预映射的。管线同时支持多种模板类型,包括平面 UV、360 布局和相机投影设置。
|
||||
|
||||
ControlNet 将这些模板的结构信息直接注入扩散过程,提前强制执行比例、布局和空间逻辑。
|
||||
|
||||
因此,视觉效果在概念阶段就已经具有空间可信度。抽象的意图转变为共享的参考点。团队可以对有据可依的东西做出反应,而不是想象它以后可能的样子。
|
||||
|
||||
### 4. 审批不再意味着重新开始
|
||||
|
||||
一旦方向获批,工作流不会重置。他们可以:
|
||||
|
||||
- 局部修复特定区域
|
||||
- 保留构图
|
||||
- 在约 20 分钟内将选定的输出放大到 18K
|
||||
|
||||
这完全改变了创意从概念到投影就绪内容的速度。以前,审批通常意味着重新制作。有了 ComfyUI,审批意味着继续推进。
|
||||
|
||||
### 5. 更少的人,更好的协作
|
||||
|
||||
一旦系统稳定,一名主要艺术家在 ComfyUI 中操作。在此设置周围,另外两名团队成员持续参与艺术指导、提示词调优、选择和对齐讨论。
|
||||
|
||||
他们必须定义新的工作方法以保持创意意图在核心位置,但在实践中,ComfyUI 作为共享的探索工具运作,而非单独的技术设置。
|
||||
|
||||
### 6. 不可否认的时刻
|
||||
|
||||
在 Moment Factory 的创新团队中,这在早期就感觉像是一个突破——这种程度的可塑性和控制力在更僵化的工具中根本无法实现。但真正的转折点出现在百老汇 25 号的一次现场演示中。在流程后期,Moment Factory 更换了表面模板,并重新运行了整个管线,没有重新制作任何资产。构图保持不变,空间逻辑完好无损。内容直接进入媒体服务器时间线。
|
||||
|
||||
全场安静了。
|
||||
|
||||
在那一刻,它不再是一个有前景的实验,而成为一种共识。人们不再问"如果怎样"——他们在问如何编写提示词,以及它还能应用在哪些场景中。
|
||||
|
||||
那时它变得不可否认:这不仅仅是研发的强大工具,而是 Moment Factory 各团队思考、迭代和制作方式的一次转变。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/demo.webp" alt="Moment Factory 现场投影映射演示" caption="建筑尺度投影映射的室内观众视角。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="为什么 ComfyUI 在建筑尺度至关重要">
|
||||
|
||||
Moment Factory 多年来一直在探索基于扩散的投影映射工作流。目标很明确:将生成系统不仅用于图像,还作为复杂大规模环境中的结构化空间素材。
|
||||
|
||||
然而,建筑尺度所要求的不仅仅是图像生成,还需要:
|
||||
|
||||
- 对空间条件的精确控制
|
||||
- 将 UV 布局和深度约束直接注入推理的能力
|
||||
- 不破坏构图的快速模板切换
|
||||
- 无需从头重建的迭代优化
|
||||
- 可以随约束变化而发展的管线
|
||||
|
||||
这种程度的结构可塑性是必不可少的。
|
||||
|
||||
ComfyUI 基于节点的架构使团队能够设计和重塑工作流本身,而不仅仅是输出。条件逻辑、批处理策略、模板输入和放大阶段可以随着项目的发展而重新配置。
|
||||
|
||||
项目无需适应工具,工具可以适应建筑。
|
||||
|
||||
在那一刻变得清晰:实现可靠的建筑尺度生成式工作流需要一个足够灵活的系统,可以在创意过程中被重新构建。ComfyUI 提供了这种灵活性。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/moment-factory/workflow.webp" alt="ComfyUI 基于节点的工作流" caption="Moment Factory 使用的 ComfyUI 基于节点工作流截图。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="总结">
|
||||
|
||||
ComfyUI 没有做出创意决策。愿景始终是人类的。约束是建筑性的,期望从一开始就是制作级别的。
|
||||
|
||||
ComfyUI 带来的是结构灵活性。它允许工作流本身随着项目的发展而被塑造和重塑。空间输入可以直接注入推理。模板可以在不破坏构图的情况下切换。优化可以在不重建整个方向的情况下进行。
|
||||
|
||||
生成系统不再像黑箱一样运作,而开始像可控材料一样行为。空间逻辑被提前嵌入,扩展到建筑分辨率成为一个可管理的步骤,而非赌博。
|
||||
|
||||
影响不仅仅是速度。决策可以更早地得到验证,直接针对几何形状和投影条件。空间对齐成为概念开发的一部分,而不是后期修正。这种转变减少了进入制作前的不确定性。
|
||||
|
||||
从这个意义上说,ComfyUI 不仅加速了探索,还使建筑尺度的生成式工作流在真实制作约束下具有结构可行性。
|
||||
|
||||
<Contributors label="MOMENT FACTORY 贡献者" people={[{"name":"Guillaume Borgomano","role":"高级多媒体总监 & 创新创意负责人"},{"name":"Conner Tozier","role":"首席动效设计师 & 生成式 AI 负责人"}]} />
|
||||
|
||||
</Section>
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: "Doodles、SYSTMS 和 ComfyUI 等开源工具如何重写艺术家的规则"
|
||||
category: "开源 × 品牌"
|
||||
description: "Doodles 和 SYSTMS 在包括 ComfyUI 在内的开源基础设施上构建了 Doodles AI——一个由 PRISM 1.0 驱动的生成平台,证明了开源工作流可以支撑品牌级、商业成功的产品。"
|
||||
cover: "https://media.comfy.org/website/customers/open-story-movement/cover.webp"
|
||||
order: 1
|
||||
readMore: "https://blog.comfy.org/p/how-open-source-is-fueling-the-open"
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "简介"
|
||||
- id: topic-2
|
||||
label: "无墙 IP"
|
||||
- id: topic-3
|
||||
label: "最后一英里"
|
||||
- id: topic-4
|
||||
label: "编码 DNA"
|
||||
- id: topic-5
|
||||
label: "要点"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Doodles 是一个围绕加拿大插画师 Scott Martin(又名 Burnt Toast)标志性柔和色彩作品构建的娱乐品牌,即将推出 **Doodles AI**——一个由 **PRISM 1.0** 驱动的生成平台,这是一个基于 Doodles 大量作品训练的生成图像模型,能够以标志性的 Doodles 视觉语言重新想象人物和物体。
|
||||
|
||||
幕后的工程由 **SYSTMS** 负责,这是一家 AI 工作室,其口号"Engineering the Impossible"反映了他们使用开源基础设施构建定制创意管线的方法,包括像 ComfyUI 这样的基于节点的工作流工具。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/cover.webp" alt="由 PRISM 1.0 驱动的 Doodles AI 生成平台" caption="Doodles AI 平台以 Doodles 视觉语言重新想象人物和物体。" />
|
||||
|
||||
这些部分如何整合在一起的故事,为关注开源、AI、艺术家驱动品牌以及 Doodles 团队所称的"开放叙事"这一新兴概念交汇点的所有人提供了一个引人注目的蓝图。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="无墙 IP">
|
||||
|
||||
艺术家传统上一直保护自己的知识产权,这有充分的理由。但 Doodles 团队正在探索一种新模式,社区不仅仅是消费品牌——他们共同创造品牌。用户在 Doodles AI 平台上生成的每一次创作都会使模型更强大。
|
||||
|
||||
通过强化学习,用户生成的内容成为 PRISM 未来迭代的训练数据的一部分。用户不仅仅是客户;他们是塑造品牌视觉 DNA 的协作者。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/walls.webp" alt="Doodles 社区共创" caption="用户成为协作者,通过 AI 生成的内容共同创造 Doodles 品牌。" />
|
||||
|
||||
正如 Scott Martin 在 2025 年初重新担任 CEO 时所说,目标是重新校准——创意优先、社区为中心、艺术驱动一切。Martin 在 2021 年与 Evan Keast 和 Jordan Castro 共同创立 Doodles 之前,曾与 Google、Snapchat、Dropbox 和 Adobe 合作建立了自己的插画师职业生涯,他深谙这个等式的商业和艺术两面。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="最后一英里就是整个游戏">
|
||||
|
||||
Doodles AI 代表着一种强大的证明:开源工具可以驱动商业成功、品牌级品质的产品。
|
||||
|
||||
SYSTMS 团队以最原始的形式使用开源工具,在该领域的最前沿优先考虑控制和创新。这些工具现在能够生成具有品牌保真度的输出,使 Doodles 区别于 MidJourney 或 Sora 等通用平台,这一点意义重大。这就是创意 AI 中的"最后一英里"问题——从 85% 到 100% 的保真度——也是真正价值所在。
|
||||
|
||||
Doodles AI 展示了当开源工作流遇上专业创意方向时的可能性。ComfyUI 强大的基于节点的平台允许用户将开源模型、API 和其他工具的复杂系统打包成面向消费者的应用程序,使其成为此类项目的天然选择。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/workflow.webp" alt="驱动 Doodles AI 的 ComfyUI 工作流" caption="开源工作流驱动品牌级生成输出。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="编码 DNA">
|
||||
|
||||
Doodles AI 以 PRISM 1.0 作为图像到图像模型推出,但路线图雄心勃勃:2D 和 3D 输出生成、带声音的视频、实时 AR 和游戏应用。原始 Doodles 持有者在发布当天获得 100 次免费生成——这是一个有意识的举措,旨在为社区注入活力,让他们用平台的输出刷遍每一条时间线。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/dna.webp" alt="Doodles AI 输出示例" caption="Doodles AI 输出展示品牌保真的生成结果。" />
|
||||
|
||||
更深层的布局是与整个 AI 行业的速度和规模保持一致。通过在开源基础设施上构建并培育共创者社区,Doodles 已将自己定位为可以将其"编码 DNA"接入尚未存在的未来技术。这是一个赌注:开放性——开源、开放叙事、开放创造——不仅在哲学上有吸引力,而且在战略上是明智的。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="对艺术家意味着什么">
|
||||
|
||||
对于在场外观望的艺术家来说,信息很明确:构建模块已经就位,社区正在建设,创作者和消费者之间的界限正在消失。问题不在于开源是否会重塑创意产业。而在于当它发生时,你是否在用它构建。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/open-story-movement/output.webp" alt="Doodles AI 创意输出" caption="开源工具大规模驱动品牌级创意输出。" />
|
||||
|
||||
<Contributors label="链接" people={[{"name":"Doodles: doodles.app | SYSTMS: systms.ai | ComfyUI: comfy.org","role":"官方网站"}]} />
|
||||
|
||||
</Section>
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "Series Entertainment 如何使用 ComfyUI 重塑游戏和视频制作"
|
||||
category: "游戏与视频制作"
|
||||
description: "使用可复用的 ComfyUI 生产系统,在 100,000+ 资产和多部 Netflix 作品中实现情感叙事的规模化。"
|
||||
cover: "https://media.comfy.org/website/customers/series-entertainment/cover.webp"
|
||||
order: 0
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "简介"
|
||||
- id: topic-2
|
||||
label: "产出成果"
|
||||
- id: topic-3
|
||||
label: "面临的问题"
|
||||
- id: topic-4
|
||||
label: "解决方案"
|
||||
- id: topic-5
|
||||
label: "为何选择 ComfyUI"
|
||||
- id: topic-6
|
||||
label: "总结"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
Series Entertainment 构建以故事为驱动的游戏和短视频体验,其中角色、情感和视觉一致性至关重要。随着工作范围扩展到内部项目、合作伙伴协作和 Netflix 作品,团队面临日益增长的挑战:他们需要在更多项目中生产更多内容,同时不能放慢速度或失去一致性。
|
||||
|
||||
为了应对这一挑战,Series 利用 ComfyUI 扩展了工作流。通过在 ComfyUI 之上构建自定义的可复用工作流,Series 改变了创建角色、情感和视频的方式。最终打造出一个支持超过 100,000 个资产、交付 Netflix 游戏并持续为多个在研项目提供动力的可扩展生产系统。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/series.webp" alt="Series Entertainment 游戏作品,包括 Olympus Rising、Gilded Scales、Evergrove 和 The Wandering Teahouse" caption="Series Entertainment 制作跨多个作品和视觉风格的故事驱动游戏和视频体验。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="Series 使用 ComfyUI 达成的产出成果">
|
||||
|
||||
将 ComfyUI 集成到生产工作流后,Series 实现了:
|
||||
|
||||
- 在游戏和视频中生成超过 100,000 个资产
|
||||
- 180 倍的生产速度提升
|
||||
- 数秒内生成六种不同的角色情感
|
||||
- 每位创作者每周生产 15 分钟的最终视频
|
||||
- 多部 Netflix 作品交付,更多体验正在积极开发中
|
||||
|
||||
这些产出涵盖角色资产、情感变体、背景一致性和短视频——全部通过可复用的 ComfyUI 工作流创建。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="Series 试图解决的问题">
|
||||
|
||||
Series 的工作依赖于富有表现力的角色和一致的视觉标识。随着项目规模和复杂度的增长,团队需要一种在不打破时间线的前提下扩展内容创作的方法。
|
||||
|
||||
传统动画工作流依赖手动关键帧、多个断开的工具和漫长的制作周期——每个视频可能需要数周。制作变体通常意味着从头返工,实验过程缓慢且昂贵。
|
||||
|
||||
Series 需要能够在团队和项目间复用的工作流,同时仍然支持情感叙事、角色一致性和快速迭代。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="Series 如何使用 ComfyUI 解决问题">
|
||||
|
||||
Series 围绕 ComfyUI 的节点式工作流系统重建了制作流程。他们不再将生成视为一次性步骤,而是将工作流作为长期生产资产。ComfyUI 成为了创意结构的所在——从角色创建到情感生成再到视频输出。
|
||||
|
||||
### 规模化情感生成
|
||||
|
||||
Series 使用 ComfyUI 构建了一个自定义头像系统,可在数秒内生成六种不同的情感:开心、悲伤、严肃、讽刺、思考和惊讶。这使得创建具有多种情感状态的表现力角色成为可能,而无需手动重新创建每个变体。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/panel.webp" alt="ComfyUI 表情编辑器节点,用于面部表情操控" caption="ComfyUI 中的表情编辑器节点实现了对角色情感的精细控制。" />
|
||||
|
||||
### 从测试到生产的可复用管线
|
||||
|
||||
利用 ComfyUI 的模块化节点系统,Series 构建了四条精简管线,支持从早期探索到最终输出的完整生产周期。这些工作流的效率比传统手工流程(每个资产可能需要六小时以上)**提高了 180 倍**,同时保持生产品质。
|
||||
|
||||
管线范围从快速的 512×512 单情感测试到高分辨率批量生成,使团队能够快速实验并使用相同的工作流直接进入生产。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/workflows.webp" alt="ComfyUI 面部表情操控和放大管线工作流" caption="ComfyUI 工作流展示了并行的表情编辑、放大和面部细化管线。" />
|
||||
|
||||
### 跨游戏和分支叙事的一致性
|
||||
|
||||
在多部 Netflix 作品中,Series 使用 ComfyUI 构建了工作流,确保角色和背景在复杂的分支叙事中保持一致。风格化和一致性管线帮助确保角色在场景、情感和故事路径之间保持视觉统一——即使资产数量不断增长。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/consistency.webp" alt="角色在多个场景和情感状态中保持一致" caption="使用 ComfyUI 一致性管线在六个不同场景和情感状态中保持同一角色。" />
|
||||
|
||||
### 使用 ComfyUI 实现规模化生产
|
||||
|
||||
Series 还将 ComfyUI 作为 AI 辅助动画管线的一部分,将故事开发直接连接到图像和视频生成。该管线包含机器人辅助视频生成,允许创作者反复运行相同的工作流以高效生产视频。使用这种方法,每位创作者可以规模化生成 Lorespark 视频,每周交付超过 **15 分钟的最终视频**。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/batch.webp" alt="ComfyUI 使用 Nano Banana 和 Google Gemini 的批处理工作流" caption="批处理工作流将多个角色图像连接到 Nano Banana,实现风格一致的生成。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="为什么 ComfyUI 适合 Series">
|
||||
|
||||
ComfyUI 之所以有效,是因为其节点式结构使工作流显式且可复用——一旦构建了工作流,就可以在项目间优化和共享。这使 Series 能够将视频生成从一次性过程转变为可重复的系统。
|
||||
|
||||
批量执行和机器人集成使这些工作流能够大规模运行。由于相同的工作流同时支持低分辨率测试和高分辨率最终输出,团队可以从探索无缝过渡到交付,无需切换工具或重建管线。
|
||||
|
||||
最重要的是,ComfyUI 让 Series 专注于构建结构,而非依赖试错式提示。情感、一致性和生产逻辑都存在于工作流本身之中。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/series-entertainment/scale.webp" alt="以一致风格生成的同一角色的六个变体" caption="同一角色的多个姿态和表情变体,在保持视觉一致性的同时实现规模化生成。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="总结">
|
||||
|
||||
通过将 ComfyUI 作为核心创意平台,Series Entertainment 彻底改变了游戏和视频的制作方式。最初只是对规模和一致性的需求,最终演变成一个以工作流驱动的生产系统,支持情感叙事、大规模资产和多团队的持续开发。
|
||||
|
||||
<Quote name="Series Entertainment">对 Series 来说,ComfyUI 不是实验。它就是娱乐内容的制作方式。</Quote>
|
||||
|
||||
</Section>
|
||||
101
apps/website/src/content/customers/zh-CN/ubisoft-chord.mdx
Normal file
101
apps/website/src/content/customers/zh-CN/ubisoft-chord.mdx
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: "育碧开源 CHORD 模型,通过 ComfyUI 实现 AAA 级 PBR 材质生成"
|
||||
category: "AAA 游戏制作"
|
||||
description: "育碧 La Forge 开源了 CHORD PBR 材质估算模型及 ComfyUI 自定义节点,为 AAA 游戏制作实现了端到端的纹理生成工作流。"
|
||||
cover: "https://media.comfy.org/website/customers/ubisoft/cover.webp"
|
||||
order: 3
|
||||
readMore: "https://blog.comfy.org/p/ubisoft-open-sources-the-chord-model"
|
||||
sections:
|
||||
- id: topic-1
|
||||
label: "简介"
|
||||
- id: topic-2
|
||||
label: "挑战"
|
||||
- id: topic-3
|
||||
label: "为什么选择 ComfyUI"
|
||||
- id: topic-4
|
||||
label: "流水线"
|
||||
- id: topic-5
|
||||
label: "试用"
|
||||
- id: topic-6
|
||||
label: "成果"
|
||||
---
|
||||
|
||||
<Section id="topic-1">
|
||||
|
||||
育碧 La Forge 开源了其 PBR 材质估算模型 **CHORD(Chain of Rendering Decomposition)**,以及 **ComfyUI-Chord** 自定义节点实现,用于构建端到端的 AI 材质生成工作流。
|
||||
|
||||
模型权重和代码以仅限研究的许可证发布。除了研究之外,这是将 ComfyUI 集成到 AAA 级视频游戏制作工作流中的重要一步。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/cover.webp" alt="ComfyUI 中的 CHORD PBR 材质生成" caption="使用 ComfyUI 中的 CHORD 模型生成的 PBR 材质。" />
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-2" title="当今 AAA 游戏中的 PBR 材质制作">
|
||||
|
||||
在 AAA 游戏开发中,PBR 材质是视觉真实感的基础。大型游戏需要数百种可复用的材质,每种都包含完整的基础颜色、法线、高度、粗糙度和金属度贴图,并须满足严格的 svBRDF 标准。
|
||||
|
||||
传统上,这些资产由纹理艺术家使用摄影测量、程序化工具和大量手动调整来制作——这使得流程耗时且高度依赖专业知识。
|
||||
|
||||
育碧的生成式基础材质原型直接针对这一制作瓶颈。ComfyUI 工作流输出的 PBR 纹理集可直接集成到 DCC 工具和游戏引擎中,用于原型制作和占位资产。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-3" title="育碧为何选择 ComfyUI 作为工作流平台">
|
||||
|
||||
育碧选择 ComfyUI 源于生产实际需求。对于大型工作室来说,需要的不是另一个图像生成器——而是一个可控且可集成的 AI 工作流平台,能够满足游戏开发的定制需求。
|
||||
|
||||
<Quote name="育碧 La Forge 博客">考虑到我们原型的多阶段特性,ComfyUI 为我们提供了一个高效的框架来构建集成工作流,涵盖纹理图像合成、材质估算和材质放大。这也使我们能够利用最先进的生成模型和 ComfyUI 的强大功能,通过 ControlNet、图像引导、修复等众多选项为创作者提供精细控制。</Quote>
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-4" title="生成式基础材质流水线的三个阶段">
|
||||
|
||||
CHORD 模型集成在一个更广泛的流水线中,由三个核心阶段组成。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/pipeline.webp" alt="三阶段生成式基础材质流水线" caption="三阶段生成式基础材质流水线:纹理生成、CHORD 估算和放大。" />
|
||||
|
||||
### 阶段一 — 纹理图像生成
|
||||
|
||||
第一阶段使用具有完全条件控制的自定义扩散模型,从文本提示或参考输入(如线稿和高度图)生成无缝、可平铺的 2D 纹理。
|
||||
|
||||
### 阶段二 — CHORD 图像到材质估算
|
||||
|
||||
将单一纹理转换为完整的 PBR 贴图集——包括基础颜色、法线、高度、粗糙度和金属度——使用链式分解、统一多模态预测和高效的单步扩散推理,实现可控且可扩展的结果。
|
||||
|
||||
### 阶段三 — 材质放大
|
||||
|
||||
由于 CHORD 在 1024 分辨率下运行最佳,第三阶段应用工业级 PBR 放大。所有通道放大 2 倍或 4 倍,以生成用于实时游戏制作的 2K 和 4K 纹理资产。
|
||||
|
||||
这条完整的流水线使艺术家能够快速迭代创意,在现有工作流中混合搭配 AI 生成的输出,降低了工业级 PBR 材质创建的门槛。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-5" title="如何在 ComfyUI 中试用 CHORD">
|
||||
|
||||
育碧开源了 CHORD 模型权重、ComfyUI 自定义节点和示例工作流,涵盖流水线中的纹理图像生成阶段和图像到材质估算阶段。
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/workflow.webp" alt="ComfyUI 中的 CHORD 示例工作流" caption="ComfyUI 中端到端 PBR 材质生成的 CHORD 示例工作流。" />
|
||||
|
||||
<Steps items={["安装或更新 ComfyUI 至最新版本","从育碧安装 CHORD ComfyUI 自定义节点","下载 CHORD 模型并放置在 ./ComfyUI/models/checkpoints 目录","在 ComfyUI 中加载 CHORD 示例工作流"]} />
|
||||
|
||||
您可以将纹理图像生成模型替换为任何其他图像模型,也可以单独使用每个阶段的工作流模块。
|
||||
|
||||
</Section>
|
||||
|
||||
<Section id="topic-6" title="输出示例">
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example1.webp" alt="CHORD PBR 材质输出示例 1" caption="生成的 PBR 材质集,展示基础颜色、法线、高度、粗糙度和金属度贴图。" />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example2.webp" alt="CHORD PBR 材质输出示例 2" caption="另一组生成的 PBR 材质集,展示 CHORD 可实现的多样纹理效果。" />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example3.webp" alt="CHORD PBR 材质输出示例 3" caption="具有完整 PBR 通道分解的材质生成输出。" />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example4.webp" alt="CHORD PBR 材质输出示例 4" caption="从单一输入纹理生成的高质量 PBR 纹理集。" />
|
||||
|
||||
<Figure src="https://media.comfy.org/website/customers/ubisoft/example5.webp" alt="CHORD PBR 材质输出示例 5" caption="最终渲染的 PBR 材质,展示可用于生产的质量。" />
|
||||
|
||||
CHORD 的发布表明,ComfyUI 已从一个社区驱动的工具成长为一个真正的生产平台。工作室用户可以构建端到端流水线,从提示或参考输入到纹理生成、材质估算、PBR 放大,最终导出到 DCC 工具或游戏引擎。每个阶段也可以独立运行并嵌入现有的生产系统中。
|
||||
|
||||
<Contributors label="作者" people={[{"name":"Jo Zhang","role":"ComfyUI 博客"},{"name":"Daxiong (Lin)","role":"ComfyUI 博客"}]} />
|
||||
|
||||
</Section>
|
||||
2
apps/website/src/env.d.ts
vendored
2
apps/website/src/env.d.ts
vendored
@@ -1 +1 @@
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,19 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ContactSection from '../components/customers/ContactSection.vue'
|
||||
import FeedbackSection from '../components/customers/FeedbackSection.vue'
|
||||
import HeroSection from '../components/customers/HeroSection.vue'
|
||||
import StorySection from '../components/customers/StorySection.vue'
|
||||
import FeedbackSection from '../components/customers/FeedbackSection.vue'
|
||||
import VideoSection from '../components/customers/VideoSection.vue'
|
||||
import ContactSection from '../components/customers/ContactSection.vue'
|
||||
import { toCardProps } from '../utils/customers'
|
||||
import { loadStories } from '../utils/loadStories'
|
||||
|
||||
const stories = (await loadStories('en')).map(toCardProps)
|
||||
---
|
||||
|
||||
<BaseLayout title="Customer Stories — Comfy">
|
||||
<HeroSection client:load />
|
||||
<StorySection />
|
||||
<StorySection stories={stories} />
|
||||
<FeedbackSection client:load />
|
||||
<VideoSection client:load />
|
||||
<ContactSection />
|
||||
|
||||
@@ -1,39 +1,33 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import CustomerArticle from '../../components/customers/CustomerArticle.astro'
|
||||
import DetailHeroSection from '../../components/customers/DetailHeroSection.vue'
|
||||
import ContentSection from '../../components/common/ContentSection.vue'
|
||||
import WhatsNextSection from '../../components/customers/WhatsNextSection.vue'
|
||||
import { customerStories, getNextStory, getStoryBySlug } from '../../config/customerStories'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import { nextStory, storySlug } from '../../utils/customers'
|
||||
import { loadStories } from '../../utils/loadStories'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return customerStories.map((story) => ({
|
||||
params: { slug: story.slug }
|
||||
export async function getStaticPaths() {
|
||||
const stories = await loadStories('en')
|
||||
return stories.map((entry) => ({
|
||||
params: { slug: storySlug(entry.id) },
|
||||
props: { entry, next: nextStory(stories, storySlug(entry.id)) }
|
||||
}))
|
||||
}
|
||||
|
||||
const { slug } = Astro.params
|
||||
const story = getStoryBySlug(slug as string)!
|
||||
const title = t(story.title)
|
||||
const nextStory = getNextStory(slug as string)
|
||||
const { entry, next } = Astro.props
|
||||
---
|
||||
|
||||
<BaseLayout title={`${title} — Comfy`}>
|
||||
<BaseLayout title={`${entry.data.title} — Comfy`}>
|
||||
<DetailHeroSection
|
||||
label={t(story.category)}
|
||||
title={title}
|
||||
description={t(story.body)}
|
||||
image={story.image}
|
||||
/>
|
||||
<ContentSection
|
||||
prefix={story.detailPrefix}
|
||||
readMoreHref={story.readMoreHref}
|
||||
client:load
|
||||
label={entry.data.category}
|
||||
title={entry.data.title}
|
||||
description={entry.data.description}
|
||||
image={entry.data.cover}
|
||||
/>
|
||||
<CustomerArticle entry={entry} />
|
||||
<WhatsNextSection
|
||||
title={t(nextStory.title)}
|
||||
image={nextStory.image}
|
||||
href={`/customers/${nextStory.slug}`}
|
||||
title={next.data.title}
|
||||
image={next.data.cover}
|
||||
href={`/customers/${storySlug(next.id)}`}
|
||||
/>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ContactSection from '../../components/customers/ContactSection.vue'
|
||||
import FeedbackSection from '../../components/customers/FeedbackSection.vue'
|
||||
import HeroSection from '../../components/customers/HeroSection.vue'
|
||||
import StorySection from '../../components/customers/StorySection.vue'
|
||||
import FeedbackSection from '../../components/customers/FeedbackSection.vue'
|
||||
import VideoSection from '../../components/customers/VideoSection.vue'
|
||||
import ContactSection from '../../components/customers/ContactSection.vue'
|
||||
import { toCardProps } from '../../utils/customers'
|
||||
import { loadStories } from '../../utils/loadStories'
|
||||
|
||||
const stories = (await loadStories('zh-CN')).map(toCardProps)
|
||||
---
|
||||
|
||||
<BaseLayout title="客户故事 — Comfy">
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<StorySection locale="zh-CN" />
|
||||
<StorySection stories={stories} locale="zh-CN" />
|
||||
<FeedbackSection locale="zh-CN" client:load />
|
||||
<VideoSection locale="zh-CN" client:load />
|
||||
<ContactSection locale="zh-CN" />
|
||||
|
||||
@@ -1,41 +1,34 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import CustomerArticle from '../../../components/customers/CustomerArticle.astro'
|
||||
import DetailHeroSection from '../../../components/customers/DetailHeroSection.vue'
|
||||
import ContentSection from '../../../components/common/ContentSection.vue'
|
||||
import WhatsNextSection from '../../../components/customers/WhatsNextSection.vue'
|
||||
import { customerStories, getNextStory, getStoryBySlug } from '../../../config/customerStories'
|
||||
import { t } from '../../../i18n/translations'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { nextStory, storySlug } from '../../../utils/customers'
|
||||
import { loadStories } from '../../../utils/loadStories'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return customerStories.map((story) => ({
|
||||
params: { slug: story.slug }
|
||||
export async function getStaticPaths() {
|
||||
const stories = await loadStories('zh-CN')
|
||||
return stories.map((entry) => ({
|
||||
params: { slug: storySlug(entry.id) },
|
||||
props: { entry, next: nextStory(stories, storySlug(entry.id)) }
|
||||
}))
|
||||
}
|
||||
|
||||
const { slug } = Astro.params
|
||||
const story = getStoryBySlug(slug as string)!
|
||||
const title = t(story.title, 'zh-CN')
|
||||
const nextStory = getNextStory(slug as string)
|
||||
const { entry, next } = Astro.props
|
||||
---
|
||||
|
||||
<BaseLayout title={`${title} — Comfy`}>
|
||||
<BaseLayout title={`${entry.data.title} — Comfy`}>
|
||||
<DetailHeroSection
|
||||
label={t(story.category, 'zh-CN')}
|
||||
title={title}
|
||||
description={t(story.body, 'zh-CN')}
|
||||
image={story.image}
|
||||
/>
|
||||
<ContentSection
|
||||
prefix={story.detailPrefix}
|
||||
locale="zh-CN"
|
||||
readMoreHref={story.readMoreHref}
|
||||
client:load
|
||||
label={entry.data.category}
|
||||
title={entry.data.title}
|
||||
description={entry.data.description}
|
||||
image={entry.data.cover}
|
||||
/>
|
||||
<CustomerArticle entry={entry} locale="zh-CN" />
|
||||
<WhatsNextSection
|
||||
title={t(nextStory.title, 'zh-CN')}
|
||||
image={nextStory.image}
|
||||
href={`/zh-CN/customers/${nextStory.slug}`}
|
||||
title={next.data.title}
|
||||
image={next.data.cover}
|
||||
href={`/zh-CN/customers/${storySlug(next.id)}`}
|
||||
locale="zh-CN"
|
||||
/>
|
||||
</BaseLayout>
|
||||
|
||||
128
apps/website/src/utils/customers.test.ts
Normal file
128
apps/website/src/utils/customers.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { customerStorySchema } from '../content/customers.schema'
|
||||
import { nextStory, sortStories, storySlug, toCardProps } from './customers'
|
||||
|
||||
const validFrontmatter = {
|
||||
title:
|
||||
'How Series Entertainment Rebuilt Game and Video Production with ComfyUI',
|
||||
category: 'GAME & VIDEO PRODUCTION',
|
||||
description: 'Scaling emotional storytelling across 100,000+ assets.',
|
||||
cover:
|
||||
'https://media.comfy.org/website/customers/series-entertainment/cover.webp',
|
||||
order: 0,
|
||||
sections: [
|
||||
{ id: 'intro', label: 'INTRO' },
|
||||
{ id: 'the-problem', label: 'THE PROBLEM' }
|
||||
]
|
||||
}
|
||||
|
||||
describe('customerStorySchema', () => {
|
||||
it('accepts a complete, valid story frontmatter', () => {
|
||||
expect(customerStorySchema.safeParse(validFrontmatter).success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts an optional external readMore url', () => {
|
||||
const result = customerStorySchema.safeParse({
|
||||
...validFrontmatter,
|
||||
readMore: 'https://blog.comfy.org/p/example'
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects frontmatter missing a required field', () => {
|
||||
const { title: _title, ...withoutTitle } = validFrontmatter
|
||||
expect(customerStorySchema.safeParse(withoutTitle).success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a cover that is not a url', () => {
|
||||
const result = customerStorySchema.safeParse({
|
||||
...validFrontmatter,
|
||||
cover: 'cover.webp'
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('requires each section to declare an id and a label', () => {
|
||||
const result = customerStorySchema.safeParse({
|
||||
...validFrontmatter,
|
||||
sections: [{ id: 'intro' }]
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unknown frontmatter keys so typos fail the build', () => {
|
||||
const result = customerStorySchema.safeParse({
|
||||
...validFrontmatter,
|
||||
readMoreHref: 'https://blog.comfy.org/p/example'
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('storySlug', () => {
|
||||
it('drops the locale prefix from a collection id', () => {
|
||||
expect(storySlug('en/series-entertainment')).toBe('series-entertainment')
|
||||
expect(storySlug('zh-CN/groove-jones')).toBe('groove-jones')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortStories', () => {
|
||||
it('orders stories by their order field ascending', () => {
|
||||
const stories = [
|
||||
{ id: 'en/c', data: { order: 2 } },
|
||||
{ id: 'en/a', data: { order: 0 } },
|
||||
{ id: 'en/b', data: { order: 1 } }
|
||||
]
|
||||
expect(sortStories(stories).map((s) => s.id)).toEqual([
|
||||
'en/a',
|
||||
'en/b',
|
||||
'en/c'
|
||||
])
|
||||
})
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const stories = [
|
||||
{ id: 'en/b', data: { order: 1 } },
|
||||
{ id: 'en/a', data: { order: 0 } }
|
||||
]
|
||||
sortStories(stories)
|
||||
expect(stories.map((s) => s.id)).toEqual(['en/b', 'en/a'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextStory', () => {
|
||||
const ordered = [
|
||||
{ id: 'en/a', data: { order: 0 } },
|
||||
{ id: 'en/b', data: { order: 1 } },
|
||||
{ id: 'en/c', data: { order: 2 } }
|
||||
]
|
||||
|
||||
it('returns the following story', () => {
|
||||
expect(nextStory(ordered, 'a').id).toBe('en/b')
|
||||
})
|
||||
|
||||
it('wraps around from the last story to the first', () => {
|
||||
expect(nextStory(ordered, 'c').id).toBe('en/a')
|
||||
})
|
||||
|
||||
it('throws when no story matches the slug', () => {
|
||||
expect(() => nextStory(ordered, 'missing')).toThrow()
|
||||
})
|
||||
|
||||
it('throws when the list is empty', () => {
|
||||
expect(() => nextStory([], 'a')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toCardProps', () => {
|
||||
it('maps a story entry to listing-card props', () => {
|
||||
const entry = { id: 'en/series-entertainment', data: validFrontmatter }
|
||||
expect(toCardProps(entry)).toEqual({
|
||||
slug: 'series-entertainment',
|
||||
title: validFrontmatter.title,
|
||||
category: validFrontmatter.category,
|
||||
cover: validFrontmatter.cover
|
||||
})
|
||||
})
|
||||
})
|
||||
48
apps/website/src/utils/customers.ts
Normal file
48
apps/website/src/utils/customers.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { CollectionEntry } from 'astro:content'
|
||||
|
||||
import type { CustomerStoryFrontmatter } from '../content/customers.schema'
|
||||
|
||||
export type CustomerStoryEntry = CollectionEntry<'customers'>
|
||||
|
||||
export function storySlug(id: string): string {
|
||||
const separator = id.indexOf('/')
|
||||
return separator === -1 ? id : id.slice(separator + 1)
|
||||
}
|
||||
|
||||
export function sortStories<T extends { data: { order: number } }>(
|
||||
stories: T[]
|
||||
): T[] {
|
||||
return [...stories].sort((a, b) => a.data.order - b.data.order)
|
||||
}
|
||||
|
||||
export function nextStory<T extends { id: string }>(
|
||||
ordered: T[],
|
||||
slug: string
|
||||
): T {
|
||||
const index = ordered.findIndex((story) => storySlug(story.id) === slug)
|
||||
// Fail loud on a bad slug or empty list rather than silently returning the
|
||||
// first story, which would link to the wrong "what's next" article.
|
||||
if (index === -1) {
|
||||
throw new Error(`nextStory: no story found for slug "${slug}"`)
|
||||
}
|
||||
return ordered[(index + 1) % ordered.length]
|
||||
}
|
||||
|
||||
export interface StoryCard {
|
||||
slug: string
|
||||
title: string
|
||||
category: string
|
||||
cover: string
|
||||
}
|
||||
|
||||
export function toCardProps(entry: {
|
||||
id: string
|
||||
data: CustomerStoryFrontmatter
|
||||
}): StoryCard {
|
||||
return {
|
||||
slug: storySlug(entry.id),
|
||||
title: entry.data.title,
|
||||
category: entry.data.category,
|
||||
cover: entry.data.cover
|
||||
}
|
||||
}
|
||||
17
apps/website/src/utils/loadStories.ts
Normal file
17
apps/website/src/utils/loadStories.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { getCollection } from 'astro:content'
|
||||
|
||||
import type { Locale } from '../i18n/translations'
|
||||
import type { CustomerStoryEntry } from './customers'
|
||||
import { sortStories } from './customers'
|
||||
|
||||
// Loads a locale's customer stories from the content collection, sorted by the
|
||||
// frontmatter `order`. Centralises the `<locale>/` id-prefix convention so the
|
||||
// listing and detail pages do not each hardcode it.
|
||||
export async function loadStories(
|
||||
locale: Locale
|
||||
): Promise<CustomerStoryEntry[]> {
|
||||
const stories = await getCollection('customers', ({ id }) =>
|
||||
id.startsWith(`${locale}/`)
|
||||
)
|
||||
return sortStories(stories)
|
||||
}
|
||||
45
browser_tests/assets/linear-validation-warning.json
Normal file
45
browser_tests/assets/linear-validation-warning.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": {
|
||||
"0": 64,
|
||||
"1": 104
|
||||
},
|
||||
"size": {
|
||||
"0": 210,
|
||||
"1": 58
|
||||
},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"linearData": {
|
||||
"inputs": [],
|
||||
"outputs": ["9"]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -34,6 +34,10 @@ export class AppModeHelper {
|
||||
public readonly outputPlaceholder: Locator
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
public readonly linearWidgets: Locator
|
||||
/** The validation warning shown above the app mode run button. */
|
||||
public readonly validationWarning: Locator
|
||||
/** The action that opens graph mode errors from the validation warning. */
|
||||
public readonly viewErrorsInGraphButton: Locator
|
||||
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
|
||||
public readonly imagePickerPopover: Locator
|
||||
/** The Run button in the app mode footer. */
|
||||
@@ -92,13 +96,19 @@ export class AppModeHelper {
|
||||
this.outputPlaceholder = this.page.getByTestId(
|
||||
TestIds.builder.outputPlaceholder
|
||||
)
|
||||
this.linearWidgets = this.page.getByTestId('linear-widgets')
|
||||
this.linearWidgets = this.page.getByTestId(TestIds.linear.widgetContainer)
|
||||
this.validationWarning = this.page.getByTestId(
|
||||
TestIds.linear.validationWarning
|
||||
)
|
||||
this.viewErrorsInGraphButton = this.validationWarning.getByTestId(
|
||||
TestIds.linear.viewErrorsInGraph
|
||||
)
|
||||
this.imagePickerPopover = this.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
this.runButton = this.page
|
||||
.getByTestId('linear-run-button')
|
||||
.getByTestId(TestIds.linear.runButton)
|
||||
.getByRole('button', { name: /run/i })
|
||||
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
|
||||
this.emptyWorkflowText = this.page.getByTestId(
|
||||
|
||||
@@ -80,19 +80,23 @@ class HelpCenterHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept the Zendesk support URL so it never actually loads in the
|
||||
* new tab opened by the Contact Support command.
|
||||
* Intercept the Pylon support URL (and the legacy Zendesk one for safety)
|
||||
* so it never actually loads in the new tab opened by the Contact Support
|
||||
* command.
|
||||
*/
|
||||
async stubSupportPage(): Promise<void> {
|
||||
await this.page
|
||||
.context()
|
||||
.route('https://support.comfy.org/**', (route: Route) =>
|
||||
for (const pattern of [
|
||||
'https://comfy-org.portal.usepylon.com/**',
|
||||
'https://support.comfy.org/**'
|
||||
]) {
|
||||
await this.page.context().route(pattern, (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: '<html></html>'
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -172,6 +172,9 @@ export const TestIds = {
|
||||
mobileNavigation: 'linear-mobile-navigation',
|
||||
mobileWorkflows: 'linear-mobile-workflows',
|
||||
outputInfo: 'linear-output-info',
|
||||
runButton: 'linear-run-button',
|
||||
validationWarning: 'linear-validation-warning',
|
||||
viewErrorsInGraph: 'linear-view-errors',
|
||||
widgetContainer: 'linear-widgets'
|
||||
},
|
||||
builder: {
|
||||
|
||||
106
browser_tests/tests/appModeValidationWarning.spec.ts
Normal file
106
browser_tests/tests/appModeValidationWarning.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const SAVE_IMAGE_NODE_ID = '9'
|
||||
|
||||
function buildSaveImageRequiredInputError(): NodeError {
|
||||
return {
|
||||
class_type: 'SaveImage',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing: images',
|
||||
details: '',
|
||||
extra_info: { input_name: 'images' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'App mode validation warning',
|
||||
{ tag: ['@ui', '@workflow'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await enableErrorsOverlay(comfyPage)
|
||||
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens graph errors from the app mode validation warning', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await expect(comfyPage.appMode.validationWarning).toBeHidden()
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
const appModeOverlay = comfyPage.appMode.centerPanel.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(appModeOverlay).toBeHidden()
|
||||
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.validationWarning).toContainText(
|
||||
/Required input missing/i
|
||||
)
|
||||
await expect(comfyPage.appMode.viewErrorsInGraphButton).toBeVisible()
|
||||
|
||||
await comfyPage.appMode.viewErrorsInGraphButton.click()
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps the app mode run button enabled when the warning is visible', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
await exec.mockValidationFailure({
|
||||
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
|
||||
})
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
await expect(comfyPage.appMode.validationWarning).toBeVisible()
|
||||
await expect(comfyPage.appMode.runButton).toBeEnabled()
|
||||
|
||||
let promptQueued = false
|
||||
const mockResponse: PromptResponse = {
|
||||
prompt_id: 'test-id',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
}
|
||||
await comfyPage.page.route(
|
||||
'**/api/prompt',
|
||||
async (route) => {
|
||||
promptQueued = true
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
},
|
||||
{ times: 1 }
|
||||
)
|
||||
|
||||
await comfyPage.appMode.runButton.click()
|
||||
|
||||
await expect.poll(() => promptQueued).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -103,14 +103,14 @@ test.describe('Settings', () => {
|
||||
})
|
||||
|
||||
test.describe('Support', () => {
|
||||
test('Should open external zendesk link with OSS tag', async ({
|
||||
test('Should open Pylon question form with OSS environment tag', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
// Prevent loading the external page
|
||||
await comfyPage.page
|
||||
.context()
|
||||
.route('https://support.comfy.org/**', (route) =>
|
||||
.route('https://comfy-org.portal.usepylon.com/**', (route) =>
|
||||
route.fulfill({ body: '<html></html>', contentType: 'text/html' })
|
||||
)
|
||||
|
||||
@@ -119,8 +119,9 @@ test.describe('Support', () => {
|
||||
const popup = await popupPromise
|
||||
|
||||
const url = new URL(popup.url())
|
||||
expect(url.hostname).toBe('support.comfy.org')
|
||||
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
|
||||
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
|
||||
expect(url.pathname).toBe('/forms/question')
|
||||
expect(url.searchParams.get('comfy_environment')).toBe('oss')
|
||||
|
||||
await popup.close()
|
||||
})
|
||||
|
||||
@@ -122,9 +122,15 @@ test.describe('Error dialog', () => {
|
||||
await popup.close()
|
||||
})
|
||||
|
||||
test('Should open contact support when "Help Fix This" is clicked', async ({
|
||||
test('Should open the Pylon bug-report form when "Help Fix This" is clicked', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page
|
||||
.context()
|
||||
.route('https://comfy-org.portal.usepylon.com/**', (route) =>
|
||||
route.fulfill({ body: '<html></html>', contentType: 'text/html' })
|
||||
)
|
||||
|
||||
const errorDialog = await triggerConfigureError(comfyPage)
|
||||
await expect(errorDialog).toBeVisible()
|
||||
|
||||
@@ -133,7 +139,9 @@ test.describe('Error dialog', () => {
|
||||
)
|
||||
|
||||
const url = new URL(popup.url())
|
||||
expect(url.hostname).toBe('support.comfy.org')
|
||||
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
|
||||
expect(url.pathname).toBe('/forms/report-a-bug')
|
||||
expect(url.searchParams.get('product_area')).toBe('Workflow Error')
|
||||
|
||||
await popup.close()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -15,9 +16,10 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.links.get(1)?.target_slot
|
||||
})
|
||||
comfyPage.page.evaluate(
|
||||
(linkId) => window.app!.graph!.links.get(linkId)?.target_slot,
|
||||
toLinkId(1)
|
||||
)
|
||||
)
|
||||
.toBe(1)
|
||||
})
|
||||
|
||||
@@ -99,26 +99,28 @@ test.describe('Help Center', () => {
|
||||
expect(url.pathname).toBe('/Comfy-Org/ComfyUI')
|
||||
})
|
||||
|
||||
test('Help & Support item opens the Zendesk support form with OSS tag', async ({
|
||||
test('Help & Support item opens the Pylon question form tagged as OSS', async ({
|
||||
helpCenter
|
||||
}) => {
|
||||
const url = await waitForPopup(helpCenter.page, () =>
|
||||
helpCenter.menuItem('help').click()
|
||||
)
|
||||
|
||||
expect(url.hostname).toBe('support.comfy.org')
|
||||
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
|
||||
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
|
||||
expect(url.pathname).toBe('/forms/question')
|
||||
expect(url.searchParams.get('comfy_environment')).toBe('oss')
|
||||
})
|
||||
|
||||
test('Give Feedback item opens Contact Support in OSS mode', async ({
|
||||
test('Give Feedback item opens the Pylon question form in OSS mode', async ({
|
||||
helpCenter
|
||||
}) => {
|
||||
const url = await waitForPopup(helpCenter.page, () =>
|
||||
helpCenter.menuItem('feedback').click()
|
||||
)
|
||||
|
||||
expect(url.hostname).toBe('support.comfy.org')
|
||||
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
|
||||
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
|
||||
expect(url.pathname).toBe('/forms/question')
|
||||
expect(url.searchParams.get('comfy_environment')).toBe('oss')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Displays linear controls when app mode active', async ({
|
||||
@@ -16,7 +17,9 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.linear.runButton)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
|
||||
@@ -420,6 +420,14 @@ export default defineConfig([
|
||||
'@intlify/vue-i18n/no-raw-text': 'off'
|
||||
}
|
||||
},
|
||||
// Astro exposes virtual modules (astro:content, astro:assets, ...) that the
|
||||
// TypeScript resolver cannot see but are valid at build time.
|
||||
{
|
||||
files: ['apps/website/**/*.{ts,mts,vue}'],
|
||||
rules: {
|
||||
'import-x/no-unresolved': ['error', { ignore: ['^astro:'] }]
|
||||
}
|
||||
},
|
||||
// i18n import enforcement
|
||||
// Vue components must use the useI18n() composable, not the global t/d/st/te
|
||||
{
|
||||
|
||||
1
global.d.ts
vendored
1
global.d.ts
vendored
@@ -5,7 +5,6 @@ declare const __SENTRY_DSN__: string
|
||||
declare const __ALGOLIA_APP_ID__: string
|
||||
declare const __ALGOLIA_API_KEY__: string
|
||||
declare const __USE_PROD_CONFIG__: boolean
|
||||
declare const __GIT_BRANCH_PREFIX__: string
|
||||
|
||||
interface ImpactQueueFunction {
|
||||
(...args: unknown[]): void
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
|
||||
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:coverage:critical": "cross-env COVERAGE_CRITICAL=true vitest run --coverage",
|
||||
"test:unit": "vitest run",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
|
||||
@@ -114,14 +115,12 @@
|
||||
"jsonata": "catalog:",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
"motion-v": "catalog:",
|
||||
"pinia": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"primeicons": "catalog:",
|
||||
"primevue": "catalog:",
|
||||
"reka-ui": "catalog:",
|
||||
"semver": "^7.7.2",
|
||||
"shiki": "catalog:",
|
||||
"three": "catalog:",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"typegpu": "catalog:",
|
||||
|
||||
643
pnpm-lock.yaml
generated
643
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ publicHoistPattern:
|
||||
catalog:
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
'@astrojs/check': ^0.9.9
|
||||
'@astrojs/mdx': ^6.0.3
|
||||
'@astrojs/sitemap': ^3.7.3
|
||||
'@astrojs/vue': ^6.0.1
|
||||
'@comfyorg/comfyui-electron-types': 0.6.2
|
||||
@@ -104,7 +105,6 @@ catalog:
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
monocart-coverage-reports: ^2.12.9
|
||||
motion-v: ^2.3.0
|
||||
oxfmt: ^0.54.0
|
||||
oxlint: ^1.69.0
|
||||
oxlint-tsgolint: ^0.23.0
|
||||
@@ -117,7 +117,6 @@ catalog:
|
||||
primevue: ^4.2.5
|
||||
reka-ui: 2.5.0
|
||||
rollup-plugin-visualizer: ^6.0.4
|
||||
shiki: ^3.0.0
|
||||
storybook: ^10.2.10
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.3.0
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 173 KiB |
@@ -1,243 +1,5 @@
|
||||
@import '@comfyorg/design-system/css/style.css';
|
||||
|
||||
/* Markdown prose styles for the agent chat, matching Figma DES-455 tokens */
|
||||
.agent-markdown h1,
|
||||
.agent-markdown h2,
|
||||
.agent-markdown p,
|
||||
.agent-markdown ol,
|
||||
.agent-markdown ul,
|
||||
.agent-markdown li,
|
||||
.agent-markdown table {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.agent-markdown h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: normal;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.agent-markdown h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding-top: 0.875rem;
|
||||
padding-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.agent-markdown p {
|
||||
font-size: 0.875rem;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.agent-markdown p:has(> em:only-child) {
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
|
||||
.agent-markdown ol,
|
||||
.agent-markdown ul {
|
||||
font-size: 0.875rem;
|
||||
padding-left: 1.25rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.agent-markdown ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.agent-markdown ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.agent-markdown strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.agent-markdown a {
|
||||
color: var(--color-primary-background);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agent-markdown blockquote {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-left: 3px solid var(--color-border-default);
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.agent-markdown table {
|
||||
width: 100%;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-collapse: collapse;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-secondary-background);
|
||||
}
|
||||
|
||||
.agent-markdown th {
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 0.625rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
background-color: var(--color-secondary-background-hover);
|
||||
}
|
||||
|
||||
.agent-markdown td {
|
||||
padding: 0.625rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.agent-markdown tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.agent-markdown > *:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.agent-markdown > *:last-child {
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Scroll-driven fade mask for conversation containers.
|
||||
Top edge fades in as you scroll away from the start;
|
||||
bottom edge fades out when you reach the end. */
|
||||
@property --sf-top {
|
||||
syntax: '<length>';
|
||||
inherits: false;
|
||||
initial-value: 0;
|
||||
}
|
||||
|
||||
@property --sf-bottom {
|
||||
syntax: '<length>';
|
||||
inherits: false;
|
||||
initial-value: 40px;
|
||||
}
|
||||
|
||||
@keyframes sf-grow-top {
|
||||
from {
|
||||
--sf-top: 0;
|
||||
}
|
||||
to {
|
||||
--sf-top: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sf-shrink-bottom {
|
||||
from {
|
||||
--sf-bottom: 40px;
|
||||
}
|
||||
to {
|
||||
--sf-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-fade {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
black var(--sf-top),
|
||||
black calc(100% - var(--sf-bottom)),
|
||||
transparent 100%
|
||||
);
|
||||
animation:
|
||||
sf-grow-top linear both,
|
||||
sf-shrink-bottom linear both;
|
||||
animation-timeline: scroll(self y), scroll(self y);
|
||||
animation-range:
|
||||
0 80px,
|
||||
calc(100% - 80px) 100%;
|
||||
}
|
||||
|
||||
.agent-code-block {
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.agent-code-block-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background-color: var(--color-secondary-background-hover);
|
||||
border-bottom: 1px solid var(--color-border-default);
|
||||
}
|
||||
|
||||
.agent-code-block-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.6875rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.agent-code-block-filename {
|
||||
color: var(--color-base-foreground);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.agent-code-block-copy {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
font-size: 0.6875rem;
|
||||
font-family: inherit;
|
||||
padding: 0.125rem 0.5rem;
|
||||
line-height: 1.5;
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.agent-code-block-copy:hover {
|
||||
background-color: var(--color-secondary-background);
|
||||
color: var(--color-base-foreground);
|
||||
}
|
||||
|
||||
.agent-code-block pre {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.agent-code-block code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-base-foreground);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.agent-inline-code {
|
||||
background-color: var(--color-secondary-background-hover);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 0.25rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.875em;
|
||||
padding: 0.125rem 0.375rem;
|
||||
}
|
||||
|
||||
@keyframes shimmer-sweep {
|
||||
from {
|
||||
background-position: 100% center;
|
||||
}
|
||||
to {
|
||||
background-position: 0% center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
|
||||
and JS listeners aren't broken. */
|
||||
.disable-animations *,
|
||||
|
||||
@@ -1,154 +1,133 @@
|
||||
<template>
|
||||
<div
|
||||
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-row"
|
||||
class="pointer-events-none absolute top-0 left-0 z-999 flex size-full flex-col"
|
||||
>
|
||||
<!-- Left column: workflow tabs + canvas/panels -->
|
||||
<div class="pointer-events-none flex flex-1 flex-col overflow-hidden">
|
||||
<slot name="workflow-tabs" />
|
||||
<slot name="workflow-tabs" />
|
||||
|
||||
<div
|
||||
class="pointer-events-none flex flex-1 overflow-hidden"
|
||||
:class="{
|
||||
'flex-row': sidebarLocation === 'left',
|
||||
'flex-row-reverse': sidebarLocation === 'right'
|
||||
}"
|
||||
>
|
||||
<div class="side-toolbar-container">
|
||||
<slot name="side-toolbar" />
|
||||
</div>
|
||||
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
|
||||
:state-key="
|
||||
isSelectMode
|
||||
? sidebarLocation === 'left'
|
||||
? 'builder-splitter'
|
||||
: 'builder-splitter-right'
|
||||
: sidebarStateKey
|
||||
"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
@resizeend="normalizeSavedSizes"
|
||||
>
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="firstPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot
|
||||
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right'"
|
||||
name="right-side-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
|
||||
layout="vertical"
|
||||
:pt:gutter="
|
||||
cn(
|
||||
'rounded-t-lg',
|
||||
!(bottomPanelVisible && !focusMode) && 'hidden'
|
||||
)
|
||||
"
|
||||
state-key="bottom-panel-splitter"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel
|
||||
class="graph-canvas-panel relative overflow-visible"
|
||||
>
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="lastPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: agent panel, full viewport height -->
|
||||
<div
|
||||
v-if="agentPanelVisible"
|
||||
class="pointer-events-auto relative h-full shrink-0 overflow-hidden border-l border-interface-stroke bg-comfy-menu-bg"
|
||||
:style="{ width: `${agentPanelWidth}px` }"
|
||||
class="pointer-events-none flex flex-1 overflow-hidden"
|
||||
:class="{
|
||||
'flex-row': sidebarLocation === 'left',
|
||||
'flex-row-reverse': sidebarLocation === 'right'
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="agent-resize-handle absolute top-0 left-0 z-10 h-full w-[5px] cursor-col-resize"
|
||||
:data-resizing="isResizing"
|
||||
@pointerdown="onResizePointerDown"
|
||||
@lostpointercapture="isResizing = false"
|
||||
/>
|
||||
<slot name="agent-panel" />
|
||||
<div class="side-toolbar-container">
|
||||
<slot name="side-toolbar" />
|
||||
</div>
|
||||
|
||||
<Splitter
|
||||
:key="splitterRefreshKey"
|
||||
class="pointer-events-none flex-1 overflow-hidden border-none bg-transparent"
|
||||
:state-key="
|
||||
isSelectMode
|
||||
? sidebarLocation === 'left'
|
||||
? 'builder-splitter'
|
||||
: 'builder-splitter-right'
|
||||
: sidebarStateKey
|
||||
"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
@resizeend="normalizeSavedSizes"
|
||||
>
|
||||
<!-- First panel: sidebar when left, properties when right -->
|
||||
<SplitterPanel
|
||||
v-if="firstPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'left'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="firstPanelStyle"
|
||||
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'left' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot
|
||||
v-if="sidebarLocation === 'left' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right'"
|
||||
name="right-side-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Main panel (always present) -->
|
||||
<SplitterPanel :size="centerPanelDefaultSize" class="flex flex-col">
|
||||
<slot name="topmenu" :sidebar-panel-visible />
|
||||
|
||||
<Splitter
|
||||
class="splitter-overlay-bottom pointer-events-none mx-1 mb-1 flex-1 border-none bg-transparent"
|
||||
layout="vertical"
|
||||
:pt:gutter="
|
||||
cn(
|
||||
'rounded-t-lg',
|
||||
!(bottomPanelVisible && !focusMode) && 'hidden'
|
||||
)
|
||||
"
|
||||
state-key="bottom-panel-splitter"
|
||||
state-storage="local"
|
||||
@resizestart="onResizestart"
|
||||
>
|
||||
<SplitterPanel class="graph-canvas-panel relative overflow-visible">
|
||||
<slot name="graph-canvas-panel" />
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
v-show="bottomPanelVisible && !focusMode"
|
||||
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
|
||||
>
|
||||
<slot name="bottom-panel" />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</SplitterPanel>
|
||||
|
||||
<!-- Last panel: properties when left, sidebar when right -->
|
||||
<SplitterPanel
|
||||
v-if="lastPanelVisible"
|
||||
:class="
|
||||
sidebarLocation === 'right'
|
||||
? cn(
|
||||
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
|
||||
sidebarPanelVisible && 'min-w-78'
|
||||
)
|
||||
: 'pointer-events-auto bg-comfy-menu-bg'
|
||||
"
|
||||
:min-size="
|
||||
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
|
||||
"
|
||||
:size="SIDE_PANEL_SIZE"
|
||||
:style="lastPanelStyle"
|
||||
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
|
||||
:aria-label="
|
||||
sidebarLocation === 'right' ? t('sideToolbar.sidebar') : undefined
|
||||
"
|
||||
>
|
||||
<slot v-if="sidebarLocation === 'left'" name="right-side-panel" />
|
||||
<slot
|
||||
v-else-if="sidebarLocation === 'right' && sidebarPanelVisible"
|
||||
name="side-bar-panel"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import type { SplitterResizeStartEvent } from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
@@ -158,7 +137,6 @@ import {
|
||||
SIDEBAR_MIN_SIZE,
|
||||
SIDE_PANEL_SIZE
|
||||
} from '@/constants/splitterConstants'
|
||||
import { useAgentPanelStore } from '@/platform/agent/stores/agentPanelStore'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
@@ -169,26 +147,6 @@ const workspaceStore = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const agentPanelStore = useAgentPanelStore()
|
||||
const { isOpen: agentPanelVisible, width: agentPanelWidth } =
|
||||
storeToRefs(agentPanelStore)
|
||||
|
||||
const isResizing = ref(false)
|
||||
let resizeStartX = 0
|
||||
let resizeStartWidth = 0
|
||||
|
||||
function onResizePointerDown(e: PointerEvent) {
|
||||
isResizing.value = true
|
||||
resizeStartX = e.clientX
|
||||
resizeStartWidth = agentPanelStore.width
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
useEventListener(document, 'pointermove', (e: PointerEvent) => {
|
||||
if (!isResizing.value) return
|
||||
agentPanelStore.setWidth(resizeStartWidth + (resizeStartX - e.clientX))
|
||||
})
|
||||
const { t } = useI18n()
|
||||
const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
@@ -346,14 +304,4 @@ const lastPanelStyle = computed(() => {
|
||||
.splitter-overlay-bottom :deep(.p-splitter-gutter) {
|
||||
transform: translateY(5px);
|
||||
}
|
||||
|
||||
.agent-resize-handle:hover {
|
||||
transition: background-color 0.2s ease 300ms;
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
|
||||
.agent-resize-handle[data-resizing='true'] {
|
||||
transition: none;
|
||||
background-color: var(--p-primary-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import CodeBlockContainer from './CodeBlockContainer.vue'
|
||||
import CodeBlockContent from './CodeBlockContent.vue'
|
||||
import { CodeBlockKey } from './context'
|
||||
|
||||
const {
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
code: string
|
||||
language: string
|
||||
showLineNumbers?: boolean
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
provide(CodeBlockKey, { code: computed(() => code) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CodeBlockContainer :class="cn('text-xs', className)" :language="language">
|
||||
<slot />
|
||||
<CodeBlockContent
|
||||
:code="code"
|
||||
:language="language"
|
||||
:show-line-numbers="showLineNumbers"
|
||||
/>
|
||||
</CodeBlockContainer>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center gap-1', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, language } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
language: string
|
||||
}>()
|
||||
|
||||
const containerStyle = {
|
||||
contentVisibility: 'auto' as const,
|
||||
containIntrinsicSize: 'auto 200px'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group relative w-full overflow-hidden rounded-md border border-border-default bg-base-background text-base-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
:data-language="language"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,105 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { BundledLanguage, ThemedToken } from 'shiki'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { TokenizedCode } from './utils'
|
||||
import {
|
||||
createRawTokens,
|
||||
highlightCode,
|
||||
isBold,
|
||||
isItalic,
|
||||
isUnderline
|
||||
} from './utils'
|
||||
|
||||
const {
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false
|
||||
} = defineProps<{
|
||||
code: string
|
||||
language: string
|
||||
showLineNumbers?: boolean
|
||||
}>()
|
||||
|
||||
const rawTokens = computed(() => createRawTokens(code))
|
||||
const tokenized = ref<TokenizedCode>(
|
||||
highlightCode(code, language as BundledLanguage) ?? rawTokens.value
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [code, language],
|
||||
() => {
|
||||
tokenized.value =
|
||||
highlightCode(code, language as BundledLanguage) ?? rawTokens.value
|
||||
highlightCode(code, language as BundledLanguage, (result) => {
|
||||
tokenized.value = result
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const preStyle = computed(() => ({
|
||||
color: tokenized.value.fg
|
||||
}))
|
||||
|
||||
interface KeyedToken {
|
||||
token: ThemedToken
|
||||
key: string
|
||||
}
|
||||
interface KeyedLine {
|
||||
tokens: KeyedToken[]
|
||||
key: string
|
||||
}
|
||||
|
||||
const keyedLines = computed<KeyedLine[]>(() =>
|
||||
tokenized.value.tokens.map((line, lineIdx) => ({
|
||||
key: `line-${lineIdx}`,
|
||||
tokens: line.map((token, tokenIdx) => ({
|
||||
token,
|
||||
key: `line-${lineIdx}-${tokenIdx}`
|
||||
}))
|
||||
}))
|
||||
)
|
||||
|
||||
const lineNumberClasses = cn(
|
||||
'block',
|
||||
'before:content-[counter(line)]',
|
||||
'before:inline-block',
|
||||
'before:[counter-increment:line]',
|
||||
'before:w-8',
|
||||
'before:mr-4',
|
||||
'before:text-right',
|
||||
'before:text-muted-foreground/50',
|
||||
'before:font-mono',
|
||||
'before:select-none'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative overflow-auto">
|
||||
<pre
|
||||
class="m-0 overflow-auto bg-base-background p-4 text-sm"
|
||||
:style="preStyle"
|
||||
><code
|
||||
:class="
|
||||
cn(
|
||||
'font-mono text-sm',
|
||||
showLineNumbers && '[counter-increment:line_0] [counter-reset:line]',
|
||||
)
|
||||
"
|
||||
><template v-for="line in keyedLines" :key="line.key"><span :class="showLineNumbers ? lineNumberClasses : 'block'"><span
|
||||
v-for="tokenObj in line.tokens"
|
||||
:key="tokenObj.key"
|
||||
:style="{
|
||||
color: tokenObj.token.color,
|
||||
backgroundColor: tokenObj.token.bgColor,
|
||||
fontStyle: isItalic(tokenObj.token.fontStyle) ? 'italic' : undefined,
|
||||
fontWeight: isBold(tokenObj.token.fontStyle) ? 'bold' : undefined,
|
||||
textDecoration: isUnderline(tokenObj.token.fontStyle)
|
||||
? 'underline'
|
||||
: undefined,
|
||||
}"
|
||||
>{{ tokenObj.token.content }}</span></span></template></code></pre>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,74 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed, inject, onBeforeUnmount, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
import { CodeBlockKey } from './context'
|
||||
|
||||
const { timeout = 2000, class: className } = defineProps<{
|
||||
timeout?: number
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
copy: []
|
||||
error: [error: Error]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const context = inject(CodeBlockKey)
|
||||
if (!context)
|
||||
throw new Error('CodeBlockCopyButton must be used within a CodeBlock')
|
||||
|
||||
const { code } = context
|
||||
const isCopied = ref(false)
|
||||
let resetTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const label = computed(() => (isCopied.value ? t('g.copied') : t('g.copy')))
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (!navigator?.clipboard?.writeText) {
|
||||
emit('error', new Error('Clipboard API not available'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(code.value)
|
||||
isCopied.value = true
|
||||
emit('copy')
|
||||
clearTimeout(resetTimer)
|
||||
resetTimer = setTimeout(() => {
|
||||
isCopied.value = false
|
||||
}, timeout)
|
||||
} catch (error) {
|
||||
emit('error', error instanceof Error ? error : new Error('Copy failed'))
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => clearTimeout(resetTimer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
:class="cn('shrink-0', className)"
|
||||
size="icon-sm"
|
||||
variant="muted-textonly"
|
||||
:aria-label="label"
|
||||
@click="copyToClipboard"
|
||||
>
|
||||
<i
|
||||
:class="isCopied ? 'icon-[lucide--check]' : 'icon-[lucide--copy]'"
|
||||
class="size-3.5"
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="cn('font-mono text-xs font-medium text-base-foreground', className)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,22 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center justify-between border-b border-border-default bg-secondary-background-hover px-3 py-1.5 text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center gap-1.5', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { ComputedRef, InjectionKey } from 'vue'
|
||||
|
||||
export interface CodeBlockContext {
|
||||
code: ComputedRef<string>
|
||||
}
|
||||
|
||||
export const CodeBlockKey: InjectionKey<CodeBlockContext> = Symbol('CodeBlock')
|
||||
@@ -1,91 +0,0 @@
|
||||
import type {
|
||||
BundledLanguage,
|
||||
BundledTheme,
|
||||
HighlighterGeneric,
|
||||
ThemedToken
|
||||
} from 'shiki'
|
||||
import { createHighlighter } from 'shiki'
|
||||
|
||||
export const isItalic = (fontStyle: number | undefined): boolean =>
|
||||
!!(fontStyle && fontStyle & 1)
|
||||
export const isBold = (fontStyle: number | undefined): boolean =>
|
||||
!!(fontStyle && fontStyle & 2)
|
||||
export const isUnderline = (fontStyle: number | undefined): boolean =>
|
||||
!!(fontStyle && fontStyle & 4)
|
||||
|
||||
export interface TokenizedCode {
|
||||
tokens: ThemedToken[][]
|
||||
fg: string
|
||||
bg: string
|
||||
}
|
||||
|
||||
const THEME: BundledTheme = 'one-dark-pro'
|
||||
|
||||
const highlighterCache = new Map<
|
||||
string,
|
||||
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
|
||||
>()
|
||||
const tokensCache = new Map<string, TokenizedCode>()
|
||||
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>()
|
||||
|
||||
function cacheKey(code: string, language: BundledLanguage): string {
|
||||
const start = code.slice(0, 100)
|
||||
const end = code.length > 100 ? code.slice(-100) : ''
|
||||
return `${language}:${code.length}:${start}:${end}`
|
||||
}
|
||||
|
||||
function getHighlighter(
|
||||
language: BundledLanguage
|
||||
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> {
|
||||
const cached = highlighterCache.get(language)
|
||||
if (cached) return cached
|
||||
|
||||
const promise = createHighlighter({ themes: [THEME], langs: [language] })
|
||||
highlighterCache.set(language, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
export function createRawTokens(code: string): TokenizedCode {
|
||||
return {
|
||||
tokens: code
|
||||
.split('\n')
|
||||
.map((line) =>
|
||||
line === '' ? [] : [{ content: line, color: 'inherit' } as ThemedToken]
|
||||
),
|
||||
fg: 'inherit',
|
||||
bg: 'transparent'
|
||||
}
|
||||
}
|
||||
|
||||
export function highlightCode(
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
callback?: (result: TokenizedCode) => void
|
||||
): TokenizedCode | null {
|
||||
const key = cacheKey(code, language)
|
||||
const cached = tokensCache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
if (callback) {
|
||||
if (!subscribers.has(key)) subscribers.set(key, new Set())
|
||||
subscribers.get(key)!.add(callback)
|
||||
}
|
||||
|
||||
getHighlighter(language)
|
||||
.then((highlighter) => {
|
||||
const loadedLangs = highlighter.getLoadedLanguages()
|
||||
const lang = loadedLangs.includes(language) ? language : 'text'
|
||||
const result = highlighter.codeToTokens(code, { lang, theme: THEME })
|
||||
const tokenized: TokenizedCode = {
|
||||
tokens: result.tokens,
|
||||
fg: result.fg ?? 'inherit',
|
||||
bg: result.bg ?? 'transparent'
|
||||
}
|
||||
tokensCache.set(key, tokenized)
|
||||
subscribers.get(key)?.forEach((sub) => sub(tokenized))
|
||||
subscribers.delete(key)
|
||||
})
|
||||
.catch(() => subscribers.delete(key))
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useMutationObserver } from '@vueuse/core'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { provide, ref, useTemplateRef } from 'vue'
|
||||
|
||||
import { conversationKey } from './context'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const scrollEl = useTemplateRef<HTMLDivElement>('scrollEl')
|
||||
const isAtBottom = ref(true)
|
||||
|
||||
function updateAtBottom() {
|
||||
const el = scrollEl.value
|
||||
if (!el) return
|
||||
isAtBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight < 24
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const el = scrollEl.value
|
||||
if (!el) return
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
useMutationObserver(
|
||||
scrollEl,
|
||||
() => {
|
||||
if (isAtBottom.value) {
|
||||
requestAnimationFrame(scrollToBottom)
|
||||
}
|
||||
},
|
||||
{ childList: true, subtree: true, characterData: true }
|
||||
)
|
||||
|
||||
provide(conversationKey, { isAtBottom, scrollToBottom })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex-1 overflow-hidden">
|
||||
<div
|
||||
ref="scrollEl"
|
||||
:class="cn('scroll-fade absolute inset-0 scrollbar-custom', className)"
|
||||
@scroll="updateAtBottom"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<slot name="overlay" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,14 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col gap-4 p-4', className)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,21 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,46 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
import { useConversation } from './context'
|
||||
|
||||
const { class: className } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isAtBottom, scrollToBottom } = useConversation()
|
||||
const label = t('agent.scrollToBottom')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!isAtBottom"
|
||||
class="pointer-events-none absolute inset-x-0 bottom-2 z-10 flex justify-center"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
size="icon"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-auto rounded-full shadow-md ring-1 ring-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
:aria-label="label"
|
||||
@click="scrollToBottom"
|
||||
>
|
||||
<i class="icon-[lucide--chevron-down] size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{{ label }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import { inject } from 'vue'
|
||||
|
||||
export interface ConversationContext {
|
||||
isAtBottom: Ref<boolean>
|
||||
scrollToBottom: () => void
|
||||
}
|
||||
|
||||
export const conversationKey: InjectionKey<ConversationContext> =
|
||||
Symbol('conversation')
|
||||
|
||||
export function useConversation(): ConversationContext {
|
||||
const context = inject(conversationKey)
|
||||
if (!context) {
|
||||
throw new Error('Conversation parts must be used within <Conversation>')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const { from, class: className } = defineProps<{
|
||||
from: 'user' | 'assistant' | 'system'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group flex w-full gap-2',
|
||||
from === 'user' ? 'is-user ml-auto justify-end' : 'is-assistant',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
|
||||
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
|
||||
|
||||
const { tooltip, pressed = false } = defineProps<{
|
||||
tooltip: string
|
||||
pressed?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ click: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tooltip :delay-duration="500">
|
||||
<TooltipTrigger>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="tooltip"
|
||||
:aria-pressed="pressed"
|
||||
:class="
|
||||
pressed
|
||||
? 'text-base-foreground'
|
||||
: 'text-muted-foreground hover:text-base-foreground'
|
||||
"
|
||||
class="flex cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-1 transition-colors hover:bg-secondary-background-hover"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" class="whitespace-nowrap">{{
|
||||
tooltip
|
||||
}}</TooltipContent>
|
||||
</Tooltip>
|
||||
</template>
|
||||
@@ -1,5 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-end gap-0.5">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,48 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MessageAttachment } from '@/platform/agent/composables/useAgentChatPrototype'
|
||||
|
||||
const { attachments } = defineProps<{
|
||||
attachments: readonly MessageAttachment[]
|
||||
}>()
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div
|
||||
v-for="(attachment, i) in attachments"
|
||||
:key="i"
|
||||
class="flex items-center gap-3 rounded-lg border border-border-default bg-secondary-background p-2"
|
||||
>
|
||||
<div
|
||||
class="size-10 shrink-0 overflow-hidden rounded-md border border-border-default"
|
||||
>
|
||||
<img
|
||||
v-if="attachment.type.startsWith('image/')"
|
||||
:src="attachment.url"
|
||||
:alt="attachment.name"
|
||||
class="size-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex size-full items-center justify-center bg-secondary-background-hover"
|
||||
>
|
||||
<i class="icon-[lucide--file] size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="block truncate text-xs font-medium text-base-foreground">
|
||||
{{ attachment.name }}
|
||||
</span>
|
||||
<span class="block text-xs text-muted-foreground">
|
||||
{{ formatFileSize(attachment.size) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,58 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ToolRunConfirmation } from '@/platform/agent/composables/useAgentChatPrototype'
|
||||
|
||||
const { confirmation } = defineProps<{
|
||||
confirmation: ToolRunConfirmation
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
approve: []
|
||||
reject: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-3 rounded-lg border border-border-default bg-secondary-background px-4 py-3 text-xs"
|
||||
>
|
||||
<template v-if="confirmation.status === 'pending'">
|
||||
<p class="m-0 text-base-foreground">
|
||||
{{ $t('agent.confirmation.prompt') }}
|
||||
</p>
|
||||
<ul class="m-0 list-disc pl-4">
|
||||
<li class="text-base-foreground underline">
|
||||
{{ confirmation.workflowName }}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="m-0 text-muted-foreground">
|
||||
{{ $t('agent.confirmation.question') }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" @click="emit('reject')">
|
||||
{{ $t('agent.confirmation.reject') }}
|
||||
</Button>
|
||||
<Button variant="inverted" size="sm" @click="emit('approve')">
|
||||
{{ $t('agent.confirmation.run') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<i
|
||||
:class="
|
||||
confirmation.status === 'approved'
|
||||
? 'icon-[lucide--check]'
|
||||
: 'icon-[lucide--x]'
|
||||
"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<span>
|
||||
{{
|
||||
confirmation.status === 'approved'
|
||||
? $t('agent.confirmation.approved')
|
||||
: $t('agent.confirmation.rejected')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user